Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor!: rename Container.exec_mocks to Container.execs and extend mocking #124

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
73eba4f
Rename and extend container execs.
tonyandrewmeyer Apr 18, 2024
63b62c8
wait() doesn't have the stdout/stderr in the exception (it's not avai…
tonyandrewmeyer Apr 22, 2024
830c732
Add basic StoredState consistency checks.
tonyandrewmeyer Apr 1, 2024
e84a1c3
Update scenario/consistency_checker.py
tonyandrewmeyer May 24, 2024
e827b92
Update scenario/mocking.py
tonyandrewmeyer May 28, 2024
01ae7ac
Make container.execs a frozenset.
tonyandrewmeyer May 31, 2024
9077a53
Mention the exec command prefix matching.
tonyandrewmeyer May 31, 2024
d39cfd8
Undo auto-reformat.
tonyandrewmeyer May 31, 2024
2d0734a
Ensure that there aren't two identical command prefixes.
tonyandrewmeyer May 31, 2024
514c37f
Enable coverage report.
tonyandrewmeyer May 31, 2024
dffea4d
Add a test for duplicate exec detection.
tonyandrewmeyer May 31, 2024
53f2296
Add addition tests for container.get_exec.
tonyandrewmeyer Jun 4, 2024
f1fe0d1
Small adjustments per review.
tonyandrewmeyer Jun 5, 2024
e86d703
Small clean-up.
tonyandrewmeyer Jun 6, 2024
5e9ab73
Rename State.service_status to State.service_statuses, since there ar…
tonyandrewmeyer Jun 7, 2024
fb75c86
Fix broken files.
tonyandrewmeyer May 30, 2024
52f517c
Update scenario/mocking.py
tonyandrewmeyer May 30, 2024
36327d6
Update tests and docs to match final (hopefully\!) API decision.
tonyandrewmeyer Jun 4, 2024
d3f9916
Fix tests.
tonyandrewmeyer Jun 4, 2024
38c7a71
Move the checks that were on binding to the consistency checker.
tonyandrewmeyer Jun 5, 2024
dbfbb91
Update tests now that emitting custom events is not possible.
tonyandrewmeyer Jun 5, 2024
3492ab1
Support 'ctx.on.event_name' for specifying events.
tonyandrewmeyer Apr 24, 2024
0cad14c
Update tests and docs to match final (hopefully\!) API decision.
tonyandrewmeyer Jun 4, 2024
3e4c574
Fix tests.
tonyandrewmeyer Jun 4, 2024
03ef892
Style fixes.
tonyandrewmeyer Jun 6, 2024
432f50a
Post-merge fixes.
tonyandrewmeyer Jun 18, 2024
02d59be
Post-merge fixes.
tonyandrewmeyer Aug 7, 2024
4221b02
Simplify code.
tonyandrewmeyer Aug 7, 2024
e3a15bb
Grammar fix.
tonyandrewmeyer Aug 7, 2024
cc8b3ca
Fix docstring example.
tonyandrewmeyer Aug 7, 2024
df5462f
Fix README.
tonyandrewmeyer Aug 7, 2024
7fe631d
Remove duplicated code.
tonyandrewmeyer Aug 7, 2024
ee72367
Remove unintended change.
tonyandrewmeyer Aug 7, 2024
a631927
Use lists for Exec command prefix examples.
tonyandrewmeyer Aug 7, 2024
23241cb
Small tweaks based on review.
tonyandrewmeyer Aug 8, 2024
42f21b4
Fix frozen bypass.
tonyandrewmeyer Aug 8, 2024
15ea896
Fix stdin usage.
tonyandrewmeyer Aug 8, 2024
8928a0f
Move the exec args to a context attribute.
tonyandrewmeyer Aug 12, 2024
acdb14f
Merge branch '7.0' into container-exec-enhancements
tonyandrewmeyer Aug 12, 2024
71525f0
Fix merge.
tonyandrewmeyer Aug 12, 2024
5000836
Minor fixes based on review.
tonyandrewmeyer Aug 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,8 @@ class MyCharm(ops.CharmBase):
def _on_start(self, _):
foo = self.unit.get_container('foo')
proc = foo.exec(['ls', '-ll'])
stdout, _ = proc.wait_output("...")
proc.stdin.write("...")
stdout, _ = proc.wait_output()
assert stdout == LS_LL


