fabric / fabric
1
"""
2
This module contains helpers/fixtures to assist in testing Fabric-driven code.
3

4
It is not intended for production use, and pulls in some test-oriented
5
dependencies such as `mock <https://pypi.org/project/mock/>`_. You can install
6
an 'extra' variant of Fabric to get these dependencies if you aren't already
7
using them for your own testing purposes: ``pip install fabric[testing]``.
8

9
.. note::
10
    If you're using pytest for your test suite, you may be interested in
11
    grabbing ``fabric[pytest]`` instead, which encompasses the dependencies of
12
    both this module and the `fabric.testing.fixtures` module, which contains
13
    pytest fixtures.
14

15
.. versionadded:: 2.1
16
"""
17

18 1
from itertools import chain, repeat
19 1
from io import BytesIO
20 1
import os
21

22 1
try:
23 1
    from mock import Mock, PropertyMock, call, patch, ANY
24 0
except ImportError:
25 0
    import warnings
26

27 0
    warning = (
28
        "You appear to be missing some optional test-related dependencies;"
29
        "please 'pip install fabric[testing]'."
30
    )
31 0
    warnings.warn(warning, ImportWarning)
32 0
    raise
33

34

35 1
class Command(object):
36
    """
37
    Data record specifying params of a command execution to mock/expect.
38

39
    :param str cmd:
40
        Command string to expect. If not given, no expectations about the
41
        command executed will be set up. Default: ``None``.
42

43
    :param bytes out: Data yielded as remote stdout. Default: ``b""``.
44

45
    :param bytes err: Data yielded as remote stderr. Default: ``b""``.
46

47
    :param int exit: Remote exit code. Default: ``0``.
48

49
    :param int waits:
50
        Number of calls to the channel's ``exit_status_ready`` that should
51
        return ``False`` before it then returns ``True``. Default: ``0``
52
        (``exit_status_ready`` will return ``True`` immediately).
53

54
    .. versionadded:: 2.1
55
    """
56

57 1
    def __init__(self, cmd=None, out=b"", err=b"", in_=None, exit=0, waits=0):
58 1
        self.cmd = cmd
59 1
        self.out = out
60 1
        self.err = err
61 1
        self.in_ = in_
62 1
        self.exit = exit
63 1
        self.waits = waits
64

65 1
    def __repr__(self):
66
        # TODO: just leverage attrs, maybe vendored into Invoke so we don't
67
        # grow more dependencies? Ehhh
68 0
        return "<{} cmd={!r}>".format(self.__class__.__name__, self.cmd)
69

70

71 1
class MockChannel(Mock):
72
    """
73
    Mock subclass that tracks state for its ``recv(_stderr)?`` methods.
74

75
    Turns out abusing function closures inside MockRemote to track this state
76
    only worked for 1 command per session!
77

78
    .. versionadded:: 2.1
79
    """
80

81 1
    def __init__(self, *args, **kwargs):
82
        # TODO: worth accepting strings and doing the BytesIO setup ourselves?
83
        # Stored privately to avoid any possible collisions ever. shrug.
84 1
        object.__setattr__(self, "__stdout", kwargs.pop("stdout"))
85 1
        object.__setattr__(self, "__stderr", kwargs.pop("stderr"))
86
        # Stdin less private so it can be asserted about
87 1
        object.__setattr__(self, "_stdin", BytesIO())
88 1
        super(MockChannel, self).__init__(*args, **kwargs)
89

90 1
    def _get_child_mock(self, **kwargs):
91
        # Don't return our own class on sub-mocks.
92 1
        return Mock(**kwargs)
93

94 1
    def recv(self, count):
95 1
        return object.__getattribute__(self, "__stdout").read(count)
96

97 1
    def recv_stderr(self, count):
98 1
        return object.__getattribute__(self, "__stderr").read(count)
99

100 1
    def sendall(self, data):
101 0
        return object.__getattribute__(self, "_stdin").write(data)
102

103

104 1
class Session(object):
105
    """
106
    A mock remote session of a single connection and 1 or more command execs.
107

108
    Allows quick configuration of expected remote state, and also helps
109
    generate the necessary test mocks used by `MockRemote` itself. Only useful
110
    when handed into `MockRemote`.
111

112
    The parameters ``cmd``, ``out``, ``err``, ``exit`` and ``waits`` are all
113
    shorthand for the same constructor arguments for a single anonymous
114
    `.Command`; see `.Command` for details.
115

116
    To give fully explicit `.Command` objects, use the ``commands`` parameter.
117

118
    :param str user:
119
    :param str host:
120
    :param int port:
121
        Sets up expectations that a connection will be generated to the given
122
        user, host and/or port. If ``None`` (default), no expectations are
123
        generated / any value is accepted.
124

125
    :param commands:
126
        Iterable of `.Command` objects, used when mocking nontrivial sessions
127
        involving >1 command execution per host. Default: ``None``.
128

129
        .. note::
130
            Giving ``cmd``, ``out`` etc alongside explicit ``commands`` is not
131
            allowed and will result in an error.
132

133
    .. versionadded:: 2.1
134
    """
