Skip to content

Commit

Permalink
docs: expand the reference documentation (#169)
Browse files Browse the repository at this point in the history
Expands the docstrings so that when used with Sphinx's autodoc they will
fit in with the other ops docs on ops.rtd but also still work
standalone.

The Sphinx autodoc system uses the `__new__` signature in preference to
the `__init__` one, which means that by default all classes that are
using the `_MaxPositionalArgs` class have a `*args, **kwargs` signature,
which is not informative. custom_conf.py monkeypatches Sphinx to work
around this, including tweaking the signature so that the `*` appears in
the correct place for the maximum number of positional arguments.

Also bumps the Sphinx version to align with ops.

---------

Co-authored-by: PietroPasotti <[email protected]>
  • Loading branch information
tonyandrewmeyer and PietroPasotti committed Sep 2, 2024
1 parent 1da9684 commit 7f7c07d
Show file tree
Hide file tree
Showing 9 changed files with 491 additions and 188 deletions.
26 changes: 25 additions & 1 deletion docs/custom_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def _compute_navigation_tree(context):
r'''^ ([\w.]+::)? # explicit module name
([\w.]+\.)? # module and/or class name(s)
([^.()]+) \s* # thing name
(?: \[\s*(.*)\s*])? # optional: type parameters list, Sphinx 7&8
(?: \((.*)\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
)? $ # and nothing more
Expand Down Expand Up @@ -306,9 +307,32 @@ def _compute_navigation_tree(context):
# ('envvar', 'LD_LIBRARY_PATH').
nitpick_ignore = [
# Please keep this list sorted alphabetically.
('py:class', '_CharmSpec'),
('py:class', '_Event'),
('py:class', '_EntityStatus'),
('py:class', 'ModelError'), # This is in a copied docstring so we can't fix it.
('py:class', 'scenario.state._EntityStatus'),
('py:class', 'scenario.state._Event'),
('py:class', 'scenario.state._max_posargs.<locals>._MaxPositionalArgs'),
]

# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses
# of _MaxPositionalArgs.
import inspect
import sphinx.ext.autodoc

_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
18 changes: 5 additions & 13 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@

Scenario API reference
======================

.. toctree::
:maxdepth: 2
:caption: Contents:

scenario.State
==============

.. automodule:: scenario.state

scenario
========

scenario.Context
================
.. automodule:: scenario
:special-members: __call__

.. automodule:: scenario.context
.. automodule:: scenario.errors


Indices
Expand Down
100 changes: 60 additions & 40 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,98 +1,112 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml
#
alabaster==0.7.13
alabaster==1.0.0
# via sphinx
babel==2.14.0
anyio==4.4.0
# via
# starlette
# watchfiles
babel==2.16.0
# via sphinx
beautifulsoup4==4.12.3
# via
# canonical-sphinx-extensions
# furo
# pyspelling
bracex==2.4
bracex==2.5
# via wcmatch
canonical-sphinx-extensions==0.0.19
canonical-sphinx-extensions==0.0.23
# via ops-scenario (pyproject.toml)
certifi==2024.2.2
certifi==2024.7.4
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via uvicorn
colorama==0.4.6
# via sphinx-autobuild
docutils==0.19
docutils==0.21.2
# via
# canonical-sphinx-extensions
# myst-parser
# sphinx
# sphinx-tabs
furo==2024.1.29
furo==2024.8.6
# via ops-scenario (pyproject.toml)
gitdb==4.0.11
# via gitpython
gitpython==3.1.43
# via canonical-sphinx-extensions
h11==0.14.0
# via uvicorn
html5lib==1.1
# via pyspelling
idna==3.6
# via requests
idna==3.8
# via
# anyio
# requests
imagesize==1.4.1
# via sphinx
jinja2==3.1.3
jinja2==3.1.4
# via
# myst-parser
# sphinx
linkify-it-py==2.0.3
# via ops-scenario (pyproject.toml)
livereload==2.6.3
# via sphinx-autobuild
lxml==5.2.1
lxml==5.3.0
# via pyspelling
markdown==3.6
markdown==3.7
# via pyspelling
markdown-it-py==3.0.0
# via
# mdit-py-plugins
# myst-parser
markupsafe==2.1.5
# via jinja2
mdit-py-plugins==0.4.0
mdit-py-plugins==0.4.1
# via myst-parser
mdurl==0.1.2
# via markdown-it-py
myst-parser==2.0.0
myst-parser==4.0.0
# via ops-scenario (pyproject.toml)
ops==2.12.0
ops==2.15.0
# via ops-scenario (pyproject.toml)
packaging==24.0
packaging==24.1
# via sphinx
pygments==2.17.2
pygments==2.18.0
# via
# furo
# sphinx
# sphinx-tabs
pyspelling==2.10
# via ops-scenario (pyproject.toml)
pyyaml==6.0.1
pyyaml==6.0.2
# via
# myst-parser
# ops
# ops-scenario (pyproject.toml)
# pyspelling
requests==2.31.0
requests==2.32.3
# via
# canonical-sphinx-extensions
# sphinx
six==1.16.0
# via
# html5lib
# livereload
# via html5lib
smmap==5.0.1
# via gitdb
sniffio==1.3.1
# via anyio
snowballstemmer==2.2.0
# via sphinx
soupsieve==2.5
soupsieve==2.6
# via
# beautifulsoup4
# pyspelling
sphinx==6.2.1
sphinx==8.0.2
# via
# canonical-sphinx-extensions
# furo
Expand All @@ -106,43 +120,49 @@ sphinx==6.2.1
# sphinx-tabs
# sphinxcontrib-jquery
# sphinxext-opengraph
sphinx-autobuild==2024.2.4
sphinx-autobuild==2024.4.16
# via ops-scenario (pyproject.toml)
sphinx-basic-ng==1.0.0b2
# via furo
sphinx-copybutton==0.5.2
# via ops-scenario (pyproject.toml)
sphinx-design==0.5.0
sphinx-design==0.6.1
# via ops-scenario (pyproject.toml)
sphinx-notfound-page==1.0.0
sphinx-notfound-page==1.0.4
# via ops-scenario (pyproject.toml)
sphinx-tabs==3.4.5
# via ops-scenario (pyproject.toml)
sphinxcontrib-applehelp==1.0.4
sphinxcontrib-applehelp==2.0.0
# via sphinx
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-devhelp==2.0.0
# via sphinx
sphinxcontrib-htmlhelp==2.0.1
sphinxcontrib-htmlhelp==2.1.0
# via sphinx
sphinxcontrib-jquery==4.1
# via ops-scenario (pyproject.toml)
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==1.1.5
sphinxcontrib-serializinghtml==2.0.0
# via sphinx
sphinxext-opengraph==0.9.1
# via ops-scenario (pyproject.toml)
tornado==6.4
# via livereload
starlette==0.38.2
# via sphinx-autobuild
uc-micro-py==1.0.3
# via linkify-it-py
urllib3==2.2.1
urllib3==2.2.2
# via requests
wcmatch==8.5.1
uvicorn==0.30.6
# via sphinx-autobuild
watchfiles==0.24.0
# via sphinx-autobuild
wcmatch==9.0
# via pyspelling
webencodings==0.5.1
# via html5lib
websocket-client==1.7.0
websocket-client==1.8.0
# via ops
websockets==13.0.1
# via sphinx-autobuild
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ docs = [
"linkify-it-py",
"myst-parser",
"pyspelling",
"sphinx==6.2.1",
"sphinx ~= 8.0.0",
"sphinx-autobuild",
"sphinx-copybutton",
"sphinx-design",
Expand Down
59 changes: 58 additions & 1 deletion scenario/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,64 @@
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.

"""Charm state-transition testing SDK for Ops charms.
Write tests that declaratively 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.
These tests are 'state-transition' tests, a way to test isolated units of charm
functionality (how the state changes in reaction to events). They are not
necessarily tests of individual methods or functions (but might be, depending on
the charm's event observers); they are testing the 'contract' of the charm: given
a certain state, when a certain event happens, the charm should transition to a
certain (likely different) state. They do not test against a real Juju
controller and model, and focus on a single Juju unit, unlike integration tests.
For simplicity, we refer to them as 'unit' tests in the charm context.
Writing these tests should nudge you into thinking of a charm as a black-box
input->output function. The input is the union of an `Event` (why am I, charm,
being executed), a `State` (am I leader? what is my integration data? what is my
config?...) and the charm's execution `Context` (what integrations can I have?
what containers can I have?...). The output is another `State`: the state after
the charm has had a chance to interact with the mocked Juju model and affect the
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 charm does not raise uncaught exceptions while handling the event
- the output state (as compared with the input state) is as expected.
A test consists of three broad steps:
- **Arrange**:
- declare the context
- declare the input state
- **Act**:
- select an event to fire
- run the context (i.e. obtain the output state, given the input state and the event)
- **Assert**:
- verify that the output state (as compared with the input state) is how you expect it to be
- verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls
- optionally, you can use a context manager to get a hold of the charm instance and run
assertions on APIs and state internal to it.
The most basic scenario is one in which all is defaulted and barely any data is
available. The charm has no config, no integrations, no leadership, and its
status is `unknown`. With that, we can write the simplest possible test:
.. code-block:: python
def test_base():
ctx = Context(MyCharm)
out = ctx.run(ctx.on.start(), State())
assert out.unit_status == UnknownStatus()
"""

from scenario.context import Context, Manager
from scenario.state import (
ActionFailed,
Expand Down Expand Up @@ -87,5 +145,4 @@
"UnitID",
"UnknownStatus",
"WaitingStatus",
"deferred",
]
Loading

0 comments on commit 7f7c07d

Please sign in to comment.