Expand All @@ -648,7 +649,7 @@ def test_pebble_exec():
name='foo',
execs={
scenario.Exec(
command_prefix=['ls', '-ll'],
command_prefix=['ls'],
return_code=0,
stdout=LS_LL,
),
Expand All @@ -663,7 +664,8 @@ def test_pebble_exec():
ctx.on.pebble_ready(container),
state_in,
)
assert state_out.containers["foo"].get_exec(['ls', '-ll']).stdin == "..."
assert ctx.exec_history[container.name].command == ['ls', '-ll']
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
assert ctx.exec_history[container.name].stdin == "..."
```

Scenario will attempt to find the right `Exec` object by matching the provided
Expand All @@ -674,15 +676,7 @@ example if the command is `['ls', '-ll']` then the searching will be:
2. an `Exec` with the command prefix `('ls', )`
3. an `Exec` with the command prefix `()`

If none of these are found Scenario will raise a `RuntimeError`.

Note that the `return_code`, `stdout`, and `stderr` attributes of an `Exec`
object are the *output* that the charm will receive when a matching `exec` call
is made. You'll want to provide these when constructing the input state, and
will rarely want to assert on them in the output state (they cannot change).
The `stdin` attribute is ignored in the input state, and in the output
state will contain any content that the charm wrote to stdin as part of the
`exec` call, so you'll want to assert that it has the appropriate value.
If none of these are found Scenario will raise an `ExecError`.

### Pebble Notices

Expand Down
4 changes: 3 additions & 1 deletion scenario/consistency_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,18 +588,20 @@ def check_containers_consistency(
f"container with that name is not present in the state. It's odd, but "
f"consistent, if it cannot connect; but it should at least be there.",
)
# - you're processing a Notice event and that notice is not in any of the containers
if event.notice and event.notice.id not in all_notices:
errors.append(
f"the event being processed concerns notice {event.notice!r}, but that "
"notice is not in any of the containers present in the state.",
)
# - you're processing a Check event and that check is not in the check's container
if (
event.check_info
and (evt_container_name, event.check_info.name) not in all_checks
):
errors.append(
f"the event being processed concerns check {event.check_info.name}, but that "
f"check is not the {evt_container_name} container.",
f"check is not in the {evt_container_name} container.",
)

# - a container in state.containers is not in meta.containers
Expand Down
2 changes: 2 additions & 0 deletions scenario/context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.
import collections
import tempfile
from contextlib import contextmanager
from pathlib import Path
Expand Down Expand Up @@ -450,6 +451,7 @@ def __init__(
self.juju_log: List["JujuLogLine"] = []
self.app_status_history: List["_EntityStatus"] = []
self.unit_status_history: List["_EntityStatus"] = []
self.exec_history = collections.defaultdict(list)
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
self.workload_version_history: List[str] = []
self.removed_secret_revisions: List[int] = []
self.emitted_events: List[EventBase] = []
Expand Down
140 changes: 105 additions & 35 deletions scenario/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# See LICENSE file for licensing details.
import datetime
import shutil
from io import StringIO
from pathlib import Path
from typing import (
TYPE_CHECKING,
Expand All @@ -14,6 +13,7 @@
Mapping,
Optional,
Set,
TextIO,
Tuple,
Union,
cast,
Expand All @@ -33,7 +33,7 @@
_ModelBackend,
)
from ops.pebble import Client, ExecError
from ops.testing import _TestingPebbleClient
from ops.testing import ExecArgs, _TestingPebbleClient

from scenario.logger import logger as scenario_logger
from scenario.state import (
Expand Down Expand Up @@ -74,38 +74,44 @@ class ActionMissingFromContextError(Exception):
class _MockExecProcess:
def __init__(
self,
command: Tuple[str, ...],
change_id: int,
exec: "Exec",
args: ExecArgs,
return_code: int,
stdin: Optional[TextIO],
stdout: Optional[TextIO],
stderr: Optional[TextIO],
):
self._command = command
self._change_id = change_id
self._exec = exec
self._args = args
self._return_code = return_code
self._waited = False
self.stdout = StringIO(self._exec.stdout)
self.stderr = StringIO(self._exec.stderr)
# You can't pass *in* the stdin, the charm is responsible for that.
self.stdin = StringIO()
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr

def __del__(self):
if not self._waited:
self._close_stdin()

def _close_stdin(self):
if self._args.stdin is None and self.stdin is not None:
self.stdin.seek(0)
self._args.stdin = self.stdin.read()

def wait(self):
self._close_stdin()
self._waited = True
self._exec._update_stdin(self.stdin.getvalue())
exit_code = self._exec.return_code
if exit_code != 0:
raise ExecError(list(self._command), exit_code, None, None)
if self._return_code != 0:
raise ExecError(list(self._args.command), self._return_code, None, None)

def wait_output(self):
exec = self._exec
self._exec._update_stdin(self.stdin.getvalue())
exit_code = exec.return_code
if exit_code != 0:
raise ExecError(
list(self._command),
exit_code,
self.stdout.read(),
self.stderr.read(),
)
return exec.stdout, exec.stderr
self._close_stdin()
self._waited = True
stdout = self.stdout.read() if self.stdout is not None else None
stderr = self.stderr.read() if self.stderr is not None else None
if self._return_code != 0:
raise ExecError(list(self._args.command), self._return_code, stdout, stderr)
return stdout, stderr

def send_signal(self, sig: Union[int, str]): # noqa: U100
raise NotImplementedError()
Expand Down Expand Up @@ -179,6 +185,8 @@ def get_pebble(self, socket_path: str) -> "Client":
state=self._state,
event=self._event,
charm_spec=self._charm_spec,
context=self._context,
container_name=container_name,
)

def _get_relation_by_id(
Expand Down Expand Up @@ -710,11 +718,15 @@ def __init__(
state: "State",
event: "_Event",
charm_spec: "_CharmSpec",
context: "Context",
container_name: str,
):
self._state = state
self.socket_path = socket_path
self._event = event
self._charm_spec = charm_spec
self._context = context
self._container_name = container_name

# wipe just in case
if container_root.exists():
Expand Down Expand Up @@ -784,21 +796,79 @@ def _find_exec_handler(self, command) -> Optional["Exec"]:
# matter how much of it was used, so we have failed to find a handler.
return None

def exec(self, command, **kwargs): # noqa: U100 type: ignore
def exec(
self,
command: List[str],
*,
environment: Optional[Dict[str, str]] = None,
working_dir: Optional[str] = None,
timeout: Optional[float] = None,
user_id: Optional[int] = None,
user: Optional[str] = None,
group_id: Optional[int] = None,
group: Optional[str] = None,
stdin: Optional[Union[str, bytes, TextIO]] = None,
stdout: Optional[TextIO] = None,
stderr: Optional[TextIO] = None,
encoding: Optional[str] = "utf-8",
combine_stderr: bool = False,
**kwargs,
):
handler = self._find_exec_handler(command)
if not handler:
raise RuntimeError(
f"mock for cmd {command} not found. Please pass to the Container "
f"{self._container.name} a scenario.Exec mock for the "
f"command your charm is attempting to run, or patch "
f"out whatever leads to the call.",
raise ExecError(
command,
127,
"",
f"mock for cmd {command} not found. Please patch out whatever "
f"leads to the call, or pass to the Container {self._container.name} "
f"a scenario.Exec mock for the command your charm is attempting "
f"to run, such as "
f"'Container(..., execs={{scenario.Exec({list(command)}, ...)}})'",
)

if stdin is None:
proc_stdin = self._transform_exec_handler_output("", encoding)
else:
proc_stdin = None
stdin = stdin.read() if hasattr(stdin, "read") else stdin # type: ignore
if stdout is None:
proc_stdout = self._transform_exec_handler_output(handler.stdout, encoding)
else:
proc_stdout = None
stdout.write(handler.stdout)
if stderr is None:
proc_stderr = self._transform_exec_handler_output(handler.stderr, encoding)
else:
proc_stderr = None
stderr.write(handler.stderr)

args = ExecArgs(
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
command,
environment or {},
working_dir,
timeout,
user_id,
user,
group_id,
group,
stdin, # type:ignore # If None, will be replaced by proc_stdin.read() later.
encoding,
combine_stderr,
)
self._context.exec_history[self._container_name].append(args)

change_id = handler._run()
return _MockExecProcess(
change_id=change_id,
command=command,
exec=handler,
return cast(
pebble.ExecProcess[Any],
_MockExecProcess(
change_id=change_id,
args=args,
return_code=handler.return_code,
stdin=proc_stdin,
stdout=proc_stdout,
stderr=proc_stderr,
),
)

def _check_connection(self):
Expand Down
30 changes: 14 additions & 16 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,13 +674,22 @@ class Exec(_max_posargs(1)):

command_prefix: Sequence[str]
return_code: int = 0
"""The return code of the process (0 is success)."""
"""The return code of the process.

Use 0 to mock the process ending successfully, and other values for failure.
"""
stdout: str = ""
"""Any content written to stdout by the process."""
"""Any content written to stdout by the process.

Provide content that the real process would write to stdout, which can be
read by the charm.
"""
stderr: str = ""
"""Any content written to stderr by the process."""
stdin: Optional[str] = None
"""Any content written to stdin by the charm."""
"""Any content written to stderr by the process.

Provide content that the real process would write to stderr, which can be
read by the charm.
"""

# change ID: used internally to keep track of mocked processes
_change_id: int = dataclasses.field(default_factory=_generate_new_change_id)
Expand All @@ -695,10 +704,6 @@ def __post_init__(self):
def _run(self) -> int:
return self._change_id

def _update_stdin(self, stdin: str):
# bypass frozen dataclass
object.__setattr__(self, "stdin", stdin)


@dataclasses.dataclass(frozen=True)
class Mount(_max_posargs(0)):
Expand Down Expand Up @@ -960,13 +965,6 @@ def get_filesystem(self, ctx: "Context") -> Path:
"""
return ctx._get_container_root(self.name)

def get_exec(self, command_prefix: Sequence[str]):
"""Get the Exec object from the container with the given command prefix."""
for exec in self.execs:
if exec.command_prefix == command_prefix:
return exec
raise KeyError(f"no exec found with command prefix {command_prefix}")


_RawStatusLiteral = Literal[
"waiting",
Expand Down
Loading
Loading