135

136 1
    def __init__(
137
        self,
138
        host=None,
139
        user=None,
140
        port=None,
141
        commands=None,
142
        cmd=None,
143
        out=None,
144
        in_=None,
145
        err=None,
146
        exit=None,
147
        waits=None,
148
    ):
149
        # Sanity check
150 1
        params = cmd or out or err or exit or waits
151 1
        if commands and params:
152 0
            raise ValueError(
153
                "You can't give both 'commands' and individual "
154
                "Command parameters!"
155
            )  # noqa
156
        # Fill in values
157 1
        self.host = host
158 1
        self.user = user
159 1
        self.port = port
160 1
        self.commands = commands
161 1
        if params:
162
            # Honestly dunno which is dumber, this or duplicating Command's
163
            # default kwarg values in this method's signature...sigh
164 1
            kwargs = {}
165 1
            if cmd is not None:
166 1
                kwargs["cmd"] = cmd
167 1
            if out is not None:
168 1
                kwargs["out"] = out
169 1
            if err is not None:
170 0
                kwargs["err"] = err
171 1
            if in_ is not None:
172 0
                kwargs["in_"] = in_
173 1
            if exit is not None:
174 0
                kwargs["exit"] = exit
175 1
            if waits is not None:
176 0
                kwargs["waits"] = waits
177 1
            self.commands = [Command(**kwargs)]
178 1
        if not self.commands:
179 1
            self.commands = [Command()]
180

181 1
    def generate_mocks(self):
182
        """
183
        Mocks `~paramiko.client.SSHClient` and `~paramiko.channel.Channel`.
184

185
        Specifically, the client will expect itself to be connected to
186
        ``self.host`` (if given), the channels will be associated with the
187
        client's `~paramiko.transport.Transport`, and the channels will
188
        expect/provide command-execution behavior as specified on the
189
        `.Command` objects supplied to this `.Session`.
190

191
        The client is then attached as ``self.client`` and the channels as
192
        ``self.channels``.
193

194
        :returns:
195
            ``None`` - this is mostly a "deferred setup" method and callers
196
            will just reference the above attributes (and call more methods) as
197
            needed.
198

199
        .. versionadded:: 2.1
200
        """
201 1
        client = Mock()
202 1
        transport = client.get_transport.return_value  # another Mock
203

204
        # NOTE: this originally did chain([False], repeat(True)) so that
205
        # get_transport().active was False initially, then True. However,
206
        # because we also have to consider when get_transport() comes back None
207
        # (which it does initially), the case where we get back a non-None
208
        # transport _and_ it's not active yet, isn't useful to test, and
209
        # complicates text expectations. So we don't, for now.
210 1
        actives = repeat(True)
211
        # NOTE: setting PropertyMocks on a mock's type() is apparently
212
        # How It Must Be Done, otherwise it sets the real attr value.
213 1
        type(transport).active = PropertyMock(side_effect=actives)
214

215 1
        channels = []
216 1
        for command in self.commands:
217
            # Mock of a Channel instance, not e.g. Channel-the-class.
218
            # Specifically, one that can track individual state for recv*().
219 1
            channel = MockChannel(
220
                stdout=BytesIO(command.out), stderr=BytesIO(command.err)
221
            )
222 1
            channel.recv_exit_status.return_value = command.exit
223

224
            # If requested, make exit_status_ready return False the first N
225
            # times it is called in the wait() loop.
226 1
            readies = chain(repeat(False, command.waits), repeat(True))
227 1
            channel.exit_status_ready.side_effect = readies
228

229 1
            channels.append(channel)
230

231
        # Have our transport yield those channel mocks in order when
232
        # open_session() is called.
233 1
        transport.open_session.side_effect = channels
234

235 1
        self.client = client
236 1
        self.channels = channels
237

238 1
    def sanity_check(self):
239
        # Per-session we expect a single transport get
240 1
        transport = self.client.get_transport
241 1
        transport.assert_called_once_with()
242
        # And a single connect to our target host.
243 1
        self.client.connect.assert_called_once_with(
244
            username=self.user or ANY,
245
            hostname=self.host or ANY,
246
            port=self.port or ANY,
247
        )
248

249
        # Calls to open_session will be 1-per-command but are on transport, not
250
        # channel, so we can only really inspect how many happened in
251
        # aggregate. Save a list for later comparison to call_args.
252 1
        session_opens = []
253

254 1
        for channel, command in zip(self.channels, self.commands):
255
            # Expect an open_session for each command exec
256 1
            session_opens.append(call())
257
            # Expect that the channel gets an exec_command
258 1
            channel.exec_command.assert_called_with(command.cmd or ANY)
259
            # Expect written stdin, if given
260 1
            if command.in_:
261 0
                assert channel._stdin.getvalue() == command.in_
262

263
        # Make sure open_session was called expected number of times.
264 1
        calls = transport.return_value.open_session.call_args_list
265 1
        assert calls == session_opens
266

267

