mirror of
https://github.com/emilybache/GildedRose-Refactoring-Kata.git
synced 2026-02-08 11:11:24 +00:00
306 lines
11 KiB
Python
306 lines
11 KiB
Python
import shlex
|
|
from functools import wraps, partial, reduce
|
|
from io import TextIOWrapper
|
|
from itertools import chain, zip_longest
|
|
from os import PathLike
|
|
from subprocess import STDOUT, PIPE
|
|
from tempfile import TemporaryFile
|
|
from testfixtures.utils import extend_docstring
|
|
from typing import Union, Callable, List, Optional, Sequence, Tuple, Dict, Iterable
|
|
from .mock import Mock, call, _Call as Call
|
|
|
|
|
|
AnyStr = Union[str, bytes]
|
|
Command = Union[str, bytes, PathLike, Sequence[str], Sequence[bytes]]
|
|
|
|
|
|
def shell_join(command: Command) -> str:
|
|
if isinstance(command, str):
|
|
return command
|
|
elif isinstance(command, bytes):
|
|
return command.decode()
|
|
elif isinstance(command, PathLike):
|
|
return str(command)
|
|
elif isinstance(command, Iterable):
|
|
quoted_parts = []
|
|
for part in command:
|
|
if isinstance(part, str):
|
|
pass
|
|
elif isinstance(part, bytes):
|
|
part = part.decode()
|
|
elif isinstance(part, PathLike):
|
|
part = str(part)
|
|
elif not isinstance(part, (str, bytes)):
|
|
raise TypeError(f'{part!r} in {command} was {type(part)}, must be str')
|
|
quoted_parts.append(shlex.quote(part))
|
|
return " ".join(quoted_parts)
|
|
else:
|
|
raise TypeError(f'{command!r} was {type(command)}, must be str')
|
|
|
|
|
|
class PopenBehaviour:
|
|
"""
|
|
An object representing the behaviour of a :class:`MockPopen` when
|
|
simulating a particular command.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
stdout: bytes = b'',
|
|
stderr: bytes = b'',
|
|
returncode: int = 0,
|
|
pid: int = 1234,
|
|
poll_count: int = 3
|
|
):
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.returncode = returncode
|
|
self.pid = pid
|
|
self.poll_count = poll_count
|
|
|
|
|
|
def record(func) -> Callable:
|
|
@wraps(func)
|
|
def recorder(self, *args, **kw):
|
|
self._record((func.__name__,), *args, **kw)
|
|
return func(self, *args, **kw)
|
|
return recorder
|
|
|
|
|
|
class MockPopenInstance:
|
|
"""
|
|
A mock process as returned by :class:`MockPopen`.
|
|
"""
|
|
|
|
#: A :class:`~unittest.mock.Mock` representing the pipe into this process.
|
|
#: This is only set if ``stdin=PIPE`` is passed the constructor.
|
|
#: The mock records writes and closes in :attr:`MockPopen.all_calls`.
|
|
stdin: Mock = None
|
|
|
|
#: A file representing standard output from this process.
|
|
stdout: TemporaryFile = None
|
|
|
|
#: A file representing error output from this process.
|
|
stderr: TemporaryFile = None
|
|
|
|
# These are not types as instantiation of this class is an internal implementation detail.
|
|
def __init__(self, mock_class, root_call,
|
|
args, bufsize=0, executable=None,
|
|
stdin=None, stdout=None, stderr=None,
|
|
preexec_fn=None, close_fds=False, shell=False, cwd=None,
|
|
env=None, universal_newlines=False,
|
|
startupinfo=None, creationflags=0, restore_signals=True,
|
|
start_new_session=False, pass_fds=(),
|
|
encoding=None, errors=None, text=None):
|
|
self.mock: Mock = Mock()
|
|
self.class_instance_mock: Mock = mock_class.mock.Popen_instance
|
|
#: A :func:`unittest.mock.call` representing the call made to instantiate
|
|
#: this mock process.
|
|
self.root_call: Call = root_call
|
|
#: The calls made on this mock process, represented using
|
|
#: :func:`~unittest.mock.call` instances.
|
|
self.calls: List[Call] = []
|
|
self.all_calls: List[Call] = mock_class.all_calls
|
|
|
|
cmd = shell_join(args)
|
|
|
|
behaviour = mock_class.commands.get(cmd, mock_class.default_behaviour)
|
|
if behaviour is None:
|
|
raise KeyError('Nothing specified for command %r' % cmd)
|
|
|
|
if callable(behaviour):
|
|
behaviour = behaviour(command=cmd, stdin=stdin)
|
|
|
|
self.behaviour: PopenBehaviour = behaviour
|
|
|
|
stdout_value = behaviour.stdout
|
|
stderr_value = behaviour.stderr
|
|
|
|
if stderr == STDOUT:
|
|
line_iterator = chain.from_iterable(zip_longest(
|
|
stdout_value.splitlines(True),
|
|
stderr_value.splitlines(True)
|
|
))
|
|
stdout_value = b''.join(l for l in line_iterator if l)
|
|
stderr_value = None
|
|
|
|
self.poll_count: int = behaviour.poll_count
|
|
for name, option, mock_value in (
|
|
('stdout', stdout, stdout_value),
|
|
('stderr', stderr, stderr_value)
|
|
):
|
|
value = None
|
|
if option is PIPE:
|
|
value = TemporaryFile()
|
|
value.write(mock_value)
|
|
value.flush()
|
|
value.seek(0)
|
|
if universal_newlines or text or encoding:
|
|
value = TextIOWrapper(value, encoding=encoding, errors=errors)
|
|
setattr(self, name, value)
|
|
|
|
if stdin == PIPE:
|
|
self.stdin = Mock()
|
|
for method in 'write', 'close':
|
|
record_writes = partial(self._record, ('stdin', method))
|
|
getattr(self.stdin, method).side_effect = record_writes
|
|
|
|
self.pid: int = behaviour.pid
|
|
#: The return code of this mock process.
|
|
self.returncode: Optional[int] = None
|
|
self.args: Command = args
|
|
|
|
def _record(self, names, *args, **kw):
|
|
for mock in self.class_instance_mock, self.mock:
|
|
reduce(getattr, names, mock)(*args, **kw)
|
|
for base_call, store in (
|
|
(call, self.calls),
|
|
(self.root_call, self.all_calls)
|
|
):
|
|
store.append(reduce(getattr, names, base_call)(*args, **kw))
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.wait()
|
|
for stream in self.stdout, self.stderr:
|
|
if stream:
|
|
stream.close()
|
|
|
|
@record
|
|
def wait(self, timeout: float = None) -> int:
|
|
"Simulate calls to :meth:`subprocess.Popen.wait`"
|
|
self.returncode = self.behaviour.returncode
|
|
return self.returncode
|
|
|
|
@record
|
|
def communicate(self, input: AnyStr = None, timeout: float = None) -> Tuple[AnyStr, AnyStr]:
|
|
"Simulate calls to :meth:`subprocess.Popen.communicate`"
|
|
self.returncode = self.behaviour.returncode
|
|
return (self.stdout and self.stdout.read(),
|
|
self.stderr and self.stderr.read())
|
|
|
|
@record
|
|
def poll(self) -> Optional[int]:
|
|
"Simulate calls to :meth:`subprocess.Popen.poll`"
|
|
while self.poll_count and self.returncode is None:
|
|
self.poll_count -= 1
|
|
return None
|
|
# This call to wait() is NOT how poll() behaves in reality.
|
|
# poll() NEVER sets the returncode.
|
|
# The returncode is *only* ever set by process completion.
|
|
# The following is an artifact of the fixture's implementation.
|
|
self.returncode = self.behaviour.returncode
|
|
return self.returncode
|
|
|
|
@record
|
|
def send_signal(self, signal: int) -> None:
|
|
"Simulate calls to :meth:`subprocess.Popen.send_signal`"
|
|
pass
|
|
|
|
@record
|
|
def terminate(self) -> None:
|
|
"Simulate calls to :meth:`subprocess.Popen.terminate`"
|
|
pass
|
|
|
|
@record
|
|
def kill(self) -> None:
|
|
"Simulate calls to :meth:`subprocess.Popen.kill`"
|
|
pass
|
|
|
|
|
|
class MockPopen:
|
|
"""
|
|
A specialised mock for testing use of :class:`subprocess.Popen`.
|
|
An instance of this class can be used in place of the
|
|
:class:`subprocess.Popen` and is often inserted where it's needed using
|
|
:func:`unittest.mock.patch` or a :class:`~testfixtures.Replacer`.
|
|
"""
|
|
|
|
default_behaviour: PopenBehaviour = None
|
|
|
|
def __init__(self):
|
|
self.commands: Dict[str, PopenBehaviour] = {}
|
|
self.mock: Mock = Mock()
|
|
#: All calls made using this mock and the objects it returns, represented using
|
|
#: :func:`~unittest.mock.call` instances.
|
|
self.all_calls: List[Call] = []
|
|
|
|
def _resolve_behaviour(self, stdout, stderr, returncode,
|
|
pid, poll_count, behaviour):
|
|
if behaviour is None:
|
|
return PopenBehaviour(
|
|
stdout, stderr, returncode, pid, poll_count
|
|
)
|
|
else:
|
|
return behaviour
|
|
|
|
def set_command(
|
|
self,
|
|
command: str,
|
|
stdout: bytes = b'',
|
|
stderr: bytes = b'',
|
|
returncode: int = 0,
|
|
pid: int = 1234,
|
|
poll_count: int = 3,
|
|
behaviour: Union[PopenBehaviour, Callable] = None
|
|
):
|
|
"""
|
|
Set the behaviour of this mock when it is used to simulate the
|
|
specified command.
|
|
|
|
:param command: A :class:`str` representing the command to be simulated.
|
|
"""
|
|
self.commands[shell_join(command)] = self._resolve_behaviour(
|
|
stdout, stderr, returncode, pid, poll_count, behaviour
|
|
)
|
|
|
|
def set_default(self, stdout=b'', stderr=b'', returncode=0,
|
|
pid=1234, poll_count=3, behaviour=None):
|
|
"""
|
|
Set the behaviour of this mock when it is used to simulate commands
|
|
that have no explicit behavior specified using
|
|
:meth:`~MockPopen.set_command`.
|
|
"""
|
|
self.default_behaviour = self._resolve_behaviour(
|
|
stdout, stderr, returncode, pid, poll_count, behaviour
|
|
)
|
|
|
|
def __call__(self, *args, **kw):
|
|
self.mock.Popen(*args, **kw)
|
|
root_call = call.Popen(*args, **kw)
|
|
self.all_calls.append(root_call)
|
|
return MockPopenInstance(self, root_call, *args, **kw)
|
|
|
|
|
|
set_command_params = """
|
|
:param stdout:
|
|
:class:`bytes` representing the simulated content written by the process
|
|
to the stdout pipe.
|
|
:param stderr:
|
|
:class:`bytes` representing the simulated content written by the process
|
|
to the stderr pipe.
|
|
:param returncode:
|
|
An integer representing the return code of the simulated process.
|
|
:param pid:
|
|
An integer representing the process identifier of the simulated
|
|
process. This is useful if you have code the prints out the pids
|
|
of running processes.
|
|
:param poll_count:
|
|
Specifies the number of times :meth:`~MockPopenInstance.poll` can be
|
|
called before :attr:`~MockPopenInstance.returncode` is set and returned
|
|
by :meth:`~MockPopenInstance.poll`.
|
|
|
|
If supplied, ``behaviour`` must be either a :class:`PopenBehaviour`
|
|
instance or a callable that takes the ``command`` string representing
|
|
the command to be simulated and the ``stdin`` supplied when instantiating
|
|
the :class:`subprocess.Popen` with that command and should
|
|
return a :class:`PopenBehaviour` instance.
|
|
"""
|
|
|
|
|
|
# add the param docs, so we only have one copy of them!
|
|
extend_docstring(set_command_params,
|
|
[MockPopen.set_command, MockPopen.set_default])
|