Skip to content

Commit 8e27e57

Browse files
committed
Merge branch 'morosi-feat' into 'master'
Add argument checker and side_effect to CommandResult See merge request it/e3-core!112
2 parents b24255e + 79d21b8 commit 8e27e57

File tree

2 files changed

+142
-8
lines changed

2 files changed

+142
-8
lines changed

src/e3/mock/os/process.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, Protocol
66
from unittest.mock import patch
77
from contextlib import contextmanager
88
import copy
9+
import fnmatch
910

1011
from e3.os.process import Run, to_cmd_lines
1112

@@ -49,6 +50,57 @@ def mock_run(config: MockRunConfig | None = None) -> Iterator[MockRun]:
4950
yield run
5051

5152

53+
class ArgumentChecker(Protocol):
54+
"""Argument checker."""
55+
56+
def check(self, arg: str) -> bool:
57+
"""Check an argument.
58+
59+
:param arg: the argument
60+
:return: if the argument is valid
61+
"""
62+
...
63+
64+
def __repr__(self) -> str:
65+
"""Return a textual representation of the expected argument."""
66+
...
67+
68+
69+
class GlobChecker(ArgumentChecker):
70+
"""Check an argument against a glob."""
71+
72+
def __init__(self, pattern: str) -> None:
73+
"""Initialize GlobChecker.
74+
75+
:param pattern: the glob pattern
76+
"""
77+
self.pattern = pattern
78+
79+
def check(self, arg: str) -> bool:
80+
"""See ArgumentChecker."""
81+
return fnmatch.fnmatch(arg, self.pattern)
82+
83+
def __repr__(self) -> str:
84+
"""See ArgumentChecker."""
85+
return self.pattern.__repr__()
86+
87+
88+
class SideEffect(Protocol):
89+
"""Function to be called when a mocked command is called."""
90+
91+
def __call__(
92+
self, result: CommandResult, cmd: list[str], *args: Any, **kwargs: Any
93+
) -> None:
94+
"""Run when the mocked command is called.
95+
96+
:param result: the mocked command
97+
:param cmd: actual arguments of the command
98+
:param args: additional arguments for Run
99+
:param kwargs: additional keyword arguments for Run
100+
"""
101+
...
102+
103+
52104
class CommandResult:
53105
"""Result of a command.
54106
@@ -58,22 +110,25 @@ class CommandResult:
58110

59111
def __init__(
60112
self,
61-
cmd: list[str],
113+
cmd: list[str | ArgumentChecker],
62114
status: int | None = None,
63115
raw_out: bytes = b"",
64116
raw_err: bytes = b"",
117+
side_effect: SideEffect | None = None,
65118
) -> None:
66119
"""Initialize CommandResult.
67120
68121
:param cmd: expected arguments of the command
69122
:param status: status code
70123
:param raw_out: raw output log
71124
:param raw_err: raw error log
125+
:param side_effect: a function to be called when the command is called
72126
"""
73127
self.cmd = cmd
74128
self.status = status if status is not None else 0
75129
self.raw_out = raw_out
76130
self.raw_err = raw_err
131+
self.side_effect = side_effect
77132

78133
def check(self, cmd: list[str]) -> None:
79134
"""Check that cmd matches the expected arguments.
@@ -86,10 +141,16 @@ def check(self, cmd: list[str]) -> None:
86141
)
87142

88143
for i, arg in enumerate(cmd):
89-
if arg != self.cmd[i] and self.cmd[i] != "*":
90-
raise UnexpectedCommandError(
91-
f"unexpected arguments {cmd}, expected {self.cmd}"
92-
)
144+
checker = self.cmd[i]
145+
if isinstance(checker, str):
146+
if arg == checker or checker == "*":
147+
continue
148+
elif checker.check(arg):
149+
continue
150+
151+
raise UnexpectedCommandError(
152+
f"unexpected arguments {cmd}, expected {self.cmd}"
153+
)
93154

94155
def __call__(self, cmd: list[str], *args: Any, **kwargs: Any) -> None:
95156
"""Allow to run code to emulate the command.
@@ -101,7 +162,8 @@ def __call__(self, cmd: list[str], *args: Any, **kwargs: Any) -> None:
101162
:param args: additional arguments for Run
102163
:param kwargs: additional keyword arguments for Run
103164
"""
104-
pass
165+
if self.side_effect:
166+
self.side_effect(self, cmd, *args, **kwargs)
105167

106168

107169
class MockRun(Run):

tests/tests_e3/mock/os/process/main_test.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
import e3.fs
1111
import e3.os.fs
1212
import e3.os.process
13-
from e3.mock.os.process import mock_run, CommandResult, MockRun, UnexpectedCommandError
13+
from e3.mock.os.process import (
14+
mock_run,
15+
CommandResult,
16+
MockRun,
17+
UnexpectedCommandError,
18+
GlobChecker,
19+
SideEffect,
20+
)
1421

1522
if TYPE_CHECKING:
1623
from typing import Any
@@ -201,3 +208,68 @@ def test_mock_run_nested() -> None:
201208

202209
assert e3.os.process.Run == run1
203210
echo_world()
211+
212+
213+
def test_glob_checker() -> None:
214+
"""Test mock_run with the glob checker."""
215+
with mock_run(
216+
config={
217+
"results": [
218+
CommandResult(
219+
cmd=[
220+
# Any path ending with python
221+
GlobChecker("**/python"),
222+
# Any temp directory containing test.py
223+
GlobChecker("/tmp/**/test.py"),
224+
]
225+
)
226+
]
227+
* 2
228+
}
229+
):
230+
# Both paths match the globs
231+
e3.os.process.Run(["/usr/bin/python", "/tmp/abcd/test.py"])
232+
233+
# Second path starts with /pmt instead of /tmp
234+
with pytest.raises(UnexpectedCommandError) as excinfo:
235+
e3.os.process.Run(["/usr/bin/python", "/pmt/abcd/test.py"])
236+
237+
# Check this is the correct error
238+
assert (
239+
str(excinfo.value)
240+
== "unexpected arguments ['/usr/bin/python', '/pmt/abcd/test.py'], "
241+
"expected ['**/python', '/tmp/**/test.py']"
242+
)
243+
244+
245+
def test_side_effect_function() -> None:
246+
"""Test mock_run with a side effect function."""
247+
248+
def echo(result: CommandResult, cmd: list[str]):
249+
"""Write the value to echo to the raw output."""
250+
result.raw_out = cmd[1].encode()
251+
252+
with mock_run(
253+
config={"results": [CommandResult(cmd=["echo", '"hello"'], side_effect=echo)]}
254+
) as mocked_run:
255+
e3.os.process.Run(["echo", '"hello"'])
256+
257+
# Check the raw output is correct
258+
assert mocked_run.raw_out == b'"hello"'
259+
260+
261+
def test_side_effect_class() -> None:
262+
"""Test mock_run with a side effect class."""
263+
264+
class Echo(SideEffect):
265+
def __call__(self, result: CommandResult, cmd: list[str]) -> None:
266+
"""Write the value to echo to the raw output."""
267+
result.raw_out = cmd[1].encode()
268+
269+
with mock_run(
270+
config={"results": [CommandResult(cmd=["echo", '"hello"'], side_effect=Echo())]}
271+
) as mocked_run:
272+
e3.os.process.Run(["echo", '"hello"'])
273+
274+
# Check the raw output is correct
275+
assert mocked_run.raw_out == b'"hello"'

0 commit comments

Comments
 (0)