Skip to content

Commit 17e154f

Browse files
committed
Improves error messages on RunnerResults for various scenarios.
Fixes handling of `async_` keyword parameter.
1 parent 7da0e90 commit 17e154f

File tree

7 files changed

+88
-29
lines changed

7 files changed

+88
-29
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
Changelog
22
---------
33

4+
- Improves error messages for various scenarios on `RunnerResults`
5+
[Daverball]
6+
47
- Only sets `ansible_connection` to `local` when `ansible_port`
58
is `22`, since anything else is likely a SSH tunnel
69
[Daverball]

docs/index.rst

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Connect to a server using a username and a password::
7171
remote_pass=password
7272
)
7373

74-
print api.command('whoami').stdout() # prints 'admin'
74+
print(api.command('whoami').stdout()) # prints 'admin'
7575

7676
Run a command on multiple servers and get the output for each::
7777

@@ -81,7 +81,20 @@ Run a command on multiple servers and get the output for each::
8181
result = api.command('whoami')
8282

8383
for server in servers:
84-
print result.stdout(server)
84+
print(result.stdout(server))
85+
86+
Or alternatively::
87+
88+
api = Api(['a.example.org', 'b.example.org'])
89+
results = api.command('whoami')
90+
91+
for server, result in results['contacted'].items():
92+
if 'stdout' in result:
93+
print(server, result['stdout'])
94+
95+
The latter is more robust for optional result components, since not
96+
every server's result may contain it.
97+
8598

8699
Which Modules are Available?
87100
----------------------------

scripts/generate_module_hints.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,12 @@ def write_return_type(returns: dict[str, Any] | None) -> None:
389389
return_type += '[Incomplete]'
390390
elif return_type == 'dict':
391391
return_type += '[str, Incomplete]'
392+
elif return_type == 'complex':
393+
# TODO: This seems to be more or less an alias to dict
394+
# but it contains a schema for the contents. If it
395+
# is always dict, then try to merge this with dict
396+
# and generate a TypedDict using `contains`.
397+
return_type = 'Incomplete'
392398
suffix = ' # type:ignore[override]' if name == 'values' else ''
393399
if len(name) + len(return_type) + len(suffix) > 33:
394400
# signature doesn't fit on one line