268 1
class MockRemote(object):
269
    """
270
    Class representing mocked remote state.
271

272
    By default this class is set up for start/stop style patching as opposed to
273
    the more common context-manager or decorator approach; this is so it can be
274
    used in situations requiring setup/teardown semantics.
275

276
    Defaults to setting up a single anonymous `Session`, so it can be used as a
277
    "request & forget" pytest fixture. Users requiring detailed remote session
278
    expectations can call methods like `expect`, which wipe that anonymous
279
    Session & set up a new one instead.
280

281
    .. versionadded:: 2.1
282
    """
283

284 1
    def __init__(self):
285 1
        self.expect_sessions(Session())
286

287
    # TODO: make it easier to assume single session w/ >1 command?
288

289 1
    def expect(self, *args, **kwargs):
290
        """
291
        Convenience method for creating & 'expect'ing a single `Session`.
292

293
        Returns the single `MockChannel` yielded by that Session.
294

295
        .. versionadded:: 2.1
296
        """
297 1
        return self.expect_sessions(Session(*args, **kwargs))[0]
298

299 1
    def expect_sessions(self, *sessions):
300
        """
301
        Sets the mocked remote environment to expect the given ``sessions``.
302

303
        Returns a list of `MockChannel` objects, one per input `Session`.
304

305
        .. versionadded:: 2.1
306
        """
307
        # First, stop the default session to clean up its state, if it seems to
308
        # be running.
309 1
        self.stop()
310
        # Update sessions list with new session(s)
311 1
        self.sessions = sessions
312
        # And start patching again, returning mocked channels
313 1
        return self.start()
314

315 1
    def start(self):
316
        """
317
        Start patching SSHClient with the stored sessions, returning channels.
318

319
        .. versionadded:: 2.1
320
        """
321
        # Patch SSHClient so the sessions' generated mocks can be set as its
322
        # return values
323 1
        self.patcher = patcher = patch("fabric.connection.SSHClient")
324 1
        SSHClient = patcher.start()
325
        # Mock clients, to be inspected afterwards during sanity-checks
326 1
        clients = []
327 1
        for session in self.sessions:
328 1
            session.generate_mocks()
329 1
            clients.append(session.client)
330
        # Each time the mocked SSHClient class is instantiated, it will
331
        # yield one of our mocked clients (w/ mocked transport & channel)
332
        # generated above.
333 1
        SSHClient.side_effect = clients
334 1
        return list(chain.from_iterable(x.channels for x in self.sessions))
335

336 1
    def stop(self):
337
        """
338
        Stop patching SSHClient.
339

340
        .. versionadded:: 2.1
341
        """
342
        # Short circuit if we don't seem to have start()ed yet.
343 1
        if not hasattr(self, "patcher"):
344 1
            return
345
        # Stop patching SSHClient
346 1
        self.patcher.stop()
347

348 1
    def sanity(self):
349
        """
350
        Run post-execution sanity checks (usually 'was X called' tests.)
351

352
        .. versionadded:: 2.1
353
        """
354 1
        for session in self.sessions:
355
            # Basic sanity tests about transport, channel etc
356 1
            session.sanity_check()
357

358

359
# TODO: unify with the stuff in paramiko itself (now in its tests/conftest.py),
360
# they're quite distinct and really shouldn't be.
361 1
class MockSFTP(object):
362
    """
363
    Class managing mocked SFTP remote state.
364

365
    Used in start/stop fashion in eg doctests; wrapped in the SFTP fixtures in
366
    conftest.py for main use.
367

368
    .. versionadded:: 2.1
369
    """
370

371 1
    def __init__(self, autostart=True):
372 1
        if autostart:
373 0
            self.start()
374

375 1
    def start(self):
376
        # Set up mocks
377 1
        self.os_patcher = patch("fabric.transfer.os")
378 1
        self.client_patcher = patch("fabric.connection.SSHClient")
379 1
        mock_os = self.os_patcher.start()
380 1
        Client = self.client_patcher.start()
381 1
        sftp = Client.return_value.open_sftp.return_value
382

383
        # Handle common filepath massage actions; tests will assume these.
384 1
        def fake_abspath(path):
385 1
            return "/local/{}".format(path)
386

387 1
        mock_os.path.abspath.side_effect = fake_abspath
388 1
        sftp.getcwd.return_value = "/remote"
389
        # Ensure stat st_mode is a real number; Python 2 stat.S_IMODE doesn't
390
        # appear to care if it's handed a MagicMock, but Python 3's does (?!)
391 1
        fake_mode = 0o644  # arbitrary real-ish mode
392 1
        sftp.stat.return_value.st_mode = fake_mode
393 1
        mock_os.stat.return_value.st_mode = fake_mode
394
        # Not super clear to me why the 'wraps' functionality in mock isn't
395
        # working for this :(
396 1
        mock_os.path.basename.side_effect = os.path.basename
397
        # Return the sftp and OS mocks for use by decorator use case.
398 1
        return sftp, mock_os
399

400 1
    def stop(self):
401 0
        self.os_patcher.stop()
402 0
        self.client_patcher.stop()

Read our documentation on viewing source code .

Loading