Skip to content

Commit e5ecb36

Browse files
committed
Enhance MockContext a whole bunch
- repeat kwarg - boolean, string result value shorthands - supports tuple values, not just lists - regex result-dict keys - other minor cleanup, fixes
1 parent 1170617 commit e5ecb36

File tree

4 files changed

+281
-43
lines changed

4 files changed

+281
-43
lines changed

invoke/context.py

+95-40
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import os
22
import re
33
from contextlib import contextmanager
4+
from itertools import cycle
45

56
try:
6-
from invoke.vendor.six import raise_from, iteritems
7+
from invoke.vendor.six import raise_from, iteritems, string_types
78
except ImportError:
8-
from six import raise_from, iteritems
9+
from six import raise_from, iteritems, string_types
910

1011
from .config import Config, DataProxy
1112
from .exceptions import Failure, AuthFailure, ResponseNotAccepted
@@ -404,26 +405,55 @@ def __init__(self, config=None, **kwargs):
404405
A Configuration object to use. Identical in behavior to `.Context`.
405406
406407
:param run:
407-
A data structure of `Results <.Result>`, to return from calls to
408-
the instantiated object's `~.Context.run` method (instead of
409-
actually executing the requested shell command).
408+
A data structure indicating what `.Result` objects to return from
409+
calls to the instantiated object's `~.Context.run` method (instead
410+
of actually executing the requested shell command).
410411
411412
Specifically, this kwarg accepts:
412413
413-
- A single `.Result` object, which will be returned once.
414-
- An iterable of `Results <.Result>`, which will be returned on
415-
each subsequent call to ``.run``.
416-
- A map of command strings to either of the above, allowing
417-
specific call-and-response semantics instead of assuming a call
418-
order.
414+
- A single `.Result` object.
415+
416+
- Remember that class's first positional argument is its stdout
417+
- it can thus be handy to hand in expressions like
418+
``Result("command output here!")``.)
419+
420+
- A boolean; if True, yields a `.Result` whose ``exited`` is ``0``,
421+
and if False, ``1``.
422+
- An iterable of the above values, which will be returned on each
423+
subsequent call to ``.run`` (the first item on the first call,
424+
the second on the second call, etc).
425+
- A dict mapping command strings or compiled regexen to the above
426+
values (including an iterable), allowing specific
427+
call-and-response semantics instead of assuming a call order.
419428
420429
:param sudo:
421430
Identical to ``run``, but whose values are yielded from calls to
422431
`~.Context.sudo`.
423432
433+
:param bool repeat:
434+
A flag determining whether results yielded by this class' methods
435+
repeat or are consumed.
436+
437+
For example, when a single result is indicated, it will normally
438+
only be returned once, causing `NotImplementedError` afterwards.
439+
But when ``repeat=True`` is given, that result is returned on
440+
every call, forever.
441+
442+
Similarly, iterable results are normally exhausted once, but when
443+
this setting is enabled, they are wrapped in `itertools.cycle`.
444+
445+
Default: ``False`` (for backwards compatibility reasons).
446+
424447
:raises:
425448
``TypeError``, if the values given to ``run`` or other kwargs
426-
aren't individual `.Result` objects or iterables.
449+
aren't of the expected types.
450+
451+
.. versionchanged:: 1.5
452+
Added support for boolean and string result values.
453+
.. versionchanged:: 1.5
454+
Added support for regex dict keys.
455+
.. versionchanged:: 1.5
456+
Added the ``repeat`` keyword argument.
427457
"""
428458
# Figure out if we can support Mock in the current environment
429459
Mock = None
@@ -434,52 +464,77 @@ def __init__(self, config=None, **kwargs):
434464
from unittest.mock import Mock
435465
except ImportError:
436466
pass
437-
# TODO: would be nice to allow regexen instead of exact string matches
467+
# Set up like any other Context would, with the config
438468
super(MockContext, self).__init__(config)
469+
# Pull out behavioral kwargs
470+
self._set("__repeat", kwargs.pop("repeat", False))
471+
# The rest must be things like run/sudo - mock Context method info
439472
for method, results in iteritems(kwargs):
440-
# Special convenience case: individual Result -> one-item list
441-
if (
442-
not hasattr(results, "__iter__")
443-
and not isinstance(results, Result)
444-
# No need for explicit dict test; they have __iter__
445-
):
473+
# For each possible value type, normalize to iterable of Result
474+
# objects (possibly repeating).
475+
singletons = tuple([Result, bool] + list(string_types))
476+
if isinstance(results, dict):
477+
for key, value in iteritems(results):
478+
results[key] = self._normalize(value)
479+
elif isinstance(results, singletons) or hasattr(results, "__iter__"):
480+
results = self._normalize(results)
481+
# Unknown input value: cry
482+
else:
446483
err = "Not sure how to yield results from a {!r}"
447484
raise TypeError(err.format(type(results)))
448-
# Set the return values
485+
# Save results for use by the method
449486
self._set("__{}".format(method), results)
450487
# Wrap the method in a Mock, if applicable
451488
if Mock is not None:
452489
self._set(method, Mock(wraps=getattr(self, method)))
453490

491+
def _normalize(self, value):
492+
# First turn everything into an iterable
493+
if not hasattr(value, "__iter__") or isinstance(value, string_types):
494+
value = [value]
495+
# Then turn everything within into a Result
496+
results = []
497+
for obj in value:
498+
if isinstance(obj, bool):
499+
obj = Result(exited=0 if obj else 1)
500+
elif isinstance(obj, string_types):
501+
obj = Result(obj)
502+
results.append(obj)
503+
# Finally, turn that iterable into an iteratOR, depending on repeat
504+
return cycle(results) if getattr(self, "__repeat") else iter(results)
505+
454506
# TODO: _maybe_ make this more metaprogrammy/flexible (using __call__ etc)?
455507
# Pretty worried it'd cause more hard-to-debug issues than it's presently
456508
# worth. Maybe in situations where Context grows a _lot_ of methods (e.g.
457509
# in Fabric 2; though Fabric could do its own sub-subclass in that case...)
458510

459511
def _yield_result(self, attname, command):
460-
# NOTE: originally had this with a bunch of explicit
461-
# NotImplementedErrors, but it doubled method size, and chance of
462-
# unexpected index/etc errors seems low here.
463512
try:
464-
# Obtain result if possible
465-
value = getattr(self, attname)
466-
# TODO: thought there's a 'better' 2x3 DictType or w/e, but can't
467-
# find one offhand
468-
if isinstance(value, dict):
469-
if hasattr(value[command], "__iter__"):
470-
result = value[command].pop(0)
471-
elif isinstance(value[command], Result):
472-
result = value.pop(command)
473-
elif hasattr(value, "__iter__"):
474-
result = value.pop(0)
475-
elif isinstance(value, Result):
476-
result = value
477-
delattr(self, attname)
478-
# Populate command string unless explicitly given
513+
obj = getattr(self, attname)
514+
# Dicts need to try direct lookup or regex matching
515+
if isinstance(obj, dict):
516+
try:
517+
obj = obj[command]
518+
except KeyError:
519+
# TODO: could optimize by skipping this if not any regex
520+
# objects in keys()?
521+
for key, value in iteritems(obj):
522+
if hasattr(key, "match") and key.match(command):
523+
obj = value
524+
break
525+
else:
526+
# Nope, nothing did match.
527+
raise KeyError
528+
# Here, the value was either never a dict or has been extracted
529+
# from one, so we can assume it's an iterable of Result objects due
530+
# to work done by __init__.
531+
result = next(obj)
532+
# Populate Result's command string with what matched unless
533+
# explicitly given
479534
if not result.command:
480535
result.command = command
481536
return result
482-
except (AttributeError, IndexError, KeyError):
537+
except (AttributeError, IndexError, KeyError, StopIteration):
483538
raise_from(NotImplementedError, None)
484539

485540
def run(self, command, *args, **kwargs):
@@ -531,4 +586,4 @@ def set_result_for(self, attname, command, result):
531586
if not isinstance(value, dict):
532587
raise heck
533588
# OK, we're good to modify, so do so.
534-
value[command] = result
589+
value[command] = self._normalize(result)

sites/docs/concepts/testing.rst

+109-1
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,120 @@ possible commands, using a dict value::
9797
def test_homebrew_gsed():
9898
expected_sed = "gsed -e s/foo/bar/g file.txt"
9999
c = MockContext(run={
100-
"which gsed": Result(),
100+
"which gsed": Result(exited=0),
101101
expected_sed: Result(),
102102
})
103103
replace(c, 'file.txt', 'foo', 'bar')
104104
c.run.assert_called_with(expected_sed)
105105

106+
Boolean mock results
107+
--------------------
108+
109+
You may have noticed the above example uses a handful of 'empty' `.Result`
110+
objects; these stand in for "succeeded, but otherwise had no useful attributes"
111+
command executions (as `.Result` defaults to an exit code of ``0`` and empty
112+
strings for stdout/stderr).
113+
114+
This is relatively common - think "interrogative" commands where the caller
115+
only cares for a boolean result, or times when a command is called purely for
116+
its side effects. To support this, there's a shorthand in `.MockContext`:
117+
passing ``True`` or ``False`` to stand in for otherwise blank Results with exit
118+
codes of ``0`` or ``1`` respectively.
119+
120+
The example tests then look like this::
121+
122+
from invoke import MockContext, Result
123+
from mytasks import replace
124+
125+
def test_regular_sed():
126+
expected_sed = "sed -e s/foo/bar/g file.txt"
127+
c = MockContext(run={
128+
"which gsed": False,
129+
expected_sed: True,
130+
})
131+
replace(c, 'file.txt', 'foo', 'bar')
132+
c.run.assert_called_with(expected_sed)
133+
134+
def test_homebrew_gsed():
135+
expected_sed = "gsed -e s/foo/bar/g file.txt"
136+
c = MockContext(run={
137+
"which gsed": True,
138+
expected_sed: True,
139+
})
140+
replace(c, 'file.txt', 'foo', 'bar')
141+
c.run.assert_called_with(expected_sed)
142+
143+
String mock results
144+
-------------------
145+
146+
Another convenient shorthand is using string values, which are interpreted to
147+
be the stdout of the resulting `.Result`. This only really saves you from
148+
writing out the class itself (since ``stdout`` is the first positional arg of
149+
`.Result`!) but "command X results in stdout Y" is a common enough use case
150+
that we implemented it anyway.
151+
152+
By example, let's modify an earlier example where we cared about stdout::
153+
154+
from invoke import MockContext
155+
from mytasks import get_platform
156+
157+
def test_get_platform_on_mac():
158+
c = MockContext(run="Darwin\n")
159+
assert "Apple" in get_platform(c)
160+
161+
def test_get_platform_on_linux():
162+
c = MockContext(run="Linux\n")
163+
assert "desktop" in get_platform(c)
164+
165+
As with everything else in this document, this tactic can be applied to
166+
iterators or mappings as well as individual values.
167+
168+
Regular expression command matching
169+
-----------------------------------
170+
171+
The dict form of `.MockContext` kwarg can accept regular expression objects as
172+
keys, in addition to strings; ideal for situations where you either don't know
173+
the exact command being invoked, or simply don't need or want to write out the
174+
entire thing.
175+
176+
Imagine you're writing a function to run package management commands on a few
177+
different Linux distros and you're trying to test its error handling. You might
178+
want to set up a context that pretends any arbitrary ``apt`` or ``yum`` command
179+
fails, and ensure the function returns stderr when it encounters a problem::
180+
181+
import re
182+
from invoke import MockContext
183+
from mypackage.tasks import install
184+
185+
package_manager = re.compile(r"^(apt(-get)?|yum) .*")
186+
187+
def test_package_success_returns_True():
188+
c = MockContext(run={package_manager: True})
189+
assert install(c, package="somepackage") is True
190+
191+
def test_package_explosions_return_stderr():
192+
c = MockContext(run={
193+
package_manager: Result(stderr="oh no!", exited=1),
194+
})
195+
assert install(c, package="otherpackage") == "oh no!"
196+
197+
A bit contrived - there are a bunch of other ways to organize this exact test
198+
code so you don't truly need the regex - but hopefully it's clear that when you
199+
*do* need this flexibility, this is how you could go about it.
200+
201+
Repeated results
202+
----------------
203+
204+
By default, the values in these mock structures are consumed, causing
205+
`.MockContext` to raise ``NotImplementedError`` afterwards (as it does for any
206+
unexpected command executions). This was designed with the assumption that
207+
most code under test will run a given command once.
208+
209+
If your situation doesn't match this, give ``repeat=True`` to the constructor,
210+
and you'll see values repeat indefinitely instead (or in cycles, for
211+
iterables).
212+
213+
106214
Expect `Results <.Result>`
107215
==========================
108216

sites/www/changelog.rst

+20
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22
Changelog
33
=========
44

5+
- :feature:`-` `~invoke.context.MockContext` now accepts a few quality-of-life
6+
shortcuts as keys and values in its ``run``/``sudo`` arguments:
7+
8+
- Keys may be compiled regular expression objects, as well as strings, and
9+
will match any calls whose commands match the regex.
10+
- Values may be ``True`` or ``False`` as shorthand for otherwise empty
11+
`~invoke.runners.Result` objects with exit codes of ``0`` or ``1``
12+
respectively.
13+
- Values may also be strings, as shorthand for otherwise empty
14+
`~invoke.runners.Result` objects with those strings given as the
15+
``stdout`` argument.
16+
17+
- :feature:`441` Add a new ``repeat`` kwarg to `~invoke.context.MockContext`
18+
which, when True (default: False) causes stored results for its methods to be
19+
yielded repeatedly instead of consumed. Feature request courtesy of
20+
``@SwampFalc``.
21+
- :bug:`- major` Immutable iterable result values handed to
22+
`~invoke.context.MockContext` would yield errors (due to the use of
23+
``pop()``). The offending logic has been retooled to be more iterator-focused
24+
and now works for tuples and etc.
525
- :support:`-` Update the `testing documentation </concepts/testing>` a bit:
626
cleaned up existing examples and added new sections for the other updates in
727
the 1.5 release.

0 commit comments

Comments
 (0)