src/suitable/_module_types.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,7 +1467,7 @@ class PackageFactsResults(RunnerResults):
14671467
14681468
"""
14691469

1470-
def ansible_facts(self, server: str | None = None) -> complex:
1470+
def ansible_facts(self, server: str | None = None) -> Incomplete:
14711471
"""
14721472
Facts to add to ansible_facts.
14731473
@@ -1763,7 +1763,7 @@ class ServiceFactsResults(RunnerResults):
17631763
17641764
"""
17651765

1766-
def ansible_facts(self, server: str | None = None) -> complex:
1766+
def ansible_facts(self, server: str | None = None) -> Incomplete:
17671767
"""
17681768
Facts to add to ansible_facts about the services on the system.
17691769
@@ -2072,7 +2072,7 @@ class SysvinitResults(RunnerResults):
20722072
20732073
"""
20742074

2075-
def results(self, server: str | None = None) -> complex:
2075+
def results(self, server: str | None = None) -> Incomplete:
20762076
"""
20772077
results from actions taken.
20782078
@@ -3126,7 +3126,7 @@ def stdout_lines(self, server: str | None = None) -> list[Incomplete]:
31263126
"""
31273127
return self.acquire(server, 'stdout_lines')
31283128

3129-
def output(self, server: str | None = None) -> complex:
3129+
def output(self, server: str | None = None) -> Incomplete:
31303130
"""
31313131
Based on the value of display option will return either the set of
31323132
transformed XML to JSON format from the RPC response with type dict or
@@ -3177,7 +3177,7 @@ def stdout_lines(self, server: str | None = None) -> list[Incomplete]:
31773177
"""
31783178
return self.acquire(server, 'stdout_lines')
31793179

3180-
def output(self, server: str | None = None) -> complex:
3180+
def output(self, server: str | None = None) -> Incomplete:
31813181
"""
31823182
Based on the value of display option will return either the set of
31833183
transformed XML to JSON format from the RPC response with type dict or
@@ -3528,7 +3528,7 @@ def undefined_zones(self, server: str | None = None) -> list[Incomplete]:
35283528
"""
35293529
return self.acquire(server, 'undefined_zones')
35303530

3531-
def firewalld_info(self, server: str | None = None) -> complex:
3531+
def firewalld_info(self, server: str | None = None) -> Incomplete:
35323532
"""
35333533
Returns various information about firewalld configuration.
35343534
@@ -3590,7 +3590,7 @@ class RhelFactsResults(RunnerResults):
35903590
35913591
"""
35923592

3593-
def ansible_facts(self, server: str | None = None) -> complex:
3593+
def ansible_facts(self, server: str | None = None) -> Incomplete:
35943594
"""
35953595
Relevant Ansible Facts.
35963596
@@ -4340,7 +4340,7 @@ def exitcode(self, server: str | None = None) -> str:
43404340
"""
43414341
return self.acquire(server, 'exitcode')
43424342

4343-
def feature_result(self, server: str | None = None) -> complex:
4343+
def feature_result(self, server: str | None = None) -> Incomplete:
43444344
"""
43454345
List of features that were installed or removed.
43464346
@@ -4411,7 +4411,7 @@ def matched(self, server: str | None = None) -> int:
44114411
"""
44124412
return self.acquire(server, 'matched')
44134413

4414-
def files(self, server: str | None = None) -> complex:
4414+
def files(self, server: str | None = None) -> Incomplete:
44154415
"""
44164416
Information on the files/folders that match the criteria returned as a
44174417
list of dictionary elements for each file matched. The entries are
@@ -4764,7 +4764,7 @@ class WinPowershellResults(RunnerResults):
47644764
47654765
"""
47664766

4767-
def result(self, server: str | None = None) -> complex:
4767+
def result(self, server: str | None = None) -> Incomplete:
47684768
"""
47694769
The values that were set by `$Ansible.Result` in the script.
47704770
@@ -5276,7 +5276,7 @@ def changed(self, server: str | None = None) -> bool:
52765276
"""
52775277
return self.acquire(server, 'changed')
52785278

5279-
def stat(self, server: str | None = None) -> complex:
5279+
def stat(self, server: str | None = None) -> Incomplete:
52805280
"""
52815281
dictionary containing all the stat data.
52825282
@@ -5716,7 +5716,7 @@ def privileges(self, server: str | None = None) -> dict[str, Incomplete]:
57165716
"""
57175717
return self.acquire(server, 'privileges')
57185718

5719-
def label(self, server: str | None = None) -> complex:
5719+
def label(self, server: str | None = None) -> Incomplete:
57205720
"""
57215721
The mandatory label set to the logon session.
57225722
@@ -5750,7 +5750,7 @@ def groups(self, server: str | None = None) -> list[Incomplete]:
57505750
"""
57515751
return self.acquire(server, 'groups')
57525752

5753-
def account(self, server: str | None = None) -> complex:
5753+
def account(self, server: str | None = None) -> Incomplete:
57545754
"""
57555755
The running account SID details.
57565756

src/suitable/module_runner.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def get_module_args(
172172
args_str = ' '.join(args).replace('=', '\\=')
173173

174174
kwargs_str = ' '.join(
175-
'{}="{}"'.format(k.rstrip('_'), v.replace('"', '\\"'))
175+
'{}="{}"'.format(k, v.replace('"', '\\"'))
176176
for k, v in kwargs.items()
177177
)
178178

@@ -189,6 +189,13 @@ def execute(self, *args: Any, **kwargs: Any) -> RunnerResults:
189189
if set_global_context:
190190
set_global_context(self.api.options)
191191

192+
# translate parameters that use a reserved keyword
193+
# TODO: For now async is the only one we know about
194+
# but there may be other ones
195+
if 'async_' in kwargs:
196+
# with conflicts prefer the real name
197+
kwargs.setdefault('async', kwargs.pop('async_'))
198+
192199
# legacy key=value pairs shorthand approach
193200
module_args: dict[str, Any] | str
194201
if args:
@@ -392,4 +399,4 @@ def evaluate_results(
392399
server: result
393400
for server, result in callback.unreachable.items()
394401
}
395-
})
402+
}, dry_run=self.api.options.check)

