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

docs: expand the reference documentation #169

Merged
merged 18 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.

tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
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.

tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
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
Loading