diff --git a/pyproject.toml b/pyproject.toml index 7fa96266..398f63b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "ops-scenario" -version = "5.2.1" +version = "5.2.2" authors = [ { name = "Pietro Pasotti", email = "pietro.pasotti@canonical.com" } diff --git a/scenario/context.py b/scenario/context.py index 51498b97..ad985974 100644 --- a/scenario/context.py +++ b/scenario/context.py @@ -150,7 +150,49 @@ def __init__( charm_root: "PathLike" = None, juju_version: str = "3.0", ): - """Initializer. + """Represents a simulated charm's execution context. + + It is the main entry point to running a scenario test. + + It contains: the charm source code being executed, the metadata files associated with it, + a charm project repository root, and the juju version to be simulated. + + After you have instantiated Context, typically you will call one of `run()` or + `run_action()` to execute the charm once, write any assertions you like on the output + state returned by the call, write any assertions you like on the Context attributes, + then discard the Context. + Each Context instance is in principle designed to be single-use: + Context is not cleaned up automatically between charm runs. + You can call `.clear()` to do some clean up, but we don't guarantee all state will be gone. + + Any side effects generated by executing the charm, that are not rightful part of the State, + are in fact stored in the Context: + - ``juju_log``: record of what the charm has sent to juju-log + - ``app_status_history``: record of the app statuses the charm has set + - ``unit_status_history``: record of the unit statuses the charm has set + - ``workload_version_history``: record of the workload versions the charm has set + - ``emitted_events``: record of the events (including custom ones) that the charm has + processed + + This allows you to write assertions not only on the output state, but also, to some + extent, on the path the charm took to get there. + + A typical scenario test will look like: + + >>> from scenario import Context, State + >>> from ops import ActiveStatus + >>> from charm import MyCharm, MyCustomEvent + >>> + >>> def test_foo(): + >>> # Arrange: set the context up + >>> c = Context(MyCharm) + >>> # Act: prepare the state and emit an event + >>> state_out = c.run('update-status', State()) + >>> # Assert: verify the output state is what you think it should be + >>> assert state_out.unit_status == ActiveStatus('foobar') + >>> # Assert: verify the Context contains what you think it should + >>> assert len(c.emitted_events) == 4 + >>> assert isinstance(c.emitted_events[3], MyCustomEvent) :arg charm_type: the CharmBase subclass to call ``ops.main()`` on. :arg meta: charm metadata to use. Needs to be a valid metadata.yaml format (as a dict). @@ -171,7 +213,6 @@ def __init__( >>> (local_path / 'foo').mkdir() >>> (local_path / 'foo' / 'bar.yaml').write_text('foo: bar') >>> scenario.Context(... charm_root=virtual_root).run(...) - """ if not any((meta, actions, config)): diff --git a/scenario/scripts/snapshot.py b/scenario/scripts/snapshot.py index 14b67a1f..f2c678b6 100644 --- a/scenario/scripts/snapshot.py +++ b/scenario/scripts/snapshot.py @@ -874,15 +874,19 @@ def if_include(key, fn, default): else: raise ValueError(f"unknown format {format}") - controller_timestamp = juju_status["controller"]["timestamp"] - local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") - print( - f"# Generated by scenario.snapshot. \n" - f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" - f"# Controller timestamp := {controller_timestamp}. \n" - f"# Juju version := {juju_version} \n" - f"# Charm fingerprint := {charm_version} \n", - ) + # json does not support comments, so it would be invalid output. + if format != FormatOption.json: + # print out some metadata + controller_timestamp = juju_status["controller"]["timestamp"] + local_timestamp = datetime.datetime.now().strftime("%m/%d/%Y, %H:%M:%S") + print( + f"# Generated by scenario.snapshot. \n" + f"# Snapshot of {state_model.name}:{target.unit_name} at {local_timestamp}. \n" + f"# Controller timestamp := {controller_timestamp}. \n" + f"# Juju version := {juju_version} \n" + f"# Charm fingerprint := {charm_version} \n", + ) + print(txt) return state diff --git a/scenario/scripts/state_apply.py b/scenario/scripts/state_apply.py index cd6a2013..f864b141 100644 --- a/scenario/scripts/state_apply.py +++ b/scenario/scripts/state_apply.py @@ -223,14 +223,18 @@ def state_apply( "of k8s charms, this might mean files obtained through Mounts,", ), ): - """Gather and output the State of a remote target unit. + """Apply a State to a remote target unit. If black is available, the output will be piped through it for formatting. Usage: state-apply myapp/0 > ./tests/scenario/case1.py """ push_files_ = json.loads(push_files.read_text()) if push_files else None - state_ = json.loads(state.read_text()) + state_json = json.loads(state.read_text()) + + # TODO: state_json to State + raise NotImplementedError("WIP: implement State.from_json") + state_: State = State.from_json(state_json) return _state_apply( target=target,