src/suitable/runner_results.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,39 @@ class RunnerResults(_Base):
3434
3535
"""
3636

37-
def __init__(self, results: _RunnerResults) -> None:
37+
def __init__(self, results: _RunnerResults, dry_run: bool = False) -> None:
38+
self.dry_run = dry_run
3839
self.update(results) # type:ignore[arg-type]
3940

4041
def __getattr__(self, key: str) -> ResultsCallback:
4142
return lambda server=None: self.acquire(server, key)
4243

4344
def acquire(self, server: str | None, key: str) -> Any:
45+
contacted = self['contacted']
4446

4547
# if no server is given and exactly one contacted server exists
4648
# return the value of said server directly
47-
if server is None and len(self['contacted']) == 1:
48-
server = next((k for k in self['contacted'].keys()), None)
49-
50-
if server not in self['contacted']:
49+
if server is None:
50+
if len(contacted) == 1:
51+
server = next((k for k in contacted.keys()), None)
52+
elif contacted:
53+
raise ValueError(
54+
"When contacting multiple servers you need to "
55+
"specify which server's result you want"
56+
)
57+
elif self.dry_run:
58+
raise ValueError('Results are not available in dry run')
59+
elif (unreachable := self['unreachable']):
60+
raise ValueError(
61+
f"{', '.join(unreachable)} could not be contacted"
62+
)
63+
64+
if server not in contacted:
65+
if self.dry_run:
66+
raise ValueError('Results are not available in dry run')
5167
raise KeyError(f"{server} could not be contacted")
5268

53-
if key not in self['contacted'][server]:
54-
raise AttributeError
69+
if key not in (result := contacted[server]):
70+
raise AttributeError(key)
5571

56-
return self['contacted'][server][key]
72+
return result[key]

tests/test_api.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ def test_results():
8181
result.rc('localhost')
8282

8383

84+
def test_results_dry_run():
85+
result = Api('localhost', dry_run=True).command('whoami')
86+
assert not result['contacted']
87+
with pytest.raises(ValueError, match=r'not available in dry run'):
88+
result.rc()
89+
90+
with pytest.raises(ValueError, match=r'not available in dry run'):
91+
result.rc('localhost')
92+
93+
8494
@pytest.mark.parametrize("server", ('localhost',))
8595
def test_results_single_server(server):
8696
result = Api(server).command('whoami')
@@ -92,15 +102,17 @@ def test_results_multiple_servers():
92102
result = RunnerResults({
93103
'contacted': {
94104
'web.seantis.dev': {'rc': 0},
95-
'db.seantis.dev': {'rc': 1}
105+
'db.seantis.dev': {'rc': 1},
106+
'buggy.result.dev': {},
96107
}
97108
})
98109

99-
with pytest.raises(KeyError):
100-
result.rc()
101-
102110
assert result.rc('web.seantis.dev') == 0
103111
assert result.rc('db.seantis.dev') == 1
112+
with pytest.raises(AttributeError, match=r'rc'):
113+
result.rc('buggy.result.dev')
114+
with pytest.raises(ValueError, match=r'When contacting multiple'):
115+
result.rc()
104116

105117

106118
@pytest.mark.parametrize("server", (('localhost', 'localhost:22'),))
@@ -109,6 +121,8 @@ def test_whoami_multiple_servers(server):
109121
results = host.command('whoami')
110122
assert results.rc(server[0]) == 0
111123
assert results.rc(server[1]) == 0
124+
with pytest.raises(ValueError, match=r'When contacting multiple'):
125+
results.rc()
112126

113127

114128
def test_non_scalar_parameter():

0 commit comments

Comments
 (0)