diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5fcd9e8..0f8dfeca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,9 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/add-trailing-comma - rev: v2.4.0 + rev: v3.1.0 hooks: - id: add-trailing-comma - args: [--py36-plus] - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: diff --git a/README.md b/README.md index 286629c7..44013362 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,6 @@ available. The charm has no config, no relations, no leadership, and its status With that, we can write the simplest possible scenario test: ```python -import ops -import scenario - - -class MyCharm(ops.CharmBase): - pass - - def test_scenario_base(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run(scenario.Event("start"), scenario.State()) @@ -99,9 +91,7 @@ def test_scenario_base(): Now let's start making it more complicated. Our charm sets a special state if it has leadership on 'start': ```python -import ops import pytest -import scenario class MyCharm(ops.CharmBase): @@ -133,9 +123,6 @@ sets the expected unit/application status. We have seen a simple example above i charm transitions through a sequence of statuses? ```python -import ops - - # charm code: def _on_event(self, _event): self.unit.status = ops.MaintenanceStatus('determining who the ruler is...') @@ -174,11 +161,6 @@ context. You can verify that the charm has followed the expected path by checking the unit/app status history like so: ```python -import ops -import scenario -from charm import MyCharm - - def test_statuses(): ctx = scenario.Context(MyCharm, meta={"name": "foo"}) out = ctx.run('start', scenario.State(leader=False)) @@ -187,7 +169,7 @@ def test_statuses(): ops.MaintenanceStatus('determining who the ruler is...'), ops.WaitingStatus('checking this is right...'), ] - assert out.unit_status == ops.ActiveStatus("I am ruled"), + assert out.unit_status == ops.ActiveStatus("I am ruled") # similarly you can check the app status history: assert ctx.app_status_history == [ @@ -206,10 +188,16 @@ If you want to simulate a situation in which the charm already has seen some eve Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State. ```python -import ops -import scenario +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, event): + self.model.unit.status = ops.ActiveStatus("foo") # ... +ctx = scenario.Context(MyCharm, meta={"name": "foo"}) ctx.run('start', scenario.State(unit_status=ops.ActiveStatus('foo'))) assert ctx.unit_status_history == [ ops.ActiveStatus('foo'), # now the first status is active: 'foo'! @@ -223,10 +211,9 @@ Using a similar api to `*_status_history`, you can assert that the charm has set hook execution: ```python -import scenario - # ... -ctx: scenario.Context +ctx = scenario.Context(HistoryCharm, meta={"name": "foo"}) +ctx.run("start", scenario.State()) assert ctx.workload_version_history == ['1', '1.2', '1.5'] # ... ``` @@ -241,10 +228,6 @@ given Juju event triggering (say, 'start'), a specific chain of events is emitte resulting state, black-box as it is, gives little insight into how exactly it was obtained. ```python -import ops -import scenario - - def test_foo(): ctx = scenario.Context(...) ctx.run('start', ...) @@ -259,8 +242,6 @@ You can configure what events will be captured by passing the following argument For example: ```python -import scenario - def test_emitted_full(): ctx = scenario.Context( MyCharm, @@ -288,14 +269,13 @@ This context manager allows you to intercept any events emitted by the framework Usage: ```python -import ops -import scenario +import scenario.capture_events -with capture_events() as emitted: - ctx = scenario.Context(...) +with scenario.capture_events.capture_events() as emitted: + ctx = scenario.Context(SimpleCharm, meta={"name": "capture"}) state_out = ctx.run( "update-status", - scenario.State(deferred=[scenario.DeferredEvent("start", ...)]) + scenario.State(deferred=[scenario.deferred("start", SimpleCharm._on_start)]) ) # deferred events get reemitted first @@ -310,9 +290,9 @@ assert isinstance(emitted[1], ops.UpdateStatusEvent) You can filter events by type like so: ```python -import ops +import scenario.capture_events -with capture_events(ops.StartEvent, ops.RelationEvent) as emitted: +with scenario.capture_events.capture_events(ops.StartEvent, ops.RelationEvent) as emitted: # capture all `start` and `*-relation-*` events. pass ``` @@ -330,10 +310,6 @@ Configuration: You can write scenario tests to verify the shape of relation data: ```python -import ops -import scenario - - # This charm copies over remote app data to local unit data class MyCharm(ops.CharmBase): ... @@ -395,8 +371,6 @@ have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms o - `Relation.remote_units_data` maps to `PeerRelation.peers_data` ```python -import scenario - relation = scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, @@ -407,15 +381,22 @@ be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `pee be flagged by the Consistency Checker: ```python -import scenario - state_in = scenario.State(relations=[ scenario.PeerRelation( endpoint="peers", peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}}, )]) -scenario.Context(..., unit_id=1).run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. +meta = { + "name": "invalid", + "peers": { + "peers": { + "interface": "foo", + } + } +} +ctx = scenario.Context(ops.CharmBase, meta=meta, unit_id=1) +ctx.run("start", state_in) # invalid: this unit's id cannot be the ID of a peer. ``` @@ -432,8 +413,6 @@ Because of that, `SubordinateRelation`, compared to `Relation`, always talks in - `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags) ```python -import scenario - relation = scenario.SubordinateRelation( endpoint="peers", remote_unit_data={"foo": "bar"}, @@ -449,8 +428,6 @@ If you want to trigger relation events, the easiest way to do so is get a hold o event from one of its aptly-named properties: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = relation.changed_event joined_event = relation.joined_event @@ -460,8 +437,6 @@ joined_event = relation.joined_event This is in fact syntactic sugar for: ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") changed_event = scenario.Event('foo-relation-changed', relation=relation) ``` @@ -489,7 +464,7 @@ import dataclasses import scenario.state rel = scenario.Relation('foo') -rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, relation_id=scenario.state.next_relation_id()) +rel2 = dataclasses.replace(rel, local_app_data={"foo": "bar"}, id=scenario.state.next_relation_id()) assert rel2.id == rel.id + 1 ``` @@ -510,8 +485,6 @@ The `remote_unit_id` will default to the first ID found in the relation's `remot writing is close to that domain, you should probably override it and pass it manually. ```python -import scenario - relation = scenario.Relation(endpoint="foo", interface="bar") remote_unit_2_is_joining_event = relation.joined_event(remote_unit_id=2) @@ -531,8 +504,6 @@ On top of the relation-provided network bindings, a charm can also define some ` If you want to, you can override any of these relation or extra-binding associated networks with a custom one by passing it to `State.networks`. ```python -import scenario - state = scenario.State(networks={ 'foo': scenario.Network.default(private_address='192.0.2.1') }) @@ -551,8 +522,6 @@ To give the charm access to some containers, you need to pass them to the input An example of a state including some containers: ```python -import scenario - state = scenario.State(containers=[ scenario.Container(name="foo", can_connect=True), scenario.Container(name="bar", can_connect=False) @@ -568,14 +537,12 @@ You can configure a container to have some files in it: ```python import pathlib -import scenario - local_file = pathlib.Path('/path/to/local/real/file.txt') container = scenario.Container( name="foo", can_connect=True, - mounts={'local': Mount('/local/share/config.yaml', local_file)} + mounts={'local': scenario.Mount('/local/share/config.yaml', local_file)} ) state = scenario.State(containers=[container]) ``` @@ -596,9 +563,6 @@ data and passing it to the charm via the container. ```python import tempfile -import ops -import scenario - class MyCharm(ops.CharmBase): def __init__(self, framework): @@ -639,10 +603,6 @@ that envvar into the charm's runtime. If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api. ```python -import ops -import scenario - - class MyCharm(ops.CharmBase): def __init__(self, framework): super().__init__(framework) @@ -676,9 +636,6 @@ worse issues to deal with. You need to specify, for each possible command the ch result of that would be: its return code, what will be written to stdout/stderr. ```python -import ops -import scenario - LS_LL = """ .rw-rw-r-- 228 ubuntu ubuntu 18 jan 12:05 -- charmcraft.yaml .rw-rw-r-- 497 ubuntu ubuntu 18 jan 12:05 -- config.yaml @@ -722,10 +679,8 @@ If your charm defines `storage` in its metadata, you can use `scenario.Storage` Using the same `get_filesystem` API as `Container`, you can access the temporary directory used by Scenario to mock the filesystem root before and after the scenario runs. ```python -import scenario - # Some charm with a 'foo' filesystem-type storage defined in its metadata: -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) storage = scenario.Storage("foo") # Setup storage with some content: @@ -753,7 +708,7 @@ Note that State only wants to know about **attached** storages. A storage which If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. -```python +```python notest # In MyCharm._on_foo: # The charm requests two new "foo" storage instances to be provisioned: self.model.storages.request("foo", 2) @@ -761,10 +716,8 @@ self.model.storages.request("foo", 2) From test code, you can inspect that: -```python -import scenario - -ctx = scenario.Context(MyCharm) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run('some-event-that-will-cause_on_foo-to-be-called', scenario.State()) # the charm has requested two 'foo' storages to be provisioned: @@ -775,16 +728,14 @@ Requesting storages has no other consequence in Scenario. In real life, this req So a natural follow-up Scenario test suite for this case would be: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) foo_0 = scenario.Storage('foo') # The charm is notified that one of the storages it has requested is ready: -ctx.run(foo_0.attached_event, State(storage=[foo_0])) +ctx.run(foo_0.attached_event, scenario.State(storage=[foo_0])) foo_1 = scenario.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +ctx.run(foo_1.attached_event, scenario.State(storage=[foo_0, foo_1])) ``` ## Ports @@ -793,16 +744,12 @@ Since `ops 2.6.0`, charms can invoke the `open-port`, `close-port`, and `opened- - simulate a charm run with a port opened by some previous execution ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(MyCharm, meta=MyCharm.META) ctx.run("start", scenario.State(opened_ports=[scenario.Port("tcp", 42)])) ``` - assert that a charm has called `open-port` or `close-port`: ```python -import scenario - -ctx = scenario.Context(MyCharm) +ctx = scenario.Context(PortCharm, meta=MyCharm.META) state1 = ctx.run("start", scenario.State()) assert state1.opened_ports == [scenario.Port("tcp", 42)] @@ -815,8 +762,6 @@ assert state2.opened_ports == [] Scenario has secrets. Here's how you use them. ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -846,8 +791,6 @@ If this charm does not own the secret, but also it was not granted view rights b To specify a secret owned by this unit (or app): ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -864,8 +807,6 @@ state = scenario.State( To specify a secret owned by some other application and give this unit (or app) access to it: ```python -import scenario - state = scenario.State( secrets=[ scenario.Secret( @@ -883,12 +824,6 @@ state = scenario.State( Scenario can simulate StoredState. You can define it on the input side as: ```python -import ops -import scenario - -from ops.charm import CharmBase - - class MyCharmType(ops.CharmBase): my_stored_state = ops.StoredState() @@ -920,13 +855,13 @@ However, when testing, this constraint is unnecessarily strict (and it would als So, the only consistency-level check we enforce in Scenario when it comes to resource is that if a resource is provided in State, it needs to have been declared in the metadata. ```python -import scenario +import pathlib ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}}) with ctx.manager("start", scenario.State(resources={'foo': '/path/to/resource.tar'})) as mgr: # If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back. path = mgr.charm.model.resources.fetch('foo') - assert path == '/path/to/resource.tar' + assert path == pathlib.Path('/path/to/resource.tar') ``` ## Model @@ -936,12 +871,6 @@ but if you need to set the model name or UUID, you can provide a `scenario.Model to the state: ```python -import ops -import scenario - -class MyCharm(ops.CharmBase): - pass - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) state_in = scenario.State(model=scenario.Model(name="my-model")) out = ctx.run("start", state_in) @@ -961,11 +890,6 @@ How to test actions with scenario: ## Actions without parameters ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): ctx = scenario.Context(MyCharm) @@ -990,11 +914,6 @@ def test_backup_action(): If the action takes parameters, you'll need to instantiate an `Action`. ```python -import scenario - -from charm import MyCharm - - def test_backup_action(): # Define an action: action = scenario.Action('do_backup', params={'a': 'b'}) @@ -1015,10 +934,7 @@ event in its queue (they would be there because they had been deferred in the pr valid. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_update_status(self, event): @@ -1043,14 +959,7 @@ def test_start_on_deferred_update_status(MyCharm): You can also generate the 'deferred' data structure (called a DeferredEvent) from the corresponding Event (and the handler): -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation deferred_start = scenario.Event('start').deferred(MyCharm._on_start) deferred_install = scenario.Event('install').deferred(MyCharm._on_start) ``` @@ -1059,10 +968,7 @@ On the output side, you can verify that an event that you expect to have been de been deferred. ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_start(self, event): @@ -1082,10 +988,7 @@ Relation instance they are about. So do they in Scenario. You can use the deferr structure: ```python -import scenario - - -class MyCharm(...): +class MyCharm(ops.CharmBase): ... def _on_foo_relation_changed(self, event): @@ -1106,14 +1009,7 @@ def test_start_on_deferred_update_status(MyCharm): but you can also use a shortcut from the relation event itself: -```python -import scenario - - -class MyCharm(...): - ... - - +```python continuation foo_relation = scenario.Relation('foo') foo_relation.changed_event.deferred(handler=MyCharm._on_foo_relation_changed) ``` @@ -1126,8 +1022,6 @@ charm libraries or objects other than the main charm class. For general-purpose usage, you will need to instantiate DeferredEvent directly. ```python -import scenario - my_deferred_event = scenario.DeferredEvent( handle_path='MyCharm/MyCharmLib/on/database_ready[1]', owner='MyCharmLib', # the object observing the event. Could also be MyCharm. @@ -1149,10 +1043,9 @@ the event is emitted at all. If for whatever reason you don't want to do that and you attempt to run that event directly you will get an error: -```python -import scenario - -scenario.Context(...).run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("ingress_provided", scenario.State()) # raises scenario.ops_main_mock.NoObserverError ``` This happens because the framework, by default, searches for an event source named `ingress_provided` in `charm.on`, but @@ -1160,10 +1053,9 @@ since the event is defined on another Object, it will fail to find it. You can prefix the event name with the path leading to its owner to tell Scenario where to find the event source: -```python -import scenario - -scenario.Context(...).run("my_charm_lib.on.foo", scenario.State()) +```python notest +ctx = scenario.Context(MyCharm, meta=MyCharm.META) +ctx.run("my_charm_lib.on.foo", scenario.State()) ``` This will instruct Scenario to emit `my_charm.my_charm_lib.on.foo`. @@ -1178,10 +1070,7 @@ given piece of data, or would return this and that _if_ it had been called. Scenario offers a cheekily-named context manager for this use case specifically: -```python -import ops -import scenario - +```python notest from charms.bar.lib_name.v1.charm_lib import CharmLib @@ -1235,16 +1124,12 @@ either inferred from the charm type being passed to `Context` or be passed to it the inferred one. This also allows you to test charms defined on the fly, as in: ```python -import ops -import scenario - - class MyCharmType(ops.CharmBase): pass ctx = scenario.Context(charm_type=MyCharmType, meta={'name': 'my-charm-name'}) -ctx.run('start', State()) +ctx.run('start', scenario.State()) ``` A consequence of this fact is that you have no direct control over the temporary directory that we are creating to put the metadata @@ -1253,9 +1138,6 @@ you are passing to `.run()` (because `ops` expects it to be a file...). That is, ```python import tempfile -import ops -import scenario - class MyCharmType(ops.CharmBase): pass @@ -1288,11 +1170,10 @@ the dataclasses `replace` api. ```python import dataclasses -import scenario relation = scenario.Relation('foo', remote_app_data={"1": "2"}) -# make a copy of relation, but with remote_app_data set to {"3", "4"} -relation2 = dataclasses.replace(relation, remote_app_data={"3", "4"}) +# make a copy of relation, but with remote_app_data set to {"3": "4"} +relation2 = dataclasses.replace(relation, remote_app_data={"3": "4"}) ``` # Consistency checks diff --git a/tests/readme-conftest.py b/tests/readme-conftest.py new file mode 100644 index 00000000..d125b930 --- /dev/null +++ b/tests/readme-conftest.py @@ -0,0 +1,56 @@ +"""pytest configuration for testing the README""" + +import ops + +import scenario + + +def pytest_markdown_docs_globals(): + class MyCharm(ops.CharmBase): + META = {"name": "mycharm", "storage": {"foo": {"type": "filesystem"}}} + + class SimpleCharm(ops.CharmBase): + META = {"name": "simplecharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + pass + + class HistoryCharm(ops.CharmBase): + META = {"name": "historycharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _: ops.StartEvent): + self.unit.set_workload_version("1") + self.unit.set_workload_version("1.2") + self.unit.set_workload_version("1.5") + self.unit.set_workload_version("2.0") + + class PortCharm(ops.CharmBase): + META = {"name": "portcharm"} + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + framework.observe(self.on.stop, self._on_stop) + + def _on_start(self, _: ops.StartEvent): + self.unit.open_port(protocol="tcp", port=42) + + def _on_stop(self, _: ops.StopEvent): + self.unit.close_port(protocol="tcp", port=42) + + return { + "ops": ops, + "scenario": scenario, + "MyCharm": MyCharm, + "HistoryCharm": HistoryCharm, + "PortCharm": PortCharm, + "SimpleCharm": SimpleCharm, + } diff --git a/tox.ini b/tox.ini index 1f97b2c0..ec7fd41c 100644 --- a/tox.ini +++ b/tox.ini @@ -68,3 +68,20 @@ deps = commands = ruff format {[vars]tst_path} {[vars]src_path} isort --profile black {[vars]tst_path} {[vars]src_path} + +[testenv:test-readme] +description = Test code snippets in the README. +skip_install = true +allowlist_externals = + mkdir + cp +deps = + . + ops + pytest + pytest-markdown-docs +commands = + mkdir -p {envtmpdir}/test-readme + cp {toxinidir}/README.md {envtmpdir}/test-readme/README.md + cp {toxinidir}/tests/readme-conftest.py {envtmpdir}/test-readme/conftest.py + pytest -v --tb native --log-cli-level=INFO -s {posargs} --markdown-docs {envtmpdir}/test-readme/README.md