diff --git a/docs/custom_conf.py b/docs/custom_conf.py index f567b91ec..fb14823a1 100644 --- a/docs/custom_conf.py +++ b/docs/custom_conf.py @@ -18,33 +18,17 @@ from docutils import nodes +import inspect +import sphinx.ext.autodoc from sphinx import addnodes from sphinx.util.docutils import SphinxDirective -import furo -import furo.navigation sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) -# Furo patch to get local TOC to show in sidebar (as sphinx-rtd-theme did) -# See https://github.com/pradyunsg/furo/blob/490527b2aef00b1198770c3389a1979911ee1fcb/src/furo/__init__.py#L115-L128 - -_old_compute_navigation_tree = furo._compute_navigation_tree - - -def _compute_navigation_tree(context): - tree_html = _old_compute_navigation_tree(context) - if not tree_html and context.get('toc'): - tree_html = furo.navigation.get_navigation_tree(context['toc']) - return tree_html - - -furo._compute_navigation_tree = _compute_navigation_tree - # Pull in fix from https://github.com/sphinx-doc/sphinx/pull/11222/files to fix # "invalid signature for autoattribute ('ops.pebble::ServiceDict.backoff-delay')" -import re # noqa: E402 -import sphinx.ext.autodoc # noqa: E402 +import re sphinx.ext.autodoc.py_ext_sig_re = re.compile( r"""^ ([\w.]+::)? # explicit module name @@ -216,7 +200,9 @@ def _compute_navigation_tree(context): # pyspelling, sphinx, sphinx-autobuild, sphinx-copybutton, sphinx-design, # sphinx-notfound-page, sphinx-reredirects, sphinx-tabs, sphinxcontrib-jquery, # sphinxext-opengraph -custom_required_modules = [] +custom_required_modules = [ + 'ops-scenario>=7.0.5,<8', +] # Add files or directories that should be excluded from processing. custom_excludes = [ @@ -315,6 +301,8 @@ def _compute_navigation_tree(context): # Please keep this list sorted alphabetically. ('py:class', '_ChangeDict'), ('py:class', '_CheckInfoDict'), + ('py:class', '_EntityStatus'), + ('py:class', '_Event'), ('py:class', '_FileInfoDict'), ('py:class', '_NoticeDict'), ('py:class', '_ProgressDict'), @@ -326,6 +314,8 @@ def _compute_navigation_tree(context): ('py:class', '_TextOrBinaryIO'), ('py:class', '_WarningDict'), ('py:class', '_Writeable'), + ('py:class', 'AnyJson'), + ('py:class', 'CharmType'), ('py:obj', 'ops._private.harness.CharmType'), ('py:class', 'ops._private.harness.CharmType'), ('py:class', 'ops.charm._ContainerBaseDict'), @@ -345,9 +335,34 @@ def _compute_navigation_tree(context): ('py:class', 'ops.testing._ConfigOption'), ('py:class', 'ops.testing.CharmType'), ('py:obj', 'ops.testing.CharmType'), + ('py:class', 'scenario.state._EntityStatus'), + ('py:class', 'scenario.state._Event'), + ('py:class', 'scenario.state._max_posargs.._MaxPositionalArgs'), ] +# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses +# of _MaxPositionalArgs. +_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature + + +def _custom_get_signature(self): + if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__): + signature = inspect.signature(self.object) + parameters = [] + for position, param in enumerate(signature.parameters.values()): + if position >= self.object._max_positional_args: + parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY)) + else: + parameters.append(param) + signature = signature.replace(parameters=parameters) + return None, None, signature + return _real_get_signature(self) + + +sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature + + # This is very strongly based on # https://github.com/sphinx-doc/sphinx/blob/03b9134ee00e98df4f8b5f6d22f345cdafe31870/sphinx/domains/changeset.py#L46 # Unfortunately, the built-in class is not easily subclassable without also diff --git a/docs/harness.rst b/docs/harness.rst new file mode 100644 index 000000000..a03cded49 --- /dev/null +++ b/docs/harness.rst @@ -0,0 +1,39 @@ +.. _harness: + +Harness (legacy unit testing) +============================= + +.. deprecated:: 2.17 + The Harness framework is deprecated and will be moved out of the base + install in a future ops release. Charm authors that don't want to upgrade + will still be able to use it with ``pip install ops[harness]``. + +The Harness API includes: + +- :class:`ops.testing.Harness`, a class to set up the simulated environment, + that provides: + + - :meth:`~ops.testing.Harness.add_relation` method, to declare a relation + (integration) with another app. + - :meth:`~ops.testing.Harness.begin` and :meth:`~ops.testing.Harness.cleanup` + methods to start and end the testing lifecycle. + - :meth:`~ops.testing.Harness.evaluate_status` method, which aggregates the + status of the charm after test interactions. + - :attr:`~ops.testing.Harness.model` attribute, which exposes e.g. the + :attr:`~ops.Model.unit` attribute for detailed assertions on the unit's state. + +.. warning:: The Harness API has flaws with resetting the charm state between + Juju events. Care must be taken when emitting multiple events with the same + Harness object. + +.. note:: + Unit testing is only one aspect of a comprehensive testing strategy. For more + on testing charms, see `Charm SDK | Testing `_. + + +.. autoclass:: ops.testing.ActionFailed + :noindex: +.. autoclass:: ops.testing.ActionOutput +.. autoclass:: ops.testing.ExecArgs +.. autoclass:: ops.testing.ExecResult +.. autoclass:: ops.testing.Harness diff --git a/docs/index.rst b/docs/index.rst index c27e2bbe5..24f0a7f4b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,79 +1,72 @@ - -ops library API reference -========================= +API reference +============= The `ops` library is a Python framework for writing and testing Juju charms. See more: `Charm SDK documentation `_ -The library provides: +The library (`available on PyPI`_) provides: -- :ref:`ops_main_entry_point`, used to initialise and run your charm; - :ref:`ops_module`, the API to respond to Juju events and manage the application; +- :ref:`ops_main_entry_point`, used to initialise and run your charm; - :ref:`ops_pebble_module`, the Pebble client, a low-level API for Kubernetes containers; -- :ref:`ops_testing_module`, the framework for unit testing charms in a simulated environment; +- the APIs for unit testing charms in a simulated environment: + + - :doc:`State-transition testing `. This is the + recommended approach (it was previously known as 'Scenario'). + - :doc:`Harness `. This is a deprecated framework, and has issues, + particularly with resetting the charm state between Juju events. You can structure your charm however you like, but with the `ops` library, you get a framework that promotes consistency and readability by following best practices. It also helps you organise your code better by separating different -aspects of the charm, such as managing the application’s state, handling +aspects of the charm, such as managing the application's state, handling integrations with other services, and making the charm easier to test. +.. _available on PyPI: https://pypi.org/project/ops/ .. toctree:: + :hidden: :maxdepth: 2 - :caption: Contents: + self + state-transition-testing + harness .. _ops_module: -ops module -========== +ops +--- .. automodule:: ops :exclude-members: main - .. _ops_main_entry_point: ops.main entry point -==================== +-------------------- The main entry point to initialise and run your charm. .. autofunction:: ops.main - legacy main module ------------------ .. automodule:: ops.main :noindex: - .. _ops_pebble_module: -ops.pebble module -================= +ops.pebble +---------- .. automodule:: ops.pebble - .. _ops_testing_module: -ops.testing module -================== - -.. autoclass:: ops.testing.ActionFailed -.. autoclass:: ops.testing.ActionOutput -.. autoclass:: ops.testing.ExecArgs -.. autoclass:: ops.testing.ExecResult -.. autoclass:: ops.testing.Harness - Indices ======= * :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/requirements.txt b/docs/requirements.txt index 340a58046..0c4e6f1b1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml @@ -73,6 +73,10 @@ mdurl==0.1.2 # via markdown-it-py myst-parser==4.0.0 # via ops (pyproject.toml) +ops==2.16.1 + # via ops-scenario +ops-scenario==7.0.5 + # via ops (pyproject.toml) packaging==24.1 # via sphinx pygments==2.18.0 @@ -85,7 +89,9 @@ pyspelling==2.10 pyyaml==6.0.2 # via # myst-parser + # ops # ops (pyproject.toml) + # ops-scenario # pyspelling requests==2.32.3 # via @@ -160,6 +166,8 @@ wcmatch==9.0 webencodings==0.5.1 # via html5lib websocket-client==1.8.0 - # via ops (pyproject.toml) + # via + # ops + # ops (pyproject.toml) websockets==12.0 # via sphinx-autobuild diff --git a/docs/state-transition-testing.rst b/docs/state-transition-testing.rst new file mode 100644 index 000000000..156848023 --- /dev/null +++ b/docs/state-transition-testing.rst @@ -0,0 +1,126 @@ +.. _state-transition-tests: + +Unit testing (was: Scenario) +============================ + +Install ops with the ``testing`` extra to use this API; for example: +``pip install ops[testing]`` + +State-transition tests, previously known as 'Scenario', expect you to define the +Juju state all at once, define the Juju context against which to test the charm, +and fire a single event on the charm to execute its logic. The tests can then +assert that the Juju state has changed as expected. + +A very simple test, where the charm has no config, no integrations, the unit +is the leader, and has a `start` handler that sets the status to active might +look like this: + +.. code-block:: python + + from ops import testing + + def test_base(): + ctx = testing.Context(MyCharm) + state = testing.State(leader=True) + out = ctx.run(ctx.on.start(), state) + assert out.unit_status == testing.ActiveStatus() + +These 'state-transition' tests give charm authors a way to test +how the state changes in reaction to events. They are not +necessarily tests of individual methods or functions; +they are testing the 'contract' of the charm: given +a certain state, when a certain event happens, the charm should transition to +another state. Unlike integration tests, they do not test using a real Juju +controller and model, and focus on a single Juju unit. +For simplicity, we refer to them as 'unit' tests. + +Writing these tests should nudge you into thinking of a charm as a black-box +'input to output' function. The inputs are: + +- Event: why am I, the charm, being executed +- State: am I the leader? what is my integration data? what is my config? +- Context: what integrations can I have? what containers can I have? + +The output is another `State`: the state after +the charm has interacted with the mocked Juju model. +The output state is the same type of data structure as the input state. + +.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png + :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right + +Writing unit tests for a charm, then, means verifying that: + +- the output state (as compared with the input state) is as expected +- the charm does not raise uncaught exceptions while handling the event + +A test consists of three broad steps: + +- **Arrange**: + - declare the context + - declare the input state +- **Act**: + - run an event (ie. obtain the output state, given the input state and the event) +- **Assert**: + - verify that the output state is what you expect it to be + - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls + +.. note:: + Unit testing is only one aspect of a comprehensive testing strategy. For more + on testing charms, see `Charm SDK | Testing `_. + + +.. + _The list here is manually maintained, because the `automodule` directive + expects to document names defined in the module, and not imported ones, and + we're doing the opposite of that - and we also want to use the 'ops.testing' + namespace, not expose the 'ops._private.harness' and 'scenario' ones. + Ideally, someone will figure out a nicer way to do this that doesn't require + keeping this list in sync (see test/test_infra.py for a check that we are ok). + +.. autoclass:: ops.testing.ActionFailed +.. autoclass:: ops.testing.ActiveStatus +.. autoclass:: ops.testing.Address +.. autoclass:: ops.testing.BindAddress +.. autoclass:: ops.testing.BlockedStatus +.. autoclass:: ops.testing.CharmEvents +.. autoclass:: ops.testing.CheckInfo +.. autoclass:: ops.testing.CloudCredential +.. autoclass:: ops.testing.CloudSpec +.. autoclass:: ops.testing.Container +.. autoclass:: ops.testing.Context + :special-members: __call__ +.. autoclass:: ops.testing.DeferredEvent +.. autoclass:: ops.testing.ErrorStatus +.. autoclass:: ops.testing.Exec +.. autoclass:: ops.testing.ICMPPort +.. autoclass:: ops.testing.JujuLogLine +.. autoclass:: ops.testing.MaintenanceStatus +.. autoclass:: ops.testing.Manager +.. autoclass:: ops.testing.Model +.. autoclass:: ops.testing.Mount +.. autoclass:: ops.testing.Network +.. autoclass:: ops.testing.Notice +.. autoclass:: ops.testing.PeerRelation +.. autoclass:: ops.testing.Port +.. autoclass:: ops.testing.Relation +.. autoclass:: ops.testing.RelationBase +.. autoclass:: ops.testing.Resource +.. autoclass:: ops.testing.Secret +.. autoclass:: ops.testing.State +.. autoclass:: ops.testing.Storage +.. autoclass:: ops.testing.StoredState +.. autoclass:: ops.testing.SubordinateRelation +.. autoclass:: ops.testing.TCPPort +.. autoclass:: ops.testing.UDPPort +.. autoclass:: ops.testing.UnknownStatus +.. autoclass:: ops.testing.WaitingStatus +.. autoclass:: ops.testing.errors.ContextSetupError +.. autoclass:: ops.testing.errors.AlreadyEmittedError +.. autoclass:: ops.testing.errors.ScenarioRuntimeError +.. autoclass:: ops.testing.errors.UncaughtCharmError +.. autoclass:: ops.testing.errors.InconsistentScenarioError +.. autoclass:: ops.testing.errors.StateValidationError +.. autoclass:: ops.testing.errors.MetadataNotFoundError +.. autoclass:: ops.testing.errors.ActionMissingFromContextError +.. autoclass:: ops.testing.errors.NoObserverError +.. autoclass:: ops.testing.errors.BadOwnerPath diff --git a/ops/__init__.py b/ops/__init__.py index 0e34ef169..b4c05f3f2 100644 --- a/ops/__init__.py +++ b/ops/__init__.py @@ -14,7 +14,7 @@ """The API to respond to Juju events and manage the application. -This module provides code freatures to your charm, including: +This module provides core features to your charm, including: - :class:`~ops.CharmBase`, the base class for charms and :class:`~ops.Object`, the base class for charm libraries. diff --git a/ops/_private/harness.py b/ops/_private/harness.py index 09334406e..1bc9f51e3 100644 --- a/ops/_private/harness.py +++ b/ops/_private/harness.py @@ -64,6 +64,17 @@ from ops.model import Container, RelationNotFoundError, StatusName, _NetworkDict from ops.pebble import ExecProcess +if typing.TYPE_CHECKING: + try: + from ops.testing import State # type: ignore + except ImportError: + # This is used in the ActionFailed type annotations: it will never be + # used in this case, because it's only relevant when ops.testing has + # the State class, so we just define it as a dummy class. + class State: + pass + + ReadableBuffer = Union[bytes, str, StringIO, BytesIO, BinaryIO] _StringOrPath = Union[str, pathlib.PurePosixPath, pathlib.Path] _FileKwargs = TypedDict( @@ -161,18 +172,35 @@ class ActionOutput: """The action's results, as set or updated by :meth:`ops.ActionEvent.set_results`.""" -class ActionFailed(Exception): # noqa - """Raised when :code:`event.fail()` is called during a :meth:`Harness.run_action` call.""" +class ActionFailed(Exception): # noqa: N818 (name doesn't end with "Error") + """Raised when :code:`event.fail()` is called during an action handler.""" message: str """Optional details of the failure, as provided by :meth:`ops.ActionEvent.fail`.""" output: ActionOutput - """Any logs and results set by the Charm.""" + """Any logs and results set by the Charm. + + When using Context.run, both logs and results will be empty - these + can be found in Context.action_logs and Context.action_results. + """ + + state: typing.Optional['State'] + """The Juju state after the action has been run. + + When using Harness.run_action, this will be None. + """ - def __init__(self, message: str, output: ActionOutput): + def __init__( + self, + message: str, + output: typing.Optional[ActionOutput] = None, + *, + state: typing.Optional['State'] = None, + ): self.message = message - self.output = output + self.output = output or ActionOutput([], {}) + self.state = state def __str__(self): if self.message: @@ -286,6 +314,13 @@ def __init__( juju_debug_at=self._juju_context.debug_at, ) + warnings.warn( + 'Harness is deprecated; we recommend using state transition testing ' + "(previously known as 'Scenario') instead", + PendingDeprecationWarning, + stacklevel=2, + ) + def _event_context(self, event_name: str): """Configures the Harness to behave as if an event hook were running. diff --git a/ops/charm.py b/ops/charm.py index edef8db32..deac3715c 100644 --- a/ops/charm.py +++ b/ops/charm.py @@ -118,10 +118,11 @@ class ActionEvent(EventBase): invokes a Juju Action. Callbacks bound to these events may be used for responding to the administrator's Juju Action request. - To read the parameters for the action, see the instance variable :attr:`params`. - To respond with the result of the action, call :meth:`set_results`. To add - progress messages that are visible as the action is progressing use - :meth:`log`. + To read the parameters for the action, see the instance variable + :attr:`~ops.ActionEvent.params`. + To respond with the result of the action, call + :meth:`~ops.ActionEvent.set_results`. To add progress messages that are + visible as the action is progressing use :meth:`~ops.ActionEvent.log`. """ id: str = '' @@ -240,7 +241,7 @@ class StartEvent(HookEvent): """Event triggered immediately after first configuration change. This event is triggered immediately after the first - :class:`ConfigChangedEvent`. Callback methods bound to the event should be + :class:`~ops.ConfigChangedEvent`. Callback methods bound to the event should be used to ensure that the charm's software is in a running state. Note that the charm's software should be configured so as to persist in this state through reboots without further intervention on Juju's part. @@ -298,8 +299,8 @@ class ConfigChangedEvent(HookEvent): specifically has changed in the config). - Right after the unit starts up for the first time. This event notifies the charm of its initial configuration. - Typically, this event will fire between an :class:`install ` - and a :class:`start ` during the startup sequence + Typically, this event will fire between an :class:`~ops.InstallEvent` + and a :class:~`ops.StartEvent` during the startup sequence (when a unit is first deployed), but in general it will fire whenever the unit is (re)started, for example after pod churn on Kubernetes, on unit rescheduling, on unit upgrade or refresh, and so on. @@ -323,7 +324,7 @@ class UpdateStatusEvent(HookEvent): to this event should determine the "health" of the application and set the status appropriately. - The interval between :class:`update-status ` events can + The interval between :class:`~ops.UpdateStatusEvent` events can be configured model-wide, e.g. ``juju model-config update-status-hook-interval=1m``. """ @@ -355,7 +356,7 @@ class PreSeriesUpgradeEvent(HookEvent): It can be assumed that only after all units on a machine have executed the callback method associated with this event, the administrator will initiate steps to actually upgrade the series. After the upgrade has been completed, - the :class:`PostSeriesUpgradeEvent` will fire. + the :class:`~ops.PostSeriesUpgradeEvent` will fire. .. jujuremoved:: 4.0 """ @@ -412,7 +413,7 @@ class LeaderSettingsChangedEvent(HookEvent): .. deprecated:: 2.4.0 This event has been deprecated in favor of using a Peer relation, and having the leader set a value in the Application data bag for - that peer relation. (See :class:`RelationChangedEvent`.) + that peer relation. (See :class:`~ops.RelationChangedEvent`.) """ @@ -550,7 +551,7 @@ class RelationCreatedEvent(RelationEvent): This is triggered when a new integration with another app is added in Juju. This can occur before units for those applications have started. All existing - relations will trigger `RelationCreatedEvent` before :class:`StartEvent` is + relations will trigger `RelationCreatedEvent` before :class:`~ops.StartEvent` is emitted. """ @@ -582,7 +583,7 @@ class RelationChangedEvent(RelationEvent): to see the new information, where ``event`` is the event object passed to the callback method bound to this event. - This event always fires once, after :class:`RelationJoinedEvent`, and + This event always fires once, after :class:`~ops.RelationJoinedEvent`, and will subsequently fire whenever that remote unit changes its data for the relation. Callback methods bound to this event should be the only ones that rely on remote relation data. They should not error if the data @@ -597,7 +598,7 @@ class RelationChangedEvent(RelationEvent): class RelationDepartedEvent(RelationEvent): """Event triggered when a unit leaves a relation. - This is the inverse of the :class:`RelationJoinedEvent`, representing when a + This is the inverse of the :class:`~ops.RelationJoinedEvent`, representing when a unit is leaving the relation (the unit is being removed, the app is being removed, the relation is being removed). For remaining units, this event is emitted once for each departing unit. For departing units, this event is @@ -610,7 +611,7 @@ class RelationDepartedEvent(RelationEvent): unit has already shut down. Once all callback methods bound to this event have been run for such a - relation, the unit agent will fire the :class:`RelationBrokenEvent`. + relation, the unit agent will fire the :class:`~ops.RelationBrokenEvent`. """ unit: model.Unit # pyright: ignore[reportIncompatibleVariableOverride] @@ -670,7 +671,7 @@ class RelationBrokenEvent(RelationEvent): The event indicates that the current relation is no longer valid, and that the charm's software must be configured as though the relation had never existed. It will only be called after every callback method bound to - :class:`RelationDepartedEvent` has been run. If a callback method + :class:`~ops.RelationDepartedEvent` has been run. If a callback method bound to this event is being executed, it is guaranteed that no remote units are currently known locally. """ @@ -741,7 +742,7 @@ class StorageAttachedEvent(StorageEvent): Callback methods bound to this event allow the charm to run code when storage has been added. Such methods will be run before the - :class:`InstallEvent` fires, so that the installation routine may + :class:`~ops.InstallEvent` fires, so that the installation routine may use the storage. The name prefix of this hook will depend on the storage key defined in the ``metadata.yaml`` file. """ @@ -755,7 +756,7 @@ class StorageDetachingEvent(StorageEvent): Callback methods bound to this event allow the charm to run code before storage is removed. Such methods will be run before storage - is detached, and always before the :class:`StopEvent` fires, thereby + is detached, and always before the :class:`~ops.StopEvent` fires, thereby allowing the charm to gracefully release resources before they are removed and before the unit terminates. The name prefix of the hook will depend on the storage key defined in the ``metadata.yaml`` @@ -905,7 +906,7 @@ class PebbleCheckFailedEvent(PebbleCheckEvent): """Event triggered when a Pebble check exceeds the configured failure threshold. Note that the check may have started passing by the time this event is - emitted (which will mean that a :class:`PebbleCheckRecoveredEvent` will be + emitted (which will mean that a :class:`~ops.PebbleCheckRecoveredEvent` will be emitted next). If the handler is executing code that should only be done if the check is currently failing, check the current status with ``event.info.status == ops.pebble.CheckStatus.DOWN``. @@ -1185,59 +1186,59 @@ class CharmEvents(ObjectEvents): # each event class's docstring. Please keep in sync. install = EventSource(InstallEvent) - """Triggered when a charm is installed (see :class:`InstallEvent`).""" + """Triggered when a charm is installed (see :class:`~ops.InstallEvent`).""" start = EventSource(StartEvent) - """Triggered immediately after first configuration change (see :class:`StartEvent`).""" + """Triggered immediately after first configuration change (see :class:`~ops.StartEvent`).""" stop = EventSource(StopEvent) - """Triggered when a charm is shut down (see :class:`StopEvent`).""" + """Triggered when a charm is shut down (see :class:`~ops.StopEvent`).""" remove = EventSource(RemoveEvent) - """Triggered when a unit is about to be terminated (see :class:`RemoveEvent`).""" + """Triggered when a unit is about to be terminated (see :class:`~ops.RemoveEvent`).""" update_status = EventSource(UpdateStatusEvent) """Triggered periodically by a status update request from Juju (see - :class:`UpdateStatusEvent`). + :class:`~ops.UpdateStatusEvent`). """ config_changed = EventSource(ConfigChangedEvent) - """Triggered when a configuration change occurs (see :class:`ConfigChangedEvent`).""" + """Triggered when a configuration change occurs (see :class:`~ops.ConfigChangedEvent`).""" upgrade_charm = EventSource(UpgradeCharmEvent) - """Triggered by request to upgrade the charm (see :class:`UpgradeCharmEvent`).""" + """Triggered by request to upgrade the charm (see :class:`~ops.UpgradeCharmEvent`).""" pre_series_upgrade = EventSource(PreSeriesUpgradeEvent) - """Triggered to prepare a unit for series upgrade (see :class:`PreSeriesUpgradeEvent`). + """Triggered to prepare a unit for series upgrade (see :class:`~ops.PreSeriesUpgradeEvent`). .. jujuremoved:: 4.0 """ post_series_upgrade = EventSource(PostSeriesUpgradeEvent) - """Triggered after a series upgrade (see :class:`PostSeriesUpgradeEvent`). + """Triggered after a series upgrade (see :class:`~ops.PostSeriesUpgradeEvent`). .. jujuremoved:: 4.0 """ leader_elected = EventSource(LeaderElectedEvent) - """Triggered when a new leader has been elected (see :class:`LeaderElectedEvent`).""" + """Triggered when a new leader has been elected (see :class:`~ops.LeaderElectedEvent`).""" leader_settings_changed = EventSource(LeaderSettingsChangedEvent) """Triggered when leader changes any settings (see - :class:`LeaderSettingsChangedEvent`). + :class:`~ops.LeaderSettingsChangedEvent`). .. deprecated:: 2.4.0 """ collect_metrics = EventSource(CollectMetricsEvent) - """Triggered by Juju to collect metrics (see :class:`CollectMetricsEvent`). + """Triggered by Juju to collect metrics (see :class:`~ops.CollectMetricsEvent`). .. jujuremoved:: 4.0 """ secret_changed = EventSource(SecretChangedEvent) """Triggered by Juju on the observer when the secret owner changes its contents (see - :class:`SecretChangedEvent`). + :class:`~ops.SecretChangedEvent`). .. jujuadded:: 3.0 Charm secrets added in Juju 3.0, user secrets added in Juju 3.3 @@ -1245,33 +1246,33 @@ class CharmEvents(ObjectEvents): secret_expired = EventSource(SecretExpiredEvent) """Triggered by Juju on the owner when a secret's expiration time elapses (see - :class:`SecretExpiredEvent`). + :class:`~ops.SecretExpiredEvent`). .. jujuadded:: 3.0 """ secret_rotate = EventSource(SecretRotateEvent) """Triggered by Juju on the owner when the secret's rotation policy elapses (see - :class:`SecretRotateEvent`). + :class:`~ops.SecretRotateEvent`). .. jujuadded:: 3.0 """ secret_remove = EventSource(SecretRemoveEvent) """Triggered by Juju on the owner when a secret revision can be removed (see - :class:`SecretRemoveEvent`). + :class:`~ops.SecretRemoveEvent`). .. jujuadded:: 3.0 """ collect_app_status = EventSource(CollectStatusEvent) """Triggered on the leader at the end of every hook to collect app statuses for evaluation - (see :class:`CollectStatusEvent`). + (see :class:`~ops.CollectStatusEvent`). """ collect_unit_status = EventSource(CollectStatusEvent) """Triggered at the end of every hook to collect unit statuses for evaluation - (see :class:`CollectStatusEvent`). + (see :class:`~ops.CollectStatusEvent`). """ diff --git a/ops/model.py b/ops/model.py index d03c4b584..f4fbb4083 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1982,7 +1982,7 @@ class UnknownStatus(StatusBase): charm has not called status-set yet. This status is read-only; trying to set unit or application status to - ``UnknownStatus`` will raise :class:`InvalidStatusError`. + ``UnknownStatus`` will raise :class:`~ops.InvalidStatusError`. """ name = 'unknown' @@ -2002,7 +2002,7 @@ class ErrorStatus(StatusBase): human intervention in order to operate correctly). This status is read-only; trying to set unit or application status to - ``ErrorStatus`` will raise :class:`InvalidStatusError`. + ``ErrorStatus`` will raise :class:`~ops.InvalidStatusError`. """ name = 'error' diff --git a/ops/testing.py b/ops/testing.py index e93ba7bcb..fc916ef55 100644 --- a/ops/testing.py +++ b/ops/testing.py @@ -32,6 +32,11 @@ on testing charms, see `Charm SDK | Testing `_. """ +# ruff: noqa: F401 (unused import) +# pyright: reportUnusedImport=false + +import importlib.metadata + from ._private.harness import ( ActionFailed, ActionOutput, @@ -56,27 +61,140 @@ storage, ) -# The Harness testing framework. -_ = ActionFailed -_ = ActionOutput -_ = AppUnitOrName -_ = CharmType -_ = ExecArgs -_ = ExecHandler -_ = ExecResult -_ = Harness -_ = ReadableBuffer -_ = YAMLStringOrFile +# The Harness unit testing framework. +__all__ = [ + 'ActionFailed', + 'ActionOutput', + 'AppUnitOrName', + 'CharmType', + 'ExecArgs', + 'ExecHandler', + 'ExecResult', + 'Harness', + 'ReadableBuffer', + 'YAMLStringOrFile', +] + +# If the 'ops.testing' optional extra is installed, make those +# names available in this namespace. +try: + _version = importlib.metadata.version('ops-scenario') +except importlib.metadata.PackageNotFoundError: + pass +else: + if _version and int(_version.split('.', 1)[0]) >= 7: + from scenario import ( + ActiveStatus, + Address, + AnyJson, + BindAddress, + BlockedStatus, + CheckInfo, + CloudCredential, + CloudSpec, + Container, + Context, + DeferredEvent, + ErrorStatus, + Exec, + ICMPPort, + JujuLogLine, + MaintenanceStatus, + Manager, + Model, + Mount, + Network, + Notice, + PeerRelation, + Port, + RawDataBagContents, + RawSecretRevisionContents, + Relation, + RelationBase, + Resource, + Secret, + State, + Storage, + StoredState, + SubordinateRelation, + TCPPort, + UDPPort, + UnitID, + UnknownStatus, + WaitingStatus, + errors, + ) + + # This can be imported in the group above after Scenario exposes it at the top level. + # https://github.com/canonical/ops-scenario/pull/200 + from scenario.context import CharmEvents + + # The Scenario unit testing framework. + __all__.extend([ + 'ActiveStatus', + 'Address', + 'AnyJson', + 'BindAddress', + 'BlockedStatus', + 'CharmEvents', + 'CheckInfo', + 'CloudCredential', + 'CloudSpec', + 'Container', + 'Context', + 'DeferredEvent', + 'ErrorStatus', + 'Exec', + 'ICMPPort', + 'JujuLogLine', + 'MaintenanceStatus', + 'Manager', + 'Model', + 'Mount', + 'Network', + 'Notice', + 'PeerRelation', + 'Port', + 'RawDataBagContents', + 'RawSecretRevisionContents', + 'Relation', + 'RelationBase', + 'Resource', + 'Secret', + 'State', + 'Storage', + 'StoredState', + 'SubordinateRelation', + 'TCPPort', + 'UDPPort', + 'UnitID', + 'UnknownStatus', + 'WaitingStatus', + 'errors', + ]) + + # Until Scenario uses the ops._private.harness.ActionFailed, we need to + # monkeypatch it in, so that the ops.testing.ActionFailed object is the + # one that we expect, even if people are mixing Harness and Scenario. + # https://github.com/canonical/ops-scenario/issues/201 + import scenario.context as _context + import scenario.runtime as _runtime + + _context.ActionFailed = ActionFailed # type: ignore[reportPrivateImportUsage] + _runtime.ActionFailed = ActionFailed # type: ignore[reportPrivateImportUsage] # Names exposed for backwards compatibility -_ = CharmBase -_ = CharmMeta -_ = Container -_ = ExecProcess -_ = RelationNotFoundError -_ = RelationRole -_ = charm -_ = framework -_ = model -_ = pebble -_ = storage +_compatibility_names = [ + 'CharmBase', + 'CharmMeta', + 'Container', # If Scenario has been installed, then this will be scenario.Container. + 'ExecProcess', + 'RelationNotFoundError', + 'RelationRole', + 'charm', + 'framework', + 'model', + 'pebble', + 'storage', +] +__all__.extend(_compatibility_names) # type: ignore[reportUnsupportedDunderAll] diff --git a/pyproject.toml b/pyproject.toml index 4bfa2d1db..867022fb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,15 @@ docs = [ "sphinx-notfound-page", "sphinx-tabs", "sphinxcontrib-jquery", - "sphinxext-opengraph" + "sphinxext-opengraph", + "ops-scenario>=7.0.5,<8", ] +testing = [ + "ops-scenario>=7.0.5,<8", +] +# Empty for now, because Harness is bundled with the base install, but allow +# specifying the extra to ease transition later. +harness = [] [project.urls] "Homepage" = "https://juju.is/docs/sdk" diff --git a/test/test_infra.py b/test/test_infra.py index 445bbce2d..47eaec39a 100644 --- a/test/test_infra.py +++ b/test/test_infra.py @@ -16,9 +16,12 @@ import pathlib import subprocess import sys +import typing import pytest +import ops.testing + @pytest.mark.parametrize( 'mod_name', @@ -45,3 +48,40 @@ def test_import(mod_name: str, tmp_path: pathlib.Path): proc = subprocess.run([sys.executable, testfile], env=environ) assert proc.returncode == 0 + + +@pytest.mark.skipif( + not hasattr(ops.testing, 'Context'), reason='requires optional ops[testing] install' +) +def test_ops_testing_doc(): + """Ensure that ops.testing's documentation includes all the expected names.""" + prefix = '.. autoclass:: ops.testing.' + # We don't document the type aliases. + expected_names = set( + name + for name in ops.testing.__all__ + if name != 'errors' + and name not in ops.testing._compatibility_names + and getattr(ops.testing, name).__class__.__module__ != 'typing' + ) + expected_names.update( + f'errors.{name}' for name in dir(ops.testing.errors) if not name.startswith('_') + ) + # ops.testing.UnitID is `int` - we don't document it, but it's hard to fit + # into the above logic, so we just exclude it here. + expected_names.discard('UnitID') + # ops.testing.Container is a documented class when Scenario is installed, + # but exported for compatibility when not, so we do want to have it present + # even though the above compatibility_names logic would exclude it. + expected_names.add('Container') + + found_names: typing.Set[str] = set() + for test_doc in ('docs/harness.rst', 'docs/state-transition-testing.rst'): + with open(test_doc) as testing_doc: + found_names.update({ + line.split(prefix, 1)[1].strip() + for line in testing_doc + if line.strip().startswith(prefix) + }) + + assert expected_names == found_names diff --git a/test/test_testing.py b/test/test_testing.py index 965ebc894..832142730 100644 --- a/test/test_testing.py +++ b/test/test_testing.py @@ -7025,3 +7025,12 @@ def test_get_cloud_spec_without_set_error(self, request: pytest.FixtureRequest): harness.begin() with pytest.raises(ops.ModelError): harness.model.get_cloud_spec() + + +@pytest.mark.skipif( + not hasattr(ops.testing, 'Context'), reason='requires optional ops[testing] install' +) +def test_scenario_available(): + ctx = ops.testing.Context(ops.CharmBase, meta={'name': 'foo'}) + state = ctx.run(ctx.on.start(), ops.testing.State()) + assert isinstance(state.unit_status, ops.testing.UnknownStatus) diff --git a/tox.ini b/tox.ini index cf097a12f..ca45096c6 100644 --- a/tox.ini +++ b/tox.ini @@ -80,6 +80,7 @@ deps = pyright==1.1.377 pytest~=7.2 typing_extensions~=4.2 + ops-scenario>=7.0.5,<8.0 commands = pyright {posargs} @@ -94,6 +95,7 @@ deps = pytest~=7.2 pytest-xdist~=3.6 typing_extensions~=4.2 + ops-scenario>=7.0.5,<8.0 commands = pytest -n auto --ignore={[vars]tst_path}smoke -v --tb native {posargs}