diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69ddc2..c8513d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -39,18 +39,50 @@ jobs: permissions: contents: read steps: - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.11 - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Setup containers - uses: SSSD/sssd-ci-containers/actions/setup@master - with: - path: sssd-ci-containers - limit: dns client kdc + - name: Install dependencies + shell: bash + run: | + set -ex + sudo apt-get update + sudo apt-get install -y podman docker-compose + + - name: Workaround https://github.com/actions/runner-images/issues/7753 + shell: bash + run: | + curl -O http://archive.ubuntu.com/ubuntu/pool/universe/g/golang-github-containernetworking-plugins/containernetworking-plugins_1.1.1+ds1-3_amd64.deb + sudo dpkg -i containernetworking-plugins_1.1.1+ds1-3_amd64.deb + rm --force containernetworking-plugins_1.1.1+ds1-3_amd64.deb + + - name: Workaround https://github.com/containers/crun/issues/1308 + shell: bash + run: | + CRUN_VER='1.11.2' + sudo curl -L "https://github.com/containers/crun/releases/download/${CRUN_VER}/crun-${CRUN_VER}-linux-amd64" -o "/usr/bin/crun" + sudo chmod +x "/usr/bin/crun" + crun --version + + - name: Start podman socket + shell: bash + run: | + sudo systemctl enable podman.socket + sudo systemctl restart podman.socket + + - name: Build containers + working-directory: example/containers + run: | + sudo docker-compose -H unix:///run/podman/podman.sock build + + - name: Start containers + working-directory: example/containers + run: | + sudo docker-compose -H unix:///run/podman/podman.sock up --detach - name: Install packages run: | @@ -60,9 +92,11 @@ jobs: pip3 install virtualenv python3 -m venv .venv source .venv/bin/activate + pip3 install -e . pip3 install -r ./requirements.txt + pip3 install -r ./example/requirements.txt - name: Run example tests run: | source .venv/bin/activate - pytest --color=yes --mh-config=./example/mhc.yaml -vv ./example + pytest --color=yes --mh-config=./example/mhc.yaml -vvv ./example diff --git a/docs.bak/Makefile b/docs.bak/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs.bak/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs.bak/_build/doctrees/api.doctree b/docs.bak/_build/doctrees/api.doctree new file mode 100644 index 0000000..f3ee7d6 Binary files /dev/null and b/docs.bak/_build/doctrees/api.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.cli.doctree b/docs.bak/_build/doctrees/api/pytest_mh.cli.doctree new file mode 100644 index 0000000..4859baf Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.cli.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.doctree b/docs.bak/_build/doctrees/api/pytest_mh.doctree new file mode 100644 index 0000000..7e74b20 Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.ssh.doctree b/docs.bak/_build/doctrees/api/pytest_mh.ssh.doctree new file mode 100644 index 0000000..cf16991 Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.ssh.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.doctree new file mode 100644 index 0000000..006d7e3 Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.firewall.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.firewall.doctree new file mode 100644 index 0000000..046ac47 Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.firewall.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.fs.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.fs.doctree new file mode 100644 index 0000000..996100e Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.fs.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.journald.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.journald.doctree new file mode 100644 index 0000000..3d5949b Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.journald.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.services.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.services.doctree new file mode 100644 index 0000000..7d67d7b Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.services.doctree differ diff --git a/docs.bak/_build/doctrees/api/pytest_mh.utils.tc.doctree b/docs.bak/_build/doctrees/api/pytest_mh.utils.tc.doctree new file mode 100644 index 0000000..f03591a Binary files /dev/null and b/docs.bak/_build/doctrees/api/pytest_mh.utils.tc.doctree differ diff --git a/docs.bak/_build/doctrees/classes.doctree b/docs.bak/_build/doctrees/classes.doctree new file mode 100644 index 0000000..4657ceb Binary files /dev/null and b/docs.bak/_build/doctrees/classes.doctree differ diff --git a/docs.bak/_build/doctrees/config.doctree b/docs.bak/_build/doctrees/config.doctree new file mode 100644 index 0000000..f7740f4 Binary files /dev/null and b/docs.bak/_build/doctrees/config.doctree differ diff --git a/docs.bak/_build/doctrees/environment.pickle b/docs.bak/_build/doctrees/environment.pickle new file mode 100644 index 0000000..29be0b6 Binary files /dev/null and b/docs.bak/_build/doctrees/environment.pickle differ diff --git a/docs.bak/_build/doctrees/index.doctree b/docs.bak/_build/doctrees/index.doctree new file mode 100644 index 0000000..05f1519 Binary files /dev/null and b/docs.bak/_build/doctrees/index.doctree differ diff --git a/docs.bak/_build/doctrees/pytest.doctree b/docs.bak/_build/doctrees/pytest.doctree new file mode 100644 index 0000000..515beda Binary files /dev/null and b/docs.bak/_build/doctrees/pytest.doctree differ diff --git a/docs.bak/_build/doctrees/quick-start.doctree b/docs.bak/_build/doctrees/quick-start.doctree new file mode 100644 index 0000000..81b0fda Binary files /dev/null and b/docs.bak/_build/doctrees/quick-start.doctree differ diff --git a/docs.bak/_build/doctrees/runtime-requirements.doctree b/docs.bak/_build/doctrees/runtime-requirements.doctree new file mode 100644 index 0000000..ba4199d Binary files /dev/null and b/docs.bak/_build/doctrees/runtime-requirements.doctree differ diff --git a/docs.bak/_build/doctrees/topology.doctree b/docs.bak/_build/doctrees/topology.doctree new file mode 100644 index 0000000..1273960 Binary files /dev/null and b/docs.bak/_build/doctrees/topology.doctree differ diff --git a/docs.bak/_build/html/.buildinfo b/docs.bak/_build/html/.buildinfo new file mode 100644 index 0000000..fd827ac --- /dev/null +++ b/docs.bak/_build/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 2ee3f1b99120090e966b0df365817b0a +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs.bak/_build/html/_sources/api.rst.txt b/docs.bak/_build/html/_sources/api.rst.txt new file mode 100644 index 0000000..95621c1 --- /dev/null +++ b/docs.bak/_build/html/_sources/api.rst.txt @@ -0,0 +1,12 @@ +API Reference +============= + +.. autosummary:: + :toctree: api + :nosignatures: + :recursive: + + pytest_mh + pytest_mh.cli + pytest_mh.ssh + pytest_mh.utils diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.cli.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.cli.rst.txt new file mode 100644 index 0000000..5287dae --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.cli.rst.txt @@ -0,0 +1,41 @@ +pytest\_mh.cli +============== + +.. automodule:: pytest_mh.cli + + + + .. rubric:: Module Attributes + + .. autosummary:: + + CLIBuilderArgs + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Any + CLIBuilder + Enum + SSHClient + SSHPowerShellProcess + SSHProcess + auto + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.rst.txt new file mode 100644 index 0000000..72c4192 --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.rst.txt @@ -0,0 +1,53 @@ +pytest\_mh +========== + +.. automodule:: pytest_mh + + + + + + + + .. rubric:: Functions + + .. autosummary:: + + mh + pytest_addoption + pytest_configure + + + + + + .. rubric:: Classes + + .. autosummary:: + + KnownTopologyBase + KnownTopologyGroupBase + MultihostConfig + MultihostDomain + MultihostFixture + MultihostHost + MultihostHostArtifacts + MultihostItemData + MultihostOSFamily + MultihostPlugin + MultihostRole + MultihostTopologyControllerArtifacts + MultihostUtility + Topology + TopologyController + TopologyDomain + TopologyMark + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.ssh.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.ssh.rst.txt new file mode 100644 index 0000000..fc957ee --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.ssh.rst.txt @@ -0,0 +1,45 @@ +pytest\_mh.ssh +============== + +.. automodule:: pytest_mh.ssh + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Any + Enum + MultihostLogger + SSHBashProcess + SSHClient + SSHLog + SSHPowerShellProcess + SSHProcess + SSHProcessResult + auto + + + + + + .. rubric:: Exceptions + + .. autosummary:: + + SSHAuthenticationError + SSHProcessError + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.firewall.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.firewall.rst.txt new file mode 100644 index 0000000..76dafc6 --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.firewall.rst.txt @@ -0,0 +1,29 @@ +pytest\_mh.utils.firewall +========================= + +.. automodule:: pytest_mh.utils.firewall + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Firewalld + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.fs.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.fs.rst.txt new file mode 100644 index 0000000..e05a11a --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.fs.rst.txt @@ -0,0 +1,29 @@ +pytest\_mh.utils.fs +=================== + +.. automodule:: pytest_mh.utils.fs + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + LinuxFileSystem + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.journald.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.journald.rst.txt new file mode 100644 index 0000000..528886a --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.journald.rst.txt @@ -0,0 +1,29 @@ +pytest\_mh.utils.journald +========================= + +.. automodule:: pytest_mh.utils.journald + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + JournaldUtils + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.rst.txt new file mode 100644 index 0000000..0db126f --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.rst.txt @@ -0,0 +1,35 @@ +pytest\_mh.utils +================ + +.. automodule:: pytest_mh.utils + + + + + + + + + + + + + + + + + + + +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + pytest_mh.utils.firewall + pytest_mh.utils.fs + pytest_mh.utils.journald + pytest_mh.utils.services + pytest_mh.utils.tc + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.services.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.services.rst.txt new file mode 100644 index 0000000..6476136 --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.services.rst.txt @@ -0,0 +1,29 @@ +pytest\_mh.utils.services +========================= + +.. automodule:: pytest_mh.utils.services + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + SystemdServices + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/api/pytest_mh.utils.tc.rst.txt b/docs.bak/_build/html/_sources/api/pytest_mh.utils.tc.rst.txt new file mode 100644 index 0000000..85bcef9 --- /dev/null +++ b/docs.bak/_build/html/_sources/api/pytest_mh.utils.tc.rst.txt @@ -0,0 +1,29 @@ +pytest\_mh.utils.tc +=================== + +.. automodule:: pytest_mh.utils.tc + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + LinuxTrafficControl + + + + + + + + + diff --git a/docs.bak/_build/html/_sources/classes.rst.txt b/docs.bak/_build/html/_sources/classes.rst.txt new file mode 100644 index 0000000..a6081e4 --- /dev/null +++ b/docs.bak/_build/html/_sources/classes.rst.txt @@ -0,0 +1,341 @@ +Extending pytest-mh +################### + +There are five main classes that are used by the ``pytest-mh`` plugin that give +you access to remote hosts and provide you tools to build your own API that +fulfills specific requirements. + +By extending these classes, you can provide your own functionality and +configuration options. + +* :class:`~pytest_mh.MultihostConfig`: top level class that reads configuration and creates domain objects +* :class:`~pytest_mh.MultihostDomain`: creates host objects +* :class:`~pytest_mh.MultihostHost`: lives through the whole pytest session, gives low-level access to the host +* :class:`~pytest_mh.MultihostRole`: lives only for a single test case, provides high-level API +* :class:`~pytest_mh.MultihostUtility`: provides high-level API that can be shared between multiple roles +* :class:`~pytest_mh.TopologyController`: control topology behavior such as per-topology setup and teardown + +.. mermaid:: + :caption: Class relationship + :align: center + + graph LR + subgraph Lives for the whole pytest session + MultihostConfig -->|creates| MultihostDomain + MultihostDomain -->|creates| MultihostHost + end + + subgraph Lives only for single test case + mh(mh fixture) -->|creates| MultihostRole + MultihostRole -->|uses| MultihostHost + MultihostRole -->|creates| MultihostUtility + end + +In order to start using ``pytest-mh``, you must provide at least your +own::class:`~pytest_mh.MultihostConfig` to define what domain objects will be +created and :class:`~pytest_mh.MultihostDomain` to associate hosts and roles +with specific classes. It is recommended that you also extend the other classes +as well to provide high-level API for your tests. + +.. note:: + + :class:`~pytest_mh.MultihostHost`, :class:`~pytest_mh.MultihostRole` and + :class:`~pytest_mh.MultihostUtility` **have setup and teardown methods** + that you can use to properly initialize the host and also **to clean up** + after the test is finished. + + By extending these classes, you can give test writers a well-defined, + unified API that can automate several tasks and make sure the hosts are + properly setup before the test starts and all changes are correctly reverted + once the test is finished. + + This makes it easier to write new tests and ensure that the tests start + with a fresh setup every time. + +MultihostConfig +=============== + +:class:`~pytest_mh.MultihostConfig` is created by ``pytest-mh`` pytest plugin +during pytest session initialization. It reads the given multihost configuration +and creates the domain objects. + +You must provide your own class that extends :class:`~pytest_mh.MultihostConfig` +in order to use the plugin. Your class must override +:attr:`~pytest_mh.MultihostConfig.id_to_domain_class` which creates your own +:class:`~pytest_mh.MultihostDomain` object. + +Optionally, you can override +:attr:`~pytest_mh.MultihostConfig.TopologyMarkClass` and provide your own +:class:`~pytest_mh.TopologyMark` class. With this, you can provide additional +information to the topology marker as needed by your project. + +.. code-block:: python + + class ExampleMultihostConfig(MultihostConfig): + @property + def TopologyMarkClass(self) -> Type[TopologyMark]: + return ExampleTopologyMark + + @property + def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]: + """ + Map domain id to domain class. Asterisk ``*`` can be used as fallback + value. + + :rtype: Class name. + """ + return {"*": ExampleMultihostDomain} + +MultihostDomain +=============== + +:class:`~pytest_mh.MultihostDomain` is created by +:class:`~pytest_mh.MultihostConfig` and it allows you to associate roles from +your multihost configuration to your own hosts, roles, and Python classes to give +them meaning. + +.. code-block:: python + + class ExampleMultihostDomain(MultihostDomain[ExampleMultihostConfig]): + def __init__(self, config: ExampleMultihostConfig, confdict: dict[str, Any]) -> None: + super().__init__(config, confdict) + + @property + def role_to_host_class(self) -> dict[str, Type[MultihostHost]]: + """ + Map role to host class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + return { + "client": ClientHost, + "ldap": LDAPHost, + } + + @property + def role_to_role_class(self) -> dict[str, Type[MultihostRole]]: + """ + Map role to role class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + return { + "client": Client, + "ldap": LDAP, + } + +MultihostHost +============= + +One :class:`~pytest_mh.MultihostHost` object is created per each host defined in +your multihost configuration. Each host is created as an instance of a class +that is determined by the role to host mapping in +:meth:`~pytest_mh.MultihostDomain.role_to_host_class`. + +This object gives you access to a SSH connection to the remote host. The object +lives for the whole pytest session which makes it a good place to put +functionality and data that must be available across all tests. For example, it +can perform an initial backup of the host. + +It provides two setup and teardown methods: + +* :meth:`~pytest_mh.MultihostHost.pytest_setup` - called when pytest starts before execution of any test +* :meth:`~pytest_mh.MultihostHost.pytest_teardown` - called when pytest terminated after all tests are done +* :meth:`~pytest_mh.MultihostHost.setup` - called before execution of each test +* :meth:`~pytest_mh.MultihostHost.teardown` - called after a test is done + +.. seealso:: + + See `/example/lib/hosts/kdc.py + `__ + to see an example implementation of custom host. + +MultihostRole +============= + +Similar to :class:`~pytest_mh.MultihostHost`, one +:class:`~pytest_mh.MultihostRole` object is created per each host defined in +your multihost configuration. The difference between these two is that while +:class:`~pytest_mh.MultihostHost` lives for the whole pytest session, +:class:`~pytest_mh.MultihostRole` lives only for a single test run therefore the +role objects are not shared between tests. Role objects are also available to +you in your tests through pytest dynamic fixtures. + +The purpose of the :class:`~pytest_mh.MultihostRole` object is to provide high +level API for your project that you can use in your tests and to perform +per-test setup and clean up. For this purpose, it provides setup and teardown +methods that you can overwrite: + +* :meth:`~pytest_mh.MultihostRole.setup` - called before execution of each test +* :meth:`~pytest_mh.MultihostRole.teardown` - called after a test is done + +.. seealso:: + + See `/example/lib/roles/kdc.py + `__ + to see an example implementation of custom role. + +MultihostUtility +================ + +Role object can also contain instances of :class:`~pytest_mh.MultihostUtility` +that can be used to share functionality between individual roles. A +:meth:`~pytest_mh.MultihostUtility.setup` and +:meth:`~pytest_mh.MultihostUtility.teardown` methods are automatically called +after the role is setup and before the role teardown is executed. + +.. note:: + + :class:`~pytest_mh.MultihostUtility` also contains + :meth:`~pytest_mh.MultihostUtility.setup_when_used` which is called only + after the class is first used inside the test (after + :meth:`~pytest_mh.MultihostUtility.setup`) and + :meth:`~pytest_mh.MultihostUtility.teardown_when_used` which is called only + if the class was used (before :meth:`~pytest_mh.MultihostUtility.teardown`). + + This can be especially useful if the utility class is used only sporadically + but the setup and teardown are quite expensive. In such case, you probably + want to perform the setup and teardown only if the class was actually used + in the test. + +There are already some utility classes implemented in ``pytest-mh``. See +:mod:`pytest_mh.utils` for more information on them. + +.. seealso:: + + See `/pytest_mh/utils/fs.py + `__ + to see an implementation of a utility class that gives you access to files + and directories on the remote host. + + Each change that is made through the utility object (such as writing to a + file) is automatically reverted (the original file is restored). + +TopologyController +================== + +Topology controller can be assigned to a topology via `@pytest.mark.topology` +or through known topology class. This controller provides various methods to +control the topology behavior: + +* per-topology setup and teardown, called once before the first test/after the + last test for given topology is executed +* per-test topology setup and teardown, called before and after every test case + for given topology +* check topology requirements and skip the test if these are not satisfied + +In order to use the controller, you need to inherit from +:class:`~pytest_mh.TopologyController` and override desired methods. Each method +can take any parameter as defined by the topology fixtures. The parameter value +is an instance of a :class:`~pytest_mh.MultihostHost` object. + +See :class:`~pytest_mh.TopologyController` for API documentation + +.. code-block:: python + :caption: Example topology controller + + class ExampleController(TopologyController): + def skip(self, client: ClientHost) -> str | None: + result = client.ssh.run( + ''' + # Implement your requirement check here + exit 1 + ''', raise_on_error=False) + if result.rc != 0: + return "Topology requirements were not met" + + return None + + def topology_setup(self, client: ClientHost): + # One-time setup, prepare the host for this topology + # Changes done here are shared for all tests + pass + + def topology_teardown(self, client: ClientHost): + # One-time teardown, this should undo changes from + # topology_setup + pass + + def setup(self, client: ClientHost): + # Perform per-topology test setup + # This is called before execution of every test + pass + + def teardown(self, client: ClientHost): + # Perform per-topology test teardown, this should undo changes + # from setup + pass + +.. code-block:: python + :caption: Example with low-level topology mark + + class ExampleController(TopologyController): + # Implement methods you are interested in here + pass + + @pytest.mark.topology( + "example", Topology(TopologyDomain("example", client=1)), + controller=ExampleController(), + fixtures=dict(client="example.client[0]") + ) + def test_example(client: Client): + pass + +.. code-block:: python + :caption: Example with KnownTopology (recommended) + + class ExampleController(TopologyController): + # Implement methods you are interested in here + pass + + @final + @unique + class KnownTopology(KnownTopologyBase): + EXAMPLE = TopologyMark( + name='example', + topology=Topology(TopologyDomain("example", client=1)), + controller=ExampleController(), + fixtures=dict(client='example.client[0]'), + ) + + @pytest.mark.topology(KnownTopology.EXAMPLE) + def test_example(client: Client): + pass + +.. _setup-and-teardown: + +Setup and teardown +================== + +The following schema shows how individual setup and teardown methods of host, +role, and utility objects are executed. + +.. mermaid:: + :caption: Setup and teardown + :align: center + + graph TD + s([start]) --> hps(host.pytest_setup) + + subgraph run [ ] + subgraph setup [Setup before test] + hs(host.setup) --> cs(controller.setup) --> rs[role.setup] + rs --> us[utility.setup] + end + + setup -->|run test| teardown + + subgraph teardown [Teardown after test] + ut[utility.teadown] --> rt[role.teardown] + rt --> ct(controller.teardown) + ct --> ht(host.teardown) + end + end + + hps -->|run tests| cts(controller.topopology_setup) -->|run all tests for topology| run + run -->|all tests for topology finished| ctt(controller.topology_teardown) -->|all tests finished| hpt(host.pytest_teardown) + hpt --> e([end]) + + style run fill:#FFF + style setup fill:#DFD,stroke-width:2px,stroke:#AFA + style teardown fill:#FDD,stroke-width:2px,stroke:#FAA diff --git a/docs.bak/_build/html/_sources/config.rst.txt b/docs.bak/_build/html/_sources/config.rst.txt new file mode 100644 index 0000000..bc8bf91 --- /dev/null +++ b/docs.bak/_build/html/_sources/config.rst.txt @@ -0,0 +1,162 @@ +Multihost configuration +####################### + +The multihost configuration file contains definition of the domains, hosts, and +their roles that are available to run the tests. It uses the `YAML +`__ language. + +Basic definition +**************** + +.. code-block:: yaml + + domains: + - id: + hosts: + - hostname: + role: + os: + family: (optional, defaults to "linux") + ssh: + host: (optional, defaults to host name) + port: (optional, defaults to 22) + username: (optional, defaults to "root") + password: (optional, defaults to "Secret123") + config: (optional, defaults to {}) + artifacts: (optional, defaults to {}) + +The top level element of the configuration is a list of ``domains``. Each domain +has ``id`` attribute and defines the list of available hosts. + +* ``id``: domain identifier which is used in the path inside ``mh`` fixture, see :ref:`mh-fixture` +* ``hosts``: list of available hosts and their roles + + * ``hostname``: DNS host name, may not necessarily be resolvable from the machine that runs pytest + * ``role``: host role + * ``os.family``: host operating system family, defaults to "linux", see :class:`~pytest_mh.MultihostHostOSFamily` + * ``ssh.host``: ssh host to connect to (may be a resolvable host name or an + IP address), defaults to the value of ``hostname`` + * ``ssh.port``: ssh port, defaults to 22 + * ``ssh.username``: ssh username, defaults to ``root`` + * ``ssh.password``: ssh password for the user, defaults to ``Secret123`` + * ``config``: additional configuration, place for custom options, see :ref:`custom-config` + * ``artifacts``: list of artifacts that are automatically downloaded, see :ref:`gathering-artifacts` + +.. code-block:: yaml + :caption: Sample configuration file + + domains: + - id: test + hosts: + - hostname: client.test + role: client + ssh: + host: 192.168.100.10 + user: root + password: MySecret123 + artifacts: + - /etc/sssd/* + - /var/log/sssd/* + - /var/lib/sss/db/* + + - hostname: master.ldap.test + role: ldap + config: + binddn: cn=Directory Manager + bindpw: Secret123 + +.. _custom-config: + +Customize configuration +======================= + +The ``config`` section of the host configuration can be used to extend the +configuration with custom options that are required by your project. If the +field is not set, it defaults to an empty dictionary ``dict()``. + +To make a new configuration option available, simply inherit from +:class:`~pytest_mh.MultihostHost` and access the option through +:attr:`~pytest_mh.MultihostHost.config` (``self.config``). + +.. code-block:: python + :caption: Adding custom configuration options + + class LDAPHost(MultihostHost[MyDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.binddn: str = self.config.get("binddn", "cn=Directory Manager") + """Bind DN ``config.binddn``, defaults to ``cn=Directory Manager``""" + + self.bindpw: str = self.config.get("bindpw", "Secret123") + """Bind password ``config.bindpw``, defaults to ``Secret123``""" + +The example above adds two new options ``binddn`` and ``bindpw``. Since the +options provide default values, they are optional. You can set them in +the multihost configuration in the ``config`` field. + +.. code-block:: yaml + + domains: + - id: test + hosts: + - hostname: client.test + role: client + ssh: + host: 192.168.100.10 + user: root + password: MySecret123 + + - hostname: master.ldap.test + role: ldap + config: + binddn: cn=Directory Manager + bindpw: Secret123 + +.. _gathering-artifacts: + +Gathering artifacts +=================== + +The ``artifacts`` field of the host definition can be used to specify which +artifacts should be automatically collected from the host when a test is +finished. The field contains a list of artifacts. The values are path to the +artifacts with a possible wildcard character. For example: + +.. code-block:: yaml + + - hostname: client.test + role: client + ssh: + host: 192.168.100.10 + user: root + password: MySecret123 + config: + artifacts: + - /etc/sssd/* + - /var/log/sssd/* + - /var/lib/sss/db/* + +It is also possible to gather artifacts from +:meth:`pytest_mh.MultihostHost.pytest_setup` and +:meth:`pytest_mh.MultihostHost.pytest_teardown` calls. To do that, you need to +provide artifacts as dictionary with ``pytest_setup``, ``pytest_teardown`` and +``test`` keys. + + +.. code-block:: yaml + + - hostname: client.test + role: client + ssh: + host: 192.168.100.10 + user: root + password: MySecret123 + config: + artifacts: + pytest_setup: + - /var/log/host_setup.log + pytest_teardown: + - /var/log/host_teardown.log + test: + - /var/log/testrun.log diff --git a/docs.bak/_build/html/_sources/index.rst.txt b/docs.bak/_build/html/_sources/index.rst.txt new file mode 100644 index 0000000..2255271 --- /dev/null +++ b/docs.bak/_build/html/_sources/index.rst.txt @@ -0,0 +1,107 @@ +pytest_mh - pytest multihost test framework +########################################### + +.. warning:: + + This plugin is still actively developed and even though it is mostly stable, + we reserve the right to introduce minor breaking changes if it is required for + new functionality. + +``pytest-mh`` is a pytest plugin that, at a basic level, allows you to run shell +commands and scripts over SSH on remote Linux or Windows hosts. You use it to +execute system or application tests for your project on a remote host or hosts +(or containers) while running pytest locally keeping your local machine intact. + +The plugin also provides building blocks that can be used to setup and teardown +your tests, perform automatic clean up of all changes done on the remote host, +and build a flexible and unified high-level API to manipulate the hosts from +your tests. + +.. code-block:: python + :caption: Example test taken from SSSD demo + + @pytest.mark.topology(KnownTopology.AD) + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.topology(KnownTopology.IPA) + @pytest.mark.topology(KnownTopology.Samba) + def test__id(client: Client, provider: GenericProvider): + u = provider.user("tuser").add() + provider.group("tgroup_1").add().add_member(u) + provider.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + +.. seealso:: + + A real life example of how ``pytest-mh`` can help test your code can be + seen in the `SSSD + `__ project. + +When do I want use the framework? +********************************* + +* **Does your program affect the host in any way?** If yes, it is safer to run it in + virtual machine or in a container to avoid affecting your local host. + ``pytest-mh`` takes care of that. +* **Does your program use client-server model?** If yes, it is better to run the + client and the server on separate machines to make the tests more real. + ``pytest-mh`` takes care of that. +* **Does your program communicate with multiple backends?** If yes, you need to + be able to assign each test to a specific backend and also be able to reuse a + single test for multiple backends. ``pytest-mh`` takes care of that. +* **Do you need complex tests that changes state of the system, file system or + other programs or databases?** If yes, you need to make sure that all changes + are reverted when a test is done so the test does not affect other tests. + ``pytest-mh`` takes care of that. +* Does your program **talk to LDAP/IPA/AD/Samba/Kerberos**? If yes, ``pytest-mh`` + can help you with that. +* **Do you use** `pytest-multihost + `__ **framework for your current + tests?** ``pytest-mh`` is a full Python 3 re-implementation of the old + ``pytest-multihost`` plugin. It builds on all its features and takes it to + a whole new level. You definitely want to switch to ``pytest-mh``, + however it is not backwards compatible. + +When I don't want to use it? +**************************** + +* Do you want to test your Python code? Then this plugin will not help + you. It is designed for running system or applications tests, i.e. testing + your application as a whole. + +What does the framework do? +*************************** + +* Allows you to **run commands over SSH on remote hosts** (or virtual machines or + containers) using bash or Powershell. +* Allows you to **define your own roles with a provide fully typed API** to your + tests that fulfills all your needs. +* All **changes that you do on the remote host during a single test can be + completely reverted** so they do not affect other tests. +* Defines an available **multihost topology** - what roles are available in your + current setup. +* **Associates each test with certain topology** - defines what roles are + required to run the test. +* Supports **topology parametrization** - a single test can run on multiple + topologies. +* **Run only tests that can be run on available topology**. +* Provides **access to roles through dynamic pytest fixtures**. +* **The code is fully typed** - you get rich suggestions from your editor and the + types can be fully checked. +* **Everything can be extended**. + +.. toctree:: + :maxdepth: 2 + + quick-start + config + topology + classes + runtime-requirements + pytest + api diff --git a/docs/pytest.rst b/docs.bak/_build/html/_sources/pytest.rst.txt similarity index 100% rename from docs/pytest.rst rename to docs.bak/_build/html/_sources/pytest.rst.txt diff --git a/docs/quick-start.rst b/docs.bak/_build/html/_sources/quick-start.rst.txt similarity index 100% rename from docs/quick-start.rst rename to docs.bak/_build/html/_sources/quick-start.rst.txt diff --git a/docs/runtime-requirements.rst b/docs.bak/_build/html/_sources/runtime-requirements.rst.txt similarity index 100% rename from docs/runtime-requirements.rst rename to docs.bak/_build/html/_sources/runtime-requirements.rst.txt diff --git a/docs/topology.rst b/docs.bak/_build/html/_sources/topology.rst.txt similarity index 100% rename from docs/topology.rst rename to docs.bak/_build/html/_sources/topology.rst.txt diff --git a/docs.bak/_build/html/_sphinx_design_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css b/docs.bak/_build/html/_sphinx_design_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css new file mode 100644 index 0000000..eb19f69 --- /dev/null +++ b/docs.bak/_build/html/_sphinx_design_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css @@ -0,0 +1 @@ +.sd-bg-primary{background-color:var(--sd-color-primary) !important}.sd-bg-text-primary{color:var(--sd-color-primary-text) !important}button.sd-bg-primary:focus,button.sd-bg-primary:hover{background-color:var(--sd-color-primary-highlight) !important}a.sd-bg-primary:focus,a.sd-bg-primary:hover{background-color:var(--sd-color-primary-highlight) !important}.sd-bg-secondary{background-color:var(--sd-color-secondary) !important}.sd-bg-text-secondary{color:var(--sd-color-secondary-text) !important}button.sd-bg-secondary:focus,button.sd-bg-secondary:hover{background-color:var(--sd-color-secondary-highlight) !important}a.sd-bg-secondary:focus,a.sd-bg-secondary:hover{background-color:var(--sd-color-secondary-highlight) !important}.sd-bg-success{background-color:var(--sd-color-success) !important}.sd-bg-text-success{color:var(--sd-color-success-text) !important}button.sd-bg-success:focus,button.sd-bg-success:hover{background-color:var(--sd-color-success-highlight) !important}a.sd-bg-success:focus,a.sd-bg-success:hover{background-color:var(--sd-color-success-highlight) !important}.sd-bg-info{background-color:var(--sd-color-info) !important}.sd-bg-text-info{color:var(--sd-color-info-text) !important}button.sd-bg-info:focus,button.sd-bg-info:hover{background-color:var(--sd-color-info-highlight) !important}a.sd-bg-info:focus,a.sd-bg-info:hover{background-color:var(--sd-color-info-highlight) !important}.sd-bg-warning{background-color:var(--sd-color-warning) !important}.sd-bg-text-warning{color:var(--sd-color-warning-text) !important}button.sd-bg-warning:focus,button.sd-bg-warning:hover{background-color:var(--sd-color-warning-highlight) !important}a.sd-bg-warning:focus,a.sd-bg-warning:hover{background-color:var(--sd-color-warning-highlight) !important}.sd-bg-danger{background-color:var(--sd-color-danger) !important}.sd-bg-text-danger{color:var(--sd-color-danger-text) !important}button.sd-bg-danger:focus,button.sd-bg-danger:hover{background-color:var(--sd-color-danger-highlight) !important}a.sd-bg-danger:focus,a.sd-bg-danger:hover{background-color:var(--sd-color-danger-highlight) !important}.sd-bg-light{background-color:var(--sd-color-light) !important}.sd-bg-text-light{color:var(--sd-color-light-text) !important}button.sd-bg-light:focus,button.sd-bg-light:hover{background-color:var(--sd-color-light-highlight) !important}a.sd-bg-light:focus,a.sd-bg-light:hover{background-color:var(--sd-color-light-highlight) !important}.sd-bg-muted{background-color:var(--sd-color-muted) !important}.sd-bg-text-muted{color:var(--sd-color-muted-text) !important}button.sd-bg-muted:focus,button.sd-bg-muted:hover{background-color:var(--sd-color-muted-highlight) !important}a.sd-bg-muted:focus,a.sd-bg-muted:hover{background-color:var(--sd-color-muted-highlight) !important}.sd-bg-dark{background-color:var(--sd-color-dark) !important}.sd-bg-text-dark{color:var(--sd-color-dark-text) !important}button.sd-bg-dark:focus,button.sd-bg-dark:hover{background-color:var(--sd-color-dark-highlight) !important}a.sd-bg-dark:focus,a.sd-bg-dark:hover{background-color:var(--sd-color-dark-highlight) !important}.sd-bg-black{background-color:var(--sd-color-black) !important}.sd-bg-text-black{color:var(--sd-color-black-text) !important}button.sd-bg-black:focus,button.sd-bg-black:hover{background-color:var(--sd-color-black-highlight) !important}a.sd-bg-black:focus,a.sd-bg-black:hover{background-color:var(--sd-color-black-highlight) !important}.sd-bg-white{background-color:var(--sd-color-white) !important}.sd-bg-text-white{color:var(--sd-color-white-text) !important}button.sd-bg-white:focus,button.sd-bg-white:hover{background-color:var(--sd-color-white-highlight) !important}a.sd-bg-white:focus,a.sd-bg-white:hover{background-color:var(--sd-color-white-highlight) !important}.sd-text-primary,.sd-text-primary>p{color:var(--sd-color-primary) !important}a.sd-text-primary:focus,a.sd-text-primary:hover{color:var(--sd-color-primary-highlight) !important}.sd-text-secondary,.sd-text-secondary>p{color:var(--sd-color-secondary) !important}a.sd-text-secondary:focus,a.sd-text-secondary:hover{color:var(--sd-color-secondary-highlight) !important}.sd-text-success,.sd-text-success>p{color:var(--sd-color-success) !important}a.sd-text-success:focus,a.sd-text-success:hover{color:var(--sd-color-success-highlight) !important}.sd-text-info,.sd-text-info>p{color:var(--sd-color-info) !important}a.sd-text-info:focus,a.sd-text-info:hover{color:var(--sd-color-info-highlight) !important}.sd-text-warning,.sd-text-warning>p{color:var(--sd-color-warning) !important}a.sd-text-warning:focus,a.sd-text-warning:hover{color:var(--sd-color-warning-highlight) !important}.sd-text-danger,.sd-text-danger>p{color:var(--sd-color-danger) !important}a.sd-text-danger:focus,a.sd-text-danger:hover{color:var(--sd-color-danger-highlight) !important}.sd-text-light,.sd-text-light>p{color:var(--sd-color-light) !important}a.sd-text-light:focus,a.sd-text-light:hover{color:var(--sd-color-light-highlight) !important}.sd-text-muted,.sd-text-muted>p{color:var(--sd-color-muted) !important}a.sd-text-muted:focus,a.sd-text-muted:hover{color:var(--sd-color-muted-highlight) !important}.sd-text-dark,.sd-text-dark>p{color:var(--sd-color-dark) !important}a.sd-text-dark:focus,a.sd-text-dark:hover{color:var(--sd-color-dark-highlight) !important}.sd-text-black,.sd-text-black>p{color:var(--sd-color-black) !important}a.sd-text-black:focus,a.sd-text-black:hover{color:var(--sd-color-black-highlight) !important}.sd-text-white,.sd-text-white>p{color:var(--sd-color-white) !important}a.sd-text-white:focus,a.sd-text-white:hover{color:var(--sd-color-white-highlight) !important}.sd-outline-primary{border-color:var(--sd-color-primary) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-primary:focus,a.sd-outline-primary:hover{border-color:var(--sd-color-primary-highlight) !important}.sd-outline-secondary{border-color:var(--sd-color-secondary) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-secondary:focus,a.sd-outline-secondary:hover{border-color:var(--sd-color-secondary-highlight) !important}.sd-outline-success{border-color:var(--sd-color-success) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-success:focus,a.sd-outline-success:hover{border-color:var(--sd-color-success-highlight) !important}.sd-outline-info{border-color:var(--sd-color-info) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-info:focus,a.sd-outline-info:hover{border-color:var(--sd-color-info-highlight) !important}.sd-outline-warning{border-color:var(--sd-color-warning) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-warning:focus,a.sd-outline-warning:hover{border-color:var(--sd-color-warning-highlight) !important}.sd-outline-danger{border-color:var(--sd-color-danger) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-danger:focus,a.sd-outline-danger:hover{border-color:var(--sd-color-danger-highlight) !important}.sd-outline-light{border-color:var(--sd-color-light) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-light:focus,a.sd-outline-light:hover{border-color:var(--sd-color-light-highlight) !important}.sd-outline-muted{border-color:var(--sd-color-muted) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-muted:focus,a.sd-outline-muted:hover{border-color:var(--sd-color-muted-highlight) !important}.sd-outline-dark{border-color:var(--sd-color-dark) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-dark:focus,a.sd-outline-dark:hover{border-color:var(--sd-color-dark-highlight) !important}.sd-outline-black{border-color:var(--sd-color-black) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-black:focus,a.sd-outline-black:hover{border-color:var(--sd-color-black-highlight) !important}.sd-outline-white{border-color:var(--sd-color-white) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-white:focus,a.sd-outline-white:hover{border-color:var(--sd-color-white-highlight) !important}.sd-bg-transparent{background-color:transparent !important}.sd-outline-transparent{border-color:transparent !important}.sd-text-transparent{color:transparent !important}.sd-p-0{padding:0 !important}.sd-pt-0,.sd-py-0{padding-top:0 !important}.sd-pr-0,.sd-px-0{padding-right:0 !important}.sd-pb-0,.sd-py-0{padding-bottom:0 !important}.sd-pl-0,.sd-px-0{padding-left:0 !important}.sd-p-1{padding:.25rem !important}.sd-pt-1,.sd-py-1{padding-top:.25rem !important}.sd-pr-1,.sd-px-1{padding-right:.25rem !important}.sd-pb-1,.sd-py-1{padding-bottom:.25rem !important}.sd-pl-1,.sd-px-1{padding-left:.25rem !important}.sd-p-2{padding:.5rem !important}.sd-pt-2,.sd-py-2{padding-top:.5rem !important}.sd-pr-2,.sd-px-2{padding-right:.5rem !important}.sd-pb-2,.sd-py-2{padding-bottom:.5rem !important}.sd-pl-2,.sd-px-2{padding-left:.5rem !important}.sd-p-3{padding:1rem !important}.sd-pt-3,.sd-py-3{padding-top:1rem !important}.sd-pr-3,.sd-px-3{padding-right:1rem !important}.sd-pb-3,.sd-py-3{padding-bottom:1rem !important}.sd-pl-3,.sd-px-3{padding-left:1rem !important}.sd-p-4{padding:1.5rem !important}.sd-pt-4,.sd-py-4{padding-top:1.5rem !important}.sd-pr-4,.sd-px-4{padding-right:1.5rem !important}.sd-pb-4,.sd-py-4{padding-bottom:1.5rem !important}.sd-pl-4,.sd-px-4{padding-left:1.5rem !important}.sd-p-5{padding:3rem !important}.sd-pt-5,.sd-py-5{padding-top:3rem !important}.sd-pr-5,.sd-px-5{padding-right:3rem !important}.sd-pb-5,.sd-py-5{padding-bottom:3rem !important}.sd-pl-5,.sd-px-5{padding-left:3rem !important}.sd-m-auto{margin:auto !important}.sd-mt-auto,.sd-my-auto{margin-top:auto !important}.sd-mr-auto,.sd-mx-auto{margin-right:auto !important}.sd-mb-auto,.sd-my-auto{margin-bottom:auto !important}.sd-ml-auto,.sd-mx-auto{margin-left:auto !important}.sd-m-0{margin:0 !important}.sd-mt-0,.sd-my-0{margin-top:0 !important}.sd-mr-0,.sd-mx-0{margin-right:0 !important}.sd-mb-0,.sd-my-0{margin-bottom:0 !important}.sd-ml-0,.sd-mx-0{margin-left:0 !important}.sd-m-1{margin:.25rem !important}.sd-mt-1,.sd-my-1{margin-top:.25rem !important}.sd-mr-1,.sd-mx-1{margin-right:.25rem !important}.sd-mb-1,.sd-my-1{margin-bottom:.25rem !important}.sd-ml-1,.sd-mx-1{margin-left:.25rem !important}.sd-m-2{margin:.5rem !important}.sd-mt-2,.sd-my-2{margin-top:.5rem !important}.sd-mr-2,.sd-mx-2{margin-right:.5rem !important}.sd-mb-2,.sd-my-2{margin-bottom:.5rem !important}.sd-ml-2,.sd-mx-2{margin-left:.5rem !important}.sd-m-3{margin:1rem !important}.sd-mt-3,.sd-my-3{margin-top:1rem !important}.sd-mr-3,.sd-mx-3{margin-right:1rem !important}.sd-mb-3,.sd-my-3{margin-bottom:1rem !important}.sd-ml-3,.sd-mx-3{margin-left:1rem !important}.sd-m-4{margin:1.5rem !important}.sd-mt-4,.sd-my-4{margin-top:1.5rem !important}.sd-mr-4,.sd-mx-4{margin-right:1.5rem !important}.sd-mb-4,.sd-my-4{margin-bottom:1.5rem !important}.sd-ml-4,.sd-mx-4{margin-left:1.5rem !important}.sd-m-5{margin:3rem !important}.sd-mt-5,.sd-my-5{margin-top:3rem !important}.sd-mr-5,.sd-mx-5{margin-right:3rem !important}.sd-mb-5,.sd-my-5{margin-bottom:3rem !important}.sd-ml-5,.sd-mx-5{margin-left:3rem !important}.sd-w-25{width:25% !important}.sd-w-50{width:50% !important}.sd-w-75{width:75% !important}.sd-w-100{width:100% !important}.sd-w-auto{width:auto !important}.sd-h-25{height:25% !important}.sd-h-50{height:50% !important}.sd-h-75{height:75% !important}.sd-h-100{height:100% !important}.sd-h-auto{height:auto !important}.sd-d-none{display:none !important}.sd-d-inline{display:inline !important}.sd-d-inline-block{display:inline-block !important}.sd-d-block{display:block !important}.sd-d-grid{display:grid !important}.sd-d-flex-row{display:-ms-flexbox !important;display:flex !important;flex-direction:row !important}.sd-d-flex-column{display:-ms-flexbox !important;display:flex !important;flex-direction:column !important}.sd-d-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}@media(min-width: 576px){.sd-d-sm-none{display:none !important}.sd-d-sm-inline{display:inline !important}.sd-d-sm-inline-block{display:inline-block !important}.sd-d-sm-block{display:block !important}.sd-d-sm-grid{display:grid !important}.sd-d-sm-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-sm-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 768px){.sd-d-md-none{display:none !important}.sd-d-md-inline{display:inline !important}.sd-d-md-inline-block{display:inline-block !important}.sd-d-md-block{display:block !important}.sd-d-md-grid{display:grid !important}.sd-d-md-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-md-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 992px){.sd-d-lg-none{display:none !important}.sd-d-lg-inline{display:inline !important}.sd-d-lg-inline-block{display:inline-block !important}.sd-d-lg-block{display:block !important}.sd-d-lg-grid{display:grid !important}.sd-d-lg-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-lg-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 1200px){.sd-d-xl-none{display:none !important}.sd-d-xl-inline{display:inline !important}.sd-d-xl-inline-block{display:inline-block !important}.sd-d-xl-block{display:block !important}.sd-d-xl-grid{display:grid !important}.sd-d-xl-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-xl-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}.sd-align-major-start{justify-content:flex-start !important}.sd-align-major-end{justify-content:flex-end !important}.sd-align-major-center{justify-content:center !important}.sd-align-major-justify{justify-content:space-between !important}.sd-align-major-spaced{justify-content:space-evenly !important}.sd-align-minor-start{align-items:flex-start !important}.sd-align-minor-end{align-items:flex-end !important}.sd-align-minor-center{align-items:center !important}.sd-align-minor-stretch{align-items:stretch !important}.sd-text-justify{text-align:justify !important}.sd-text-left{text-align:left !important}.sd-text-right{text-align:right !important}.sd-text-center{text-align:center !important}.sd-font-weight-light{font-weight:300 !important}.sd-font-weight-lighter{font-weight:lighter !important}.sd-font-weight-normal{font-weight:400 !important}.sd-font-weight-bold{font-weight:700 !important}.sd-font-weight-bolder{font-weight:bolder !important}.sd-font-italic{font-style:italic !important}.sd-text-decoration-none{text-decoration:none !important}.sd-text-lowercase{text-transform:lowercase !important}.sd-text-uppercase{text-transform:uppercase !important}.sd-text-capitalize{text-transform:capitalize !important}.sd-text-wrap{white-space:normal !important}.sd-text-nowrap{white-space:nowrap !important}.sd-text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sd-fs-1,.sd-fs-1>p{font-size:calc(1.375rem + 1.5vw) !important;line-height:unset !important}.sd-fs-2,.sd-fs-2>p{font-size:calc(1.325rem + 0.9vw) !important;line-height:unset !important}.sd-fs-3,.sd-fs-3>p{font-size:calc(1.3rem + 0.6vw) !important;line-height:unset !important}.sd-fs-4,.sd-fs-4>p{font-size:calc(1.275rem + 0.3vw) !important;line-height:unset !important}.sd-fs-5,.sd-fs-5>p{font-size:1.25rem !important;line-height:unset !important}.sd-fs-6,.sd-fs-6>p{font-size:1rem !important;line-height:unset !important}.sd-border-0{border:0 solid !important}.sd-border-top-0{border-top:0 solid !important}.sd-border-bottom-0{border-bottom:0 solid !important}.sd-border-right-0{border-right:0 solid !important}.sd-border-left-0{border-left:0 solid !important}.sd-border-1{border:1px solid !important}.sd-border-top-1{border-top:1px solid !important}.sd-border-bottom-1{border-bottom:1px solid !important}.sd-border-right-1{border-right:1px solid !important}.sd-border-left-1{border-left:1px solid !important}.sd-border-2{border:2px solid !important}.sd-border-top-2{border-top:2px solid !important}.sd-border-bottom-2{border-bottom:2px solid !important}.sd-border-right-2{border-right:2px solid !important}.sd-border-left-2{border-left:2px solid !important}.sd-border-3{border:3px solid !important}.sd-border-top-3{border-top:3px solid !important}.sd-border-bottom-3{border-bottom:3px solid !important}.sd-border-right-3{border-right:3px solid !important}.sd-border-left-3{border-left:3px solid !important}.sd-border-4{border:4px solid !important}.sd-border-top-4{border-top:4px solid !important}.sd-border-bottom-4{border-bottom:4px solid !important}.sd-border-right-4{border-right:4px solid !important}.sd-border-left-4{border-left:4px solid !important}.sd-border-5{border:5px solid !important}.sd-border-top-5{border-top:5px solid !important}.sd-border-bottom-5{border-bottom:5px solid !important}.sd-border-right-5{border-right:5px solid !important}.sd-border-left-5{border-left:5px solid !important}.sd-rounded-0{border-radius:0 !important}.sd-rounded-1{border-radius:.2rem !important}.sd-rounded-2{border-radius:.3rem !important}.sd-rounded-3{border-radius:.5rem !important}.sd-rounded-pill{border-radius:50rem !important}.sd-rounded-circle{border-radius:50% !important}.shadow-none{box-shadow:none !important}.sd-shadow-sm{box-shadow:0 .125rem .25rem var(--sd-color-shadow) !important}.sd-shadow-md{box-shadow:0 .5rem 1rem var(--sd-color-shadow) !important}.sd-shadow-lg{box-shadow:0 1rem 3rem var(--sd-color-shadow) !important}@keyframes sd-slide-from-left{0%{transform:translateX(-100%)}100%{transform:translateX(0)}}@keyframes sd-slide-from-right{0%{transform:translateX(200%)}100%{transform:translateX(0)}}@keyframes sd-grow100{0%{transform:scale(0);opacity:.5}100%{transform:scale(1);opacity:1}}@keyframes sd-grow50{0%{transform:scale(0.5);opacity:.5}100%{transform:scale(1);opacity:1}}@keyframes sd-grow50-rot20{0%{transform:scale(0.5) rotateZ(-20deg);opacity:.5}75%{transform:scale(1) rotateZ(5deg);opacity:1}95%{transform:scale(1) rotateZ(-1deg);opacity:1}100%{transform:scale(1) rotateZ(0);opacity:1}}.sd-animate-slide-from-left{animation:1s ease-out 0s 1 normal none running sd-slide-from-left}.sd-animate-slide-from-right{animation:1s ease-out 0s 1 normal none running sd-slide-from-right}.sd-animate-grow100{animation:1s ease-out 0s 1 normal none running sd-grow100}.sd-animate-grow50{animation:1s ease-out 0s 1 normal none running sd-grow50}.sd-animate-grow50-rot20{animation:1s ease-out 0s 1 normal none running sd-grow50-rot20}.sd-badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.sd-badge:empty{display:none}a.sd-badge{text-decoration:none}.sd-btn .sd-badge{position:relative;top:-1px}.sd-btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;cursor:pointer;display:inline-block;font-weight:400;font-size:1rem;line-height:1.5;padding:.375rem .75rem;text-align:center;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:middle;user-select:none;-moz-user-select:none;-ms-user-select:none;-webkit-user-select:none}.sd-btn:hover{text-decoration:none}@media(prefers-reduced-motion: reduce){.sd-btn{transition:none}}.sd-btn-primary,.sd-btn-outline-primary:hover,.sd-btn-outline-primary:focus{color:var(--sd-color-primary-text) !important;background-color:var(--sd-color-primary) !important;border-color:var(--sd-color-primary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-primary:hover,.sd-btn-primary:focus{color:var(--sd-color-primary-text) !important;background-color:var(--sd-color-primary-highlight) !important;border-color:var(--sd-color-primary-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-primary{color:var(--sd-color-primary) !important;border-color:var(--sd-color-primary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-secondary,.sd-btn-outline-secondary:hover,.sd-btn-outline-secondary:focus{color:var(--sd-color-secondary-text) !important;background-color:var(--sd-color-secondary) !important;border-color:var(--sd-color-secondary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-secondary:hover,.sd-btn-secondary:focus{color:var(--sd-color-secondary-text) !important;background-color:var(--sd-color-secondary-highlight) !important;border-color:var(--sd-color-secondary-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-secondary{color:var(--sd-color-secondary) !important;border-color:var(--sd-color-secondary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-success,.sd-btn-outline-success:hover,.sd-btn-outline-success:focus{color:var(--sd-color-success-text) !important;background-color:var(--sd-color-success) !important;border-color:var(--sd-color-success) !important;border-width:1px !important;border-style:solid !important}.sd-btn-success:hover,.sd-btn-success:focus{color:var(--sd-color-success-text) !important;background-color:var(--sd-color-success-highlight) !important;border-color:var(--sd-color-success-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-success{color:var(--sd-color-success) !important;border-color:var(--sd-color-success) !important;border-width:1px !important;border-style:solid !important}.sd-btn-info,.sd-btn-outline-info:hover,.sd-btn-outline-info:focus{color:var(--sd-color-info-text) !important;background-color:var(--sd-color-info) !important;border-color:var(--sd-color-info) !important;border-width:1px !important;border-style:solid !important}.sd-btn-info:hover,.sd-btn-info:focus{color:var(--sd-color-info-text) !important;background-color:var(--sd-color-info-highlight) !important;border-color:var(--sd-color-info-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-info{color:var(--sd-color-info) !important;border-color:var(--sd-color-info) !important;border-width:1px !important;border-style:solid !important}.sd-btn-warning,.sd-btn-outline-warning:hover,.sd-btn-outline-warning:focus{color:var(--sd-color-warning-text) !important;background-color:var(--sd-color-warning) !important;border-color:var(--sd-color-warning) !important;border-width:1px !important;border-style:solid !important}.sd-btn-warning:hover,.sd-btn-warning:focus{color:var(--sd-color-warning-text) !important;background-color:var(--sd-color-warning-highlight) !important;border-color:var(--sd-color-warning-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-warning{color:var(--sd-color-warning) !important;border-color:var(--sd-color-warning) !important;border-width:1px !important;border-style:solid !important}.sd-btn-danger,.sd-btn-outline-danger:hover,.sd-btn-outline-danger:focus{color:var(--sd-color-danger-text) !important;background-color:var(--sd-color-danger) !important;border-color:var(--sd-color-danger) !important;border-width:1px !important;border-style:solid !important}.sd-btn-danger:hover,.sd-btn-danger:focus{color:var(--sd-color-danger-text) !important;background-color:var(--sd-color-danger-highlight) !important;border-color:var(--sd-color-danger-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-danger{color:var(--sd-color-danger) !important;border-color:var(--sd-color-danger) !important;border-width:1px !important;border-style:solid !important}.sd-btn-light,.sd-btn-outline-light:hover,.sd-btn-outline-light:focus{color:var(--sd-color-light-text) !important;background-color:var(--sd-color-light) !important;border-color:var(--sd-color-light) !important;border-width:1px !important;border-style:solid !important}.sd-btn-light:hover,.sd-btn-light:focus{color:var(--sd-color-light-text) !important;background-color:var(--sd-color-light-highlight) !important;border-color:var(--sd-color-light-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-light{color:var(--sd-color-light) !important;border-color:var(--sd-color-light) !important;border-width:1px !important;border-style:solid !important}.sd-btn-muted,.sd-btn-outline-muted:hover,.sd-btn-outline-muted:focus{color:var(--sd-color-muted-text) !important;background-color:var(--sd-color-muted) !important;border-color:var(--sd-color-muted) !important;border-width:1px !important;border-style:solid !important}.sd-btn-muted:hover,.sd-btn-muted:focus{color:var(--sd-color-muted-text) !important;background-color:var(--sd-color-muted-highlight) !important;border-color:var(--sd-color-muted-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-muted{color:var(--sd-color-muted) !important;border-color:var(--sd-color-muted) !important;border-width:1px !important;border-style:solid !important}.sd-btn-dark,.sd-btn-outline-dark:hover,.sd-btn-outline-dark:focus{color:var(--sd-color-dark-text) !important;background-color:var(--sd-color-dark) !important;border-color:var(--sd-color-dark) !important;border-width:1px !important;border-style:solid !important}.sd-btn-dark:hover,.sd-btn-dark:focus{color:var(--sd-color-dark-text) !important;background-color:var(--sd-color-dark-highlight) !important;border-color:var(--sd-color-dark-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-dark{color:var(--sd-color-dark) !important;border-color:var(--sd-color-dark) !important;border-width:1px !important;border-style:solid !important}.sd-btn-black,.sd-btn-outline-black:hover,.sd-btn-outline-black:focus{color:var(--sd-color-black-text) !important;background-color:var(--sd-color-black) !important;border-color:var(--sd-color-black) !important;border-width:1px !important;border-style:solid !important}.sd-btn-black:hover,.sd-btn-black:focus{color:var(--sd-color-black-text) !important;background-color:var(--sd-color-black-highlight) !important;border-color:var(--sd-color-black-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-black{color:var(--sd-color-black) !important;border-color:var(--sd-color-black) !important;border-width:1px !important;border-style:solid !important}.sd-btn-white,.sd-btn-outline-white:hover,.sd-btn-outline-white:focus{color:var(--sd-color-white-text) !important;background-color:var(--sd-color-white) !important;border-color:var(--sd-color-white) !important;border-width:1px !important;border-style:solid !important}.sd-btn-white:hover,.sd-btn-white:focus{color:var(--sd-color-white-text) !important;background-color:var(--sd-color-white-highlight) !important;border-color:var(--sd-color-white-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-white{color:var(--sd-color-white) !important;border-color:var(--sd-color-white) !important;border-width:1px !important;border-style:solid !important}.sd-stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.sd-hide-link-text{font-size:0}.sd-octicon,.sd-material-icon{display:inline-block;fill:currentColor;vertical-align:middle}.sd-avatar-xs{border-radius:50%;object-fit:cover;object-position:center;width:1rem;height:1rem}.sd-avatar-sm{border-radius:50%;object-fit:cover;object-position:center;width:3rem;height:3rem}.sd-avatar-md{border-radius:50%;object-fit:cover;object-position:center;width:5rem;height:5rem}.sd-avatar-lg{border-radius:50%;object-fit:cover;object-position:center;width:7rem;height:7rem}.sd-avatar-xl{border-radius:50%;object-fit:cover;object-position:center;width:10rem;height:10rem}.sd-avatar-inherit{border-radius:50%;object-fit:cover;object-position:center;width:inherit;height:inherit}.sd-avatar-initial{border-radius:50%;object-fit:cover;object-position:center;width:initial;height:initial}.sd-card{background-clip:border-box;background-color:var(--sd-color-card-background);border:1px solid var(--sd-color-card-border);border-radius:.25rem;color:var(--sd-color-card-text);display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;position:relative;word-wrap:break-word}.sd-card>hr{margin-left:0;margin-right:0}.sd-card-hover:hover{border-color:var(--sd-color-card-border-hover);transform:scale(1.01)}.sd-card-body{-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem 1rem}.sd-card-title{margin-bottom:.5rem}.sd-card-subtitle{margin-top:-0.25rem;margin-bottom:0}.sd-card-text:last-child{margin-bottom:0}.sd-card-link:hover{text-decoration:none}.sd-card-link+.card-link{margin-left:1rem}.sd-card-header{padding:.5rem 1rem;margin-bottom:0;background-color:var(--sd-color-card-header);border-bottom:1px solid var(--sd-color-card-border)}.sd-card-header:first-child{border-radius:calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0}.sd-card-footer{padding:.5rem 1rem;background-color:var(--sd-color-card-footer);border-top:1px solid var(--sd-color-card-border)}.sd-card-footer:last-child{border-radius:0 0 calc(0.25rem - 1px) calc(0.25rem - 1px)}.sd-card-header-tabs{margin-right:-0.5rem;margin-bottom:-0.5rem;margin-left:-0.5rem;border-bottom:0}.sd-card-header-pills{margin-right:-0.5rem;margin-left:-0.5rem}.sd-card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(0.25rem - 1px)}.sd-card-img,.sd-card-img-bottom,.sd-card-img-top{width:100%}.sd-card-img,.sd-card-img-top{border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.sd-card-img,.sd-card-img-bottom{border-bottom-left-radius:calc(0.25rem - 1px);border-bottom-right-radius:calc(0.25rem - 1px)}.sd-cards-carousel{width:100%;display:flex;flex-wrap:nowrap;-ms-flex-direction:row;flex-direction:row;overflow-x:hidden;scroll-snap-type:x mandatory}.sd-cards-carousel.sd-show-scrollbar{overflow-x:auto}.sd-cards-carousel:hover,.sd-cards-carousel:focus{overflow-x:auto}.sd-cards-carousel>.sd-card{flex-shrink:0;scroll-snap-align:start}.sd-cards-carousel>.sd-card:not(:last-child){margin-right:3px}.sd-card-cols-1>.sd-card{width:90%}.sd-card-cols-2>.sd-card{width:45%}.sd-card-cols-3>.sd-card{width:30%}.sd-card-cols-4>.sd-card{width:22.5%}.sd-card-cols-5>.sd-card{width:18%}.sd-card-cols-6>.sd-card{width:15%}.sd-card-cols-7>.sd-card{width:12.8571428571%}.sd-card-cols-8>.sd-card{width:11.25%}.sd-card-cols-9>.sd-card{width:10%}.sd-card-cols-10>.sd-card{width:9%}.sd-card-cols-11>.sd-card{width:8.1818181818%}.sd-card-cols-12>.sd-card{width:7.5%}.sd-container,.sd-container-fluid,.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container-xl{margin-left:auto;margin-right:auto;padding-left:var(--sd-gutter-x, 0.75rem);padding-right:var(--sd-gutter-x, 0.75rem);width:100%}@media(min-width: 576px){.sd-container-sm,.sd-container{max-width:540px}}@media(min-width: 768px){.sd-container-md,.sd-container-sm,.sd-container{max-width:720px}}@media(min-width: 992px){.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container{max-width:960px}}@media(min-width: 1200px){.sd-container-xl,.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container{max-width:1140px}}.sd-row{--sd-gutter-x: 1.5rem;--sd-gutter-y: 0;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-top:calc(var(--sd-gutter-y) * -1);margin-right:calc(var(--sd-gutter-x) * -0.5);margin-left:calc(var(--sd-gutter-x) * -0.5)}.sd-row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--sd-gutter-x) * 0.5);padding-left:calc(var(--sd-gutter-x) * 0.5);margin-top:var(--sd-gutter-y)}.sd-col{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-auto>*{flex:0 0 auto;width:auto}.sd-row-cols-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}@media(min-width: 576px){.sd-col-sm{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-sm-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-sm-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-sm-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-sm-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-sm-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-sm-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-sm-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-sm-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-sm-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-sm-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-sm-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-sm-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-sm-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 768px){.sd-col-md{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-md-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-md-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-md-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-md-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-md-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-md-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-md-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-md-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-md-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-md-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-md-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-md-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-md-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 992px){.sd-col-lg{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-lg-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-lg-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-lg-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-lg-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-lg-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-lg-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-lg-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-lg-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-lg-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-lg-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-lg-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-lg-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-lg-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 1200px){.sd-col-xl{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-xl-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-xl-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-xl-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-xl-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-xl-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-xl-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-xl-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-xl-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-xl-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-xl-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-xl-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-xl-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-xl-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}.sd-col-auto{flex:0 0 auto;-ms-flex:0 0 auto;width:auto}.sd-col-1{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}.sd-col-2{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-col-3{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-col-4{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-col-5{flex:0 0 auto;-ms-flex:0 0 auto;width:41.6666666667%}.sd-col-6{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-col-7{flex:0 0 auto;-ms-flex:0 0 auto;width:58.3333333333%}.sd-col-8{flex:0 0 auto;-ms-flex:0 0 auto;width:66.6666666667%}.sd-col-9{flex:0 0 auto;-ms-flex:0 0 auto;width:75%}.sd-col-10{flex:0 0 auto;-ms-flex:0 0 auto;width:83.3333333333%}.sd-col-11{flex:0 0 auto;-ms-flex:0 0 auto;width:91.6666666667%}.sd-col-12{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-g-0,.sd-gy-0{--sd-gutter-y: 0}.sd-g-0,.sd-gx-0{--sd-gutter-x: 0}.sd-g-1,.sd-gy-1{--sd-gutter-y: 0.25rem}.sd-g-1,.sd-gx-1{--sd-gutter-x: 0.25rem}.sd-g-2,.sd-gy-2{--sd-gutter-y: 0.5rem}.sd-g-2,.sd-gx-2{--sd-gutter-x: 0.5rem}.sd-g-3,.sd-gy-3{--sd-gutter-y: 1rem}.sd-g-3,.sd-gx-3{--sd-gutter-x: 1rem}.sd-g-4,.sd-gy-4{--sd-gutter-y: 1.5rem}.sd-g-4,.sd-gx-4{--sd-gutter-x: 1.5rem}.sd-g-5,.sd-gy-5{--sd-gutter-y: 3rem}.sd-g-5,.sd-gx-5{--sd-gutter-x: 3rem}@media(min-width: 576px){.sd-col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-sm-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-sm-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-sm-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-sm-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-sm-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-sm-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-sm-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-sm-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-sm-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-sm-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-sm-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-sm-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-sm-0,.sd-gy-sm-0{--sd-gutter-y: 0}.sd-g-sm-0,.sd-gx-sm-0{--sd-gutter-x: 0}.sd-g-sm-1,.sd-gy-sm-1{--sd-gutter-y: 0.25rem}.sd-g-sm-1,.sd-gx-sm-1{--sd-gutter-x: 0.25rem}.sd-g-sm-2,.sd-gy-sm-2{--sd-gutter-y: 0.5rem}.sd-g-sm-2,.sd-gx-sm-2{--sd-gutter-x: 0.5rem}.sd-g-sm-3,.sd-gy-sm-3{--sd-gutter-y: 1rem}.sd-g-sm-3,.sd-gx-sm-3{--sd-gutter-x: 1rem}.sd-g-sm-4,.sd-gy-sm-4{--sd-gutter-y: 1.5rem}.sd-g-sm-4,.sd-gx-sm-4{--sd-gutter-x: 1.5rem}.sd-g-sm-5,.sd-gy-sm-5{--sd-gutter-y: 3rem}.sd-g-sm-5,.sd-gx-sm-5{--sd-gutter-x: 3rem}}@media(min-width: 768px){.sd-col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-md-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-md-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-md-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-md-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-md-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-md-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-md-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-md-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-md-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-md-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-md-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-md-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-md-0,.sd-gy-md-0{--sd-gutter-y: 0}.sd-g-md-0,.sd-gx-md-0{--sd-gutter-x: 0}.sd-g-md-1,.sd-gy-md-1{--sd-gutter-y: 0.25rem}.sd-g-md-1,.sd-gx-md-1{--sd-gutter-x: 0.25rem}.sd-g-md-2,.sd-gy-md-2{--sd-gutter-y: 0.5rem}.sd-g-md-2,.sd-gx-md-2{--sd-gutter-x: 0.5rem}.sd-g-md-3,.sd-gy-md-3{--sd-gutter-y: 1rem}.sd-g-md-3,.sd-gx-md-3{--sd-gutter-x: 1rem}.sd-g-md-4,.sd-gy-md-4{--sd-gutter-y: 1.5rem}.sd-g-md-4,.sd-gx-md-4{--sd-gutter-x: 1.5rem}.sd-g-md-5,.sd-gy-md-5{--sd-gutter-y: 3rem}.sd-g-md-5,.sd-gx-md-5{--sd-gutter-x: 3rem}}@media(min-width: 992px){.sd-col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-lg-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-lg-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-lg-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-lg-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-lg-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-lg-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-lg-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-lg-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-lg-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-lg-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-lg-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-lg-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-lg-0,.sd-gy-lg-0{--sd-gutter-y: 0}.sd-g-lg-0,.sd-gx-lg-0{--sd-gutter-x: 0}.sd-g-lg-1,.sd-gy-lg-1{--sd-gutter-y: 0.25rem}.sd-g-lg-1,.sd-gx-lg-1{--sd-gutter-x: 0.25rem}.sd-g-lg-2,.sd-gy-lg-2{--sd-gutter-y: 0.5rem}.sd-g-lg-2,.sd-gx-lg-2{--sd-gutter-x: 0.5rem}.sd-g-lg-3,.sd-gy-lg-3{--sd-gutter-y: 1rem}.sd-g-lg-3,.sd-gx-lg-3{--sd-gutter-x: 1rem}.sd-g-lg-4,.sd-gy-lg-4{--sd-gutter-y: 1.5rem}.sd-g-lg-4,.sd-gx-lg-4{--sd-gutter-x: 1.5rem}.sd-g-lg-5,.sd-gy-lg-5{--sd-gutter-y: 3rem}.sd-g-lg-5,.sd-gx-lg-5{--sd-gutter-x: 3rem}}@media(min-width: 1200px){.sd-col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-xl-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-xl-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-xl-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-xl-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-xl-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-xl-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-xl-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-xl-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-xl-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-xl-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-xl-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-xl-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-xl-0,.sd-gy-xl-0{--sd-gutter-y: 0}.sd-g-xl-0,.sd-gx-xl-0{--sd-gutter-x: 0}.sd-g-xl-1,.sd-gy-xl-1{--sd-gutter-y: 0.25rem}.sd-g-xl-1,.sd-gx-xl-1{--sd-gutter-x: 0.25rem}.sd-g-xl-2,.sd-gy-xl-2{--sd-gutter-y: 0.5rem}.sd-g-xl-2,.sd-gx-xl-2{--sd-gutter-x: 0.5rem}.sd-g-xl-3,.sd-gy-xl-3{--sd-gutter-y: 1rem}.sd-g-xl-3,.sd-gx-xl-3{--sd-gutter-x: 1rem}.sd-g-xl-4,.sd-gy-xl-4{--sd-gutter-y: 1.5rem}.sd-g-xl-4,.sd-gx-xl-4{--sd-gutter-x: 1.5rem}.sd-g-xl-5,.sd-gy-xl-5{--sd-gutter-y: 3rem}.sd-g-xl-5,.sd-gx-xl-5{--sd-gutter-x: 3rem}}.sd-flex-row-reverse{flex-direction:row-reverse !important}details.sd-dropdown{position:relative}details.sd-dropdown .sd-summary-title{font-weight:700;padding-right:3em !important;-moz-user-select:none;-ms-user-select:none;-webkit-user-select:none;user-select:none}details.sd-dropdown:hover{cursor:pointer}details.sd-dropdown .sd-summary-content{cursor:default}details.sd-dropdown summary{list-style:none;padding:1em}details.sd-dropdown summary .sd-octicon.no-title{vertical-align:middle}details.sd-dropdown[open] summary .sd-octicon.no-title{visibility:hidden}details.sd-dropdown summary::-webkit-details-marker{display:none}details.sd-dropdown summary:focus{outline:none}details.sd-dropdown .sd-summary-icon{margin-right:.5em}details.sd-dropdown .sd-summary-icon svg{opacity:.8}details.sd-dropdown summary:hover .sd-summary-up svg,details.sd-dropdown summary:hover .sd-summary-down svg{opacity:1;transform:scale(1.1)}details.sd-dropdown .sd-summary-up svg,details.sd-dropdown .sd-summary-down svg{display:block;opacity:.6}details.sd-dropdown .sd-summary-up,details.sd-dropdown .sd-summary-down{pointer-events:none;position:absolute;right:1em;top:1em}details.sd-dropdown[open]>.sd-summary-title .sd-summary-down{visibility:hidden}details.sd-dropdown:not([open])>.sd-summary-title .sd-summary-up{visibility:hidden}details.sd-dropdown:not([open]).sd-card{border:none}details.sd-dropdown:not([open])>.sd-card-header{border:1px solid var(--sd-color-card-border);border-radius:.25rem}details.sd-dropdown.sd-fade-in[open] summary~*{-moz-animation:sd-fade-in .5s ease-in-out;-webkit-animation:sd-fade-in .5s ease-in-out;animation:sd-fade-in .5s ease-in-out}details.sd-dropdown.sd-fade-in-slide-down[open] summary~*{-moz-animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out;-webkit-animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out;animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out}.sd-col>.sd-dropdown{width:100%}.sd-summary-content>.sd-tab-set:first-child{margin-top:0}@keyframes sd-fade-in{0%{opacity:0}100%{opacity:1}}@keyframes sd-slide-down{0%{transform:translate(0, -10px)}100%{transform:translate(0, 0)}}.sd-tab-set{border-radius:.125rem;display:flex;flex-wrap:wrap;margin:1em 0;position:relative}.sd-tab-set>input{opacity:0;position:absolute}.sd-tab-set>input:checked+label{border-color:var(--sd-color-tabs-underline-active);color:var(--sd-color-tabs-label-active)}.sd-tab-set>input:checked+label+.sd-tab-content{display:block}.sd-tab-set>input:not(:checked)+label:hover{color:var(--sd-color-tabs-label-hover);border-color:var(--sd-color-tabs-underline-hover)}.sd-tab-set>input:focus+label{outline-style:auto}.sd-tab-set>input:not(.focus-visible)+label{outline:none;-webkit-tap-highlight-color:transparent}.sd-tab-set>label{border-bottom:.125rem solid transparent;margin-bottom:0;color:var(--sd-color-tabs-label-inactive);border-color:var(--sd-color-tabs-underline-inactive);cursor:pointer;font-size:var(--sd-fontsize-tabs-label);font-weight:700;padding:1em 1.25em .5em;transition:color 250ms;width:auto;z-index:1}html .sd-tab-set>label:hover{color:var(--sd-color-tabs-label-active)}.sd-col>.sd-tab-set{width:100%}.sd-tab-content{box-shadow:0 -0.0625rem var(--sd-color-tabs-overline),0 .0625rem var(--sd-color-tabs-underline);display:none;order:99;padding-bottom:.75rem;padding-top:.75rem;width:100%}.sd-tab-content>:first-child{margin-top:0 !important}.sd-tab-content>:last-child{margin-bottom:0 !important}.sd-tab-content>.sd-tab-set{margin:0}.sd-sphinx-override,.sd-sphinx-override *{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}.sd-sphinx-override p{margin-top:0}:root{--sd-color-primary: #0071bc;--sd-color-secondary: #6c757d;--sd-color-success: #28a745;--sd-color-info: #17a2b8;--sd-color-warning: #f0b37e;--sd-color-danger: #dc3545;--sd-color-light: #f8f9fa;--sd-color-muted: #6c757d;--sd-color-dark: #212529;--sd-color-black: black;--sd-color-white: white;--sd-color-primary-highlight: #0060a0;--sd-color-secondary-highlight: #5c636a;--sd-color-success-highlight: #228e3b;--sd-color-info-highlight: #148a9c;--sd-color-warning-highlight: #cc986b;--sd-color-danger-highlight: #bb2d3b;--sd-color-light-highlight: #d3d4d5;--sd-color-muted-highlight: #5c636a;--sd-color-dark-highlight: #1c1f23;--sd-color-black-highlight: black;--sd-color-white-highlight: #d9d9d9;--sd-color-primary-text: #fff;--sd-color-secondary-text: #fff;--sd-color-success-text: #fff;--sd-color-info-text: #fff;--sd-color-warning-text: #212529;--sd-color-danger-text: #fff;--sd-color-light-text: #212529;--sd-color-muted-text: #fff;--sd-color-dark-text: #fff;--sd-color-black-text: #fff;--sd-color-white-text: #212529;--sd-color-shadow: rgba(0, 0, 0, 0.15);--sd-color-card-border: rgba(0, 0, 0, 0.125);--sd-color-card-border-hover: hsla(231, 99%, 66%, 1);--sd-color-card-background: transparent;--sd-color-card-text: inherit;--sd-color-card-header: transparent;--sd-color-card-footer: transparent;--sd-color-tabs-label-active: hsla(231, 99%, 66%, 1);--sd-color-tabs-label-hover: hsla(231, 99%, 66%, 1);--sd-color-tabs-label-inactive: hsl(0, 0%, 66%);--sd-color-tabs-underline-active: hsla(231, 99%, 66%, 1);--sd-color-tabs-underline-hover: rgba(178, 206, 245, 0.62);--sd-color-tabs-underline-inactive: transparent;--sd-color-tabs-overline: rgb(222, 222, 222);--sd-color-tabs-underline: rgb(222, 222, 222);--sd-fontsize-tabs-label: 1rem} diff --git a/docs.bak/_build/html/_sphinx_design_static/design-tabs.js b/docs.bak/_build/html/_sphinx_design_static/design-tabs.js new file mode 100644 index 0000000..36b38cf --- /dev/null +++ b/docs.bak/_build/html/_sphinx_design_static/design-tabs.js @@ -0,0 +1,27 @@ +var sd_labels_by_text = {}; + +function ready() { + const li = document.getElementsByClassName("sd-tab-label"); + for (const label of li) { + syncId = label.getAttribute("data-sync-id"); + if (syncId) { + label.onclick = onLabelClick; + if (!sd_labels_by_text[syncId]) { + sd_labels_by_text[syncId] = []; + } + sd_labels_by_text[syncId].push(label); + } + } +} + +function onLabelClick() { + // Activate other inputs with the same sync id. + syncId = this.getAttribute("data-sync-id"); + for (label of sd_labels_by_text[syncId]) { + if (label === this) continue; + label.previousElementSibling.checked = true; + } + window.localStorage.setItem("sphinx-design-last-tab", syncId); +} + +document.addEventListener("DOMContentLoaded", ready, false); diff --git a/docs.bak/_build/html/_static/_sphinx_javascript_frameworks_compat.js b/docs.bak/_build/html/_static/_sphinx_javascript_frameworks_compat.js new file mode 100644 index 0000000..8141580 --- /dev/null +++ b/docs.bak/_build/html/_static/_sphinx_javascript_frameworks_compat.js @@ -0,0 +1,123 @@ +/* Compatability shim for jQuery and underscores.js. + * + * Copyright Sphinx contributors + * Released under the two clause BSD licence + */ + +/** + * small helper function to urldecode strings + * + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#Decoding_query_parameters_from_a_URL + */ +jQuery.urldecode = function(x) { + if (!x) { + return x + } + return decodeURIComponent(x.replace(/\+/g, ' ')); +}; + +/** + * small helper function to urlencode strings + */ +jQuery.urlencode = encodeURIComponent; + +/** + * This function returns the parsed url parameters of the + * current request. Multiple values per key are supported, + * it will always return arrays of strings for the value parts. + */ +jQuery.getQueryParameters = function(s) { + if (typeof s === 'undefined') + s = document.location.search; + var parts = s.substr(s.indexOf('?') + 1).split('&'); + var result = {}; + for (var i = 0; i < parts.length; i++) { + var tmp = parts[i].split('=', 2); + var key = jQuery.urldecode(tmp[0]); + var value = jQuery.urldecode(tmp[1]); + if (key in result) + result[key].push(value); + else + result[key] = [value]; + } + return result; +}; + +/** + * highlight a given string on a jquery object by wrapping it in + * span elements with the given class name. + */ +jQuery.fn.highlightText = function(text, className) { + function highlight(node, addItems) { + if (node.nodeType === 3) { + var val = node.nodeValue; + var pos = val.toLowerCase().indexOf(text); + if (pos >= 0 && + !jQuery(node.parentNode).hasClass(className) && + !jQuery(node.parentNode).hasClass("nohighlight")) { + var span; + var isInSVG = jQuery(node).closest("body, svg, foreignObject").is("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.className = className; + } + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + node.parentNode.insertBefore(span, node.parentNode.insertBefore( + document.createTextNode(val.substr(pos + text.length)), + node.nextSibling)); + node.nodeValue = val.substr(0, pos); + if (isInSVG) { + var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + var bbox = node.parentElement.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute('class', className); + addItems.push({ + "parent": node.parentNode, + "target": rect}); + } + } + } + else if (!jQuery(node).is("button, select, textarea")) { + jQuery.each(node.childNodes, function() { + highlight(this, addItems); + }); + } + } + var addItems = []; + var result = this.each(function() { + highlight(this, addItems); + }); + for (var i = 0; i < addItems.length; ++i) { + jQuery(addItems[i].parent).before(addItems[i].target); + } + return result; +}; + +/* + * backward compatibility for jQuery.browser + * This will be supported until firefox bug is fixed. + */ +if (!jQuery.browser) { + jQuery.uaMatch = function(ua) { + ua = ua.toLowerCase(); + + var match = /(chrome)[ \/]([\w.]+)/.exec(ua) || + /(webkit)[ \/]([\w.]+)/.exec(ua) || + /(opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) || + /(msie) ([\w.]+)/.exec(ua) || + ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) || + []; + + return { + browser: match[ 1 ] || "", + version: match[ 2 ] || "0" + }; + }; + jQuery.browser = {}; + jQuery.browser[jQuery.uaMatch(navigator.userAgent).browser] = true; +} diff --git a/docs.bak/_build/html/_static/basic.css b/docs.bak/_build/html/_static/basic.css new file mode 100644 index 0000000..30fee9d --- /dev/null +++ b/docs.bak/_build/html/_static/basic.css @@ -0,0 +1,925 @@ +/* + * basic.css + * ~~~~~~~~~ + * + * Sphinx stylesheet -- basic theme. + * + * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ + +/* -- main layout ----------------------------------------------------------- */ + +div.clearer { + clear: both; +} + +div.section::after { + display: block; + content: ''; + clear: left; +} + +/* -- relbar ---------------------------------------------------------------- */ + +div.related { + width: 100%; + font-size: 90%; +} + +div.related h3 { + display: none; +} + +div.related ul { + margin: 0; + padding: 0 0 0 10px; + list-style: none; +} + +div.related li { + display: inline; +} + +div.related li.right { + float: right; + margin-right: 5px; +} + +/* -- sidebar --------------------------------------------------------------- */ + +div.sphinxsidebarwrapper { + padding: 10px 5px 0 10px; +} + +div.sphinxsidebar { + float: left; + width: 230px; + margin-left: -100%; + font-size: 90%; + word-wrap: break-word; + overflow-wrap : break-word; +} + +div.sphinxsidebar ul { + list-style: none; +} + +div.sphinxsidebar ul ul, +div.sphinxsidebar ul.want-points { + margin-left: 20px; + list-style: square; +} + +div.sphinxsidebar ul ul { + margin-top: 0; + margin-bottom: 0; +} + +div.sphinxsidebar form { + margin-top: 10px; +} + +div.sphinxsidebar input { + border: 1px solid #98dbcc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar #searchbox form.search { + overflow: hidden; +} + +div.sphinxsidebar #searchbox input[type="text"] { + float: left; + width: 80%; + padding: 0.25em; + box-sizing: border-box; +} + +div.sphinxsidebar #searchbox input[type="submit"] { + float: left; + width: 20%; + border-left: none; + padding: 0.25em; + box-sizing: border-box; +} + + +img { + border: 0; + max-width: 100%; +} + +/* -- search page ----------------------------------------------------------- */ + +ul.search { + margin: 10px 0 0 20px; + padding: 0; +} + +ul.search li { + padding: 5px 0 5px 20px; + background-image: url(file.png); + background-repeat: no-repeat; + background-position: 0 7px; +} + +ul.search li a { + font-weight: bold; +} + +ul.search li p.context { + color: #888; + margin: 2px 0 0 30px; + text-align: left; +} + +ul.keywordmatches li.goodmatch a { + font-weight: bold; +} + +/* -- index page ------------------------------------------------------------ */ + +table.contentstable { + width: 90%; + margin-left: auto; + margin-right: auto; +} + +table.contentstable p.biglink { + line-height: 150%; +} + +a.biglink { + font-size: 1.3em; +} + +span.linkdescr { + font-style: italic; + padding-top: 5px; + font-size: 90%; +} + +/* -- general index --------------------------------------------------------- */ + +table.indextable { + width: 100%; +} + +table.indextable td { + text-align: left; + vertical-align: top; +} + +table.indextable ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} + +table.indextable > tbody > tr > td > ul { + padding-left: 0em; +} + +table.indextable tr.pcap { + height: 10px; +} + +table.indextable tr.cap { + margin-top: 10px; + background-color: #f2f2f2; +} + +img.toggler { + margin-right: 3px; + margin-top: 3px; + cursor: pointer; +} + +div.modindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +div.genindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +/* -- domain module index --------------------------------------------------- */ + +table.modindextable td { + padding: 2px; + border-collapse: collapse; +} + +/* -- general body styles --------------------------------------------------- */ + +div.body { + min-width: 360px; + max-width: 800px; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: auto; + -ms-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +a.headerlink { + visibility: hidden; +} + +a:visited { + color: #551A8B; +} + +h1:hover > a.headerlink, +h2:hover > a.headerlink, +h3:hover > a.headerlink, +h4:hover > a.headerlink, +h5:hover > a.headerlink, +h6:hover > a.headerlink, +dt:hover > a.headerlink, +caption:hover > a.headerlink, +p.caption:hover > a.headerlink, +div.code-block-caption:hover > a.headerlink { + visibility: visible; +} + +div.body p.caption { + text-align: inherit; +} + +div.body td { + text-align: left; +} + +.first { + margin-top: 0 !important; +} + +p.rubric { + margin-top: 30px; + font-weight: bold; +} + +img.align-left, figure.align-left, .figure.align-left, object.align-left { + clear: left; + float: left; + margin-right: 1em; +} + +img.align-right, figure.align-right, .figure.align-right, object.align-right { + clear: right; + float: right; + margin-left: 1em; +} + +img.align-center, figure.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +img.align-default, figure.align-default, .figure.align-default { + display: block; + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-default { + text-align: center; +} + +.align-right { + text-align: right; +} + +/* -- sidebars -------------------------------------------------------------- */ + +div.sidebar, +aside.sidebar { + margin: 0 0 0.5em 1em; + border: 1px solid #ddb; + padding: 7px; + background-color: #ffe; + width: 40%; + float: right; + clear: right; + overflow-x: auto; +} + +p.sidebar-title { + font-weight: bold; +} + +nav.contents, +aside.topic, +div.admonition, div.topic, blockquote { + clear: left; +} + +/* -- topics ---------------------------------------------------------------- */ + +nav.contents, +aside.topic, +div.topic { + border: 1px solid #ccc; + padding: 7px; + margin: 10px 0 10px 0; +} + +p.topic-title { + font-size: 1.1em; + font-weight: bold; + margin-top: 10px; +} + +/* -- admonitions ----------------------------------------------------------- */ + +div.admonition { + margin-top: 10px; + margin-bottom: 10px; + padding: 7px; +} + +div.admonition dt { + font-weight: bold; +} + +p.admonition-title { + margin: 0px 10px 5px 0px; + font-weight: bold; +} + +div.body p.centered { + text-align: center; + margin-top: 25px; +} + +/* -- content of sidebars/topics/admonitions -------------------------------- */ + +div.sidebar > :last-child, +aside.sidebar > :last-child, +nav.contents > :last-child, +aside.topic > :last-child, +div.topic > :last-child, +div.admonition > :last-child { + margin-bottom: 0; +} + +div.sidebar::after, +aside.sidebar::after, +nav.contents::after, +aside.topic::after, +div.topic::after, +div.admonition::after, +blockquote::after { + display: block; + content: ''; + clear: both; +} + +/* -- tables ---------------------------------------------------------------- */ + +table.docutils { + margin-top: 10px; + margin-bottom: 10px; + border: 0; + border-collapse: collapse; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +table.align-default { + margin-left: auto; + margin-right: auto; +} + +table caption span.caption-number { + font-style: italic; +} + +table caption span.caption-text { +} + +table.docutils td, table.docutils th { + padding: 1px 8px 1px 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #aaa; +} + +th { + text-align: left; + padding-right: 5px; +} + +table.citation { + border-left: solid 1px gray; + margin-left: 1px; +} + +table.citation td { + border-bottom: none; +} + +th > :first-child, +td > :first-child { + margin-top: 0px; +} + +th > :last-child, +td > :last-child { + margin-bottom: 0px; +} + +/* -- figures --------------------------------------------------------------- */ + +div.figure, figure { + margin: 0.5em; + padding: 0.5em; +} + +div.figure p.caption, figcaption { + padding: 0.3em; +} + +div.figure p.caption span.caption-number, +figcaption span.caption-number { + font-style: italic; +} + +div.figure p.caption span.caption-text, +figcaption span.caption-text { +} + +/* -- field list styles ----------------------------------------------------- */ + +table.field-list td, table.field-list th { + border: 0 !important; +} + +.field-list ul { + margin: 0; + padding-left: 1em; +} + +.field-list p { + margin: 0; +} + +.field-name { + -moz-hyphens: manual; + -ms-hyphens: manual; + -webkit-hyphens: manual; + hyphens: manual; +} + +/* -- hlist styles ---------------------------------------------------------- */ + +table.hlist { + margin: 1em 0; +} + +table.hlist td { + vertical-align: top; +} + +/* -- object description styles --------------------------------------------- */ + +.sig { + font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; +} + +.sig-name, code.descname { + background-color: transparent; + font-weight: bold; +} + +.sig-name { + font-size: 1.1em; +} + +code.descname { + font-size: 1.2em; +} + +.sig-prename, code.descclassname { + background-color: transparent; +} + +.optional { + font-size: 1.3em; +} + +.sig-paren { + font-size: larger; +} + +.sig-param.n { + font-style: italic; +} + +/* C++ specific styling */ + +.sig-inline.c-texpr, +.sig-inline.cpp-texpr { + font-family: unset; +} + +.sig.c .k, .sig.c .kt, +.sig.cpp .k, .sig.cpp .kt { + color: #0033B3; +} + +.sig.c .m, +.sig.cpp .m { + color: #1750EB; +} + +.sig.c .s, .sig.c .sc, +.sig.cpp .s, .sig.cpp .sc { + color: #067D17; +} + + +/* -- other body styles ----------------------------------------------------- */ + +ol.arabic { + list-style: decimal; +} + +ol.loweralpha { + list-style: lower-alpha; +} + +ol.upperalpha { + list-style: upper-alpha; +} + +ol.lowerroman { + list-style: lower-roman; +} + +ol.upperroman { + list-style: upper-roman; +} + +:not(li) > ol > li:first-child > :first-child, +:not(li) > ul > li:first-child > :first-child { + margin-top: 0px; +} + +:not(li) > ol > li:last-child > :last-child, +:not(li) > ul > li:last-child > :last-child { + margin-bottom: 0px; +} + +ol.simple ol p, +ol.simple ul p, +ul.simple ol p, +ul.simple ul p { + margin-top: 0; +} + +ol.simple > li:not(:first-child) > p, +ul.simple > li:not(:first-child) > p { + margin-top: 0; +} + +ol.simple p, +ul.simple p { + margin-bottom: 0; +} + +aside.footnote > span, +div.citation > span { + float: left; +} +aside.footnote > span:last-of-type, +div.citation > span:last-of-type { + padding-right: 0.5em; +} +aside.footnote > p { + margin-left: 2em; +} +div.citation > p { + margin-left: 4em; +} +aside.footnote > p:last-of-type, +div.citation > p:last-of-type { + margin-bottom: 0em; +} +aside.footnote > p:last-of-type:after, +div.citation > p:last-of-type:after { + content: ""; + clear: both; +} + +dl.field-list { + display: grid; + grid-template-columns: fit-content(30%) auto; +} + +dl.field-list > dt { + font-weight: bold; + word-break: break-word; + padding-left: 0.5em; + padding-right: 5px; +} + +dl.field-list > dd { + padding-left: 0.5em; + margin-top: 0em; + margin-left: 0em; + margin-bottom: 0em; +} + +dl { + margin-bottom: 15px; +} + +dd > :first-child { + margin-top: 0px; +} + +dd ul, dd table { + margin-bottom: 10px; +} + +dd { + margin-top: 3px; + margin-bottom: 10px; + margin-left: 30px; +} + +.sig dd { + margin-top: 0px; + margin-bottom: 0px; +} + +.sig dl { + margin-top: 0px; + margin-bottom: 0px; +} + +dl > dd:last-child, +dl > dd:last-child > :last-child { + margin-bottom: 0; +} + +dt:target, span.highlighted { + background-color: #fbe54e; +} + +rect.highlighted { + fill: #fbe54e; +} + +dl.glossary dt { + font-weight: bold; + font-size: 1.1em; +} + +.versionmodified { + font-style: italic; +} + +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} + +.footnote:target { + background-color: #ffa; +} + +.line-block { + display: block; + margin-top: 1em; + margin-bottom: 1em; +} + +.line-block .line-block { + margin-top: 0; + margin-bottom: 0; + margin-left: 1.5em; +} + +.guilabel, .menuselection { + font-family: sans-serif; +} + +.accelerator { + text-decoration: underline; +} + +.classifier { + font-style: oblique; +} + +.classifier:before { + font-style: normal; + margin: 0 0.5em; + content: ":"; + display: inline-block; +} + +abbr, acronym { + border-bottom: dotted 1px; + cursor: help; +} + +.translated { + background-color: rgba(207, 255, 207, 0.2) +} + +.untranslated { + background-color: rgba(255, 207, 207, 0.2) +} + +/* -- code displays --------------------------------------------------------- */ + +pre { + overflow: auto; + overflow-y: hidden; /* fixes display issues on Chrome browsers */ +} + +pre, div[class*="highlight-"] { + clear: both; +} + +span.pre { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; + white-space: nowrap; +} + +div[class*="highlight-"] { + margin: 1em 0; +} + +td.linenos pre { + border: 0; + background-color: transparent; + color: #aaa; +} + +table.highlighttable { + display: block; +} + +table.highlighttable tbody { + display: block; +} + +table.highlighttable tr { + display: flex; +} + +table.highlighttable td { + margin: 0; + padding: 0; +} + +table.highlighttable td.linenos { + padding-right: 0.5em; +} + +table.highlighttable td.code { + flex: 1; + overflow: hidden; +} + +.highlight .hll { + display: block; +} + +div.highlight pre, +table.highlighttable pre { + margin: 0; +} + +div.code-block-caption + div { + margin-top: 0; +} + +div.code-block-caption { + margin-top: 1em; + padding: 2px 5px; + font-size: small; +} + +div.code-block-caption code { + background-color: transparent; +} + +table.highlighttable td.linenos, +span.linenos, +div.highlight span.gp { /* gp: Generic.Prompt */ + user-select: none; + -webkit-user-select: text; /* Safari fallback only */ + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ +} + +div.code-block-caption span.caption-number { + padding: 0.1em 0.3em; + font-style: italic; +} + +div.code-block-caption span.caption-text { +} + +div.literal-block-wrapper { + margin: 1em 0; +} + +code.xref, a code { + background-color: transparent; + font-weight: bold; +} + +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + background-color: transparent; +} + +.viewcode-link { + float: right; +} + +.viewcode-back { + float: right; + font-family: sans-serif; +} + +div.viewcode-block:target { + margin: -1px -10px; + padding: 0 10px; +} + +/* -- math display ---------------------------------------------------------- */ + +img.math { + vertical-align: middle; +} + +div.body div.math p { + text-align: center; +} + +span.eqno { + float: right; +} + +span.eqno a.headerlink { + position: absolute; + z-index: 1; +} + +div.math:hover a.headerlink { + visibility: visible; +} + +/* -- printout stylesheet --------------------------------------------------- */ + +@media print { + div.document, + div.documentwrapper, + div.bodywrapper { + margin: 0 !important; + width: 100%; + } + + div.sphinxsidebar, + div.related, + div.footer, + #top-link { + display: none; + } +} \ No newline at end of file diff --git a/docs.bak/_build/html/_static/css/badge_only.css b/docs.bak/_build/html/_static/css/badge_only.css new file mode 100644 index 0000000..c718cee --- /dev/null +++ b/docs.bak/_build/html/_static/css/badge_only.css @@ -0,0 +1 @@ +.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}} \ No newline at end of file diff --git a/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff new file mode 100644 index 0000000..6cb6000 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff2 b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff2 new file mode 100644 index 0000000..7059e23 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Bold.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff new file mode 100644 index 0000000..f815f63 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff2 b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff2 new file mode 100644 index 0000000..f2c76e5 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/Roboto-Slab-Regular.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.eot b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..e9f60ca Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.eot differ diff --git a/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.svg b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..855c845 --- /dev/null +++ b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.ttf b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..35acda2 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.ttf differ diff --git a/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..400014a Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff2 b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..4d13fc6 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/fontawesome-webfont.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff b/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff new file mode 100644 index 0000000..88ad05b Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff2 b/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff2 new file mode 100644 index 0000000..c4e3d80 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-bold-italic.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-bold.woff b/docs.bak/_build/html/_static/css/fonts/lato-bold.woff new file mode 100644 index 0000000..c6dff51 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-bold.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-bold.woff2 b/docs.bak/_build/html/_static/css/fonts/lato-bold.woff2 new file mode 100644 index 0000000..bb19504 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-bold.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff b/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff new file mode 100644 index 0000000..76114bc Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff2 b/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff2 new file mode 100644 index 0000000..3404f37 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-normal-italic.woff2 differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-normal.woff b/docs.bak/_build/html/_static/css/fonts/lato-normal.woff new file mode 100644 index 0000000..ae1307f Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-normal.woff differ diff --git a/docs.bak/_build/html/_static/css/fonts/lato-normal.woff2 b/docs.bak/_build/html/_static/css/fonts/lato-normal.woff2 new file mode 100644 index 0000000..3bf9843 Binary files /dev/null and b/docs.bak/_build/html/_static/css/fonts/lato-normal.woff2 differ diff --git a/docs.bak/_build/html/_static/css/theme.css b/docs.bak/_build/html/_static/css/theme.css new file mode 100644 index 0000000..19a446a --- /dev/null +++ b/docs.bak/_build/html/_static/css/theme.css @@ -0,0 +1,4 @@ +html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search>a:hover{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.version{margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file diff --git a/docs.bak/_build/html/_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css b/docs.bak/_build/html/_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css new file mode 100644 index 0000000..eb19f69 --- /dev/null +++ b/docs.bak/_build/html/_static/design-style.1e8bd061cd6da7fc9cf755528e8ffc24.min.css @@ -0,0 +1 @@ +.sd-bg-primary{background-color:var(--sd-color-primary) !important}.sd-bg-text-primary{color:var(--sd-color-primary-text) !important}button.sd-bg-primary:focus,button.sd-bg-primary:hover{background-color:var(--sd-color-primary-highlight) !important}a.sd-bg-primary:focus,a.sd-bg-primary:hover{background-color:var(--sd-color-primary-highlight) !important}.sd-bg-secondary{background-color:var(--sd-color-secondary) !important}.sd-bg-text-secondary{color:var(--sd-color-secondary-text) !important}button.sd-bg-secondary:focus,button.sd-bg-secondary:hover{background-color:var(--sd-color-secondary-highlight) !important}a.sd-bg-secondary:focus,a.sd-bg-secondary:hover{background-color:var(--sd-color-secondary-highlight) !important}.sd-bg-success{background-color:var(--sd-color-success) !important}.sd-bg-text-success{color:var(--sd-color-success-text) !important}button.sd-bg-success:focus,button.sd-bg-success:hover{background-color:var(--sd-color-success-highlight) !important}a.sd-bg-success:focus,a.sd-bg-success:hover{background-color:var(--sd-color-success-highlight) !important}.sd-bg-info{background-color:var(--sd-color-info) !important}.sd-bg-text-info{color:var(--sd-color-info-text) !important}button.sd-bg-info:focus,button.sd-bg-info:hover{background-color:var(--sd-color-info-highlight) !important}a.sd-bg-info:focus,a.sd-bg-info:hover{background-color:var(--sd-color-info-highlight) !important}.sd-bg-warning{background-color:var(--sd-color-warning) !important}.sd-bg-text-warning{color:var(--sd-color-warning-text) !important}button.sd-bg-warning:focus,button.sd-bg-warning:hover{background-color:var(--sd-color-warning-highlight) !important}a.sd-bg-warning:focus,a.sd-bg-warning:hover{background-color:var(--sd-color-warning-highlight) !important}.sd-bg-danger{background-color:var(--sd-color-danger) !important}.sd-bg-text-danger{color:var(--sd-color-danger-text) !important}button.sd-bg-danger:focus,button.sd-bg-danger:hover{background-color:var(--sd-color-danger-highlight) !important}a.sd-bg-danger:focus,a.sd-bg-danger:hover{background-color:var(--sd-color-danger-highlight) !important}.sd-bg-light{background-color:var(--sd-color-light) !important}.sd-bg-text-light{color:var(--sd-color-light-text) !important}button.sd-bg-light:focus,button.sd-bg-light:hover{background-color:var(--sd-color-light-highlight) !important}a.sd-bg-light:focus,a.sd-bg-light:hover{background-color:var(--sd-color-light-highlight) !important}.sd-bg-muted{background-color:var(--sd-color-muted) !important}.sd-bg-text-muted{color:var(--sd-color-muted-text) !important}button.sd-bg-muted:focus,button.sd-bg-muted:hover{background-color:var(--sd-color-muted-highlight) !important}a.sd-bg-muted:focus,a.sd-bg-muted:hover{background-color:var(--sd-color-muted-highlight) !important}.sd-bg-dark{background-color:var(--sd-color-dark) !important}.sd-bg-text-dark{color:var(--sd-color-dark-text) !important}button.sd-bg-dark:focus,button.sd-bg-dark:hover{background-color:var(--sd-color-dark-highlight) !important}a.sd-bg-dark:focus,a.sd-bg-dark:hover{background-color:var(--sd-color-dark-highlight) !important}.sd-bg-black{background-color:var(--sd-color-black) !important}.sd-bg-text-black{color:var(--sd-color-black-text) !important}button.sd-bg-black:focus,button.sd-bg-black:hover{background-color:var(--sd-color-black-highlight) !important}a.sd-bg-black:focus,a.sd-bg-black:hover{background-color:var(--sd-color-black-highlight) !important}.sd-bg-white{background-color:var(--sd-color-white) !important}.sd-bg-text-white{color:var(--sd-color-white-text) !important}button.sd-bg-white:focus,button.sd-bg-white:hover{background-color:var(--sd-color-white-highlight) !important}a.sd-bg-white:focus,a.sd-bg-white:hover{background-color:var(--sd-color-white-highlight) !important}.sd-text-primary,.sd-text-primary>p{color:var(--sd-color-primary) !important}a.sd-text-primary:focus,a.sd-text-primary:hover{color:var(--sd-color-primary-highlight) !important}.sd-text-secondary,.sd-text-secondary>p{color:var(--sd-color-secondary) !important}a.sd-text-secondary:focus,a.sd-text-secondary:hover{color:var(--sd-color-secondary-highlight) !important}.sd-text-success,.sd-text-success>p{color:var(--sd-color-success) !important}a.sd-text-success:focus,a.sd-text-success:hover{color:var(--sd-color-success-highlight) !important}.sd-text-info,.sd-text-info>p{color:var(--sd-color-info) !important}a.sd-text-info:focus,a.sd-text-info:hover{color:var(--sd-color-info-highlight) !important}.sd-text-warning,.sd-text-warning>p{color:var(--sd-color-warning) !important}a.sd-text-warning:focus,a.sd-text-warning:hover{color:var(--sd-color-warning-highlight) !important}.sd-text-danger,.sd-text-danger>p{color:var(--sd-color-danger) !important}a.sd-text-danger:focus,a.sd-text-danger:hover{color:var(--sd-color-danger-highlight) !important}.sd-text-light,.sd-text-light>p{color:var(--sd-color-light) !important}a.sd-text-light:focus,a.sd-text-light:hover{color:var(--sd-color-light-highlight) !important}.sd-text-muted,.sd-text-muted>p{color:var(--sd-color-muted) !important}a.sd-text-muted:focus,a.sd-text-muted:hover{color:var(--sd-color-muted-highlight) !important}.sd-text-dark,.sd-text-dark>p{color:var(--sd-color-dark) !important}a.sd-text-dark:focus,a.sd-text-dark:hover{color:var(--sd-color-dark-highlight) !important}.sd-text-black,.sd-text-black>p{color:var(--sd-color-black) !important}a.sd-text-black:focus,a.sd-text-black:hover{color:var(--sd-color-black-highlight) !important}.sd-text-white,.sd-text-white>p{color:var(--sd-color-white) !important}a.sd-text-white:focus,a.sd-text-white:hover{color:var(--sd-color-white-highlight) !important}.sd-outline-primary{border-color:var(--sd-color-primary) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-primary:focus,a.sd-outline-primary:hover{border-color:var(--sd-color-primary-highlight) !important}.sd-outline-secondary{border-color:var(--sd-color-secondary) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-secondary:focus,a.sd-outline-secondary:hover{border-color:var(--sd-color-secondary-highlight) !important}.sd-outline-success{border-color:var(--sd-color-success) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-success:focus,a.sd-outline-success:hover{border-color:var(--sd-color-success-highlight) !important}.sd-outline-info{border-color:var(--sd-color-info) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-info:focus,a.sd-outline-info:hover{border-color:var(--sd-color-info-highlight) !important}.sd-outline-warning{border-color:var(--sd-color-warning) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-warning:focus,a.sd-outline-warning:hover{border-color:var(--sd-color-warning-highlight) !important}.sd-outline-danger{border-color:var(--sd-color-danger) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-danger:focus,a.sd-outline-danger:hover{border-color:var(--sd-color-danger-highlight) !important}.sd-outline-light{border-color:var(--sd-color-light) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-light:focus,a.sd-outline-light:hover{border-color:var(--sd-color-light-highlight) !important}.sd-outline-muted{border-color:var(--sd-color-muted) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-muted:focus,a.sd-outline-muted:hover{border-color:var(--sd-color-muted-highlight) !important}.sd-outline-dark{border-color:var(--sd-color-dark) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-dark:focus,a.sd-outline-dark:hover{border-color:var(--sd-color-dark-highlight) !important}.sd-outline-black{border-color:var(--sd-color-black) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-black:focus,a.sd-outline-black:hover{border-color:var(--sd-color-black-highlight) !important}.sd-outline-white{border-color:var(--sd-color-white) !important;border-style:solid !important;border-width:1px !important}a.sd-outline-white:focus,a.sd-outline-white:hover{border-color:var(--sd-color-white-highlight) !important}.sd-bg-transparent{background-color:transparent !important}.sd-outline-transparent{border-color:transparent !important}.sd-text-transparent{color:transparent !important}.sd-p-0{padding:0 !important}.sd-pt-0,.sd-py-0{padding-top:0 !important}.sd-pr-0,.sd-px-0{padding-right:0 !important}.sd-pb-0,.sd-py-0{padding-bottom:0 !important}.sd-pl-0,.sd-px-0{padding-left:0 !important}.sd-p-1{padding:.25rem !important}.sd-pt-1,.sd-py-1{padding-top:.25rem !important}.sd-pr-1,.sd-px-1{padding-right:.25rem !important}.sd-pb-1,.sd-py-1{padding-bottom:.25rem !important}.sd-pl-1,.sd-px-1{padding-left:.25rem !important}.sd-p-2{padding:.5rem !important}.sd-pt-2,.sd-py-2{padding-top:.5rem !important}.sd-pr-2,.sd-px-2{padding-right:.5rem !important}.sd-pb-2,.sd-py-2{padding-bottom:.5rem !important}.sd-pl-2,.sd-px-2{padding-left:.5rem !important}.sd-p-3{padding:1rem !important}.sd-pt-3,.sd-py-3{padding-top:1rem !important}.sd-pr-3,.sd-px-3{padding-right:1rem !important}.sd-pb-3,.sd-py-3{padding-bottom:1rem !important}.sd-pl-3,.sd-px-3{padding-left:1rem !important}.sd-p-4{padding:1.5rem !important}.sd-pt-4,.sd-py-4{padding-top:1.5rem !important}.sd-pr-4,.sd-px-4{padding-right:1.5rem !important}.sd-pb-4,.sd-py-4{padding-bottom:1.5rem !important}.sd-pl-4,.sd-px-4{padding-left:1.5rem !important}.sd-p-5{padding:3rem !important}.sd-pt-5,.sd-py-5{padding-top:3rem !important}.sd-pr-5,.sd-px-5{padding-right:3rem !important}.sd-pb-5,.sd-py-5{padding-bottom:3rem !important}.sd-pl-5,.sd-px-5{padding-left:3rem !important}.sd-m-auto{margin:auto !important}.sd-mt-auto,.sd-my-auto{margin-top:auto !important}.sd-mr-auto,.sd-mx-auto{margin-right:auto !important}.sd-mb-auto,.sd-my-auto{margin-bottom:auto !important}.sd-ml-auto,.sd-mx-auto{margin-left:auto !important}.sd-m-0{margin:0 !important}.sd-mt-0,.sd-my-0{margin-top:0 !important}.sd-mr-0,.sd-mx-0{margin-right:0 !important}.sd-mb-0,.sd-my-0{margin-bottom:0 !important}.sd-ml-0,.sd-mx-0{margin-left:0 !important}.sd-m-1{margin:.25rem !important}.sd-mt-1,.sd-my-1{margin-top:.25rem !important}.sd-mr-1,.sd-mx-1{margin-right:.25rem !important}.sd-mb-1,.sd-my-1{margin-bottom:.25rem !important}.sd-ml-1,.sd-mx-1{margin-left:.25rem !important}.sd-m-2{margin:.5rem !important}.sd-mt-2,.sd-my-2{margin-top:.5rem !important}.sd-mr-2,.sd-mx-2{margin-right:.5rem !important}.sd-mb-2,.sd-my-2{margin-bottom:.5rem !important}.sd-ml-2,.sd-mx-2{margin-left:.5rem !important}.sd-m-3{margin:1rem !important}.sd-mt-3,.sd-my-3{margin-top:1rem !important}.sd-mr-3,.sd-mx-3{margin-right:1rem !important}.sd-mb-3,.sd-my-3{margin-bottom:1rem !important}.sd-ml-3,.sd-mx-3{margin-left:1rem !important}.sd-m-4{margin:1.5rem !important}.sd-mt-4,.sd-my-4{margin-top:1.5rem !important}.sd-mr-4,.sd-mx-4{margin-right:1.5rem !important}.sd-mb-4,.sd-my-4{margin-bottom:1.5rem !important}.sd-ml-4,.sd-mx-4{margin-left:1.5rem !important}.sd-m-5{margin:3rem !important}.sd-mt-5,.sd-my-5{margin-top:3rem !important}.sd-mr-5,.sd-mx-5{margin-right:3rem !important}.sd-mb-5,.sd-my-5{margin-bottom:3rem !important}.sd-ml-5,.sd-mx-5{margin-left:3rem !important}.sd-w-25{width:25% !important}.sd-w-50{width:50% !important}.sd-w-75{width:75% !important}.sd-w-100{width:100% !important}.sd-w-auto{width:auto !important}.sd-h-25{height:25% !important}.sd-h-50{height:50% !important}.sd-h-75{height:75% !important}.sd-h-100{height:100% !important}.sd-h-auto{height:auto !important}.sd-d-none{display:none !important}.sd-d-inline{display:inline !important}.sd-d-inline-block{display:inline-block !important}.sd-d-block{display:block !important}.sd-d-grid{display:grid !important}.sd-d-flex-row{display:-ms-flexbox !important;display:flex !important;flex-direction:row !important}.sd-d-flex-column{display:-ms-flexbox !important;display:flex !important;flex-direction:column !important}.sd-d-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}@media(min-width: 576px){.sd-d-sm-none{display:none !important}.sd-d-sm-inline{display:inline !important}.sd-d-sm-inline-block{display:inline-block !important}.sd-d-sm-block{display:block !important}.sd-d-sm-grid{display:grid !important}.sd-d-sm-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-sm-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 768px){.sd-d-md-none{display:none !important}.sd-d-md-inline{display:inline !important}.sd-d-md-inline-block{display:inline-block !important}.sd-d-md-block{display:block !important}.sd-d-md-grid{display:grid !important}.sd-d-md-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-md-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 992px){.sd-d-lg-none{display:none !important}.sd-d-lg-inline{display:inline !important}.sd-d-lg-inline-block{display:inline-block !important}.sd-d-lg-block{display:block !important}.sd-d-lg-grid{display:grid !important}.sd-d-lg-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-lg-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}@media(min-width: 1200px){.sd-d-xl-none{display:none !important}.sd-d-xl-inline{display:inline !important}.sd-d-xl-inline-block{display:inline-block !important}.sd-d-xl-block{display:block !important}.sd-d-xl-grid{display:grid !important}.sd-d-xl-flex{display:-ms-flexbox !important;display:flex !important}.sd-d-xl-inline-flex{display:-ms-inline-flexbox !important;display:inline-flex !important}}.sd-align-major-start{justify-content:flex-start !important}.sd-align-major-end{justify-content:flex-end !important}.sd-align-major-center{justify-content:center !important}.sd-align-major-justify{justify-content:space-between !important}.sd-align-major-spaced{justify-content:space-evenly !important}.sd-align-minor-start{align-items:flex-start !important}.sd-align-minor-end{align-items:flex-end !important}.sd-align-minor-center{align-items:center !important}.sd-align-minor-stretch{align-items:stretch !important}.sd-text-justify{text-align:justify !important}.sd-text-left{text-align:left !important}.sd-text-right{text-align:right !important}.sd-text-center{text-align:center !important}.sd-font-weight-light{font-weight:300 !important}.sd-font-weight-lighter{font-weight:lighter !important}.sd-font-weight-normal{font-weight:400 !important}.sd-font-weight-bold{font-weight:700 !important}.sd-font-weight-bolder{font-weight:bolder !important}.sd-font-italic{font-style:italic !important}.sd-text-decoration-none{text-decoration:none !important}.sd-text-lowercase{text-transform:lowercase !important}.sd-text-uppercase{text-transform:uppercase !important}.sd-text-capitalize{text-transform:capitalize !important}.sd-text-wrap{white-space:normal !important}.sd-text-nowrap{white-space:nowrap !important}.sd-text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sd-fs-1,.sd-fs-1>p{font-size:calc(1.375rem + 1.5vw) !important;line-height:unset !important}.sd-fs-2,.sd-fs-2>p{font-size:calc(1.325rem + 0.9vw) !important;line-height:unset !important}.sd-fs-3,.sd-fs-3>p{font-size:calc(1.3rem + 0.6vw) !important;line-height:unset !important}.sd-fs-4,.sd-fs-4>p{font-size:calc(1.275rem + 0.3vw) !important;line-height:unset !important}.sd-fs-5,.sd-fs-5>p{font-size:1.25rem !important;line-height:unset !important}.sd-fs-6,.sd-fs-6>p{font-size:1rem !important;line-height:unset !important}.sd-border-0{border:0 solid !important}.sd-border-top-0{border-top:0 solid !important}.sd-border-bottom-0{border-bottom:0 solid !important}.sd-border-right-0{border-right:0 solid !important}.sd-border-left-0{border-left:0 solid !important}.sd-border-1{border:1px solid !important}.sd-border-top-1{border-top:1px solid !important}.sd-border-bottom-1{border-bottom:1px solid !important}.sd-border-right-1{border-right:1px solid !important}.sd-border-left-1{border-left:1px solid !important}.sd-border-2{border:2px solid !important}.sd-border-top-2{border-top:2px solid !important}.sd-border-bottom-2{border-bottom:2px solid !important}.sd-border-right-2{border-right:2px solid !important}.sd-border-left-2{border-left:2px solid !important}.sd-border-3{border:3px solid !important}.sd-border-top-3{border-top:3px solid !important}.sd-border-bottom-3{border-bottom:3px solid !important}.sd-border-right-3{border-right:3px solid !important}.sd-border-left-3{border-left:3px solid !important}.sd-border-4{border:4px solid !important}.sd-border-top-4{border-top:4px solid !important}.sd-border-bottom-4{border-bottom:4px solid !important}.sd-border-right-4{border-right:4px solid !important}.sd-border-left-4{border-left:4px solid !important}.sd-border-5{border:5px solid !important}.sd-border-top-5{border-top:5px solid !important}.sd-border-bottom-5{border-bottom:5px solid !important}.sd-border-right-5{border-right:5px solid !important}.sd-border-left-5{border-left:5px solid !important}.sd-rounded-0{border-radius:0 !important}.sd-rounded-1{border-radius:.2rem !important}.sd-rounded-2{border-radius:.3rem !important}.sd-rounded-3{border-radius:.5rem !important}.sd-rounded-pill{border-radius:50rem !important}.sd-rounded-circle{border-radius:50% !important}.shadow-none{box-shadow:none !important}.sd-shadow-sm{box-shadow:0 .125rem .25rem var(--sd-color-shadow) !important}.sd-shadow-md{box-shadow:0 .5rem 1rem var(--sd-color-shadow) !important}.sd-shadow-lg{box-shadow:0 1rem 3rem var(--sd-color-shadow) !important}@keyframes sd-slide-from-left{0%{transform:translateX(-100%)}100%{transform:translateX(0)}}@keyframes sd-slide-from-right{0%{transform:translateX(200%)}100%{transform:translateX(0)}}@keyframes sd-grow100{0%{transform:scale(0);opacity:.5}100%{transform:scale(1);opacity:1}}@keyframes sd-grow50{0%{transform:scale(0.5);opacity:.5}100%{transform:scale(1);opacity:1}}@keyframes sd-grow50-rot20{0%{transform:scale(0.5) rotateZ(-20deg);opacity:.5}75%{transform:scale(1) rotateZ(5deg);opacity:1}95%{transform:scale(1) rotateZ(-1deg);opacity:1}100%{transform:scale(1) rotateZ(0);opacity:1}}.sd-animate-slide-from-left{animation:1s ease-out 0s 1 normal none running sd-slide-from-left}.sd-animate-slide-from-right{animation:1s ease-out 0s 1 normal none running sd-slide-from-right}.sd-animate-grow100{animation:1s ease-out 0s 1 normal none running sd-grow100}.sd-animate-grow50{animation:1s ease-out 0s 1 normal none running sd-grow50}.sd-animate-grow50-rot20{animation:1s ease-out 0s 1 normal none running sd-grow50-rot20}.sd-badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.sd-badge:empty{display:none}a.sd-badge{text-decoration:none}.sd-btn .sd-badge{position:relative;top:-1px}.sd-btn{background-color:transparent;border:1px solid transparent;border-radius:.25rem;cursor:pointer;display:inline-block;font-weight:400;font-size:1rem;line-height:1.5;padding:.375rem .75rem;text-align:center;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;vertical-align:middle;user-select:none;-moz-user-select:none;-ms-user-select:none;-webkit-user-select:none}.sd-btn:hover{text-decoration:none}@media(prefers-reduced-motion: reduce){.sd-btn{transition:none}}.sd-btn-primary,.sd-btn-outline-primary:hover,.sd-btn-outline-primary:focus{color:var(--sd-color-primary-text) !important;background-color:var(--sd-color-primary) !important;border-color:var(--sd-color-primary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-primary:hover,.sd-btn-primary:focus{color:var(--sd-color-primary-text) !important;background-color:var(--sd-color-primary-highlight) !important;border-color:var(--sd-color-primary-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-primary{color:var(--sd-color-primary) !important;border-color:var(--sd-color-primary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-secondary,.sd-btn-outline-secondary:hover,.sd-btn-outline-secondary:focus{color:var(--sd-color-secondary-text) !important;background-color:var(--sd-color-secondary) !important;border-color:var(--sd-color-secondary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-secondary:hover,.sd-btn-secondary:focus{color:var(--sd-color-secondary-text) !important;background-color:var(--sd-color-secondary-highlight) !important;border-color:var(--sd-color-secondary-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-secondary{color:var(--sd-color-secondary) !important;border-color:var(--sd-color-secondary) !important;border-width:1px !important;border-style:solid !important}.sd-btn-success,.sd-btn-outline-success:hover,.sd-btn-outline-success:focus{color:var(--sd-color-success-text) !important;background-color:var(--sd-color-success) !important;border-color:var(--sd-color-success) !important;border-width:1px !important;border-style:solid !important}.sd-btn-success:hover,.sd-btn-success:focus{color:var(--sd-color-success-text) !important;background-color:var(--sd-color-success-highlight) !important;border-color:var(--sd-color-success-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-success{color:var(--sd-color-success) !important;border-color:var(--sd-color-success) !important;border-width:1px !important;border-style:solid !important}.sd-btn-info,.sd-btn-outline-info:hover,.sd-btn-outline-info:focus{color:var(--sd-color-info-text) !important;background-color:var(--sd-color-info) !important;border-color:var(--sd-color-info) !important;border-width:1px !important;border-style:solid !important}.sd-btn-info:hover,.sd-btn-info:focus{color:var(--sd-color-info-text) !important;background-color:var(--sd-color-info-highlight) !important;border-color:var(--sd-color-info-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-info{color:var(--sd-color-info) !important;border-color:var(--sd-color-info) !important;border-width:1px !important;border-style:solid !important}.sd-btn-warning,.sd-btn-outline-warning:hover,.sd-btn-outline-warning:focus{color:var(--sd-color-warning-text) !important;background-color:var(--sd-color-warning) !important;border-color:var(--sd-color-warning) !important;border-width:1px !important;border-style:solid !important}.sd-btn-warning:hover,.sd-btn-warning:focus{color:var(--sd-color-warning-text) !important;background-color:var(--sd-color-warning-highlight) !important;border-color:var(--sd-color-warning-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-warning{color:var(--sd-color-warning) !important;border-color:var(--sd-color-warning) !important;border-width:1px !important;border-style:solid !important}.sd-btn-danger,.sd-btn-outline-danger:hover,.sd-btn-outline-danger:focus{color:var(--sd-color-danger-text) !important;background-color:var(--sd-color-danger) !important;border-color:var(--sd-color-danger) !important;border-width:1px !important;border-style:solid !important}.sd-btn-danger:hover,.sd-btn-danger:focus{color:var(--sd-color-danger-text) !important;background-color:var(--sd-color-danger-highlight) !important;border-color:var(--sd-color-danger-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-danger{color:var(--sd-color-danger) !important;border-color:var(--sd-color-danger) !important;border-width:1px !important;border-style:solid !important}.sd-btn-light,.sd-btn-outline-light:hover,.sd-btn-outline-light:focus{color:var(--sd-color-light-text) !important;background-color:var(--sd-color-light) !important;border-color:var(--sd-color-light) !important;border-width:1px !important;border-style:solid !important}.sd-btn-light:hover,.sd-btn-light:focus{color:var(--sd-color-light-text) !important;background-color:var(--sd-color-light-highlight) !important;border-color:var(--sd-color-light-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-light{color:var(--sd-color-light) !important;border-color:var(--sd-color-light) !important;border-width:1px !important;border-style:solid !important}.sd-btn-muted,.sd-btn-outline-muted:hover,.sd-btn-outline-muted:focus{color:var(--sd-color-muted-text) !important;background-color:var(--sd-color-muted) !important;border-color:var(--sd-color-muted) !important;border-width:1px !important;border-style:solid !important}.sd-btn-muted:hover,.sd-btn-muted:focus{color:var(--sd-color-muted-text) !important;background-color:var(--sd-color-muted-highlight) !important;border-color:var(--sd-color-muted-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-muted{color:var(--sd-color-muted) !important;border-color:var(--sd-color-muted) !important;border-width:1px !important;border-style:solid !important}.sd-btn-dark,.sd-btn-outline-dark:hover,.sd-btn-outline-dark:focus{color:var(--sd-color-dark-text) !important;background-color:var(--sd-color-dark) !important;border-color:var(--sd-color-dark) !important;border-width:1px !important;border-style:solid !important}.sd-btn-dark:hover,.sd-btn-dark:focus{color:var(--sd-color-dark-text) !important;background-color:var(--sd-color-dark-highlight) !important;border-color:var(--sd-color-dark-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-dark{color:var(--sd-color-dark) !important;border-color:var(--sd-color-dark) !important;border-width:1px !important;border-style:solid !important}.sd-btn-black,.sd-btn-outline-black:hover,.sd-btn-outline-black:focus{color:var(--sd-color-black-text) !important;background-color:var(--sd-color-black) !important;border-color:var(--sd-color-black) !important;border-width:1px !important;border-style:solid !important}.sd-btn-black:hover,.sd-btn-black:focus{color:var(--sd-color-black-text) !important;background-color:var(--sd-color-black-highlight) !important;border-color:var(--sd-color-black-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-black{color:var(--sd-color-black) !important;border-color:var(--sd-color-black) !important;border-width:1px !important;border-style:solid !important}.sd-btn-white,.sd-btn-outline-white:hover,.sd-btn-outline-white:focus{color:var(--sd-color-white-text) !important;background-color:var(--sd-color-white) !important;border-color:var(--sd-color-white) !important;border-width:1px !important;border-style:solid !important}.sd-btn-white:hover,.sd-btn-white:focus{color:var(--sd-color-white-text) !important;background-color:var(--sd-color-white-highlight) !important;border-color:var(--sd-color-white-highlight) !important;border-width:1px !important;border-style:solid !important}.sd-btn-outline-white{color:var(--sd-color-white) !important;border-color:var(--sd-color-white) !important;border-width:1px !important;border-style:solid !important}.sd-stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.sd-hide-link-text{font-size:0}.sd-octicon,.sd-material-icon{display:inline-block;fill:currentColor;vertical-align:middle}.sd-avatar-xs{border-radius:50%;object-fit:cover;object-position:center;width:1rem;height:1rem}.sd-avatar-sm{border-radius:50%;object-fit:cover;object-position:center;width:3rem;height:3rem}.sd-avatar-md{border-radius:50%;object-fit:cover;object-position:center;width:5rem;height:5rem}.sd-avatar-lg{border-radius:50%;object-fit:cover;object-position:center;width:7rem;height:7rem}.sd-avatar-xl{border-radius:50%;object-fit:cover;object-position:center;width:10rem;height:10rem}.sd-avatar-inherit{border-radius:50%;object-fit:cover;object-position:center;width:inherit;height:inherit}.sd-avatar-initial{border-radius:50%;object-fit:cover;object-position:center;width:initial;height:initial}.sd-card{background-clip:border-box;background-color:var(--sd-color-card-background);border:1px solid var(--sd-color-card-border);border-radius:.25rem;color:var(--sd-color-card-text);display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;position:relative;word-wrap:break-word}.sd-card>hr{margin-left:0;margin-right:0}.sd-card-hover:hover{border-color:var(--sd-color-card-border-hover);transform:scale(1.01)}.sd-card-body{-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem 1rem}.sd-card-title{margin-bottom:.5rem}.sd-card-subtitle{margin-top:-0.25rem;margin-bottom:0}.sd-card-text:last-child{margin-bottom:0}.sd-card-link:hover{text-decoration:none}.sd-card-link+.card-link{margin-left:1rem}.sd-card-header{padding:.5rem 1rem;margin-bottom:0;background-color:var(--sd-color-card-header);border-bottom:1px solid var(--sd-color-card-border)}.sd-card-header:first-child{border-radius:calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0}.sd-card-footer{padding:.5rem 1rem;background-color:var(--sd-color-card-footer);border-top:1px solid var(--sd-color-card-border)}.sd-card-footer:last-child{border-radius:0 0 calc(0.25rem - 1px) calc(0.25rem - 1px)}.sd-card-header-tabs{margin-right:-0.5rem;margin-bottom:-0.5rem;margin-left:-0.5rem;border-bottom:0}.sd-card-header-pills{margin-right:-0.5rem;margin-left:-0.5rem}.sd-card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(0.25rem - 1px)}.sd-card-img,.sd-card-img-bottom,.sd-card-img-top{width:100%}.sd-card-img,.sd-card-img-top{border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.sd-card-img,.sd-card-img-bottom{border-bottom-left-radius:calc(0.25rem - 1px);border-bottom-right-radius:calc(0.25rem - 1px)}.sd-cards-carousel{width:100%;display:flex;flex-wrap:nowrap;-ms-flex-direction:row;flex-direction:row;overflow-x:hidden;scroll-snap-type:x mandatory}.sd-cards-carousel.sd-show-scrollbar{overflow-x:auto}.sd-cards-carousel:hover,.sd-cards-carousel:focus{overflow-x:auto}.sd-cards-carousel>.sd-card{flex-shrink:0;scroll-snap-align:start}.sd-cards-carousel>.sd-card:not(:last-child){margin-right:3px}.sd-card-cols-1>.sd-card{width:90%}.sd-card-cols-2>.sd-card{width:45%}.sd-card-cols-3>.sd-card{width:30%}.sd-card-cols-4>.sd-card{width:22.5%}.sd-card-cols-5>.sd-card{width:18%}.sd-card-cols-6>.sd-card{width:15%}.sd-card-cols-7>.sd-card{width:12.8571428571%}.sd-card-cols-8>.sd-card{width:11.25%}.sd-card-cols-9>.sd-card{width:10%}.sd-card-cols-10>.sd-card{width:9%}.sd-card-cols-11>.sd-card{width:8.1818181818%}.sd-card-cols-12>.sd-card{width:7.5%}.sd-container,.sd-container-fluid,.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container-xl{margin-left:auto;margin-right:auto;padding-left:var(--sd-gutter-x, 0.75rem);padding-right:var(--sd-gutter-x, 0.75rem);width:100%}@media(min-width: 576px){.sd-container-sm,.sd-container{max-width:540px}}@media(min-width: 768px){.sd-container-md,.sd-container-sm,.sd-container{max-width:720px}}@media(min-width: 992px){.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container{max-width:960px}}@media(min-width: 1200px){.sd-container-xl,.sd-container-lg,.sd-container-md,.sd-container-sm,.sd-container{max-width:1140px}}.sd-row{--sd-gutter-x: 1.5rem;--sd-gutter-y: 0;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-top:calc(var(--sd-gutter-y) * -1);margin-right:calc(var(--sd-gutter-x) * -0.5);margin-left:calc(var(--sd-gutter-x) * -0.5)}.sd-row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--sd-gutter-x) * 0.5);padding-left:calc(var(--sd-gutter-x) * 0.5);margin-top:var(--sd-gutter-y)}.sd-col{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-auto>*{flex:0 0 auto;width:auto}.sd-row-cols-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}@media(min-width: 576px){.sd-col-sm{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-sm-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-sm-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-sm-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-sm-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-sm-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-sm-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-sm-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-sm-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-sm-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-sm-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-sm-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-sm-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-sm-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 768px){.sd-col-md{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-md-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-md-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-md-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-md-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-md-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-md-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-md-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-md-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-md-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-md-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-md-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-md-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-md-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 992px){.sd-col-lg{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-lg-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-lg-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-lg-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-lg-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-lg-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-lg-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-lg-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-lg-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-lg-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-lg-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-lg-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-lg-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-lg-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}@media(min-width: 1200px){.sd-col-xl{flex:1 0 0%;-ms-flex:1 0 0%}.sd-row-cols-xl-auto{flex:1 0 auto;-ms-flex:1 0 auto;width:100%}.sd-row-cols-xl-1>*{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-row-cols-xl-2>*{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-row-cols-xl-3>*{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-row-cols-xl-4>*{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-row-cols-xl-5>*{flex:0 0 auto;-ms-flex:0 0 auto;width:20%}.sd-row-cols-xl-6>*{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-row-cols-xl-7>*{flex:0 0 auto;-ms-flex:0 0 auto;width:14.2857142857%}.sd-row-cols-xl-8>*{flex:0 0 auto;-ms-flex:0 0 auto;width:12.5%}.sd-row-cols-xl-9>*{flex:0 0 auto;-ms-flex:0 0 auto;width:11.1111111111%}.sd-row-cols-xl-10>*{flex:0 0 auto;-ms-flex:0 0 auto;width:10%}.sd-row-cols-xl-11>*{flex:0 0 auto;-ms-flex:0 0 auto;width:9.0909090909%}.sd-row-cols-xl-12>*{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}}.sd-col-auto{flex:0 0 auto;-ms-flex:0 0 auto;width:auto}.sd-col-1{flex:0 0 auto;-ms-flex:0 0 auto;width:8.3333333333%}.sd-col-2{flex:0 0 auto;-ms-flex:0 0 auto;width:16.6666666667%}.sd-col-3{flex:0 0 auto;-ms-flex:0 0 auto;width:25%}.sd-col-4{flex:0 0 auto;-ms-flex:0 0 auto;width:33.3333333333%}.sd-col-5{flex:0 0 auto;-ms-flex:0 0 auto;width:41.6666666667%}.sd-col-6{flex:0 0 auto;-ms-flex:0 0 auto;width:50%}.sd-col-7{flex:0 0 auto;-ms-flex:0 0 auto;width:58.3333333333%}.sd-col-8{flex:0 0 auto;-ms-flex:0 0 auto;width:66.6666666667%}.sd-col-9{flex:0 0 auto;-ms-flex:0 0 auto;width:75%}.sd-col-10{flex:0 0 auto;-ms-flex:0 0 auto;width:83.3333333333%}.sd-col-11{flex:0 0 auto;-ms-flex:0 0 auto;width:91.6666666667%}.sd-col-12{flex:0 0 auto;-ms-flex:0 0 auto;width:100%}.sd-g-0,.sd-gy-0{--sd-gutter-y: 0}.sd-g-0,.sd-gx-0{--sd-gutter-x: 0}.sd-g-1,.sd-gy-1{--sd-gutter-y: 0.25rem}.sd-g-1,.sd-gx-1{--sd-gutter-x: 0.25rem}.sd-g-2,.sd-gy-2{--sd-gutter-y: 0.5rem}.sd-g-2,.sd-gx-2{--sd-gutter-x: 0.5rem}.sd-g-3,.sd-gy-3{--sd-gutter-y: 1rem}.sd-g-3,.sd-gx-3{--sd-gutter-x: 1rem}.sd-g-4,.sd-gy-4{--sd-gutter-y: 1.5rem}.sd-g-4,.sd-gx-4{--sd-gutter-x: 1.5rem}.sd-g-5,.sd-gy-5{--sd-gutter-y: 3rem}.sd-g-5,.sd-gx-5{--sd-gutter-x: 3rem}@media(min-width: 576px){.sd-col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-sm-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-sm-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-sm-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-sm-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-sm-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-sm-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-sm-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-sm-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-sm-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-sm-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-sm-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-sm-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-sm-0,.sd-gy-sm-0{--sd-gutter-y: 0}.sd-g-sm-0,.sd-gx-sm-0{--sd-gutter-x: 0}.sd-g-sm-1,.sd-gy-sm-1{--sd-gutter-y: 0.25rem}.sd-g-sm-1,.sd-gx-sm-1{--sd-gutter-x: 0.25rem}.sd-g-sm-2,.sd-gy-sm-2{--sd-gutter-y: 0.5rem}.sd-g-sm-2,.sd-gx-sm-2{--sd-gutter-x: 0.5rem}.sd-g-sm-3,.sd-gy-sm-3{--sd-gutter-y: 1rem}.sd-g-sm-3,.sd-gx-sm-3{--sd-gutter-x: 1rem}.sd-g-sm-4,.sd-gy-sm-4{--sd-gutter-y: 1.5rem}.sd-g-sm-4,.sd-gx-sm-4{--sd-gutter-x: 1.5rem}.sd-g-sm-5,.sd-gy-sm-5{--sd-gutter-y: 3rem}.sd-g-sm-5,.sd-gx-sm-5{--sd-gutter-x: 3rem}}@media(min-width: 768px){.sd-col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-md-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-md-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-md-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-md-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-md-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-md-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-md-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-md-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-md-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-md-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-md-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-md-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-md-0,.sd-gy-md-0{--sd-gutter-y: 0}.sd-g-md-0,.sd-gx-md-0{--sd-gutter-x: 0}.sd-g-md-1,.sd-gy-md-1{--sd-gutter-y: 0.25rem}.sd-g-md-1,.sd-gx-md-1{--sd-gutter-x: 0.25rem}.sd-g-md-2,.sd-gy-md-2{--sd-gutter-y: 0.5rem}.sd-g-md-2,.sd-gx-md-2{--sd-gutter-x: 0.5rem}.sd-g-md-3,.sd-gy-md-3{--sd-gutter-y: 1rem}.sd-g-md-3,.sd-gx-md-3{--sd-gutter-x: 1rem}.sd-g-md-4,.sd-gy-md-4{--sd-gutter-y: 1.5rem}.sd-g-md-4,.sd-gx-md-4{--sd-gutter-x: 1.5rem}.sd-g-md-5,.sd-gy-md-5{--sd-gutter-y: 3rem}.sd-g-md-5,.sd-gx-md-5{--sd-gutter-x: 3rem}}@media(min-width: 992px){.sd-col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-lg-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-lg-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-lg-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-lg-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-lg-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-lg-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-lg-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-lg-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-lg-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-lg-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-lg-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-lg-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-lg-0,.sd-gy-lg-0{--sd-gutter-y: 0}.sd-g-lg-0,.sd-gx-lg-0{--sd-gutter-x: 0}.sd-g-lg-1,.sd-gy-lg-1{--sd-gutter-y: 0.25rem}.sd-g-lg-1,.sd-gx-lg-1{--sd-gutter-x: 0.25rem}.sd-g-lg-2,.sd-gy-lg-2{--sd-gutter-y: 0.5rem}.sd-g-lg-2,.sd-gx-lg-2{--sd-gutter-x: 0.5rem}.sd-g-lg-3,.sd-gy-lg-3{--sd-gutter-y: 1rem}.sd-g-lg-3,.sd-gx-lg-3{--sd-gutter-x: 1rem}.sd-g-lg-4,.sd-gy-lg-4{--sd-gutter-y: 1.5rem}.sd-g-lg-4,.sd-gx-lg-4{--sd-gutter-x: 1.5rem}.sd-g-lg-5,.sd-gy-lg-5{--sd-gutter-y: 3rem}.sd-g-lg-5,.sd-gx-lg-5{--sd-gutter-x: 3rem}}@media(min-width: 1200px){.sd-col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto}.sd-col-xl-1{-ms-flex:0 0 auto;flex:0 0 auto;width:8.3333333333%}.sd-col-xl-2{-ms-flex:0 0 auto;flex:0 0 auto;width:16.6666666667%}.sd-col-xl-3{-ms-flex:0 0 auto;flex:0 0 auto;width:25%}.sd-col-xl-4{-ms-flex:0 0 auto;flex:0 0 auto;width:33.3333333333%}.sd-col-xl-5{-ms-flex:0 0 auto;flex:0 0 auto;width:41.6666666667%}.sd-col-xl-6{-ms-flex:0 0 auto;flex:0 0 auto;width:50%}.sd-col-xl-7{-ms-flex:0 0 auto;flex:0 0 auto;width:58.3333333333%}.sd-col-xl-8{-ms-flex:0 0 auto;flex:0 0 auto;width:66.6666666667%}.sd-col-xl-9{-ms-flex:0 0 auto;flex:0 0 auto;width:75%}.sd-col-xl-10{-ms-flex:0 0 auto;flex:0 0 auto;width:83.3333333333%}.sd-col-xl-11{-ms-flex:0 0 auto;flex:0 0 auto;width:91.6666666667%}.sd-col-xl-12{-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.sd-g-xl-0,.sd-gy-xl-0{--sd-gutter-y: 0}.sd-g-xl-0,.sd-gx-xl-0{--sd-gutter-x: 0}.sd-g-xl-1,.sd-gy-xl-1{--sd-gutter-y: 0.25rem}.sd-g-xl-1,.sd-gx-xl-1{--sd-gutter-x: 0.25rem}.sd-g-xl-2,.sd-gy-xl-2{--sd-gutter-y: 0.5rem}.sd-g-xl-2,.sd-gx-xl-2{--sd-gutter-x: 0.5rem}.sd-g-xl-3,.sd-gy-xl-3{--sd-gutter-y: 1rem}.sd-g-xl-3,.sd-gx-xl-3{--sd-gutter-x: 1rem}.sd-g-xl-4,.sd-gy-xl-4{--sd-gutter-y: 1.5rem}.sd-g-xl-4,.sd-gx-xl-4{--sd-gutter-x: 1.5rem}.sd-g-xl-5,.sd-gy-xl-5{--sd-gutter-y: 3rem}.sd-g-xl-5,.sd-gx-xl-5{--sd-gutter-x: 3rem}}.sd-flex-row-reverse{flex-direction:row-reverse !important}details.sd-dropdown{position:relative}details.sd-dropdown .sd-summary-title{font-weight:700;padding-right:3em !important;-moz-user-select:none;-ms-user-select:none;-webkit-user-select:none;user-select:none}details.sd-dropdown:hover{cursor:pointer}details.sd-dropdown .sd-summary-content{cursor:default}details.sd-dropdown summary{list-style:none;padding:1em}details.sd-dropdown summary .sd-octicon.no-title{vertical-align:middle}details.sd-dropdown[open] summary .sd-octicon.no-title{visibility:hidden}details.sd-dropdown summary::-webkit-details-marker{display:none}details.sd-dropdown summary:focus{outline:none}details.sd-dropdown .sd-summary-icon{margin-right:.5em}details.sd-dropdown .sd-summary-icon svg{opacity:.8}details.sd-dropdown summary:hover .sd-summary-up svg,details.sd-dropdown summary:hover .sd-summary-down svg{opacity:1;transform:scale(1.1)}details.sd-dropdown .sd-summary-up svg,details.sd-dropdown .sd-summary-down svg{display:block;opacity:.6}details.sd-dropdown .sd-summary-up,details.sd-dropdown .sd-summary-down{pointer-events:none;position:absolute;right:1em;top:1em}details.sd-dropdown[open]>.sd-summary-title .sd-summary-down{visibility:hidden}details.sd-dropdown:not([open])>.sd-summary-title .sd-summary-up{visibility:hidden}details.sd-dropdown:not([open]).sd-card{border:none}details.sd-dropdown:not([open])>.sd-card-header{border:1px solid var(--sd-color-card-border);border-radius:.25rem}details.sd-dropdown.sd-fade-in[open] summary~*{-moz-animation:sd-fade-in .5s ease-in-out;-webkit-animation:sd-fade-in .5s ease-in-out;animation:sd-fade-in .5s ease-in-out}details.sd-dropdown.sd-fade-in-slide-down[open] summary~*{-moz-animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out;-webkit-animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out;animation:sd-fade-in .5s ease-in-out,sd-slide-down .5s ease-in-out}.sd-col>.sd-dropdown{width:100%}.sd-summary-content>.sd-tab-set:first-child{margin-top:0}@keyframes sd-fade-in{0%{opacity:0}100%{opacity:1}}@keyframes sd-slide-down{0%{transform:translate(0, -10px)}100%{transform:translate(0, 0)}}.sd-tab-set{border-radius:.125rem;display:flex;flex-wrap:wrap;margin:1em 0;position:relative}.sd-tab-set>input{opacity:0;position:absolute}.sd-tab-set>input:checked+label{border-color:var(--sd-color-tabs-underline-active);color:var(--sd-color-tabs-label-active)}.sd-tab-set>input:checked+label+.sd-tab-content{display:block}.sd-tab-set>input:not(:checked)+label:hover{color:var(--sd-color-tabs-label-hover);border-color:var(--sd-color-tabs-underline-hover)}.sd-tab-set>input:focus+label{outline-style:auto}.sd-tab-set>input:not(.focus-visible)+label{outline:none;-webkit-tap-highlight-color:transparent}.sd-tab-set>label{border-bottom:.125rem solid transparent;margin-bottom:0;color:var(--sd-color-tabs-label-inactive);border-color:var(--sd-color-tabs-underline-inactive);cursor:pointer;font-size:var(--sd-fontsize-tabs-label);font-weight:700;padding:1em 1.25em .5em;transition:color 250ms;width:auto;z-index:1}html .sd-tab-set>label:hover{color:var(--sd-color-tabs-label-active)}.sd-col>.sd-tab-set{width:100%}.sd-tab-content{box-shadow:0 -0.0625rem var(--sd-color-tabs-overline),0 .0625rem var(--sd-color-tabs-underline);display:none;order:99;padding-bottom:.75rem;padding-top:.75rem;width:100%}.sd-tab-content>:first-child{margin-top:0 !important}.sd-tab-content>:last-child{margin-bottom:0 !important}.sd-tab-content>.sd-tab-set{margin:0}.sd-sphinx-override,.sd-sphinx-override *{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}.sd-sphinx-override p{margin-top:0}:root{--sd-color-primary: #0071bc;--sd-color-secondary: #6c757d;--sd-color-success: #28a745;--sd-color-info: #17a2b8;--sd-color-warning: #f0b37e;--sd-color-danger: #dc3545;--sd-color-light: #f8f9fa;--sd-color-muted: #6c757d;--sd-color-dark: #212529;--sd-color-black: black;--sd-color-white: white;--sd-color-primary-highlight: #0060a0;--sd-color-secondary-highlight: #5c636a;--sd-color-success-highlight: #228e3b;--sd-color-info-highlight: #148a9c;--sd-color-warning-highlight: #cc986b;--sd-color-danger-highlight: #bb2d3b;--sd-color-light-highlight: #d3d4d5;--sd-color-muted-highlight: #5c636a;--sd-color-dark-highlight: #1c1f23;--sd-color-black-highlight: black;--sd-color-white-highlight: #d9d9d9;--sd-color-primary-text: #fff;--sd-color-secondary-text: #fff;--sd-color-success-text: #fff;--sd-color-info-text: #fff;--sd-color-warning-text: #212529;--sd-color-danger-text: #fff;--sd-color-light-text: #212529;--sd-color-muted-text: #fff;--sd-color-dark-text: #fff;--sd-color-black-text: #fff;--sd-color-white-text: #212529;--sd-color-shadow: rgba(0, 0, 0, 0.15);--sd-color-card-border: rgba(0, 0, 0, 0.125);--sd-color-card-border-hover: hsla(231, 99%, 66%, 1);--sd-color-card-background: transparent;--sd-color-card-text: inherit;--sd-color-card-header: transparent;--sd-color-card-footer: transparent;--sd-color-tabs-label-active: hsla(231, 99%, 66%, 1);--sd-color-tabs-label-hover: hsla(231, 99%, 66%, 1);--sd-color-tabs-label-inactive: hsl(0, 0%, 66%);--sd-color-tabs-underline-active: hsla(231, 99%, 66%, 1);--sd-color-tabs-underline-hover: rgba(178, 206, 245, 0.62);--sd-color-tabs-underline-inactive: transparent;--sd-color-tabs-overline: rgb(222, 222, 222);--sd-color-tabs-underline: rgb(222, 222, 222);--sd-fontsize-tabs-label: 1rem} diff --git a/docs.bak/_build/html/_static/design-tabs.js b/docs.bak/_build/html/_static/design-tabs.js new file mode 100644 index 0000000..36b38cf --- /dev/null +++ b/docs.bak/_build/html/_static/design-tabs.js @@ -0,0 +1,27 @@ +var sd_labels_by_text = {}; + +function ready() { + const li = document.getElementsByClassName("sd-tab-label"); + for (const label of li) { + syncId = label.getAttribute("data-sync-id"); + if (syncId) { + label.onclick = onLabelClick; + if (!sd_labels_by_text[syncId]) { + sd_labels_by_text[syncId] = []; + } + sd_labels_by_text[syncId].push(label); + } + } +} + +function onLabelClick() { + // Activate other inputs with the same sync id. + syncId = this.getAttribute("data-sync-id"); + for (label of sd_labels_by_text[syncId]) { + if (label === this) continue; + label.previousElementSibling.checked = true; + } + window.localStorage.setItem("sphinx-design-last-tab", syncId); +} + +document.addEventListener("DOMContentLoaded", ready, false); diff --git a/docs.bak/_build/html/_static/doctools.js b/docs.bak/_build/html/_static/doctools.js new file mode 100644 index 0000000..d06a71d --- /dev/null +++ b/docs.bak/_build/html/_static/doctools.js @@ -0,0 +1,156 @@ +/* + * doctools.js + * ~~~~~~~~~~~ + * + * Base JavaScript utilities for all Sphinx HTML documentation. + * + * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ +"use strict"; + +const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ + "TEXTAREA", + "INPUT", + "SELECT", + "BUTTON", +]); + +const _ready = (callback) => { + if (document.readyState !== "loading") { + callback(); + } else { + document.addEventListener("DOMContentLoaded", callback); + } +}; + +/** + * Small JavaScript module for the documentation. + */ +const Documentation = { + init: () => { + Documentation.initDomainIndexTable(); + Documentation.initOnKeyListeners(); + }, + + /** + * i18n support + */ + TRANSLATIONS: {}, + PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), + LOCALE: "unknown", + + // gettext and ngettext don't access this so that the functions + // can safely bound to a different name (_ = Documentation.gettext) + gettext: (string) => { + const translated = Documentation.TRANSLATIONS[string]; + switch (typeof translated) { + case "undefined": + return string; // no translation + case "string": + return translated; // translation exists + default: + return translated[0]; // (singular, plural) translation tuple exists + } + }, + + ngettext: (singular, plural, n) => { + const translated = Documentation.TRANSLATIONS[singular]; + if (typeof translated !== "undefined") + return translated[Documentation.PLURAL_EXPR(n)]; + return n === 1 ? singular : plural; + }, + + addTranslations: (catalog) => { + Object.assign(Documentation.TRANSLATIONS, catalog.messages); + Documentation.PLURAL_EXPR = new Function( + "n", + `return (${catalog.plural_expr})` + ); + Documentation.LOCALE = catalog.locale; + }, + + /** + * helper function to focus on search bar + */ + focusSearchBar: () => { + document.querySelectorAll("input[name=q]")[0]?.focus(); + }, + + /** + * Initialise the domain index toggle buttons + */ + initDomainIndexTable: () => { + const toggler = (el) => { + const idNumber = el.id.substr(7); + const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); + if (el.src.substr(-9) === "minus.png") { + el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; + toggledRows.forEach((el) => (el.style.display = "none")); + } else { + el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; + toggledRows.forEach((el) => (el.style.display = "")); + } + }; + + const togglerElements = document.querySelectorAll("img.toggler"); + togglerElements.forEach((el) => + el.addEventListener("click", (event) => toggler(event.currentTarget)) + ); + togglerElements.forEach((el) => (el.style.display = "")); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); + }, + + initOnKeyListeners: () => { + // only install a listener if it is really needed + if ( + !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS && + !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + ) + return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (!event.shiftKey) { + switch (event.key) { + case "ArrowLeft": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const prevLink = document.querySelector('link[rel="prev"]'); + if (prevLink && prevLink.href) { + window.location.href = prevLink.href; + event.preventDefault(); + } + break; + case "ArrowRight": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const nextLink = document.querySelector('link[rel="next"]'); + if (nextLink && nextLink.href) { + window.location.href = nextLink.href; + event.preventDefault(); + } + break; + } + } + + // some keyboard layouts may need Shift to get / + switch (event.key) { + case "/": + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; + Documentation.focusSearchBar(); + event.preventDefault(); + } + }); + }, +}; + +// quick alias for translations +const _ = Documentation.gettext; + +_ready(Documentation.init); diff --git a/docs.bak/_build/html/_static/documentation_options.js b/docs.bak/_build/html/_static/documentation_options.js new file mode 100644 index 0000000..7e4c114 --- /dev/null +++ b/docs.bak/_build/html/_static/documentation_options.js @@ -0,0 +1,13 @@ +const DOCUMENTATION_OPTIONS = { + VERSION: '', + LANGUAGE: 'en', + COLLAPSE_INDEX: false, + BUILDER: 'html', + FILE_SUFFIX: '.html', + LINK_SUFFIX: '.html', + HAS_SOURCE: true, + SOURCELINK_SUFFIX: '.txt', + NAVIGATION_WITH_KEYS: false, + SHOW_SEARCH_SUMMARY: true, + ENABLE_SEARCH_SHORTCUTS: true, +}; \ No newline at end of file diff --git a/docs.bak/_build/html/_static/file.png b/docs.bak/_build/html/_static/file.png new file mode 100644 index 0000000..a858a41 Binary files /dev/null and b/docs.bak/_build/html/_static/file.png differ diff --git a/docs.bak/_build/html/_static/jquery.js b/docs.bak/_build/html/_static/jquery.js new file mode 100644 index 0000000..c4c6022 --- /dev/null +++ b/docs.bak/_build/html/_static/jquery.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=y.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=y.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),y.elements=c+" "+a,j(b)}function f(a){var b=x[a[v]];return b||(b={},w++,a[v]=w,x[w]=b),b}function g(a,c,d){if(c||(c=b),q)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():u.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||t.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),q)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return y.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(y,b.frag)}function j(a){a||(a=b);var d=f(a);return!y.shivCSS||p||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),q||i(a,d),a}function k(a){for(var b,c=a.getElementsByTagName("*"),e=c.length,f=RegExp("^(?:"+d().join("|")+")$","i"),g=[];e--;)b=c[e],f.test(b.nodeName)&&g.push(b.applyElement(l(b)));return g}function l(a){for(var b,c=a.attributes,d=c.length,e=a.ownerDocument.createElement(A+":"+a.nodeName);d--;)b=c[d],b.specified&&e.setAttribute(b.nodeName,b.nodeValue);return e.style.cssText=a.style.cssText,e}function m(a){for(var b,c=a.split("{"),e=c.length,f=RegExp("(^|[\\s,>+~])("+d().join("|")+")(?=[[\\s,>+~#.:]|$)","gi"),g="$1"+A+"\\:$2";e--;)b=c[e]=c[e].split("}"),b[b.length-1]=b[b.length-1].replace(f,g),c[e]=b.join("}");return c.join("{")}function n(a){for(var b=a.length;b--;)a[b].removeNode()}function o(a){function b(){clearTimeout(g._removeSheetTimer),d&&d.removeNode(!0),d=null}var d,e,g=f(a),h=a.namespaces,i=a.parentWindow;return!B||a.printShived?a:("undefined"==typeof h[A]&&h.add(A),i.attachEvent("onbeforeprint",function(){b();for(var f,g,h,i=a.styleSheets,j=[],l=i.length,n=Array(l);l--;)n[l]=i[l];for(;h=n.pop();)if(!h.disabled&&z.test(h.media)){try{f=h.imports,g=f.length}catch(o){g=0}for(l=0;g>l;l++)n.push(f[l]);try{j.push(h.cssText)}catch(o){}}j=m(j.reverse().join("")),e=k(a),d=c(a,j)}),i.attachEvent("onafterprint",function(){n(e),clearTimeout(g._removeSheetTimer),g._removeSheetTimer=setTimeout(b,500)}),a.printShived=!0,a)}var p,q,r="3.7.3",s=a.html5||{},t=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,u=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",w=0,x={};!function(){try{var a=b.createElement("a");a.innerHTML="",p="hidden"in a,q=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){p=!0,q=!0}}();var y={elements:s.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:r,shivCSS:s.shivCSS!==!1,supportsUnknownElements:q,shivMethods:s.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=y,j(b);var z=/^$|\b(?:all|print)\b/,A="html5shiv",B=!q&&function(){var c=b.documentElement;return!("undefined"==typeof b.namespaces||"undefined"==typeof b.parentWindow||"undefined"==typeof c.applyElement||"undefined"==typeof c.removeNode||"undefined"==typeof a.attachEvent)}();y.type+=" print",y.shivPrint=o,o(b),"object"==typeof module&&module.exports&&(module.exports=y)}("undefined"!=typeof window?window:this,document); \ No newline at end of file diff --git a/docs.bak/_build/html/_static/js/html5shiv.min.js b/docs.bak/_build/html/_static/js/html5shiv.min.js new file mode 100644 index 0000000..cd1c674 --- /dev/null +++ b/docs.bak/_build/html/_static/js/html5shiv.min.js @@ -0,0 +1,4 @@ +/** +* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed +*/ +!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.3-pre",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b),"object"==typeof module&&module.exports&&(module.exports=t)}("undefined"!=typeof window?window:this,document); \ No newline at end of file diff --git a/docs.bak/_build/html/_static/js/theme.js b/docs.bak/_build/html/_static/js/theme.js new file mode 100644 index 0000000..1fddb6e --- /dev/null +++ b/docs.bak/_build/html/_static/js/theme.js @@ -0,0 +1 @@ +!function(n){var e={};function t(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return n[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=n,t.c=e,t.d=function(n,e,i){t.o(n,e)||Object.defineProperty(n,e,{enumerable:!0,get:i})},t.r=function(n){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(n,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(n,"__esModule",{value:!0})},t.t=function(n,e){if(1&e&&(n=t(n)),8&e)return n;if(4&e&&"object"==typeof n&&n&&n.__esModule)return n;var i=Object.create(null);if(t.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:n}),2&e&&"string"!=typeof n)for(var o in n)t.d(i,o,function(e){return n[e]}.bind(null,o));return i},t.n=function(n){var e=n&&n.__esModule?function(){return n.default}:function(){return n};return t.d(e,"a",e),e},t.o=function(n,e){return Object.prototype.hasOwnProperty.call(n,e)},t.p="",t(t.s=0)}([function(n,e,t){t(1),n.exports=t(3)},function(n,e,t){(function(){var e="undefined"!=typeof window?window.jQuery:t(2);n.exports.ThemeNav={navBar:null,win:null,winScroll:!1,winResize:!1,linkScroll:!1,winPosition:0,winHeight:null,docHeight:null,isRunning:!1,enable:function(n){var t=this;void 0===n&&(n=!0),t.isRunning||(t.isRunning=!0,e((function(e){t.init(e),t.reset(),t.win.on("hashchange",t.reset),n&&t.win.on("scroll",(function(){t.linkScroll||t.winScroll||(t.winScroll=!0,requestAnimationFrame((function(){t.onScroll()})))})),t.win.on("resize",(function(){t.winResize||(t.winResize=!0,requestAnimationFrame((function(){t.onResize()})))})),t.onResize()})))},enableSticky:function(){this.enable(!0)},init:function(n){n(document);var e=this;this.navBar=n("div.wy-side-scroll:first"),this.win=n(window),n(document).on("click","[data-toggle='wy-nav-top']",(function(){n("[data-toggle='wy-nav-shift']").toggleClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift")})).on("click",".wy-menu-vertical .current ul li a",(function(){var t=n(this);n("[data-toggle='wy-nav-shift']").removeClass("shift"),n("[data-toggle='rst-versions']").toggleClass("shift"),e.toggleCurrent(t),e.hashChange()})).on("click","[data-toggle='rst-current-version']",(function(){n("[data-toggle='rst-versions']").toggleClass("shift-up")})),n("table.docutils:not(.field-list,.footnote,.citation)").wrap("
"),n("table.docutils.footnote").wrap("
"),n("table.docutils.citation").wrap("
"),n(".wy-menu-vertical ul").not(".simple").siblings("a").each((function(){var t=n(this);expand=n(''),expand.on("click",(function(n){return e.toggleCurrent(t),n.stopPropagation(),!1})),t.prepend(expand)}))},reset:function(){var n=encodeURI(window.location.hash)||"#";try{var e=$(".wy-menu-vertical"),t=e.find('[href="'+n+'"]');if(0===t.length){var i=$('.document [id="'+n.substring(1)+'"]').closest("div.section");0===(t=e.find('[href="#'+i.attr("id")+'"]')).length&&(t=e.find('[href="#"]'))}if(t.length>0){$(".wy-menu-vertical .current").removeClass("current").attr("aria-expanded","false"),t.addClass("current").attr("aria-expanded","true"),t.closest("li.toctree-l1").parent().addClass("current").attr("aria-expanded","true");for(let n=1;n<=10;n++)t.closest("li.toctree-l"+n).addClass("current").attr("aria-expanded","true");t[0].scrollIntoView()}}catch(n){console.log("Error expanding nav for anchor",n)}},onScroll:function(){this.winScroll=!1;var n=this.win.scrollTop(),e=n+this.winHeight,t=this.navBar.scrollTop()+(n-this.winPosition);n<0||e>this.docHeight||(this.navBar.scrollTop(t),this.winPosition=n)},onResize:function(){this.winResize=!1,this.winHeight=this.win.height(),this.docHeight=$(document).height()},hashChange:function(){this.linkScroll=!0,this.win.one("hashchange",(function(){this.linkScroll=!1}))},toggleCurrent:function(n){var e=n.closest("li");e.siblings("li.current").removeClass("current").attr("aria-expanded","false"),e.siblings().find("li.current").removeClass("current").attr("aria-expanded","false");var t=e.find("> ul li");t.length&&(t.removeClass("current").attr("aria-expanded","false"),e.toggleClass("current").attr("aria-expanded",(function(n,e){return"true"==e?"false":"true"})))}},"undefined"!=typeof window&&(window.SphinxRtdTheme={Navigation:n.exports.ThemeNav,StickyNav:n.exports.ThemeNav}),function(){for(var n=0,e=["ms","moz","webkit","o"],t=0;t0 + var meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$"; // [C]VC[V] is m=1 + var mgr1 = "^(" + C + ")?" + V + C + V + C; // [C]VCVC... is m>1 + var s_v = "^(" + C + ")?" + v; // vowel in stem + + this.stemWord = function (w) { + var stem; + var suffix; + var firstch; + var origword = w; + + if (w.length < 3) + return w; + + var re; + var re2; + var re3; + var re4; + + firstch = w.substr(0,1); + if (firstch == "y") + w = firstch.toUpperCase() + w.substr(1); + + // Step 1a + re = /^(.+?)(ss|i)es$/; + re2 = /^(.+?)([^s])s$/; + + if (re.test(w)) + w = w.replace(re,"$1$2"); + else if (re2.test(w)) + w = w.replace(re2,"$1$2"); + + // Step 1b + re = /^(.+?)eed$/; + re2 = /^(.+?)(ed|ing)$/; + if (re.test(w)) { + var fp = re.exec(w); + re = new RegExp(mgr0); + if (re.test(fp[1])) { + re = /.$/; + w = w.replace(re,""); + } + } + else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1]; + re2 = new RegExp(s_v); + if (re2.test(stem)) { + w = stem; + re2 = /(at|bl|iz)$/; + re3 = new RegExp("([^aeiouylsz])\\1$"); + re4 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + if (re2.test(w)) + w = w + "e"; + else if (re3.test(w)) { + re = /.$/; + w = w.replace(re,""); + } + else if (re4.test(w)) + w = w + "e"; + } + } + + // Step 1c + re = /^(.+?)y$/; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = new RegExp(s_v); + if (re.test(stem)) + w = stem + "i"; + } + + // Step 2 + re = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = new RegExp(mgr0); + if (re.test(stem)) + w = stem + step2list[suffix]; + } + + // Step 3 + re = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = new RegExp(mgr0); + if (re.test(stem)) + w = stem + step3list[suffix]; + } + + // Step 4 + re = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; + re2 = /^(.+?)(s|t)(ion)$/; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = new RegExp(mgr1); + if (re.test(stem)) + w = stem; + } + else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1] + fp[2]; + re2 = new RegExp(mgr1); + if (re2.test(stem)) + w = stem; + } + + // Step 5 + re = /^(.+?)e$/; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = new RegExp(mgr1); + re2 = new RegExp(meq1); + re3 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) + w = stem; + } + re = /ll$/; + re2 = new RegExp(mgr1); + if (re.test(w) && re2.test(w)) { + re = /.$/; + w = w.replace(re,""); + } + + // and turn initial Y back to y + if (firstch == "y") + w = firstch.toLowerCase() + w.substr(1); + return w; + } +} + diff --git a/docs.bak/_build/html/_static/minus.png b/docs.bak/_build/html/_static/minus.png new file mode 100644 index 0000000..d96755f Binary files /dev/null and b/docs.bak/_build/html/_static/minus.png differ diff --git a/docs.bak/_build/html/_static/plus.png b/docs.bak/_build/html/_static/plus.png new file mode 100644 index 0000000..7107cec Binary files /dev/null and b/docs.bak/_build/html/_static/plus.png differ diff --git a/docs.bak/_build/html/_static/pygments.css b/docs.bak/_build/html/_static/pygments.css new file mode 100644 index 0000000..84ab303 --- /dev/null +++ b/docs.bak/_build/html/_static/pygments.css @@ -0,0 +1,75 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight .hll { background-color: #ffffcc } +.highlight { background: #f8f8f8; } +.highlight .c { color: #3D7B7B; font-style: italic } /* Comment */ +.highlight .err { border: 1px solid #FF0000 } /* Error */ +.highlight .k { color: #008000; font-weight: bold } /* Keyword */ +.highlight .o { color: #666666 } /* Operator */ +.highlight .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.highlight .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #9C6500 } /* Comment.Preproc */ +.highlight .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.highlight .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.highlight .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.highlight .gd { color: #A00000 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.highlight .gr { color: #E40000 } /* Generic.Error */ +.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.highlight .gi { color: #008400 } /* Generic.Inserted */ +.highlight .go { color: #717171 } /* Generic.Output */ +.highlight .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: #0044DD } /* Generic.Traceback */ +.highlight .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #008000 } /* Keyword.Pseudo */ +.highlight .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #B00040 } /* Keyword.Type */ +.highlight .m { color: #666666 } /* Literal.Number */ +.highlight .s { color: #BA2121 } /* Literal.String */ +.highlight .na { color: #687822 } /* Name.Attribute */ +.highlight .nb { color: #008000 } /* Name.Builtin */ +.highlight .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.highlight .no { color: #880000 } /* Name.Constant */ +.highlight .nd { color: #AA22FF } /* Name.Decorator */ +.highlight .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.highlight .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #0000FF } /* Name.Function */ +.highlight .nl { color: #767600 } /* Name.Label */ +.highlight .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.highlight .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.highlight .nv { color: #19177C } /* Name.Variable */ +.highlight .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #bbbbbb } /* Text.Whitespace */ +.highlight .mb { color: #666666 } /* Literal.Number.Bin */ +.highlight .mf { color: #666666 } /* Literal.Number.Float */ +.highlight .mh { color: #666666 } /* Literal.Number.Hex */ +.highlight .mi { color: #666666 } /* Literal.Number.Integer */ +.highlight .mo { color: #666666 } /* Literal.Number.Oct */ +.highlight .sa { color: #BA2121 } /* Literal.String.Affix */ +.highlight .sb { color: #BA2121 } /* Literal.String.Backtick */ +.highlight .sc { color: #BA2121 } /* Literal.String.Char */ +.highlight .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.highlight .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: #BA2121 } /* Literal.String.Double */ +.highlight .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.highlight .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.highlight .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.highlight .sx { color: #008000 } /* Literal.String.Other */ +.highlight .sr { color: #A45A77 } /* Literal.String.Regex */ +.highlight .s1 { color: #BA2121 } /* Literal.String.Single */ +.highlight .ss { color: #19177C } /* Literal.String.Symbol */ +.highlight .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #0000FF } /* Name.Function.Magic */ +.highlight .vc { color: #19177C } /* Name.Variable.Class */ +.highlight .vg { color: #19177C } /* Name.Variable.Global */ +.highlight .vi { color: #19177C } /* Name.Variable.Instance */ +.highlight .vm { color: #19177C } /* Name.Variable.Magic */ +.highlight .il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs.bak/_build/html/_static/searchtools.js b/docs.bak/_build/html/_static/searchtools.js new file mode 100644 index 0000000..7918c3f --- /dev/null +++ b/docs.bak/_build/html/_static/searchtools.js @@ -0,0 +1,574 @@ +/* + * searchtools.js + * ~~~~~~~~~~~~~~~~ + * + * Sphinx JavaScript utilities for the full-text search. + * + * :copyright: Copyright 2007-2023 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ +"use strict"; + +/** + * Simple result scoring code. + */ +if (typeof Scorer === "undefined") { + var Scorer = { + // Implement the following function to further tweak the score for each result + // The function takes a result array [docname, title, anchor, descr, score, filename] + // and returns the new score. + /* + score: result => { + const [docname, title, anchor, descr, score, filename] = result + return score + }, + */ + + // query matches the full name of an object + objNameMatch: 11, + // or matches in the last dotted part of the object name + objPartialMatch: 6, + // Additive scores depending on the priority of the object + objPrio: { + 0: 15, // used to be importantResults + 1: 5, // used to be objectResults + 2: -5, // used to be unimportantResults + }, + // Used when the priority is not in the mapping. + objPrioDefault: 0, + + // query found in title + title: 15, + partialTitle: 7, + // query found in terms + term: 5, + partialTerm: 2, + }; +} + +const _removeChildren = (element) => { + while (element && element.lastChild) element.removeChild(element.lastChild); +}; + +/** + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + */ +const _escapeRegExp = (string) => + string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string + +const _displayItem = (item, searchTerms, highlightTerms) => { + const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; + const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; + const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; + const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; + const contentRoot = document.documentElement.dataset.content_root; + + const [docName, title, anchor, descr, score, _filename] = item; + + let listItem = document.createElement("li"); + let requestUrl; + let linkUrl; + if (docBuilder === "dirhtml") { + // dirhtml builder + let dirname = docName + "/"; + if (dirname.match(/\/index\/$/)) + dirname = dirname.substring(0, dirname.length - 6); + else if (dirname === "index/") dirname = ""; + requestUrl = contentRoot + dirname; + linkUrl = requestUrl; + } else { + // normal html builders + requestUrl = contentRoot + docName + docFileSuffix; + linkUrl = docName + docLinkSuffix; + } + let linkEl = listItem.appendChild(document.createElement("a")); + linkEl.href = linkUrl + anchor; + linkEl.dataset.score = score; + linkEl.innerHTML = title; + if (descr) { + listItem.appendChild(document.createElement("span")).innerHTML = + " (" + descr + ")"; + // highlight search terms in the description + if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js + highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); + } + else if (showSearchSummary) + fetch(requestUrl) + .then((responseData) => responseData.text()) + .then((data) => { + if (data) + listItem.appendChild( + Search.makeSearchSummary(data, searchTerms) + ); + // highlight search terms in the summary + if (SPHINX_HIGHLIGHT_ENABLED) // set in sphinx_highlight.js + highlightTerms.forEach((term) => _highlightText(listItem, term, "highlighted")); + }); + Search.output.appendChild(listItem); +}; +const _finishSearch = (resultCount) => { + Search.stopPulse(); + Search.title.innerText = _("Search Results"); + if (!resultCount) + Search.status.innerText = Documentation.gettext( + "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories." + ); + else + Search.status.innerText = _( + `Search finished, found ${resultCount} page(s) matching the search query.` + ); +}; +const _displayNextItem = ( + results, + resultCount, + searchTerms, + highlightTerms, +) => { + // results left, load the summary and display it + // this is intended to be dynamic (don't sub resultsCount) + if (results.length) { + _displayItem(results.pop(), searchTerms, highlightTerms); + setTimeout( + () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), + 5 + ); + } + // search finished, update title and status message + else _finishSearch(resultCount); +}; + +/** + * Default splitQuery function. Can be overridden in ``sphinx.search`` with a + * custom function per language. + * + * The regular expression works by splitting the string on consecutive characters + * that are not Unicode letters, numbers, underscores, or emoji characters. + * This is the same as ``\W+`` in Python, preserving the surrogate pair area. + */ +if (typeof splitQuery === "undefined") { + var splitQuery = (query) => query + .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) + .filter(term => term) // remove remaining empty strings +} + +/** + * Search Module + */ +const Search = { + _index: null, + _queued_query: null, + _pulse_status: -1, + + htmlToText: (htmlString) => { + const htmlElement = new DOMParser().parseFromString(htmlString, 'text/html'); + htmlElement.querySelectorAll(".headerlink").forEach((el) => { el.remove() }); + const docContent = htmlElement.querySelector('[role="main"]'); + if (docContent !== undefined) return docContent.textContent; + console.warn( + "Content block not found. Sphinx search tries to obtain it via '[role=main]'. Could you check your theme or template." + ); + return ""; + }, + + init: () => { + const query = new URLSearchParams(window.location.search).get("q"); + document + .querySelectorAll('input[name="q"]') + .forEach((el) => (el.value = query)); + if (query) Search.performSearch(query); + }, + + loadIndex: (url) => + (document.body.appendChild(document.createElement("script")).src = url), + + setIndex: (index) => { + Search._index = index; + if (Search._queued_query !== null) { + const query = Search._queued_query; + Search._queued_query = null; + Search.query(query); + } + }, + + hasIndex: () => Search._index !== null, + + deferQuery: (query) => (Search._queued_query = query), + + stopPulse: () => (Search._pulse_status = -1), + + startPulse: () => { + if (Search._pulse_status >= 0) return; + + const pulse = () => { + Search._pulse_status = (Search._pulse_status + 1) % 4; + Search.dots.innerText = ".".repeat(Search._pulse_status); + if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); + }; + pulse(); + }, + + /** + * perform a search for something (or wait until index is loaded) + */ + performSearch: (query) => { + // create the required interface elements + const searchText = document.createElement("h2"); + searchText.textContent = _("Searching"); + const searchSummary = document.createElement("p"); + searchSummary.classList.add("search-summary"); + searchSummary.innerText = ""; + const searchList = document.createElement("ul"); + searchList.classList.add("search"); + + const out = document.getElementById("search-results"); + Search.title = out.appendChild(searchText); + Search.dots = Search.title.appendChild(document.createElement("span")); + Search.status = out.appendChild(searchSummary); + Search.output = out.appendChild(searchList); + + const searchProgress = document.getElementById("search-progress"); + // Some themes don't use the search progress node + if (searchProgress) { + searchProgress.innerText = _("Preparing search..."); + } + Search.startPulse(); + + // index already loaded, the browser was quick! + if (Search.hasIndex()) Search.query(query); + else Search.deferQuery(query); + }, + + /** + * execute search (requires search index to be loaded) + */ + query: (query) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + const allTitles = Search._index.alltitles; + const indexEntries = Search._index.indexentries; + + // stem the search terms and add them to the correct list + const stemmer = new Stemmer(); + const searchTerms = new Set(); + const excludedTerms = new Set(); + const highlightTerms = new Set(); + const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); + splitQuery(query.trim()).forEach((queryTerm) => { + const queryTermLower = queryTerm.toLowerCase(); + + // maybe skip this "word" + // stopwords array is from language_data.js + if ( + stopwords.indexOf(queryTermLower) !== -1 || + queryTerm.match(/^\d+$/) + ) + return; + + // stem the word + let word = stemmer.stemWord(queryTermLower); + // select the correct list + if (word[0] === "-") excludedTerms.add(word.substr(1)); + else { + searchTerms.add(word); + highlightTerms.add(queryTermLower); + } + }); + + if (SPHINX_HIGHLIGHT_ENABLED) { // set in sphinx_highlight.js + localStorage.setItem("sphinx_highlight_terms", [...highlightTerms].join(" ")) + } + + // console.debug("SEARCH: searching for:"); + // console.info("required: ", [...searchTerms]); + // console.info("excluded: ", [...excludedTerms]); + + // array of [docname, title, anchor, descr, score, filename] + let results = []; + _removeChildren(document.getElementById("search-progress")); + + const queryLower = query.toLowerCase(); + for (const [title, foundTitles] of Object.entries(allTitles)) { + if (title.toLowerCase().includes(queryLower) && (queryLower.length >= title.length/2)) { + for (const [file, id] of foundTitles) { + let score = Math.round(100 * queryLower.length / title.length) + results.push([ + docNames[file], + titles[file] !== title ? `${titles[file]} > ${title}` : title, + id !== null ? "#" + id : "", + null, + score, + filenames[file], + ]); + } + } + } + + // search for explicit entries in index directives + for (const [entry, foundEntries] of Object.entries(indexEntries)) { + if (entry.includes(queryLower) && (queryLower.length >= entry.length/2)) { + for (const [file, id] of foundEntries) { + let score = Math.round(100 * queryLower.length / entry.length) + results.push([ + docNames[file], + titles[file], + id ? "#" + id : "", + null, + score, + filenames[file], + ]); + } + } + } + + // lookup as object + objectTerms.forEach((term) => + results.push(...Search.performObjectSearch(term, objectTerms)) + ); + + // lookup as search terms in fulltext + results.push(...Search.performTermsSearch(searchTerms, excludedTerms)); + + // let the scorer override scores with a custom scoring function + if (Scorer.score) results.forEach((item) => (item[4] = Scorer.score(item))); + + // now sort the results by score (in opposite order of appearance, since the + // display function below uses pop() to retrieve items) and then + // alphabetically + results.sort((a, b) => { + const leftScore = a[4]; + const rightScore = b[4]; + if (leftScore === rightScore) { + // same score: sort alphabetically + const leftTitle = a[1].toLowerCase(); + const rightTitle = b[1].toLowerCase(); + if (leftTitle === rightTitle) return 0; + return leftTitle > rightTitle ? -1 : 1; // inverted is intentional + } + return leftScore > rightScore ? 1 : -1; + }); + + // remove duplicate search results + // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept + let seen = new Set(); + results = results.reverse().reduce((acc, result) => { + let resultStr = result.slice(0, 4).concat([result[5]]).map(v => String(v)).join(','); + if (!seen.has(resultStr)) { + acc.push(result); + seen.add(resultStr); + } + return acc; + }, []); + + results = results.reverse(); + + // for debugging + //Search.lastresults = results.slice(); // a copy + // console.info("search results:", Search.lastresults); + + // print the results + _displayNextItem(results, results.length, searchTerms, highlightTerms); + }, + + /** + * search for object names + */ + performObjectSearch: (object, objectTerms) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const objects = Search._index.objects; + const objNames = Search._index.objnames; + const titles = Search._index.titles; + + const results = []; + + const objectSearchCallback = (prefix, match) => { + const name = match[4] + const fullname = (prefix ? prefix + "." : "") + name; + const fullnameLower = fullname.toLowerCase(); + if (fullnameLower.indexOf(object) < 0) return; + + let score = 0; + const parts = fullnameLower.split("."); + + // check for different match types: exact matches of full name or + // "last name" (i.e. last dotted part) + if (fullnameLower === object || parts.slice(-1)[0] === object) + score += Scorer.objNameMatch; + else if (parts.slice(-1)[0].indexOf(object) > -1) + score += Scorer.objPartialMatch; // matches in last name + + const objName = objNames[match[1]][2]; + const title = titles[match[0]]; + + // If more than one term searched for, we require other words to be + // found in the name/title/description + const otherTerms = new Set(objectTerms); + otherTerms.delete(object); + if (otherTerms.size > 0) { + const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); + if ( + [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) + ) + return; + } + + let anchor = match[3]; + if (anchor === "") anchor = fullname; + else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; + + const descr = objName + _(", in ") + title; + + // add custom score for some objects according to scorer + if (Scorer.objPrio.hasOwnProperty(match[2])) + score += Scorer.objPrio[match[2]]; + else score += Scorer.objPrioDefault; + + results.push([ + docNames[match[0]], + fullname, + "#" + anchor, + descr, + score, + filenames[match[0]], + ]); + }; + Object.keys(objects).forEach((prefix) => + objects[prefix].forEach((array) => + objectSearchCallback(prefix, array) + ) + ); + return results; + }, + + /** + * search for full-text terms in the index + */ + performTermsSearch: (searchTerms, excludedTerms) => { + // prepare search + const terms = Search._index.terms; + const titleTerms = Search._index.titleterms; + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + + const scoreMap = new Map(); + const fileMap = new Map(); + + // perform the search on the required terms + searchTerms.forEach((word) => { + const files = []; + const arr = [ + { files: terms[word], score: Scorer.term }, + { files: titleTerms[word], score: Scorer.title }, + ]; + // add support for partial matches + if (word.length > 2) { + const escapedWord = _escapeRegExp(word); + Object.keys(terms).forEach((term) => { + if (term.match(escapedWord) && !terms[word]) + arr.push({ files: terms[term], score: Scorer.partialTerm }); + }); + Object.keys(titleTerms).forEach((term) => { + if (term.match(escapedWord) && !titleTerms[word]) + arr.push({ files: titleTerms[word], score: Scorer.partialTitle }); + }); + } + + // no match but word was a required one + if (arr.every((record) => record.files === undefined)) return; + + // found search word in contents + arr.forEach((record) => { + if (record.files === undefined) return; + + let recordFiles = record.files; + if (recordFiles.length === undefined) recordFiles = [recordFiles]; + files.push(...recordFiles); + + // set score for the word in each file + recordFiles.forEach((file) => { + if (!scoreMap.has(file)) scoreMap.set(file, {}); + scoreMap.get(file)[word] = record.score; + }); + }); + + // create the mapping + files.forEach((file) => { + if (fileMap.has(file) && fileMap.get(file).indexOf(word) === -1) + fileMap.get(file).push(word); + else fileMap.set(file, [word]); + }); + }); + + // now check if the files don't contain excluded terms + const results = []; + for (const [file, wordList] of fileMap) { + // check if all requirements are matched + + // as search terms with length < 3 are discarded + const filteredTermCount = [...searchTerms].filter( + (term) => term.length > 2 + ).length; + if ( + wordList.length !== searchTerms.size && + wordList.length !== filteredTermCount + ) + continue; + + // ensure that none of the excluded terms is in the search result + if ( + [...excludedTerms].some( + (term) => + terms[term] === file || + titleTerms[term] === file || + (terms[term] || []).includes(file) || + (titleTerms[term] || []).includes(file) + ) + ) + break; + + // select one (max) score for the file. + const score = Math.max(...wordList.map((w) => scoreMap.get(file)[w])); + // add result to the result list + results.push([ + docNames[file], + titles[file], + "", + null, + score, + filenames[file], + ]); + } + return results; + }, + + /** + * helper function to return a node containing the + * search summary for a given text. keywords is a list + * of stemmed words. + */ + makeSearchSummary: (htmlText, keywords) => { + const text = Search.htmlToText(htmlText); + if (text === "") return null; + + const textLower = text.toLowerCase(); + const actualStartPosition = [...keywords] + .map((k) => textLower.indexOf(k.toLowerCase())) + .filter((i) => i > -1) + .slice(-1)[0]; + const startWithContext = Math.max(actualStartPosition - 120, 0); + + const top = startWithContext === 0 ? "" : "..."; + const tail = startWithContext + 240 < text.length ? "..." : ""; + + let summary = document.createElement("p"); + summary.classList.add("context"); + summary.textContent = top + text.substr(startWithContext, 240).trim() + tail; + + return summary; + }, +}; + +_ready(Search.init); diff --git a/docs.bak/_build/html/_static/sphinx_highlight.js b/docs.bak/_build/html/_static/sphinx_highlight.js new file mode 100644 index 0000000..8a96c69 --- /dev/null +++ b/docs.bak/_build/html/_static/sphinx_highlight.js @@ -0,0 +1,154 @@ +/* Highlighting utilities for Sphinx HTML documentation. */ +"use strict"; + +const SPHINX_HIGHLIGHT_ENABLED = true + +/** + * highlight a given string on a node by wrapping it in + * span elements with the given class name. + */ +const _highlight = (node, addItems, text, className) => { + if (node.nodeType === Node.TEXT_NODE) { + const val = node.nodeValue; + const parent = node.parentNode; + const pos = val.toLowerCase().indexOf(text); + if ( + pos >= 0 && + !parent.classList.contains(className) && + !parent.classList.contains("nohighlight") + ) { + let span; + + const closestNode = parent.closest("body, svg, foreignObject"); + const isInSVG = closestNode && closestNode.matches("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.classList.add(className); + } + + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + const rest = document.createTextNode(val.substr(pos + text.length)); + parent.insertBefore( + span, + parent.insertBefore( + rest, + node.nextSibling + ) + ); + node.nodeValue = val.substr(0, pos); + /* There may be more occurrences of search term in this node. So call this + * function recursively on the remaining fragment. + */ + _highlight(rest, addItems, text, className); + + if (isInSVG) { + const rect = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect" + ); + const bbox = parent.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute("class", className); + addItems.push({ parent: parent, target: rect }); + } + } + } else if (node.matches && !node.matches("button, select, textarea")) { + node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); + } +}; +const _highlightText = (thisNode, text, className) => { + let addItems = []; + _highlight(thisNode, addItems, text, className); + addItems.forEach((obj) => + obj.parent.insertAdjacentElement("beforebegin", obj.target) + ); +}; + +/** + * Small JavaScript module for the documentation. + */ +const SphinxHighlight = { + + /** + * highlight the search words provided in localstorage in the text + */ + highlightSearchWords: () => { + if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight + + // get and clear terms from localstorage + const url = new URL(window.location); + const highlight = + localStorage.getItem("sphinx_highlight_terms") + || url.searchParams.get("highlight") + || ""; + localStorage.removeItem("sphinx_highlight_terms") + url.searchParams.delete("highlight"); + window.history.replaceState({}, "", url); + + // get individual terms from highlight string + const terms = highlight.toLowerCase().split(/\s+/).filter(x => x); + if (terms.length === 0) return; // nothing to do + + // There should never be more than one element matching "div.body" + const divBody = document.querySelectorAll("div.body"); + const body = divBody.length ? divBody[0] : document.querySelector("body"); + window.setTimeout(() => { + terms.forEach((term) => _highlightText(body, term, "highlighted")); + }, 10); + + const searchBox = document.getElementById("searchbox"); + if (searchBox === null) return; + searchBox.appendChild( + document + .createRange() + .createContextualFragment( + '" + ) + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms") + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/docs.bak/_build/html/api.html b/docs.bak/_build/html/api.html new file mode 100644 index 0000000..c40f202 --- /dev/null +++ b/docs.bak/_build/html/api.html @@ -0,0 +1,138 @@ + + + + + + + API Reference — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

API Reference

+ + + + + + + + + + + + + + + +

pytest_mh

pytest_mh.cli

pytest_mh.ssh

pytest_mh.utils

+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.cli.html b/docs.bak/_build/html/api/pytest_mh.cli.html new file mode 100644 index 0000000..147f70a --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.cli.html @@ -0,0 +1,226 @@ + + + + + + + pytest_mh.cli — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.cli

+

Module Attributes

+ + + + + + +

CLIBuilderArgs

CLIBuilder args format.

+

Classes

+ + + + + + + + + + + + + + + + + + + + + + + + +

Any(*args, **kwargs)

Special type indicating an unconstrained type.

CLIBuilder(ssh)

Enum(value[, names, module, qualname, type, ...])

Create a collection of name/value pairs.

SSHClient(host, *, user, password, port, ...)

Interactive SSH client.

SSHPowerShellProcess(*args, **kwargs)

SSH Process with Powershell.

SSHProcess(*, command[, cwd, env, input, shell])

SSH Process.

auto([value])

Instances are replaced with an appropriate value in Enum class suites.

+
+
+class pytest_mh.cli.CLIBuilder(ssh: SSHClient)
+

Bases: object

+
+
+class option(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
+

Bases: Enum

+

Command line parameter types.

+
+
+PLAIN = 1
+

Use plain parameter value without any modification.

+
+ +
+
+VALUE = 2
+

Use parameter value but enclose it in quotes in script mode.

+
+ +
+
+SWITCH = 3
+

Parameter is a switch which is enabled if value is True.

+
+ +
+
+POSITIONAL = 4
+

Parameter is a positional argument.

+
+ +
+ +
+
+command(command: str, args: dict[str, tuple[option, Any] | None]) str
+
+ +
+
+argv(command: str, args: dict[str, tuple[option, Any] | None]) list[str]
+
+ +
+
+args(args: dict[str, tuple[option, Any] | None], quote_value=False) list[str]
+
+ +
+ +
+
+pytest_mh.cli.CLIBuilderArgs
+

CLIBuilder args format.

+

alias of dict[str, tuple[option, Any] | None]

+
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.html b/docs.bak/_build/html/api/pytest_mh.html new file mode 100644 index 0000000..dca7d75 --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.html @@ -0,0 +1,2027 @@ + + + + + + + pytest_mh — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh

+

Functions

+ + + + + + + + + + + + +

mh(request)

Pytest multihost fixture.

pytest_addoption(parser)

Pytest hook: add command line options.

pytest_configure(config)

Pytest hook: register multihost plugin.

+

Classes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

KnownTopologyBase(value[, names, module, ...])

Base class for a predefined set of topologies.

KnownTopologyGroupBase(value[, names, ...])

Base class for a predefined set of list of topologies.

MultihostConfig(confdict, *, logger, ...)

Multihost configuration.

MultihostDomain(config, confdict)

Multihost domain class.

MultihostFixture(request, data, multihost, ...)

Multihost object provides access to underlaying multihost configuration, individual domains and hosts.

MultihostHost(domain, confdict)

Base multihost host class.

MultihostHostArtifacts([config])

Manage set of artifacts that are collected at specific places.

MultihostItemData(multihost, topology_mark)

Multihost internal pytest data, stored in pytest.Item.multihost

MultihostOSFamily(value[, names, module, ...])

Host operating system family.

MultihostPlugin(pytest_config)

Pytest multihost plugin.

MultihostRole(mh, role, host)

Base role class.

MultihostTopologyControllerArtifacts()

Manage set of artifacts that are collected at specific places.

MultihostUtility(host)

Base class for utility functions that operate on remote hosts, such as writing a file or managing SSSD.

Topology(*domains)

A topology specifies requirements that a multihost configuration must fulfil in order to run a test.

TopologyController()

Topology controller can be associated with a topology via TopologyMark to provide additional per-topology hooks such as per-topology setup and teardown.

TopologyDomain(id, **kwargs)

Create a new topology domain.

TopologyMark(name, topology, *[, ...])

Topology mark is used to describe test case requirements.

+
+
+pytest_mh.mh(request: FixtureRequest) Generator[MultihostFixture, None, None]
+

Pytest multihost fixture. Returns instance of MultihostFixture. +When a pytest test is finished, this fixture takes care of tearing down the +MultihostFixture object automatically in order to clean up after +the test run.

+
+

Note

+

It is preferred that the test case does not use this fixture directly +but rather access the hosts through dynamically created role fixtures +that are defined in @pytest.mark.topology.

+
+
+
Parameters:
+

request (pytest.FixtureRequest) – Pytest’s request fixture.

+
+
Raises:
+

ValueError – If not multihost configuration was given.

+
+
Yield:
+

MultihostFixture

+
+
+
+ +
+
+class pytest_mh.KnownTopologyBase(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
+

Bases: Enum

+

Base class for a predefined set of topologies.

+

Users of this plugin may inherit from this class in order to created a +predefined, well-known set of topology markers.

+
+
Example usage
+
@final
+@unique
+class KnownTopology(KnownTopologyBase):
+    A = TopologyMark(
+        name='A',
+        topology=Topology(TopologyDomain('test', a=1)),
+        fixtures=dict(a='test.a[0]'),
+    )
+
+    B = TopologyMark(
+        name='B',
+        topology=Topology(TopologyDomain('test', b=1)),
+        fixtures=dict(b='test.b[0]'),
+    )
+
+
+@pytest.mark.topology(KnownTopology.A)
+def test_a(a: ARole):
+    pass
+
+@pytest.mark.topology(KnownTopology.B)
+def test_b(b: BRole):
+    pass
+
+
+
+
+ +
+
+class pytest_mh.KnownTopologyGroupBase(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
+

Bases: Enum

+

Base class for a predefined set of list of topologies.

+

Users of this plugin may inherit from this class in order to create a +predefined, well-known set of list of topology markers that can be used +directly in @pytest.mark.topology to enable topology parametrization for +a test case.

+
+
Example usage
+
@final
+@unique
+class KnownTopologyGroup(KnownTopologyGroupBase):
+    All = [
+        TopologyMark(
+            name='A',
+            topology=Topology(TopologyDomain('test', a=1)),
+            fixtures=dict(a='test.a[0]', generic='test.a[0]'),
+        ),
+
+        B = TopologyMark(
+            name='B',
+            topology=Topology(TopologyDomain('test', b=1)),
+            fixtures=dict(b='test.b[0]', generic='test.a[0]'),
+        )
+    ]
+
+
+# Will run once for A, once for B
+@pytest.mark.topology(KnownTopologyGroup.All)
+def test_all(generic: GenericRole):
+    pass
+
+
+
+
+ +
+
+class pytest_mh.MultihostConfig(confdict: dict[str, Any], *, logger: MultihostLogger, lazy_ssh: bool, artifacts_dir: Path, artifacts_mode: Literal['never', 'on-failure', 'always'], artifacts_compression: bool)
+

Bases: ABC

+

Multihost configuration.

+
+
+logger: MultihostLogger
+

Multihost logger

+
+ +
+
+lazy_ssh: bool
+

If True, hosts postpone connecting to ssh when the connection is first required

+
+ +
+
+artifacts_dir: Path
+

Artifacts output directory.

+
+ +
+
+artifacts_mode: Literal['never', 'on-failure', 'always']
+

Artifacts collection mode.

+
+ +
+
+artifacts_compression: bool
+

Store artifacts in compressed archive?

+
+ +
+
+domains: list[MultihostDomain]
+

Available domains

+
+ +
+
+property required_fields: list[str]
+

Fields that must be set in the host configuration. An error is raised +if any field is missing.

+

The field name may contain a . to check nested fields.

+
+ +
+
+property TopologyMarkClass: Type[TopologyMark]
+

Class name of the type or subtype of TopologyMark.

+
+ +
+
+create_domain(domain: dict[str, Any]) MultihostDomain
+

Create new multihost domain from dictionary.

+

It maps the role name to a Python class using +id_to_domain_class. If the role is not found in the property, it +fallbacks to *. If even asterisk is not found, it raises +ValueError.

+
+
Parameters:
+

domain (dict[str, Any]) – Domain in dictionary form.

+
+
Raises:
+

ValueError – If domain does not have id or mapping to Python class is not found.

+
+
Returns:
+

New multihost domain.

+
+
Return type:
+

MultihostDomain

+
+
+
+ +
+
+topology_hosts(topology: Topology) list[MultihostHost]
+

Return all hosts required by the topology as list.

+
+
Parameters:
+

topology (Multihost topology) – Topology.

+
+
Returns:
+

List of MultihostHost.

+
+
Return type:
+

list[MultihostHost]

+
+
+
+ +
+
+abstract property id_to_domain_class: dict[str, Type[MultihostDomain]]
+

Map domain id to domain class. Asterisk * can be used as fallback +value.

+
+
Return type:
+

Class name.

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostDomain(config: ConfigType, confdict: dict[str, Any])
+

Bases: ABC, Generic[ConfigType]

+

Multihost domain class.

+
+
+mh_config: ConfigType
+

Multihost configuration

+
+ +
+
+logger: MultihostLogger
+

Multihost logger

+
+ +
+
+id: str
+

Domain id

+
+ +
+
+hosts: list[MultihostHost]
+

Available hosts in this domain

+
+ +
+
+property required_fields: list[str]
+

Fields that must be set in the domain configuration. An error is raised +if any field is missing.

+

The field name may contain a . to check nested fields.

+
+ +
+
+property roles: list[str]
+

All roles available in this domain.

+
+
Returns:
+

Role names.

+
+
Return type:
+

list[str]

+
+
+
+ +
+
+create_host(confdict: dict[str, Any]) MultihostHost
+

Create host object from role.

+

It maps the role name to a Python class using +role_to_host_class. If the role is not found in the property, it +fallbacks to *. If even asterisk is not found, it fallbacks to +MultiHost.

+
+
Parameters:
+

confdict (dict[str, Any]) – Host configuration as a dictionary.

+
+
Raises:
+

ValueError – If role property is missing in the host +configuration.

+
+
Returns:
+

Host instance.

+
+
Return type:
+

MultihostHost

+
+
+
+ +
+
+create_role(mh: MultihostFixture, host: MultihostHost) MultihostRole
+

Create role object from given host.

+

It maps the role name to a Python class using +role_to_role_class. If the role is not found in the property, it +fallbacks to *. If even asterisk is not found, it raises +ValueError.

+
+
Parameters:
+
    +
  • mh (Multihost) – Multihost instance.

  • +
  • host (MultihostHost) – Multihost host instance.

  • +
+
+
Raises:
+

ValueError – If unexpected role name is given.

+
+
Returns:
+

Role instance.

+
+
Return type:
+

MultihostRole

+
+
+
+ +
+
+abstract property role_to_host_class: dict[str, Type[MultihostHost]]
+

Map role to host class. Asterisk * can be used as fallback value.

+
+
Return type:
+

Class name.

+
+
+
+ +
+
+abstract property role_to_role_class: dict[str, Type[MultihostRole]]
+

Map role to role class. Asterisk * can be used as fallback value.

+
+
Return type:
+

Class name.

+
+
+
+ +
+
+hosts_by_role(role: str) list[MultihostHost]
+

Return all hosts of the given role.

+
+
Parameters:
+

role (str) – Role name.

+
+
Returns:
+

List of hosts of given role.

+
+
Return type:
+

list[MultihostHost]

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostFixture(request: FixtureRequest, data: MultihostItemData, multihost: MultihostConfig, topology_mark: TopologyMark)
+

Bases: object

+

Multihost object provides access to underlaying multihost configuration, +individual domains and hosts. This object should be used only in tests +as the mh() pytest fixture.

+

Domains are accessible as dynamically created properties of this object, +hosts are accessible by roles as dynamically created properties of each +domain. Each host object is instance of specific role class based on +MultihostRole.

+
+
Example multihost configuration
+
domains:
+- id: test
+  hosts:
+  - name: client
+    hostname: client.test
+    role: client
+
+  - name: ldap
+    hostname: master.ldap.test
+    role: ldap
+
+
+
+

The configuration above creates one domain of id test with two hosts. +The following example shows how to access the hosts:

+
+
Example of the MultihostFixture object
+
def test_example(mh: MultihostFixture):
+    mh.ns.test            # -> namespace containing roles as properties
+    mh.ns.test.client     # -> list of hosts providing given role
+    mh.ns.test.client[0]  # -> host object, instance of specific role
+
+
+
+
+
Parameters:
+
    +
  • request (pytest.FixtureRequest) – Pytest request.

  • +
  • data (MultihostItemData) – Multihost item data.

  • +
  • multihost (MultihostConfig) – Multihost configuration.

  • +
  • topology_mark (TopologyMark) – Multihost topology mark.

  • +
+
+
+
+
+data: MultihostItemData
+

Multihost item data.

+
+ +
+
+request: FixtureRequest
+

Pytest request.

+
+ +
+
+multihost: MultihostConfig
+

Multihost configuration.

+
+ +
+
+topology_mark: TopologyMark
+

Topology mark.

+
+ +
+
+topology: Topology
+

Topology data.

+
+ +
+
+topology_controller: TopologyController
+

Topology controller.

+
+ +
+
+logger: MultihostLogger
+

Multihost logger.

+
+ +
+
+ns: SimpleNamespace
+

Roles as object accessible through topology path, e.g. mh.ns.domain_id.role_name.

+
+ +
+
+roles: list[MultihostRole]
+

Available MultihostRole objects.

+
+ +
+
+hosts: list[MultihostHost]
+

Available MultihostHost objects.

+
+ +
+
+log_phase(phase: str) None
+

Log current test phase.

+
+
Parameters:
+

phase (str) – Phase name or description.

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostHost(domain: DomainType, confdict: dict[str, Any])
+

Bases: Generic[DomainType]

+

Base multihost host class.

+
+
Example configuration in YAML format
+
- hostname: dc.ad.test
+  role: ad
+  os:
+    family: linux
+  ssh:
+    host: 1.2.3.4
+    username: root
+    password: Secret123
+  config:
+    binddn: Administrator@ad.test
+    bindpw: vagrant
+    client:
+      ad_domain: ad.test
+      krb5_keytab: /enrollment/ad.keytab
+      ldap_krb5_keytab: /enrollment/ad.keytab
+
+
+
+
    +
  • Required fields: hostname, role

  • +
  • Optional fields: artifacts, config, os, ssh

  • +
+
+
Parameters:
+
    +
  • domain (DomainType) – Multihost domain object.

  • +
  • confdict (dict[str, Any]) – Host configuration as a dictionary.

  • +
  • shell (str) – Shell used in SSH connection, defaults to ‘/usr/bin/bash -c’.

  • +
+
+
+
+
+mh_domain: DomainType
+

Multihost domain.

+
+ +
+
+role: str
+

Host role.

+
+ +
+
+hostname: str
+

Host hostname.

+
+ +
+
+logger: MultihostLogger
+

Multihost logger.

+
+ +
+
+config: dict[str, Any]
+

Custom configuration.

+
+ +
+
+configured_artifacts: MultihostHostArtifacts
+

Host artifacts produced during tests, configured by the user.

+
+ +
+
+ssh_host: str
+

SSH host (resolvable hostname or IP address), defaults to hostname.

+
+ +
+
+ssh_port: int
+

SSH port, defaults to 22.

+
+ +
+
+ssh_username: str
+

SSH username, defaults to root.

+
+ +
+
+ssh_password: str
+

SSH password, defaults to Secret123.

+
+ +
+
+os_family: MultihostOSFamily
+

Host operating system os_family.

+
+ +
+
+shell: Type[SSHProcess]
+

Shell used in SSH session.

+
+ +
+
+ssh: SSHClient
+

SSH client.

+
+ +
+
+cli: CLIBuilder
+

Command line builder.

+
+ +
+
+artifacts: MultihostHostArtifacts
+

List of artifacts that will be automatically collected at specific +places. This list can be dynamically extended. Values may contain +wildcard character.

+
+ +
+
+artifacts_collector: MultihostArtifactsCollector
+

Artifacts collector.

+
+ +
+
+property required_fields: list[str]
+

Fields that must be set in the host configuration. An error is raised +if any field is missing.

+

The field name may contain a . to check nested fields.

+
+ +
+
+pytest_setup() None
+

Called once before execution of any tests.

+
+ +
+
+pytest_teardown() None
+

Called once after all tests are finished.

+
+ +
+
+setup() None
+

Called before execution of each test.

+
+ +
+
+teardown() None
+

Called after execution of each test.

+
+ +
+
+get_artifacts_list(host: MultihostHost, type: Literal['pytest_setup', 'pytest_teardown', 'topology_setup', 'topology_teardown', 'test']) set[str]
+

Return the list of artifacts to collect.

+

This just returns artifacts, but it is possible to override this +method in order to generate additional artifacts that were not created +by the test, or detect which artifacts were created and update the +artifacts list.

+
+
Parameters:
+
    +
  • host (MultihostHost) – Host where the artifacts are being collected.

  • +
  • type (MultihostArtifactsType) – Type of artifacts that are being collected.

  • +
+
+
Returns:
+

List of artifacts to collect.

+
+
Return type:
+

set[str]

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostHostArtifacts(config: list[str] | dict[str, list[str]] | None = None)
+

Bases: object

+

Manage set of artifacts that are collected at specific places.

+
+
+pytest_setup: set[str]
+

List of artifacts collected for host after initial pytest_setup.

+

See MultihostHost.pytest_setup().

+
+ +
+
+pytest_teardown: set[str]
+

List of artifacts collected for host after final pytest_teardown.

+

See MultihostHost.pytest_teardown().

+
+ +
+
+test: set[str]
+

List of artifacts collected for a test when the test run is finished.

+
+ +
+
+get(artifacts_type: Literal['pytest_setup', 'pytest_teardown', 'topology_setup', 'topology_teardown', 'test']) set[str]
+

Get list of artifacts by type.

+
+
Parameters:
+

artifacts_type (MultihostArtifactsType) – Type to retrieve.

+
+
Raises:
+

ValueError – If invalid artifacts type is given.

+
+
Returns:
+

List of artifacts.

+
+
Return type:
+

set[str]

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostItemData(multihost: MultihostConfig | None, topology_mark: TopologyMark | None)
+

Bases: object

+

Multihost internal pytest data, stored in pytest.Item.multihost

+
+
+multihost: MultihostConfig | None
+

Multihost object.

+
+ +
+
+topology_mark: TopologyMark | None
+

Topology mark for the test run.

+
+ +
+
+outcome: Literal['passed', 'failed', 'skipped', 'error', 'unknown']
+

Test run outcome, available in fixture finalizers.

+
+ +
+
+static SetData(item: Item, data: MultihostItemData | None) None
+
+ +
+
+static GetData(item: Item) MultihostItemData | None
+
+ +
+ +
+
+class pytest_mh.MultihostOSFamily(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
+

Bases: Enum

+

Host operating system family.

+
+
+Linux = 'linux'
+
+ +
+
+Windows = 'windows'
+
+ +
+ +
+
+class pytest_mh.MultihostPlugin(pytest_config: Config)
+

Bases: object

+

Pytest multihost plugin.

+
+
+classmethod GetLogger() Logger
+

Get plugin’s logger.

+
+ +
+
+pytest_runtest_makereport(item: Item, call: CallInfo[None]) Generator[None, TestReport, None]
+

Store test outcome in multihost data: item.multihost.outcome. The outcome +can be ‘passed’, ‘failed’ or ‘skipped’.

+
+ +
+
+pytest_output_item_collected(config: Config, item) None
+
+ +
+ +
+
+class pytest_mh.MultihostRole(mh: MultihostFixture, role: str, host: HostType)
+

Bases: Generic[HostType]

+

Base role class. Roles are the main interface to the remote hosts that can +be directly accessed in test cases as fixtures.

+

All changes to the remote host that were done through the role object API +are automatically reverted when a test is finished.

+
+
+logger: MultihostLogger
+

Multihost logger.

+
+ +
+
+artifacts: set[str]
+

List of artifacts that will be automatically collected at specific +places. This list can be dynamically extended. Values may contain +wildcard character.

+
+ +
+
+setup() None
+

Setup all MultihostUtility objects +that are attributes of this class.

+
+ +
+
+teardown() None
+

Teardown all MultihostUtility objects +that are attributes of this class.

+
+ +
+
+get_artifacts_list(host: MultihostHost, type: Literal['pytest_setup', 'pytest_teardown', 'topology_setup', 'topology_teardown', 'test']) set[str]
+

Return the list of artifacts to collect.

+

This just returns artifacts, but it is possible to override this +method in order to generate additional artifacts that were not created +by the test, or detect which artifacts were created and update the +artifacts list.

+
+
Parameters:
+
    +
  • host (MultihostHost) – Host where the artifacts are being collected.

  • +
  • type (MultihostArtifactsType) – Type of artifacts that are being collected.

  • +
+
+
Returns:
+

List of artifacts to collect.

+
+
Return type:
+

set[str]

+
+
+
+ +
+
+ssh(user: str, password: str, *, shell=<class 'pytest_mh.ssh.SSHBashProcess'>) SSHClient
+

Open SSH connection to the host as given user.

+
+
Parameters:
+
    +
  • user (str) – Username.

  • +
  • password (str) – User password.

  • +
  • shell (str, optional) – Shell that will run the commands, defaults to SSHBashProcess

  • +
+
+
Returns:
+

SSH client connection.

+
+
Return type:
+

SSHClient

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostTopologyControllerArtifacts
+

Bases: object

+

Manage set of artifacts that are collected at specific places.

+
+
+topology_setup: dict[MultihostHost, set[str]]
+

List of artifacts collected for host after initial topology_setup.

+

See TopologyController.topology_setup().

+
+ +
+
+topology_teardown: dict[MultihostHost, set[str]]
+

List of artifacts collected for host after final topology_teardown.

+

See TopologyController.topology_teardown().

+
+ +
+
+test: dict[MultihostHost, set[str]]
+

List of artifacts collected for host when a test run is finished.

+
+ +
+
+get(host: MultihostHost, artifacts_type: MultihostArtifactsType) set[str]
+

Get list of artifacts by host and type.

+
+
Parameters:
+

artifacts_type (MultihostArtifactsType) – Type to retrieve.

+
+
Raises:
+

ValueError – If invalid artifacts type is given.

+
+
Returns:
+

List of artifacts.

+
+
Return type:
+

set[str]

+
+
+
+ +
+ +
+
+class pytest_mh.MultihostUtility(host: HostType)
+

Bases: Generic[HostType]

+

Base class for utility functions that operate on remote hosts, such as +writing a file or managing SSSD.

+

Instances of MultihostUtility can be used in any role class which +is a subclass of MultihostRole. In this case, setup() and +teardown() methods are called automatically when the object is created +and destroyed to ensure proper setup and clean up on the remote host.

+
+
Parameters:
+

host (HostType) – Remote host instance.

+
+
+
+
+host: HostType
+

Multihost host.

+
+ +
+
+logger: MultihostLogger
+

Multihost logger.

+
+ +
+
+used: bool
+

Indicate if this utility instance was already used or not within current test.

+
+ +
+
+artifacts: set[str]
+

List of artifacts that will be automatically collected at specific +places. This list can be dynamically extended. Values may contain +wildcard character.

+
+ +
+
+setup() None
+

Setup object.

+
+ +
+
+teardown() None
+

Teardown object.

+
+ +
+
+setup_when_used() None
+

Setup the object when it is used for the first time.

+
+ +
+
+teardown_when_used() None
+

Teardown the object only if it was used.

+
+ +
+
+get_artifacts_list(host: MultihostHost, type: Literal['pytest_setup', 'pytest_teardown', 'topology_setup', 'topology_teardown', 'test']) set[str]
+

Return the list of artifacts to collect.

+

This just returns artifacts, but it is possible to override this +method in order to generate additional artifacts that were not created +by the test, or detect which artifacts were created and update the +artifacts list.

+
+
Parameters:
+
    +
  • host (MultihostHost) – Host where the artifacts are being collected.

  • +
  • host – Host where the artifacts are being collected.

  • +
  • type (MultihostArtifactsType) – Type of artifacts that are being collected.

  • +
+
+
Returns:
+

List of artifacts to collect.

+
+
Return type:
+

set[str]

+
+
+
+ +
+
+static GetUtilityAttributes(o: object) dict[str, MultihostUtility]
+

Get all attributes of the o that are instance of +MultihostUtility.

+
+
Parameters:
+

o (object) – Any object.

+
+
Returns:
+

Dictionary {attribute name: value}

+
+
Return type:
+

dict[str, MultihostUtility]

+
+
+
+ +
+
+classmethod SetupUtilityAttributes(o: object) None
+

Setup all MultihostUtility objects attributes of the given +object.

+
+
Parameters:
+

o (object) – Any object.

+
+
+
+ +
+
+classmethod TeardownUtilityAttributes(o: object) None
+

Teardown all MultihostUtility objects attributes of the given +object.

+
+
Parameters:
+

o (object) – Any object.

+
+
+
+ +
+
+classmethod IgnoreCall(method)
+

Calling a method decorated with IgnoreCall does not execute neither +setup_when_used() nor teardown_when_used(). It does not +count as “using” the class.

+
+ +
+ +
+
+pytest_mh.pytest_addoption(parser)
+

Pytest hook: add command line options.

+
+ +
+
+pytest_mh.pytest_configure(config: Config)
+

Pytest hook: register multihost plugin.

+
+ +
+
+class pytest_mh.Topology(*domains: TopologyDomain)
+

Bases: object

+

A topology specifies requirements that a multihost configuration must fulfil +in order to run a test.

+

Each topology consist of one or more domains (TopologyDomain) that +defines how many hosts are available inside the domain and what roles are +implemented.

+

The following example defines an ldap topology that consist of one domain of +id test and requires two roles: client and ldap each provided +by one host.

+
Topology(
+    TopologyDomain(
+        'test',
+         client=1, ldap=1
+    )
+)
+
+
+

This topology can be satisfied for example by the following multihost +configuration:

+
domains:
+- name: ldap.test
+  id: test
+  hosts:
+  - name: client
+    hostname: client.test
+    role: client
+
+  - name: ldap
+    hostname: master.ldap.test
+    role: ldap
+
+
+
+
Parameters:
+

*args (TopologyDomain) – Domains that are included in this topology.

+
+
+
+
+get(id: str) TopologyDomain
+

Find topology domain of the given id and return it.

+
+
Parameters:
+

id (str) – Topology domain id to lookup.

+
+
Raises:
+

KeyError – The domain was not found.

+
+
Return type:
+

TopologyDomain

+
+
+
+ +
+
+export() list[dict]
+

Export the topology into a list of dictionaries that can be easily +converted to JSON, YAML or other formats.

+
[
+    {
+        'id': 'test',
+        'roles': {
+            'client': 1,
+            'ldap': 1
+    }
+]
+
+
+
+
Return type:
+

dict

+
+
+
+ +
+
+satisfies(other: Topology) bool
+

Check if the topology satisfies the other topology.

+

Returns True if this topology contains all domains and required +roles defined in the other topology and False otherwise.

+
+
Parameters:
+

other (Topology) – The other topology.

+
+
Return type:
+

bool

+
+
+
+ +
+
+classmethod FromMultihostConfig(mhc: dict) Topology
+

Create Topology from multihost configuration object.

+
+
Parameters:
+

mhc (dict) – Multihost configuration object (dictionary)

+
+
Returns:
+

Inferred topology.

+
+
Return type:
+

Topology

+
+
+
+ +
+ +
+
+class pytest_mh.TopologyController
+

Bases: object

+

Topology controller can be associated with a topology via TopologyMark +to provide additional per-topology hooks such as per-topology setup +and teardown.

+

When inheriting from this class, keep it mind that there is postpone +initialization of all present properties therefore you can not access +them inside the constructor. The properties are initialized a test is +collected.

+

Each method can take MultihostHost object as parameters as defined in +topology fixtures.

+
+
Example topology controller
+
class ExampleController(TopologyController):
+    def set_artifacts(self, client: ClientHost) -> None:
+        self.artifacts.topology_setup[client] = {"/etc/issue"}
+
+    def skip(self, client: ClientHost) -> str | None:
+        result = client.ssh.run(
+            '''
+            # Implement your requirement check here
+            exit 1
+            ''', raise_on_error=False)
+        if result.rc != 0:
+            return "Topology requirements were not met"
+
+        return None
+
+    def topology_setup(self, client: ClientHost):
+        # One-time setup, prepare the host for this topology
+        # Changes done here are shared for all tests
+        pass
+
+    def topology_teardown(self, client: ClientHost):
+        # One-time teardown, this should undo changes from
+        # topology_setup
+        pass
+
+    def setup(self, client: ClientHost):
+        # Perform per-topology test setup
+        # This is called before execution of every test
+        pass
+
+    def teardown(self, client: ClientHost):
+        # Perform per-topology test teardown, this should undo changes
+        # from setup
+        pass
+
+
+
+
+
Example with low-level topology mark
+
class ExampleController(TopologyController):
+    # Implement methods you are interested in here
+    pass
+
+@pytest.mark.topology(
+    "example", Topology(TopologyDomain("example", client=1)),
+    controller=ExampleController(),
+    fixtures=dict(client="example.client[0]")
+)
+def test_example(client: Client):
+    pass
+
+
+
+
+
Example with KnownTopology
+
class ExampleController(TopologyController):
+    # Implement methods you are interested in here
+    pass
+
+@final
+@unique
+class KnownTopology(KnownTopologyBase):
+    EXAMPLE = TopologyMark(
+        name='example',
+        topology=Topology(TopologyDomain("example", client=1)),
+        controller=ExampleController(),
+        fixtures=dict(client='example.client[0]'),
+    )
+
+@pytest.mark.topology(KnownTopology.EXAMPLE)
+def test_example(client: Client):
+    pass
+
+
+
+
+
+artifacts: MultihostTopologyControllerArtifacts
+

List of artifacts that will be automatically collected at specific +places. This list can be dynamically extended. Values may contain +wildcard character.

+
+ +
+
+property name: str
+

Topology name.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

Topology name.

+
+
Return type:
+

str

+
+
+
+ +
+
+property topology: Topology
+

Multihost topology.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

Topology.

+
+
Return type:
+

Topology

+
+
+
+ +
+
+property multihost: MultihostConfig
+

Multihost configuration.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

Multihost configuration.

+
+
Return type:
+

MultihostConfig

+
+
+
+ +
+
+property logger: MultihostLogger
+

Multihost logger.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

Multihost logger.

+
+
Return type:
+

MultihostLogger

+
+
+
+ +
+
+property ns: SimpleNamespace
+

Namespace of MultihostHost objects accessible by domain id and roles names.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

Namespace.

+
+
Return type:
+

SimpleNamespace

+
+
+
+ +
+
+property hosts: list[MultihostHost]
+

List of MultihostHost objects available in this topology.

+

This property cannot be accessed from the constructor.

+
+
Returns:
+

List of MultihostHost objects.

+
+
Return type:
+

list[MultihostHost]

+
+
+
+ +
+
+get_artifacts_list(host: MultihostHost, type: MultihostArtifactsType) set[str]
+

Return the list of artifacts to collect.

+

This just returns artifacts, but it is possible to override this +method in order to generate additional artifacts that were not created +by the test, or detect which artifacts were created and update the +artifacts list.

+
+
Parameters:
+
    +
  • host (MultihostHost) – Host where the artifacts are being collected.

  • +
  • type (MultihostArtifactsType) – Type of artifacts that are being collected.

  • +
+
+
Returns:
+

List of artifacts to collect.

+
+
Return type:
+

set[str]

+
+
+
+ +
+
+set_artifacts(*args, **kwargs) None
+

Called before topology_setup() to set topology artifacts.

+

Note that the artifacts can be set in any other method as well. This +dedicated method is just for your convenience.

+
+ +
+
+skip(*args, **kwargs) str | None
+

Called before a test is executed.

+

If a non-None value is returned the test is skipped, using the returned +value as a skip reason.

+
+
Return type:
+

str | None

+
+
+
+ +
+
+topology_setup(*args, **kwargs) None
+

Called once before executing the first test of given topology.

+
+ +
+
+topology_teardown(*args, **kwargs) None
+

Called once after all tests for given topology were run.

+
+ +
+
+setup(*args, **kwargs) None
+

Called before execution of each test.

+
+ +
+
+teardown(*args, **kwargs) None
+

Called after execution of each test.

+
+ +
+ +
+
+class pytest_mh.TopologyDomain(id: str, **kwargs: int)
+

Bases: object

+

Create a new topology domain.

+

Topology domain specifies domain id required by the topology as well as +required roles and number of hosts that must implement these roles. See +Topology for more information.

+

The following example defines a topology domain of id test that +requires two roles: client and ldap each provided by one host.

+
TopologyDomain(
+    'test',
+    client=1, ldap=1
+)
+
+
+
+
Parameters:
+
    +
  • id (str) – Domain id.

  • +
  • *kwargs (dict[str, int]) – Required roles.

  • +
+
+
+
+
+get(role: str) int
+

Find role and return the number of hosts that must implement this role.

+
+
Parameters:
+

role – Host role to lookup.

+
+
Raises:
+

KeyError – The domain was not found.

+
+
Return type:
+

int

+
+
+
+ +
+
+export() dict
+

Export the topology domain into a dictionary object that can be easily +converted to JSON, YAML or other formats.

+
{
+    'id': 'test',
+    'roles': {
+        'client': 1,
+        'ldap': 1
+    }
+}
+
+
+
+
Return type:
+

dict

+
+
+
+ +
+
+satisfies(other: TopologyDomain) bool
+

Check if the topology domain satisfies the other domain.

+

Returns True if the domain ids match and this domain contains all +required roles defined in the other topology and False +otherwise.

+
+
Parameters:
+

other (TopologyDomain) – The other topology domain.

+
+
Return type:
+

bool

+
+
+
+ +
+ +
+
+class pytest_mh.TopologyMark(name: str, topology: Topology, *, controller: TopologyController | None = None, fixtures: dict[str, str] | None = None)
+

Bases: object

+

Topology mark is used to describe test case requirements. It defines:

+
    +
  • name, that is used to identify topology in pytest output

  • +
  • topology (:class:Topology) that is required to run the test

  • +
  • controller (:class:TopologyController) to provide per-topology hooks, optional

  • +
  • fixtures that are available during the test run, optional

  • +
+
+
Example usage
+
@pytest.mark.topology(
+    name, topology,
+    controller=controller,
+    fixture=dict(fixture1='path1', fixture2='path2', ...)
+)
+def test_fixture_name(fixture1: BaseRole, fixture2: BaseRole, ...):
+    assert True
+
+
+
+

Fixture path points to a host in the multihost configuration and can be +either in the form of $domain-id.$role (all host of given role) or +$domain-id.$role[$index] (specific host on given index).

+

The name is visible in verbose pytest output after the test name, for example:

+
tests/test_basic.py::test_case (topology-name) PASSED
+
+
+
+
Parameters:
+
    +
  • name (str) – Topology name used in pytest output.

  • +
  • topology (Topology) – Topology required to run the test.

  • +
  • controller (TopologyController | None, optional) – Topology controller, defaults to None

  • +
  • fixtures (dict[str, str] | None, optional) – Dynamically created fixtures available during the test run, defaults to None

  • +
+
+
+
+
+name: str
+

Topology name.

+
+ +
+
+topology: Topology
+

Multihost topology.

+
+ +
+
+controller: TopologyController
+

Multihost topology controller.

+
+ +
+
+fixtures: dict[str, str]
+

Dynamic fixtures mapping.

+
+ +
+
+property args: set[str]
+

Names of all dynamically created fixtures.

+
+ +
+
+apply(mh: MultihostFixture, funcargs: dict[str, Any]) None
+

Create required fixtures by modifying pytest.Item.funcargs.

+
+
Parameters:
+
    +
  • mh (Multihost) – _description_

  • +
  • funcargs (dict[str, Any]) – Pytest test item funcargs that will be modified.

  • +
+
+
+
+ +
+
+export() dict
+

Export the topology mark into a dictionary object that can be easily +converted to JSON, YAML or other formats.

+
{
+    'name': 'client',
+    'fixtures': { 'client': 'test.client[0]' },
+    'topology': [
+        {
+            'id': 'test',
+            'hosts': { 'client': 1 }
+        }
+    ]
+}
+
+
+
+
Return type:
+

dict

+
+
+
+ +
+
+classmethod ExpandMarkers(item: Item) list[Mark]
+
+ +
+
+classmethod Create(item: Function, mark: Mark) TopologyMark
+

Create instance of TopologyMark from @pytest.mark.topology.

+
+
Raises:
+

ValueError

+
+
Return type:
+

TopologyMark

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.ssh.html b/docs.bak/_build/html/api/pytest_mh.ssh.html new file mode 100644 index 0000000..f670a23 --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.ssh.html @@ -0,0 +1,764 @@ + + + + + + + pytest_mh.ssh — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.ssh

+

Classes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Any(*args, **kwargs)

Special type indicating an unconstrained type.

Enum(value[, names, module, qualname, type, ...])

Create a collection of name/value pairs.

MultihostLogger(*args, **kwargs)

Multihost logger class.

SSHBashProcess(*args, **kwargs)

SSH Process with Bash.

SSHClient(host, *, user, password, port, ...)

Interactive SSH client.

SSHLog(value[, names, module, qualname, ...])

SSH command log level.

SSHPowerShellProcess(*args, **kwargs)

SSH Process with Powershell.

SSHProcess(*, command[, cwd, env, input, shell])

SSH Process.

SSHProcessResult(rc, stdout, stderr)

SSH Process result.

auto([value])

Instances are replaced with an appropriate value in Enum class suites.

+

Exceptions

+ + + + + + + + + +

SSHAuthenticationError(host, port, user)

SSHProcessError(id, command, rc, cwd, env, ...)

SSH Process Error.

+
+
+class pytest_mh.ssh.SSHLog(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
+

Bases: Enum

+

SSH command log level.

+
+
+Silent = 1
+

No log messages are produced.

+
+ +
+
+Short = 2
+

Command execution and return code is logged. Its output is omitted.

+
+ +
+
+Full = 3
+

Command execution, its return code and output is logged.

+
+ +
+
+Error = 4
+

Only log the command and its result on non-zero exit code.

+
+ +
+ +
+
+class pytest_mh.ssh.SSHProcess(*, command: str, cwd: str | None = None, env: dict[str, Any] | None = None, input: str | None = None, shell: str | None = None, conn: SSHClient, read_timeout: float, logger: MultihostLogger, log_level: SSHLog, sync_exec: bool)
+

Bases: object

+

SSH Process.

+
+

Note

+

You should not create instances of this class yourself. Use method +SSHClient.run(), SSHClient.exec(), +SSHClient.async_run() and SSHClient.async_exec() from +SSHClient to execute a command over SSH.

+
+
+
Parameters:
+
    +
  • command (str) – Command to execute.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • shell (str | None, optional) – Shell used to execute the command, defaults to None (use user’s login shell)

  • +
  • conn (pssh.clients.ssh.SSHClient) – Connected SSH client.

  • +
  • read_timeout (float) – Timeout in seconds, how long should the client wait +for output, defaults to 30 seconds

  • +
  • logger (MultihostLogger) – Multihost logger.

  • +
  • log_level (SSHLog) – Log level.

  • +
  • sync_exec (bool) – Is this a blocking execution?

  • +
+
+
+
+
+property stdout: Generator[str, None, None]
+

Standard output, returns generator which yields output line by line.

+
# Read single line, this will block until there is a line to read or read_timeout is reached
+line = next(process.stdout)
+
+# Read all lines, this will block until EOF or read_timeout is reached
+lines = list(process.stdout)
+
+# Iterate over all lines
+for line in process.stdout:
+    pass
+
+
+
+
Raises:
+

RuntimeError – If the process is not yet started.

+
+
Returns:
+

Standard output generator.

+
+
Return type:
+

Generator[str, None, None]

+
+
+
+ +
+
+property stderr: Generator[str, None, None]
+

Standard error output, returns generator which yields error output line by line.

+
# Read single line, this will block until there is a line to read or read_timeout is reached
+line = next(process.stderr)
+
+# Read all lines, this will block until EOF or read_timeout is reached
+lines = list(process.stderr)
+
+# Iterate over all lines
+for line in process.stderr:
+    pass
+
+
+
+
Raises:
+

RuntimeError – If the process is not yet started.

+
+
Returns:
+

Standard error output generator.

+
+
Return type:
+

Generator[str, None, None]

+
+
+
+ +
+
+property stdin: Stdin
+

File-like object representing command’s standard input.

+
# Write data
+process.stdin.write('Hello World')
+
+# Send EOF to indicate that there will be no more input data.
+process.send_eof()
+
+
+
+
Raises:
+

RuntimeError – If the process is not yet started.

+
+
Returns:
+

Standard input file.

+
+
Return type:
+

pssh.clients.base.single.Stdin

+
+
+
+ +
+
+run() SSHProcess
+

Execute the command.

+
+
Returns:
+

Self.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+wait(raise_on_error: bool = True) SSHProcessResult
+

Wait for the command to finish.

+

EOF is send to standard input to indicate that there will be no +additional input data. Then it waits for the command to finish.

+
+
Parameters:
+

raise_on_error (bool, optional) – If True, SSHProcessError is raised on non-zero return code, defaults to True

+
+
Raises:
+

SSHProcessError – If raise_on_error is True and the command exited with non-zero return code.

+
+
Returns:
+

Command result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+send_eof() None
+

Send EOF to standard input to indicate that there will be no more +input data.

+
+
Raises:
+

RuntimeError – If the process is not yet started.

+
+
+
+ +
+ +
+
+class pytest_mh.ssh.SSHBashProcess(*args, **kwargs)
+

Bases: SSHProcess

+

SSH Process with Bash.

+
+

Note

+

You should not create instances of this class yourself. Use method +SSHClient.run(), SSHClient.exec(), +SSHClient.async_run() and SSHClient.async_exec() from +SSHClient to execute a command over SSH.

+
+
+
Parameters:
+
    +
  • command (str) – Command to execute.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • shell (str | None, optional) – Shell used to execute the command, defaults to None (use user’s login shell)

  • +
  • conn (pssh.clients.ssh.SSHClient) – Connected SSH client.

  • +
  • read_timeout (float) – Timeout in seconds, how long should the client wait +for output, defaults to 30 seconds

  • +
  • logger (MultihostLogger) – Multihost logger.

  • +
  • log_level (SSHLog) – Log level.

  • +
  • sync_exec (bool) – Is this a blocking execution?

  • +
+
+
+
+ +
+
+class pytest_mh.ssh.SSHPowerShellProcess(*args, **kwargs)
+

Bases: SSHProcess

+

SSH Process with Powershell.

+
+

Note

+

You should not create instances of this class yourself. Use method +SSHClient.run(), SSHClient.exec(), +SSHClient.async_run() and SSHClient.async_exec() from +SSHClient to execute a command over SSH.

+
+
+
Parameters:
+
    +
  • command (str) – Command to execute.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • shell (str | None, optional) – Shell used to execute the command, defaults to None (use user’s login shell)

  • +
  • conn (pssh.clients.ssh.SSHClient) – Connected SSH client.

  • +
  • read_timeout (float) – Timeout in seconds, how long should the client wait +for output, defaults to 30 seconds

  • +
  • logger (MultihostLogger) – Multihost logger.

  • +
  • log_level (SSHLog) – Log level.

  • +
  • sync_exec (bool) – Is this a blocking execution?

  • +
+
+
+
+ +
+
+class pytest_mh.ssh.SSHProcessResult(rc: int, stdout: list[str], stderr: list[str])
+

Bases: object

+

SSH Process result.

+
+
Parameters:
+
    +
  • rc (int) – Return code.

  • +
  • stdout (list[str]) – Standard output, line by line.

  • +
  • stderr (list[str]) – Standard error output, line by line.

  • +
+
+
+
+ +
+
+exception pytest_mh.ssh.SSHProcessError(id: int, command: str, rc: int, cwd: str | None, env: dict[str, Any], input: str | None, stdout: str, stderr: str)
+

Bases: Exception

+

SSH Process Error.

+
+ +
+
+exception pytest_mh.ssh.SSHAuthenticationError(host: str, port: int, user: str)
+

Bases: Exception

+
+ +
+
+class pytest_mh.ssh.SSHClient(host: str, *, user: str, password: str, port: int = 22, shell: ~typing.Type[~pytest_mh.ssh.SSHProcess] = <class 'pytest_mh.ssh.SSHProcess'>, logger: ~pytest_mh._private.logging.MultihostLogger)
+

Bases: object

+

Interactive SSH client.

+
+
Example: Blocking call
+
# Connect to SSH server, it is automatically disconnected when leaving the with statement
+with SSHClient(host, user=username, password=password, logger=logger) as ssh:
+    result = ssh.run('echo Hello World')
+    print(result.rc)
+    print(result.stdout)
+
+    result = ssh.run('cat', input='Hello World')
+    print(result.rc)
+    print(result.stdout)
+
+
+
+
+
Example: Non-blocking call
+
# Connect to SSH server, it is automatically disconnected when leaving the with statement
+with SSHClient(host, user=username, password=password, logger=logger) as ssh:
+    # The process is executed, but it does not block. In order to wait for it to finish, run process.wait()
+    process = ssh.async_run('echo Hello World')
+    result = process.wait()
+    print(result.rc)
+    print(result.stdout)
+
+    # You can write to stdin directly in asynchronous run
+    process = ssh.async_run('cat')
+    process.stdin.write('Hello World')
+    process.send_eof()
+    result = process.wait()
+    print(result.rc)
+    print(result.stdout)
+
+    # You can also work with inputs and outputs more interactively.
+    # The process is automatically waited when leaving the with statement.
+    with ssh.async_run('bash') as process:
+        process.stdin.write('echo Hello World\n')
+        print(next(process.stdout))
+
+        process.stdin.write('echo This works as well\n')
+        print(next(process.stdout))
+
+
+
+
+

Note

+

It is possible to set MH_SSH_DEBUG=yes environment variable to +log output and exist status to from commands, regardless of what log +level is used. This essentially enforces the SSHLog.Full level.

+
+
+
Parameters:
+
    +
  • host (BaseRole | str) – Host name to connect to.

  • +
  • user (str) – Username to authenticate.

  • +
  • password (str) – Password.

  • +
  • logger (MultihostLogger) – Multihost logger.

  • +
  • port (int, optional) – SSH port, defaults to 22

  • +
  • shell (str, optional) – User shell used to run commands, defaults to ‘/usr/bin/bash -c’

  • +
+
+
+
+
+property connected: bool
+
+
Returns:
+

True if the client is connected, False otherwise.

+
+
Return type:
+

bool

+
+
+
+ +
+
+property conn: SSHClient
+

Low-level connection object.

+
+
Returns:
+

Parallel-ssh connection object.

+
+
Return type:
+

pssh.clients.ssh.SSHClient

+
+
+
+ +
+
+connect() None
+

Connect to the host.

+
+
Raises:
+

SSHAuthenticationError – If user fails to authenticate.

+
+
+
+ +
+
+disconnect() None
+

Disconnect client.

+
+ +
+
+async_run(command: str, *, cwd: str | None = None, env: dict[str, Any] | None = None, input: str | None = None, read_timeout: float = 30, log_level: SSHLog = SSHLog.Full) SSHProcess
+

Non-blocking command call.

+

The command is run under shell specified in the constructor and it is +executed immediately, however it does not wait for the command to finish.

+
+
Parameters:
+
    +
  • command (str) – Command to run.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None (= do not change)

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • read_timeout (float, optional) – Timeout in seconds, how long should the client wait for output, defaults to 30 seconds

  • +
  • log_level (SSHLog, optional) – Log level, defaults to SSHLog.Full

  • +
+
+
Returns:
+

Instance of SSHProcess, the process is already running.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+run(command: str, *, cwd: str | None = None, env: dict[str, Any] | None = None, input: str | None = None, read_timeout: float = 2, log_level: SSHLog = SSHLog.Full, raise_on_error: bool = True) SSHProcessResult
+

Blocking command call.

+

The command is run under shell specified in the constructor and it is +executed immediately. It waits for the command to finish and returns +its result.

+
+
Parameters:
+
    +
  • command (str) – Command to run.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None (= do not change)

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • read_timeout (float, optional) – Timeout in seconds, how long should the client wait +for output, defaults to 30 seconds

  • +
  • log_level (SSHLog, optional) – Log level, defaults to SSHLog.Full

  • +
  • raise_on_error (bool, optional) – If True, raise SSHProcessError if +command exited with non-zero return code, defaults to True

  • +
+
+
Raises:
+

SSHProcessError – If raise_on_error is True and the command exited with non-zero return code.

+
+
Returns:
+

Command result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_exec(argv: list[Any], *, cwd: str | None = None, env: dict[str, Any] | None = None, input: str | None = None, read_timeout: float = 2, log_level: SSHLog = SSHLog.Full) SSHProcess
+

Non-blocking command call.

+

The command is run under shell specified in the constructor and it is +executed immediately, however it does not wait for the command to finish.

+

The command is provided as argv list.

+
+
Parameters:
+
    +
  • argv (list[Any]) – Command to run.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None (= do not change)

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • read_timeout (float, optional) – Timeout in seconds, how long should the client wait for output, defaults to 30 seconds

  • +
  • log_level (SSHLog, optional) – Log level, defaults to SSHLog.Full

  • +
+
+
Returns:
+

Instance of SSHProcess, the process is already running.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+exec(argv: list[Any], *, cwd: str | None = None, env: dict[str, Any] | None = None, input: str | None = None, read_timeout: float = 2, log_level: SSHLog = SSHLog.Full, raise_on_error: bool = True) SSHProcessResult
+

Blocking command call.

+

The command is run under shell specified in the constructor and it is +executed immediately. It waits for the command to finish and returns its +result.

+

The command is provided as argv list.

+
+
Parameters:
+
    +
  • argv (list[Any]) – Command to run.

  • +
  • cwd (str | None, optional) – Working directory, defaults to None (= do not change)

  • +
  • env (dict[str, Any] | None, optional) – Additional environment variables, defaults to None

  • +
  • input (str | None, optional) – Content of standard input, defaults to None

  • +
  • read_timeout (float, optional) – Timeout in seconds, how long should the client wait +for output, defaults to 30 seconds

  • +
  • log_level (SSHLog, optional) – Log level, defaults to SSHLog.Full

  • +
  • raise_on_error (bool, optional) – If True, raise SSHProcessError if +command exited with non-zero return code, defaults to True

  • +
+
+
Raises:
+

SSHProcessError – If raise_on_error is True and the command exited with non-zero return code.

+
+
Returns:
+

Command result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+expect(expect_script: str, *, verbose: bool = True, raise_on_error: bool = False) SSHProcessResult
+

Run expect script.

+
+
Parameters:
+
    +
  • expect_script (str) – Expect script.

  • +
  • verbose (bool, optional) – Enable expect debug output (-d), default to True.

  • +
  • raise_on_error (bool, optional) – If True, raise SSHProcessError if +command exited with non-zero return code, defaults to False

  • +
+
+
Returns:
+

Expect script result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+expect_nobody(expect_script: str, *, verbose: bool = True, raise_on_error: bool = False) SSHProcessResult
+

Run expect script as user nobody.

+

The main use case is to avoid running the command as root if the client +is connected to the root user SSH session.

+
+
Parameters:
+
    +
  • expect_script (str) – Expect script.

  • +
  • verbose (bool, optional) – Enable expect debug output (-d), default to True.

  • +
  • raise_on_error (bool, optional) – If True, raise SSHProcessError if +command exited with non-zero return code, defaults to False

  • +
+
+
Returns:
+

Expect return code.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.firewall.html b/docs.bak/_build/html/api/pytest_mh.utils.firewall.html new file mode 100644 index 0000000..357436f --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.firewall.html @@ -0,0 +1,270 @@ + + + + + + + pytest_mh.utils.firewall — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.utils.firewall

+

Classes

+ + + + + + +

Firewalld(host)

Configure firewall using firewalld.

+
+
+class pytest_mh.utils.firewall.Firewalld(host: MultihostHost)
+

Bases: Firewall

+

Configure firewall using firewalld.

+

All changes are automatically reverted when a test is finished.

+
+
Parameters:
+

host (MultihostHost) – Remote host instance.

+
+
+
+
+property inbound: FirewalldInboundRules
+

Configure firewall inbound rules.

+
+
Returns:
+

Inbound rules manager.

+
+
Return type:
+

FirewallInboundRules

+
+
+
+ +
+
+property outbound: FirewalldOutboundRules
+

Configure firewall outbound rules.

+
+
Returns:
+

Outbound rules manager.

+
+
Return type:
+

FirewalldOutboundRules

+
+
+
+ +
+
+add_direct_rule(chain: str, args: list[Any], *, table: str = 'filter', ip_family: Literal['ipv4', 'ipv6', 'all'] = 'all', priority: int | None = None) int
+

Add a new direct rule.

+

This methods returns a priority of this rule. You need to use this +priority if you remove the rule with remove_direct_rule().

+
+
Parameters:
+
    +
  • chain (str) – iptables chain (e.g. INPUT or OUTPUT).

  • +
  • args (list[Any]) – iptables arguments

  • +
  • table (str, optional) – iptables table, defaults to “filter”

  • +
  • ip_family (Literal["ipv4", "ipv6", "all"], optional) – If the rules is added as IPv4, IPv6 rule or both, defaults to all.

  • +
  • priority (int | None, optional) – Rule priority, defaults to None (= auto-assign next value)

  • +
+
+
Returns:
+

Rule priority, to be used for rule removal.

+
+
Return type:
+

int

+
+
+
+ +
+
+remove_direct_rule(priority: int, chain: str, args: list[Any], *, table: str = 'filter', ip_family: Literal['ipv4', 'ipv6', 'all']) None
+

Remove direct rule.

+
+
Parameters:
+
    +
  • priority (int) – Rule priority.

  • +
  • chain (str) – iptables chain (e.g. INPUT or OUTPUT).

  • +
  • args (list[Any]) – iptables arguments

  • +
  • table (str, optional) – iptables table, defaults to “filter”

  • +
  • ip_family (Literal["ipv4", "ipv6", "all"], optional) – If the rules is removed from IPv4, IPv6 rules or both, defaults to all.

  • +
+
+
+
+ +
+
+add_rich_rule(rule: str, priority: int | None = None) int
+

Add rich rule.

+

The parameter “rule” is the part after “rule priority=X”. This part is +added automatically. That is:

+
$ firewall-cmd --add-rich-rule rule priority={priority} {rule}
+
+
+
+
Parameters:
+
    +
  • rule (str) – Firewalld rich rule.

  • +
  • priority (int | None, optional) – Rule priority, defaults to None (= auto-assign next +value)

  • +
+
+
Returns:
+

Rule priority, to be used for rule removal.

+
+
Return type:
+

int

+
+
+
+ +
+
+remove_rich_rule(priority: int, rule: str) None
+

Remove rich rule.

+

The parameter “rule” is the part after “rule priority=X”. This part is +added automatically. That is:

+
$ firewall-cmd --remove-rich-rule rule priority="{priority}" {rule}
+
+
+
+
Parameters:
+
    +
  • priority (int) – Rule priority

  • +
  • rule (str) – Firewalld rich rule.

  • +
+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.fs.html b/docs.bak/_build/html/api/pytest_mh.utils.fs.html new file mode 100644 index 0000000..82f7a82 --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.fs.html @@ -0,0 +1,551 @@ + + + + + + + pytest_mh.utils.fs — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.utils.fs

+

Classes

+ + + + + + +

LinuxFileSystem(host)

Perform file system operations on remote host.

+
+
+class pytest_mh.utils.fs.LinuxFileSystem(host: MultihostHost)
+

Bases: MultihostUtility

+

Perform file system operations on remote host.

+

All changes are automatically reverted when a test is finished.

+
+
Parameters:
+

host (MultihostHost) – Remote host instance.

+
+
+
+
+mkdir(path: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) None
+

Create directory on remote host.

+
+
Parameters:
+
    +
  • path (str) – Path of the directory.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
+
+
+
+ +
+
+mkdir_p(path: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) None
+

Create directory on remote host, including all missing parent directories.

+
+
Parameters:
+
    +
  • path (str) – Path of the directory.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
+
+
+
+ +
+
+mktmp(contents: str | None = None, *, mode: str | None = None, user: str | None = None, group: str | None = None, dedent: bool = True) str
+

Create temporary file on remote host.

+
+
Parameters:
+
    +
  • contents (str | None) – File contents to write.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
  • dedent (bool, optional) – Automatically dedent and strip file contents, defaults to True

  • +
+
+
Raises:
+

OSError – If the file can not be created.

+
+
Returns:
+

Temporary file path.

+
+
Return type:
+

str

+
+
+
+ +
+
+rm(path: str) None
+

Remove remote file or directory.

+
+
Parameters:
+

path (str) – File path.

+
+
+
+ +
+
+read(path: str) str
+

Read remote file and return its contents.

+
+
Parameters:
+

path (str) – File path.

+
+
Returns:
+

File contents.

+
+
Return type:
+

str

+
+
+
+ +
+
+exists(path: str) bool
+

Checks file or directory to see if they exist.

+
+
Parameters:
+

path (str) – File path.

+
+
Returns:
+

True or False

+
+
Return type:
+

bool

+
+
+
+ +
+
+write(path: str, contents: str, *, mode: str | None = None, user: str | None = None, group: str | None = None, dedent: bool = True) None
+

Write to a remote file.

+
+
Parameters:
+
    +
  • path (str) – File path.

  • +
  • contents (str) – File contents to write.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
  • dedent (bool, optional) – Automatically dedent and strip file contents, defaults to True

  • +
+
+
+
+ +
+
+append(path: str, contents: str, *, dedent: bool = True) None
+

Append to a remote file.

+
+
Parameters:
+
    +
  • path (str) – File path.

  • +
  • contents (str) – File contents to write.

  • +
  • dedent (bool, optional) – Automatically dedent and strip file contents, defaults to True

  • +
+
+
+
+ +
+
+touch(path: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) None
+

Touch a remote file.

+
+
Parameters:
+
    +
  • path (str) – File path.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
  • dedent (bool, optional) – Automatically dedent and strip file contents, defaults to True

  • +
+
+
+
+ +
+
+truncate(path: str, *, size: int = 0) None
+

Truncate remote file.

+
+
Parameters:
+
    +
  • path (str) – File path.

  • +
  • size (int, optional) – Target file size, defaults to 0

  • +
+
+
+
+ +
+
+copy(srcpath: str, dstpath: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) None
+

Copy a remote file @srcpath to remote @dstpath.

+
+
Parameters:
+
    +
  • srcpath (str) – Remote source file path.

  • +
  • dstpath (str) – Remote destination file path.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
  • dedent (bool, optional) – Automatically dedent and strip file contents, defaults to True

  • +
+
+
+
+ +
+
+upload(local_path: str, remote_path: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) None
+

Upload local file.

+
+
Parameters:
+
    +
  • local_path (str) – Source local path.

  • +
  • remote_path (str) – Destination remote path.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
+
+
+
+ +
+
+upload_to_tmp(local_path: str, *, mode: str | None = None, user: str | None = None, group: str | None = None) str
+

Upload local file to a new temporary file on remote host.

+
+
Parameters:
+
    +
  • local_path (str) – Source local path.

  • +
  • mode (str | None, optional) – Access mode (chmod value), defaults to None

  • +
  • user (str | None, optional) – Owner, defaults to None

  • +
  • group (str | None, optional) – Group, defaults to None

  • +
+
+
Returns:
+

Temporary file path.

+
+
Return type:
+

str

+
+
+
+ +
+
+download(remote_path: str, local_path: str) None
+

Download file from remote host to local machine.

+
+
Parameters:
+
    +
  • remote_path (str) – Remote path.

  • +
  • local_path (str) – Local path.

  • +
+
+
+
+ +
+
+download_files(paths: list[str], local_path: str) None
+

Download multiple files from remote host. The files are stored in single +gzipped tarball on the local machine. The remote file path may contain +glob pattern.

+
+
Parameters:
+
    +
  • paths (list[str]) – List of remote file paths. May contain glob pattern.

  • +
  • local_path (str) – Path to the gzipped tarball destination file on local machine.

  • +
+
+
+
+ +
+
+backup(path: str) bool
+

Backup file or directory.

+

The path is automatically restored from the backup when a test is +finished.

+
+

Note

+

It is also possible that the file or directory does not exist. In +that case, the path is removed during the teardown process to +remove any file or directory that might have been created.

+
+
+
Parameters:
+

path (str) – Path to back up.

+
+
Returns:
+

True if the path exists and backup was done, False otherwise.

+
+
Return type:
+

bool

+
+
+
+ +
+
+restore(path: str) bool
+

Restore file or directory from previous backup.

+
+

Note

+

It is also possible that the file or directory does not exist. In +that case, the path is removed to remove any file or directory that +might have been created.

+
+
+
Parameters:
+

path (str) – Path to restore.

+
+
Returns:
+

True if the backup of path exists and it was restored, False otherwise.

+
+
Return type:
+

bool

+
+
+
+ +
+
+wc(file: str, lines: bool = False, word: bool = False, bytes: bool = False, chars: bool = False) SSHProcessResult
+

Print newline, word, and byte counts for specific file.

+

Output example without additional arguments: 67 564 3514 file_name

+
+
Parameters:
+
    +
  • file (str) – File whose content is counted

  • +
  • lines (bool, optional) – Print the newline counts, defaults to False

  • +
  • word (bool, optional) – Print the word counts, defaults to False

  • +
  • bytes (bool, optional) – Print the byte counts, defaults to False

  • +
  • chars (bool, optional) – Print the character counts, defaults to False

  • +
+
+
Returns:
+

Result of process

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+diff(path1: str, path2: str, *, brief: bool = False, recursive: bool = False, ignore_case: bool = False, args: list[str] | None = None) SSHProcessResult
+

Compare files line by line. +Exit status is 0 if inputs are the same, 1 if different, 2 if trouble.

+
+
Parameters:
+
    +
  • path1 (str) – Path to file or directory to be compared

  • +
  • path2 (str) – Path to file or directory to be compared

  • +
  • brief (bool, optional) – Report only when files differ, but do not print the diff itself, defaults to False

  • +
  • recursive (bool, optional) – Recursively compare any subdirectories found, defaults to False

  • +
  • ignore_case (bool, optional) – Ignore case differences in file contents, defaults to False

  • +
  • args (list[str] | None, optional) – Additional options, defaults to None

  • +
+
+
Returns:
+

Result of process

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+chmod(mode: str, path: str, args: list[str] | None = None) SSHProcessResult
+

Change file/folder mode bits. +Mode can be specified in two ways: octal number e.g. “666”, “444” or +a symbolic representation of changes e.g. “u=rw,go=r”, “go-rw”

+
+
Parameters:
+
    +
  • mode (str) – New mode of file/folder

  • +
  • path (str) – File or folder whose permissions change

  • +
  • args (list[str] | None, optional) – Additional options, defaults to None

  • +
+
+
Returns:
+

Result of process

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+chown(path: str, user: str | None = None, group: str | None = None, args: list[str] | None = None) SSHProcessResult
+

Change file owner and group.

+
+
Parameters:
+
    +
  • path (str) – Path to file

  • +
  • user (str | None, optional) – New file owner, if None then user remains same, defaults to None

  • +
  • group (str | None, optional) – New file group, if None then group remains same, defaults to None

  • +
  • args (list[str] | None, optional) – Additional options, defaults to None

  • +
+
+
Returns:
+

Result of process

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.html b/docs.bak/_build/html/api/pytest_mh.utils.html new file mode 100644 index 0000000..f5f8984 --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.html @@ -0,0 +1,150 @@ + + + + + + + pytest_mh.utils — pytest_mh documentation + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.journald.html b/docs.bak/_build/html/api/pytest_mh.utils.journald.html new file mode 100644 index 0000000..aa53bff --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.journald.html @@ -0,0 +1,231 @@ + + + + + + + pytest_mh.utils.journald — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.utils.journald

+

Classes

+ + + + + + +

JournaldUtils(host)

Perform journald related tasks.

+
+
+class pytest_mh.utils.journald.JournaldUtils(host: MultihostHost)
+

Bases: MultihostUtility

+

Perform journald related tasks.

+
+
Parameters:
+

host (MultihostHost) – Remote host instance.

+
+
+
+
+setup() None
+

Called before execution of each test.

+
+ +
+
+clear() None
+

Reset timestamp

+
+ +
+
+journalctl(current: bool = True, *, unit: str | None = None, lines: int | None = None, since: str | None = None, reverse: bool = False, no_pager: bool = False, grep: str | None = None, output: str | None = None, identifier: str | None = None, system: bool = False, user: bool = False, args: list[Any] | None = None) SSHProcessResult
+

Execute journalctl with given arguments. Show messages only for current test run, by default. +Note that raise_on_error is False and the command may return non-zero return code.

+
+
Parameters:
+
    +
  • current (bool, optional) – Show messages only for current test run, defaults to True

  • +
  • unit (str | None, optional) – Show messages for the specified systemd unit, defaults to None

  • +
  • lines (int | None, optional) – Show the most recent journal events and limit the number of events shown, defaults to None

  • +
  • since (str | None, optional) – Start showing entries on or newer than the specified date, defaults to None

  • +
  • reverse (bool, optional) – Reverse output so that the newest entries are displayed first, defaults to False

  • +
  • no_pager (bool, optional) – Do not pipe output into a pager, defaults to False

  • +
  • grep (str | None, optional) – Filter output to entries where the MESSAGE= field matches specified regex, defaults to None

  • +
  • output (str | None, optional) – Controls the formatting of the journal entries, defaults to None

  • +
  • identifier (str | None, optional) – Show messages for the specified syslog identifier SYSLOG_IDENTIFIER, defaults to None

  • +
  • system (bool, optional) – Show messages from system services and the kernel, defaults to False

  • +
  • user (bool, optional) – Show messages from service of current user, defaults to False

  • +
  • args (list[Any] | None, optional) – Additional options, defaults to None

  • +
+
+
Returns:
+

SSH process result

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+is_match(pattern: str) bool
+

Search the logs for a pattern.

+
+
Parameters:
+

pattern (str) – Pattern to be searched for

+
+
Returns:
+

True, if pattern found

+
+
Return type:
+

bool

+
+
+
+ +
+
+count(pattern: str) int
+

Search the logs for a pattern and return number of occurrences.

+
+
Parameters:
+

pattern (str) – Pattern to be searched for

+
+
Returns:
+

Number of occurrences of the pattern

+
+
Return type:
+

int

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.services.html b/docs.bak/_build/html/api/pytest_mh.utils.services.html new file mode 100644 index 0000000..664f260 --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.services.html @@ -0,0 +1,433 @@ + + + + + + + pytest_mh.utils.services — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.utils.services

+

Classes

+ + + + + + +

SystemdServices(host)

Manage remote services.

+
+
+class pytest_mh.utils.services.SystemdServices(host: MultihostHost)
+

Bases: MultihostUtility

+

Manage remote services.

+
+
Parameters:
+

host (HostType) – Remote host instance.

+
+
+
+
+teardown() None
+

Teardown object.

+
+ +
+
+async_start(service: str) SSHProcess
+

Start a systemd unit. Non-blocking call.

+

systemctl status $unit is called automatically if the unit can not +be started. The status is then visible in the logs.

+
+
Parameters:
+

service (str) – Unit name.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+start(service: str, raise_on_error: bool = True) SSHProcessResult
+

Start a systemd unit. The call will wait until the operation is finished.

+

systemctl status $unit is called automatically if the unit can not +be started. The status is then visible in the logs.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_stop(service: str) SSHProcess
+

Stop a systemd unit. Non-blocking call.

+

systemctl status $unit is called automatically if the unit can not +be stopped. The status is then visible in the logs.

+
+
Parameters:
+

service (str) – Unit name.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+stop(service: str, raise_on_error: bool = True) SSHProcessResult
+

Stop a systemd unit. The call will wait until the operation is finished.

+

systemctl status $unit is called automatically if the unit can not +be stoped. The status is then visible in the logs.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_restart(service: str) SSHProcess
+

Restart a systemd unit. Non-blocking call.

+

systemctl status $unit is called automatically if the unit can not +be restarted. The status is then visible in the logs.

+
+
Parameters:
+

service (str) – Unit name.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+restart(service: str, raise_on_error: bool = True) SSHProcessResult
+

Restart a systemd unit. The call will wait until the operation is finished.

+

systemctl status $unit is called automatically if the unit can not +be restarted. The status is then visible in the logs.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_reload(service: str) SSHProcess
+

Reload a systemd unit. Non-blocking call.

+

systemctl status $unit is called automatically if the unit can not +be reloaded. The status is then visible in the logs.

+
+
Parameters:
+

service (str) – Unit name.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+reload(service: str, raise_on_error: bool = True) SSHProcessResult
+

Reload a systemd unit. The call will wait until the operation is finished.

+

systemctl status $unit is called automatically if the unit can not +be reloaded. The status is then visible in the logs.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_status(service: str) SSHProcess
+

Get systemd unit status. Non-blocking call.

+
+
Parameters:
+

service (str) – Unit name.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+status(service: str, raise_on_error: bool = True) SSHProcessResult
+

Get systemd unit status. The call will wait until the operation is finished.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+
+async_get_property(service: str, prop: str) SSHProcess
+

Get property of systemd unit. Non-blocking call.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • prop (str) – Propery name.

  • +
+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+get_property(service: str, prop: str, raise_on_error: bool = True) str
+

Get property of systemd unit. The call will wait until the operation is finished.

+
+
Parameters:
+
    +
  • service (str) – Unit name.

  • +
  • prop (str) – Propery name.

  • +
  • raise_on_error (bool, optional) – Raise exception on error, defaults to True

  • +
+
+
Returns:
+

property value as string.

+
+
Return type:
+

str

+
+
+
+ +
+
+async_reload_daemon() SSHProcess
+

Reload systemd daemon to refresh unit files. Non-blocking call.

+
+
Returns:
+

Running SSH process.

+
+
Return type:
+

SSHProcess

+
+
+
+ +
+
+reload_daemon(raise_on_error: bool = True) SSHProcessResult
+

Reload systemd daemon to refresh unit files. The call will wait until the operation is finished.

+
+
Parameters:
+

raise_on_error (bool, optional) – Raise exception on error, defaults to True

+
+
Returns:
+

SSH process result.

+
+
Return type:
+

SSHProcessResult

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/api/pytest_mh.utils.tc.html b/docs.bak/_build/html/api/pytest_mh.utils.tc.html new file mode 100644 index 0000000..6d9cafd --- /dev/null +++ b/docs.bak/_build/html/api/pytest_mh.utils.tc.html @@ -0,0 +1,179 @@ + + + + + + + pytest_mh.utils.tc — pytest_mh documentation + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh.utils.tc

+

Classes

+ + + + + + +

LinuxTrafficControl(host)

Perform traffic control operations on remote host.

+
+
+class pytest_mh.utils.tc.LinuxTrafficControl(host: MultihostHost)
+

Bases: MultihostUtility

+

Perform traffic control operations on remote host.

+

All changes are automatically reverted when a test is finished.

+
+
Parameters:
+

host (MultihostHost) – Remote host instance.

+
+
+
+
+add_delay(hostname: str, time: str | int)
+

Add delay to the network connection. A maximum of 15 connections can be delayed at a time. It is recommended +to specify the delay from minimum to maximum to avoid starvation.

+
+
Parameters:
+
    +
  • hostname (str) – Target hostname.

  • +
  • time (str | int) – Delay. Units can be specified; if not specified, the default is milliseconds.

  • +
+
+
+
+ +
+
+remove_delay(hostname: str)
+

Remove delay in the network connection.

+
+
Parameters:
+

hostname (str) – Target hostname.

+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/classes.html b/docs.bak/_build/html/classes.html new file mode 100644 index 0000000..b355eb3 --- /dev/null +++ b/docs.bak/_build/html/classes.html @@ -0,0 +1,446 @@ + + + + + + + Extending pytest-mh — pytest_mh documentation + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Extending pytest-mh

+

There are five main classes that are used by the pytest-mh plugin that give +you access to remote hosts and provide you tools to build your own API that +fulfills specific requirements.

+

By extending these classes, you can provide your own functionality and +configuration options.

+
    +
  • MultihostConfig: top level class that reads configuration and creates domain objects

  • +
  • MultihostDomain: creates host objects

  • +
  • MultihostHost: lives through the whole pytest session, gives low-level access to the host

  • +
  • MultihostRole: lives only for a single test case, provides high-level API

  • +
  • MultihostUtility: provides high-level API that can be shared between multiple roles

  • +
  • TopologyController: control topology behavior such as per-topology setup and teardown

  • +
+
+
+ graph LR + subgraph Lives for the whole pytest session + MultihostConfig -->|creates| MultihostDomain + MultihostDomain -->|creates| MultihostHost + end + + subgraph Lives only for single test case + mh(mh fixture) -->|creates| MultihostRole + MultihostRole -->|uses| MultihostHost + MultihostRole -->|creates| MultihostUtility + end +
+

Class relationship

+
+
+

In order to start using pytest-mh, you must provide at least your +own:MultihostConfig to define what domain objects will be +created and MultihostDomain to associate hosts and roles +with specific classes. It is recommended that you also extend the other classes +as well to provide high-level API for your tests.

+
+

Note

+

MultihostHost, MultihostRole and +MultihostUtility have setup and teardown methods +that you can use to properly initialize the host and also to clean up +after the test is finished.

+

By extending these classes, you can give test writers a well-defined, +unified API that can automate several tasks and make sure the hosts are +properly setup before the test starts and all changes are correctly reverted +once the test is finished.

+

This makes it easier to write new tests and ensure that the tests start +with a fresh setup every time.

+
+
+

MultihostConfig

+

MultihostConfig is created by pytest-mh pytest plugin +during pytest session initialization. It reads the given multihost configuration +and creates the domain objects.

+

You must provide your own class that extends MultihostConfig +in order to use the plugin. Your class must override +id_to_domain_class which creates your own +MultihostDomain object.

+

Optionally, you can override +TopologyMarkClass and provide your own +TopologyMark class. With this, you can provide additional +information to the topology marker as needed by your project.

+
class ExampleMultihostConfig(MultihostConfig):
+    @property
+    def TopologyMarkClass(self) -> Type[TopologyMark]:
+        return ExampleTopologyMark
+
+    @property
+    def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]:
+        """
+        Map domain id to domain class. Asterisk ``*`` can be used as fallback
+        value.
+
+        :rtype: Class name.
+        """
+        return {"*": ExampleMultihostDomain}
+
+
+
+
+

MultihostDomain

+

MultihostDomain is created by +MultihostConfig and it allows you to associate roles from +your multihost configuration to your own hosts, roles, and Python classes to give +them meaning.

+
class ExampleMultihostDomain(MultihostDomain[ExampleMultihostConfig]):
+    def __init__(self, config: ExampleMultihostConfig, confdict: dict[str, Any]) -> None:
+        super().__init__(config, confdict)
+
+    @property
+    def role_to_host_class(self) -> dict[str, Type[MultihostHost]]:
+        """
+        Map role to host class. Asterisk ``*`` can be used as fallback value.
+
+        :rtype: Class name.
+        """
+        return {
+            "client": ClientHost,
+            "ldap": LDAPHost,
+        }
+
+    @property
+    def role_to_role_class(self) -> dict[str, Type[MultihostRole]]:
+        """
+        Map role to role class. Asterisk ``*`` can be used as fallback value.
+
+        :rtype: Class name.
+        """
+        return {
+            "client": Client,
+            "ldap": LDAP,
+        }
+
+
+
+
+

MultihostHost

+

One MultihostHost object is created per each host defined in +your multihost configuration. Each host is created as an instance of a class +that is determined by the role to host mapping in +role_to_host_class().

+

This object gives you access to a SSH connection to the remote host. The object +lives for the whole pytest session which makes it a good place to put +functionality and data that must be available across all tests. For example, it +can perform an initial backup of the host.

+

It provides two setup and teardown methods:

+
    +
  • pytest_setup() - called when pytest starts before execution of any test

  • +
  • pytest_teardown() - called when pytest terminated after all tests are done

  • +
  • setup() - called before execution of each test

  • +
  • teardown() - called after a test is done

  • +
+
+

See also

+

See /example/lib/hosts/kdc.py +to see an example implementation of custom host.

+
+
+
+

MultihostRole

+

Similar to MultihostHost, one +MultihostRole object is created per each host defined in +your multihost configuration. The difference between these two is that while +MultihostHost lives for the whole pytest session, +MultihostRole lives only for a single test run therefore the +role objects are not shared between tests. Role objects are also available to +you in your tests through pytest dynamic fixtures.

+

The purpose of the MultihostRole object is to provide high +level API for your project that you can use in your tests and to perform +per-test setup and clean up. For this purpose, it provides setup and teardown +methods that you can overwrite:

+
    +
  • setup() - called before execution of each test

  • +
  • teardown() - called after a test is done

  • +
+
+

See also

+

See /example/lib/roles/kdc.py +to see an example implementation of custom role.

+
+
+
+

MultihostUtility

+

Role object can also contain instances of MultihostUtility +that can be used to share functionality between individual roles. A +setup() and +teardown() methods are automatically called +after the role is setup and before the role teardown is executed.

+
+

Note

+

MultihostUtility also contains +setup_when_used() which is called only +after the class is first used inside the test (after +setup()) and +teardown_when_used() which is called only +if the class was used (before teardown()).

+

This can be especially useful if the utility class is used only sporadically +but the setup and teardown are quite expensive. In such case, you probably +want to perform the setup and teardown only if the class was actually used +in the test.

+
+

There are already some utility classes implemented in pytest-mh. See +pytest_mh.utils for more information on them.

+
+

See also

+

See /pytest_mh/utils/fs.py +to see an implementation of a utility class that gives you access to files +and directories on the remote host.

+

Each change that is made through the utility object (such as writing to a +file) is automatically reverted (the original file is restored).

+
+
+
+

TopologyController

+

Topology controller can be assigned to a topology via @pytest.mark.topology +or through known topology class. This controller provides various methods to +control the topology behavior:

+
    +
  • per-topology setup and teardown, called once before the first test/after the +last test for given topology is executed

  • +
  • per-test topology setup and teardown, called before and after every test case +for given topology

  • +
  • check topology requirements and skip the test if these are not satisfied

  • +
+

In order to use the controller, you need to inherit from +TopologyController and override desired methods. Each method +can take any parameter as defined by the topology fixtures. The parameter value +is an instance of a MultihostHost object.

+

See TopologyController for API documentation

+
+
Example topology controller
+
class ExampleController(TopologyController):
+    def skip(self, client: ClientHost) -> str | None:
+        result = client.ssh.run(
+            '''
+            # Implement your requirement check here
+            exit 1
+            ''', raise_on_error=False)
+        if result.rc != 0:
+            return "Topology requirements were not met"
+
+        return None
+
+    def topology_setup(self, client: ClientHost):
+        # One-time setup, prepare the host for this topology
+        # Changes done here are shared for all tests
+        pass
+
+    def topology_teardown(self, client: ClientHost):
+        # One-time teardown, this should undo changes from
+        # topology_setup
+        pass
+
+    def setup(self, client: ClientHost):
+        # Perform per-topology test setup
+        # This is called before execution of every test
+        pass
+
+    def teardown(self, client: ClientHost):
+        # Perform per-topology test teardown, this should undo changes
+        # from setup
+        pass
+
+
+
+
+
Example with low-level topology mark
+
class ExampleController(TopologyController):
+    # Implement methods you are interested in here
+    pass
+
+@pytest.mark.topology(
+    "example", Topology(TopologyDomain("example", client=1)),
+    controller=ExampleController(),
+    fixtures=dict(client="example.client[0]")
+)
+def test_example(client: Client):
+    pass
+
+
+
+
+
Example with KnownTopology (recommended)
+
class ExampleController(TopologyController):
+    # Implement methods you are interested in here
+    pass
+
+@final
+@unique
+class KnownTopology(KnownTopologyBase):
+    EXAMPLE = TopologyMark(
+        name='example',
+        topology=Topology(TopologyDomain("example", client=1)),
+        controller=ExampleController(),
+        fixtures=dict(client='example.client[0]'),
+    )
+
+@pytest.mark.topology(KnownTopology.EXAMPLE)
+def test_example(client: Client):
+    pass
+
+
+
+
+
+

Setup and teardown

+

The following schema shows how individual setup and teardown methods of host, +role, and utility objects are executed.

+
+
+ graph TD + s([start]) --> hps(host.pytest_setup) + + subgraph run [ ] + subgraph setup [Setup before test] + hs(host.setup) --> cs(controller.setup) --> rs[role.setup] + rs --> us[utility.setup] + end + + setup -->|run test| teardown + + subgraph teardown [Teardown after test] + ut[utility.teadown] --> rt[role.teardown] + rt --> ct(controller.teardown) + ct --> ht(host.teardown) + end + end + + hps -->|run tests| cts(controller.topopology_setup) -->|run all tests for topology| run + run -->|all tests for topology finished| ctt(controller.topology_teardown) -->|all tests finished| hpt(host.pytest_teardown) + hpt --> e([end]) + + style run fill:#FFF + style setup fill:#DFD,stroke-width:2px,stroke:#AFA + style teardown fill:#FDD,stroke-width:2px,stroke:#FAA +
+

Setup and teardown

+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/config.html b/docs.bak/_build/html/config.html new file mode 100644 index 0000000..5896dbb --- /dev/null +++ b/docs.bak/_build/html/config.html @@ -0,0 +1,271 @@ + + + + + + + Multihost configuration — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Multihost configuration

+

The multihost configuration file contains definition of the domains, hosts, and +their roles that are available to run the tests. It uses the YAML language.

+
+

Basic definition

+
domains:
+- id: <domain id>
+  hosts:
+  - hostname: <dns host name>
+    role: <host role>
+    os:
+      family: <host operating system family> (optional, defaults to "linux")
+    ssh:
+      host: <ssh host> (optional, defaults to host name)
+      port: <ssh port> (optional, defaults to 22)
+      username: <ssh username> (optional, defaults to "root")
+      password: <ssh password> (optional, defaults to "Secret123")
+    config: <additional configuration> (optional, defaults to {})
+    artifacts: <list of produced artifacts> (optional, defaults to {})
+
+
+

The top level element of the configuration is a list of domains. Each domain +has id attribute and defines the list of available hosts.

+
    +
  • id: domain identifier which is used in the path inside mh fixture, see Accessing hosts - Deep dive into multihost fixtures

  • +
  • hosts: list of available hosts and their roles

    +
      +
    • hostname: DNS host name, may not necessarily be resolvable from the machine that runs pytest

    • +
    • role: host role

    • +
    • os.family: host operating system family, defaults to “linux”, see MultihostHostOSFamily

    • +
    • ssh.host: ssh host to connect to (may be a resolvable host name or an +IP address), defaults to the value of hostname

    • +
    • ssh.port: ssh port, defaults to 22

    • +
    • ssh.username: ssh username, defaults to root

    • +
    • ssh.password: ssh password for the user, defaults to Secret123

    • +
    • config: additional configuration, place for custom options, see Customize configuration

    • +
    • artifacts: list of artifacts that are automatically downloaded, see Gathering artifacts

    • +
    +
  • +
+
+
Sample configuration file
+
domains:
+- id: test
+  hosts:
+  - hostname: client.test
+    role: client
+    ssh:
+      host: 192.168.100.10
+      user: root
+      password: MySecret123
+    artifacts:
+    - /etc/sssd/*
+    - /var/log/sssd/*
+    - /var/lib/sss/db/*
+
+  - hostname: master.ldap.test
+    role: ldap
+    config:
+      binddn: cn=Directory Manager
+      bindpw: Secret123
+
+
+
+
+

Customize configuration

+

The config section of the host configuration can be used to extend the +configuration with custom options that are required by your project. If the +field is not set, it defaults to an empty dictionary dict().

+

To make a new configuration option available, simply inherit from +MultihostHost and access the option through +config (self.config).

+
+
Adding custom configuration options
+
class LDAPHost(MultihostHost[MyDomain]):
+  def __init__(self, *args, **kwargs) -> None:
+      super().__init__(*args, **kwargs)
+
+      self.binddn: str = self.config.get("binddn", "cn=Directory Manager")
+      """Bind DN ``config.binddn``, defaults to ``cn=Directory Manager``"""
+
+      self.bindpw: str = self.config.get("bindpw", "Secret123")
+      """Bind password ``config.bindpw``, defaults to ``Secret123``"""
+
+
+
+

The example above adds two new options binddn and bindpw. Since the +options provide default values, they are optional. You can set them in +the multihost configuration in the config field.

+
domains:
+- id: test
+  hosts:
+  - hostname: client.test
+    role: client
+    ssh:
+      host: 192.168.100.10
+      user: root
+      password: MySecret123
+
+  - hostname: master.ldap.test
+    role: ldap
+    config:
+      binddn: cn=Directory Manager
+      bindpw: Secret123
+
+
+
+
+

Gathering artifacts

+

The artifacts field of the host definition can be used to specify which +artifacts should be automatically collected from the host when a test is +finished. The field contains a list of artifacts. The values are path to the +artifacts with a possible wildcard character. For example:

+
- hostname: client.test
+  role: client
+  ssh:
+    host: 192.168.100.10
+    user: root
+    password: MySecret123
+  config:
+    artifacts:
+    - /etc/sssd/*
+    - /var/log/sssd/*
+    - /var/lib/sss/db/*
+
+
+

It is also possible to gather artifacts from +pytest_mh.MultihostHost.pytest_setup() and +pytest_mh.MultihostHost.pytest_teardown() calls. To do that, you need to +provide artifacts as dictionary with pytest_setup, pytest_teardown and +test keys.

+
- hostname: client.test
+  role: client
+  ssh:
+    host: 192.168.100.10
+    user: root
+    password: MySecret123
+  config:
+    artifacts:
+      pytest_setup:
+      - /var/log/host_setup.log
+      pytest_teardown:
+      - /var/log/host_teardown.log
+      test:
+      - /var/log/testrun.log
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/genindex.html b/docs.bak/_build/html/genindex.html new file mode 100644 index 0000000..4125319 --- /dev/null +++ b/docs.bak/_build/html/genindex.html @@ -0,0 +1,896 @@ + + + + + + Index — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + +

Index

+ +
+ A + | B + | C + | D + | E + | F + | G + | H + | I + | J + | K + | L + | M + | N + | O + | P + | R + | S + | T + | U + | V + | W + +
+

A

+ + + +
+ +

B

+ + +
+ +

C

+ + + +
+ +

D

+ + + +
+ +

E

+ + + +
+ +

F

+ + + +
+ +

G

+ + + +
+ +

H

+ + + +
+ +

I

+ + + +
+ +

J

+ + + +
+ +

K

+ + + +
+ +

L

+ + +
+ +

M

+ + + +
+ +

N

+ + + +
+ +

O

+ + + +
+ +

P

+ + + +
+ +

R

+ + + +
+ +

S

+ + + +
+ +

T

+ + + +
+ +

U

+ + + +
+ +

V

+ + +
+ +

W

+ + + +
+ + + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/index.html b/docs.bak/_build/html/index.html new file mode 100644 index 0000000..914dbb8 --- /dev/null +++ b/docs.bak/_build/html/index.html @@ -0,0 +1,256 @@ + + + + + + + pytest_mh - pytest multihost test framework — pytest_mh documentation + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

pytest_mh - pytest multihost test framework

+
+

Warning

+

This plugin is still actively developed and even though it is mostly stable, +we reserve the right to introduce minor breaking changes if it is required for +new functionality.

+
+

pytest-mh is a pytest plugin that, at a basic level, allows you to run shell +commands and scripts over SSH on remote Linux or Windows hosts. You use it to +execute system or application tests for your project on a remote host or hosts +(or containers) while running pytest locally keeping your local machine intact.

+

The plugin also provides building blocks that can be used to setup and teardown +your tests, perform automatic clean up of all changes done on the remote host, +and build a flexible and unified high-level API to manipulate the hosts from +your tests.

+
+
Example test taken from SSSD demo
+
@pytest.mark.topology(KnownTopology.AD)
+@pytest.mark.topology(KnownTopology.LDAP)
+@pytest.mark.topology(KnownTopology.IPA)
+@pytest.mark.topology(KnownTopology.Samba)
+def test__id(client: Client, provider: GenericProvider):
+    u = provider.user("tuser").add()
+    provider.group("tgroup_1").add().add_member(u)
+    provider.group("tgroup_2").add().add_member(u)
+
+    client.sssd.start()
+    result = client.tools.id("tuser")
+
+    assert result is not None
+    assert result.user.name == "tuser"
+    assert result.memberof(["tgroup_1", "tgroup_2"])
+
+
+
+
+

See also

+

A real life example of how pytest-mh can help test your code can be +seen in the SSSD project.

+
+
+

When do I want use the framework?

+
    +
  • Does your program affect the host in any way? If yes, it is safer to run it in +virtual machine or in a container to avoid affecting your local host. +pytest-mh takes care of that.

  • +
  • Does your program use client-server model? If yes, it is better to run the +client and the server on separate machines to make the tests more real. +pytest-mh takes care of that.

  • +
  • Does your program communicate with multiple backends? If yes, you need to +be able to assign each test to a specific backend and also be able to reuse a +single test for multiple backends. pytest-mh takes care of that.

  • +
  • Do you need complex tests that changes state of the system, file system or +other programs or databases? If yes, you need to make sure that all changes +are reverted when a test is done so the test does not affect other tests. +pytest-mh takes care of that.

  • +
  • Does your program talk to LDAP/IPA/AD/Samba/Kerberos? If yes, pytest-mh +can help you with that.

  • +
  • Do you use pytest-multihost framework for your current +tests? pytest-mh is a full Python 3 re-implementation of the old +pytest-multihost plugin. It builds on all its features and takes it to +a whole new level. You definitely want to switch to pytest-mh, +however it is not backwards compatible.

  • +
+
+
+

When I don’t want to use it?

+
    +
  • Do you want to test your Python code? Then this plugin will not help +you. It is designed for running system or applications tests, i.e. testing +your application as a whole.

  • +
+
+
+

What does the framework do?

+
    +
  • Allows you to run commands over SSH on remote hosts (or virtual machines or +containers) using bash or Powershell.

  • +
  • Allows you to define your own roles with a provide fully typed API to your +tests that fulfills all your needs.

  • +
  • All changes that you do on the remote host during a single test can be +completely reverted so they do not affect other tests.

  • +
  • Defines an available multihost topology - what roles are available in your +current setup.

  • +
  • Associates each test with certain topology - defines what roles are +required to run the test.

  • +
  • Supports topology parametrization - a single test can run on multiple +topologies.

  • +
  • Run only tests that can be run on available topology.

  • +
  • Provides access to roles through dynamic pytest fixtures.

  • +
  • The code is fully typed - you get rich suggestions from your editor and the +types can be fully checked.

  • +
  • Everything can be extended.

  • +
+ +
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/objects.inv b/docs.bak/_build/html/objects.inv new file mode 100644 index 0000000..6db9c04 Binary files /dev/null and b/docs.bak/_build/html/objects.inv differ diff --git a/docs.bak/_build/html/py-modindex.html b/docs.bak/_build/html/py-modindex.html new file mode 100644 index 0000000..c605265 --- /dev/null +++ b/docs.bak/_build/html/py-modindex.html @@ -0,0 +1,169 @@ + + + + + + Python Module Index — pytest_mh documentation + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + +

Python Module Index

+ +
+ p +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
+ p
+ pytest_mh +
    + pytest_mh.cli +
    + pytest_mh.ssh +
    + pytest_mh.utils +
    + pytest_mh.utils.firewall +
    + pytest_mh.utils.fs +
    + pytest_mh.utils.journald +
    + pytest_mh.utils.services +
    + pytest_mh.utils.tc +
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/pytest.html b/docs.bak/_build/html/pytest.html new file mode 100644 index 0000000..992cdc5 --- /dev/null +++ b/docs.bak/_build/html/pytest.html @@ -0,0 +1,190 @@ + + + + + + + Using pytest-mh — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Using pytest-mh

+
+

Register plugin with pytest

+

pytest-mh plugin does not autoregister itself with pytest, it lets you +do it manually in conftest.py. It also requires you to set your own +MultihostConfig class so the plugin knows what domain, host, +and role objects should be created.

+
+
Registering pytest-mh with pytest in conftest.py
+
from pytest_mh import MultihostPlugin
+
+# Load additional plugins
+pytest_plugins = (
+    "pytest_mh",
+)
+
+
+# Setup pytest-mh and tell it to use "ExampleMultihostConfig" class
+def pytest_plugin_registered(plugin) -> None:
+    if isinstance(plugin, MultihostPlugin):
+        plugin.config_class = ExampleMultihostConfig
+
+
+
+
+

See also

+

Read Extending pytest-mh and Quick Start Guide to see how to implement your own +configuration, domain, hosts, and roles classes by extending base classes +provided by pytest_mh.

+
+
+
+

Running tests

+

In order to run the tests, you need to provide multihost configuration (see +Multihost configuration for more details). Once you have it, you can run your test suite +with pytest as usually, you just need to specify the path to the configuration with +--mh-config=<path-to-mhc.yaml>.

+
$ pytest --mh-config=./mhc.yaml
+
+
+
+

New pytest command line options

+

pytest-mh adds several command line options to the pytest.

+
    +
  • --mh-config=<path> - Path to the multihost configuration file in YAML +format.

  • +
  • --mh-log-path - Path to the log file where multihost messages will be +written.

  • +
  • --mh-lazy-ssh - If set, SSH connection to the host is not established +immediately but is postponed to its first use. Otherwise the connection to +all hosts is established immediately when pytest starts to test if all hosts +are accessible.

  • +
  • --mh-exact-topology - If set, test is run only if its topology matches +exactly given multihost configuration. Otherwise it is sufficient that the +topology can be fulfilled by the configuration even though the configuration +may contain more hosts or domains then are required.

  • +
  • --mh-collect-artifacts=always|on-failure|never - Specifies when test +artifacts are collected. Default value is on-failure - only collect +artifacts if test fails.

  • +
  • --mh-artifacts-dir - Directory where test artifacts are stored.

  • +
  • --mh-compress-artifacts - If set, test artifacts are stored in a compressed archive.

  • +
  • --mh-topology - Filter tests by given topology, can be set multiple times.

  • +
  • --mh-not-topology - Do not run tests for given topology, can be set multiple times.

  • +
  • --mh-collect-logs=always|on-failure|never - Specifies when logs are +collected. Uses --mh-collect-artifacts as default value.

  • +
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/quick-start.html b/docs.bak/_build/html/quick-start.html new file mode 100644 index 0000000..bab6431 --- /dev/null +++ b/docs.bak/_build/html/quick-start.html @@ -0,0 +1,1015 @@ + + + + + + + Quick Start Guide — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Quick Start Guide

+

This guide will show you how to setup and extend the pytest-mh plugin. We will +write a simple test of Kerberos authentication that spans over two separate +hosts - one host has the Kerberos KDC running and the other host will be used as +a client machine.

+
+

Note

+

The complete code is located in the example directory +of pytest-mh repository.

+
+
+

See also

+

A real life example of how pytest-mh can help to test your code can be +seen in the SSSD project.

+
+

All projects are different, therefore pytest_mh plugin provides only the +most basic functionality like ssh access to hosts and building blocks to +build your own tools and API. It is expected that you implement required +functionality in host, role and utility classes by extending +MultihostHost, MultihostRole and +MultihostUtility.

+

Since pytest_mh plugin is fully extensible, it is possible to also add +your own configuration options and different domain types by extending +MultihostConfig and MultihostDomain. +This step is actually required as the base classes are abstract and you have to +overwrite specific methods and properties in order to give a list of your own +domain, host and role classes that will be automatically be instantiated by the +plugin.

+
+

Note

+

The difference between host, roles, and utility classes:

+
    +
  • Host classes are created only once before the first test is executed and +exist during the whole pytest session. They can be used to setup +everything that should live for the whole session.

  • +
  • Role classes are the main objects that are directly accessible from +individual tests. They are created just before the test execution and +destroyed once the test is finished. They can perform setup required to +run the tests and proper clean up after the test is finished. Roles should +also define and implement proper API to access required resources.

  • +
  • Utility classes are instantiated inside individual roles. They represent +functionality that can be shared between roles. They are also responsible +to clean up every change that is done through their API. The +pytest_mh plugin already has some utility classes bundled within, +see pytest_mh.utils.

  • +
+
+
+

Create configuration and domain classes

+

First of all, we need to extend MultihostConfig and tell it +how to create our own domain object. Additionally, we need to extend +MultihostDomain and define a mapping between role name and +host classes and also a mapping between role name and role classes. This tells +the plugin which host and role classes should be instantiated for given role.

+

In the example below, we define two roles: “client” and “kdc”. Each role has its +own role (client, KDC) and host class (ClientHost, KDCHost).

+
+
/lib/config.py
+
 1from __future__ import annotations
+ 2
+ 3from typing import Type
+ 4
+ 5from pytest_mh import MultihostConfig, MultihostDomain, MultihostHost, MultihostRole
+ 6
+ 7
+ 8class ExampleMultihostConfig(MultihostConfig):
+ 9    @property
+10    def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]:
+11        """
+12        Map domain id to domain class. Asterisk ``*`` can be used as fallback
+13        value.
+14
+15        :rtype: Class name.
+16        """
+17        return {"*": ExampleMultihostDomain}
+18
+19
+20class ExampleMultihostDomain(MultihostDomain[ExampleMultihostConfig]):
+21    @property
+22    def role_to_host_class(self) -> dict[str, Type[MultihostHost]]:
+23        """
+24        Map role to host class. Asterisk ``*`` can be used as fallback value.
+25
+26        :rtype: Class name.
+27        """
+28        from .hosts.client import ClientHost
+29        from .hosts.kdc import KDCHost
+30
+31        return {
+32            "client": ClientHost,
+33            "kdc": KDCHost,
+34        }
+35
+36    @property
+37    def role_to_role_class(self) -> dict[str, Type[MultihostRole]]:
+38        """
+39        Map role to role class. Asterisk ``*`` can be used as fallback value.
+40
+41        :rtype: Class name.
+42        """
+43        from .roles.client import Client
+44        from .roles.kdc import KDC
+45
+46        return {
+47            "client": Client,
+48            "kdc": KDC,
+49        }
+
+
+
+
+

Note

+

It is not necessary to create distinct role and host class for every role. +The classes can be shared for multiple roles if it makes sense for your +project.

+
+
+
+

Create host classes

+
+

KDC Host

+

The KDC host takes care of backup and restore of the KDC data. It create backup +of KDC database when pytest is started and restores it to the original state +every time a test is finished. This ensures that the database is always the same +for each test execution. It also removes the backup file when pytest is +terminated.

+
+
/lib/hosts/kdc.py
+
 1from __future__ import annotations
+ 2
+ 3from pytest_mh import MultihostHost
+ 4from pytest_mh.ssh import SSHPowerShellProcess
+ 5
+ 6from ..config import ExampleMultihostDomain
+ 7
+ 8
+ 9class KDCHost(MultihostHost[ExampleMultihostDomain]):
+10    """
+11    Kerberos KDC server host object.
+12
+13    Provides features specific to Kerberos KDC.
+14
+15    .. note::
+16
+17        Full backup and restore is supported.
+18    """
+19
+20    def __init__(self, *args, **kwargs) -> None:
+21        super().__init__(*args, **kwargs)
+22
+23        self.__backup_location: str | None = None
+24        """Backup file or folder location."""
+25
+26    def pytest_setup(self) -> None:
+27        """
+28        Called once before execution of any tests.
+29        """
+30        super().setup()
+31
+32        # Backup KDC data
+33        self.ssh.run('kdb5_util dump /tmp/mh.kdc.kdb.backup && rm -f "/tmp/mh.kdc.kdb.backup.dump_ok"')
+34        self.__backup_location = "/tmp/mh.kdc.kdb.backup"
+35
+36    def pytest_teardown(self) -> None:
+37        """
+38        Called once after all tests are finished.
+39        """
+40        # Remove backup file
+41        if self.__backup_location is not None:
+42            if self.ssh.shell is SSHPowerShellProcess:
+43                self.ssh.exec(["Remove-Item", "-Force", "-Recurse", self.__backup_location])
+44            else:
+45                self.ssh.exec(["rm", "-fr", self.__backup_location])
+46
+47        super().teardown()
+48
+49    def teardown(self) -> None:
+50        """
+51        Called after execution of each test.
+52        """
+53        # Restore KDC data to its original state
+54        self.ssh.run(f'kdb5_util load "{self.__backup_location}"')
+55        super().teardown()
+
+
+
+
+
+

Client Host

+

The client host does not perform any backup and restore as it is not needed, but +it reads additional configuration values from the multihost configuration +(mhc.yaml) file.

+
+

Note

+

The additional configuration is read from the standard config field +which is there for this very reason. But if it makes sense, you can of +course extend any section.

+
+
+
/lib/hosts/client.py
+
 1from __future__ import annotations
+ 2
+ 3from pytest_mh import MultihostHost
+ 4
+ 5from ..config import ExampleMultihostDomain
+ 6
+ 7
+ 8class ClientHost(MultihostHost[ExampleMultihostDomain]):
+ 9    """
+10    Kerberos client host object.
+11
+12    Provides features specific to Kerberos client.
+13
+14    This class adds ``config.realm``, ``config.krbdomain`` and ``config.kdc``
+15    multihost configuration options to set the default kerberos realm,
+16    domain and the kdc hostname.
+17
+18    .. code-block:: yaml
+19        :caption: Example multihost configuration
+20        :emphasize-lines: 6-8
+21
+22        - hostname: client.test
+23          role: client
+24          config:
+25            realm: TEST
+26            krbdomain: test
+27            kdc: kdc.test
+28
+29    .. note::
+30
+31        Full backup and restore is supported.
+32    """
+33
+34    def __init__(self, *args, **kwargs) -> None:
+35        super().__init__(*args, **kwargs)
+36
+37        self.realm: str = self.config.get("realm", "TEST")
+38        self.krbdomain: str = self.config.get("krbdomain", "test")
+39        self.kdc: str = self.config.get("kdc", "kdc.test")
+
+
+
+
+
+
+

Create role classes

+

Unlike hosts, the role classes are the right place to provide all functionality +that will help you write good tests so they are usually quite complex.

+
+

KDC Role

+

The KDC class implements the functionality desired for “kdc” role. In this +example, we focus on adding the Kerberos principal (or Kerberos user if you +are not familiar with Kerberos terminology) and querying the kadmin tool to get +some additional information.

+
+
/lib/roles/kdc.py
+
  1from __future__ import annotations
+  2
+  3from pytest_mh import MultihostRole
+  4from pytest_mh.ssh import SSHProcessResult
+  5
+  6from ..hosts.kdc import KDCHost
+  7
+  8
+  9class KDC(MultihostRole[KDCHost]):
+ 10    """
+ 11    Kerberos KDC role.
+ 12
+ 13    Provides unified Python API for managing objects in the Kerberos KDC.
+ 14
+ 15    .. code-block:: python
+ 16        :caption: Creating user and group
+ 17
+ 18        @pytest.mark.topology(KnownTopology.KDC)
+ 19        def test_example(kdc: KDC):
+ 20            kdc.principal('tuser').add()
+ 21
+ 22    .. note::
+ 23
+ 24        The role object is instantiated automatically as a dynamic pytest
+ 25        fixture by the multihost plugin. You should not create the object
+ 26        manually.
+ 27    """
+ 28
+ 29    def __init__(self, *args, **kwargs) -> None:
+ 30        super().__init__(*args, **kwargs)
+ 31
+ 32    def kadmin(self, command: str) -> SSHProcessResult:
+ 33        """
+ 34        Run kadmin command on the KDC.
+ 35
+ 36        :param command: kadmin command
+ 37        :type command: str
+ 38        """
+ 39        result = self.host.ssh.exec(["kadmin.local", "-q", command])
+ 40
+ 41        # Remove "Authenticating as principal root/admin@TEST with password."
+ 42        # from the output and keep only output of the command itself.
+ 43        result.stdout_lines = result.stdout_lines[1:]
+ 44        result.stdout = "\n".join(result.stdout_lines)
+ 45
+ 46        return result
+ 47
+ 48    def list_principals(self) -> list[str]:
+ 49        """
+ 50        List existing Kerberos principals.
+ 51
+ 52        :return: List of Kerberos principals.
+ 53        :rtype: list[str]
+ 54        """
+ 55        result = self.kadmin("listprincs")
+ 56        return result.stdout_lines
+ 57
+ 58    def principal(self, name: str) -> KDCPrincipal:
+ 59        """
+ 60        Get Kerberos principal object.
+ 61
+ 62        .. code-block:: python
+ 63            :caption: Example usage
+ 64
+ 65            @pytest.mark.topology(KnownTopology.KDC)
+ 66            def test_example(client: Client, kdc: KDC):
+ 67                kdc.principal('tuser').add()
+ 68
+ 69        :param name: Principal name.
+ 70        :type name: str
+ 71        :return: New principal object.
+ 72        :rtype: KDCPrincipal
+ 73        """
+ 74        return KDCPrincipal(self, name)
+ 75
+ 76
+ 77class KDCPrincipal(object):
+ 78    """
+ 79    Kerberos principals management.
+ 80    """
+ 81
+ 82    def __init__(self, role: KDC, name: str) -> None:
+ 83        """
+ 84        :param role: KDC role object.
+ 85        :type role: KDC
+ 86        :param name: Principal name.
+ 87        :type name: str
+ 88        """
+ 89        self.role: KDC = role
+ 90        """KDC role."""
+ 91
+ 92        self.name: str = name
+ 93        """Principal name."""
+ 94
+ 95    def add(self, *, password: str | None = "Secret123") -> KDCPrincipal:
+ 96        """
+ 97        Add a new Kerberos principal.
+ 98
+ 99        Random password is generated if ``password`` is ``None``.
+100
+101        :param password: Principal's password, defaults to 'Secret123'
+102        :type password: str | None
+103        :return: Self.
+104        :rtype: KDCPrincipal
+105        """
+106        if password is not None:
+107            self.role.kadmin(f'addprinc -pw "{password}" "{self.name}"')
+108        else:
+109            self.role.kadmin(f'addprinc -randkey "{self.name}"')
+110
+111        return self
+112
+113    def get(self) -> dict[str, str]:
+114        """
+115        Retrieve principal information.
+116
+117        :return: Principal information.
+118        :rtype: dict[str, str]
+119        """
+120        result = self.role.kadmin(f'getprinc "{self.name}"')
+121        out = {}
+122        for line in result.stdout_lines:
+123            (key, value) = line.split(":", maxsplit=1)
+124            out[key] = value.strip()
+125
+126        return out
+127
+128    def delete(self) -> None:
+129        """
+130        Delete existing Kerberos principal.
+131        """
+132        self.role.kadmin(f'delprinc -force "{self.name}"')
+133
+134    def set_string(self, key: str, value: str) -> KDCPrincipal:
+135        """
+136        Set principal's string attribute.
+137
+138        :param key: Attribute name.
+139        :type key: str
+140        :param value: Atribute value.
+141        :type value: str
+142        :return: Self.
+143        :rtype: KDCPrincipal
+144        """
+145        self.role.kadmin(f'setstr "{self.name}" "{key}" "{value}"')
+146        return self
+147
+148    def get_strings(self) -> dict[str, str]:
+149        """
+150        Get all principal's string attributes.
+151
+152        :return: String attributes.
+153        :rtype: dict[str, str]
+154        """
+155        result = self.role.kadmin(f'getstrs "{self.name}"')
+156        out = {}
+157        for line in result.stdout_lines:
+158            (key, value) = line.split(":", maxsplit=1)
+159            out[key] = value.strip()
+160
+161        return out
+162
+163    def get_string(self, key: str) -> str | None:
+164        """
+165        Set principal's string attribute.
+166
+167        :param key: Attribute name.
+168        :type key: str
+169        :return: Attribute's value or None if not found.
+170        :rtype: str | None
+171        """
+172        attrs = self.get_strings()
+173
+174        return attrs.get(key, None)
+
+
+
+
+
+

Client Role

+

The client role first creates /etc/krb5.conf so the Kerberos client knows +what KDC we want to use. For this, it uses the bundle +LinuxFileSystem utility class, which writes the file to +the remote path and when a test is finished, it makes sure to restore the +original content or remove the file if it was not present before.

+
+
/lib/roles/client.py
+
  1"""Client multihost role."""
+  2
+  3from __future__ import annotations
+  4
+  5import textwrap
+  6
+  7from pytest_mh import MultihostRole
+  8from pytest_mh.ssh import SSHProcessError, SSHProcessResult
+  9from pytest_mh.utils.fs import LinuxFileSystem
+ 10
+ 11from ..hosts.client import ClientHost
+ 12
+ 13
+ 14class Client(MultihostRole[ClientHost]):
+ 15    """
+ 16    Kerberos client role.
+ 17
+ 18    Provides unified Python API for managing and testing Kerberos client.
+ 19
+ 20    .. note::
+ 21
+ 22        The role object is instantiated automatically as a dynamic pytest
+ 23        fixture by the multihost plugin. You should not create the object
+ 24        manually.
+ 25    """
+ 26
+ 27    def __init__(self, *args, **kwargs) -> None:
+ 28        super().__init__(*args, **kwargs)
+ 29
+ 30        self.realm: str = self.host.realm
+ 31        """
+ 32        Kerberos realm.
+ 33        """
+ 34
+ 35        self.fs: LinuxFileSystem = LinuxFileSystem(self.host)
+ 36        """
+ 37        File system manipulation.
+ 38        """
+ 39
+ 40    def setup(self) -> None:
+ 41        """
+ 42        Called before execution of each test.
+ 43
+ 44        Setup client host:
+ 45
+ 46        #. Create krb5.conf
+ 47
+ 48        .. note::
+ 49
+ 50            Original krb5.conf is automatically restored when the test is finished.
+ 51        """
+ 52        super().setup()
+ 53        config = textwrap.dedent(
+ 54            f"""
+ 55            [logging]
+ 56            default = FILE:/var/log/krb5libs.log
+ 57            kdc = FILE:/var/log/krb5kdc.log
+ 58            admin_server = FILE:/var/log/kadmind.log
+ 59
+ 60            [libdefaults]
+ 61            default_realm = {self.host.realm}
+ 62            default_ccache_name = KCM:
+ 63            dns_lookup_realm = false
+ 64            dns_lookup_kdc = false
+ 65            ticket_lifetime = 24h
+ 66            renew_lifetime = 7d
+ 67            forwardable = yes
+ 68
+ 69            [realms]
+ 70            {self.host.realm} = {{
+ 71              kdc = {self.host.kdc}:88
+ 72              admin_server = {self.host.kdc}:749
+ 73              max_life = 7d
+ 74              max_renewable_life = 14d
+ 75            }}
+ 76
+ 77            [domain_realm]
+ 78            .{self.host.krbdomain} = {self.host.realm}
+ 79            {self.host.krbdomain} = {self.host.realm}
+ 80        """
+ 81        ).lstrip()
+ 82        self.fs.write("/etc/krb5.conf", config, user="root", group="root", mode="0644")
+ 83
+ 84    def kinit(
+ 85        self, principal: str, *, password: str, realm: str | None = None, args: list[str] | None = None
+ 86    ) -> SSHProcessResult:
+ 87        """
+ 88        Run ``kinit`` command.
+ 89
+ 90        Principal can be without the realm part. The realm can be given in
+ 91        separate parameter ``realm``, in such case the principal name is
+ 92        constructed as ``$principal@$realm``. If the principal does not contain
+ 93        realm specification and ``realm`` parameter is not set then the default
+ 94        realm is used.
+ 95
+ 96        :param principal: Kerberos principal.
+ 97        :type principal: str
+ 98        :param password: Principal's password.
+ 99        :type password: str
+100        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
+101        :type realm: str | None, optional
+102        :param args: Additional parameters to ``klist``, defaults to None
+103        :type args: list[str] | None, optional
+104        :return: Command result.
+105        :rtype: SSHProcessResult
+106        """
+107        if args is None:
+108            args = []
+109
+110        if realm is not None:
+111            principal = f"{principal}@{realm}"
+112
+113        return self.host.ssh.exec(["kinit", *args, principal], input=password)
+114
+115    def kvno(self, principal: str, *, realm: str | None = None, args: list[str] | None = None) -> SSHProcessResult:
+116        """
+117        Run ``kvno`` command.
+118
+119        Principal can be without the realm part. The realm can be given in
+120        separate parameter ``realm``, in such case the principal name is
+121        constructed as ``$principal@$realm``. If the principal does not contain
+122        realm specification and ``realm`` parameter is not set then the default
+123        realm is used.
+124
+125        :param principal: Kerberos principal.
+126        :type principal: str
+127        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
+128        :type realm: str | None, optional
+129        :param args: Additional parameters to ``klist``, defaults to None
+130        :type args: list[str] | None, optional
+131        :return: Command result.
+132        :rtype: SSHProcessResult
+133        """
+134        if args is None:
+135            args = []
+136
+137        if realm is not None:
+138            principal = f"{principal}@{realm}"
+139
+140        return self.host.ssh.exec(["kvno", *args, principal])
+141
+142    def klist(self, *, args: list[str] | None = None) -> SSHProcessResult:
+143        """
+144        Run ``klist`` command.
+145
+146        :param args: Additional parameters to ``klist``, defaults to None
+147        :type args: list[str] | None, optional
+148        :return: Command result.
+149        :rtype: SSHProcessResult
+150        """
+151        if args is None:
+152            args = []
+153
+154        return self.host.ssh.exec(["klist", *args])
+155
+156    def kswitch(self, principal: str, realm: str) -> SSHProcessResult:
+157        """
+158        Run ``kswitch -p principal@realm`` command.
+159
+160        :param principal: Kerberos principal.
+161        :type principal: str
+162        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``)
+163        :type realm: str
+164        :return: Command result.
+165        :rtype: SSHProcessResult
+166        """
+167        if "@" not in principal:
+168            principal = f"{principal}@{realm}"
+169
+170        return self.host.ssh.exec(["kswitch", "-p", principal])
+171
+172    def kdestroy(
+173        self, *, all: bool = False, ccache: str | None = None, principal: str | None = None, realm: str | None = None
+174    ) -> SSHProcessResult:
+175        """
+176        Run ``kdestroy`` command.
+177
+178        Principal can be without the realm part. The realm can be given in
+179        separate parameter ``realm``, in such case the principal name is
+180        constructed as ``$principal@$realm``. If the principal does not contain
+181        realm specification and ``realm`` parameter is not set then the default
+182        realm is used.
+183
+184        :param all: Destroy all ccaches (``kdestroy -A``), defaults to False
+185        :type all: bool, optional
+186        :param ccache: Destroy specific ccache (``kdestroy -c $cache``), defaults to None
+187        :type ccache: str | None, optional
+188        :param principal: Destroy ccache for given principal (``kdestroy -p $princ``), defaults to None
+189        :type principal: str | None, optional
+190        :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None
+191        :type realm: str | None, optional
+192        :return: Command result.
+193        :rtype: SSHProcessResult
+194        """
+195        args = []
+196
+197        if all:
+198            args.append("-A")
+199
+200        if ccache is not None:
+201            args.append("-c")
+202            args.append(ccache)
+203
+204        if realm is not None and principal is not None:
+205            principal = f"{principal}@{realm}"
+206
+207        if principal is not None:
+208            args.append("-p")
+209            args.append(principal)
+210
+211        return self.host.ssh.exec(["kdestroy", *args])
+212
+213    def has_tgt(self, realm: str) -> bool:
+214        """
+215        Check that the user has obtained Kerberos Ticket Granting Ticket.
+216
+217        :param realm: Expected realm for which the TGT was obtained.
+218        :type realm: str
+219        :return: True if TGT is available, False otherwise.
+220        :rtype: bool
+221        """
+222        try:
+223            result = self.klist()
+224        except SSHProcessError:
+225            return False
+226
+227        return f"krbtgt/{realm}@{realm}" in result.stdout
+
+
+
+
+
+
+

Define multihost topology

+

Each test is associated with one or more topologies. A topology defines multihost +requirements that must be met in order to run the test. If the requirements are +not met, the test will not run. These requirements are:

+
    +
  • What domains are available

  • +
  • What roles and how many roles inside each domain are available

  • +
+

To assign a topology to a test case, we use @pytest.mark.topology(...). The +next example defines a topology with one domain that contains one client and one +kdc role. Hosts that implements these roles are then available as pytest +fixtures.

+
@pytest.mark.topology(
+    "kdc", Topology(TopologyDomain("test", client=1, kdc=1)),
+    fixtures=dict(client="test.client[0]", kdc="test.kdc[0]")
+)
+def test_example(client: Client, kdc: KDC):
+    pass
+
+
+

However, this can be little bit cumbersome, therefore it is good practice to +define a list of known topologies first.

+
+
/lib/topology.py
+
 1from __future__ import annotations
+ 2
+ 3from enum import unique
+ 4from typing import final
+ 5
+ 6from pytest_mh import KnownTopologyBase, Topology, TopologyDomain, TopologyMark
+ 7
+ 8
+ 9@final
+10@unique
+11class KnownTopology(KnownTopologyBase):
+12    """
+13    Well-known topologies that can be given to ``pytest.mark.topology``
+14    directly. It is expected to use these values in favor of providing
+15    custom marker values.
+16
+17    .. code-block:: python
+18        :caption: Example usage
+19
+20        @pytest.mark.topology(KnownTopology.KDC)
+21        def test_kdc(client: Client, kdc: KDC):
+22            assert True
+23    """
+24
+25    KDC = TopologyMark(
+26        name="kdc",
+27        topology=Topology(TopologyDomain("test", client=1, kdc=1)),
+28        fixtures=dict(client="test.client[0]", kdc="test.kdc[0]"),
+29    )
+
+
+
+

Now we can shorten the topology marker like this:

+
@pytest.mark.topology(KnownTopology.KDC)
+def test_example(client: Client, kdc: KDC):
+    pass
+
+
+
+

See also

+

There is also KnownTopologyGroupBase to define a list of +topologies that should be assigned to the test case and thus create topology +parameterization.

+
+
+
+

Create multihost configuration

+

Now, our test framework is ready to use. We just need to provide multihost +configuration file that defines available hosts.

+

We set custom fields that are required by ClientHost and we also define list +of artifacts that are automatically fetched from the remote host.

+
+
/mhc.yaml
+
 1domains:
+ 2- id: test
+ 3  hosts:
+ 4  - hostname: client.test
+ 5    role: client
+ 6    config:
+ 7      realm: TEST
+ 8      krbdomain: test
+ 9      kdc: kdc.test
+10
+11  - hostname: kdc.test
+12    role: kdc
+13    artifacts:
+14    - /var/log/krb5kdc.log
+
+
+
+
+

Note

+

The example configuration assumes running containers from +sssd-ci-containers project.

+
+
+
+

Enable pytest-mh in pytest

+

The pytest-mh plugin needs to be manually enabled in conftest.py and it +needs to know the configuration class that should be instantiated.

+
+
/conftest.py
+
 1# Configuration file for multihost tests.
+ 2
+ 3from __future__ import annotations
+ 4
+ 5from lib.config import ExampleMultihostConfig
+ 6
+ 7from pytest_mh import MultihostPlugin
+ 8
+ 9# Load additional plugins
+10pytest_plugins = ("pytest_mh",)
+11
+12
+13# Setup pytest-mh
+14def pytest_plugin_registered(plugin) -> None:
+15    if isinstance(plugin, MultihostPlugin):
+16        plugin.config_class = ExampleMultihostConfig
+
+
+
+
+
+

Write and run a simple test

+

All the pieces are now available. We have successfully setup the pytest-mh +plugin, created our own test framework API. Now it is time to write some tests.

+
+
/tests/test_kdc.py
+
 1from __future__ import annotations
+ 2
+ 3import pytest
+ 4from lib.roles.client import Client
+ 5from lib.roles.kdc import KDC
+ 6from lib.topology import KnownTopology
+ 7
+ 8
+ 9@pytest.mark.topology(KnownTopology.KDC)
+10def test_kinit(client: Client, kdc: KDC):
+11    kdc.principal("user-1").add(password="Secret123")
+12
+13    client.kinit("user-1", realm=client.realm, password="Secret123")
+14    assert client.has_tgt(client.realm)
+15
+16    client.kdestroy()
+17    assert not client.has_tgt(client.realm)
+18
+19
+20@pytest.mark.topology(KnownTopology.KDC)
+21def test_kvno(client: Client, kdc: KDC):
+22    kdc.principal("user-1").add(password="Secret123")
+23    kdc.principal("host/myhost").add()
+24
+25    client.kinit("user-1", realm=client.realm, password="Secret123")
+26    assert client.has_tgt(client.realm)
+27
+28    client.kvno("host/myhost", realm=client.realm)
+29    assert "host/myhost" in client.klist().stdout
+
+
+
+

Now we can run them. Notice how the topology name is mentioned in the test name.

+
$ pytest --mh-config=./mhc.yaml -vv
+Multihost configuration:
+domains:
+- id: test
+    hosts:
+    - hostname: client.test
+      role: client
+      config:
+        realm: TEST
+        krbdomain: test
+        kdc: kdc.test
+    - hostname: kdc.test
+      role: kdc
+      artifacts:
+      - /var/log/krb5kdc.log
+
+Detected topology:
+- id: test
+    hosts:
+    client: 1
+    kdc: 1
+
+Additional settings:
+config file: ./mhc.yaml
+log path: None
+lazy ssh: False
+topology filter: None
+require exact topology: False
+collect artifacts: on-failure
+artifacts directory: ./artifacts
+
+============================================================================================================ test session starts =============================================================================================================
+platform linux -- Python 3.10.8, pytest-7.2.1, pluggy-1.0.0 -- /home/pbrezina/workspace/pytest-mh/.venv/bin/python3
+cachedir: .pytest_cache
+rootdir: /home/pbrezina/workspace/pytest-mh, configfile: pytest.ini
+collected 2 items
+
+tests/test_kdc.py::test_kinit (kdc) PASSED                                                                                                                                                                                             [ 50%]
+tests/test_kdc.py::test_kvno (kdc) PASSED
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/runtime-requirements.html b/docs.bak/_build/html/runtime-requirements.html new file mode 100644 index 0000000..8913980 --- /dev/null +++ b/docs.bak/_build/html/runtime-requirements.html @@ -0,0 +1,183 @@ + + + + + + + Additional runtime requirements — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Additional runtime requirements

+

Sometimes, topology itself is not enough to detect if the test can or can not +be run and you want to check for a runtime requirement like that your program +was built with certain configure flags or features.

+

This can be achieved with pytest.mark.require(condition[, reason]) marker +that takes a function as a parameter and the test is skipped if the function +returns False (the requirement was not met).

+

The function takes all fixtures that are available to the test as parameters.

+
+

Note

+

The function can either return bool or tuple[bool, str]. In this +case, the second value is the reason for skipping the test.

+
+
@pytest.mark.topology(KnownTopology.LDAP)
+@pytest.mark.require(
+    lambda client: "files-provider" in client.features,
+    "SSSD was not built with files provider"
+)
+def test_example_explicit_reason(client: Client, ldap: LDAP):
+    pass
+
+@pytest.mark.topology(KnownTopology.LDAP)
+@pytest.mark.require(
+    lambda client: ("files-provider" in client.features, "SSSD was not built with files provider")
+)
+def test_example_reason_as_tuple(client: Client, ldap: LDAP):
+    pass
+
+@pytest.mark.topology(KnownTopology.LDAP)
+@pytest.mark.require(
+    lambda **kwargs: "files-provider" in kwargs["client"].features
+)
+def test_example_kwargs(client: Client, ldap: LDAP):
+    pass
+
+
+

It is also possible to pass a function directly instead of an anonymous (lambda) +function if the requirement is shared between multiple tests. However, there is +a documented glitch in pytest that requires you to use different marker syntax. +See pytest documentation +for more information.

+
def require_files_provider(client: Client):
+    return "files-provider" in client.features, "SSSD was not built with files provider"
+
+@pytest.mark.require.with_args(require_files_provider)
+@pytest.mark.topology(KnownTopology.LDAP)
+def test_example():
+    pass
+
+
+
+

Note

+

The requirement is evaluated when the test is executed but before setup +phase, so no setup method was called on any multihost role in order to make +the skip fast.

+

If you require to setup the role, you can always call the setup method +directly from the function passed to the require marker.

+
+
+

Warning

+

pytest-mh provides the requirement marker as a generic way to skip +a test when a condition is not met. The condition can use multihost roles +or other pytest fixtures used by the marked test and it can also call +commands on remote hosts.

+

The example above shows a check if an SSSD project was built with +“files-provider” feature, however feature detection is not part of +pytest-mh since feature detection is project specific mechanism.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/_build/html/search.html b/docs.bak/_build/html/search.html new file mode 100644 index 0000000..fd6900e --- /dev/null +++ b/docs.bak/_build/html/search.html @@ -0,0 +1,129 @@ + + + + + + Search — pytest_mh documentation + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • +
  • +
+
+
+
+
+ + + + +
+ +
+ +
+
+ +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/docs.bak/_build/html/searchindex.js b/docs.bak/_build/html/searchindex.js new file mode 100644 index 0000000..29129f1 --- /dev/null +++ b/docs.bak/_build/html/searchindex.js @@ -0,0 +1 @@ +Search.setIndex({"docnames": ["api", "api/pytest_mh", "api/pytest_mh.cli", "api/pytest_mh.ssh", "api/pytest_mh.utils", "api/pytest_mh.utils.firewall", "api/pytest_mh.utils.fs", "api/pytest_mh.utils.journald", "api/pytest_mh.utils.services", "api/pytest_mh.utils.tc", "classes", "config", "index", "pytest", "quick-start", "runtime-requirements", "topology"], "filenames": ["api.rst", "api/pytest_mh.rst", "api/pytest_mh.cli.rst", "api/pytest_mh.ssh.rst", "api/pytest_mh.utils.rst", "api/pytest_mh.utils.firewall.rst", "api/pytest_mh.utils.fs.rst", "api/pytest_mh.utils.journald.rst", "api/pytest_mh.utils.services.rst", "api/pytest_mh.utils.tc.rst", "classes.rst", "config.rst", "index.rst", "pytest.rst", "quick-start.rst", "runtime-requirements.rst", "topology.rst"], "titles": ["API Reference", "pytest_mh", "pytest_mh.cli", "pytest_mh.ssh", "pytest_mh.utils", "pytest_mh.utils.firewall", "pytest_mh.utils.fs", "pytest_mh.utils.journald", "pytest_mh.utils.services", "pytest_mh.utils.tc", "Extending pytest-mh", "Multihost configuration", "pytest_mh - pytest multihost test framework", "Using pytest-mh", "Quick Start Guide", "Additional runtime requirements", "Multihost topology"], "terms": {"function": [1, 10, 12, 14, 15], "class": [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16], "mh": [1, 11, 12, 15], "request": [1, 16], "fixturerequest": 1, "gener": [1, 3, 14, 15, 16], "multihostfixtur": [1, 16], "none": [1, 2, 3, 5, 6, 7, 8, 10, 11, 12, 13, 14], "pytest": [1, 11, 15, 16], "multihost": [1, 3, 10, 13, 15], "fixtur": [1, 10, 11, 12, 14, 15], "return": [1, 3, 5, 6, 7, 8, 10, 14, 15], "instanc": [1, 3, 5, 6, 7, 8, 9, 10, 16], "when": [1, 3, 5, 6, 9, 10, 11, 13, 14, 15, 16], "test": [1, 5, 6, 7, 9, 10, 11, 15, 16], "i": [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16], "finish": [1, 3, 5, 6, 8, 9, 10, 11, 14, 16], "thi": [1, 3, 5, 10, 12, 14, 15, 16], "take": [1, 10, 12, 14, 15], "care": [1, 12, 14], "tear": 1, "down": 1, "object": [1, 2, 3, 8, 10, 13, 14, 16], "automat": [1, 3, 5, 6, 8, 9, 10, 11, 12, 14, 16], "order": [1, 3, 10, 13, 14, 15, 16], "clean": [1, 10, 12, 14], "up": [1, 6, 10, 12, 14, 16], "after": [1, 5, 10, 14], "run": [1, 3, 7, 8, 10, 11, 12, 15, 16], "It": [1, 3, 6, 9, 10, 11, 12, 13, 14, 15, 16], "prefer": 1, "case": [1, 3, 6, 10, 14, 15, 16], "doe": [1, 3, 6, 13, 14, 16], "us": [1, 2, 3, 5, 10, 11, 14, 15], "directli": [1, 3, 14, 15, 16], "rather": 1, "access": [1, 6, 10, 11, 12, 13, 14], "host": [1, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15], "through": [1, 10, 11, 12, 14, 16], "dynam": [1, 10, 12, 14], "creat": [1, 3, 6, 10, 12, 13, 16], "role": [1, 10, 11, 12, 13, 15, 16], "ar": [1, 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16], "defin": [1, 10, 11, 12, 16], "mark": [1, 10, 12, 14, 15, 16], "topologi": [1, 10, 12, 13, 15], "paramet": [1, 2, 3, 5, 6, 7, 8, 9, 10, 14, 15, 16], "": [1, 3, 14], "rais": [1, 3, 6, 8], "valueerror": 1, "If": [1, 3, 5, 6, 11, 12, 13, 14, 15, 16], "configur": [1, 5, 10, 12, 13, 15, 16], "wa": [1, 6, 10, 14, 15, 16], "given": [1, 7, 10, 13, 14, 16], "yield": [1, 3], "multihostartifact": [], "base": [1, 2, 3, 5, 6, 7, 8, 9, 13, 14, 16], "manag": [1, 5, 8, 11, 14], "set": [1, 3, 11, 13, 14, 16], "artifact": [1, 13, 14], "collect": [1, 11, 13, 14], "specif": [1, 6, 10, 12, 14, 15, 16], "place": [1, 10, 11, 14], "pytest_setup": [1, 10, 11, 14], "str": [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16], "list": [1, 2, 3, 5, 6, 7, 11, 14, 16], "dure": [1, 6, 10, 12, 14], "initi": [1, 10], "see": [1, 6, 10, 11, 13, 14, 15, 16], "multihosthost": [1, 5, 6, 7, 8, 9, 11, 12, 14], "pytest_teardown": [1, 10, 11, 14], "final": [1, 10, 14, 16], "topology_setup": [1, 10], "topologycontrol": [1, 12], "topology_teardown": [1, 10], "get_by_typ": [], "type": [1, 2, 3, 5, 6, 7, 8, 10, 12, 14, 16], "multihostartifactcollectiontyp": [], "get": [1, 8, 11, 12, 14, 16], "retriev": [1, 14], "_description_": 1, "multihostconfig": [1, 12, 13, 14], "confdict": [1, 10], "dict": [1, 2, 3, 10, 11, 14, 16], "ani": [1, 2, 3, 5, 6, 7, 10, 12, 14, 15, 16], "logger": [1, 3], "multihostlogg": [1, 3], "lazy_ssh": 1, "bool": [1, 3, 6, 7, 8, 14, 15], "artifacts_dir": 1, "path": [1, 6, 11, 13, 14, 16], "artifacts_mod": 1, "liter": [1, 5], "never": [1, 13], "failur": [1, 13, 14], "alwai": [1, 13, 14, 15], "artifacts_compress": 1, "abc": 1, "true": [1, 2, 3, 6, 7, 8, 14, 16], "postpon": [1, 13], "connect": [1, 3, 9, 10, 11, 13], "ssh": [1, 2, 7, 8, 10, 11, 12, 13, 14], "first": [1, 7, 10, 13, 14, 16], "requir": [1, 10, 11, 12, 13, 14, 16], "output": [1, 3, 5, 6, 7, 14, 16], "directori": [1, 3, 6, 10, 11, 13, 14, 16], "mode": [1, 2, 6, 14], "store": [1, 6, 13], "compress": [1, 13], "archiv": [1, 13], "domain": [1, 10, 11, 12, 13, 16], "multihostdomain": [1, 12, 14, 16], "avail": [1, 10, 11, 12, 14, 15, 16], "properti": [1, 3, 5, 8, 10, 14], "required_field": 1, "field": [1, 7, 11, 14], "must": [1, 10, 14, 16], "an": [1, 10, 11, 12, 15, 16], "error": [1, 3, 8], "miss": [1, 6], "The": [1, 3, 5, 6, 8, 10, 11, 12, 14, 15, 16], "name": [1, 2, 3, 8, 10, 11, 12, 14, 16], "mai": [1, 6, 7, 11, 13], "contain": [1, 6, 10, 11, 12, 13, 14], "check": [1, 6, 10, 12, 14, 15], "nest": 1, "topologymarkclass": [1, 10], "topologymark": [1, 10, 14, 16], "subtyp": 1, "create_domain": 1, "new": [1, 5, 6, 10, 11, 12, 14], "from": [1, 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16], "dictionari": [1, 11], "map": [1, 10, 14, 16], "python": [1, 10, 12, 14, 16], "id_to_domain_class": [1, 10, 14], "found": [1, 6, 7, 14], "fallback": [1, 10, 14], "even": [1, 12, 13, 16], "asterisk": [1, 10, 14], "form": 1, "have": [1, 6, 10, 13, 14, 16], "id": [1, 3, 10, 11, 12, 14, 16], "topology_host": 1, "all": [1, 3, 5, 6, 9, 10, 12, 13, 14, 15, 16], "abstract": [1, 14], "can": [1, 3, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16], "valu": [1, 2, 3, 5, 6, 8, 10, 11, 13, 14, 15, 16], "config": [1, 10, 11, 13, 14, 16], "configtyp": 1, "mh_config": 1, "create_host": 1, "role_to_host_class": [1, 10, 14], "create_rol": 1, "multihostrol": [1, 12, 14, 16], "role_to_role_class": [1, 10, 14], "unexpect": 1, "hosts_by_rol": 1, "data": [1, 3, 10, 14], "multihostitemdata": 1, "topology_mark": 1, "provid": [1, 3, 10, 11, 12, 13, 14, 15, 16], "underlai": 1, "individu": [1, 10, 14, 16], "should": [1, 3, 10, 11, 13, 14, 16], "onli": [1, 3, 6, 7, 10, 12, 13, 14, 16], "each": [1, 7, 10, 11, 12, 14, 16], "exampl": [1, 3, 6, 10, 11, 12, 14, 15, 16], "client": [1, 3, 10, 11, 12, 15, 16], "hostnam": [1, 9, 11, 14], "ldap": [1, 10, 11, 12, 15, 16], "master": [1, 11], "abov": [1, 11, 15, 16], "one": [1, 10, 14], "two": [1, 6, 10, 11, 14, 16], "follow": [1, 10, 16], "show": [1, 7, 10, 14, 15, 16], "how": [1, 3, 10, 12, 13, 14, 16], "def": [1, 10, 11, 12, 13, 14, 15, 16], "test_exampl": [1, 10, 14, 15, 16], "n": [1, 3, 14, 16], "namespac": 1, "0": [1, 6, 10, 14, 16], "item": [1, 14], "topology_control": 1, "control": [1, 7, 9, 10], "simplenamespac": 1, "e": [1, 5, 6, 12], "g": [1, 5, 6], "domain_id": 1, "role_nam": 1, "log_phas": 1, "phase": [1, 15], "log": [1, 3, 7, 8, 11, 13, 14], "current": [1, 7, 12, 16], "descript": 1, "domaintyp": 1, "yaml": [1, 11, 13, 14, 16], "format": [1, 2, 7, 13], "dc": 1, "ad": [1, 5, 11, 12, 14, 16], "o": [1, 11], "famili": [1, 11], "linux": [1, 11, 12, 14], "1": [1, 2, 3, 6, 10, 14, 16], "2": [1, 2, 3, 6, 14], "3": [1, 2, 3, 12, 14], "4": [1, 2, 3], "usernam": [1, 3, 11], "root": [1, 3, 11, 14], "password": [1, 3, 11, 14], "secret123": [1, 11, 14], "binddn": [1, 11], "administr": 1, "bindpw": [1, 11], "vagrant": 1, "ad_domain": 1, "krb5_keytab": 1, "enrol": 1, "keytab": 1, "ldap_krb5_keytab": 1, "option": [1, 2, 3, 5, 6, 7, 8, 10, 11, 14], "shell": [1, 3, 12, 14], "default": [1, 3, 5, 6, 7, 8, 9, 11, 13, 14], "usr": [1, 3], "bin": [1, 3, 14], "bash": [1, 3, 12], "c": [1, 3, 14], "mh_domain": 1, "custom": [1, 10, 14, 16], "configured_artifact": 1, "produc": [1, 3, 11], "user": [1, 3, 6, 7, 11, 12, 14, 16], "ssh_host": 1, "resolv": [1, 11], "ip": [1, 11], "address": [1, 11], "ssh_port": 1, "int": [1, 3, 5, 6, 7, 9], "port": [1, 3, 11], "22": [1, 3, 11], "ssh_usernam": 1, "ssh_password": 1, "os_famili": 1, "multihosthostosfamili": 11, "oper": [1, 6, 8, 9, 11], "system": [1, 6, 7, 11, 12, 14], "sshprocess": [1, 3, 8], "session": [1, 3, 10, 14], "sshclient": [1, 2, 3], "cli": [1, 12], "clibuild": [1, 2], "command": [1, 2, 3, 7, 12, 14, 15], "line": [1, 2, 3, 6, 7, 14], "builder": 1, "extend": [1, 11, 12, 13, 14, 16], "wildcard": [1, 11], "charact": [1, 6, 11], "artifacts_collector": 1, "multihostartifactscollector": 1, "collector": 1, "call": [1, 3, 7, 8, 10, 11, 14, 15, 16], "onc": [1, 10, 13, 14, 16], "befor": [1, 7, 10, 14, 15, 16], "execut": [1, 3, 7, 10, 12, 14, 15, 16], "setup": [1, 7, 12, 13, 14, 15, 16], "teardown": [1, 6, 8, 12, 14, 16], "get_artifacts_list": 1, "just": [1, 13, 14], "possibl": [1, 3, 6, 11, 14, 15, 16], "overrid": [1, 10], "method": [1, 3, 5, 10, 14, 15, 16], "addit": [1, 3, 6, 7, 10, 11, 12, 13, 14, 16], "were": [1, 10], "detect": [1, 14, 15], "which": [1, 2, 3, 10, 11, 14, 16], "updat": 1, "multihostartifactstyp": 1, "being": 1, "modul": [1, 2, 3, 4], "qualnam": [1, 2, 3], "start": [1, 2, 3, 7, 8, 10, 12, 13, 16], "boundari": [1, 2, 3], "enum": [1, 2, 3, 14], "window": [1, 12], "intern": 1, "outcom": 1, "pass": [1, 3, 10, 14, 15, 16], "fail": [1, 3, 13], "skip": [1, 10, 15, 16], "unknown": 1, "static": 1, "setdata": 1, "getdata": 1, "multihostplugin": [1, 13, 14], "pytest_config": 1, "plugin": [1, 10, 12, 14, 16], "classmethod": 1, "getlogg": 1, "pytest_runtest_makereport": 1, "callinfo": 1, "testreport": 1, "pytest_output_item_collect": 1, "hosttyp": [1, 8], "main": [1, 3, 10, 14], "interfac": [1, 16], "remot": [1, 5, 6, 7, 8, 9, 10, 12, 14, 15, 16], "chang": [1, 3, 5, 6, 9, 10, 12, 14, 16], "done": [1, 6, 10, 12, 14, 16], "api": [1, 10, 12, 14], "revert": [1, 5, 6, 9, 10, 12, 16], "multihostutil": [1, 6, 7, 8, 9, 12, 14], "attribut": [1, 2, 11, 14], "sshbashprocess": [1, 3], "open": 1, "util": [1, 10, 12, 14], "write": [1, 3, 6, 10, 12, 16], "file": [1, 3, 6, 8, 10, 11, 12, 13, 14, 15], "sssd": [1, 11, 12, 14, 15], "subclass": [1, 16], "In": [1, 3, 6, 10, 13, 14, 15, 16], "destroi": [1, 14], "ensur": [1, 10, 14], "proper": [1, 14], "indic": [1, 3], "alreadi": [1, 3, 10, 14], "within": [1, 14, 16], "setup_when_us": [1, 10], "time": [1, 9, 10, 13, 14, 16], "teardown_when_us": [1, 10], "getutilityattribut": 1, "setuputilityattribut": 1, "teardownutilityattribut": 1, "ignorecal": 1, "decor": 1, "neither": 1, "nor": 1, "count": [1, 6, 7], "pytest_addopt": 1, "parser": 1, "hook": 1, "add": [1, 5, 9, 11, 12, 13, 14, 16], "pytest_configur": 1, "regist": [1, 12], "topologydomain": [1, 10, 14, 16], "A": [1, 9, 10, 12, 14], "specifi": [1, 3, 6, 7, 9, 11, 13], "fulfil": [1, 10, 12, 13], "consist": [1, 16], "more": [1, 3, 10, 12, 13, 14, 15, 16], "mani": [1, 14, 16], "insid": [1, 10, 11, 14, 16], "what": [1, 3, 10, 13, 14, 16], "implement": [1, 10, 12, 13, 14, 16], "satisfi": [1, 10], "arg": [1, 2, 3, 5, 6, 7, 11, 14], "includ": [1, 6], "find": 1, "lookup": 1, "keyerror": 1, "export": 1, "easili": [1, 16], "convert": 1, "json": 1, "other": [1, 10, 12, 14, 15, 16], "fals": [1, 2, 3, 6, 7, 10, 14, 15], "otherwis": [1, 3, 6, 13, 14], "frommultihostconfig": 1, "mhc": [1, 13, 14, 16], "infer": 1, "associ": [1, 10, 12, 14, 16], "via": [1, 10], "per": [1, 10, 16], "inherit": [1, 10, 11], "keep": [1, 12, 14], "mind": 1, "present": [1, 14], "therefor": [1, 10, 14, 16], "you": [1, 3, 5, 10, 11, 12, 13, 14, 15, 16], "them": [1, 10, 11, 14], "constructor": [1, 3], "examplecontrol": [1, 10], "self": [1, 3, 10, 11, 14], "clienthost": [1, 10, 14], "result": [1, 3, 6, 7, 8, 10, 12, 14], "your": [1, 10, 11, 12, 13, 14, 15, 16], "here": [1, 10], "exit": [1, 3, 6, 10], "raise_on_error": [1, 3, 7, 8, 10], "rc": [1, 3, 10], "met": [1, 10, 14, 15, 16], "One": [1, 10, 16], "prepar": [1, 10], "share": [1, 10, 14, 15, 16], "undo": [1, 10], "perform": [1, 6, 7, 9, 10, 12, 14], "everi": [1, 10, 14, 16], "low": [1, 3, 10], "level": [1, 3, 10, 11, 12], "interest": [1, 10], "knowntopologi": [1, 10, 12, 14, 15, 16], "uniqu": [1, 10, 14, 16], "knowntopologybas": [1, 10, 14, 16], "cannot": 1, "non": [1, 3, 7, 8], "reason": [1, 14, 15], "kwarg": [1, 3, 11, 14, 15], "well": [1, 3, 10, 14, 16], "number": [1, 6, 7], "inform": [1, 10, 14, 15, 16], "match": [1, 7, 13], "describ": [1, 16], "identifi": [1, 7, 11], "usag": [1, 14], "fixture1": 1, "path1": [1, 6], "fixture2": 1, "path2": [1, 6], "test_fixture_nam": 1, "baserol": [1, 3], "assert": [1, 12, 14, 16], "point": 1, "either": [1, 15, 16], "index": [1, 16], "visibl": [1, 8, 16], "verbos": [1, 3, 16], "test_bas": [1, 16], "py": [1, 10, 13, 14, 16], "test_cas": 1, "appli": 1, "funcarg": 1, "modifi": 1, "expandmark": 1, "predefin": [1, 16], "known": [1, 10, 12, 14], "marker": [1, 10, 12, 14, 15], "b": 1, "test_a": 1, "arol": 1, "test_b": 1, "brole": 1, "knowntopologygroupbas": [1, 14, 16], "enabl": [1, 2, 3, 12], "parametr": [1, 12], "knowntopologygroup": [1, 16], "Will": 1, "test_al": 1, "genericrol": 1, "plain": 2, "without": [2, 6, 14], "modif": 2, "enclos": 2, "quot": 2, "script": [2, 3, 12], "switch": [2, 12], "posit": 2, "argument": [2, 5, 6, 7, 16], "tupl": [2, 15], "argv": [2, 3], "quote_valu": 2, "clibuilderarg": 2, "alia": 2, "except": [3, 8, 14], "sshlog": 3, "silent": 3, "No": 3, "messag": [3, 7, 13], "short": [3, 16], "code": [3, 7, 12, 14, 16], "Its": 3, "omit": 3, "full": [3, 12, 14], "its": [3, 6, 12, 13, 14], "zero": [3, 7], "cwd": 3, "env": 3, "input": [3, 5, 6, 14], "conn": 3, "read_timeout": 3, "float": 3, "log_level": 3, "sync_exec": 3, "process": [3, 6, 7, 8], "yourself": 3, "exec": [3, 14], "async_run": 3, "async_exec": 3, "over": [3, 12, 14], "work": 3, "environ": 3, "variabl": 3, "content": [3, 6, 14], "standard": [3, 14], "login": 3, "pssh": 3, "timeout": 3, "second": [3, 15], "long": 3, "wait": [3, 8], "30": 3, "block": [3, 8, 12, 14], "stdout": [3, 14], "read": [3, 6, 10, 13, 14], "singl": [3, 6, 10, 12, 16], "until": [3, 8], "reach": 3, "next": [3, 5, 14, 16], "eof": 3, "iter": 3, "runtimeerror": 3, "yet": 3, "stderr": 3, "stdin": 3, "like": [3, 14, 15, 16], "repres": [3, 14, 16], "hello": 3, "world": 3, "send": 3, "send_eof": 3, "sshprocessresult": [3, 6, 7, 8, 14], "Then": [3, 12, 16], "sshprocesserror": [3, 14], "sshpowershellprocess": [3, 14], "powershel": [3, 12], "sshauthenticationerror": 3, "_privat": 3, "interact": 3, "server": [3, 12, 14, 16], "disconnect": 3, "leav": 3, "statement": 3, "echo": 3, "print": [3, 6], "cat": 3, "asynchron": 3, "also": [3, 6, 10, 11, 12, 13, 14, 15, 16], "mh_ssh_debug": 3, "ye": [3, 12, 14], "exist": [3, 6, 14], "statu": [3, 6, 8], "regardless": 3, "essenti": 3, "enforc": 3, "authent": [3, 14], "parallel": 3, "under": [3, 16], "immedi": [3, 13], "howev": [3, 12, 14, 15], "do": [3, 6, 7, 11, 13], "expect": [3, 14, 16], "expect_script": 3, "debug": 3, "d": 3, "expect_nobodi": 3, "nobodi": 3, "avoid": [3, 9, 12], "firewalld": 5, "inbound": 5, "firewalldinboundrul": 5, "rule": 5, "firewallinboundrul": 5, "outbound": 5, "firewalldoutboundrul": 5, "add_direct_rul": 5, "chain": 5, "tabl": 5, "filter": [5, 7, 13, 14, 16], "ip_famili": 5, "ipv4": 5, "ipv6": 5, "prioriti": 5, "direct": 5, "need": [5, 10, 11, 12, 13, 14, 16], "remov": [5, 6, 9, 14], "remove_direct_rul": 5, "iptabl": 5, "both": 5, "auto": 5, "assign": [5, 10, 12, 14, 16], "add_rich_rul": 5, "rich": [5, 12], "part": [5, 14, 15], "x": 5, "That": 5, "cmd": 5, "remove_rich_rul": 5, "linuxfilesystem": [6, 14], "mkdir": 6, "group": [6, 12, 14, 16], "chmod": 6, "owner": 6, "mkdir_p": 6, "parent": 6, "mktmp": 6, "dedent": [6, 14], "temporari": 6, "strip": [6, 14], "oserror": 6, "rm": [6, 14], "thei": [6, 11, 12, 14, 16], "append": [6, 14], "touch": 6, "truncat": 6, "size": 6, "target": [6, 9], "copi": 6, "srcpath": 6, "dstpath": 6, "sourc": 6, "destin": 6, "upload": 6, "local_path": 6, "remote_path": 6, "local": [6, 12, 14], "upload_to_tmp": 6, "download": [6, 11], "machin": [6, 11, 12, 14], "download_fil": 6, "multipl": [6, 10, 12, 13, 14, 15, 16], "gzip": 6, "tarbal": 6, "glob": 6, "pattern": [6, 7], "backup": [6, 10, 14], "restor": [6, 10, 14], "might": 6, "been": 6, "back": 6, "previou": 6, "wc": 6, "word": 6, "byte": 6, "char": 6, "newlin": 6, "67": 6, "564": 6, "3514": 6, "file_nam": 6, "whose": 6, "diff": 6, "brief": 6, "recurs": [6, 14], "ignore_cas": 6, "compar": 6, "same": [6, 14, 16], "differ": [6, 10, 14, 15, 16], "troubl": 6, "report": 6, "itself": [6, 13, 14, 15], "subdirectori": 6, "ignor": 6, "folder": [6, 14], "bit": [6, 14], "wai": [6, 12, 15, 16], "octal": 6, "666": 6, "444": 6, "symbol": 6, "represent": 6, "u": [6, 12, 16], "rw": 6, "go": [6, 16], "r": 6, "permiss": 6, "chown": 6, "remain": 6, "journaldutil": 7, "relat": 7, "task": [7, 10], "clear": 7, "reset": 7, "timestamp": 7, "journalctl": 7, "unit": [7, 8, 9], "sinc": [7, 11, 14, 15, 16], "revers": 7, "no_pag": 7, "grep": 7, "note": [1, 7, 14], "systemd": [7, 8], "most": [7, 14], "recent": 7, "journal": 7, "event": 7, "limit": 7, "shown": 7, "entri": 7, "newer": 7, "than": 7, "date": 7, "so": [7, 12, 13, 14, 15, 16], "newest": 7, "displai": 7, "pipe": 7, "pager": 7, "where": [1, 7, 13, 16], "regex": 7, "syslog": 7, "syslog_identifi": 7, "servic": 7, "kernel": 7, "is_match": 7, "search": 7, "occurr": 7, "systemdservic": 8, "async_start": 8, "systemctl": 8, "async_stop": 8, "stop": 8, "stope": 8, "async_restart": 8, "restart": 8, "async_reload": 8, "reload": 8, "async_statu": 8, "async_get_properti": 8, "prop": 8, "properi": 8, "get_properti": 8, "string": [8, 14], "async_reload_daemon": 8, "daemon": 8, "refresh": 8, "reload_daemon": 8, "linuxtrafficcontrol": 9, "traffic": 9, "add_delai": 9, "delai": 9, "network": 9, "maximum": 9, "15": 9, "recommend": [9, 10, 16], "minimum": 9, "starvat": 9, "millisecond": 9, "remove_delai": 9, "There": [10, 14], "five": 10, "give": [10, 14, 16], "tool": [10, 12, 14], "build": [10, 12, 14], "own": [10, 12, 13, 14, 16], "By": [10, 16], "top": [10, 11], "live": [10, 14], "whole": [10, 12, 14], "high": [10, 12], "between": [10, 14, 15, 16], "behavior": 10, "relationship": 10, "least": 10, "properli": 10, "writer": 10, "unifi": [10, 12, 14], "autom": 10, "sever": [10, 13], "make": [10, 11, 12, 14, 15, 16], "sure": [10, 12, 14, 16], "correctli": 10, "easier": 10, "fresh": 10, "With": 10, "project": [10, 11, 12, 14, 15], "examplemultihostconfig": [10, 13, 14], "exampletopologymark": 10, "rtype": [10, 14], "examplemultihostdomain": [10, 14], "allow": [10, 12, 16], "mean": [10, 16], "__init__": [10, 11, 14], "super": [10, 11, 14], "ldaphost": [10, 11], "determin": 10, "good": [10, 14], "put": 10, "across": [10, 16], "For": [10, 11, 14, 16], "termin": [10, 14], "lib": [10, 11, 14], "kdc": 10, "similar": 10, "while": [10, 12], "purpos": [10, 16], "overwrit": [10, 14], "especi": 10, "sporad": 10, "quit": [10, 14], "expens": 10, "probabl": 10, "want": [10, 14, 15, 16], "actual": [10, 14], "some": [10, 14], "pytest_mh": [10, 11, 13, 14], "f": [10, 14], "made": 10, "origin": [10, 14], "variou": [10, 16], "last": 10, "desir": [10, 14], "document": [10, 15], "schema": 10, "languag": 11, "dn": 11, "element": 11, "ha": [11, 14, 16], "deep": [11, 12], "dive": [11, 12], "necessarili": 11, "sampl": 11, "192": 11, "168": 11, "100": 11, "10": [11, 14], "mysecret123": 11, "etc": [1, 11, 14], "var": [11, 14], "sss": 11, "db": 11, "cn": 11, "section": [11, 14, 16], "empti": 11, "To": [11, 14, 16], "simpli": 11, "mydomain": 11, "bind": 11, "still": [12, 16], "activ": [12, 16], "develop": 12, "though": [12, 13], "mostli": 12, "stabl": 12, "we": [12, 14, 16], "reserv": 12, "right": [12, 14], "introduc": 12, "minor": 12, "break": 12, "basic": [12, 14, 16], "applic": [12, 16], "intact": 12, "flexibl": 12, "manipul": [12, 14], "taken": 12, "demo": 12, "ipa": [12, 16], "samba": 12, "test__id": 12, "genericprovid": [12, 16], "tuser": [12, 14], "tgroup_1": 12, "add_memb": 12, "tgroup_2": 12, "memberof": 12, "real": [12, 14], "life": [12, 14], "help": [12, 14], "seen": [12, 14], "program": [12, 15], "affect": 12, "safer": 12, "virtual": 12, "model": 12, "better": 12, "separ": [12, 14], "commun": 12, "backend": 12, "abl": 12, "reus": 12, "complex": [12, 14], "state": [12, 14], "databas": [12, 14], "talk": [12, 16], "kerbero": [12, 14], "re": 12, "old": 12, "featur": [12, 14, 15], "definit": 12, "backward": 12, "compat": 12, "design": 12, "fulli": [12, 14], "complet": [12, 14], "certain": [12, 15], "support": [12, 14], "suggest": 12, "editor": 12, "everyth": [12, 14], "quick": [12, 13], "guid": [12, 13], "simpl": [12, 16], "runtim": 12, "refer": [12, 16], "autoregist": 13, "let": [13, 16], "manual": [13, 14], "conftest": [13, 14], "know": [13, 14], "import": [13, 14], "load": [13, 14], "pytest_plugin": [13, 14], "tell": [13, 14, 16], "pytest_plugin_regist": [13, 14], "isinst": [13, 14], "config_class": [13, 14], "detail": 13, "suit": 13, "usual": [13, 14], "written": [13, 16], "lazi": [13, 14], "establish": 13, "exact": [13, 14], "exactli": 13, "suffici": 13, "dir": 13, "span": 14, "locat": 14, "repositori": 14, "extens": [14, 16], "step": [14, 16], "instanti": 14, "resourc": 14, "respons": 14, "bundl": 14, "our": [14, 16], "addition": 14, "below": 14, "kdchost": 14, "__future__": 14, "annot": 14, "necessari": 14, "distinct": 14, "sens": [14, 16], "__backup_loc": 14, "kdb5_util": 14, "dump": 14, "tmp": 14, "kdb": 14, "dump_ok": 14, "forc": 14, "els": 14, "fr": 14, "veri": [14, 16], "But": 14, "cours": 14, "realm": 14, "krbdomain": 14, "caption": 14, "emphas": 14, "6": 14, "8": 14, "unlik": 14, "focu": 14, "princip": 14, "familiar": 14, "terminologi": 14, "queri": 14, "kadmin": 14, "param": 14, "q": 14, "admin": 14, "stdout_lin": 14, "join": 14, "list_princip": 14, "listprinc": 14, "kdcprincip": 14, "random": 14, "addprinc": 14, "pw": 14, "randkei": 14, "getprinc": 14, "out": 14, "kei": [11, 14], "split": 14, "maxsplit": 14, "delet": 14, "delprinc": 14, "set_str": 14, "atribut": 14, "setstr": 14, "get_str": 14, "getstr": 14, "attr": 14, "krb5": 14, "conf": 14, "textwrap": 14, "krb5lib": 14, "krb5kdc": 14, "admin_serv": 14, "kadmind": 14, "libdefault": 14, "default_realm": 14, "default_ccache_nam": 14, "kcm": 14, "dns_lookup_realm": 14, "dns_lookup_kdc": 14, "ticket_lifetim": 14, "24h": 14, "renew_lifetim": 14, "7d": 14, "forward": 14, "88": 14, "749": 14, "max_lif": 14, "max_renewable_lif": 14, "14d": 14, "domain_realm": 14, "lstrip": 14, "0644": 14, "kinit": 14, "construct": 14, "klist": 14, "kvno": 14, "kswitch": 14, "p": 14, "kdestroi": 14, "ccach": 14, "cach": 14, "princ": 14, "has_tgt": 14, "obtain": 14, "ticket": 14, "grant": 14, "tgt": 14, "try": 14, "krbtgt": 14, "These": 14, "littl": 14, "cumbersom": 14, "practic": 14, "favor": 14, "test_kdc": 14, "now": [14, 16], "shorten": [14, 16], "thu": 14, "parameter": [14, 16], "framework": 14, "readi": 14, "fetch": 14, "assum": [14, 16], "ci": 14, "piec": 14, "successfulli": 14, "test_kinit": 14, "test_kvno": 14, "myhost": 14, "notic": [14, 16], "mention": 14, "vv": 14, "platform": 14, "7": 14, "pluggi": 14, "home": 14, "pbrezina": 14, "workspac": 14, "venv": 14, "python3": 14, "cachedir": 14, "pytest_cach": 14, "rootdir": 14, "configfil": 14, "ini": 14, "50": [14, 16], "sometim": 15, "enough": 15, "built": 15, "flag": 15, "achiev": 15, "condit": 15, "lambda": 15, "test_example_explicit_reason": 15, "test_example_reason_as_tupl": 15, "test_example_kwarg": 15, "instead": [15, 16], "anonym": 15, "glitch": 15, "syntax": 15, "require_files_provid": 15, "with_arg": 15, "evalu": 15, "fast": 15, "mechan": 15, "particular": 16, "nice": 16, "interchang": 16, "articl": 16, "further": 16, "about": 16, "explain": 16, "later": 16, "human": 16, "readabl": 16, "k": 16, "keyword": 16, "cover": 16, "would": 16, "realli": 16, "learn": 16, "besid": 16, "advanc": 16, "gain": 16, "snippet": 16, "advantag": 16, "hint": 16, "test_example2": 16, "pleas": 16, "simplifi": 16, "rewritten": 16, "hood": 16, "benefit": 16, "don": 16, "t": 16, "combin": 16, "highli": 16, "conveni": [1, 16], "scratch": 16, "against": 16, "freeipa": 16, "anyprovid": 16, "common": 16, "create_us": 16, "Or": 16, "v": 16, "25": 16, "37": 16, "six": 16, "75": 16, "87": 16, "artifacts_typ": 1, "multihosthostartifact": 1, "multihostosfamili": 1, "invalid": 1, "multihosttopologycontrollerartifact": 1, "set_artifact": 1, "issu": 1, "dedic": 1, "host_setup": 11, "host_teardown": 11, "testrun": 11}, "objects": {"": [[1, 0, 0, "-", "pytest_mh"]], "pytest_mh": [[1, 1, 1, "", "KnownTopologyBase"], [1, 1, 1, "", "KnownTopologyGroupBase"], [1, 1, 1, "", "MultihostConfig"], [1, 1, 1, "", "MultihostDomain"], [1, 1, 1, "", "MultihostFixture"], [1, 1, 1, "", "MultihostHost"], [1, 1, 1, "", "MultihostHostArtifacts"], [1, 1, 1, "", "MultihostItemData"], [1, 1, 1, "", "MultihostOSFamily"], [1, 1, 1, "", "MultihostPlugin"], [1, 1, 1, "", "MultihostRole"], [1, 1, 1, "", "MultihostTopologyControllerArtifacts"], [1, 1, 1, "", "MultihostUtility"], [1, 1, 1, "", "Topology"], [1, 1, 1, "", "TopologyController"], [1, 1, 1, "", "TopologyDomain"], [1, 1, 1, "", "TopologyMark"], [2, 0, 0, "-", "cli"], [1, 6, 1, "", "mh"], [1, 6, 1, "", "pytest_addoption"], [1, 6, 1, "", "pytest_configure"], [3, 0, 0, "-", "ssh"], [4, 0, 0, "-", "utils"]], "pytest_mh.MultihostConfig": [[1, 2, 1, "", "TopologyMarkClass"], [1, 3, 1, "", "artifacts_compression"], [1, 3, 1, "", "artifacts_dir"], [1, 3, 1, "", "artifacts_mode"], [1, 4, 1, "", "create_domain"], [1, 3, 1, "", "domains"], [1, 2, 1, "", "id_to_domain_class"], [1, 3, 1, "", "lazy_ssh"], [1, 3, 1, "", "logger"], [1, 2, 1, "", "required_fields"], [1, 4, 1, "", "topology_hosts"]], "pytest_mh.MultihostDomain": [[1, 4, 1, "", "create_host"], [1, 4, 1, "", "create_role"], [1, 3, 1, "", "hosts"], [1, 4, 1, "", "hosts_by_role"], [1, 3, 1, "", "id"], [1, 3, 1, "", "logger"], [1, 3, 1, "", "mh_config"], [1, 2, 1, "", "required_fields"], [1, 2, 1, "", "role_to_host_class"], [1, 2, 1, "", "role_to_role_class"], [1, 2, 1, "", "roles"]], "pytest_mh.MultihostFixture": [[1, 3, 1, "", "data"], [1, 3, 1, "", "hosts"], [1, 4, 1, "", "log_phase"], [1, 3, 1, "", "logger"], [1, 3, 1, "", "multihost"], [1, 3, 1, "", "ns"], [1, 3, 1, "", "request"], [1, 3, 1, "", "roles"], [1, 3, 1, "", "topology"], [1, 3, 1, "", "topology_controller"], [1, 3, 1, "", "topology_mark"]], "pytest_mh.MultihostHost": [[1, 3, 1, "", "artifacts"], [1, 3, 1, "", "artifacts_collector"], [1, 3, 1, "", "cli"], [1, 3, 1, "", "config"], [1, 3, 1, "", "configured_artifacts"], [1, 4, 1, "", "get_artifacts_list"], [1, 3, 1, "", "hostname"], [1, 3, 1, "", "logger"], [1, 3, 1, "", "mh_domain"], [1, 3, 1, "", "os_family"], [1, 4, 1, "", "pytest_setup"], [1, 4, 1, "", "pytest_teardown"], [1, 2, 1, "", "required_fields"], [1, 3, 1, "", "role"], [1, 4, 1, "", "setup"], [1, 3, 1, "", "shell"], [1, 3, 1, "", "ssh"], [1, 3, 1, "", "ssh_host"], [1, 3, 1, "", "ssh_password"], [1, 3, 1, "", "ssh_port"], [1, 3, 1, "", "ssh_username"], [1, 4, 1, "", "teardown"]], "pytest_mh.MultihostHostArtifacts": [[1, 4, 1, "", "get"], [1, 3, 1, "", "pytest_setup"], [1, 3, 1, "", "pytest_teardown"], [1, 3, 1, "", "test"]], "pytest_mh.MultihostItemData": [[1, 4, 1, "", "GetData"], [1, 4, 1, "", "SetData"], [1, 3, 1, "", "multihost"], [1, 3, 1, "", "outcome"], [1, 3, 1, "", "topology_mark"]], "pytest_mh.MultihostOSFamily": [[1, 3, 1, "", "Linux"], [1, 3, 1, "", "Windows"]], "pytest_mh.MultihostPlugin": [[1, 4, 1, "", "GetLogger"], [1, 4, 1, "", "pytest_output_item_collected"], [1, 4, 1, "", "pytest_runtest_makereport"]], "pytest_mh.MultihostRole": [[1, 3, 1, "", "artifacts"], [1, 4, 1, "", "get_artifacts_list"], [1, 3, 1, "", "logger"], [1, 4, 1, "", "setup"], [1, 4, 1, "", "ssh"], [1, 4, 1, "", "teardown"]], "pytest_mh.MultihostTopologyControllerArtifacts": [[1, 4, 1, "", "get"], [1, 3, 1, "", "test"], [1, 3, 1, "", "topology_setup"], [1, 3, 1, "", "topology_teardown"]], "pytest_mh.MultihostUtility": [[1, 4, 1, "", "GetUtilityAttributes"], [1, 4, 1, "", "IgnoreCall"], [1, 4, 1, "", "SetupUtilityAttributes"], [1, 4, 1, "", "TeardownUtilityAttributes"], [1, 3, 1, "", "artifacts"], [1, 4, 1, "", "get_artifacts_list"], [1, 3, 1, "", "host"], [1, 3, 1, "", "logger"], [1, 4, 1, "", "setup"], [1, 4, 1, "", "setup_when_used"], [1, 4, 1, "", "teardown"], [1, 4, 1, "", "teardown_when_used"], [1, 3, 1, "", "used"]], "pytest_mh.Topology": [[1, 4, 1, "", "FromMultihostConfig"], [1, 4, 1, "", "export"], [1, 4, 1, "", "get"], [1, 4, 1, "", "satisfies"]], "pytest_mh.TopologyController": [[1, 3, 1, "", "artifacts"], [1, 4, 1, "", "get_artifacts_list"], [1, 2, 1, "", "hosts"], [1, 2, 1, "", "logger"], [1, 2, 1, "", "multihost"], [1, 2, 1, "", "name"], [1, 2, 1, "", "ns"], [1, 4, 1, "", "set_artifacts"], [1, 4, 1, "", "setup"], [1, 4, 1, "", "skip"], [1, 4, 1, "", "teardown"], [1, 2, 1, "", "topology"], [1, 4, 1, "", "topology_setup"], [1, 4, 1, "", "topology_teardown"]], "pytest_mh.TopologyDomain": [[1, 4, 1, "", "export"], [1, 4, 1, "", "get"], [1, 4, 1, "", "satisfies"]], "pytest_mh.TopologyMark": [[1, 4, 1, "", "Create"], [1, 4, 1, "", "ExpandMarkers"], [1, 4, 1, "", "apply"], [1, 2, 1, "", "args"], [1, 3, 1, "", "controller"], [1, 4, 1, "", "export"], [1, 3, 1, "", "fixtures"], [1, 3, 1, "", "name"], [1, 3, 1, "", "topology"]], "pytest_mh.cli": [[2, 1, 1, "", "CLIBuilder"], [2, 5, 1, "", "CLIBuilderArgs"]], "pytest_mh.cli.CLIBuilder": [[2, 4, 1, "", "args"], [2, 4, 1, "", "argv"], [2, 4, 1, "", "command"], [2, 1, 1, "", "option"]], "pytest_mh.cli.CLIBuilder.option": [[2, 3, 1, "", "PLAIN"], [2, 3, 1, "", "POSITIONAL"], [2, 3, 1, "", "SWITCH"], [2, 3, 1, "", "VALUE"]], "pytest_mh.ssh": [[3, 7, 1, "", "SSHAuthenticationError"], [3, 1, 1, "", "SSHBashProcess"], [3, 1, 1, "", "SSHClient"], [3, 1, 1, "", "SSHLog"], [3, 1, 1, "", "SSHPowerShellProcess"], [3, 1, 1, "", "SSHProcess"], [3, 7, 1, "", "SSHProcessError"], [3, 1, 1, "", "SSHProcessResult"]], "pytest_mh.ssh.SSHClient": [[3, 4, 1, "", "async_exec"], [3, 4, 1, "", "async_run"], [3, 2, 1, "", "conn"], [3, 4, 1, "", "connect"], [3, 2, 1, "", "connected"], [3, 4, 1, "", "disconnect"], [3, 4, 1, "", "exec"], [3, 4, 1, "", "expect"], [3, 4, 1, "", "expect_nobody"], [3, 4, 1, "", "run"]], "pytest_mh.ssh.SSHLog": [[3, 3, 1, "", "Error"], [3, 3, 1, "", "Full"], [3, 3, 1, "", "Short"], [3, 3, 1, "", "Silent"]], "pytest_mh.ssh.SSHProcess": [[3, 4, 1, "", "run"], [3, 4, 1, "", "send_eof"], [3, 2, 1, "", "stderr"], [3, 2, 1, "", "stdin"], [3, 2, 1, "", "stdout"], [3, 4, 1, "", "wait"]], "pytest_mh.utils": [[5, 0, 0, "-", "firewall"], [6, 0, 0, "-", "fs"], [7, 0, 0, "-", "journald"], [8, 0, 0, "-", "services"], [9, 0, 0, "-", "tc"]], "pytest_mh.utils.firewall": [[5, 1, 1, "", "Firewalld"]], "pytest_mh.utils.firewall.Firewalld": [[5, 4, 1, "", "add_direct_rule"], [5, 4, 1, "", "add_rich_rule"], [5, 2, 1, "", "inbound"], [5, 2, 1, "", "outbound"], [5, 4, 1, "", "remove_direct_rule"], [5, 4, 1, "", "remove_rich_rule"]], "pytest_mh.utils.fs": [[6, 1, 1, "", "LinuxFileSystem"]], "pytest_mh.utils.fs.LinuxFileSystem": [[6, 4, 1, "", "append"], [6, 4, 1, "", "backup"], [6, 4, 1, "", "chmod"], [6, 4, 1, "", "chown"], [6, 4, 1, "", "copy"], [6, 4, 1, "", "diff"], [6, 4, 1, "", "download"], [6, 4, 1, "", "download_files"], [6, 4, 1, "", "exists"], [6, 4, 1, "", "mkdir"], [6, 4, 1, "", "mkdir_p"], [6, 4, 1, "", "mktmp"], [6, 4, 1, "", "read"], [6, 4, 1, "", "restore"], [6, 4, 1, "", "rm"], [6, 4, 1, "", "touch"], [6, 4, 1, "", "truncate"], [6, 4, 1, "", "upload"], [6, 4, 1, "", "upload_to_tmp"], [6, 4, 1, "", "wc"], [6, 4, 1, "", "write"]], "pytest_mh.utils.journald": [[7, 1, 1, "", "JournaldUtils"]], "pytest_mh.utils.journald.JournaldUtils": [[7, 4, 1, "", "clear"], [7, 4, 1, "", "count"], [7, 4, 1, "", "is_match"], [7, 4, 1, "", "journalctl"], [7, 4, 1, "", "setup"]], "pytest_mh.utils.services": [[8, 1, 1, "", "SystemdServices"]], "pytest_mh.utils.services.SystemdServices": [[8, 4, 1, "", "async_get_property"], [8, 4, 1, "", "async_reload"], [8, 4, 1, "", "async_reload_daemon"], [8, 4, 1, "", "async_restart"], [8, 4, 1, "", "async_start"], [8, 4, 1, "", "async_status"], [8, 4, 1, "", "async_stop"], [8, 4, 1, "", "get_property"], [8, 4, 1, "", "reload"], [8, 4, 1, "", "reload_daemon"], [8, 4, 1, "", "restart"], [8, 4, 1, "", "start"], [8, 4, 1, "", "status"], [8, 4, 1, "", "stop"], [8, 4, 1, "", "teardown"]], "pytest_mh.utils.tc": [[9, 1, 1, "", "LinuxTrafficControl"]], "pytest_mh.utils.tc.LinuxTrafficControl": [[9, 4, 1, "", "add_delay"], [9, 4, 1, "", "remove_delay"]]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:property", "3": "py:attribute", "4": "py:method", "5": "py:data", "6": "py:function", "7": "py:exception"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "property", "Python property"], "3": ["py", "attribute", "Python attribute"], "4": ["py", "method", "Python method"], "5": ["py", "data", "Python data"], "6": ["py", "function", "Python function"], "7": ["py", "exception", "Python exception"]}, "titleterms": {"api": [0, 16], "refer": 0, "pytest_mh": [1, 2, 3, 4, 5, 6, 7, 8, 9, 12], "cli": 2, "ssh": 3, "util": [4, 5, 6, 7, 8, 9], "firewal": 5, "f": 6, "journald": 7, "servic": 8, "tc": 9, "extend": 10, "pytest": [10, 12, 13, 14], "mh": [10, 13, 14, 16], "multihostconfig": 10, "multihostdomain": 10, "multihosthost": 10, "multihostrol": 10, "multihostutil": 10, "topologycontrol": 10, "setup": 10, "teardown": 10, "multihost": [11, 12, 14, 16], "configur": [11, 14], "basic": 11, "definit": 11, "custom": 11, "gather": 11, "artifact": 11, "test": [12, 13, 14], "framework": 12, "when": 12, "do": 12, "i": 12, "want": 12, "us": [12, 13, 16], "don": 12, "t": 12, "what": 12, "doe": 12, "regist": 13, "plugin": 13, "run": [13, 14], "new": 13, "command": 13, "line": 13, "option": 13, "quick": 14, "start": 14, "guid": 14, "creat": 14, "domain": 14, "class": 14, "host": [14, 16], "kdc": 14, "client": 14, "role": 14, "defin": 14, "topologi": [14, 16], "enabl": 14, "write": 14, "simpl": 14, "addit": 15, "runtim": 15, "requir": 15, "marker": 16, "access": 16, "deep": 16, "dive": 16, "fixtur": 16, "low": 16, "level": 16, "dynam": 16, "high": 16, "known": 16, "parametr": 16}, "envversion": {"sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx.ext.intersphinx": 1, "sphinx": 60}, "alltitles": {"API Reference": [[0, "api-reference"]], "pytest_mh.cli": [[2, "module-pytest_mh.cli"]], "pytest_mh.utils": [[4, "module-pytest_mh.utils"]], "pytest_mh.utils.firewall": [[5, "module-pytest_mh.utils.firewall"]], "pytest_mh.utils.fs": [[6, "module-pytest_mh.utils.fs"]], "pytest_mh.utils.journald": [[7, "module-pytest_mh.utils.journald"]], "pytest_mh.utils.services": [[8, "module-pytest_mh.utils.services"]], "pytest_mh.utils.tc": [[9, "module-pytest_mh.utils.tc"]], "Extending pytest-mh": [[10, "extending-pytest-mh"]], "MultihostConfig": [[10, "multihostconfig"]], "MultihostDomain": [[10, "multihostdomain"]], "MultihostHost": [[10, "multihosthost"]], "MultihostRole": [[10, "multihostrole"]], "MultihostUtility": [[10, "multihostutility"]], "TopologyController": [[10, "topologycontroller"]], "Setup and teardown": [[10, "setup-and-teardown"]], "pytest_mh - pytest multihost test framework": [[12, "pytest-mh-pytest-multihost-test-framework"]], "When do I want use the framework?": [[12, "when-do-i-want-use-the-framework"]], "When I don\u2019t want to use it?": [[12, "when-i-don-t-want-to-use-it"]], "What does the framework do?": [[12, "what-does-the-framework-do"]], "Using pytest-mh": [[13, "using-pytest-mh"]], "Register plugin with pytest": [[13, "register-plugin-with-pytest"]], "Running tests": [[13, "running-tests"]], "New pytest command line options": [[13, "new-pytest-command-line-options"]], "Quick Start Guide": [[14, "quick-start-guide"]], "Create configuration and domain classes": [[14, "create-configuration-and-domain-classes"]], "Create host classes": [[14, "create-host-classes"]], "KDC Host": [[14, "kdc-host"]], "Client Host": [[14, "client-host"]], "Create role classes": [[14, "create-role-classes"]], "KDC Role": [[14, "kdc-role"]], "Client Role": [[14, "client-role"]], "Define multihost topology": [[14, "define-multihost-topology"]], "Create multihost configuration": [[14, "create-multihost-configuration"]], "Enable pytest-mh in pytest": [[14, "enable-pytest-mh-in-pytest"]], "Write and run a simple test": [[14, "write-and-run-a-simple-test"]], "Additional runtime requirements": [[15, "additional-runtime-requirements"]], "Multihost topology": [[16, "multihost-topology"]], "Using the topology marker": [[16, "using-the-topology-marker"]], "Accessing hosts - Deep dive into multihost fixtures": [[16, "accessing-hosts-deep-dive-into-multihost-fixtures"]], "Using the mh fixture - low-level API": [[16, "using-the-mh-fixture-low-level-api"]], "Using dynamic multihost fixtures - high-level API": [[16, "using-dynamic-multihost-fixtures-high-level-api"]], "Using known topologies": [[16, "using-known-topologies"]], "Topology parametrization": [[16, "topology-parametrization"]], "pytest_mh.ssh": [[3, "module-pytest_mh.ssh"]], "pytest_mh": [[1, "module-pytest_mh"]], "Multihost configuration": [[11, "multihost-configuration"]], "Basic definition": [[11, "basic-definition"]], "Customize configuration": [[11, "customize-configuration"]], "Gathering artifacts": [[11, "gathering-artifacts"]]}, "indexentries": {"create() (pytest_mh.topologymark class method)": [[1, "pytest_mh.TopologyMark.Create"]], "expandmarkers() (pytest_mh.topologymark class method)": [[1, "pytest_mh.TopologyMark.ExpandMarkers"]], "frommultihostconfig() (pytest_mh.topology class method)": [[1, "pytest_mh.Topology.FromMultihostConfig"]], "getdata() (pytest_mh.multihostitemdata static method)": [[1, "pytest_mh.MultihostItemData.GetData"]], "getlogger() (pytest_mh.multihostplugin class method)": [[1, "pytest_mh.MultihostPlugin.GetLogger"]], "getutilityattributes() (pytest_mh.multihostutility static method)": [[1, "pytest_mh.MultihostUtility.GetUtilityAttributes"]], "ignorecall() (pytest_mh.multihostutility class method)": [[1, "pytest_mh.MultihostUtility.IgnoreCall"]], "knowntopologybase (class in pytest_mh)": [[1, "pytest_mh.KnownTopologyBase"]], "knowntopologygroupbase (class in pytest_mh)": [[1, "pytest_mh.KnownTopologyGroupBase"]], "linux (pytest_mh.multihostosfamily attribute)": [[1, "pytest_mh.MultihostOSFamily.Linux"]], "multihostconfig (class in pytest_mh)": [[1, "pytest_mh.MultihostConfig"]], "multihostdomain (class in pytest_mh)": [[1, "pytest_mh.MultihostDomain"]], "multihostfixture (class in pytest_mh)": [[1, "pytest_mh.MultihostFixture"]], "multihosthost (class in pytest_mh)": [[1, "pytest_mh.MultihostHost"]], "multihosthostartifacts (class in pytest_mh)": [[1, "pytest_mh.MultihostHostArtifacts"]], "multihostitemdata (class in pytest_mh)": [[1, "pytest_mh.MultihostItemData"]], "multihostosfamily (class in pytest_mh)": [[1, "pytest_mh.MultihostOSFamily"]], "multihostplugin (class in pytest_mh)": [[1, "pytest_mh.MultihostPlugin"]], "multihostrole (class in pytest_mh)": [[1, "pytest_mh.MultihostRole"]], "multihosttopologycontrollerartifacts (class in pytest_mh)": [[1, "pytest_mh.MultihostTopologyControllerArtifacts"]], "multihostutility (class in pytest_mh)": [[1, "pytest_mh.MultihostUtility"]], "setdata() (pytest_mh.multihostitemdata static method)": [[1, "pytest_mh.MultihostItemData.SetData"]], "setuputilityattributes() (pytest_mh.multihostutility class method)": [[1, "pytest_mh.MultihostUtility.SetupUtilityAttributes"]], "teardownutilityattributes() (pytest_mh.multihostutility class method)": [[1, "pytest_mh.MultihostUtility.TeardownUtilityAttributes"]], "topology (class in pytest_mh)": [[1, "pytest_mh.Topology"]], "topologycontroller (class in pytest_mh)": [[1, "pytest_mh.TopologyController"]], "topologydomain (class in pytest_mh)": [[1, "pytest_mh.TopologyDomain"]], "topologymark (class in pytest_mh)": [[1, "pytest_mh.TopologyMark"]], "topologymarkclass (pytest_mh.multihostconfig property)": [[1, "pytest_mh.MultihostConfig.TopologyMarkClass"]], "windows (pytest_mh.multihostosfamily attribute)": [[1, "pytest_mh.MultihostOSFamily.Windows"]], "apply() (pytest_mh.topologymark method)": [[1, "pytest_mh.TopologyMark.apply"]], "args (pytest_mh.topologymark property)": [[1, "pytest_mh.TopologyMark.args"]], "artifacts (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.artifacts"]], "artifacts (pytest_mh.multihostrole attribute)": [[1, "pytest_mh.MultihostRole.artifacts"]], "artifacts (pytest_mh.multihostutility attribute)": [[1, "pytest_mh.MultihostUtility.artifacts"]], "artifacts (pytest_mh.topologycontroller attribute)": [[1, "pytest_mh.TopologyController.artifacts"]], "artifacts_collector (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.artifacts_collector"]], "artifacts_compression (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.artifacts_compression"]], "artifacts_dir (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.artifacts_dir"]], "artifacts_mode (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.artifacts_mode"]], "cli (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.cli"]], "config (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.config"]], "configured_artifacts (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.configured_artifacts"]], "controller (pytest_mh.topologymark attribute)": [[1, "pytest_mh.TopologyMark.controller"]], "create_domain() (pytest_mh.multihostconfig method)": [[1, "pytest_mh.MultihostConfig.create_domain"]], "create_host() (pytest_mh.multihostdomain method)": [[1, "pytest_mh.MultihostDomain.create_host"]], "create_role() (pytest_mh.multihostdomain method)": [[1, "pytest_mh.MultihostDomain.create_role"]], "data (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.data"]], "domains (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.domains"]], "export() (pytest_mh.topology method)": [[1, "pytest_mh.Topology.export"]], "export() (pytest_mh.topologydomain method)": [[1, "pytest_mh.TopologyDomain.export"]], "export() (pytest_mh.topologymark method)": [[1, "pytest_mh.TopologyMark.export"]], "fixtures (pytest_mh.topologymark attribute)": [[1, "pytest_mh.TopologyMark.fixtures"]], "get() (pytest_mh.multihosthostartifacts method)": [[1, "pytest_mh.MultihostHostArtifacts.get"]], "get() (pytest_mh.multihosttopologycontrollerartifacts method)": [[1, "pytest_mh.MultihostTopologyControllerArtifacts.get"]], "get() (pytest_mh.topology method)": [[1, "pytest_mh.Topology.get"]], "get() (pytest_mh.topologydomain method)": [[1, "pytest_mh.TopologyDomain.get"]], "get_artifacts_list() (pytest_mh.multihosthost method)": [[1, "pytest_mh.MultihostHost.get_artifacts_list"]], "get_artifacts_list() (pytest_mh.multihostrole method)": [[1, "pytest_mh.MultihostRole.get_artifacts_list"]], "get_artifacts_list() (pytest_mh.multihostutility method)": [[1, "pytest_mh.MultihostUtility.get_artifacts_list"]], "get_artifacts_list() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.get_artifacts_list"]], "host (pytest_mh.multihostutility attribute)": [[1, "pytest_mh.MultihostUtility.host"]], "hostname (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.hostname"]], "hosts (pytest_mh.multihostdomain attribute)": [[1, "pytest_mh.MultihostDomain.hosts"]], "hosts (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.hosts"]], "hosts (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.hosts"]], "hosts_by_role() (pytest_mh.multihostdomain method)": [[1, "pytest_mh.MultihostDomain.hosts_by_role"]], "id (pytest_mh.multihostdomain attribute)": [[1, "pytest_mh.MultihostDomain.id"]], "id_to_domain_class (pytest_mh.multihostconfig property)": [[1, "pytest_mh.MultihostConfig.id_to_domain_class"]], "lazy_ssh (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.lazy_ssh"]], "log_phase() (pytest_mh.multihostfixture method)": [[1, "pytest_mh.MultihostFixture.log_phase"]], "logger (pytest_mh.multihostconfig attribute)": [[1, "pytest_mh.MultihostConfig.logger"]], "logger (pytest_mh.multihostdomain attribute)": [[1, "pytest_mh.MultihostDomain.logger"]], "logger (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.logger"]], "logger (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.logger"]], "logger (pytest_mh.multihostrole attribute)": [[1, "pytest_mh.MultihostRole.logger"]], "logger (pytest_mh.multihostutility attribute)": [[1, "pytest_mh.MultihostUtility.logger"]], "logger (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.logger"]], "mh() (in module pytest_mh)": [[1, "pytest_mh.mh"]], "mh_config (pytest_mh.multihostdomain attribute)": [[1, "pytest_mh.MultihostDomain.mh_config"]], "mh_domain (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.mh_domain"]], "module": [[1, "module-pytest_mh"]], "multihost (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.multihost"]], "multihost (pytest_mh.multihostitemdata attribute)": [[1, "pytest_mh.MultihostItemData.multihost"]], "multihost (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.multihost"]], "name (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.name"]], "name (pytest_mh.topologymark attribute)": [[1, "pytest_mh.TopologyMark.name"]], "ns (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.ns"]], "ns (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.ns"]], "os_family (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.os_family"]], "outcome (pytest_mh.multihostitemdata attribute)": [[1, "pytest_mh.MultihostItemData.outcome"]], "pytest_addoption() (in module pytest_mh)": [[1, "pytest_mh.pytest_addoption"]], "pytest_configure() (in module pytest_mh)": [[1, "pytest_mh.pytest_configure"]], "pytest_mh": [[1, "module-pytest_mh"]], "pytest_output_item_collected() (pytest_mh.multihostplugin method)": [[1, "pytest_mh.MultihostPlugin.pytest_output_item_collected"]], "pytest_runtest_makereport() (pytest_mh.multihostplugin method)": [[1, "pytest_mh.MultihostPlugin.pytest_runtest_makereport"]], "pytest_setup (pytest_mh.multihosthostartifacts attribute)": [[1, "pytest_mh.MultihostHostArtifacts.pytest_setup"]], "pytest_setup() (pytest_mh.multihosthost method)": [[1, "pytest_mh.MultihostHost.pytest_setup"]], "pytest_teardown (pytest_mh.multihosthostartifacts attribute)": [[1, "pytest_mh.MultihostHostArtifacts.pytest_teardown"]], "pytest_teardown() (pytest_mh.multihosthost method)": [[1, "pytest_mh.MultihostHost.pytest_teardown"]], "request (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.request"]], "required_fields (pytest_mh.multihostconfig property)": [[1, "pytest_mh.MultihostConfig.required_fields"]], "required_fields (pytest_mh.multihostdomain property)": [[1, "pytest_mh.MultihostDomain.required_fields"]], "required_fields (pytest_mh.multihosthost property)": [[1, "pytest_mh.MultihostHost.required_fields"]], "role (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.role"]], "role_to_host_class (pytest_mh.multihostdomain property)": [[1, "pytest_mh.MultihostDomain.role_to_host_class"]], "role_to_role_class (pytest_mh.multihostdomain property)": [[1, "pytest_mh.MultihostDomain.role_to_role_class"]], "roles (pytest_mh.multihostdomain property)": [[1, "pytest_mh.MultihostDomain.roles"]], "roles (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.roles"]], "satisfies() (pytest_mh.topology method)": [[1, "pytest_mh.Topology.satisfies"]], "satisfies() (pytest_mh.topologydomain method)": [[1, "pytest_mh.TopologyDomain.satisfies"]], "set_artifacts() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.set_artifacts"]], "setup() (pytest_mh.multihosthost method)": [[1, "pytest_mh.MultihostHost.setup"]], "setup() (pytest_mh.multihostrole method)": [[1, "pytest_mh.MultihostRole.setup"]], "setup() (pytest_mh.multihostutility method)": [[1, "pytest_mh.MultihostUtility.setup"]], "setup() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.setup"]], "setup_when_used() (pytest_mh.multihostutility method)": [[1, "pytest_mh.MultihostUtility.setup_when_used"]], "shell (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.shell"]], "skip() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.skip"]], "ssh (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.ssh"]], "ssh() (pytest_mh.multihostrole method)": [[1, "pytest_mh.MultihostRole.ssh"]], "ssh_host (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.ssh_host"]], "ssh_password (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.ssh_password"]], "ssh_port (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.ssh_port"]], "ssh_username (pytest_mh.multihosthost attribute)": [[1, "pytest_mh.MultihostHost.ssh_username"]], "teardown() (pytest_mh.multihosthost method)": [[1, "pytest_mh.MultihostHost.teardown"]], "teardown() (pytest_mh.multihostrole method)": [[1, "pytest_mh.MultihostRole.teardown"]], "teardown() (pytest_mh.multihostutility method)": [[1, "pytest_mh.MultihostUtility.teardown"]], "teardown() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.teardown"]], "teardown_when_used() (pytest_mh.multihostutility method)": [[1, "pytest_mh.MultihostUtility.teardown_when_used"]], "test (pytest_mh.multihosthostartifacts attribute)": [[1, "pytest_mh.MultihostHostArtifacts.test"]], "test (pytest_mh.multihosttopologycontrollerartifacts attribute)": [[1, "pytest_mh.MultihostTopologyControllerArtifacts.test"]], "topology (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.topology"]], "topology (pytest_mh.topologycontroller property)": [[1, "pytest_mh.TopologyController.topology"]], "topology (pytest_mh.topologymark attribute)": [[1, "pytest_mh.TopologyMark.topology"]], "topology_controller (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.topology_controller"]], "topology_hosts() (pytest_mh.multihostconfig method)": [[1, "pytest_mh.MultihostConfig.topology_hosts"]], "topology_mark (pytest_mh.multihostfixture attribute)": [[1, "pytest_mh.MultihostFixture.topology_mark"]], "topology_mark (pytest_mh.multihostitemdata attribute)": [[1, "pytest_mh.MultihostItemData.topology_mark"]], "topology_setup (pytest_mh.multihosttopologycontrollerartifacts attribute)": [[1, "pytest_mh.MultihostTopologyControllerArtifacts.topology_setup"]], "topology_setup() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.topology_setup"]], "topology_teardown (pytest_mh.multihosttopologycontrollerartifacts attribute)": [[1, "pytest_mh.MultihostTopologyControllerArtifacts.topology_teardown"]], "topology_teardown() (pytest_mh.topologycontroller method)": [[1, "pytest_mh.TopologyController.topology_teardown"]], "used (pytest_mh.multihostutility attribute)": [[1, "pytest_mh.MultihostUtility.used"]]}}) \ No newline at end of file diff --git a/docs.bak/_build/html/topology.html b/docs.bak/_build/html/topology.html new file mode 100644 index 0000000..77cccb9 --- /dev/null +++ b/docs.bak/_build/html/topology.html @@ -0,0 +1,447 @@ + + + + + + + Multihost topology — pytest_mh documentation + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Multihost topology

+

Topology, in the sense of the pytest-mh plugin, defines what domains, hosts, +and roles are required to run a test. Each test is associated with a particular +topology. If the requirements defined by the topology are not met by the current +multihost configuration then the test is skipped. The requirements are:

+
    +
  • How many domains are needed

  • +
  • What domain IDs are needed

  • +
  • How many hosts with given role are needed inside the domain

  • +
+
+
Example topology
+
domains:
+- id: test
+  hosts:
+    client: 1
+    ldap: 1
+
+
+
+

Topologies can be nicely written in YAML. The above example describes the +following requirements:

+
    +
  • One domain of id test

  • +
  • The test domain has two hosts

  • +
  • One host implements the client role and the other host implements the ldap role

  • +
+

The meaning of the roles is defined by your own extensions of the pytest-mh +plugin. You define the meaning by extending particular multihost classes. See +Extending pytest-mh for more information.

+

It is expected that all hosts implementing the same role within a single +domain are interchangeable. Domain id must be unique and it is used to +access the hosts, see Accessing hosts - Deep dive into multihost fixtures.

+
+

Note

+

For the purpose of this article we will assume that ldap represents an +LDAP server and client represents the client that talks to the server. +The domain id test is used only as a way to group and access the roles +and hosts and does not have any further meaning.

+
+
+

Using the topology marker

+

The topology marker @pytest.mark.topology is used to associate a particular +topology with given tests. This marker provides information about the topology +that is required to run the test and defines fixture mapping between a short +pytest fixture name and a specific host and role from the topology (this is +explained later in Accessing hosts - Deep dive into multihost fixtures).

+

The marker is used as:

+
@pytest.mark.topology(name, topology, *, fixtures=dict(...))
+def test_example():
+    assert True
+
+
+

Where name is the human-readable topology name that is visible in pytest +verbose output, you can also use this name to filter tests that you want to run +(with the -k parameter). The next argument, topology, is instance of +Topology and then follows keyword arguments as a fixture +mapping - we will cover that later.

+
+

Note

+

The topology marker creates an instance of TopologyMark. +You can extend this class to add additional information to the topology.

+
+

The example topology above would be written as:

+
@pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1)))
+def test_example():
+    assert True
+
+
+
+

Warning

+

Creating custom topologies and fixture mapping is not recommended and should +be used only when it is really needed. See Using known topologies to learn +how to use predefined topologies in order to shorten the code and provide +naming consistency across all tests.

+
+
+
+

Accessing hosts - Deep dive into multihost fixtures

+

Besides defining topology required by the test, the topology marker also gives +access to the remote hosts through pytest fixtures that are created based on the +topology and the fixture mapping from the topology marker.

+

This section will go from the very basic low-level access through +mh() fixture and it will advance step by step to a nice +high-level API through dynamic fixture mapping.

+
+

Using the mh fixture - low-level API

+

Each test that is marked with the topology marker automatically gains access +to the mh() fixture. This fixture allows you to directly access +domains (MultihostDomain) and hosts (as +MultihostRole) that are available in the domain.

+
+

Note

+

It is expected that tests access only high-level API through the role object +and let the role object talk to the host. Therefore the role objects are +directly accessible through the mh() fixture instead of +hosts objects.

+
+

To access the hosts through the mh() fixture use:

+
    +
  • mh.ns.<domain-id>.<role> to access a list of all hosts that implements given role

  • +
  • mh.ns.<domain-id>.<role>[<index>] to access a specific host through index starting from 0

  • +
+

The following snippet shows how to access hosts from our topology:

+
@pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1)))
+def test_example(mh: MultihostFixture):
+    assert mh.ns.test.client[0].role == 'client'
+    assert mh.ns.test.ldap[0].role == 'ldap'
+
+
+

Since the role objects are instances of your own classes (LDAP and +Client for our example), you can also set the type to get the advantage of +Python type hinting.

+
@pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1)))
+def test_example(mh: MultihostFixture):
+    client: Client = mh.ns.test.client[0]
+    ldap: LDAP = mh.ns.test.ldap[0]
+
+    assert client.role == 'client'
+    assert ldap.role == 'ldap'
+
+
+@pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1)))
+def test_example2(mh: MultihostFixture):
+    clients: list[Client] = mh.ns.test.client
+    ldaps: list[LDAP] = mh.ns.test.ldap
+
+    for client in clients:
+        assert client.role == 'client'
+
+    for ldap in ldaps:
+        assert ldap.role == 'ldap'
+
+
+

This fixture also makes sure that various setup methods are called before +each test starts and teardown methods are executed when the test is finished +which allows you to automatically revert all changes done by the test on the +hosts. See Setup and teardown for more information.

+
+

Warning

+

Using the mh() fixture directly is not recommended. Please +see Using dynamic multihost fixtures - high-level API to learn how to simplify access to the hosts by +creating a fixture mapping.

+
+
+
+

Using dynamic multihost fixtures - high-level API

+

The topology marker allows us to create a mapping between our own fixture name +and specific path inside the mh() fixture by providing +additional keyword-only arguments to the marker.

+

The example above can be rewritten as:

+
@pytest.mark.topology(
+    'ldap', Topology(TopologyDomain('test', client=1, ldap=1)),
+    fixtures=dict(client='test.client[0]', ldap='test.ldap[0]')
+)
+def test_example(client: Client, ldap: LDAP):
+    assert client.role == 'client'
+    assert ldap.role == 'ldap'
+
+
+

By adding the fixture mapping, we tell the pytest-mh plugin to dynamically +create client and ldap fixtures for the test run and set it to the value +of individual hosts inside the mh() fixture which is still used +under the hood.

+

It is also possible to create a fixture for a group of hosts if our test would +benefit from it.

+
@pytest.mark.topology(
+    'ldap', Topology(TopologyDomain('test', client=1, ldap=1)),
+    fixtures=dict(clients='test.client', ldap='test.ldap[0]')
+)
+def test_example(clients: list[Client], ldap: LDAP):
+    for client in clients:
+        assert client.role == 'client'
+
+    assert ldap.role == 'ldap'
+
+
+
+

Note

+

We don’t have to provide a mapping for every single host, it is up to us +which hosts will be used. It is even possible to combine fixture mapping +and at the same time use mh() fixture as well:

+
@pytest.mark.topology(
+    'ldap', Topology(TopologyDomain('test', client=1, ldap=1)),
+    fixtures=dict(clients='test.client')
+)
+def test_example(mh: MultihostFixture, clients: list[Client]):
+    pass
+
+
+

It is also possible to request multiple fixtures for a single host. This can +be used in test parametrization as we will see later in +Topology parametrization.

+
@pytest.mark.topology(
+    'ldap', Topology(TopologyDomain('test', client=1, ldap=1)),
+    fixtures=dict(client='test.client[0]', ldap='test.ldap[0]', provider='test.ldap[0]')
+)
+def test_example(client: Client, provider: GenericProvider):
+    pass
+
+
+
+
+
+
+

Using known topologies

+

It is highly expected that the topology marker is shared between many tests, +therefore it is not very convenient to create it every time from scratch. It is +possible to define a list of known topologies that can be easily shared between +tests.

+

To create a list of known topologies, you need to subclass +KnownTopologyBase or +KnownTopologyGroupBase (for topology parametrization - see +Topology parametrization) and define your topology marker.

+
@final
+@unique
+class KnownTopology(KnownTopologyBase):
+    LDAP = TopologyMark(
+        name="ldap",
+        topology=Topology(TopologyDomain("test", client=1, ldap=1)),
+        fixtures=dict(client="test.client[0]", ldap="test.ldap[0]"),
+    )
+
+
+

Then you can use the known topology directly in the topology marker.

+
@pytest.mark.topology(KnownTopology.LDAP)
+def test_example(client: Client, ldap: LDAP):
+    assert client.role == 'client'
+    assert ldap.role == 'ldap'
+
+
+
+
+

Topology parametrization

+

It is possible to run single test case against multiple topologies. To associate +the test with multiple topologies you can either use multiple topology markers +or single marker that references a known topology group (see +KnownTopologyGroupBase). Then the test will run multiple +times, once for each assigned topology.

+

In our example, lets assume that our application can talk to different LDAP +providers, such as Active Directory or FreeIPA. First, we create the known +topologies so it is simple to share the markers between tests.

+
@final
+@unique
+class KnownTopology(KnownTopologyBase):
+    LDAP = TopologyMark(
+        name='ldap',
+        topology=Topology(TopologyDomain("test", client=1, ldap=1)),
+        fixtures=dict(client='test.client[0]', ldap='test.ldap[0]', provider='test.ldap[0]'),
+    )
+
+    IPA = TopologyMark(
+        name='ipa',
+        topology=Topology(TopologyDomain("test", client=1, ipa=1)),
+        fixtures=dict(client='test.client[0]', ipa='test.ipa[0]', provider='test.ipa[0]'),
+    )
+
+    AD = TopologyMark(
+        name='ad',
+        topology=Topology(TopologyDomain("test", client=1, ad=1)),
+        fixtures=dict(client='test.client[0]', ad='test.ad[0]', provider='test.ad[0]'),
+    )
+
+class KnownTopologyGroup(KnownTopologyGroupBase):
+    AnyProvider = [KnownTopology.AD, KnownTopology.IPA, KnownTopology.LDAP]
+
+
+

Now we can write a parameterized test, the test will be run for all providers. +Notice, how we added the provider fixture mapping so the host can be +accessed with the provider name (like ldap) or through a generic name +provider that will be used in topology parameterization. The roles need to +implement a common interface so they can be used in tests interchangeably.

+
@pytest.mark.topology(KnownTopology.LDAP)
+@pytest.mark.topology(KnownTopology.IPA)
+@pytest.mark.topology(KnownTopology.AD)
+def test_example(client: Client, provider: GenericProvider):
+    provider.create_user('test-user')
+    assert True
+
+
+

Or the same with the known topology group:

+
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
+def test_example(client: Client, provider: GenericProvider):
+    provider.create_user('test-user')
+    assert True
+
+
+

If the test is run, you can see that it was run once for each provider:

+
$ pytest --mh-config=mhc.yaml -k test_example -v
+...
+tests/test_basic.py::test_example (ad) PASSED                                                                                                                                                                                   [ 25%]
+tests/test_basic.py::test_example (ipa) PASSED                                                                                                                                                                                  [ 37%]
+tests/test_basic.py::test_example (ldap) PASSED
+...
+
+
+
+

Note

+

It is also possible to combine topology parametrization with +@pytest.mark.parametrize.

+
@pytest.mark.parametrize('name', ['user-1', 'user 1'])
+@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
+def test_example(client: Client, provider: GenericProvider, name: str):
+    provider.create_user(name)
+    assert True
+
+
+

Now the test is executed six times, once for each provider and once per each +user name value.

+
$ pytest --mh-config=mhc.yaml -k test_example -v
+...
+tests/test_basic.py::test_example[user-1] (ad) PASSED                                                                                                                                                                                   [ 25%]
+tests/test_basic.py::test_example[user-1] (ipa) PASSED                                                                                                                                                                                  [ 37%]
+tests/test_basic.py::test_example[user-1] (ldap) PASSED                                                                                                                                                                                 [ 50%]
+tests/test_basic.py::test_example[user 1] (ad) PASSED                                                                                                                                                                                   [ 75%]
+tests/test_basic.py::test_example[user 1] (ipa) PASSED                                                                                                                                                                                  [ 87%]
+tests/test_basic.py::test_example[user 1] (ldap) PASSED
+...
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/docs.bak/api.rst b/docs.bak/api.rst new file mode 100644 index 0000000..9e05a9d --- /dev/null +++ b/docs.bak/api.rst @@ -0,0 +1,14 @@ +API Reference +============= + +.. autosummary:: + :toctree: api + :nosignatures: + :recursive: + + pytest_mh + pytest_mh.cli + pytest_mh.conn + pytest_mh.conn.ssh + pytest_mh.conn.container + pytest_mh.utils diff --git a/docs.bak/api/pytest_mh.cli.rst b/docs.bak/api/pytest_mh.cli.rst new file mode 100644 index 0000000..5287dae --- /dev/null +++ b/docs.bak/api/pytest_mh.cli.rst @@ -0,0 +1,41 @@ +pytest\_mh.cli +============== + +.. automodule:: pytest_mh.cli + + + + .. rubric:: Module Attributes + + .. autosummary:: + + CLIBuilderArgs + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Any + CLIBuilder + Enum + SSHClient + SSHPowerShellProcess + SSHProcess + auto + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.rst b/docs.bak/api/pytest_mh.rst new file mode 100644 index 0000000..72c4192 --- /dev/null +++ b/docs.bak/api/pytest_mh.rst @@ -0,0 +1,53 @@ +pytest\_mh +========== + +.. automodule:: pytest_mh + + + + + + + + .. rubric:: Functions + + .. autosummary:: + + mh + pytest_addoption + pytest_configure + + + + + + .. rubric:: Classes + + .. autosummary:: + + KnownTopologyBase + KnownTopologyGroupBase + MultihostConfig + MultihostDomain + MultihostFixture + MultihostHost + MultihostHostArtifacts + MultihostItemData + MultihostOSFamily + MultihostPlugin + MultihostRole + MultihostTopologyControllerArtifacts + MultihostUtility + Topology + TopologyController + TopologyDomain + TopologyMark + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.ssh.rst b/docs.bak/api/pytest_mh.ssh.rst new file mode 100644 index 0000000..fc957ee --- /dev/null +++ b/docs.bak/api/pytest_mh.ssh.rst @@ -0,0 +1,45 @@ +pytest\_mh.ssh +============== + +.. automodule:: pytest_mh.ssh + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Any + Enum + MultihostLogger + SSHBashProcess + SSHClient + SSHLog + SSHPowerShellProcess + SSHProcess + SSHProcessResult + auto + + + + + + .. rubric:: Exceptions + + .. autosummary:: + + SSHAuthenticationError + SSHProcessError + + + + + diff --git a/docs.bak/api/pytest_mh.utils.firewall.rst b/docs.bak/api/pytest_mh.utils.firewall.rst new file mode 100644 index 0000000..76dafc6 --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.firewall.rst @@ -0,0 +1,29 @@ +pytest\_mh.utils.firewall +========================= + +.. automodule:: pytest_mh.utils.firewall + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + Firewalld + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.utils.fs.rst b/docs.bak/api/pytest_mh.utils.fs.rst new file mode 100644 index 0000000..e05a11a --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.fs.rst @@ -0,0 +1,29 @@ +pytest\_mh.utils.fs +=================== + +.. automodule:: pytest_mh.utils.fs + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + LinuxFileSystem + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.utils.journald.rst b/docs.bak/api/pytest_mh.utils.journald.rst new file mode 100644 index 0000000..528886a --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.journald.rst @@ -0,0 +1,29 @@ +pytest\_mh.utils.journald +========================= + +.. automodule:: pytest_mh.utils.journald + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + JournaldUtils + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.utils.rst b/docs.bak/api/pytest_mh.utils.rst new file mode 100644 index 0000000..0db126f --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.rst @@ -0,0 +1,35 @@ +pytest\_mh.utils +================ + +.. automodule:: pytest_mh.utils + + + + + + + + + + + + + + + + + + + +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + pytest_mh.utils.firewall + pytest_mh.utils.fs + pytest_mh.utils.journald + pytest_mh.utils.services + pytest_mh.utils.tc + diff --git a/docs.bak/api/pytest_mh.utils.services.rst b/docs.bak/api/pytest_mh.utils.services.rst new file mode 100644 index 0000000..6476136 --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.services.rst @@ -0,0 +1,29 @@ +pytest\_mh.utils.services +========================= + +.. automodule:: pytest_mh.utils.services + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + SystemdServices + + + + + + + + + diff --git a/docs.bak/api/pytest_mh.utils.tc.rst b/docs.bak/api/pytest_mh.utils.tc.rst new file mode 100644 index 0000000..85bcef9 --- /dev/null +++ b/docs.bak/api/pytest_mh.utils.tc.rst @@ -0,0 +1,29 @@ +pytest\_mh.utils.tc +=================== + +.. automodule:: pytest_mh.utils.tc + + + + + + + + + + + + .. rubric:: Classes + + .. autosummary:: + + LinuxTrafficControl + + + + + + + + + diff --git a/docs/classes.rst b/docs.bak/classes.rst similarity index 100% rename from docs/classes.rst rename to docs.bak/classes.rst diff --git a/docs.bak/conf.py b/docs.bak/conf.py new file mode 100644 index 0000000..1d33073 --- /dev/null +++ b/docs.bak/conf.py @@ -0,0 +1,74 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +from __future__ import annotations + +# -- Path setup -------------------------------------------------------------- +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath(".")) + +# -- Project information ----------------------------------------------------- + +project = "pytest_mh" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", + "sphinx_design", + "sphinxcontrib.mermaid", +] + +# Add any paths that contain templates here, relative to this directory. +# templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +autoclass_content = "both" +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "special-members": "__call__", + "undoc-members": True, + "inherited-members": False, + "show-inheritance": True, +} + +autosummary_imported_members = True +autosummary_ignore_module_all = False + +intersphinx_mapping = { + "pytest": ("https://docs.pytest.org/en/latest", None), +} diff --git a/docs/config.rst b/docs.bak/config.rst similarity index 100% rename from docs/config.rst rename to docs.bak/config.rst diff --git a/docs.bak/index.rst b/docs.bak/index.rst new file mode 100644 index 0000000..2255271 --- /dev/null +++ b/docs.bak/index.rst @@ -0,0 +1,107 @@ +pytest_mh - pytest multihost test framework +########################################### + +.. warning:: + + This plugin is still actively developed and even though it is mostly stable, + we reserve the right to introduce minor breaking changes if it is required for + new functionality. + +``pytest-mh`` is a pytest plugin that, at a basic level, allows you to run shell +commands and scripts over SSH on remote Linux or Windows hosts. You use it to +execute system or application tests for your project on a remote host or hosts +(or containers) while running pytest locally keeping your local machine intact. + +The plugin also provides building blocks that can be used to setup and teardown +your tests, perform automatic clean up of all changes done on the remote host, +and build a flexible and unified high-level API to manipulate the hosts from +your tests. + +.. code-block:: python + :caption: Example test taken from SSSD demo + + @pytest.mark.topology(KnownTopology.AD) + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.topology(KnownTopology.IPA) + @pytest.mark.topology(KnownTopology.Samba) + def test__id(client: Client, provider: GenericProvider): + u = provider.user("tuser").add() + provider.group("tgroup_1").add().add_member(u) + provider.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + +.. seealso:: + + A real life example of how ``pytest-mh`` can help test your code can be + seen in the `SSSD + `__ project. + +When do I want use the framework? +********************************* + +* **Does your program affect the host in any way?** If yes, it is safer to run it in + virtual machine or in a container to avoid affecting your local host. + ``pytest-mh`` takes care of that. +* **Does your program use client-server model?** If yes, it is better to run the + client and the server on separate machines to make the tests more real. + ``pytest-mh`` takes care of that. +* **Does your program communicate with multiple backends?** If yes, you need to + be able to assign each test to a specific backend and also be able to reuse a + single test for multiple backends. ``pytest-mh`` takes care of that. +* **Do you need complex tests that changes state of the system, file system or + other programs or databases?** If yes, you need to make sure that all changes + are reverted when a test is done so the test does not affect other tests. + ``pytest-mh`` takes care of that. +* Does your program **talk to LDAP/IPA/AD/Samba/Kerberos**? If yes, ``pytest-mh`` + can help you with that. +* **Do you use** `pytest-multihost + `__ **framework for your current + tests?** ``pytest-mh`` is a full Python 3 re-implementation of the old + ``pytest-multihost`` plugin. It builds on all its features and takes it to + a whole new level. You definitely want to switch to ``pytest-mh``, + however it is not backwards compatible. + +When I don't want to use it? +**************************** + +* Do you want to test your Python code? Then this plugin will not help + you. It is designed for running system or applications tests, i.e. testing + your application as a whole. + +What does the framework do? +*************************** + +* Allows you to **run commands over SSH on remote hosts** (or virtual machines or + containers) using bash or Powershell. +* Allows you to **define your own roles with a provide fully typed API** to your + tests that fulfills all your needs. +* All **changes that you do on the remote host during a single test can be + completely reverted** so they do not affect other tests. +* Defines an available **multihost topology** - what roles are available in your + current setup. +* **Associates each test with certain topology** - defines what roles are + required to run the test. +* Supports **topology parametrization** - a single test can run on multiple + topologies. +* **Run only tests that can be run on available topology**. +* Provides **access to roles through dynamic pytest fixtures**. +* **The code is fully typed** - you get rich suggestions from your editor and the + types can be fully checked. +* **Everything can be extended**. + +.. toctree:: + :maxdepth: 2 + + quick-start + config + topology + classes + runtime-requirements + pytest + api diff --git a/docs.bak/pytest.rst b/docs.bak/pytest.rst new file mode 100644 index 0000000..c7449e3 --- /dev/null +++ b/docs.bak/pytest.rst @@ -0,0 +1,71 @@ +Using pytest-mh +############### + +Register plugin with pytest +*************************** + +``pytest-mh`` plugin does not autoregister itself with pytest, it lets you +do it manually in ``conftest.py``. It also requires you to set your own +:class:`~pytest_mh.MultihostConfig` class so the plugin knows what domain, host, +and role objects should be created. + +.. code-block:: python + :caption: Registering pytest-mh with pytest in conftest.py + + from pytest_mh import MultihostPlugin + + # Load additional plugins + pytest_plugins = ( + "pytest_mh", + ) + + + # Setup pytest-mh and tell it to use "ExampleMultihostConfig" class + def pytest_plugin_registered(plugin) -> None: + if isinstance(plugin, MultihostPlugin): + plugin.config_class = ExampleMultihostConfig + +.. seealso:: + + Read :doc:`classes` and :doc:`quick-start` to see how to implement your own + configuration, domain, hosts, and roles classes by extending base classes + provided by :mod:`pytest_mh`. + +Running tests +************* + +In order to run the tests, you need to provide multihost configuration (see +:doc:`config` for more details). Once you have it, you can run your test suite +with pytest as usually, you just need to specify the path to the configuration with +``--mh-config=``. + +.. code-block:: console + + $ pytest --mh-config=./mhc.yaml + +New pytest command line options +=============================== + +``pytest-mh`` adds several command line options to the pytest. + +* ``--mh-config=`` - Path to the multihost configuration file in YAML + format. +* ``--mh-log-path`` - Path to the log file where multihost messages will be + written. +* ``--mh-lazy-ssh`` - If set, SSH connection to the host is not established + immediately but is postponed to its first use. Otherwise the connection to + all hosts is established immediately when pytest starts to test if all hosts + are accessible. +* ``--mh-exact-topology`` - If set, test is run only if its topology matches + exactly given multihost configuration. Otherwise it is sufficient that the + topology can be fulfilled by the configuration even though the configuration + may contain more hosts or domains then are required. +* ``--mh-collect-artifacts=always|on-failure|never`` - Specifies when test + artifacts are collected. Default value is ``on-failure`` - only collect + artifacts if test fails. +* ``--mh-artifacts-dir`` - Directory where test artifacts are stored. +* ``--mh-compress-artifacts`` - If set, test artifacts are stored in a compressed archive. +* ``--mh-topology`` - Filter tests by given topology, can be set multiple times. +* ``--mh-not-topology`` - Do not run tests for given topology, can be set multiple times. +* ``--mh-collect-logs=always|on-failure|never`` - Specifies when logs are + collected. Uses ``--mh-collect-artifacts`` as default value. diff --git a/docs.bak/quick-start.rst b/docs.bak/quick-start.rst new file mode 100644 index 0000000..116bc5c --- /dev/null +++ b/docs.bak/quick-start.rst @@ -0,0 +1,283 @@ +Quick Start Guide +################# + +This guide will show you how to setup and extend the ``pytest-mh`` plugin. We will +write a simple test of Kerberos authentication that spans over two separate +hosts - one host has the Kerberos KDC running and the other host will be used as +a client machine. + +.. note:: + + The complete code is located in the `example + `__ directory + of `pytest-mh `__ repository. + +.. seealso:: + + A real life example of how ``pytest-mh`` can help to test your code can be + seen in the `SSSD + `__ project. + +All projects are different, therefore :mod:`pytest_mh` plugin provides only the +most basic functionality like ``ssh`` access to hosts and building blocks to +build your own tools and API. It is expected that you implement required +functionality in host, role and utility classes by extending +:class:`~pytest_mh.MultihostHost`, :class:`~pytest_mh.MultihostRole` and +:class:`~pytest_mh.MultihostUtility`. + +Since :mod:`pytest_mh` plugin is fully extensible, it is possible to also add +your own configuration options and different domain types by extending +:class:`~pytest_mh.MultihostConfig` and :class:`~pytest_mh.MultihostDomain`. +This step is actually required as the base classes are abstract and you have to +overwrite specific methods and properties in order to give a list of your own +domain, host and role classes that will be automatically be instantiated by the +plugin. + +.. note:: + + The difference between host, roles, and utility classes: + + * Host classes are created only once before the first test is executed and + exist during the whole pytest session. They can be used to setup + everything that should live for the whole session. + * Role classes are the main objects that are directly accessible from + individual tests. They are created just before the test execution and + destroyed once the test is finished. They can perform setup required to + run the tests and proper clean up after the test is finished. Roles should + also define and implement proper API to access required resources. + * Utility classes are instantiated inside individual roles. They represent + functionality that can be shared between roles. They are also responsible + to clean up every change that is done through their API. The + :mod:`pytest_mh` plugin already has some utility classes bundled within, + see :mod:`pytest_mh.utils`. + +Create configuration and domain classes +======================================= + +First of all, we need to extend :class:`~pytest_mh.MultihostConfig` and tell it +how to create our own domain object. Additionally, we need to extend +:class:`~pytest_mh.MultihostDomain` and define a mapping between role name and +host classes and also a mapping between role name and role classes. This tells +the plugin which host and role classes should be instantiated for given role. + +In the example below, we define two roles: "client" and "kdc". Each role has its +own role (``client``, ``KDC``) and host class (``ClientHost``, ``KDCHost``). + +.. literalinclude:: ../example/lib/config.py + :caption: /lib/config.py + :emphasize-lines: 9-10, 14-27, 29-42 + :language: python + :linenos: + +.. note:: + + It is not necessary to create distinct role and host class for every role. + The classes can be shared for multiple roles if it makes sense for your + project. + +Create host classes +=================== + +KDC Host +******** + +The KDC host takes care of backup and restore of the KDC data. It create backup +of KDC database when pytest is started and restores it to the original state +every time a test is finished. This ensures that the database is always the same +for each test execution. It also removes the backup file when pytest is +terminated. + +.. literalinclude:: ../example/lib/hosts/kdc.py + :caption: /lib/hosts/kdc.py + :language: python + :emphasize-lines: 33-34, 40-45, 54 + :linenos: + +Client Host +*********** + +The client host does not perform any backup and restore as it is not needed, but +it reads additional configuration values from the multihost configuration +(``mhc.yaml``) file. + +.. note:: + + The additional configuration is read from the standard ``config`` field + which is there for this very reason. But if it makes sense, you can of + course extend any section. + +.. literalinclude:: ../example/lib/hosts/client.py + :caption: /lib/hosts/client.py + :emphasize-lines: 37-39 + :language: python + :linenos: + +Create role classes +=================== + +Unlike hosts, the role classes are the right place to provide all functionality +that will help you write good tests so they are usually quite complex. + +KDC Role +******** + +The ``KDC`` class implements the functionality desired for "kdc" role. In this +example, we focus on adding the Kerberos principal (or *Kerberos user* if you +are not familiar with Kerberos terminology) and querying the kadmin tool to get +some additional information. + +.. literalinclude:: ../example/lib/roles/kdc.py + :caption: /lib/roles/kdc.py + :language: python + :linenos: + +Client Role +*********** + +The client role first creates ``/etc/krb5.conf`` so the Kerberos client knows +what KDC we want to use. For this, it uses the bundle +:class:`~pytest_mh.utils.fs.LinuxFileSystem` utility class, which writes the file to +the remote path and when a test is finished, it makes sure to restore the +original content or remove the file if it was not present before. + +.. literalinclude:: ../example/lib/roles/client.py + :caption: /lib/roles/client.py + :language: python + :emphasize-lines: 35, 82 + :linenos: + +Define multihost topology +========================= + +Each test is associated with one or more topologies. A topology defines multihost +requirements that must be met in order to run the test. If the requirements are +not met, the test will not run. These requirements are: + +* What domains are available +* What roles and how many roles inside each domain are available + +To assign a topology to a test case, we use ``@pytest.mark.topology(...)``. The +next example defines a topology with one domain that contains one client and one +kdc role. Hosts that implements these roles are then available as pytest +fixtures. + +.. code-block:: python + + @pytest.mark.topology( + "kdc", Topology(TopologyDomain("test", client=1, kdc=1)), + fixtures=dict(client="test.client[0]", kdc="test.kdc[0]") + ) + def test_example(client: Client, kdc: KDC): + pass + +However, this can be little bit cumbersome, therefore it is good practice to +define a list of known topologies first. + +.. literalinclude:: ../example/lib/topology.py + :caption: /lib/topology.py + :language: python + :emphasize-lines: 25-29 + :linenos: + +Now we can shorten the topology marker like this: + +.. code-block:: python + + @pytest.mark.topology(KnownTopology.KDC) + def test_example(client: Client, kdc: KDC): + pass + +.. seealso:: + + There is also :class:`~pytest_mh.KnownTopologyGroupBase` to define a list of + topologies that should be assigned to the test case and thus create topology + parameterization. + +Create multihost configuration +============================== + +Now, our test framework is ready to use. We just need to provide multihost +configuration file that defines available hosts. + +We set custom fields that are required by ``ClientHost`` and we also define list +of artifacts that are automatically fetched from the remote host. + +.. literalinclude:: ../example/mhc.yaml + :caption: /mhc.yaml + :language: yaml + :emphasize-lines: 6-9, 13-14 + :linenos: + +.. note:: + + The example configuration assumes running containers from + `sssd-ci-containers `__ project. + +Enable pytest-mh in pytest +========================== + +The ``pytest-mh`` plugin needs to be manually enabled in ``conftest.py`` and it +needs to know the configuration class that should be instantiated. + +.. literalinclude:: ../example/conftest.py + :caption: /conftest.py + :language: python + :emphasize-lines: 10, 14-16 + :linenos: + +Write and run a simple test +=========================== + +All the pieces are now available. We have successfully setup the ``pytest-mh`` +plugin, created our own test framework API. Now it is time to write some tests. + +.. literalinclude:: ../example/tests/test_kdc.py + :caption: /tests/test_kdc.py + :language: python + :emphasize-lines: 9-10, 20-21 + :linenos: + +Now we can run them. Notice how the topology name is mentioned in the test name. + +.. code-block:: text + + $ pytest --mh-config=./mhc.yaml -vv + Multihost configuration: + domains: + - id: test + hosts: + - hostname: client.test + role: client + config: + realm: TEST + krbdomain: test + kdc: kdc.test + - hostname: kdc.test + role: kdc + artifacts: + - /var/log/krb5kdc.log + + Detected topology: + - id: test + hosts: + client: 1 + kdc: 1 + + Additional settings: + config file: ./mhc.yaml + log path: None + lazy ssh: False + topology filter: None + require exact topology: False + collect artifacts: on-failure + artifacts directory: ./artifacts + + ============================================================================================================ test session starts ============================================================================================================= + platform linux -- Python 3.10.8, pytest-7.2.1, pluggy-1.0.0 -- /home/pbrezina/workspace/pytest-mh/.venv/bin/python3 + cachedir: .pytest_cache + rootdir: /home/pbrezina/workspace/pytest-mh, configfile: pytest.ini + collected 2 items + + tests/test_kdc.py::test_kinit (kdc) PASSED [ 50%] + tests/test_kdc.py::test_kvno (kdc) PASSED + diff --git a/docs.bak/requirements.txt b/docs.bak/requirements.txt new file mode 100644 index 0000000..39ce3b2 --- /dev/null +++ b/docs.bak/requirements.txt @@ -0,0 +1,5 @@ +sphinx >= 3.0 +sphinx_rtd_theme >= 1.1.0 +sphinx_design +sphinxcontrib-mermaid +-r ../requirements.txt diff --git a/docs.bak/runtime-requirements.rst b/docs.bak/runtime-requirements.rst new file mode 100644 index 0000000..4af47b0 --- /dev/null +++ b/docs.bak/runtime-requirements.rst @@ -0,0 +1,78 @@ +Additional runtime requirements +############################### + +Sometimes, topology itself is not enough to detect if the test can or can not +be run and you want to check for a runtime requirement like that your program +was built with certain configure flags or features. + +This can be achieved with ``pytest.mark.require(condition[, reason])`` marker +that takes a function as a parameter and the test is skipped if the function +returns ``False`` (the requirement was not met). + +The function takes all fixtures that are available to the test as parameters. + +.. note:: + + The function can either return ``bool`` or ``tuple[bool, str]``. In this + case, the second value is the reason for skipping the test. + +.. code-block:: python + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + lambda client: "files-provider" in client.features, + "SSSD was not built with files provider" + ) + def test_example_explicit_reason(client: Client, ldap: LDAP): + pass + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + lambda client: ("files-provider" in client.features, "SSSD was not built with files provider") + ) + def test_example_reason_as_tuple(client: Client, ldap: LDAP): + pass + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + lambda **kwargs: "files-provider" in kwargs["client"].features + ) + def test_example_kwargs(client: Client, ldap: LDAP): + pass + +It is also possible to pass a function directly instead of an anonymous (lambda) +function if the requirement is shared between multiple tests. However, there is +a documented glitch in pytest that requires you to use different marker syntax. +See `pytest documentation +`__ +for more information. + +.. code-block:: python + + def require_files_provider(client: Client): + return "files-provider" in client.features, "SSSD was not built with files provider" + + @pytest.mark.require.with_args(require_files_provider) + @pytest.mark.topology(KnownTopology.LDAP) + def test_example(): + pass + +.. note:: + + The requirement is evaluated when the test is executed but before setup + phase, so no setup method was called on any multihost role in order to make + the skip fast. + + If you require to setup the role, you can always call the setup method + directly from the function passed to the ``require`` marker. + +.. warning:: + + ``pytest-mh`` provides the ``requirement`` marker as a generic way to skip + a test when a condition is not met. The condition can use multihost roles + or other pytest fixtures used by the marked test and it can also call + commands on remote hosts. + + The example above shows a check if an SSSD project was built with + "files-provider" feature, however feature detection is not part of + ``pytest-mh`` since feature detection is project specific mechanism. diff --git a/docs.bak/topology.rst b/docs.bak/topology.rst new file mode 100644 index 0000000..b0c4a4c --- /dev/null +++ b/docs.bak/topology.rst @@ -0,0 +1,377 @@ +Multihost topology +################## + +Topology, in the sense of the ``pytest-mh`` plugin, defines what domains, hosts, +and roles are required to run a test. Each test is associated with a particular +topology. If the requirements defined by the topology are not met by the current +multihost configuration then the test is skipped. The requirements are: + +* How many domains are needed +* What domain IDs are needed +* How many hosts with given role are needed inside the domain + +.. code-block:: yaml + :caption: Example topology + + domains: + - id: test + hosts: + client: 1 + ldap: 1 + +Topologies can be nicely written in YAML. The above example describes the +following requirements: + +* One domain of id ``test`` +* The ``test`` domain has two hosts +* One host implements the ``client`` role and the other host implements the ``ldap`` role + +The meaning of the roles is defined by your own extensions of the ``pytest-mh`` +plugin. You define the meaning by extending particular multihost classes. See +:doc:`classes` for more information. + +It is expected that all hosts implementing the same role within a single +domain are interchangeable. Domain ``id`` must be unique and it is used to +access the hosts, see :ref:`mh-fixture`. + +.. note:: + + For the purpose of this article we will assume that ``ldap`` represents an + LDAP server and ``client`` represents the client that talks to the server. + The domain id ``test`` is used only as a way to group and access the roles + and hosts and does not have any further meaning. + +Using the topology marker +************************* + +The topology marker ``@pytest.mark.topology`` is used to associate a particular +topology with given tests. This marker provides information about the topology +that is required to run the test and defines fixture mapping between a short +pytest fixture name and a specific host and role from the topology (this is +explained later in :ref:`mh-fixture`). + +The marker is used as: + +.. code-block:: python + + @pytest.mark.topology(name, topology, *, fixtures=dict(...)) + def test_example(): + assert True + +Where ``name`` is the human-readable topology name that is visible in ``pytest`` +verbose output, you can also use this name to filter tests that you want to run +(with the ``-k`` parameter). The next argument, ``topology``, is instance of +:class:`~pytest_mh.Topology` and then follows keyword arguments as a fixture +mapping - we will cover that later. + +.. note:: + + The topology marker creates an instance of :class:`~pytest_mh.TopologyMark`. + You can extend this class to add additional information to the topology. + +The example topology above would be written as: + +.. code-block:: python + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(): + assert True + +.. warning:: + + Creating custom topologies and fixture mapping is not recommended and should + be used only when it is really needed. See :ref:`known-topologies` to learn + how to use predefined topologies in order to shorten the code and provide + naming consistency across all tests. + +.. _mh-fixture: + +Accessing hosts - Deep dive into multihost fixtures +*************************************************** + +Besides defining topology required by the test, the topology marker also gives +access to the remote hosts through pytest fixtures that are created based on the +topology and the fixture mapping from the topology marker. + +This section will go from the very basic low-level access through +:func:`~pytest_mh.mh` fixture and it will advance step by step to a nice +high-level API through dynamic fixture mapping. + +Using the mh fixture - low-level API +==================================== + +Each test that is marked with the ``topology`` marker automatically gains access +to the :func:`~pytest_mh.mh` fixture. This fixture allows you to directly access +domains (:class:`~pytest_mh.MultihostDomain`) and hosts (as +:class:`~pytest_mh.MultihostRole`) that are available in the domain. + +.. note:: + + It is expected that tests access only high-level API through the role object + and let the role object talk to the host. Therefore the role objects are + directly accessible through the :func:`~pytest_mh.mh` fixture instead of + hosts objects. + +To access the hosts through the :func:`~pytest_mh.mh` fixture use: + +* ``mh.ns..`` to access a list of all hosts that implements given role +* ``mh.ns..[]`` to access a specific host through index starting from 0 + +The following snippet shows how to access hosts from our topology: + +.. code-block:: python + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(mh: MultihostFixture): + assert mh.ns.test.client[0].role == 'client' + assert mh.ns.test.ldap[0].role == 'ldap' + +Since the role objects are instances of your own classes (``LDAP`` and +``Client`` for our example), you can also set the type to get the advantage of +Python type hinting. + +.. code-block:: python + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(mh: MultihostFixture): + client: Client = mh.ns.test.client[0] + ldap: LDAP = mh.ns.test.ldap[0] + + assert client.role == 'client' + assert ldap.role == 'ldap' + + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example2(mh: MultihostFixture): + clients: list[Client] = mh.ns.test.client + ldaps: list[LDAP] = mh.ns.test.ldap + + for client in clients: + assert client.role == 'client' + + for ldap in ldaps: + assert ldap.role == 'ldap' + +This fixture also makes sure that various ``setup`` methods are called before +each test starts and ``teardown`` methods are executed when the test is finished +which allows you to automatically revert all changes done by the test on the +hosts. See :ref:`setup-and-teardown` for more information. + +.. warning:: + + Using the :func:`~pytest_mh.mh` fixture directly is not recommended. Please + see :ref:`dynamic-fixtures` to learn how to simplify access to the hosts by + creating a fixture mapping. + +.. _dynamic-fixtures: + +Using dynamic multihost fixtures - high-level API +================================================= + +The topology marker allows us to create a mapping between our own fixture name +and specific path inside the :func:`~pytest_mh.mh` fixture by providing +additional keyword-only arguments to the marker. + +The example above can be rewritten as: + +.. code-block:: python + :emphasize-lines: 3 + + @pytest.mark.topology( + 'ldap', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict(client='test.client[0]', ldap='test.ldap[0]') + ) + def test_example(client: Client, ldap: LDAP): + assert client.role == 'client' + assert ldap.role == 'ldap' + +By adding the fixture mapping, we tell the ``pytest-mh`` plugin to dynamically +create ``client`` and ``ldap`` fixtures for the test run and set it to the value +of individual hosts inside the :func:`~pytest_mh.mh` fixture which is still used +under the hood. + +It is also possible to create a fixture for a group of hosts if our test would +benefit from it. + +.. code-block:: python + :emphasize-lines: 3 + + @pytest.mark.topology( + 'ldap', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict(clients='test.client', ldap='test.ldap[0]') + ) + def test_example(clients: list[Client], ldap: LDAP): + for client in clients: + assert client.role == 'client' + + assert ldap.role == 'ldap' + +.. note:: + + We don't have to provide a mapping for every single host, it is up to us + which hosts will be used. It is even possible to combine fixture mapping + and at the same time use :func:`~pytest_mh.mh` fixture as well: + + .. code-block:: python + :emphasize-lines: 5 + + @pytest.mark.topology( + 'ldap', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict(clients='test.client') + ) + def test_example(mh: MultihostFixture, clients: list[Client]): + pass + + It is also possible to request multiple fixtures for a single host. This can + be used in test parametrization as we will see later in + :ref:`topology-parametrization`. + + .. code-block:: python + :emphasize-lines: 3 + + @pytest.mark.topology( + 'ldap', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict(client='test.client[0]', ldap='test.ldap[0]', provider='test.ldap[0]') + ) + def test_example(client: Client, provider: GenericProvider): + pass + +.. _known-topologies: + +Using known topologies +********************** + +It is highly expected that the topology marker is shared between many tests, +therefore it is not very convenient to create it every time from scratch. It is +possible to define a list of known topologies that can be easily shared between +tests. + +To create a list of known topologies, you need to subclass +:class:`~pytest_mh.KnownTopologyBase` or +:class:`~pytest_mh.KnownTopologyGroupBase` (for topology parametrization - see +:ref:`topology-parametrization`) and define your topology marker. + +.. code-block:: python + + @final + @unique + class KnownTopology(KnownTopologyBase): + LDAP = TopologyMark( + name="ldap", + topology=Topology(TopologyDomain("test", client=1, ldap=1)), + fixtures=dict(client="test.client[0]", ldap="test.ldap[0]"), + ) + +Then you can use the known topology directly in the topology marker. + +.. code-block:: python + + @pytest.mark.topology(KnownTopology.LDAP) + def test_example(client: Client, ldap: LDAP): + assert client.role == 'client' + assert ldap.role == 'ldap' + +.. _topology-parametrization: + +Topology parametrization +************************ + +It is possible to run single test case against multiple topologies. To associate +the test with multiple topologies you can either use multiple topology markers +or single marker that references a known topology group (see +:class:`~pytest_mh.KnownTopologyGroupBase`). Then the test will run multiple +times, once for each assigned topology. + +In our example, lets assume that our application can talk to different LDAP +providers, such as Active Directory or FreeIPA. First, we create the known +topologies so it is simple to share the markers between tests. + + +.. code-block:: python + + @final + @unique + class KnownTopology(KnownTopologyBase): + LDAP = TopologyMark( + name='ldap', + topology=Topology(TopologyDomain("test", client=1, ldap=1)), + fixtures=dict(client='test.client[0]', ldap='test.ldap[0]', provider='test.ldap[0]'), + ) + + IPA = TopologyMark( + name='ipa', + topology=Topology(TopologyDomain("test", client=1, ipa=1)), + fixtures=dict(client='test.client[0]', ipa='test.ipa[0]', provider='test.ipa[0]'), + ) + + AD = TopologyMark( + name='ad', + topology=Topology(TopologyDomain("test", client=1, ad=1)), + fixtures=dict(client='test.client[0]', ad='test.ad[0]', provider='test.ad[0]'), + ) + + class KnownTopologyGroup(KnownTopologyGroupBase): + AnyProvider = [KnownTopology.AD, KnownTopology.IPA, KnownTopology.LDAP] + +Now we can write a parameterized test, the test will be run for all providers. +Notice, how we added the ``provider`` fixture mapping so the host can be +accessed with the provider name (like ``ldap``) or through a generic name +``provider`` that will be used in topology parameterization. The roles need to +implement a common interface so they can be used in tests interchangeably. + +.. code-block:: python + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.topology(KnownTopology.IPA) + @pytest.mark.topology(KnownTopology.AD) + def test_example(client: Client, provider: GenericProvider): + provider.create_user('test-user') + assert True + +Or the same with the known topology group: + +.. code-block:: python + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider): + provider.create_user('test-user') + assert True + +If the test is run, you can see that it was run once for each provider: + +.. code-block:: console + + $ pytest --mh-config=mhc.yaml -k test_example -v + ... + tests/test_basic.py::test_example (ad) PASSED [ 25%] + tests/test_basic.py::test_example (ipa) PASSED [ 37%] + tests/test_basic.py::test_example (ldap) PASSED + ... + +.. note:: + + It is also possible to combine topology parametrization with + ``@pytest.mark.parametrize``. + + .. code-block:: python + + @pytest.mark.parametrize('name', ['user-1', 'user 1']) + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider, name: str): + provider.create_user(name) + assert True + + Now the test is executed six times, once for each provider and once per each + user name value. + + .. code-block:: console + + $ pytest --mh-config=mhc.yaml -k test_example -v + ... + tests/test_basic.py::test_example[user-1] (ad) PASSED [ 25%] + tests/test_basic.py::test_example[user-1] (ipa) PASSED [ 37%] + tests/test_basic.py::test_example[user-1] (ldap) PASSED [ 50%] + tests/test_basic.py::test_example[user 1] (ad) PASSED [ 75%] + tests/test_basic.py::test_example[user 1] (ipa) PASSED [ 87%] + tests/test_basic.py::test_example[user 1] (ldap) PASSED + ... diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 0000000..f7389b7 --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,3 @@ +.wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal; +} diff --git a/docs/api.rst b/docs/api.rst index 9e05a9d..8a2f823 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,13 +2,13 @@ API Reference ============= .. autosummary:: - :toctree: api - :nosignatures: - :recursive: + :toctree: api + :nosignatures: + :recursive: - pytest_mh - pytest_mh.cli - pytest_mh.conn - pytest_mh.conn.ssh - pytest_mh.conn.container - pytest_mh.utils + pytest_mh + pytest_mh.cli + pytest_mh.conn + pytest_mh.conn.ssh + pytest_mh.conn.container + pytest_mh.utils diff --git a/docs/articles/bundled-utilities.rst b/docs/articles/bundled-utilities.rst new file mode 100644 index 0000000..702b4fb --- /dev/null +++ b/docs/articles/bundled-utilities.rst @@ -0,0 +1,58 @@ +Ready to Use Utilities +###################### + +Pytest-mh codebase contains several generic, ready to use utilities that you can +easily add to your hosts and roles. These utilities add support for various +project independent tasks, such as reading and writing files, managing firewall, +automatic collection of core dumps and much more. + +In order to use these utilities, simply import them in your python module and +add them to your role or host. + +.. warning:: + + Only utilities that inherits from + :class:`~pytest_mh.MultihostReentrantUtility` can be safely used in both + :class:`~pytest_mh.MultihostRole` and :class:`~pytest_mh.MultihostHost` + object. Other classes should be used only in + :class:`~pytest_mh.MultihostRole`. + +.. code-block:: python + :caption: Example: Adding filesystem and systemd utilities to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.fs import LinuxFileSystem + from pytest_mh.utils.services import SystemdServices + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self) + """ + File system utilities. + """ + + self.svc: SystemdServices = SystemdServices(self) + """ + Systemd utilities. + """ + +These utilities are automatically initialized, setup and teardown. You can start +using them right away. Every change on the host that these utilities do during +a test run is guarantied to be automatically reverted. + +.. note:: + + We welcome contributions that add more project independent utilities. + +.. toctree:: + :maxdepth: 2 + + bundled-utilities/auditd + bundled-utilities/coredumpd + bundled-utilities/firewall + bundled-utilities/fs + bundled-utilities/journald + bundled-utilities/services + bundled-utilities/tc diff --git a/docs/articles/bundled-utilities/auditd.rst b/docs/articles/bundled-utilities/auditd.rst new file mode 100644 index 0000000..0ad0bbd --- /dev/null +++ b/docs/articles/bundled-utilities/auditd.rst @@ -0,0 +1,68 @@ +Auditd: Testing for AVC denials +############################### + +Auditd is a Linux audit daemon responsible for writing audit records to the +audit log file, usually ``/var/log/audit/audit.log``. + +This utility adds this log file into the list of artifacts that are collected +after each test. It also detects AVC denials (usually caused by SELinux or +AppArmor). If an AVC denial is found, it can change the test status to +``failed`` and mark it as ``original-status/AVC DENIAL``. + +.. note:: + + ``Auditd`` and ``ausearch`` must be installed on the system for this utility + to work. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.auditd.Auditd` for more + information. + +Simply add this utility to your role in order to use it during a test run. +Everything else is fully automatic. + +.. code-block:: python + :caption: Example: Adding auditd utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.auditd import Auditd + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.auditd: Auditd = Auditd(self.host, avc_mode="fail", avc_filter="my_binary") + """ + Auditd utilities. + """ + +If the ``avc_mode`` is set to ``fail``, it will change the outcome of the test +to ``failed`` even if the test itself was successful. However, the original +outcome is still visible in the verbose output. You can also set it to ``warn`` +to mark the test as ``AVC DENIAL`` but keep the test outcome intact; or to +``ignore`` to only collect the audit logs without affecting the test outcome or +category. + +.. code-block:: text + :caption: Example: Output of pytest run with AVC denial detected + + Selected tests will use the following hosts: + audit: audit.test + + collected 545 items / 544 deselected / 1 selected + + tests/test_audit.py::test_audit (audit) PASSED/AVC DENIAL + + ======= 544 deselected, 1 AVC DENIALS in 0.94s ======= + +.. warning:: + + It is not possible to run auditd inside a container therefore this utility + can detect AVC denials if the remote host is a virtual machine or bare + metal. + + If you run your tests on containerized environment as well as on virtual + machines, it is recommended to set ``avc_mode="ignore"`` for containers + and ``avc_mode="fail"`` (or ``warn``) for runs on virtual machine. + diff --git a/docs/articles/bundled-utilities/coredumpd.rst b/docs/articles/bundled-utilities/coredumpd.rst new file mode 100644 index 0000000..47fdabd --- /dev/null +++ b/docs/articles/bundled-utilities/coredumpd.rst @@ -0,0 +1,59 @@ +Coredumpd: Autodetection of Core Files +###################################### + +Collects generated core files from ``/var/lib/systemd/coredump`` and stores them +as test artifacts. If any core file was produced during the test run, it can +change the test status to ``failed`` and mark it as +``original-status/COREDUMP``. + +.. note:: + + ``systemd-coredump`` must be enabled on the system and set to store core + files in ``/var/lib/systemd/coredump`` directory (default behavior). + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.coredumpd.Coredumpd` for + more information. + +Simply add this utility to your role in order to use it during a test run. +Everything else is fully automatic. + +.. code-block:: python + :caption: Example: Adding coredumpd utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.fs import LinuxFileSystem + from pytest_mh.utils.coredumpd import Coredumpd + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self.host) + """ + File system manipulation. + """ + + self.coredumpd: Coredumpd = Coredumpd(self.host, self.fs, mode="fail", filter="my_binary") + """ + Coredumpd utilities. + """ + +If the ``mode`` is set to ``fail``, it will change the outcome of the test to +``failed`` even if the test itself was successful. However, the original outcome +is still visible in the verbose output. You can also set it to ``warn`` to mark +the test as ``COREDUMP`` but keep the test outcome intact; or to ``ignore`` to +only collect the core files but do not affect the test outcome or category. + +.. code-block:: text + :caption: Example: Output of pytest run with core file detected + + Selected tests will use the following hosts: + coredumpd: coredumpd.test + + collected 545 items / 544 deselected / 1 selected + + tests/test_coredumpd.py::test_coredumpd (coredumpd) PASSED/COREDUMP + + ======= 544 deselected, 1 COREDUMPS in 0.94s ======= diff --git a/docs/articles/bundled-utilities/firewall.rst b/docs/articles/bundled-utilities/firewall.rst new file mode 100644 index 0000000..20e60f5 --- /dev/null +++ b/docs/articles/bundled-utilities/firewall.rst @@ -0,0 +1,57 @@ +Firewall: Managing Network Access +################################# + +The :mod:`pytest_mh.utils.firewall` provides generic interface to remote system +firewall as well as two specific implementations of this interface: Firewalld +and Windows Firewall. + +These utilities allows you to create inbound and outbound rules to block or +allow access to specific ports, IP addresses or hostnames. + +.. note:: + + ``firewalld`` or the Windows Firewall must be enabled on the system. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.firewall.Firewall`, + :class:`~pytest_mh.utils.firewall.Firewalld`, + :class:`~pytest_mh.utils.firewall.WindowsFirewall` for more information. + +.. note:: + + Since the firewall also performs some setup actions, you probably want to + mark the utility with :meth:`~pytest_mh.MultihostUtility.postpone_setup` so + the setup method is called only if the firewall is actually used. This way, + it saves some resources in tests that do not utilize the firewall. + +.. code-block:: python + :caption: Example: Adding firewall utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.firewall import Firewall + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.firewall: Firewall = Firewalld(self.host).postpone_setup() + """ + Configure firewall using firewalld. + """ + +.. code-block:: python + :caption: Example: Rejecting outgoing connections to host + + @pytest.mark.topology(...) + def test_firewall(client: ClientRole, server: ServerRole): + ... + client.firewall.outbound.reject_host(server) + ... + +.. note:: + + If you create a new firewall rule to block a connection, connections that + are already established may not be terminated. Therefore if you start + blocking a connection and application under test is already running, + make sure that the application also drops active connections. diff --git a/docs/articles/bundled-utilities/fs.rst b/docs/articles/bundled-utilities/fs.rst new file mode 100644 index 0000000..0c8fec5 --- /dev/null +++ b/docs/articles/bundled-utilities/fs.rst @@ -0,0 +1,55 @@ +Filesystem: Manipulating Files and Folders +########################################## + +The :mod:`pytest_mh.utils.fs` module provides access to remote files and folders: +reading and writing files, creating folders, making temporary folders and files +and more. + +A backup is created for every path that is changed during a test and it is +restored after the test is finished. Therefore you do not have to worry about +touching any path, the original contents and state (including ownership, mode +and context) is fully restored. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.fs.LinuxFileSystem` for + more information. + +.. note:: + + Currently, we only provide :class:`~pytest_mh.utils.fs.LinuxFileSystem` to + manipulate files and folders on Linux systems. Contributions for Windows + world are welcomed. + +.. code-block:: python + :caption: Example: Adding fs utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.fs import LinuxFileSystem + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self.host) + """ + File system manipulation. + """ + +.. code-block:: python + :caption: Example: Writing contents to a file + + @pytest.mark.topology(...) + def test_fs(client: ClientRole): + ... + client.fs.write("/etc/my.conf", "configuration", mode="600") + ... + +.. code-block:: python + :caption: Example: Writing contents to a temporary file + + @pytest.mark.topology(...) + def test_fs(client: ClientRole): + ... + tmp_path = client.fs.mktmp("contents") + ... \ No newline at end of file diff --git a/docs/articles/bundled-utilities/journald.rst b/docs/articles/bundled-utilities/journald.rst new file mode 100644 index 0000000..98dc5df --- /dev/null +++ b/docs/articles/bundled-utilities/journald.rst @@ -0,0 +1,46 @@ +Journald: Searching in the Journal +################################## + +Systemd-journald is the daemon that collects system logs nowadays. This utility +dumps journal contents to a file and stores it as a test artifact. It also +exposes an API to run queries against the journal to search for messages that +were produced during the test run. + +.. note:: + + ``systemd-journald`` must be enabled on the system. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.journald.JournaldUtils` + for more information. + +Simply add this utility to your role in order to use it during a test run. +Everything else is fully automatic. + +.. code-block:: python + :caption: Example: Adding journald utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.journald import JournaldUtils + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.journald: JournaldUtils = JournaldUtils(self.host) + """ + Journald utilities. + """ + +Adding this utility automatically produces ``/var/log/journald.log`` artifact +that contains dump of the journal log entries that were recorded during test. + +.. code-block:: python + :caption: Example: Check if a log message is present in the journal + + @pytest.mark.topology(...) + def test_journal(client: ClientRole): + ... + assert client.journal.is_match("Offline", unit="my-unit") + ... diff --git a/docs/articles/bundled-utilities/services.rst b/docs/articles/bundled-utilities/services.rst new file mode 100644 index 0000000..c2b750f --- /dev/null +++ b/docs/articles/bundled-utilities/services.rst @@ -0,0 +1,48 @@ +Services: Starting and Stopping System Services +############################################### + +:class:`~pytest_mh.utils.services.SystemdServices` provides interface to start, +stop, reload and manage systemd services. The state of the service is +automatically restored when a test is finished. For example if a service was +originally stopped and then started during a test, it is automatically stopped +when the test finishes. + +.. note:: + + ``systemd`` must be used to manage services on the system. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.services.SystemdServices` + for more information. + +.. code-block:: python + :caption: Example: Adding systemd utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.services import SystemdServices + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.svc: SystemdServices = SystemdServices(self.host) + """ + Systemd service management. + """ + + +.. code-block:: python + :caption: Example: Starting a systemd service + + @pytest.mark.topology(...) + def test_tc(client: ClientRole): + ... + client.svc.start("my-service.service") + ... + +.. note:: + + This service is a subclass of :class:`~pytest_mh.MultihostReentrantUtility`, + therefore you can safely use it also in :class:`~pytest_mh.MultihostHost` + objects, not only in :class:`~pytest_mh.MultihostRole`. diff --git a/docs/articles/bundled-utilities/tc.rst b/docs/articles/bundled-utilities/tc.rst new file mode 100644 index 0000000..3c7606a --- /dev/null +++ b/docs/articles/bundled-utilities/tc.rst @@ -0,0 +1,48 @@ +Traffic Control: Delaying Network Traffic +######################################### + +``tc`` is a tool to manipulate network traffic control setting. This utility +allows you to delay communication with target host which can simulate high +network latencies or delays. + +.. note:: + + ``tc`` tool must be installed on the system. + +.. seealso:: + + See the API reference of :class:`~pytest_mh.utils.tc.LinuxTrafficControl` + for more information. + +.. note:: + + Since the utility also performs some setup actions, you probably want to + mark the utility with :meth:`~pytest_mh.MultihostUtility.postpone_setup` so + the setup method is called only if the tc tool is actually used. This way, + it saves some resources in tests that do not utilize the traffic control. + +.. code-block:: python + :caption: Example: Adding tc utility to your role + + from pytest_mh import MultihostHost + from pytest_mh.utils.tc import LinuxTrafficControl + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.tc: LinuxTrafficControl = LinuxTrafficControl(self.host).postpone_setup() + """ + Traffic control manipulation. + """ + + +.. code-block:: python + :caption: Example: Delaying traffic to host + + @pytest.mark.topology(...) + def test_tc(client: ClientRole, server: ServerRole): + ... + # Delay traffic between client and server by 1500ms + client.tc.add_delay(server, 1500) + ... diff --git a/docs/articles/extending.rst b/docs/articles/extending.rst new file mode 100644 index 0000000..0a8c1bb --- /dev/null +++ b/docs/articles/extending.rst @@ -0,0 +1,62 @@ +Extending pytest-mh for Your Needs +################################## + +Pytest-mh uses a yaml-formatted configuration file that contains the list of +multihost domains, hosts and their roles that are required for the tests. These +configuration entities are converted into their Python representations that can +and should be extended to provide additional configuration and high level API +for your project as well as the setup and teardown code. + +.. note:: + + Do not confuse ``domains`` with DNS domains. The domains in the + configuration file do not have to follow any DNS patterns. Their purpose is + to identify a group of hosts. + + As a general rule, hosts within the same domain that have the same role + should be interchangeable -- it should not matter to which of the host you + talk, you always should get the same result, for example a database replicas + or servers behind a load balancer. + +The following snippet shows a minimal configuration file with one domain and one +host. + +.. code-block:: yaml + :caption: Minimal configuration file + + domains: + - id: example + hosts: + - hostname: client.test + role: client + +The file is parsed into: + +* :class:`~pytest_mh.MultihostConfig`: top level object, container for the whole + configuration +* :class:`~pytest_mh.MultihostDomain`: the domain object, container for all + hosts within a domain, one for each domain +* :class:`~pytest_mh.MultihostHost`: individual hosts, one for each host + +Additionally, each host has a role assigned. This role creates a +:class:`~pytest_mh.MultihostRole` object, this object has a short lifespan and +it exists only for a duration of one test. A new role object is created for each +test. Further, :class:`~pytest_mh.MultihostUtility` classes can be used to share +code and functionality between roles and hosts. + +Each test is assigned with a topology. A topology describes what domains and +roles are required to run the test. If the current configuration does not satisfy +this requirement, the test is silently skipped. + +Read the following articles to get more information on how to define topologies +and use and extend these classes. + +.. toctree:: + :maxdepth: 2 + + extending/multihost-config + extending/multihost-domains + extending/multihost-hosts + extending/multihost-roles + extending/multihost-utilities + extending/multihost-topologies diff --git a/docs/articles/extending/multihost-config.rst b/docs/articles/extending/multihost-config.rst new file mode 100644 index 0000000..f44b848 --- /dev/null +++ b/docs/articles/extending/multihost-config.rst @@ -0,0 +1,101 @@ +Multihost Configuration +####################### + +:class:`~pytest_mh.MultihostConfig` has access to the whole configuration file +that is used to run the tests. Its main purpose is to map domain identifier into +a Python class that will be used to create the domain object. + +.. code-block:: python + :caption: Basic example of MultihostConfig + :emphasize-lines: 10-13 + :linenos: + + class MyProjectConfig(MultihostConfig): + @property + def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]: + """ + Map domain id to domain class. Asterisk ``*`` can be used as fallback + value. + + :rtype: Class name. + """ + return { + "example": MyProjectExampleDomain, + "*": MyProjectGenericDomain + } + +However, it can also be used to add a custom top-level configuration options or +extend the functionality of ``pytest.mark.topology`` marker. The configuration +file contents can be accessed as a dictionary through +:attr:`~pytest_mh.MultihostConfig.confdict`, however it is recommended to place +custom options under the ``config`` field which can by accessed directly through +the :attr:`~pytest_mh.MultihostConfig.config` attribute. This way, it is +possible to avoid name collisions if pytest-mh introduces new options in the +future. + +It is also possible to override or extend all public methods to further affect +the behavior. + +.. grid:: 1 + + .. grid-item-card:: Custom configuration option and topology marker + + .. tab-set:: + + .. tab-item:: Python code + + .. code-block:: python + :emphasize-lines: 3-10,13-14,17-20 + :linenos: + + class MyProjectConfig(MultihostConfig): + @property + def required_fields(self) -> list[str]: + """ + Fields that must be set in the host configuration. An error is raised + if any field is missing. + + The field name may contain a ``.`` to check nested fields. + """ + return super().required_fields + ["config.my_config_required_option"] + + @property + def my_config_option(self) -> bool: + return self.config.get("my_config_option", False) + + @property + def my_config_required_option(self) -> bool: + # This option is required and pytest will error if + # it is not present in the configuration + return self.config.get("my_config_required_option") + + @property + def TopologyMarkClass(self) -> Type[TopologyMark]: + # Set a custom topology marker type + return MyProjectTopologyMark + + @property + def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]: + """ + Map domain id to domain class. Asterisk ``*`` can be used as fallback + value. + + :rtype: Class name. + """ + return {"*": SSSDMultihostDomain} + + + .. tab-item:: mhc.yaml + + .. code-block:: yaml + :emphasize-lines: 1-3 + :linenos: + + config: + my_config_option: True + my_config_required_option: True + domains: + - id: example + hosts: + - hostname: client.test + role: client \ No newline at end of file diff --git a/docs/articles/extending/multihost-domains.rst b/docs/articles/extending/multihost-domains.rst new file mode 100644 index 0000000..40e2acc --- /dev/null +++ b/docs/articles/extending/multihost-domains.rst @@ -0,0 +1,141 @@ +Multihost Domains +################# + +:class:`~pytest_mh.MultihostDomain` has access to the domain part of the +configuration file. Its main purpose is to map role names into a Python +classes that will be used to create the host and role objects. + +.. code-block:: python + :caption: Basic example of MultihostDomain + :emphasize-lines: 12-15,27-30 + :linenos: + + class MyProjectDomain(MultihostDomain[MyProjectConfig]): + @property + def role_to_host_class(self) -> dict[str, Type[MultihostHost]]: + """ + Map role to host class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + from .hosts.client import ClientHost + from .hosts.server import ServerHost + + return { + "client": ClientHost, + "server": ServerHost, + } + + @property + def role_to_role_class(self) -> dict[str, Type[MultihostRole]]: + """ + Map role to role class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + from .roles.client import ClientRole + from .roles.server import ServerRole + + return { + "client": ClientRole, + "server": ServerRole, + } + +.. note:: + + It may be required to import the types inside the methods to reduce their + scope and avoid circular dependency since :class:`~pytest_mh.MultihostHost` + is a generic class that takes the domain class as a specific type. + +Similar to the :class:`~pytest_mh.MultihostConfig` class, it also possible to +add custom configuration options or further extend functionality by overriding +the parent class methods. The configuration dictionary can be accessed by +:attr:`~pytest_mh.MultihostDomain.confdict`, however it is recommended to place +custom options under the ``config`` field which can by accessed directly through +the :attr:`~pytest_mh.MultihostDomain.config` attribute. This way, it is +possible to avoid name collisions if pytest-mh introduces new options in the +future. + +It is also possible to override or extend all public methods to further affect +the behavior. + +.. grid:: 1 + + .. grid-item-card:: Basic example of custom configuration option + + .. tab-set:: + + .. tab-item:: Python code + + .. code-block:: python + :emphasize-lines: 3-10,13-14,17-20 + :linenos: + + class MyProjectDomain(MultihostDomain[MyProjectConfig]): + @property + def required_fields(self) -> list[str]: + """ + Fields that must be set in the host configuration. An error is raised + if any field is missing. + + The field name may contain a ``.`` to check nested fields. + """ + return super().required_fields + ["config.my_domain_required_option"] + + @property + def my_domain_option(self) -> bool: + return self.config.get("my_domain_option", False) + + @property + def my_domain_required_option(self) -> bool: + # This option is required and pytest will error if + # it is not present in the configuration + return self.config.get("my_domain_required_option") + + @property + def role_to_host_class(self) -> dict[str, Type[MultihostHost]]: + """ + Map role to host class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + from .hosts.client import ClientHost + from .hosts.server import ServerHost + + return { + "client": ClientHost, + "server": ServerHost, + } + + @property + def role_to_role_class(self) -> dict[str, Type[MultihostRole]]: + """ + Map role to role class. Asterisk ``*`` can be used as fallback value. + + :rtype: Class name. + """ + from .roles.client import ClientRole + from .roles.server import ServerRole + + return { + "client": ClientRole, + "server": ServerRole, + } + + + .. tab-item:: mhc.yaml + + .. code-block:: yaml + :emphasize-lines: 3 + :linenos: + + domains: + - id: example + config: + my_domain_option: True + my_domain_required_option: True + hosts: + - hostname: client.test + role: client + + diff --git a/docs/articles/extending/multihost-hosts.rst b/docs/articles/extending/multihost-hosts.rst new file mode 100644 index 0000000..b192ee9 --- /dev/null +++ b/docs/articles/extending/multihost-hosts.rst @@ -0,0 +1,129 @@ +Multihost Hosts +############### + +:class:`~pytest_mh.MultihostHost` has access to the host part of the +configuration file. Its main purpose is to setup and teardown the host and +prepare it to run the tests and provide access to the main connection to the +host over which is it possible to run commands on the host (see +:attr:`~pytest_mh.MultihostHost.conn` attribute). + +The host objects are created once when pytest starts and live for the whole +duration of the pytest session. Therefore they can be used to store data that +should be available to all tests. + +.. note:: + + The host class can be also used to provide high-level API for your project, + however remember that the test has direct access to the + :class:`MultihostRole` objects and only indirect access to the host objects + (via the role). Therefore vast majority of your high-level API should be + placed in the role object in order to provide most direct access. + +.. code-block:: python + :caption: Basic example of MultihostHost + :emphasize-lines: 8,18,26,36 + :linenos: + + class ClientHost(MultihostHost[MyProjectDomain]): + def pytest_setup(self) -> None: + """ + Called once before execution of any tests. + """ + # Run your setup code here. + # Do not forget to call the parent setup as well. + super().pytest_setup() + self.conn.run("echo 'Setting up'") + + def pytest_teardown(self) -> None: + """ + Called once after all tests are finished. + """ + # Run your teardown code here. + # Do not forget to call the parent teardown as well. + self.conn.run("echo 'Tearing down'") + super().pytest_teardown() + + def setup(self) -> None: + """ + Called before execution of each test. + """ + # Run your setup code here. + # Do not forget to call the parent setup as well. + super().setup() + self.conn.run("echo 'Setting up'") + + def teardown(self) -> None: + """ + Called after execution of each test. + """ + # Run your teardown code here. + # Do not forget to call the parent teardown as well. + self.conn.run("echo 'Tearing down'") + super().teardown() + +.. seealso:: + + There are several methods where you can place your setup and teardown code. + See :doc:`../life-cycle/setup-and-teardown`. + +.. seealso:: + + The host class is a perfect place to implement host-level backup and + teardown. See :doc:`../tips-and-tricks/backup-restore` for tips on how to + achieve that with :class:`~pytest_mh.MultihostBackupHost`. + +It is also possible to add custom configuration options or further extend +functionality by overriding the parent class methods. The configuration +dictionary can be accessed by :attr:`~pytest_mh.MultihostHost.confdict`, however +it is recommended to place custom options under the ``config`` field which can +by accessed through the :attr:`~pytest_mh.MultihostHost.config` attribute. This +way, it is possible to avoid collisions if pytest-mh introduces new options in +the future. + +.. grid:: 1 + + .. grid-item-card:: Basic example of custom configuration option + + .. tab-set:: + + .. tab-item:: Python code + + .. code-block:: python + :emphasize-lines: 3-10,13-14,17-20 + :linenos: + + class ClientHost(MultihostHost[MyProjectDomain]): + @property + def required_fields(self) -> list[str]: + """ + Fields that must be set in the host configuration. An error is raised + if any field is missing. + + The field name may contain a ``.`` to check nested fields. + """ + return super().required_fields + ["config.my_host_required_option"] + + @property + def my_host_option(self) -> bool: + return self.config.get("my_host_option", False) + + @property + def my_host_required_option(self) -> bool: + # This option is required and pytest will error if + # it is not present in the configuration + return self.config.get("my_host_required_option") + + .. tab-item:: mhc.yaml + + .. code-block:: yaml + :emphasize-lines: 3 + :linenos: + + domains: + - id: example + hosts: + - hostname: client.test + role: client + config: + my_host_option: True + my_host_required_option: True diff --git a/docs/articles/extending/multihost-roles.rst b/docs/articles/extending/multihost-roles.rst new file mode 100644 index 0000000..33d8839 --- /dev/null +++ b/docs/articles/extending/multihost-roles.rst @@ -0,0 +1,226 @@ +Multihost Roles +############### + +Objects that inherits from :class:`~pytest_mh.MultihostRole` are directly +accessible in the test. These objects are short-lived, new instance is created +for each test, therefore it is possible to store test related data. The main +purpose of this class is to provide role setup and teardown as well as a place +to implement high-level API for testing your project. + +.. seealso:: + + See :doc:`multihost-utilities` to see how is it possible to share code + between multiple role classes (and host classes). + +As a first example, we implement a basic code for a client role. This role +includes several built-in utilities to automatically get access to functionality +we want to use in our tests. + +.. code-block:: python + :caption: Example: Trivial client role + :linenos: + + from pytest_mh import MultihostRole + from pytest_mh.utils.firewall import Firewalld + from pytest_mh.utils.fs import LinuxFileSystem + from pytest_mh.utils.journald import JournaldUtils + from pytest_mh.utils.services import SystemdServices + from pytest_mh.utils.tc import LinuxTrafficControl + + class ClientRole(MultihostRole[ClientHost]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self.host) + """ + File system manipulation. + """ + + self.svc: SystemdServices = SystemdServices(self.host) + """ + Systemd service management. + """ + + self.firewall: Firewalld = Firewalld(self.host).postpone_setup() + """ + Configure firewall using firewalld. + """ + + self.tc: LinuxTrafficControl = LinuxTrafficControl(self.host).postpone_setup() + """ + Traffic control manipulation. + """ + + self.journald: JournaldUtils = JournaldUtils(self.host) + """ + Journald utilities. + """ + + def setup(self) -> None: + """ + Called before execution of each test. + + * stop the client + * remove client's database and logs + """ + super().setup() + + self.svc.stop("my-project-client") + self.fs.rm("/var/lib/my-project-client") + self.fs.rm("/var/log/my-project-client") + + def teardown(self) -> None: + """ + Called after execution of each test. + """ + # It is not required to restore removed files or restart + # the service. This is done automatically by the utilities. + super().teardown() + +The following snippet add a high-level API to add a local user. It uses a +built-in CLI builder, that can help you to prepare a command line for execution. +Notice, that all local users that are created during a test are later removed +during teardown. + +.. code-block:: python + :caption: Example: Method to add a local user + :emphasize-lines: 4,5,42-45,47-50,72-75,79-126 + :linenos: + + from typing import Self + + from pytest_mh import MultihostRole + from pytest_mh.cli import CLIBuilder, CLIBuilderArgs + from pytest_mh.conn import ProcessLogLevel + from pytest_mh.utils.firewall import Firewalld + from pytest_mh.utils.fs import LinuxFileSystem + from pytest_mh.utils.journald import JournaldUtils + from pytest_mh.utils.services import SystemdServices + from pytest_mh.utils.tc import LinuxTrafficControl + + + class ClientRole(MultihostRole[ClientHost]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self.host) + """ + File system manipulation. + """ + + self.svc: SystemdServices = SystemdServices(self.host) + """ + Systemd service management. + """ + + self.firewall: Firewalld = Firewalld(self.host).postpone_setup() + """ + Configure firewall using firewalld. + """ + + self.tc: LinuxTrafficControl = LinuxTrafficControl(self.host).postpone_setup() + """ + Traffic control manipulation. + """ + + self.journald: JournaldUtils = JournaldUtils(self.host) + """ + Journald utilities. + """ + + self.cli: CLIBuilder = CLIBuilder(self.host.conn) + """ + CLI builder helper. + """ + + self._added_users: list[str] = [] + """ + List of local users that were created during the test. + """ + + def setup(self) -> None: + """ + Called before execution of each test. + + * stop the client + * remove client's database and logs + """ + super().setup() + + self.svc.stop("my-project-client") + self.fs.rm("/var/lib/my-project-client") + self.fs.rm("/var/log/my-project-client") + + def teardown(self) -> None: + """ + Called after execution of each test. + """ + # It is not required to restore removed files or restart + # the service. This is done automatically by the utilities. + + # Delete users that we added + if self._users: + cmd = "\n".join([f"userdel '{x}' --force --remove" for x in self._users]) + "\n" + self.host.conn.run("set -e\n\n" + cmd) + + super().teardown() + + def add_local_user( + self, + *, + name: str, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new local user. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + if home is not None: + self.fs.backup(home) + + args: CLIBuilderArgs = { + "name": (self.cli.option.POSITIONAL, name), + "uid": (self.cli.option.VALUE, uid), + "gid": (self.cli.option.VALUE, gid), + "home": (self.cli.option.VALUE, home), + "gecos": (self.cli.option.VALUE, gecos), + "shell": (self.cli.option.VALUE, shell), + } + + passwd = f" && passwd --stdin '{name}'" if password else "" + self.logger.info(f'Creating local user "{name}" on {self.host.hostname}') + self.host.conn.run(self.cli.command("useradd", args) + passwd, input=password, log_level=ProcessLogLevel.Error) + + self._users.append(name) + + return self + +.. seealso:: + + The examples above are very trivial in order to show the idea. To see a + feature-rich roles that are actively used to test a real life project, + checkout the `sssd-test-framework roles`_. These roles provide extensive, + high-level API to manage users, group and other objects in LDAP, IPA, + SambaDC and Active Directory as well as tools to manage and test SSSD. + +.. _sssd-test-framework roles: https://github.com/SSSD/sssd-test-framework/tree/master/sssd_test_framework/roles + diff --git a/docs/articles/extending/multihost-topologies.rst b/docs/articles/extending/multihost-topologies.rst new file mode 100644 index 0000000..c42b5ee --- /dev/null +++ b/docs/articles/extending/multihost-topologies.rst @@ -0,0 +1,624 @@ +Multihost Topologies +#################### + +Multihost topology is the core of pytest-mh. It defines the requirements of a +test -- what multihost domains and roles (and how many) are required to run the +test. If the current environment defined in the configuration file does not meet +the requirements of the topology then the test is silently skipped (in pytest +terminology the test is not collected) and you will not even see it in the +results. + +Using the topology, we can say that a test requires "1 client and 1 server". +Maybe, you are using external authentication provider with Kerberos, so you +might say that the test requires "1 client, 1 server and 1 KDC". Or you want to +test that data replication and load balancer works correctly: "1 client, 3 +servers and 1 load balancer". + +The following snippets shows how this can be represented in the configuration +file: + +.. tab-set:: + + .. tab-item:: 1 client, 1 server + + .. code-block:: yaml + + domains: + - id: myapp + hosts: + - hostname: client.myapp.test + role: client + - hostname: server.myapp.test + role: server + + .. tab-item:: 1 client, 1 server, 1 KDC + + .. code-block:: yaml + + domains: + - id: myapp + hosts: + - hostname: client.myapp.test + role: client + - hostname: server.myapp.test + role: server + - id: authprovider + hosts: + - hostname: kdc.authprovider.test + role: kdc + + .. tab-item:: 1 client, 3 server, 1 load balancer + + .. code-block:: yaml + + domains: + - id: myapp + hosts: + - hostname: client.myapp.test + role: client + - hostname: server1.myapp.test + role: server + - hostname: replica1.myapp.test + role: server + - hostname: replica2.myapp.test + role: server + - hostname: balancer.myapp.test + role: balancer + +.. note:: + + See that all three servers from the third example are placed inside a single + multihost domain. This is because all these servers contains the same data + and it should not matter to which one the client talks to. If they serve + different data, they should be placed in different multihost domains. + +Topology Marker +=============== + +Pytest-mh implements a new marker ``@pytest.mark.topology`` which is converted +into an instance of :class:`~pytest_mh.TopologyMark`. This marker is used to +assign a topology to a test. One test can be associated with multiple topologies +-- this is called :ref:`topology parametrization `. In +this case, the test is multiplied and run ones for all assigned topologies, +therefore it is possible to re-use the test code for different setups/backends. + +The ``@pytest.mark.topology`` can take different types of arguments in order to +instantiate the marker: + +* individual arguments, see :ref:`adhoc_topology` +* single instance or list of :class:`~pytest_mh.TopologyMark`, see :ref:`predefined_topology` +* values from :class:`~pytest_mh.KnownTopologyBase` or + :class:`~pytest_mh.KnownTopologyGroupBase` enums (**recommended**), see + :ref:`known_topology` + +.. _adhoc_topology: + +Ad-hoc Topologies +----------------- + +If you plan to use the topology only once, you can define it directly on the +test inside ``@pytest.mark.topology``. The arguments are the same as the +arguments of the :class:`~pytest_mh.TopologyMark` constructor. + +.. code-block:: python + :caption: Example ad-hoc topology + + @pytest.mark.topology( + "client-server", + Topology(TopologyDomain("myproject", client=1, server=1)), + controller=TopologyController(), + fixtures=dict(client='myproject.client[0]', server='myproject.server[0]') + ) + def test_example(client: ClientRole, server: ServerRole): + assert True + +In this example, the first argument ``client-server`` is the topology name that +will be visible in the logs and pytest output. The second argument is the +definition of the topology, see :class:`~pytest_mh.Topology` and +:class:`~pytest_mh.TopologyDomain`. These are the only positional arguments, +everything else must be set as a keyword argument. + +The built-in :class:`~pytest_mh.TopologyMark` supports ``controller`` (defaults +to an instance of :class:`~pytest_mh.TopologyController`) and ``fixtures`` which +defines mapping between hosts and pytest fixtures available to the test. + +The ``fixtures`` argument is a dictionary where key is the fixture name and +value is the path to the hosts in the form: ``$domain-id.$role[$index]``. This +will point to a role object of a specific host from the configuration file. It +is also possible to reference a group of hosts by omitting the index: +``$domain-id.$role``. Each path can be set multiple times, which can be useful +for :ref:`topology_parametrization`. + +.. code-block:: python + :caption: Example ad-hoc topology that references a group of hosts + + @pytest.mark.topology( + "client-two-servers", + Topology(TopologyDomain("myproject", client=1, server=2)), + controller=TopologyController(), + fixtures=dict(client='myproject.client[0]', servers='myproject.server') + ) + def test_example(client: ClientRole, servers: list[ServerRole]): + assert True + +.. note:: + + Using ad-hoc topologies is not generally recommended. You should always + prefer to use :ref:`predefined_topology` or :ref:`known_topology`, since it + makes the code more readable and the topology can be easily reused once you + need it. + +.. _predefined_topology: + +Pre-defined Topologies +---------------------- + +Pre-defined topologies can be safely reused by other tests. You can create a +pre-defined topology by instantiating a :class:`~pytest_mh.TopologyMark` class +and assigning it to a variable. + +.. code-block:: python + :caption: Example pre-defined topology + + CLIENT_SERVER = TopologyMark( + "client-server", + Topology(TopologyDomain("myproject", client=1, server=1)), + controller=TopologyController(), + fixtures=dict(client='myproject.client[0]', server='myproject.server[0]') + ) + """Topology: 1 client, 1 server""" + + + @pytest.mark.topology(CLIENT_SERVER) + def test_example_1(client: ClientRole, server: ServerRole): + assert True + + + @pytest.mark.topology(CLIENT_SERVER) + def test_example_2(client: ClientRole, server: ServerRole): + assert True + +.. seealso:: + + See :ref:`adhoc_topology` for description of + :class:`~pytest_mh.TopologyMark` arguments. + +.. _known_topology: + +KnownTopology and KnownTopologyGroup +------------------------------------ + +This is kind of pre-defined topology, that groups multiple topologies in a +single :class:`~enum.Enum` class. This makes it little bit easier to use then +ungrouped :ref:`pre-defined topologies `, since you only +have to import one object to your test module and you get access to all +topologies -- you do not have to import each topology separately. + +This is done by extending :class:`~pytest_mh.KnownTopologyBase` to define your +project's topologies and :class:`~pytest_mh.KnownTopologyGroupBase` to define +list of topologies for :ref:`topology parametrization +`. + +.. code-blocK:: python + :caption: Example of KnownTopology + + @final + @unique + class KnownTopology(KnownTopologyBase): + CLIENT_SERVER = TopologyMark( + "client-server", + Topology(TopologyDomain("myproject", client=1, server=1)), + controller=TopologyController(), + fixtures=dict(client='myproject.client[0]', server='myproject.server[0]', servers='myproject.server') + ) + + CLIENT_TWO_SERVERS = TopologyMark( + "client-two-servers", + Topology(TopologyDomain("myproject", client=1, server=2)), + controller=TopologyController(), + fixtures=dict(client='myproject.client[0]', servers='myproject.server') + ) + + + @pytest.mark.topology(KnownTopology.CLIENT_SERVER) + def test_example_1(client: ClientRole, server: ServerRole): + pass + + @pytest.mark.topology(KnownTopology.CLIENT_TWO_SERVERS) + def test_example_2(client: ClientRole, servers: list[ServerRole]): + pass + +.. code-blocK:: python + :caption: Example of KnownTopologyGroup + + @final + @unique + class KnownTopologyGroup(KnownTopologyGroupBase): + All = [ + KnownTopology.CLIENT_SERVER, + KnownTopology.CLIENT_TWO_SERVERS, + ] + + + # this test will run for both CLIENT_SERVER and CLIENT_TWO_SERVERS + @pytest.mark.topology(KnownTopologyGroup.All) + def test_example(client: ClientRole, servers: list[ServerRole]): + pass + +.. note:: + + Notice, that in order to allow topology parametrization, we added + ``servers='myproject.server'`` to ``CLIENT_SERVER`` topology as well. This + is explained in more detail in :ref:`topology_parametrization`. + +Extending Topology Marker +------------------------- + +The topology marker can be extended in order to provide more parameters or +additional functionality. In order to do this, subclass +:class:`~pytest_mh.TopologyMark` and override +:meth:`~pytest_mh.TopologyMark.CreateFromArgs` and +:meth:`~pytest_mh.TopologyMark.export`. + +.. code-block:: python + :caption: Example of custom topology marker that adds new parameter + :emphasize-lines: 13,17,22,38 + :linenos: + + class MyProjectTopologyMark(TopologyMark): + """ + Add ``new_param`` parameter to the built-in topology marker. + """ + + def __init__( + self, + name: str, + topology: Topology, + *, + controller: TopologyController | None = None, + fixtures: dict[str, str] | None = None, + new_param: str | None = None, + ) -> None: + super().__init__(name, topology, controller=controller, fixtures=fixtures) + + self.new_param: str | None = new_param + """New parameter for my project.""" + + def export(self) -> dict: + d = super().export() + d["new_param"] = self.new_param + + return d + + @classmethod + def CreateFromArgs(cls, item: pytest.Function, args: Tuple, kwargs: Mapping[str, Any]) -> Self: + # First three parameters are positional, the rest are keyword arguments. + if len(args) != 2 and len(args) != 3: + nodeid = item.parent.nodeid if item.parent is not None else "" + error = f"{nodeid}::{item.originalname}: invalid arguments for @pytest.mark.topology" + raise ValueError(error) + + name = args[0] + topology = args[1] + controller = kwargs.get("controller", None) + fixtures = {k: str(v) for k, v in kwargs.get("fixtures", {}).items()} + new_param = kwargs.get("new_param", None) + + return cls(name, topology, controller=controller, fixtures=fixtures, new_param=new_param) + +Then make this a topology marker type by setting +:attr:`~pytest_mh.MultihostConfig.TopologyMarkClass` in your +:class:`~pytest_mh.MultihostConfig` class. + +.. code-block:: python + :emphasize-lines: 3,5 + :linenos: + + class MyProjectConfig(MultihostConfig): + @property + def TopologyMarkClass(self) -> Type[TopologyMark]: + # Set a custom topology marker type + return MyProjectTopologyMark + +Topology Controller +=================== + +Pytest-mh allows you to run tests against multiple topologies in one pytest run. +It is not always possible or desired to provide distinct set of host for each +topology, instead the hosts are usually being reused. However, each topology +typically requires different environment setup. +:class:`~pytest_mh.TopologyController` gives you access to topology setup and +teardown as well as the possibility to skip all tests for given topology if the +environment is not fully setup to run it. + +With the topology controller, you can: + +* setup hosts before any test for this topology is run (see: :meth:`~pytest_mh.TopologyController.topology_setup`) +* teardown hosts after all tests for this topology are finished (see: :meth:`~pytest_mh.TopologyController.topology_teardown`) +* setup hosts before each test that utilizes this topology (see: :meth:`~pytest_mh.TopologyController.setup`) +* teardown hosts after each test that utilizes this topology (see: :meth:`~pytest_mh.TopologyController.teardown`) +* skip all test for this topology if certain condition is not met (see: :meth:`~pytest_mh.TopologyController.skip`) +* set topology specific artifacts (see: :meth:`~pytest_mh.TopologyController.set_artifacts`) + +.. code-block:: python + :caption: Example topology controller + + class LDAPClientFeatureController(TopologyController[MyProjectConfig]): + """ + - skip all tests for this topology if the client does not support LDAP connections + - configure the client to use LDAP connections on topology setup + - revert configuration on topology teardown + - fetch logs from the configuration change + """ + + def set_artifacts(self, client: ClientHost) -> None: + self.artifacts.topology_setup[client] = {"/var/log/enable_ldap.log"} + self.artifacts.topology_teardown[client] = {"/var/log/disable_ldap.log"} + + def skip(self, client: ClientHost) -> str | None: + result = client.conn.run('is ldap feature enabled', raise_on_error=False) + if result.rc != 0: + return "LDAP feature is not supported on client" + + return None + + def topology_setup(self, client: ClientHost): + client.conn.run('enable LDAP on client > /var/log/enable_ldap.log') + + def topology_teardown(self, client: ClientHost): + client.conn.run('disable LDAP on client > /var/log/disable_ldap.log') + +.. seealso:: + + * Documentation for :class:`~pytest_mh.TopologyController` + * :doc:`../life-cycle/artifacts-collection` + * :doc:`../life-cycle/setup-and-teardown` + +.. warning:: + + When extending the :class:`~pytest_mh.TopologyController`, keep in mind that + it is instantiated early in the plugin life but actually initialized much + later. Therefore most attributes can not be accessed from the constructor. + + For this reason, it is recommended to only declare properties in the + constructor but place your initialization call in + :meth:`~pytest_mh.TopologyController.init`. Do not forget to call + ``super().init(*args, **kwargs)`` as the first step. + + .. code-block:: python + + class MyProjectTopologyController(TopologyController[MyProjectMultihostConfig]): + def __init__(self) -> None: + super().__init__() + + self.my_project_param: bool = False + + def _init(self, *args, **kwargs): + super().init(*args, **kwargs) + self.my_project_param = self.multihost.my_project_param + +.. seealso:: + + The topology controller can also be used to implement automatic setup, + backup and restore of the topology environment. See + :doc:`../tips-and-tricks/backup-restore` for tips on how to achieve that + with :class:`~pytest_mh.BackupTopologyController`. + +.. _topology_parametrization: + +Topology Parametrization +======================== + +A test parametrization is a way to share a test code for different input +arguments and therefore test different configurations or user inputs easily and +thus quickly extend the code coverage. Pytest allows this by using the +``@pytest.mark.parametrize`` `mark `_. + +Similar functionality can be achieved with topologies when the same test code is +run against multiple topologies. This is useful for many situations, since it is +often desirable to test the same functionality with different configurations +that however also requires different environment setup (different multihost +topology). For example: + +* A client application is able to connect to multiple different backends. This + is the case of SSSD, that implements a system interface for retrieving user + information but is able to fetch the data from various LDAP-like sources: + LDAP, Active Directory, FreeIPA and SambaDC. + +* Another example would be an application that uses some SQL database but allows + to use different servers such as MariaDB or PostreSQL. + +* Or for instance, there is a DNS client library that supports plain-text DNS + queries but also encryption over TLS, HTTPS and QUIC. It is possible to have + one test for hostname resolution but let the client library use all transfer + protocols, one by one. + +In each case, it is desirable to have only single test that is however run with +different backends or server configurations. To provide a real world example, we +can check out one of the basic SSSD tests. This test have multiple topologies +assigned and it is run once per each topology: LDAP, IPA, Samba and AD. + +.. tab-set:: + + .. tab-item:: With topology parametrization + + .. code-block:: python + :caption: Only single test is required with topology parametrization + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_id__supplementary_groups(client: Client, provider: GenericProvider): + u = provider.user("tuser").add() + provider.group("tgroup_1").add().add_member(u) + provider.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + + .. tab-item:: Without topology parametrization + + .. code-block:: python + :caption: Four tests are required without topology parametrization + + @pytest.mark.topology(KnownTopology.LDAP) + def test_id_ldap__supplementary_groups(client: Client, ldap: LDAP): + u = ldap.user("tuser").add() + ldap.group("tgroup_1").add().add_member(u) + ldap.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + + + @pytest.mark.topology(KnownTopology.IPA) + def test_id_ipa__supplementary_groups(client: Client, ipa: IPA): + u = ipa.user("tuser").add() + ipa.group("tgroup_1").add().add_member(u) + ipa.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + + + @pytest.mark.topology(KnownTopology.AD) + def test_id_ad__supplementary_groups(client: Client, ad: AD): + u = ad.user("tuser").add() + ad.group("tgroup_1").add().add_member(u) + ad.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + + + @pytest.mark.topology(KnownTopology.Samba) + def test_id_samba__supplementary_groups(client: Client, samba: Samba): + u = samba.user("tuser").add() + samba.group("tgroup_1").add().add_member(u) + samba.group("tgroup_2").add().add_member(u) + + client.sssd.start() + result = client.tools.id("tuser") + + assert result is not None + assert result.user.name == "tuser" + assert result.memberof(["tgroup_1", "tgroup_2"]) + +.. seealso:: + + See the `sssd-test-framework sources `_ to see how + the ``AnyProvider`` topology group is defined. + +The ``KnownTopologyGroup.AnyProvider`` is a list of LDAP, IPA, Samba and AD +topologies, therefore the test is run for each topology from this list, four +times in total. The topology group makes it easy to parametrize tests when this +group is used quite often. However, it is also possible to use the topology +marker multiple times, therefore we can achieve the same with: + +.. code-block:: python + + @pytest.mark.topology(KnownTopology.AD) + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.topology(KnownTopology.IPA) + @pytest.mark.topology(KnownTopology.Samba) + def test_id__supplementary_groups(client: Client, provider: GenericProvider): + +Notice, that individual tests when not using topology parametrization are +accessing the backend role via specific types: ``LDAP``, ``IPA``, ``AD`` and +``Samba`` as well as specific fixture names ``ldap``, ``ipa``, ``ad`` and +``samba``. This is not possible with topology parametrization since it is +required to use a generic interface that will work for all topologies used by +the test. Therefore the SSSD's topologies defines the ``provider`` fixture and a +generic type ``GenericProvider`` that is implemented by the individual backends. + +.. code-block:: python + :caption: Snippet from sssd-test-framework showing the topologies + :emphasize-lines: 7, 17, 27, 37 + :linenos: + + LDAP = SSSDTopologyMark( + name="ldap", + topology=Topology(TopologyDomain("sssd", client=1, ldap=1, nfs=1, kdc=1)), + controller=LDAPTopologyController(), + domains=dict(test="sssd.ldap[0]"), + fixtures=dict( + client="sssd.client[0]", ldap="sssd.ldap[0]", provider="sssd.ldap[0]", nfs="sssd.nfs[0]", kdc="sssd.kdc[0]" + ), + ) + + IPA = SSSDTopologyMark( + name="ipa", + topology=Topology(TopologyDomain("sssd", client=1, ipa=1, nfs=1)), + controller=IPATopologyController(), + domains=dict(test="sssd.ipa[0]"), + fixtures=dict( + client="sssd.client[0]", ipa="sssd.ipa[0]", provider="sssd.ipa[0]", nfs="sssd.nfs[0]" + ), + ) + + AD = SSSDTopologyMark( + name="ad", + topology=Topology(TopologyDomain("sssd", client=1, ad=1, nfs=1)), + controller=ADTopologyController(), + domains=dict(test="sssd.ad[0]"), + fixtures=dict( + client="sssd.client[0]", ad="sssd.ad[0]", provider="sssd.ad[0]", nfs="sssd.nfs[0]" + ), + ) + + Samba = SSSDTopologyMark( + name="samba", + topology=Topology(TopologyDomain("sssd", client=1, samba=1, nfs=1)), + controller=SambaTopologyController(), + domains={"test": "sssd.samba[0]"}, + fixtures=dict( + client="sssd.client[0]", samba="sssd.samba[0]", provider="sssd.samba[0]", nfs="sssd.nfs[0]" + ), + ) + +.. note:: + + Notice that SSSD is using custom topology marker ``SSSDTopologyMark`` that + adds a custom ``domains`` property. You can see its definition `here `_. + +If we run the test, we can see that it is executed four times: + +.. code-block:: console + + $ pytest --mh-config=mhc.yaml -k test_id -v + ... + tests/test_id.py::test_id__supplementary_groups (samba) PASSED [ 12%] + tests/test_id.py::test_id__supplementary_groups (ad) PASSED [ 25%] + tests/test_id.py::test_id__supplementary_groups (ipa) PASSED [ 37%] + tests/test_id.py::test_id__supplementary_groups (ldap) PASSED + +.. note:: + + It is also possible to combine topology parametrization with + ``@pytest.mark.parametrize``. + + .. code-block:: python + + @pytest.mark.parametrize("value", [1, 2]) + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_example(client: Client, provider: GenericProvider, value: int): + pass + +.. _pytest-parametrize: https://docs.pytest.org/en/latest/how-to/parametrize.html#pytest-mark-parametrize-parametrizing-test-functions +.. _sssd: https://sssd.io +.. _sssd_framework_topology: https://github.com/SSSD/sssd-test-framework/blob/0b213ff8fca10a5de55f34f7f2bc94cdba4a3487/sssd_test_framework/topology.py#L138 +.. _sssd_framework_mark: https://github.com/SSSD/sssd-test-framework/blob/0b213ff8fca10a5de55f34f7f2bc94cdba4a3487/sssd_test_framework/config.py#L22 diff --git a/docs/articles/extending/multihost-utilities.rst b/docs/articles/extending/multihost-utilities.rst new file mode 100644 index 0000000..df308e9 --- /dev/null +++ b/docs/articles/extending/multihost-utilities.rst @@ -0,0 +1,369 @@ +Multihost Utilities +################### + +:class:`~pytest_mh.MultihostUtility` can be used to share code between different +:class:`~pytest_mh.MultihostRole` classes, in addition +:class:`~pytest_mh.MultihostReentrantUtility` can be used to share code beetween +roles but also between :class:`~pytest_mh.MultihostHost` classes. + +.. seealso:: + + Pytest-mh already provides several general-purpose utility classes that are + ready to use in order to test your project. See :doc:`../bundled-utilities` + for more information. + +MultihostUtility +================ + +All instances of :class:`~pytest_mh.MultihostUtility` that are available within +:class:`~pytest_mh.MultihostRole` classes are automatically setup and teardown +before and after the test. This can be used to provide high-level API that also +cleans up after itself and share this code between multiple roles. + +.. code-block:: python + :caption: Example utility to manage local users + :linenos: + + from typing import Self + + from pytest_mh import MultihostHost, MultihostUtility + from pytest_mh.cli import CLIBuilder, CLIBuilderArgs + from pytest_mh.conn import ProcessLogLevel + from pytest_mh.utils.fs import LinuxFileSystem + + + class LocalUsersUtils(MultihostUtility[MultihostHost]): + """ + Management of local users. + + .. note:: + + All changes are automatically reverted when a test is finished. + """ + + def __init__(self, host: MultihostHost, fs: LinuxFileSystem) -> None: + """ + :param host: Remote host instance. + :type host: MultihostHost + """ + super().__init__(host) + + self.cli: CLIBuilder = host.cli + """ + CLI builder helper. + """ + + self.fs: LinuxFileSystem = fs + """ + File system manipulation. + """ + + self._users: list[str] = [] + """ + List of local users that were created during the test. + """ + + def teardown(self) -> None: + """ + Delete any added user and group. + """ + cmd = "" + + if self._users: + cmd = "\n".join([f"userdel '{x}' --force --remove" for x in self._users]) + "\n" + self.host.conn.run("set -e\n\n" + cmd) + + super().teardown() + + def add_local_user( + self, + *, + name: str, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new local user. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + if home is not None: + self.fs.backup(home) + + args: CLIBuilderArgs = { + "name": (self.cli.option.POSITIONAL, name), + "uid": (self.cli.option.VALUE, uid), + "gid": (self.cli.option.VALUE, gid), + "home": (self.cli.option.VALUE, home), + "gecos": (self.cli.option.VALUE, gecos), + "shell": (self.cli.option.VALUE, shell), + } + + passwd = f" && passwd --stdin '{name}'" if password else "" + self.logger.info(f'Creating local user "{name}" on {self.host.hostname}') + self.host.conn.run(self.cli.command("useradd", args) + passwd, input=password, log_level=ProcessLogLevel.Error) + + self._users.append(name) + + return self + +.. note:: + + Before a test is run, the hosts are setup multiple times at different scopes + and later teardown in the same order (see + :doc:`../life-cycle/setup-and-teardown`). For this reason, it is not + possible to use :class:`~pytest_mh.MultihostUtility` objects in + :class:`~pytest_mh.MultihostHost` because it can not guarantee that its + :meth:`~pytest_mh.MultihostUtility.setup` and + :meth:`~pytest_mh.MultihostUtility.teardown` methods are called at proper + places. + + In theory, it is possible, if you know what you are doing and call setup and + teardown manually at desired place. However, it is not possible to call + these methods multiple times and so you can only use it within a single + setup scope. It is therefore highly recommended to only use + :class:`~pytest_mh.MultihostReentrantUtility` in host objects. + +MultihostReentrantUtility +========================= + +:class:`~pytest_mh.MultihostReentrantUtility` are designed to work with multiple +setup scopes. Therefore, if you change something during topology setup, it is +reverted in topology teardown and so on. It is also possible to create different +setup scopes inside a test by using a context manager or the ``with`` statement. + +In order to achieve this, they gain context management magic methods +:meth:`~pytest_mh.MultihostReentrantUtility.__enter__` and +:meth:`~pytest_mh.MultihostReentrantUtility.__exit__`. The reentrant utilities +are setup once and then the enter is called every time a new scope is entered. + +.. code-block:: python + :caption: Reentrant version of user management + :emphasize-lines: 10,36-39,46-56,58-66 + :linenos: + + from collections import deque + from typing import Self + + from pytest_mh import MultihostHost, MultihostReentrantUtility + from pytest_mh.cli import CLIBuilder, CLIBuilderArgs + from pytest_mh.conn import ProcessLogLevel + from pytest_mh.utils.fs import LinuxFileSystem + + + class LocalUsersUtils(MultihostReentrantUtility[MultihostHost]): + """ + Management of local users. + + .. note:: + + All changes are automatically reverted when a test is finished. + """ + + def __init__(self, host: MultihostHost, fs: LinuxFileSystem) -> None: + """ + :param host: Remote host instance. + :type host: MultihostHost + """ + super().__init__(host) + + self.cli: CLIBuilder = host.cli + """ + CLI builder helper. + """ + + self.fs: LinuxFileSystem = fs + """ + File system manipulation. + """ + + self._states: deque[list[str]] = deque() + """ + Stored state for each setup scope. + """ + + self._users: list[str] = [] + """ + List of local users that were created during the test. + """ + + def __enter__(self) -> Self: + """ + Save current state. + + :return: Self. + :rtype: Self + """ + self._states.append(self._users) + self._users = [] + + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """ + Revert all changes done during current context. + """ + if self._users: + cmd = "\n".join([f"userdel '{x}' --force --remove" for x in self._users]) + "\n" + self.host.conn.run("set -e\n\n" + cmd) + + self._users = self._states.pop() + + def add_local_user( + self, + *, + name: str, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new local user. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + if home is not None: + self.fs.backup(home) + + args: CLIBuilderArgs = { + "name": (self.cli.option.POSITIONAL, name), + "uid": (self.cli.option.VALUE, uid), + "gid": (self.cli.option.VALUE, gid), + "home": (self.cli.option.VALUE, home), + "gecos": (self.cli.option.VALUE, gecos), + "shell": (self.cli.option.VALUE, shell), + } + + passwd = f" && passwd --stdin '{name}'" if password else "" + self.logger.info(f'Creating local user "{name}" on {self.host.hostname}') + self.host.conn.run(self.cli.command("useradd", args) + passwd, input=password, log_level=ProcessLogLevel.Error) + + self._users.append(name) + + return self + +Creating more setup-scopes in tests +----------------------------------- + +It is possible to enter the reentrant utilities multiple times in tests as well +using the ``with`` statement. + +.. code-block:: python + :caption: Reentrant utility in tests + :emphasize-lines: 3,5,7 + :linenos: + + @pytest.mark.topology(...) + def test_ad_hoc_util(example: ExampleRole) -> None: + with example.fs as fs_a: + fs_a.write("/root/test", "content_a") + with fs_a as fs_b: + fs_b.write("/root/test", "content_b") + with fs_b as fs_c: + fs_c.write("/root/test", "content_c") + assert fs_b.read("/root/test") == "content_b" + assert fs_a.read("/root/test") == "content_a" + +Postponing utility setup +======================== + +Some utilities may require a complex setup method that consumes some time, but +at the same time these utilities can be used in your tests only sporadically, +therefore it does not make sense to run the setup for tests that do not actually +use it. For this purpose, it is possible to postpone setup of the utility to a +place when it is used for the first time. + +It is possible to mark the utility with a decorator +:meth:`~pytest_mh.mh_utility_postpone_setup` or run +:meth:`MultihostUtility.postpone_setup +` when it is instantiated. Either +way, the result is the same but calling the method gives you more control if you +want to see different behavior in different roles or hosts. + +.. grid:: 1 + + .. grid-item-card:: Examples of postpone utility + + .. tab-set:: + + .. tab-item:: @mh_utility_postpone_setup decorator + + .. code-block:: python + :emphasize-lines: 3 + :linenos: + + from pytest_mh import mh_utility_postpone_setup + + @mh_utility_postpone_setup + class ExampleUtility(MultihostUtility): + def setup(self): + pass + + def teardown(self): + pass + + .. tab-item:: postpone_setup() method + + .. code-block:: python + :emphasize-lines: 5 + :linenos: + + class MyRole(MultihostRole): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.firewall: Firewalld = Firewalld(self.host).postpone_setup() + +Creating ad-hoc utilities +========================= + +Sometimes, the utility is used so rarely that it does not make sense to include +it in the role object at all. At such time, it is possible to create it directly +in the test. The setup and teardown, enter and exit methods are called +automatically. + +.. code-block:: python + :caption: Ad-hoc utility usage + :emphasize-lines: 5 + :linenos: + + from pytest_mh.utils.fs import LinuxFileSystem + + @pytest.mark.topology(...) + def test_ad_hoc_util(example: ExampleRole) -> None: + with mh_utility(LinuxFileSystem(role.host)) as fs: + fs.write("/root/test", "content") + assert fs.read("/root/test") == "content" diff --git a/docs/articles/get-started.rst b/docs/articles/get-started.rst new file mode 100644 index 0000000..e40d7c5 --- /dev/null +++ b/docs/articles/get-started.rst @@ -0,0 +1,433 @@ +Getting Started +############### + +Pytest-mh is not a plugin to support unit testing. It is a plugin designed to +support testing your application as a complete product, this is often referred +to as a black-box, application or system testing. The application is installed +on the target host and tested there by running commands on the host (and on +other hosts that are required). These hosts can be virtual machines or +containers. + +.. seealso:: + + SSH, podman and docker may be used to execute commands on the remote hosts. See + :doc:`running-commands` for more information. + +As such, it is often useful to write a high level API that will support testing +your application and make your test smaller and more readable. This may require +a non-trivial initial investment, but it will pay off in the long run. However, +implementing such API is not required and it is perfectly possible to run +commands on the host directly from each test. That, however, makes tests usually +larger and more difficult to understand and maintain -- but every project is +different and it is up to you to choose how are you going to test your +application. + +.. seealso:: + + Pytest-mh provides several building blocks to help you design your test + framework. See :doc:`extending`. **It would be good to open this document + and read it side by side.** + +We will use `sudo `__ as the application to test for this +getting started guide as sudo is known by every power user so changes are that +you are already familiar with it. Additionally, sudo tests allows us to show +many pytest-mh features. Note, that these tests were written only as an example +and sudo itself is not using pytest-mh for its tests at this moment and there +are no plans to do so. + +.. seealso:: + + The example code can be found in the `example + `__ folder of + the git repository. + +Example project: sudo +===================== + +`Sudo `__ is a widely known tool that can elevate current +user's privileges by running command as different user -- usually ``root``. It +is possible to write a set of rules to define which user can run which command +and these rules can be stored either locally in ``/etc/sudoers`` or in `LDAP +`__ +database. + +Our goals are: + +* write basic tests + + * allow user run all commands, user must authenticate + * allow user run all commands, without authentication + * allow user run all commands if he or she is a member of a group, user must authenticate + * allow user run all commands if he or she is a member of a group, without authentication + +* these tests must be written for all possible sources of data + + * file: ``/etc/sudoers`` + * LDAP pulled directly by sudo + * LDAP pulled by SSSD + +* write a simple test framework that will help us to extend the tests easily +* every change must be reverted after each test + +As you can see, these goals require us to write 12 tests in total. But since the +result is the same and only the data is fetched from different sources, we can +use :ref:`topology parametrization `. Topology +parametrization will allow us to write only for tests but run them against +different backends and thus we will do less work but get more code coverage. + +We will take the following steps to achieve it: + +#. :ref:`get_started_structure` +#. :ref:`get_started_topologies` +#. :ref:`get_started_config_file` +#. :ref:`get_started_config_domain` +#. :ref:`get_started_framework` +#. :ref:`get_started_enable` +#. :ref:`get_started_write_tests` +#. :ref:`get_started_run_tests` + +.. _get_started_structure: + +Prepare a file structure +------------------------ + +The following snippet shows a recommended file structure for your test utilizing +pytest-mh. Look at :doc:`extending` to get more information about the meaning of +individual classes. + +.. code-block:: text + + . + ├── framework/ # Test framework, high-level API + │   ├── hosts/ # Subclasses of MultihostHost + │   │   └── __init__.py + │   ├── roles/ # Subclasses of MultihostRole + │   │   └── __init__.py + │   ├── utils/ # Subclasses of MultihostUtility + │   │   └── __init__.py + │   ├── __init__.py + │   ├── config.py # Definition of MultihostConfig, MultihostDomain + │   ├── topology_controllers.py # Custom topology controllers + │   └── topology.py # Definition of multihost topologies + | + ├── tests/ # Tests + | + ├── conftest.py # Pytest conftest.py + ├── pytest.ini # Pytest configuration file + ├── py.typed # Declare that this project uses type hints + | + ├── mhc.yaml # Pytest-mh configuration file + | + ├── readme.md # Tests readme + └── requirements.txt # Tests requirements + +.. _get_started_topologies: + +Define multihost topologies +--------------------------- + +This is the first step when designing a test framework since it tells you what +hosts and roles your project needs. For sudo, we want to that that the sudo +rules can be fetched from different sources. We can consider each data source to +be a single topology. + +* **sudoers** + + * we only need one host + * users, groups and sudo rules will be created locally + +* **ldap** + + * we need a host where we will run sudo and a host that runs an LDAP server + * users, groups and sudo rules will be added to the LDAP database + * sudo will read data from LDAP + +* **sssd** + + * we need a host where we will run sudo and SSSD and a host that runs an LDAP server + * SSSD will be connected to the LDAP domain + * users, groups and sudo rules will be added to the LDAP database + * sudo will read data from SSSD which reads it from LDAP + +These are the three topologies that we will define. We will also define a +topology group as a shortcut for :ref:`topology parametrization +`. + +.. dropdown:: See the code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: ./framework/topology.py + + .. literalinclude:: ../../example/framework/topology.py + :language: python + +.. _get_started_config_file: + +Write configuration file +------------------------ + +The topologies defines which hosts and roles are needed to run sudo test. We can +convert it into a configuration file that can be used to run all sudo tests. + +The configuration file will define one domain with two hosts - one ``client`` +which will run sudo and SSSD and one ``ldap`` which will run the LDAP server. + +.. seealso:: + + The full format of the configuration file can be found at :doc:`mhc-yaml`. + +.. dropdown:: See the code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: ./mhc.yml + + .. literalinclude:: ../../example/mhc.yaml + :language: yaml + +.. _get_started_config_domain: + +Define :class:`~pytest_mh.MultihostConfig` and :class:`~pytest_mh.MultihostDomain` +---------------------------------------------------------------------------------- + +These two classes are required to correctly map the configuration file into your +Python code. Look for more information at :doc:`extending/multihost-config` and +:doc:`extending/multihost-domains`. It is possible to extend these classes in +order to add custom configuration options, use different topology mark and so +on. In this example, they only provide the mapping from configuration file to +Python classes. + +.. dropdown:: See the code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: ./framework/config.py + + .. literalinclude:: ../../example/framework/config.py + :language: python + +.. _get_started_framework: + +Design and implement the framework +---------------------------------- + +This part is rather more complicated and can not be treated universally as every +project have different needs. It is possible to use multiple building block +provided by pytest-mh in order to built a high-level API for your tests, see +:doc:`extending` and :doc:`life-cycle` to get a good grasp of all the classes +and how to use them. + +For the sudo tests, we have implemented several hosts, roles and utility classes +and one topology controller for each topology. The following table describes the +main idea behind each of these classes. + +.. dropdown:: See the table + :color: primary + :icon: code + + .. list-table:: + :header-rows: 1 + + * - Class name/Subclass of + - Description + + * - | ``ClientHost`` + | :class:`~pytest_mh.MultihostBackupHost` + - * Implements backup and restore methods for the client. + + * - | ``LDAPHost`` + | :class:`~pytest_mh.MultihostBackupHost` + - * Implements backup and restore methods for the LDAP server. + * Opens and maintains connection to the LDAP server using + python-ldap library. + + * - | ``SudoersTopologyController`` + | :class:`~pytest_mh.BackupTopologyController` + - * Configures environment for the sudoers topology + * Sets expected content of ``/etc/nsswitch.conf`` + * Creates backup of this setup and automatically restores to this + state when a test is finished + + * - | ``LDAPTopologyController`` + | :class:`~pytest_mh.BackupTopologyController` + - * Configures environment for the LDAP topology + * Sets expected content of ``/etc/nsswitch.conf`` + * Configures SSSD for identity and authentication + * Configures ``/etc/ldap.conf`` that is read by sudo + * Creates backup of this setup and automatically restores to this + state when a test is finished + + * - | ``SSSDTopologyController`` + | :class:`~pytest_mh.BackupTopologyController` + - * Configures environment for the SSSD topology + * Sets expected content of ``/etc/nsswitch.conf`` + * Configures SSSD for identity, authentication and sudo rules + * Creates backup of this setup and automatically restores to this + state when a test is finished + + * - | ``Client`` + | :class:`~pytest_mh.MultihostRole` + - * Implements ``GenericProvider`` which defines interface for + managing users, groups and sudoers. + * The implementation uses local files to store the content. + + * - | ``LDAP`` + | :class:`~pytest_mh.MultihostRole` + - * Implements ``GenericProvider`` which defines interface for + managing users, groups and sudoers. + * The implementation uses LDAP to store the content. + + * - | ``LocalUsersUtils`` + | :class:`~pytest_mh.MultihostUtility` + - * Provides shareable implementation of local users and groups + management. + * Every user and group added during testing is automatically + removed. + + * - | ``SUDOUtils`` + | :class:`~pytest_mh.MultihostUtility` + - * Implements methods to execute sudo and assert the result + +.. seealso:: + + Look at the `example code + `__ to see + how this was implemented. + +.. _get_started_enable: + +Enable pytest-mh in conftest.py +------------------------------- + +When the test framework is written and read to use, we can tell pytest to start +using it in our tests. First, we tell pytest to load pytest-mh plugin and then +we tell pytest-mh which config class it should instantiate. + +.. dropdown:: See the code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: ./conftest.py + + .. literalinclude:: ../../example/conftest.py + :language: python + +.. _get_started_write_tests: + +Write the tests +=============== + +The example code shows four tests in total, but 12 tests are executed when +pytest is run because each test is run once per each topology against different +data source. See :doc:`writing-tests` to get more information on how to write +the tests. + + * allow user run all commands, user must authenticate + * allow user run all commands, without authentication + * allow user run all commands if he or she is a member of a group, user must authenticate + * allow user run all commands if he or she is a member of a group, without authentication + +.. dropdown:: See the code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: ./tests/test_user.py + + .. literalinclude:: ../../example/tests/test_user.py + :language: python + + .. tab-item:: ./tests/test_group.py + + .. literalinclude:: ../../example/tests/test_group.py + :language: python + +.. _get_started_run_tests: + +Run the tests +============= + +The example code provides a set of containers that can be started up and used as +hosts for the tests. See the example `readme.md +`__ to +get the instruction on how to start the containers and install requirements. + +When the containers or virtual machines are ready, it is possible to run the +tests with ``pytest`` command that you are already familiar with. The only +additional thing needed to run pytest-mh tests is to provide path to the +pytest-mh configuration file with ``--mh-config``. + +.. code-block:: text + + $ pytest --color=yes --mh-config=./mhc.yaml -vvv + + Multihost configuration: + domains: + - id: sudo + hosts: + - hostname: master.ldap.test + conn: + type: ssh + host: 172.16.200.3 + role: ldap + - hostname: client.test + conn: + type: ssh + host: 172.16.200.4 + role: client + artifacts: + - /var/log/sssd + + Detected topology: + - id: sudo + hosts: + ldap: 1 + client: 1 + + Additional settings: + config file: ./example/mhc.yaml + log path: None + lazy ssh: False + topology filter: + require exact topology: False + collect artifacts: on-failure + artifacts directory: artifacts + collect logs: on-failure + + ============================= test session starts ============================== + platform linux -- Python 3.11.9, pytest-8.3.3, pluggy-1.5.0 -- /home/runner/work/pytest-mh/pytest-mh/.venv/bin/python3 + cachedir: .pytest_cache + rootdir: /home/runner/work/pytest-mh/pytest-mh/example + configfile: pytest.ini + collecting ... + + Selected tests will use the following hosts: + client: client.test + ldap: master.ldap.test + + collected 12 items + + example/tests/test_group.py::test_group__passwd (ldap) PASSED [ 8%] + example/tests/test_group.py::test_group__nopasswd (ldap) PASSED [ 16%] + example/tests/test_user.py::test_user__passwd (ldap) PASSED [ 25%] + example/tests/test_user.py::test_user__nopasswd (ldap) PASSED [ 33%] + example/tests/test_group.py::test_group__passwd (sssd) PASSED [ 41%] + example/tests/test_group.py::test_group__nopasswd (sssd) PASSED [ 50%] + example/tests/test_user.py::test_user__passwd (sssd) PASSED [ 58%] + example/tests/test_user.py::test_user__nopasswd (sssd) PASSED [ 66%] + example/tests/test_group.py::test_group__passwd (sudoers) PASSED [ 75%] + example/tests/test_group.py::test_group__nopasswd (sudoers) PASSED [ 83%] + example/tests/test_user.py::test_user__passwd (sudoers) PASSED [ 91%] + example/tests/test_user.py::test_user__nopasswd (sudoers) PASSED [100%] + + ============================= 12 passed in 24.80s ============================== diff --git a/docs/articles/life-cycle.rst b/docs/articles/life-cycle.rst new file mode 100644 index 0000000..cb6e5e4 --- /dev/null +++ b/docs/articles/life-cycle.rst @@ -0,0 +1,107 @@ +Life Cycle and Hooks +#################### + +One of the most fundamental features of pytest-mh is to provide users a way to +setup hosts before a test is run, collect test artifacts and revert all changes +that were done during the test afterwards. Therefore it provides multiple hooks +that will execute your code in order to achieve smooth and extensive setup and +teardown and more. + +.. toctree:: + :maxdepth: 2 + + life-cycle/artifacts-collection + life-cycle/setup-and-teardown + life-cycle/skipping-tests + life-cycle/changing-test-status + + +.. mermaid:: + :caption: Pytest-mh life cycle + :align: center + + graph TD + s([Start]) --> hps --> topology --> hca --> hpt --> e([End]) + + hps("`**Setup hosts** + MultihostHost.pytest_setup`") + + hca("`**Collect hosts artifacts**`") + + hpt("`**Teardown hosts** + MultihostHost.pytest_teardown`") + + subgraph topology ["`**Topology**`"] + tts --> test --> tta --> ttt + + tts("`**Setup topology** + TopologyController.topology_setup`") + + tta("`**Collect topology artifacts**`") + + ttt("`**Teardown topology** + TopologyController.topology_teardown`") + + subgraph test ["`**Test run**`"] + direction TB + + ta("`**Collect test artifacts**`") + + subgraph setup ["`**Setup before test**`"] + direction LR + ue --> hs --> ts --> rs --> us + + ue("`**Enter host utilities** + MultihostReentrantUtility.\_\_enter\_\_`") + + hs("`**Setup hosts** + MultihostHost.setup`") + + ts("`**Setup topology** + TopologyController.setup`") + + rs("`**Setup roles** + MultihostRole.setup`") + + us("`**Setup role utilities** + MultihostUtility.setup + MultihostReentrantUtility.\_\_enter\_\_`") + end + + setup --> run(("`**Run test**`")) --> ta --> teardown + + subgraph teardown ["`**Teardown after test**`"] + direction LR + ut --> rt --> tt --> ht --> uex + + uex("`**Exit host utilities** + MultihostReentrantUtility.\_\_exit\_\_`") + + ht("`**Teardown hosts** + MultihostHost.teardown`") + + tt("`**Teardown topology** + TopologyController.teardown`") + + rt("`**Teardown roles** + MultihostRole.teardown`") + + ut("`**Teardown role utilities** + MultihostUtility.teardown + MultihostReentrantUtility.\_\_exit\_\_`") + end + end + end + + classDef section fill:#fff,stroke-width:2px,stroke:#ccc + class topology,test section; + + classDef setup fill:#44d585,stroke-width:2px,stroke:#33d17a + class ue,hs,ts,rs,us setup; + class uex,ht,tt,rt,ut setup; + + classDef test_section fill:#eafaf1,stroke-width:0 + class setup,teardown test_section + + classDef test_node fill:#ff9,color:#ffffff,stroke-width:0 + class run,ta test_node; diff --git a/docs/articles/life-cycle/artifacts-collection.rst b/docs/articles/life-cycle/artifacts-collection.rst new file mode 100644 index 0000000..932a1ed --- /dev/null +++ b/docs/articles/life-cycle/artifacts-collection.rst @@ -0,0 +1,189 @@ +Artifacts Collection +#################### + +Collecting logs and other artifacts from a test is a very important task, +especially if the test fails. Most of the test frameworks allows you to collect +artifacts that are explicitly configured. Pytest-mh has this feature as well but +it also takes this a step further and allows you to collect and even produce +artifacts dynamically after a test is finished. + +This is especially useful if you do not want to rely on each test to produce +artifacts that require additional commands to be run (for example a database +dump). With pytest-mh, it is possible to implement this on a different level and +therefore each test can focus solely on testing functionality, pytest-mh will +take care of producing and collecting the extra artifacts. + +.. seealso:: + + This feature is used to capture AVC denials and coredumps in + :class:`~pytest_mh.utils.auditd.Auditd` and + :class:`~pytest_mh.utils.coredumpd.Coredumpd`. You can check out the source + code to get some examples. + + .. dropdown:: Example source code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: Auditd utility + + .. literalinclude:: ../../../pytest_mh/utils/auditd.py + :caption: Setting artifacts in __init__ + :pyobject: Auditd.__init__ + + .. tab-item:: Coredumpd utility + + .. literalinclude:: ../../../pytest_mh/utils/coredumpd.py + :caption: Dynamic artifacts in get_artifacts_list() + :pyobject: Coredumpd.get_artifacts_list + +User-defined artifacts +====================== + +The pytest-mh configuration file has a field ``artifacts`` in the host section +where is it possible to define a list of artifacts that should be automatically +downloaded from a host when a test is finished and before teardown is executed. +This list can also contain a wildcard. + +.. code-block:: yaml + :caption: User-defined artifact in mhc.yaml + + - hostname: client.test + role: client + artifacts: + - /etc/myapp/myapp.conf + - /var/lib/myapp/db/* + - /var/log/myapp/* + +Dynamic artifacts +================= + +Dynamic artifacts are not defined in the configuration file, but are defined in +the code and therefore the list of artifacts does not have to be static but can +be dynamically extended. + +Dynamic artifacts can be defined in :class:`~pytest_mh.MultihostHost`, +:class:`~pytest_mh.MultihostRole`, :class:`~pytest_mh.MultihostUtility` and +:class:`~pytest_mh.TopologyController` by adding items to the ``artifacts`` +attribute of the class. + +.. seealso:: + + The type of the ``artifacts`` attribute is slightly more complex for hosts + and topology controller since the artifacts can be collected on multiple + phases for these objects. Definition of the attribute can be found here: + + * :attr:`pytest_mh.MultihostHost.artifacts` + * :attr:`pytest_mh.MultihostRole.artifacts` + * :attr:`pytest_mh.MultihostUtility.artifacts` + * :attr:`pytest_mh.TopologyController.artifacts` + +New artifacts can also be produced when a test is finished, or the list of +artifacts can be set more dynamically based on your own conditions (e.g. +installation failed). To achieve this, it is possible to override +``get_artifacts_list()`` method of each class. This method is used by pytest-mh +to obtain the list of artifacts to collect and it must return the ``set()`` of +artifacts. + +.. seealso:: + + You can find definition of ``get_artifacts_list()`` here: + + * :meth:`pytest_mh.MultihostHost.get_artifacts_list` + * :meth:`pytest_mh.MultihostRole.get_artifacts_list` + * :meth:`pytest_mh.MultihostUtility.get_artifacts_list` + * :meth:`pytest_mh.TopologyController.get_artifacts_list` + +.. warning:: + + The default implementation of ``get_artifacts_list()`` simply returns + ``self.artifacts``. It is not mandatory to reference this attribute in any + way in your implementation, but keep in mind that then this attribute will + not have any effect. + +.. literalinclude:: ../../../pytest_mh/_private/multihost.py + :caption: get_artifacts_list() default implementation + :pyobject: MultihostRole.get_artifacts_list + +The ``get_artifacts_list()`` method takes two arguments: + +* ``host`` which is the host where the artifacts will be collected. This does + not have much meaning for hosts, roles and utilities but it is used in the + topology controller. Each topology consist from one or more hosts and + artifacts are collected from each host. +* ``artifacts_type`` identifies when artifacts are being collected. See its + definition: + + .. literalinclude:: ../../../pytest_mh/_private/artifacts.py + :caption: MultihostArtifactsType + :start-after: +DOCS/MultihostArtifactsType + :end-before: -DOCS/MultihostArtifactsType + +Diagram +======= + +.. mermaid:: + :align: center + + %%{init: {'theme': 'neutral'}}%% + + graph TD + + s --> host_pytest_setup --> host_pytest_setup_artifacts --> topology + topology --> host_pytest_teardown -->host_pytest_teardown_artifacts --> e + + s(["`**Start**`"]) + e(["`**End**`"]) + + host_pytest_setup("`**Setup hosts** + MultihostHost.pytest_setup`") + host_pytest_setup_artifacts("`**Collect hosts artifacts** + type: pytest_setup`") + + host_pytest_teardown("`**Teardown hosts** + MultihostHost.pytest_teardown`") + + host_pytest_teardown_artifacts("`**Collect hosts artifacts** + type: pytest_teardown`") + + subgraph topology ["`**Topology**`"] + topology_setup --> topology_setup_artifacts --> test + test --> topology_teardown --> topology_teardown_artifacts + + topology_setup("`**Setup topology** + TopologyController.topology_setup`") + + topology_setup_artifacts("`**Collect topology artifacts** + type: topology_setup`") + + subgraph test ["`**Test run**`"] + direction TB + + setup --> run(("`**Run test**`")) --> test_artifacts --> teardown + + setup("`**Setup before test**`") + test_artifacts("`**Collect test artifacts** + type: test`") + teardown("`**Teardown after test**`") + end + + topology_teardown("`**Teardown topology** + TopologyController.topology_teardown`") + + topology_teardown_artifacts("`**Collect topology artifacts** + type: topology_teardown`") + end + + classDef section fill:#fff,stroke-width:2px,stroke:#ccc + class topology,test section; + + classDef setup fill:#44d585,stroke-width:2px,stroke:#33d17a,font-size:1px + class ue,hs,ts,rs,us setup; + class uex,ht,tt,rt,ut setup; + + classDef artifacts fill:#ffbc00,stroke-width:0 + class host_pytest_setup_artifacts,host_pytest_teardown_artifacts,topology_setup_artifacts,topology_teardown_artifacts,test_artifacts artifacts; + + classDef test_node fill:#ff9,stroke-width:0 + class run test_node; diff --git a/docs/articles/life-cycle/changing-test-status.rst b/docs/articles/life-cycle/changing-test-status.rst new file mode 100644 index 0000000..d976ac5 --- /dev/null +++ b/docs/articles/life-cycle/changing-test-status.rst @@ -0,0 +1,39 @@ +Changing Test Status +#################### + +Sometimes, it is required to run some additional checks when a test is finished +and maybe even change the test result from success to fail, or change the test +result category or how its result is displayed in the verbose output. + +This is possible by invoking a pytest built-in hook +:func:`~_pytest.hookspec.pytest_report_teststatus`. This hook can be added to +:class:`~pytest_mh.MultihostUtility`, see: +:meth:`MultihostUtility.pytest_report_teststatus +` + +.. seealso:: + + This feature is used in :class:`~pytest_mh.utils.auditd.Auditd` and + :class:`~pytest_mh.utils.coredumpd.Coredumpd` in order to set the result + category to a custom value and optionally also fail the test. + + If an AVC denial occurred during test, it is moved to ``AVC DENIALS`` + category. If a coredump occured, it is moved to ``COREDUMPS`` category. + + .. dropdown:: Example source code + :color: primary + :icon: code + + .. tab-set:: + + .. tab-item:: Auditd utility + + .. literalinclude:: ../../../pytest_mh/utils/auditd.py + :caption: Modifying the test result in Auditd + :pyobject: Auditd.pytest_report_teststatus + + .. tab-item:: Coredumpd utility + + .. literalinclude:: ../../../pytest_mh/utils/coredumpd.py + :caption: Modifying the test result in Coredumpd + :pyobject: Coredumpd.pytest_report_teststatus diff --git a/docs/articles/life-cycle/setup-and-teardown.rst b/docs/articles/life-cycle/setup-and-teardown.rst new file mode 100644 index 0000000..099b1e1 --- /dev/null +++ b/docs/articles/life-cycle/setup-and-teardown.rst @@ -0,0 +1,196 @@ +Setup and Teardown Hooks +######################## + +Pytest-mh provides multiple setup and teardown hooks that you can use to setup +the test environment and later revert all changes that were done during the +setup and testing. + +It is possible to setup and teardown individual hosts, topologies, roles and +utilities. The scope of individual hooks spans from a whole pytest session +(called only once per session), topology (called once per multihost topology) +and test (called for each test). + +.. contents:: + :local: + +.. warning:: + + Remember the golden rule: **everything that is done during setup must be + reverted in teardown method for the same scope**. Every test should start + with a fresh, untainted and clearly defined environment. + +Scope: pytest session +===================== + +These hooks are called only once and can be used for initial setup of the hosts +that is required for all tests. Setup is called once when pytest session starts, +then all collected tests are run and when then teardown is called right before +pytest session ends. + +.. dropdown:: Setup + :color: success + :icon: gear + :open: + + Setup is called on all hosts that are required to run collected test cases. + + #. Setup reentrant utilities used by the host. This is done automatically + for all instances of :class:`~pytest_mh.MultihostReentrantUtility` that + are available in the :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostUtility.setup` + * :meth:`pytest_mh.MultihostReentrantUtility.__enter__` + + #. Setup host + + * :meth:`pytest_mh.MultihostHost.pytest_setup` + +.. dropdown:: Run collected tests + :color: primary + :icon: iterations + :open: + + Iterate over topologies and run tests. See: :ref:`setup_topology`. + +.. dropdown:: Teardown + :color: danger + :icon: history + :open: + + Teardown is called on all hosts that were required to run collected test cases. + + #. Teardown host + + * :meth:`pytest_mh.MultihostHost.pytest_teardown` + + #. Teardown reentrant utilities used by the host. This is done automatically + for all instances of :class:`~pytest_mh.MultihostReentrantUtility` that + are available in the :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__exit__` + * :meth:`pytest_mh.MultihostUtility.teardown` + +.. _setup_topology: + +Scope: Multihost topology +========================= + +The topology scope allows you to prepare hosts to run a specific topology. The +setup is run when a topology is entered the first time. After this step, all +tests for the currently selected topology are run and when these tests are +finished, then topology teardown is called. + +.. dropdown:: Setup + :color: success + :icon: gear + :open: + + #. Enter reentrant utilities used by the hosts required by this topology. + This is done automatically for all instances of + :class:`~pytest_mh.MultihostReentrantUtility` that are available in the + :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__enter__` + + #. Setup topology + + * :meth:`pytest_mh.TopologyController.topology_setup` + +.. dropdown:: Run collected tests + :color: primary + :icon: iterations + :open: + + Run all tests that require current topology. See: :ref:`setup_test`. + +.. dropdown:: Teardown + :color: danger + :icon: history + :open: + + #. Teardown topology + + * :meth:`pytest_mh.TopologyController.topology_teardown` + + #. Exit reentrant utilities used by the hosts required by this topology. + This is done automatically for all instances of + :class:`~pytest_mh.MultihostReentrantUtility` that are available in the + :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__exit__` + +.. _setup_test: + +Scope: Individual tests +======================= + +These hooks are run once for each test. + +.. dropdown:: Setup + :color: success + :icon: gear + :open: + + #. Enter reentrant utilities used by the hosts required by the test. + This is done automatically for all instances of + :class:`~pytest_mh.MultihostReentrantUtility` that are available in the + :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__enter__` + + #. Setup all hosts required by this test + + * :meth:`pytest_mh.MultihostHost.setup` + + #. Setup topology required by this test + + * :meth:`pytest_mh.TopologyController.setup` + + #. Setup utilities used by the roles. This is done automatically for all + instances of :class:`~pytest_mh.MultihostUtility` that are available in + the :class:`~pytest_mh.MultihostRole` object. + + * :meth:`pytest_mh.MultihostUtility.setup` + * :meth:`pytest_mh.MultihostReentrantUtility.__enter__` + + #. Setup all roles required by this test + + * :meth:`pytest_mh.MultihostRole.setup` + +.. dropdown:: Run test + :color: primary + :icon: iterations + :open: + + Run the test. + +.. dropdown:: Teardown + :color: danger + :icon: history + :open: + + #. Teardown all roles required by this test + + * :meth:`pytest_mh.MultihostRole.teardown` + + #. Teardown utilities used by the roles. This is done automatically for all + instances of :class:`~pytest_mh.MultihostUtility` that are available in + the :class:`~pytest_mh.MultihostRole` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__exit__` + * :meth:`pytest_mh.MultihostUtility.teardown` + + #. Teardown topology required by this test + + * :meth:`pytest_mh.TopologyController.teardown` + + #. Teardown all hosts required by this test + + * :meth:`pytest_mh.MultihostHost.teardown` + + #. Exit reentrant utilities used by the hosts required by the test. + This is done automatically for all instances of + :class:`~pytest_mh.MultihostReentrantUtility` that are available in the + :class:`~pytest_mh.MultihostHost` object. + + * :meth:`pytest_mh.MultihostReentrantUtility.__exit__` diff --git a/docs/articles/life-cycle/skipping-tests.rst b/docs/articles/life-cycle/skipping-tests.rst new file mode 100644 index 0000000..e2ef137 --- /dev/null +++ b/docs/articles/life-cycle/skipping-tests.rst @@ -0,0 +1,169 @@ +Skipping Tests +############## + +It is always possible to use pytest filtering options to run only the desired +test. But often, it may be useful to skip a test based on some external +condition: for example if the product was built with a certain feature or not. +If the feature is not supported, tests that are using this feature must be +skipped. + +It is not possible to accomplish this with the built-in ``pytest.mark.skipif`` +marker since it is evaluated too soon and only takes expressions, it does not +provide access to the fixtures. However, pytest-mh provides alternative +solutions. + +.. seealso:: + + Every project has its own specifics and it is not possible to implement + generic feature detection and provide it out of the box. `ldap.features` + property used in these examples is not part of pytest-mh. To see tips on how + to implement feature detection for your project, see + :doc:`../tips-and-tricks/features-detection`. + +.. _mark.require: + +Skipping individual tests +========================= + +It is possible to skip individual tests with ``pytest.mark.require`` marker. +This marker takes a callable as parameter, if the callable evaluates to ``True`` +the test is run. If it evaluates to ``False``, the test is skipped. + +Parameters of the callable are all fixtures that are available to the test, +including all :class:`~pytest.MultihostRole` objects required by the test, that +is all dynamic fixtures defined by the topology. + +.. warning:: + + The skip condition is evaluated before :ref:`test setup ` is + performed to avoid complex setup and teardown for a test that is going to be + skipped. + + Keep this in mind when accessing the role objects, since the role and its + utilities setup method has not yet been called, therefore some properties + may not be correctly initialized. It is up to you to make sure that you only + access the properties that have been already set in your code. + + However, it is perfectly fine to run commands and access properties that do + not depend on the setup. + +.. grid:: 1 + + .. grid-item-card:: Examples of pytest.mark.require + + .. tab-set:: + + .. tab-item:: Pytest code + + .. code-block:: python + :emphasize-lines: 3-6,16-19,32 + :linenos: + + # Use a simple lambda function + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + lambda ldap: "password_policy" in ldap.features, + "Server is not built with password policy support" + ) + def test_skip__lambda(client: Client, ldap: LDAP): + pass + + + # Use a defined function + def require_password_policy(ldap: LDAP): + return "password_policy" in ldap.features + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + require_password_policy, + "Server is not built with password policy support" + ) + def test_skip__function(client: Client, ldap: LDAP): + pass + + + # Use a defined function that also returns a reason in a tuple + def require_password_policy(ldap: LDAP): + result = "password_policy" in ldap.features + reason = "Server is not built with password policy support" + + return result, reason + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require.with_args(require_password_policy) + def test_skip__function_and_reason(client: Client, ldap: LDAP): + pass + + .. note:: + + Notice the usage of ``with_args`` in the third example + ``test_skip__function_and_reason``. Pytest marker does not + allow single function as an argument and it must be worked + around by using ``with_args``. + + See pytest documentation for more information: + :meth:`pytest.MarkDecorator.with_args` + + .. tab-item:: Pytest run result + + .. code-block:: text + + tests/test_passkey.py::test_skip__lambda (ldap) SKIPPED (Server is not built with password policy support) + tests/test_passkey.py::test_skip__function (ldap) SKIPPED (Server is not built with password policy support) + tests/test_passkey.py::test_skip__function_and_reason (ldap) SKIPPED (Server is not built with password policy support) + +Skipping topology +================= + +Sometimes, it is not possible to run any tests from specific topology even +though all hosts and roles required by the topology are available -- for example +when your program was not built with functionality required to correctly setup +the topology. It is possible to achieve this by setting a skip condition by +overriding the :meth:`~pytest_mh.TopologyController.skip` method of +:class:`~pytest_mh.TopologyController`. + +All dynamic fixtures defined by the topology are passed to the method, but this +time they are instances of :class:`~pytest_mh.MultihostHost` instead of +:class:`~pytest_mh.MultihostRole` since the role objects are only created for +tests and are not available at this point. + +.. warning:: + + The skip condition is evaluated before :ref:`topology setup + ` is performed to avoid complex setup and teardown for + tests that are going to be skipped. + + Keep this in mind when accessing the host objects, since the hosts and its + utilities setup method has not yet been called, therefore some properties + may not be correctly initialized. It is up to you to make sure that you only + access the properties that have been already set in your code. + + However, it is perfectly fine to run commands and access properties that do + not depend on the setup. + +.. grid:: 1 + + .. grid-item-card:: Examples of TopologyController.skip() + + .. tab-set:: + + .. tab-item:: Pytest code + + .. code-block:: python + + class PasswordPolicyTopology(TopologyController): + def skip(self, ldap: LDAPHost) -> str | None: + if "password_policy" not in ldap.features: + # Return reason to skip the tests + return "Server is not built with password policy support" + + # Return None to run the tests + return None + + .. tab-item:: Pytest run result + + .. code-block:: text + + tests/test_passkey.py::test_skip__lambda (ldap) SKIPPED (Server is not built with password policy support) + tests/test_passkey.py::test_skip__function (ldap) SKIPPED (Server is not built with password policy support) + tests/test_passkey.py::test_skip__function_and_reason (ldap) SKIPPED (Server is not built with password policy support) diff --git a/docs/articles/mhc-yaml.rst b/docs/articles/mhc-yaml.rst new file mode 100644 index 0000000..b0c150d --- /dev/null +++ b/docs/articles/mhc-yaml.rst @@ -0,0 +1,151 @@ +Configuration File +################## + +The configuration file (usually named ``mhc.yaml``) contains the definition of +which multihost domains and hosts are *at the moment* available to use for +testing. If all tests can be run with given set of hosts the all tests are run, +but it is perfectly possible to omit some host in order to run only a sub set of +available tests -- the tests that require more or different hosts are silently +skipped. + +The configuration file uses the YAML format. + +.. code-block:: yaml + + config: + domains: + - id: + config: + hosts: + - hostname: + role: + os: + family: + conn: + type: + config: + artifacts: + - + +.. list-table:: Description of individual fields + :header-rows: 1 + + * - Field + - Required + - Default value + - Description + + * - ``config`` + - No + - ``dict()`` + - Custom global configuration + + * - ``domains`` + - **Yes** + - *N/A* + - List of multihost domains + + * - ``domains.id`` + - **Yes** + - *N/A* + - Domain identifier + + * - ``domains.config`` + - No + - ``dict()`` + - Custom domain configuration + + * - ``domains.hosts`` + - **Yes** + - *N/A* + - List of domain's host + + * - ``hosts.hostname`` + - **Yes** + - *N/A* + - DNS hostname, should be resolvable within the hosts + + * - ``hosts.role`` + - **Yes** + - *N/A* + - Multihost role that this host fulfils + + * - ``hosts.os`` + - No + - ``linux`` + - OS family: ``linux`` or ``windows`` + + * - ``hosts.conn`` + - No + - See :ref:`below ` + - How to connect to the host + + * - ``hosts.conn.type`` + - No + - ``ssh`` + - | Connection type: ``ssh``, ``podman``, ``docker`` + | Additional properties are define by each type + + * - ``hosts.config`` + - No + - ``dict()`` + - Custom host configuration + + * - ``hosts.artifacts`` + - No + - ``list()`` + - List of artifacts to collect from the host + +Minimal configuration +===================== + +.. dropdown:: Minimal configuration example + :color: primary + :icon: code + :open: + + .. tab-set:: + + .. tab-item:: Minimal configuration + + .. code-block:: yaml + + domains: + - id: example + hosts: + - hostname: hostname.example + role: client + + .. tab-item:: With expanded default values + + .. code-block:: yaml + + config: + domains: + - id: example + config: + hosts: + - hostname: hostname.example + role: client + os: + family: linux + conn: + type: ssh + host: $host.hostname + user: root + password: Secret123 + config: + artifacts: + +.. _mhc-yaml-connection-type: + +Connection type +=============== + +The ``conn`` field declares how does pytest-mh connect to each hosts. Pytest-mh +has built-in connectors for ``ssh``, ``podman`` and ``docker``. Each connection +has additional properties. If the ``conn`` field is omitted, the default is +``type=ssh, user=root, password=Secret123, host=host.hostname``. + +See more information about each connection type at +:doc:`running-commands/configuration`. diff --git a/docs/articles/running-commands.rst b/docs/articles/running-commands.rst new file mode 100644 index 0000000..9be2488 --- /dev/null +++ b/docs/articles/running-commands.rst @@ -0,0 +1,37 @@ +Running Commands on Remote Hosts +################################ + +Running commands on remote hosts is one of the fundamental features of +pytest-mh. In order to do that, it provides abstraction over remote processes +and generic interface in the :class:`~pytest_mh.conn.Connection` class. There +are currently two implementations of this interface: + +* SSH connection in :class:`~pytest_mh.conn.ssh.SSHClient` (using ``pylibssh`` + underneath) +* Direct communication with containers in + :class:`~pytest_mh.conn.container.ContainerClient` (supports podman and + docker) + +This interface allows you to run commands and scripts in both blocking and +non-blocking manner. The main and generic connection to the host can be accessed +via :attr:`~pytest_mh.MultihostHost.conn` attribute of the +:class:`~pytest_mh.MultihostHost` class. If needed, you can establish additional +connections by instantiating one of the connection classes (for example to open +SSH connection to the host for different user). + +.. note:: + + Pytest-mh main connection expects that Linux commands are using ``bash`` and + Windows commands are using ``powershell``. + + You can provide implementation for different shells by subclassing + :attr:`~pytest_mh.conn.Shell` and passing this shell directly to the + constructor of the connector. However, this should only be done for extra + connections. + +.. toctree:: + :maxdepth: 2 + + running-commands/configuration + running-commands/blocking-calls + running-commands/non-blocking-calls diff --git a/docs/articles/running-commands/blocking-calls.rst b/docs/articles/running-commands/blocking-calls.rst new file mode 100644 index 0000000..8d22846 --- /dev/null +++ b/docs/articles/running-commands/blocking-calls.rst @@ -0,0 +1,91 @@ +Blocking Calls +############## + +It is possible to run a command using a blocking code, meaning the code will +block until the command is finished and its result is returned. The result is +instance of :class:`~pytest_mh.conn.ProcessResult` and gives you access to +return code, standard output and standard error output. + +.. code-block:: python + :caption: Example: Blocking call + + from pytest_mh import MultihostHost + from pytest_mh.conn import ProcessResult + + class ExampleRole(MultihostHost[ExampleDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def say_hello(self) -> ProcessResult: + """ + Run a single line script code. + """ + return self.host.conn.run("echo 'Hello World'") + + def say_hello_argv(self) -> ProcessResult: + """ + Execute command by passing list of arguments. + """ + return self.host.conn.exec(["echo", "Hello World"]) + + def say_script(self) -> ProcessResult: + """ + Execute a multiline script code. + """ + return self.host.conn.run( + """ + set -ex + + echo 'Hello World' + """ + ) + + def say_hello_cat(self) -> ProcessResult: + """ + You can also pass input data. + """ + return self.host.conn.run("cat", input="Hello World") + + @pytest.mark.topology(...) + def test_hello(example: ExampleRole) -> None: + result = example.say_hello() + assert result.stdout == "Hello World" + + result = example.say_hello_argv() + assert result.stdout == "Hello World" + + result = example.say_hello_script() + assert result.stdout == "Hello World" + + result = example.say_hello_cat() + assert result.stdout == "Hello World" + +If the command returns a non-zero return code, it is automatically considered a +failure and :class:`~pytest_mh.conn.ProcessError` is raised. If you don't want +to raise the error or if you want to raise it on different condition, you can +overwrite the behaviour with ``raise_on_error`` argument. + +.. code-block:: python + :caption: Example: Do not raise exception on non-zero return code + + result = self.host.conn.run("echo 'Hello World'", raise_on_error=False) + if result.rc not in (0, 1): + # Raise ProcessError if rc was not 0 or 1 + result.throw() + +.. note:: + + Each command execution is logged in the pytest-mh logger. This can often + pollute the logs with high amount of commands which output result is really + important only if it fails. Therefore you can change the log level in order + to add a log record only if the command yields non-zero return code. + + .. code-block:: python + :caption: Example: Custom log level + + self.host.conn.run( + "echo 'Hello World'", + log_level=ProcessLogLevel.Error + ) + + See :class:`~pytest_mh.conn.ProcessLogLevel` for all available log levels. diff --git a/docs/articles/running-commands/configuration.rst b/docs/articles/running-commands/configuration.rst new file mode 100644 index 0000000..37ed5b7 --- /dev/null +++ b/docs/articles/running-commands/configuration.rst @@ -0,0 +1,47 @@ +Configuring the Main Connection +############################### + +The main connection can be configured in the ``conn`` field of the host +configuration in pytest-mh configuration file (``mhc.yaml``). + +.. code-block:: yaml + + hosts: + # Configuring SSH connection, using a password + - hostname: client1.test + role: client + conn: + type: ssh + host: 192.168.0.10 # IP address or hostname + user: root + password: Secret123 + + # Configuring SSH connection, using a certificate + - hostname: client2.test + role: client + conn: + type: ssh + host: 192.168.0.20 # IP address or hostname + user: root + private_key: /my/private/key/path + private_key_password: Secret12 + + # Configuring podman connection + - hostname: client3.test + role: client + conn: + type: podman + container: client3 + + # Configuring podman connection to a container running under root + - hostname: client4.test + role: client + conn: + type: podman + container: client4 + sudo: True + sudo_password: MyPassword # Can be omitted with password-less sudo + + # Default is SSH connection with host=hostname, user=root, password=Secret123 + - hostname: client5.test + role: client diff --git a/docs/articles/running-commands/non-blocking-calls.rst b/docs/articles/running-commands/non-blocking-calls.rst new file mode 100644 index 0000000..0ee8c2d --- /dev/null +++ b/docs/articles/running-commands/non-blocking-calls.rst @@ -0,0 +1,41 @@ +Non-Blocking Calls +################## + +It is possible to run a command using a non-blocking code. This gives you more +fine grained control over the input and output. Similarly to blocking code, +there are :meth:`~pytest_mh.conn.Connection.async_run` and +:meth:`~pytest_mh.conn.Connection.async_exec` methods. + +These methods return an instance of :class:`~pytest_mh.conn.Connection.Process` +which represents a running process. You can write to +:attr:`~pytest_mh.conn.Process.stdin` or iterate over +:attr:`~pytest_mh.conn.Process.stdout` and +:attr:`~pytest_mh.conn.Process.stderr` which are line-based generators. + +.. warning:: + + Both :attr:`~pytest_mh.conn.Process.stdout` and + :attr:`~pytest_mh.conn.Process.stderr` read the process outputs line by + line, therefore they will block until a full line is read. + + This is especially a problem in the executed program prompts for input. It + is better to use :meth:`~pytest_mh.conn.Connection.expect` or + :meth:`~pytest_mh.conn.Connection.expect_nobody` for interactive programs. + +.. code-block:: python + :caption: Example: Non-Blocking call + + from pytest_mh import MultihostHost + from pytest_mh.conn import ProcessResult + + @pytest.mark.topology(...) + def test_hello(example: ExampleRole) -> None: + process = example.host.conn.async_run("cat") + + process.stdin.write("Hello\n") + assert next(process.stdout) == "Hello" + + process.stdin.write("World\n") + assert next(process.stdout) == "World" + + result = process.wait() diff --git a/docs/articles/running-tests.rst b/docs/articles/running-tests.rst new file mode 100644 index 0000000..4616eb5 --- /dev/null +++ b/docs/articles/running-tests.rst @@ -0,0 +1,91 @@ +Running Tests +############# + +Pytest-mh is a pytest plugin, therefore all tests are run with ``pytest``. There +are some additional command line arguments that you can use, all pytest-mh +arguments are prefixed with ``--mh-``. You can use the following command +to find all pytest-mh related parameters: + +.. code-block:: text + + $ pytest --help | grep -C 5 -- --mh + +The only required parameter is ``--mh-config`` that sets the path to the +pytest-mh configuration file. + +.. grid:: 1 + + .. grid-item-card:: Running pytest-mh tests + + .. tab-set:: + + .. tab-item:: Command line + + .. code-block:: text + + $ pytest --mh-config=mhc.yam --verbose + + .. tab-item:: Sample output + + .. code-block:: text + + ... + tests/test_identity.py::test_identity__lookup_username_with_id[root] (ipa) PASSED [ 2%] + tests/test_identity.py::test_identity__lookup_username_with_id[sssd] (ipa) SKIPPED (SSSD was built without support for running under non-root) [ 4%] + tests/test_identity.py::test_identity__lookup_uid_with_id[root] (ipa) PASSED [ 6%] + tests/test_identity.py::test_identity__lookup_uid_with_id[sssd] (ipa) SKIPPED (SSSD was built without support for running under non-root) [ 8%] + tests/test_identity.py::test_identity__lookup_groupname_with_getent (ipa) PASSED [ 10%] + tests/test_identity.py::test_identity__lookup_gid_with_getent (ipa) PASSED [ 12%] + ... + +Notice, that the test name in the output contains the multihost topology in +parentheses. + +.. note:: + + If a test requires a role, host or domain that is not included in the + given configuration file, it is silently skipped. + +Useful parameters +================= + +This is a short list of selected pytest-mh parameters that can be useful when +running tests locally. + +* ``--mh-topology``: Run only tests for selected topology +* ``--mh-not-topology``: Avoid running test for given topology +* ``--mh-artifacts-dir``: Store artifacts in non-default directory +* ``--mh-log-path=/dev/stderr``: Print pytest-mh log record to standard error output + +.. seealso:: + + You should definitely check out pytest documentation on how to run and + filter tests: https://docs.pytest.org/en/latest/how-to/usage.html + +Debugging tests +=============== + +Pytest-mh stores logs for each test run. These logs can be found among the test +artifacts in artifacts directory (by default ``./artifacts``). You can find them +in: + +* ``./artifacts/tests/$test-case/test.log``: log records for the test run +* ``./artifacts/tests/$test-case/setup.log``: log records for the test setup phase +* ``./artifacts/tests/$test-case/teardown.log``: log records for the test teardown phase + +.. note:: + + By default, logs and artifacts are stored only for failed tests. You can + modify this behavior with ``--mh-collect-artifacts`` and + ``--mh-collect-logs``. + +Lots of commands that are run have a log level set to ``Error`` therefore they +are added to the logs only if the command failed. This is usually the desired +behavior in order to not clutter the logs with hundreds of successful commands. +However, sometimes it is useful to override this behavior and see everything. +You can do this by setting ``MH_CONNECTION_DEBUG=yes`` environment variable. + +.. code-block:: text + :caption: Log every remote command + + $ MH_CONNECTION_DEBUG=yes pytest --mh-config=mhc.yam --verbose diff --git a/docs/articles/tips-and-tricks.rst b/docs/articles/tips-and-tricks.rst new file mode 100644 index 0000000..5346278 --- /dev/null +++ b/docs/articles/tips-and-tricks.rst @@ -0,0 +1,12 @@ +Tips and Tricks +############### + +Here is a list of several pytest-mh tips and tricks that you can use as an +inspiration for your project. + +.. toctree:: + :maxdepth: 2 + + tips-and-tricks/backup-restore + tips-and-tricks/features-detection + tips-and-tricks/pytest-fixtures diff --git a/docs/articles/tips-and-tricks/backup-restore.rst b/docs/articles/tips-and-tricks/backup-restore.rst new file mode 100644 index 0000000..7a87a4e --- /dev/null +++ b/docs/articles/tips-and-tricks/backup-restore.rst @@ -0,0 +1,161 @@ +Host Backup and Restore +####################### + +Various :doc:`setup and teardown <../life-cycle/setup-and-teardown>` hooks +called by pytest-mh can be used to implement automatic host backup and restore +functionality. This is supported out of the box with +:class:`~pytest_mh.MultihostBackupHost` and +:class:`~pytest_mh.BackupTopologyController`. + +Implementing automatic backup of a host +======================================= + +:class:`~pytest_mh.MultihostBackupHost` is an abstract class that declares +several abstract methods that have to be implemented: + +.. container:: wy-table-responsive + + .. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Abstract method name + - Description + * - :meth:`~pytest_mh.MultihostBackupHost.start` + - Start required host services. If no services are needed, this can be + implemented as a "no operation" or raise ``NotImplementedError`` + which will be ignored by internal calls to this method. + * - :meth:`~pytest_mh.MultihostBackupHost.stop` + - Stop required host services. If no services are needed, this can be + implemented as a "no operation" or raise ``NotImplementedError`` + which will be ignored by internal calls to this method. + * - :meth:`~pytest_mh.MultihostBackupHost.backup` + - Take backup of the host. The backup can be returned as any Python + data, :class:`~pathlib.PurePath` or a sequence of + :class:`~pathlib.PurePath`. If the path is returned, it is + automatically deleted from the host when all tests are run. If + non-path data is returned, any clean up is left on the user if + needed -- it is possible to override + :meth:`~pytest_mh.MultihostBackupHost.remove_backup`. + * - :meth:`~pytest_mh.MultihostBackupHost.restore` + - Restore the host from the backup. + +The backup is taken automatically during pytest setup and the host is restored +to this state after each test run. Sometimes, it is not desirable to restore the +host automatically at this point (for example if this is done by the topology +controller) and this can be disabled by passing ``auto_restore=False`` to the +constructor. + +.. code-block:: python + :caption: Example use of MultihostBackupHost + + class ExampleBackupHost(MultihostBackupHost[MyProjectMultihostDomain]): + def __init__(self, *args, **kwargs) -> None: + # restore is handled in topology controllers + super().__init__(*args, auto_restore=False, **kwargs) + + self.svc: SystemdServices = SystemdServices(self) + + def start(self) -> None: + self.svc.start("my-project") + + def stop(self) -> None: + self.svc.stop("my-project") + + def backup(self) -> Any: + self.logger.info("Creating backup of my-project service") + + # yields backup path + result = self.conn.run("my-project create-backup", log_level=ProcessLogLevel.Error) + + return PurePosixPath(result.stdout_lines[-1].strip()) + + def restore(self, backup_data: Any | None) -> None: + if backup_data is None: + return + + if not isinstance(backup_data, PurePosixPath): + raise TypeError(f"Expected PurePosixPath, got {type(backup_data)}") + + backup_path = str(backup_data) + self.logger.info(f"Restoring my-project from {backup_path}") + self.stop() + self.conn.run(f"my-project restore {backup_path}", log_level=ProcessLogLevel.Error) + self.start() + +.. note:: + + Some projects can not take online backups and the services must be stopped. + In such case, it is possible to pass ``auto_start=False`` to the constructor + to prevent automatic start up of the service before taking the first backup. + + .. code-block:: python + :caption: Example use of MultihostBackupHost with no auto start + :emphasize-lines: 3,12,15 + :linenos: + + class ExampleBackupHost(MultihostBackupHost[MyProjectMultihostDomain]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, auto_start=False, **kwargs) + + self.svc: SystemdServices = SystemdServices(self) + + ... + + def backup(self) -> Any: + self.logger.info("Creating backup of my-project service") + + self.stop() + # yields backup path + result = self.conn.run("my-project create-backup", log_level=ProcessLogLevel.Error) + self.start() + + return PurePosixPath(result.stdout_lines[-1].strip()) + + ... + +Implementing automatic backup for a topology +============================================ + +The previous section showed how to implement an automatic backup for each host. +However, it is quite often the case that each host needs to get additional setup +in order to prepare it for given topology (like configuring particular database +backend that we want to test with this topology). + +The topology controller provides various setup and teardown hooks that can setup +the topology, take backup, restore to this backup after each test and when all +tests for this topology are run, it can restore the hosts to their original +state before the topology setup was run. + +This behavior is implemented by the built-in +:class:`~pytest_mh.BackupTopologyController`. This controller can be used as is +or further modified. Usually, it is desirable to override +:meth:`~pytest_mh.BackupTopologyController.topology_setup` to prepare the hosts +for testing. The automatic backup and restore is implemented only for the hosts +that inherits from :class:`~pytest_mh.MultihostBackupHost`. + +.. warning:: + + if :class:`~pytest_mh.BackupTopologyController` is used, make sure to + disable automatic teardown in the hosts by passing ``auto_restore=False`` to + the :class:`~pytest_mh.MultihostBackupHost` constructor. + +.. code-block:: python + :caption: Example use of BackupTopologyController + + class MyProjectTopologyController(BackupTopologyController[MyProjectMultihostConfig]): + @BackupTopologyController.restore_vanilla_on_error + def topology_setup(self, client: ClientHost, server: ServerHost) -> None: + self.logger.info(f"Preparing {server.hostname}") + + # run your code + + # Backup so we can restore to this state after each test + # There is no need to pass any arguments to this call + super().topology_setup() + +.. note:: + + ``@BackupTopologyController.restore_vanilla_on_error`` decorator is used to + restore the hosts to the original state before topology setup was called if + any error occurs during the setup. diff --git a/docs/articles/tips-and-tricks/features-detection.rst b/docs/articles/tips-and-tricks/features-detection.rst new file mode 100644 index 0000000..5491967 --- /dev/null +++ b/docs/articles/tips-and-tricks/features-detection.rst @@ -0,0 +1,111 @@ +Features Detection +################## + +Many projects can be built and distributed differently on different systems, +some features may be disabled or use different (usually hard-coded) settings. +This is quite common for compiled programs, that may choose to built or omit +some parts of the code. + +Pytest-mh does not provide any built-in support of feature detection as this +functionality is highly project specific, but it is possible to use the +following code snippets as a guideline or inspiration. + +Add feature property to the host class +====================================== + +Most of the time, it is desirable to detect the features only on start up, since +the application does not change the built-time features during testing. +Therefore, the code can be safely added to the host class and use +:meth:`~pytest_mh.MultihostHost.pytest_setup` to make sure it is run only once. + +.. code-block:: python + :caption: Creating base host class with feature property + :emphasize-lines: 5,11-19 + :linenos: + + class BaseFeatureDetectionHost(MultihostHost[MyProjectDomain]): + def __init__(*args, **kwargs): + super().__init__(*args, **kwargs) + + self.features: dict[str, bool] = {} + """ + Features supported by the host. + """ + + class MyProjectHost(BaseFeatureDetectionHost): + def pytest_setup(self) -> None: + super().pytest_setup() + + # the following is a pseudocode which yields list of available + # features to stdout + result = self.conn.run("detect-project-features") + + for feature_name in result.stdout_lines: + self.features[name] = True + +Add feature property to the role class +====================================== + +Since tests have only indirect access to the host object via roles, it may be +nice to provide a shortcut to make the code smaller. It would be possible to +assign ``self.features = self.host.features`` directly in the constructor, +however it might be better to use the ``@property`` decorator to also cover the +case when ``host.features`` changes reference to different dictionary/object. + +.. code-block:: python + :caption: Add shortcut to the host feature to the role class + :emphasize-lines: 2-7 + :linenos: + + class MyProjectRole(MyProjectHost): + @property + def features(self) -> dict[str, bool]: + """ + Features supported by the role. + """ + return self.host.features + +Skipping tests +============== + +It is possible to check for a feature presence using :ref:`@pytest.mark.require +`, if the feature is not available the test will be skipped. + +.. code-block:: python + :emphasize-lines: 2-5 + :linenos: + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.require( + lambda ldap: "password_policy" in ldap.features, + "Server is not built with password policy support" + ) + def test_skip__lambda(client: Client, ldap: LDAP): + pass + +.. seealso:: + + You can also inspire in the SSSD project that has a syntactic sugar over the + ``@pytest.mark.require`` marker and introduces ``@pytest.mark.builtwith``, + which internally translates into the ``require`` marker. You can check out + the code `here + `__ + and `here + `__. + + .. code-block:: python + :caption: Example use of SSSD's builtwith marker + :emphasize-lines: 2,8 + :linenos: + + # require files-provider feature built in the client + @pytest.mark.builtwith("files-provider") + @pytest.mark.topology(KnownTopology.Client) + def test_files__root_user_is_ignored_on_lookups(client: Client): + ... + + # require passkey feature built in the client and the provider + @pytest.mark.builtwith(client="passkey", provider="passkey") + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_passkey__su_user(client: Client, provider: GenericProvider, moduledatadir: str, testdatadir: str): + ... diff --git a/docs/articles/tips-and-tricks/pytest-fixtures.rst b/docs/articles/tips-and-tricks/pytest-fixtures.rst new file mode 100644 index 0000000..6f22f7b --- /dev/null +++ b/docs/articles/tips-and-tricks/pytest-fixtures.rst @@ -0,0 +1,32 @@ +Using Pytest Fixtures +##################### + +At this moment, it is not possible to pass the pytest-mh roles that are +available to the test directly to `pytest fixtures `_. +However, there is :func:`~pytest_mh.mh_fixture` decorator which is a wrapper +around ``@pytest.fixture`` that can be used instead. + +.. code-block:: python + :caption: Example use of @mh_fixture decorator + + @mh_fixture + def my_fixture(client: Client, request: pytest.FixtureRequest): + return client.role + + @mh_fixture + def my_fixture_with_teardown(client: Client, request: pytest.FixtureRequest): + yield client.role + # teardown code + + @pytest.mark.topology(KnownTopology.LDAP) + def test_example(client: Client, ldap: LDAP, my_fixture, my_fixture_with_teardown): + pass + +It can be used as any other pytest fixture, it is possible to pass other pytest +fixtures as an argument as well. + +.. warning:: + + At this moment, only the ``function`` fixture scope is supported. + +.. _pytest_fixtures: https://docs.pytest.org/en/latest/explanation/fixtures.html diff --git a/docs/articles/writing-tests.rst b/docs/articles/writing-tests.rst new file mode 100644 index 0000000..015bda1 --- /dev/null +++ b/docs/articles/writing-tests.rst @@ -0,0 +1,306 @@ +Writing Tests +############# + +Each test that should have access to the remote hosts must be marked with one or +more topology markers. This tells pytest-mh what domains, hosts and roles are +required to run the test. The marker also defines how the +:class:`~pytest_mh.MultihostRole` objects should be accessible from within the +test. + +The recommended way is to use :ref:`"dynamic" fixtures +`, which are fixtures that do not exist anywhere +in the code but are injected into the test parameters by pytest-mh. It is also +possible to get the access through the :func:`~pytest_mh.mh` fixture, but this +is quite low level and should be avoided, unless you have a valid use case for +it. + +.. seealso:: + + The topology, topology marker and related information is deeply covered in + :doc:`extending/multihost-topologies`. + +Using the mh fixture - low-level API +==================================== + +.. warning:: + + Using the :func:`~pytest_mh.mh` fixture directly is supported but not + recommended. You should avoid that unless you have a valid use case for it. + It is, however, documented first, to get better understanding of how things + work. + +The :func:`~pytest_mh.mh` fixture is automatically available to every test and +it returns an instance of :class:`~pytest_mh.MultihostFixture`. This fixture +internally takes care of calling test setup and teardown as well as collecting +test artifacts. It does provide access to all the roles ands hosts, topology and +the topology marker as well as other stuff that are needed for this fixture to +do its job. + +There are several attributes that you may find helpful if you need access to +this object. + +.. list-table:: mh fixture attributes + :header-rows: 1 + + * - Attribute name + - Description + + * - :attr:`~pytest_mh.MultihostFixture.ns` + - Role objects accessible through namespace ``mh.ns.domain_id.role_name`` + + * - :attr:`~pytest_mh.MultihostFixture.logger` + - Multihost logger -- log messages to ``test.log`` + + * - :attr:`~pytest_mh.MultihostFixture.roles` + - List of all role objects + + * - :attr:`~pytest_mh.MultihostFixture.hosts` + - List of all hosts objects + + * - :attr:`~pytest_mh.MultihostFixture.topology` + - Current topology assigned to the test + + * - :attr:`~pytest_mh.MultihostFixture.topology_mark` + - Current topology marker assigned to the test + + * - :attr:`~pytest_mh.MultihostFixture.multihost` + - Multihost configuration (instance of :class:`~pytest_mh.MultihostConfig`) + +.. code-block:: python + :caption: Example usage of mh fixture + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(mh: MultihostFixture): + assert mh.ns.test.client[0].role == 'client' + assert mh.ns.test.ldap[0].role == 'ldap' + +This fixture can be used also in all function-scoped pytest fixtures. The +following example shows how to get direct access to the roles in the test. This, +however, can be achieved by using pytest-mh's :ref:`dynamic fixtures +` and their mapping. + +.. code-block:: python + :caption: Example usage of mh fixture inside pytest fixture + + @pytest.fixture + def client(mh: MultihostFixture) -> Client: + return mh.ns.test.client[0] + + @pytest.fixture + def ldap(mh: MultihostFixture) -> LDAP: + return mh.ns.test.ldap[0] + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(client: Client, ldap: LDAP): + assert client.role == 'client' + assert ldap.role == 'ldap' + +.. note:: + + Usually, there should not be any reason for you to access the + :func:`~pytest_mh.mh` fixture directly. The roles are available to the tests + if a fixture mapping is defined. They are also available in the + function-scoped fixtures if the fixture is defined with + :func:`~pytest_mh.mh_fixture` decorator instead of ``@pytest.fixture`` (see: + :doc:`tips-and-tricks/pytest-fixtures`). + + Most of the other properties are available as standalone fixtures. Go to + :ref:`writing_tests_builtin_fixtures` to see the list of available fixtures. + +.. _writing_tests_dynamic_fixtures: + +Using dynamic fixtures - high-level API +======================================= + +The topology marker has a ``fixtures`` parameter that define a mapping between +custom fixture names and specific multihost roles that are required by the +topology. Therefore, instead of accessing the :func:`~pytest_mh.mh` fixture and +defining custom fixture as a shortcut to the role objects, we can define the +mapping directly in the topology marker: + + .. tab-set:: + + .. tab-item:: With dynamic fixtures + + .. code-block:: python + :emphasize-lines: 3 + + @pytest.mark.topology( + 'ldap', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict(client='test.client[0]', ldap='test.ldap[0]') + ) + def test_example(client: Client, ldap: LDAP): + assert client.role == 'client' + assert ldap.role == 'ldap' + + .. tab-item:: Without dynamic fixtures + + .. code-block:: python + + @pytest.fixture + def client(mh: MultihostFixture) -> Client: + return mh.ns.test.client[0] + + @pytest.fixture + def ldap(mh: MultihostFixture) -> LDAP: + return mh.ns.test.ldap[0] + + @pytest.mark.topology('ldap', Topology(TopologyDomain('test', client=1, ldap=1))) + def test_example(client: Client, ldap: LDAP): + assert client.role == 'client' + assert ldap.role == 'ldap' + +The fixtures are referred to as "dynamic" because they do not exist anywhere as +a standalone pytest fixture function. They are dynamically created by pytest-mh +for each test and the same name refers to different object in each test. They +can even point to a different host. + + .. code-block:: python + :emphasize-lines: 5, 18 + + @pytest.mark.topology( + 'ldap-a', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict( + client='test.client[0]', + ldap='test.ldap[0]' + ) + ) + def test_example_a(client: Client, ldap: LDAP): + assert client.role == 'client' + + # ldap points to the first host with role ldap found in the test domain + assert ldap.role == 'ldap' + + @pytest.mark.topology( + 'ldap-b', Topology(TopologyDomain('test', client=1, ldap=1)), + fixtures=dict( + client='test.client[0]', + ldap='test.ldap[1]' + ) + ) + def test_example_b(client: Client, ldap: LDAP): + assert client.role == 'client' + + # ldap points to the second host with role ldap found in the test domain + assert ldap.role == 'ldap' + +Fixture path +------------ + +The fixture path is in the form of ``domain-id.role-name[index]``. The index +refers to a specific host in the order defined by current mhc.yaml and it starts +from zero. The index path can be omitted, in this case it gives you access to +the list of all hosts that implements this role. + +.. code-block:: python + :emphasize-lines: 5, 6 + + @pytest.mark.topology( + 'ldap-a', Topology(TopologyDomain('test', client=1, ldap=4)), + fixtures=dict( + client='test.client[0]', + ldap='test.ldap[0]', + all_ldaps='test.ldap' + ) + ) + def test_example_a(client: Client, ldap: LDAP, all_ldaps: list[LDAP]): + assert client.role == 'client' + + assert ldap.role == 'ldap' + assert ldap in all_ldaps + +How to write a test +=================== + +Previous sections showed how the things around multihost topologies works, so +how should you write a new test? Just follow these steps: + +#. Choose the topology or list of topologies that test will use +#. Define the topology outside the test so it can be reused (the topology is + most likely already defined in the project) +#. Write a skeleton using the topology +#. Write the test body + +.. note:: + + It is recommended to use a predefined topology marker so the topology can be + easily shared between tests. See :doc:`extending/multihost-topologies` for + more information. + +.. code-block:: python + :caption: Test skeleton + + from framework.topology import KnownTopology + + @pytest.mark.topology(KnownTopology.LDAP) + def test_skeleton(client: Client, ldap: LDAP): + pass + +The test can also use a :ref:`topology parametrization +`, which can run the test once per each topology. This +is achieved by using a topology group or assigning more then one topology to the +test. + + +.. tab-set:: + + .. tab-item:: Use topology group + + .. code-block:: python + :caption: Test skeleton + + from framework.topology import KnownTopologyGroup + + @pytest.mark.topology(KnownTopology.AnyProvider) + def test_skeleton(client: Client, provider: GenericProvider): + pass + + .. tab-item:: Assign multiple topologies selectively + + .. code-block:: python + :caption: Test skeleton + + from framework.topology import KnownTopology + + @pytest.mark.topology(KnownTopology.LDAP) + @pytest.mark.topology(KnownTopology.SSSD) + @pytest.mark.topology(KnownTopology.Sudoers) + def test_skeleton(client: Client, provider: GenericProvider): + pass + + +.. _writing_tests_builtin_fixtures: + +Built-in fixtures +================= + +.. list-table:: Built-in fixtures + :header-rows: 1 + + * - Fixture name + - Return Type + - Description + + * - :func:`mh ` + - :class:`~pytest_mh.MultihostFixture` + - Low level pytest-mh object. + + * - :func:`mh_config ` + - :class:`~pytest_mh.MultihostConfig` + - Main multihost configuration object. + + * - :func:`mh_logger ` + - :class:`~pytest_mh.MultihostLogger` + - Multihost logger, can be used to write messages into the test log. + + * - :func:`mh_topology ` + - :class:`~pytest_mh.Topology` + - Current test's topology object. + + * - :func:`mh_topology_name ` + - ``str`` + - Current test's topology name. + + * - :func:`mh_topology_mark ` + - :class:`~pytest_mh.TopologyMark` + - Current test's topology marker object. diff --git a/docs/conf.py b/docs/conf.py index 1d33073..cded6ad 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,6 +51,15 @@ # html_theme = "sphinx_rtd_theme" +# These folders are copied to the documentation's HTML output +html_static_path = ["_static"] + +# These paths are either relative to html_static_path +# or fully qualified paths (eg. https://...) +html_css_files = [ + "css/custom.css", +] + # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". @@ -70,5 +79,6 @@ autosummary_ignore_module_all = False intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), "pytest": ("https://docs.pytest.org/en/latest", None), } diff --git a/docs/index.rst b/docs/index.rst index 2255271..8424f9e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,27 +4,76 @@ pytest_mh - pytest multihost test framework .. warning:: This plugin is still actively developed and even though it is mostly stable, - we reserve the right to introduce minor breaking changes if it is required for - new functionality. - -``pytest-mh`` is a pytest plugin that, at a basic level, allows you to run shell -commands and scripts over SSH on remote Linux or Windows hosts. You use it to -execute system or application tests for your project on a remote host or hosts -(or containers) while running pytest locally keeping your local machine intact. - -The plugin also provides building blocks that can be used to setup and teardown -your tests, perform automatic clean up of all changes done on the remote host, -and build a flexible and unified high-level API to manipulate the hosts from -your tests. + we reserve the right to introduce minor breaking changes if it is required + for new functionality. Therefore we advice to pin pytest-mh version for your + project. + +``pytest-mh`` is a multihost testing pytest framework that you can use to **test +your application as a complete product**. One of the core features of this +plugin is to **define set of hosts that are required by your tests** and +**execute commands on these hosts over SSH, podman or docker** while pytest is +run locally and your **local machine is kept intact**. This plugin was designed +especially for a scenario where your application requires multiple hosts to work +(for example a client/server model), but it can be perfectly used for single +host applications as well. The plugin also provides many building blocks that +can help you build a high-level test framework API for your project, including +full backup and restore support. The tests are written in Python, using the +pytest runner, but **your application can be written in any language**. + +.. note:: + + pytest-mh plugin is designed to test your project as a full product, which is + often referred to as **system, application or black-box testing**, when your + project is installed on a host and system commands are run in order to test + its functionality. **It is not designed for unit testing.** + +What are the core features of pytest-mh? +======================================== + +* Define what hosts are required to run a test. If any of required host is not + available, the test is skipped. :doc:`articles/extending/multihost-topologies` +* Run commands on remote hosts or containers via SSH, podman or docker. + :doc:`articles/running-commands` +* Run single test against multiple backends: :ref:`topology_parametrization` +* Write high-level API for your testing framework: :doc:`articles/extending` +* Extensive custom setup and teardown logic with various setup/teardown hooks: :doc:`articles/life-cycle/setup-and-teardown` +* Automatic static and dynamic artifacts collection: :doc:`articles/life-cycle/artifacts-collection` +* Automatic change test result based on additional conditions: :doc:`articles/life-cycle/changing-test-status` +* Skip tests if the hosts are missing any required features: :doc:`articles/life-cycle/skipping-tests` +* Automatic backup and restore of hosts state: :doc:`articles/tips-and-tricks/backup-restore` +* Out of the box: write and read files and other file system operations with automatic changes reversion: :doc:`articles/bundled-utilities/fs` +* Out of the box: start, stop and manage systemd services: :doc:`articles/bundled-utilities/services` +* Out of the box: manipulate system firewall: :doc:`articles/bundled-utilities/firewall` +* Out of the box: auto detection of AVC denials: :doc:`articles/bundled-utilities/auditd` +* Out of the box: auto detection of coredumps: :doc:`articles/bundled-utilities/coredumpd` +* Out of the box: check journald logs: :doc:`articles/bundled-utilities/journald` +* Out of the box: delay network traffic: :doc:`articles/bundled-utilities/tc` + +Do I want to use pytest-mh? +=========================== + +* **Does your program affect the host in any way?** If yes, it is safer to run + it in virtual machine or in a container to avoid affecting your local host. + ``pytest-mh`` takes care of that. +* **Does your program use client-server model?** If yes, it is better to run the + client and the server on separate machines to make the tests more real. + ``pytest-mh`` takes care of that. +* **Does your program communicate with multiple backends?** If yes, you need to + be able to assign each test to a specific backend and also be able to reuse a + single test for multiple backends. ``pytest-mh`` takes care of that. +* **Do you need complex tests that changes state of the system, file system or + other programs or databases?** If yes, you need to make sure that all changes + are reverted when a test is done so the test does not affect other tests. + ``pytest-mh`` takes care of that. .. code-block:: python - :caption: Example test taken from SSSD demo + :caption: Example test taken from SSSD project @pytest.mark.topology(KnownTopology.AD) @pytest.mark.topology(KnownTopology.LDAP) @pytest.mark.topology(KnownTopology.IPA) @pytest.mark.topology(KnownTopology.Samba) - def test__id(client: Client, provider: GenericProvider): + def test_id(client: Client, provider: GenericProvider): u = provider.user("tuser").add() provider.group("tgroup_1").add().add_member(u) provider.group("tgroup_2").add().add_member(u) @@ -38,70 +87,26 @@ your tests. .. seealso:: - A real life example of how ``pytest-mh`` can help test your code can be - seen in the `SSSD - `__ project. + This project was originally created for `SSSD `__ and you + can use `sssd-test-framework + `__ (built on top of pytest-mh) + and `the sssd tests + `__ for + inspiration. -When do I want use the framework? -********************************* - -* **Does your program affect the host in any way?** If yes, it is safer to run it in - virtual machine or in a container to avoid affecting your local host. - ``pytest-mh`` takes care of that. -* **Does your program use client-server model?** If yes, it is better to run the - client and the server on separate machines to make the tests more real. - ``pytest-mh`` takes care of that. -* **Does your program communicate with multiple backends?** If yes, you need to - be able to assign each test to a specific backend and also be able to reuse a - single test for multiple backends. ``pytest-mh`` takes care of that. -* **Do you need complex tests that changes state of the system, file system or - other programs or databases?** If yes, you need to make sure that all changes - are reverted when a test is done so the test does not affect other tests. - ``pytest-mh`` takes care of that. -* Does your program **talk to LDAP/IPA/AD/Samba/Kerberos**? If yes, ``pytest-mh`` - can help you with that. -* **Do you use** `pytest-multihost - `__ **framework for your current - tests?** ``pytest-mh`` is a full Python 3 re-implementation of the old - ``pytest-multihost`` plugin. It builds on all its features and takes it to - a whole new level. You definitely want to switch to ``pytest-mh``, - however it is not backwards compatible. - -When I don't want to use it? -**************************** - -* Do you want to test your Python code? Then this plugin will not help - you. It is designed for running system or applications tests, i.e. testing - your application as a whole. - -What does the framework do? -*************************** - -* Allows you to **run commands over SSH on remote hosts** (or virtual machines or - containers) using bash or Powershell. -* Allows you to **define your own roles with a provide fully typed API** to your - tests that fulfills all your needs. -* All **changes that you do on the remote host during a single test can be - completely reverted** so they do not affect other tests. -* Defines an available **multihost topology** - what roles are available in your - current setup. -* **Associates each test with certain topology** - defines what roles are - required to run the test. -* Supports **topology parametrization** - a single test can run on multiple - topologies. -* **Run only tests that can be run on available topology**. -* Provides **access to roles through dynamic pytest fixtures**. -* **The code is fully typed** - you get rich suggestions from your editor and the - types can be fully checked. -* **Everything can be extended**. +Table of Contents +================= .. toctree:: - :maxdepth: 2 - - quick-start - config - topology - classes - runtime-requirements - pytest - api + :maxdepth: 2 + + articles/get-started + articles/extending + articles/life-cycle + articles/bundled-utilities + articles/running-commands + articles/mhc-yaml + articles/writing-tests + articles/running-tests + articles/tips-and-tricks + api diff --git a/example/conftest.py b/example/conftest.py index ccff100..c933b2a 100644 --- a/example/conftest.py +++ b/example/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from lib.config import ExampleMultihostConfig +from framework.config import SUDOMultihostConfig from pytest_mh import MultihostPlugin @@ -13,4 +13,4 @@ # Setup pytest-mh def pytest_plugin_registered(plugin) -> None: if isinstance(plugin, MultihostPlugin): - plugin.config_class = ExampleMultihostConfig + plugin.config_class = SUDOMultihostConfig diff --git a/example/containers/client/Dockerfile b/example/containers/client/Dockerfile new file mode 100644 index 0000000..4cac987 --- /dev/null +++ b/example/containers/client/Dockerfile @@ -0,0 +1,11 @@ +FROM fedora:latest +RUN echo "Secret123" | passwd --stdin root +RUN dnf install -y systemd openssh-server && dnf clean all +RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config +RUN systemctl enable sshd + +RUN dnf install -y sudo sssd shadow-utils && dnf clean all +RUN systemctl disable sssd + +CMD ["/sbin/init"] +STOPSIGNAL SIGRTMIN+3 diff --git a/example/containers/dns/Dockerfile b/example/containers/dns/Dockerfile new file mode 100644 index 0000000..bd99aa7 --- /dev/null +++ b/example/containers/dns/Dockerfile @@ -0,0 +1,3 @@ +FROM docker.io/alpine:latest +RUN apk --no-cache add dnsmasq +ENTRYPOINT ["dnsmasq", "-k"] diff --git a/example/containers/dns/dnsmasq.conf b/example/containers/dns/dnsmasq.conf new file mode 100644 index 0000000..ec3abdc --- /dev/null +++ b/example/containers/dns/dnsmasq.conf @@ -0,0 +1,15 @@ +# dnsmasq configuration for sssd containers +# +# This makes sure that all machines are accessible through DNS including +# SRV and PTR records. + +log-queries +log-facility=- +local=/test/ + +# Disable caching so we always query AD and IPA DNS +cache-size=0 + +# Add A records for LDAP, client and other machines without own DNS server +address=/master.ldap.test/172.16.200.3 +address=/client.test/172.16.200.4 diff --git a/example/containers/docker-compose.yml b/example/containers/docker-compose.yml new file mode 100644 index 0000000..ecf836a --- /dev/null +++ b/example/containers/docker-compose.yml @@ -0,0 +1,74 @@ +services: + dns: + restart: always + build: dns + image: pytest-mh-example-dns + container_name: pytest-mh-example-dns + volumes: + - ./dns/dnsmasq.conf:/etc/dnsmasq.conf + cap_add: + - NET_RAW + - NET_ADMIN + - SYS_CHROOT + security_opt: + - apparmor=unconfined + - label=disable + - seccomp=unconfined + networks: + example: + ipv4_address: 172.16.200.2 + ldap: + build: ldap + image: pytest-mh-example-ldap + container_name: pytest-mh-example-ldap + hostname: master.ldap.test + dns: 172.16.200.2 + cap_add: + - SYS_PTRACE + - AUDIT_WRITE + - AUDIT_CONTROL + - SYS_CHROOT + - NET_ADMIN + security_opt: + - apparmor=unconfined + - label=disable + - seccomp=unconfined + networks: + example: + ipv4_address: 172.16.200.3 + client: + build: client + image: pytest-mh-example-client + container_name: pytest-mh-example-client + hostname: client.test + dns: 172.16.200.2 + cap_add: + - SYS_ADMIN + - SYS_PTRACE + - NET_RAW + - NET_ADMIN + - AUDIT_WRITE + - AUDIT_CONTROL + - SYS_CHROOT + - CAP_CHOWN + - CAP_DAC_OVERRIDE + - CAP_SETGID + - CAP_SETUID + - CAP_DAC_READ_SEARCH + security_opt: + - apparmor=unconfined + - label=disable + - seccomp=unconfined + networks: + example: + ipv4_address: 172.16.200.4 +networks: + example: + name: example + driver: bridge + ipam: + config: + - subnet: 172.16.200.0/24 + gateway: 172.16.200.1 + options: + driver: host-local diff --git a/example/containers/ldap/Dockerfile b/example/containers/ldap/Dockerfile new file mode 100644 index 0000000..dcf9792 --- /dev/null +++ b/example/containers/ldap/Dockerfile @@ -0,0 +1,18 @@ +FROM fedora:latest +RUN echo "Secret123" | passwd --stdin root +RUN dnf install -y systemd openssh-server && dnf clean all +RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config +RUN systemctl enable sshd + +RUN dnf install -y 389-ds-base && dnf clean all +COPY ./instance.inf /root/localhost.inf +COPY ./allow_anon.ldif /root/allow_anon.ldif +COPY ./allow_anon.service /etc/systemd/system/allow_anon.service +RUN sed -i 's/with_systemd = 1/with_systemd = 0/g' /usr/share/dirsrv/inf/defaults.inf +RUN dscreate from-file /root/localhost.inf +RUN sed -i 's/with_systemd = 0/with_systemd = 1/g' /usr/share/dirsrv/inf/defaults.inf +RUN systemctl enable dirsrv@localhost +RUN systemctl enable allow_anon.service + +CMD ["/sbin/init"] +STOPSIGNAL SIGRTMIN+3 diff --git a/example/containers/ldap/allow_anon.ldif b/example/containers/ldap/allow_anon.ldif new file mode 100644 index 0000000..b94e49d --- /dev/null +++ b/example/containers/ldap/allow_anon.ldif @@ -0,0 +1,4 @@ +dn: dc=ldap,dc=test +changetype: modify +add: aci +aci: (targetattr=*)(version 3.0; acl "Enable anyone read"; allow (read, search, compare)(userdn="ldap:///anyone");) diff --git a/example/containers/ldap/allow_anon.service b/example/containers/ldap/allow_anon.service new file mode 100644 index 0000000..9f9bc1c --- /dev/null +++ b/example/containers/ldap/allow_anon.service @@ -0,0 +1,10 @@ +[Unit] +Description=Allow anonymous access +After=dirsrv@localhost.service + +[Service] +Type=oneshot +ExecStart=ldapmodify -D "cn=Directory Manager" -w "Secret123" -H ldap://localhost -x -f /root/allow_anon.ldif + +[Install] +WantedBy=multi-user.target diff --git a/example/containers/ldap/instance.inf b/example/containers/ldap/instance.inf new file mode 100644 index 0000000..196cd24 --- /dev/null +++ b/example/containers/ldap/instance.inf @@ -0,0 +1,13 @@ +[general] +config_version = 2 +start = False +full_machine_name = master.ldap.test + +[slapd] +instance_name = localhost +root_dn = cn=Directory Manager +root_password = Secret123 + +[backend-userroot] +suffix = dc=ldap,dc=test +create_suffix_entry = True diff --git a/example/framework/__init__.py b/example/framework/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/example/framework/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/example/lib/config.py b/example/framework/config.py similarity index 56% rename from example/lib/config.py rename to example/framework/config.py index 7412967..2fb6754 100644 --- a/example/lib/config.py +++ b/example/framework/config.py @@ -4,46 +4,56 @@ from pytest_mh import MultihostConfig, MultihostDomain, MultihostHost, MultihostRole +__all__ = [ + "SUDOMultihostConfig", + "SUDOMultihostDomain", +] -class ExampleMultihostConfig(MultihostConfig): + +class SUDOMultihostConfig(MultihostConfig): @property def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]: """ - Map domain id to domain class. Asterisk ``*`` can be used as fallback - value. + All domains are mapped to :class:`SUDOMultihostDomain`. :rtype: Class name. """ - return {"*": ExampleMultihostDomain} + return {"*": SUDOMultihostDomain} -class ExampleMultihostDomain(MultihostDomain[ExampleMultihostConfig]): +class SUDOMultihostDomain(MultihostDomain[SUDOMultihostConfig]): @property def role_to_host_class(self) -> dict[str, Type[MultihostHost]]: """ - Map role to host class. Asterisk ``*`` can be used as fallback value. + Map roles to classes: + + * client to ClientHost + * ldap to LDAPHost :rtype: Class name. """ from .hosts.client import ClientHost - from .hosts.kdc import KDCHost + from .hosts.ldap import LDAPHost return { "client": ClientHost, - "kdc": KDCHost, + "ldap": LDAPHost, } @property def role_to_role_class(self) -> dict[str, Type[MultihostRole]]: """ - Map role to role class. Asterisk ``*`` can be used as fallback value. + Map roles to classes: + + * client to Client + * ldap to LDAP :rtype: Class name. """ from .roles.client import Client - from .roles.kdc import KDC + from .roles.ldap import LDAP return { "client": Client, - "kdc": KDC, + "ldap": LDAP, } diff --git a/example/framework/hosts/__init__.py b/example/framework/hosts/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/example/framework/hosts/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/example/framework/hosts/base.py b/example/framework/hosts/base.py new file mode 100644 index 0000000..71033e6 --- /dev/null +++ b/example/framework/hosts/base.py @@ -0,0 +1,30 @@ +"""Base classes sudo testing.""" + +from __future__ import annotations + +from pytest_mh import MultihostBackupHost +from pytest_mh.utils.fs import LinuxFileSystem +from pytest_mh.utils.services import SystemdServices + +from ..config import SUDOMultihostDomain + +__all__ = [ + "BaseHost", +] + + +class BaseHost(MultihostBackupHost[SUDOMultihostDomain]): + """ + Base class for all hosts. + + Requires implementation of :class:`pytest_mh.MultihostBackupHost` interface + and provides access to filesystem and systemd via :attr:`fs` and + :attr:`svc`. + """ + + def __init__(self, *args, **kwargs) -> None: + # Restore is handled in topology controllers + super().__init__(*args, auto_restore=False, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self) + self.svc: SystemdServices = SystemdServices(self) diff --git a/example/framework/hosts/client.py b/example/framework/hosts/client.py new file mode 100644 index 0000000..a8eae92 --- /dev/null +++ b/example/framework/hosts/client.py @@ -0,0 +1,142 @@ +"""IPA multihost host.""" + +from __future__ import annotations + +from pathlib import PurePosixPath +from typing import Any + +from pytest_mh.conn import ProcessLogLevel + +from .base import BaseHost + +__all__ = [ + "ClientHost", +] + + +class ClientHost(BaseHost): + """ + Sudo client host. + + Sudo tests are run on this machine. + + Implements backup and restore of sudo and SSSD. + + Expectations: + + * installed sudo + * installed sssd + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def pytest_setup(self) -> None: + super().pytest_setup() + + # SSSD is started only for selected tests by the topology controller. + # Make sure it is stopped by default. + self.svc.stop("sssd.service") + + def start(self) -> None: + raise NotImplementedError("sudo is not a service") + + def stop(self) -> None: + raise NotImplementedError("sudo is not a service") + + def backup(self) -> Any: + """ + Backup sudoers. + + :return: Backup data. + :rtype: Any + """ + self.logger.info("Creating backup of sudo client") + + # sudo + self.fs.backup("/etc/sudo.conf") + self.fs.backup("/etc/sudoers") + self.fs.backup("/etc/sudoers.d") + + # SSSD + self.fs.backup("/etc/sssd") + self.fs.backup("/var/lib/sss") + self.fs.backup("/var/log/sssd") + + result = self.conn.run( + """ + set -ex + + function backup { + if [ -d "$1" ] || [ -f "$1" ]; then + cp --force --archive "$1" "$2" + fi + } + + path=`mktemp -d` + backup /etc/sudo.conf "$path/sudo.conf" + backup /etc/sudoers "$path/sudoers" + backup /etc/sudoers.d "$path/sudoers.d" + backup /etc/sssd "$path/sssd" + backup /var/log/sssd "$path/sssd-logs" + backup /var/lib/sss "$path/sssd-lib" + + echo $path + """, + log_level=ProcessLogLevel.Error, + ) + + return PurePosixPath(result.stdout_lines[-1].strip()) + + def restore(self, backup_data: Any | None) -> None: + """ + Restore sudoers. + + :param backup_data: Backup data. + :type backup_data: PurePath | Sequence[PurePath] | Any | None + """ + # This would have been called automatically by the utility, + # therefore there is no need for these calls. However, it is + # good to call it explicitly for clarity. + self.logger.info("Restoring sudo client from backup") + + # sudo + self.fs.restore("/etc/sudo.conf") + self.fs.restore("/etc/sudoers") + self.fs.restore("/etc/sudoers.d") + + # SSSD + self.fs.restore("/etc/sssd") + self.fs.restore("/var/lib/sss") + self.fs.restore("/var/log/sssd") + + if backup_data is None: + return + + if not isinstance(backup_data, PurePosixPath): + raise TypeError(f"Expected PurePosixPath, got {type(backup_data)}") + + backup_path = str(backup_data) + + self.logger.info(f"Restoring client data from {backup_path}") + self.conn.run( + f""" + set -ex + + function restore {{ + rm --force --recursive "$2" + if [ -d "$1" ] || [ -f "$1" ]; then + cp --force --archive "$1" "$2" + fi + }} + + rm --force --recursive /etc/sudo.conf /etc/sudoers /etc/sudoers.d /etc/sssd /var/lib/sss /var/log/sssd + restore "{backup_path}/sudo.conf" /etc/sudo.conf + restore "{backup_path}/sudoers" /etc/sudoers + restore "{backup_path}/sudoers.d" /etc/sudoers.d + restore "{backup_path}/sssd" /etc/sssd + restore "{backup_path}/sssd-logs" /var/log/sssd + restore "{backup_path}/sssd-lib" /var/lib/sss + """, + log_level=ProcessLogLevel.Error, + ) diff --git a/example/framework/hosts/ldap.py b/example/framework/hosts/ldap.py new file mode 100644 index 0000000..15b219a --- /dev/null +++ b/example/framework/hosts/ldap.py @@ -0,0 +1,220 @@ +"""LDAP multihost host.""" + +from __future__ import annotations + +from typing import Any + +import ldap +import ldap.modlist +from ldap.ldapobject import ReconnectLDAPObject + +from .base import BaseHost + +__all__ = [ + "LDAPHost", +] + + +class LDAPHost(BaseHost): + """ + LDAP Host. + + Provides backup and restore of an 389-ds server. It also maintains + a connection to the server via :attr:`ldap_conn`. + + Expectations: + + * 389-ds-server is installed + * sudo schema is installed + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + # Add custom configuration options + self.svc_name = self.config.get("service_name", "dirsrv@localhost.service") + """Dirsrv service name ``config.service_name``, defaults to ``dirsrv@localhost.service``""" + + self.binddn: str = self.config.get("binddn", "cn=Directory Manager") + """Bind DN ``config.binddn``, defaults to ``cn=Directory Manager``""" + + self.bindpw: str = self.config.get("bindpw", "Secret123") + """Bind password ``config.bindpw``, defaults to ``Secret123``""" + + # Lazy properties. + self.__ldap_conn: ReconnectLDAPObject | None = None + self.__naming_context: str | None = None + + @property + def ldap_conn(self) -> ReconnectLDAPObject: + """ + LDAP connection (``python-ldap`` library). + + :rtype: ReconnectLDAPObject + """ + if not self.__ldap_conn: + # Use host from SSH if possible, otherwise fallback to hostname + host = getattr(self.conn, "host", self.hostname) + + # Setup connection + newconn = ReconnectLDAPObject(f"ldap://{host}") + newconn.protocol_version = ldap.VERSION3 + newconn.set_option(ldap.OPT_REFERRALS, 0) + + # Setup TLS + newconn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + newconn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + newconn.start_tls_s() + + # Authenticate + newconn.simple_bind_s(self.binddn, self.bindpw) + self.__ldap_conn = newconn + + return self.__ldap_conn + + @property + def naming_context(self) -> str: + """ + Default naming context. + + :raises ValueError: If default naming context can not be obtained. + :rtype: str + """ + if not self.__naming_context: + attr = "defaultNamingContext" + result = self.ldap_conn.search_s("", ldap.SCOPE_BASE, attrlist=[attr]) + if len(result) != 1: + raise ValueError(f"Unexpected number of results for rootDSE query: {len(result)}") + + (_, values) = result[0] + if attr not in values: + raise ValueError(f"Unable to find {attr}") + + self.__naming_context = str(values[attr][0].decode("utf-8")) + + return self.__naming_context + + def disconnect(self) -> None: + """ + Disconnect LDAP connection. + """ + if self.__ldap_conn is not None: + self.__ldap_conn.unbind() + self.__ldap_conn = None + + def ldap_result_to_dict( + self, result: list[tuple[str, dict[str, list[bytes]]]] + ) -> dict[str, dict[str, list[bytes]]]: + """ + Convert result from python-ldap library from tuple into a dictionary + to simplify lookup by distinguished name. + + :param result: Search result from python-ldap. + :type result: tuple[str, dict[str, list[bytes]]] + :return: Dictionary with distinguished name as key and attributes as value. + :rtype: dict[str, dict[str, list[bytes]]] + """ + return dict((dn, attrs) for dn, attrs in result if dn is not None) + + def start(self) -> None: + self.svc.start(self.svc_name) + + def stop(self) -> None: + self.svc.stop(self.svc_name) + + def backup(self) -> Any: + """ + Backup all directory server data. + + Full backup of ``cn=config`` and default naming context is performed. + This is done by simple LDAP search on given base dn and remembering the + contents. The operation is usually very fast. + + :return: Backup data. + :rtype: Any + """ + self.logger.info("Creating backup of LDAP server") + + data = self.ldap_conn.search_s(self.naming_context, ldap.SCOPE_SUBTREE) + config = self.ldap_conn.search_s("cn=config", ldap.SCOPE_BASE) + nc = self.ldap_conn.search_s(self.naming_context, ldap.SCOPE_BASE, attrlist=["aci"]) + + dct = self.ldap_result_to_dict(data) + dct.update(self.ldap_result_to_dict(config)) + dct.update(self.ldap_result_to_dict(nc)) + + return dct + + def restore(self, backup_data: Any | None) -> None: + """ + Restore directory server data. + + Current directory server content in ``cn=config`` and default naming + context is modified to its original data. This is done by computing a + difference between original data obtained by :func:`backup` and then + calling add, delete and modify operations to convert current state to + the original state. This operation is usually very fast. + + :param backup_data: Backup data. + :type backup_data: PurePath | Sequence[PurePath] | Any | None + """ + if backup_data is None: + return + + self.logger.info("Restoring LDAP server from memory") + + if not isinstance(backup_data, dict): + raise TypeError(f"Expected dict, got {type(backup_data)}") + + data = self.ldap_conn.search_s(self.naming_context, ldap.SCOPE_SUBTREE) + config = self.ldap_conn.search_s("cn=config", ldap.SCOPE_BASE) + nc = self.ldap_conn.search_s(self.naming_context, ldap.SCOPE_BASE, attrlist=["aci"]) + + # Convert list of tuples to dictionary for better lookup + data = self.ldap_result_to_dict(data) + data.update(self.ldap_result_to_dict(config)) + data.update(self.ldap_result_to_dict(nc)) + + for dn, attrs in reversed(data.items()): + # Restore records that were modified + if dn in backup_data: + original_attrs = backup_data[dn] + modlist = ldap.modlist.modifyModlist(attrs, original_attrs) + modlist = self.__filter_modlist(dn, modlist) + if modlist: + self.ldap_conn.modify_s(dn, modlist) + + for dn, attrs in reversed(data.items()): + # Delete records that were added + if dn not in backup_data: + self.ldap_conn.delete_s(dn) + continue + + for dn, attrs in backup_data.items(): + # Add back records that were deleted + if dn not in data: + self.ldap_conn.add_s(dn, list(attrs.items())) + + def __filter_modlist(self, dn: str, modlist: list) -> list: + """ + Remove special items that can not be modified from ``modlist``. + + :param dn: Object's DN. + :type dn: str + :param modlist: LDAP modlist. + :type modlist: list + :return: Filtered modlist. + :rtype: list + """ + if dn != "cn=config": + return modlist + + result = [] + for op, attr, value in modlist: + # We are not allowed to touch these + if attr.startswith("nsslapd"): + continue + + result.append((op, attr, value)) + + return result diff --git a/example/framework/roles/__init__.py b/example/framework/roles/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/example/framework/roles/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/example/framework/roles/base.py b/example/framework/roles/base.py new file mode 100644 index 0000000..6d17f02 --- /dev/null +++ b/example/framework/roles/base.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Self, TypeVar + +from pytest_mh import MultihostRole +from pytest_mh.utils.fs import LinuxFileSystem +from pytest_mh.utils.services import SystemdServices + +from ..hosts.base import BaseHost + +__all__ = [ + "GenericProvider", +] + + +HostType = TypeVar("HostType", bound=BaseHost) + + +class GenericProvider(MultihostRole[HostType], ABC): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.fs: LinuxFileSystem = LinuxFileSystem(self.host) + """ + File system utilities. + """ + + self.svc: SystemdServices = SystemdServices(self.host) + """ + Systemd services. + """ + + @abstractmethod + def user(self, name: str) -> User: + """ + Get user object. + + :param name: User name. + :type name: str + :return: New user object. + :rtype: User + """ + pass + + @abstractmethod + def group(self, name: str) -> Group: + """ + Get group object. + + :param name: Group name. + :type name: str + :return: New group object. + :rtype: Group + """ + pass + + @abstractmethod + def sudorule(self, name: str) -> Sudorule: + """ + Get sudo rule object. + + :param name: Rule name. + :type name: str + :return: New sudo rule object. + :rtype: Sudorule + """ + pass + + +class User(ABC): + """ + Abstract user type. + """ + + def __init__(self, name: str) -> None: + """ + :param name: User name. + :type name: str + """ + self.name: str = name + """User name.""" + + @abstractmethod + def add( + self, + *, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new user. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + pass + + +class Group(ABC): + """ + Abstract group type. + """ + + def __init__(self, name: str) -> None: + """ + :param name: Group name. + :type name: str + """ + self.name: str = name + """Group name.""" + + @abstractmethod + def add( + self, + *, + gid: int | None = None, + ) -> Self: + """ + Create new local group. + + :param gid: Group id, defaults to None + :type gid: int | None, optional + :return: Self. + :rtype: Self + """ + pass + + def add_member(self, member: User) -> Self: + """ + Add group member. + + :param member: User or group to add as a member. + :type member: User + :return: Self. + :rtype: Self + """ + return self.add_members([member]) + + @abstractmethod + def add_members(self, members: list[User]) -> Self: + """ + Add multiple group members. + + :param member: List of users to add as members. + :type member: list[User] + :return: Self. + :rtype: Self + """ + pass + + def remove_member(self, member: User) -> Self: + """ + Remove group member. + + :param member: User or group to remove from the group. + :type member: User + :return: Self. + :rtype: Self + """ + return self.remove_members([member]) + + @abstractmethod + def remove_members(self, members: list[User]) -> Self: + """ + Remove multiple group members. + + :param member: List of users or groups to remove from the group. + :type member: list[User] + :return: Self. + :rtype: Self + """ + pass + + +class Sudorule(ABC): + """ + Abstract sudo rule management. + """ + + def __init__( + self, + name: str, + ) -> None: + """ + :param name: Sudo rule name. + :type name: str + """ + self.name: str = name + """Sudo rule name.""" + + @abstractmethod + def add( + self, + *, + user: User | Group, + command: str | list[str] | None = None, + nopasswd: bool | None = None, + ) -> Self: + """ + Create new sudo rule. + + :param user: Sudo user, defaults to None + :type user: User | Group + :param command: Sudo command, defaults to None + :type command: str | list[str], optional + :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) + :type nopasswd: bool | None, optional + :return: Self. + :rtype: Self + """ + pass diff --git a/example/framework/roles/client.py b/example/framework/roles/client.py new file mode 100644 index 0000000..c60bfc8 --- /dev/null +++ b/example/framework/roles/client.py @@ -0,0 +1,97 @@ +"""Client multihost role.""" + +from __future__ import annotations + +from typing import Self + +from pytest_mh.utils.fs import LinuxFileSystem + +from ..hosts.client import ClientHost +from ..utils.local_users import LocalGroup, LocalUser, LocalUsersUtils +from ..utils.sudo import SUDOUtils +from .base import GenericProvider, Group, Sudorule, User + +__all__ = [ + "Client", +] + + +class Client(GenericProvider[ClientHost]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.sudo: SUDOUtils = SUDOUtils(self.host) + """ + Methods to test sudo. + """ + + self.local_users: LocalUsersUtils = LocalUsersUtils(self.host, self.fs) + """ + Management of local users and groups. + """ + + def user(self, name: str) -> LocalUser: + return self.local_users.user(name) + + def group(self, name: str) -> LocalGroup: + return self.local_users.group(name) + + def sudorule(self, name: str) -> LocalSudorule: + return LocalSudorule(self.fs, name) + + +class LocalSudorule(Sudorule): + """ + Local sudo rule management. + """ + + def __init__(self, fs: LinuxFileSystem, name: str) -> None: + """ + :param fs: Filesystem utility. + :type fs: LinuxFileSystem + :param name: Sudo rule name. + :type name: str + """ + super().__init__(name) + + self.fs: LinuxFileSystem = fs + + def add( + self, + *, + user: User | Group, + command: str | list[str] | None = None, + nopasswd: bool | None = None, + ) -> Self: + """ + Create new sudo rule. + + :param user: Sudo user, defaults to None + :type user: User | Group + :param host: Sudo host, defaults to None + :type host: str | list[str], optional + :param command: Sudo command, defaults to None + :type command: str | list[str], optional + :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) + :type nopasswd: bool | None, optional + :return: Self. + :rtype: Self + """ + sudo_user = user.name + if isinstance(user, Group): + sudo_user = f"%{user.name}" + + option = "" + if nopasswd is not None: + option = "NOPASSWD: " if nopasswd else "" + + if command is None: + command = "ALL" + + if not isinstance(command, list): + command = [command] + + rule = f"{sudo_user} ALL=(root) {option}{', '.join(command)}\n" + self.fs.append("/etc/sudoers", rule) + + return self diff --git a/example/framework/roles/ldap.py b/example/framework/roles/ldap.py new file mode 100644 index 0000000..004ae67 --- /dev/null +++ b/example/framework/roles/ldap.py @@ -0,0 +1,390 @@ +"""Client multihost role.""" + +from __future__ import annotations + +import base64 +import hashlib +from itertools import count +from typing import Any, Self + +import ldap + +from ..hosts.ldap import LDAPHost +from ..roles.base import GenericProvider, Group, Sudorule, User + +__all__ = [ + "LDAP", +] + + +class LDAP(GenericProvider[LDAPHost]): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.auto_uid: count[int] = count(10000) + """Automatically generated uid.""" + + self.auto_gid: count[int] = count(10000) + """Automatically generated gid.""" + + def setup(self) -> None: + """ + Create default organizational units. + + It is not needed to remove them in teardown since the whole LDAP tree + is restored in topology controller after each test. + """ + # Note: it would be better to move this to the topology controllers, so + # this code runs only once. However, it is placed here to demonstrate + # role setup. + for name in ["users", "groups", "sudoers"]: + dn = f"ou={name},{self.host.naming_context}" + attrs: dict[str, Any] = {"objectClass": "organizationalUnit", "ou": name} + + self.logger.info(f"Creating organizational unit: {dn}") + self.ldap_add(dn, attrs) + + def user(self, name: str) -> LDAPUser: + return LDAPUser(self, name) + + def group(self, name: str) -> LDAPGroup: + return LDAPGroup(self, name) + + def sudorule(self, name: str) -> LDAPSudorule: + return LDAPSudorule(self, name) + + def hash_password(self, password: str) -> str: + """ + Compute sha256 hash of a password that can be used as a value. + + :param password: Password to hash. + :type password: str + :return: Base64 of sha256 hash digest. + :rtype: str + """ + digest = hashlib.sha256(password.encode("utf-8")).digest() + b64 = base64.b64encode(digest) + + return "{SHA256}" + b64.decode("utf-8") + + def ldap_add(self, dn: str, attrs: dict[str, Any | list[Any] | None]) -> None: + """ + Add an LDAP entry. + + :param dn: Distinguished name. + :type dn: str + :param attrs: Attributes, key is attribute name. + :type attrs: dict[str, Any | list[Any] | None] + """ + addlist = [] + for attr, values in attrs.items(): + bytes_values = self.__values_to_bytes(values) + + # Skip if the value is None + if bytes_values is None: + continue + + addlist.append((attr, bytes_values)) + + self.host.ldap_conn.add_s(dn, addlist) + + def ldap_delete(self, dn: str) -> None: + """ + Delete LDAP entry. + + :param dn: Distinguished name. + :type dn: str + """ + self.host.ldap_conn.delete_s(dn) + + def ldap_modify( + self, + dn: str, + *, + add: dict[str, Any | list[Any] | None] | None = None, + replace: dict[str, Any | list[Any] | None] | None = None, + delete: dict[str, Any | list[Any] | None] | None = None, + ) -> None: + """ + Modify LDAP entry. + + :param dn: Distinguished name. + :type dn: str + :param add: Attributes to add, defaults to None + :type add: dict[str, Any | list[Any] | None] | None, optional + :param replace: Attributes to replace, defaults to None + :type replace: dict[str, Any | list[Any] | None] | None, optional + :param delete: Attributes to delete, defaults to None + :type delete: dict[str, Any | list[Any] | None] | None, optional + """ + modlist = [] + + if add is None: + add = {} + + if replace is None: + replace = {} + + if delete is None: + delete = {} + + for attr, values in add.items(): + modlist.append((ldap.MOD_ADD, attr, self.__values_to_bytes(values))) + + for attr, values in replace.items(): + modlist.append((ldap.MOD_REPLACE, attr, self.__values_to_bytes(values))) + + for attr, values in delete.items(): + modlist.append((ldap.MOD_DELETE, attr, self.__values_to_bytes(values))) + + self.host.ldap_conn.modify_s(dn, modlist) + + def __values_to_bytes(self, values: Any | list[Any]) -> list[bytes] | None: + """ + Convert values to bytes. Any value is converted to string and then + encoded into bytes. The input can be either single value or list of + values or None in which case None is returned. + + :param values: Values. + :type values: Any | list[Any] + :return: Values converted to bytes. + :rtype: list[bytes] + """ + if values is None: + return None + + if not isinstance(values, list): + values = [values] + + return [str(v).encode("utf-8") for v in values] + + +class LDAPUser(User): + """ + LDAP user management. + """ + + def __init__(self, role: LDAP, name: str) -> None: + """ + :param role: LDAP role object. + :type role: LDAP + :param name: User name. + :type name: str + """ + super().__init__(name) + + self.role: LDAP = role + self.dn: str = f"cn={name},ou=users,{self.role.host.naming_context}" + + def add( + self, + *, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new LDAP user. + + User and group id is assigned automatically if they are not set. Other + parameters that are not set are ignored. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + self.role.logger.info(f"Creating LDAP user {self.name}") + + # Assign uid and gid automatically if not present to have the same + # interface as other services. + if uid is None: + uid = next(self.role.auto_uid) + + if gid is None: + gid = uid + + attrs = { + "objectClass": "posixAccount", + "cn": self.name, + "uid": self.name, + "uidNumber": uid, + "gidNumber": gid, + "homeDirectory": f"/home/{self.name}" if home is None else home, + "userPassword": self.role.hash_password(password) if password is not None else None, + "gecos": gecos, + "loginShell": shell, + } + + self.role.ldap_add(self.dn, attrs) + return self + + +class LDAPGroup(Group): + """ + LDAP group management. + """ + + def __init__(self, role: LDAP, name: str) -> None: + """ + :param role: LDAP role object. + :type role: LDAP + :param name: Group name. + :type name: str + """ + super().__init__(name) + + self.role: LDAP = role + self.dn: str = f"cn={name},ou=groups,{self.role.host.naming_context}" + + def add( + self, + *, + gid: int | None = None, + members: list[User] | None = None, + password: str | None = None, + description: str | None = None, + ) -> Self: + """ + Create new LDAP group. + + Group id is assigned automatically if it is not set. Other parameters + that are not set are ignored. + + :param gid: Group id, defaults to None + :type gid: int | None, optional + :param members: List of group members, defaults to None + :type members: list[User] | None, optional + :param password: Group password, defaults to None + :type password: str | None, optional + :param description: Description, defaults to None + :type description: str | None, optional + :return: Self. + :rtype: Self + """ + self.role.logger.info(f"Creating LDAP group {self.name}") + + # Assign gid automatically if not present to have the same + # interface as other services. + if gid is None: + gid = next(self.role.auto_gid) + + attrs = { + "objectClass": "posixGroup", + "cn": self.name, + "gidNumber": gid, + "userPassword": self.role.hash_password(password) if password is not None else None, + "description": description, + "memberUid": None, + } + + self.role.ldap_add(self.dn, attrs) + if members is not None: + self.add_members(members) + + return self + + def add_members(self, members: list[User]) -> Self: + """ + Add multiple group members. + + :param members: Users to add as members. + :type members: list[User] + :return: Self. + :rtype: Self + """ + attrs: dict[str, Any] = {"memberUid": [x.name for x in members]} + + self.role.logger.info(f"Adding members to LDAP group {self.name}: {attrs['memberUid']}") + self.role.ldap_modify(self.dn, add=attrs) + return self + + def remove_members(self, members: list[User]) -> Self: + """ + Remove multiple group members. + + :param members: Users to remove from this group. + :type members: list[User] + :return: Self. + :rtype: Self + """ + attrs: dict[str, Any] = {"memberUid": [x.name for x in members]} + + self.role.logger.info(f"Removing members from LDAP group {self.name}: {attrs['memberUid']}") + self.role.ldap_modify(self.dn, delete=attrs) + return self + + +class LDAPSudorule(Sudorule): + """ + LDAP sudo rule management. + """ + + def __init__(self, role: LDAP, name: str) -> None: + """ + :param role: LDAP role object. + :type role: LDAP + :param name: Sudo rule name. + :type name: str + """ + super().__init__(name) + + self.role: LDAP = role + self.dn: str = f"cn={name},ou=sudoers,{self.role.host.naming_context}" + + def add( + self, + *, + user: User | Group, + command: str | list[str] | None = None, + nopasswd: bool | None = None, + ) -> Self: + """ + Create new sudo rule. + + :param user: Sudo user, defaults to None + :type user: User + :param host: Sudo host, defaults to None + :type host: str | list[str], optional + :param command: Sudo command, defaults to None + :type command: str | list[str], optional + :param nopasswd: If true, no authentication is required (NOPASSWD), defaults to None (no change) + :type nopasswd: bool | None, optional + :return: Self. + :rtype: Self + """ + sudo_user = user.name + if isinstance(user, Group): + sudo_user = f"%{user.name}" + + option = None + if nopasswd is not None: + option = "!authenticate" if nopasswd else "authenticate" + + if command is None: + command = "ALL" + + attrs = { + "objectClass": "sudoRole", + "cn": self.name, + "sudoUser": sudo_user, + "sudoHost": "ALL", + "sudoCommand": command, + "sudoOption": option, + } + + self.role.ldap_add(self.dn, attrs) + return self diff --git a/example/framework/topology.py b/example/framework/topology.py new file mode 100644 index 0000000..8a3806e --- /dev/null +++ b/example/framework/topology.py @@ -0,0 +1,72 @@ +"""Predefined well-known topologies.""" + +from __future__ import annotations + +from enum import unique +from typing import final + +from pytest_mh import KnownTopologyBase, KnownTopologyGroupBase, Topology, TopologyDomain, TopologyMark + +from .topology_controllers import LDAPTopologyController, SSSDTopologyController, SudoersTopologyController + +__all__ = [ + "KnownTopology", + "KnownTopologyGroup", +] + + +@final +@unique +class KnownTopology(KnownTopologyBase): + """ + Well-known topologies that can be given to ``pytest.mark.topology`` + directly. It is expected to use these values in favor of providing + custom marker values. + + .. code-block:: python + :caption: Example usage + + @pytest.mark.topology(KnownTopology.LDAP) + def test_ldap(client: Client, ldap: LDAP): + assert True + """ + + Sudoers = TopologyMark( + name="sudoers", + topology=Topology(TopologyDomain("sudo", client=1)), + controller=SudoersTopologyController(), + fixtures=dict(client="sudo.client[0]", provider="sudo.client[0]"), + ) + + LDAP = TopologyMark( + name="ldap", + topology=Topology(TopologyDomain("sudo", client=1, ldap=1)), + controller=LDAPTopologyController(), + fixtures=dict(client="sudo.client[0]", ldap="sudo.ldap[0]", provider="sudo.ldap[0]"), + ) + + SSSD = TopologyMark( + name="sssd", + topology=Topology(TopologyDomain("sudo", client=1, ldap=1)), + controller=SSSDTopologyController(), + fixtures=dict(client="sudo.client[0]", ldap="sudo.ldap[0]", provider="sudo.ldap[0]"), + ) + + +class KnownTopologyGroup(KnownTopologyGroupBase): + """ + Groups of well-known topologies that can be given to ``pytest.mark.topology`` + directly. It is expected to use these values in favor of providing + custom marker values. + + The test is parametrized and runs multiple times, once per each topology. + + .. code-block:: python + :caption: Example usage (runs on AD, IPA, LDAP and Samba topology) + + @pytest.mark.topology(KnownTopologyGroup.AnyProvider) + def test_ldap(client: Client, provider: GenericProvider): + assert True + """ + + AnyProvider = [KnownTopology.Sudoers, KnownTopology.LDAP, KnownTopology.SSSD] diff --git a/example/framework/topology_controllers.py b/example/framework/topology_controllers.py new file mode 100644 index 0000000..fa86705 --- /dev/null +++ b/example/framework/topology_controllers.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import re + +from pytest_mh import BackupTopologyController + +from .config import SUDOMultihostConfig +from .hosts.client import ClientHost +from .hosts.ldap import LDAPHost + +__all__ = [ + "LDAPTopologyController", + "SSSDTopologyController", + "SudoersTopologyController", +] + + +class BaseTopologyController(BackupTopologyController[SUDOMultihostConfig]): + def set_nsswitch(self, client: ClientHost, contents: dict[str, str]) -> None: + """ + Set lines in nsswitch.conf. + """ + self.logger.info(f"Setting 'nsswitch.conf:sudoers={contents}' on {client.hostname}") + + nsswitch = client.fs.read("/etc/nsswitch.conf") + + # remove any sudoers line + for key in contents.keys(): + re.sub(rf"^{key}:.*$", "", nsswitch, flags=re.MULTILINE) + + # add new sudoers line + nsswitch += "\n" + for key, value in contents.items(): + nsswitch += f"{key}: {value}\n" + + # write the file, backup of the file is taken automatically + client.fs.write("/etc/authselect/nsswitch.conf", nsswitch, dedent=False) + + def configure_sssd(self, client: ClientHost, ldap: LDAPHost) -> None: + """ + Configure SSSD for identity. + """ + client.fs.backup("/etc/authselect") + client.conn.run("authselect select sssd --force --nobackup") + + # Configure SSSD + client.fs.rm("/etc/sssd/conf.d") + client.fs.write( + "/etc/sssd/sssd.conf", + f""" + [sssd] + debug_level = 0xfff0 + services = nss, pam, sudo + domains = test + + [sudo] + debug_level = 0xfff0 + + [nss] + debug_level = 0xfff0 + + [pam] + debug_level = 0xfff0 + + [domain/test] + debug_level = 0xfff0 + id_provider = ldap + ldap_uri = ldap://{ldap.hostname} + ldap_tls_reqcert = never + """, + mode="0600", + ) + + # Remove SSSD data to start fresh + client.conn.run( + """ + rm -fr /var/lib/sss/db/* /var/lib/sss/mc/* /var/log/sssd/* + """ + ) + + +class SudoersTopologyController(BaseTopologyController): + @BackupTopologyController.restore_vanilla_on_error + def topology_setup(self, client: ClientHost) -> None: + # Set sudo to use sudoers as source + self.set_nsswitch(client, {"sudoers": "files"}) + + # Backup all hosts so we can restore to this state after each test + super().topology_setup() + + +class LDAPTopologyController(BaseTopologyController): + @BackupTopologyController.restore_vanilla_on_error + def topology_setup(self, client: ClientHost, ldap: LDAPHost) -> None: + self.configure_sssd(client, ldap) + + # Configure ldap client + client.fs.write( + "/etc/ldap.conf", + f""" + uri ldap://{ldap.hostname} + sudoers_base ou=sudoers,{ldap.naming_context} + tls_checkpeer no + """, + ) + + # Set sudo to use LDAP as source + self.set_nsswitch(client, {"sudoers": "ldap"}) + + # Backup all hosts so we can restore to this state after each test + super().topology_setup() + + +class SSSDTopologyController(BaseTopologyController): + @BackupTopologyController.restore_vanilla_on_error + def topology_setup(self, client: ClientHost, ldap: LDAPHost) -> None: + # Configure SSSD to use LDAP as a backend + self.configure_sssd(client, ldap) + + # Set sudo to use SSSD as source + self.set_nsswitch(client, {"sudoers": "sss"}) + + # Backup all hosts so we can restore to this state after each test + super().topology_setup() diff --git a/example/framework/utils/__init__.py b/example/framework/utils/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/example/framework/utils/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/example/framework/utils/local_users.py b/example/framework/utils/local_users.py new file mode 100644 index 0000000..d371eb5 --- /dev/null +++ b/example/framework/utils/local_users.py @@ -0,0 +1,224 @@ +"Managing local users and groups." + +from __future__ import annotations + +from typing import Self + +from pytest_mh import MultihostHost, MultihostUtility +from pytest_mh.cli import CLIBuilder, CLIBuilderArgs +from pytest_mh.conn import ProcessLogLevel +from pytest_mh.utils.fs import LinuxFileSystem + +from ..roles.base import Group, User + +__all__ = [ + "LocalGroup", + "LocalUser", + "LocalUsersUtils", +] + + +class LocalUsersUtils(MultihostUtility[MultihostHost]): + """ + Management of local users and groups. + + .. note:: + + All changes are automatically reverted when a test is finished. + """ + + def __init__(self, host: MultihostHost, fs: LinuxFileSystem) -> None: + """ + :param host: Remote host instance. + :type host: MultihostHost + """ + super().__init__(host) + + self.cli: CLIBuilder = CLIBuilder(host.conn) + self.fs: LinuxFileSystem = fs + self._users: list[str] = [] + self._groups: list[str] = [] + + def teardown(self) -> None: + """ + Delete any added user and group. + """ + cmd = "" + + if self._users: + cmd += "\n".join([f"userdel '{x}' --force --remove" for x in self._users]) + cmd += "\n" + + if self._groups: + cmd += "\n".join([f"groupdel '{x}' -f" for x in self._groups]) + cmd += "\n" + + if cmd: + self.host.conn.run("set -e\n\n" + cmd) + + super().teardown() + + def user(self, name: str) -> LocalUser: + """ + Get user object. + + :param name: User name. + :type name: str + :return: New user object. + :rtype: LocalUser + """ + return LocalUser(self, name) + + def group(self, name: str) -> LocalGroup: + """ + Get group object. + + :param name: Group name. + :type name: str + :return: New group object. + :rtype: LocalGroup + """ + return LocalGroup(self, name) + + +class LocalUser(User): + """ + Management of local users. + """ + + def __init__(self, util: LocalUsersUtils, name: str) -> None: + """ + :param util: LocalUsersUtils utility object. + :type util: LocalUsersUtils + :param name: User name. + :type name: str + """ + super().__init__(name) + self.util = util + + def add( + self, + *, + uid: int | None = None, + gid: int | None = None, + password: str | None = "Secret123", + home: str | None = None, + gecos: str | None = None, + shell: str | None = None, + ) -> Self: + """ + Create new local user. + + :param uid: User id, defaults to None + :type uid: int | None, optional + :param gid: Primary group id, defaults to None + :type gid: int | None, optional + :param password: Password, defaults to 'Secret123' + :type password: str, optional + :param home: Home directory, defaults to None + :type home: str | None, optional + :param gecos: GECOS, defaults to None + :type gecos: str | None, optional + :param shell: Login shell, defaults to None + :type shell: str | None, optional + :return: Self. + :rtype: Self + """ + if home is not None: + self.util.fs.backup(home) + + args: CLIBuilderArgs = { + "name": (self.util.cli.option.POSITIONAL, self.name), + "uid": (self.util.cli.option.VALUE, uid), + "gid": (self.util.cli.option.VALUE, gid), + "home": (self.util.cli.option.VALUE, home), + "gecos": (self.util.cli.option.VALUE, gecos), + "shell": (self.util.cli.option.VALUE, shell), + } + + passwd = f" && passwd --stdin '{self.name}'" if password else "" + self.util.logger.info(f'Creating local user "{self.name}"') + self.util.host.conn.run( + self.util.cli.command("useradd", args) + passwd, input=password, log_level=ProcessLogLevel.Error + ) + + self.util._users.append(self.name) + return self + + +class LocalGroup(Group): + """ + Management of local groups. + """ + + def __init__(self, util: LocalUsersUtils, name: str) -> None: + """ + :param util: LocalUsersUtils utility object. + :type util: LocalUsersUtils + :param name: Group name. + :type name: str + """ + super().__init__(name) + self.util = util + + def add( + self, + *, + gid: int | None = None, + ) -> Self: + """ + Create new local group. + + :param gid: Group id, defaults to None + :type gid: int | None, optional + :return: Self. + :rtype: elf + """ + args: CLIBuilderArgs = { + "name": (self.util.cli.option.POSITIONAL, self.name), + "gid": (self.util.cli.option.VALUE, gid), + } + + self.util.logger.info(f'Creating local group "{self.name}"') + self.util.host.conn.run(self.util.cli.command("groupadd", args), log_level=ProcessLogLevel.Silent) + self.util._groups.append(self.name) + + return self + + def add_members(self, members: list[User]) -> Self: + """ + Add multiple group members. + + :param member: List of users or groups to add as members. + :type member: list[User] + :return: Self. + :rtype: Self + """ + self.util.logger.info(f'Adding members to group "{self.name}"') + + if not members: + return self + + cmd = "\n".join([f"groupmems --group '{self.name}' --add '{x.name}'" for x in members]) + self.util.host.conn.run("set -ex\n" + cmd, log_level=ProcessLogLevel.Error) + + return self + + def remove_members(self, members: list[User]) -> Self: + """ + Remove multiple group members. + + :param member: List of users or groups to remove from the group. + :type member: list[User] + :return: Self. + :rtype: Self + """ + self.util.logger.info(f'Removing members from group "{self.name}"') + + if not members: + return self + + cmd = "\n".join([f"groupmems --group '{self.name}' --delete '{x.name}'" for x in members]) + self.util.host.conn.run("set -ex\n" + cmd, log_level=ProcessLogLevel.Error) + + return self diff --git a/example/framework/utils/sudo.py b/example/framework/utils/sudo.py new file mode 100644 index 0000000..48f9262 --- /dev/null +++ b/example/framework/utils/sudo.py @@ -0,0 +1,70 @@ +"""Testing authentications and authorization mechanisms.""" + +from __future__ import annotations + +from pytest_mh import MultihostHost, MultihostUtility + +__all__ = [ + "SUDOUtils", +] + + +class SUDOUtils(MultihostUtility[MultihostHost]): + """ + Methods for testing authentication and authorization via sudo. + """ + + def run(self, username: str, password: str | None = None, *, command: str) -> bool: + """ + Execute sudo command. + + :param username: Username that calls sudo. + :type username: str + :param password: User password, defaults to None + :type password: str | None, optional + :param command: Command to execute (make sure to properly escape any quotes). + :type command: str + :return: True if the command was successful, False if the command failed or the user can not run sudo. + :rtype: bool + """ + result = self.host.conn.run( + f'su - "{username}" -c "sudo --stdin {command}"', input=password, raise_on_error=False + ) + + return result.rc == 0 + + def list(self, username: str, password: str | None = None, *, expected: list[str] | None = None) -> bool: + """ + List commands that the user can run under sudo. + + :param username: Username that runs sudo. + :type username: str + :param password: User password, defaults to None + :type password: str | None, optional + :param expected: List of expected commands (formatted as sudo output), defaults to None + :type expected: list[str] | None, optional + :return: True if the user can run sudo and allowed commands match expected commands (if set), False otherwise. + :rtype: bool + """ + result = self.host.conn.run(f'su - "{username}" -c "sudo --stdin -l"', input=password, raise_on_error=False) + if result.rc != 0: + return False + + if expected is None: + return True + + allowed = [] + for line in reversed(result.stdout_lines): + if not line.startswith(" "): + break + allowed.append(line.strip()) + + for line in expected: + if line not in allowed: + return False + allowed.remove(line) + + if len(allowed) > 0: + return False + + return True diff --git a/example/lib/hosts/__init__.py b/example/lib/hosts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/example/lib/hosts/client.py b/example/lib/hosts/client.py deleted file mode 100644 index e35d97a..0000000 --- a/example/lib/hosts/client.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from pytest_mh import MultihostHost - -from ..config import ExampleMultihostDomain - - -class ClientHost(MultihostHost[ExampleMultihostDomain]): - """ - Kerberos client host object. - - Provides features specific to Kerberos client. - - This class adds ``config.realm``, ``config.krbdomain`` and ``config.kdc`` - multihost configuration options to set the default kerberos realm, - domain and the kdc hostname. - - .. code-block:: yaml - :caption: Example multihost configuration - :emphasize-lines: 6-8 - - - hostname: client.test - role: client - config: - realm: TEST - krbdomain: test - kdc: kdc.test - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.realm: str = self.config.get("realm", "TEST") - self.krbdomain: str = self.config.get("krbdomain", "test") - self.kdc: str = self.config.get("kdc", "kdc.test") diff --git a/example/lib/hosts/kdc.py b/example/lib/hosts/kdc.py deleted file mode 100644 index 070ba91..0000000 --- a/example/lib/hosts/kdc.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from pytest_mh import MultihostHost -from pytest_mh.conn import Powershell - -from ..config import ExampleMultihostDomain - - -class KDCHost(MultihostHost[ExampleMultihostDomain]): - """ - Kerberos KDC server host object. - - Provides features specific to Kerberos KDC. - - .. note:: - - Full backup and restore is supported. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.__backup_location: str | None = None - """Backup file or folder location.""" - - def pytest_setup(self) -> None: - """ - Called once before execution of any tests. - """ - super().setup() - - # Backup KDC data - self.conn.run('kdb5_util dump /tmp/mh.kdc.kdb.backup && rm -f "/tmp/mh.kdc.kdb.backup.dump_ok"') - self.__backup_location = "/tmp/mh.kdc.kdb.backup" - - def pytest_teardown(self) -> None: - """ - Called once after all tests are finished. - """ - # Remove backup file - if self.__backup_location is not None: - if isinstance(self.conn.shell, Powershell): - self.conn.exec(["Remove-Item", "-Force", "-Recurse", self.__backup_location]) - else: - self.conn.exec(["rm", "-fr", self.__backup_location]) - - super().teardown() - - def teardown(self) -> None: - """ - Called after execution of each test. - """ - # Restore KDC data to its original state - self.conn.run(f'kdb5_util load "{self.__backup_location}"') - super().teardown() diff --git a/example/lib/roles/__init__.py b/example/lib/roles/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/example/lib/roles/client.py b/example/lib/roles/client.py deleted file mode 100644 index 2422ecf..0000000 --- a/example/lib/roles/client.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Client multihost role.""" - -from __future__ import annotations - -import textwrap - -from pytest_mh import MultihostRole -from pytest_mh.conn import ProcessError, ProcessResult -from pytest_mh.utils.fs import LinuxFileSystem - -from ..hosts.client import ClientHost - - -class Client(MultihostRole[ClientHost]): - """ - Kerberos client role. - - Provides unified Python API for managing and testing Kerberos client. - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.realm: str = self.host.realm - """ - Kerberos realm. - """ - - self.fs: LinuxFileSystem = LinuxFileSystem(self.host) - """ - File system manipulation. - """ - - def setup(self) -> None: - """ - Called before execution of each test. - - Setup client host: - - #. Create krb5.conf - - .. note:: - - Original krb5.conf is automatically restored when the test is finished. - """ - super().setup() - config = textwrap.dedent( - f""" - [logging] - default = FILE:/var/log/krb5libs.log - kdc = FILE:/var/log/krb5kdc.log - admin_server = FILE:/var/log/kadmind.log - - [libdefaults] - default_realm = {self.host.realm} - default_ccache_name = KCM: - dns_lookup_realm = false - dns_lookup_kdc = false - ticket_lifetime = 24h - renew_lifetime = 7d - forwardable = yes - - [realms] - {self.host.realm} = {{ - kdc = {self.host.kdc}:88 - admin_server = {self.host.kdc}:749 - max_life = 7d - max_renewable_life = 14d - }} - - [domain_realm] - .{self.host.krbdomain} = {self.host.realm} - {self.host.krbdomain} = {self.host.realm} - """ - ).lstrip() - self.fs.write("/etc/krb5.conf", config, user="root", group="root", mode="0644") - - def kinit( - self, principal: str, *, password: str, realm: str | None = None, args: list[str] | None = None - ) -> ProcessResult: - """ - Run ``kinit`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param principal: Kerberos principal. - :type principal: str - :param password: Principal's password. - :type password: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: ProcessResult - """ - if args is None: - args = [] - - if realm is not None: - principal = f"{principal}@{realm}" - - return self.host.conn.exec(["kinit", *args, principal], input=password) - - def kvno(self, principal: str, *, realm: str | None = None, args: list[str] | None = None) -> ProcessResult: - """ - Run ``kvno`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: ProcessResult - """ - if args is None: - args = [] - - if realm is not None: - principal = f"{principal}@{realm}" - - return self.host.conn.exec(["kvno", *args, principal]) - - def klist(self, *, args: list[str] | None = None) -> ProcessResult: - """ - Run ``klist`` command. - - :param args: Additional parameters to ``klist``, defaults to None - :type args: list[str] | None, optional - :return: Command result. - :rtype: ProcessResult - """ - if args is None: - args = [] - - return self.host.conn.exec(["klist", *args]) - - def kswitch(self, principal: str, realm: str) -> ProcessResult: - """ - Run ``kswitch -p principal@realm`` command. - - :param principal: Kerberos principal. - :type principal: str - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``) - :type realm: str - :return: Command result. - :rtype: ProcessResult - """ - if "@" not in principal: - principal = f"{principal}@{realm}" - - return self.host.conn.exec(["kswitch", "-p", principal]) - - def kdestroy( - self, *, all: bool = False, ccache: str | None = None, principal: str | None = None, realm: str | None = None - ) -> ProcessResult: - """ - Run ``kdestroy`` command. - - Principal can be without the realm part. The realm can be given in - separate parameter ``realm``, in such case the principal name is - constructed as ``$principal@$realm``. If the principal does not contain - realm specification and ``realm`` parameter is not set then the default - realm is used. - - :param all: Destroy all ccaches (``kdestroy -A``), defaults to False - :type all: bool, optional - :param ccache: Destroy specific ccache (``kdestroy -c $cache``), defaults to None - :type ccache: str | None, optional - :param principal: Destroy ccache for given principal (``kdestroy -p $princ``), defaults to None - :type principal: str | None, optional - :param realm: Kerberos realm that is appended to the principal (``$principal@$realm``), defaults to None - :type realm: str | None, optional - :return: Command result. - :rtype: ProcessResult - """ - args = [] - - if all: - args.append("-A") - - if ccache is not None: - args.append("-c") - args.append(ccache) - - if realm is not None and principal is not None: - principal = f"{principal}@{realm}" - - if principal is not None: - args.append("-p") - args.append(principal) - - return self.host.conn.exec(["kdestroy", *args]) - - def has_tgt(self, realm: str) -> bool: - """ - Check that the user has obtained Kerberos Ticket Granting Ticket. - - :param realm: Expected realm for which the TGT was obtained. - :type realm: str - :return: True if TGT is available, False otherwise. - :rtype: bool - """ - try: - result = self.klist() - except ProcessError: - return False - - return f"krbtgt/{realm}@{realm}" in result.stdout diff --git a/example/lib/roles/kdc.py b/example/lib/roles/kdc.py deleted file mode 100644 index b51605f..0000000 --- a/example/lib/roles/kdc.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -from pytest_mh import MultihostRole -from pytest_mh.conn import ProcessResult - -from ..hosts.kdc import KDCHost - - -class KDC(MultihostRole[KDCHost]): - """ - Kerberos KDC role. - - Provides unified Python API for managing objects in the Kerberos KDC. - - .. code-block:: python - :caption: Creating user and group - - @pytest.mark.topology(KnownTopology.KDC) - def test_example(kdc: KDC): - kdc.principal('tuser').add() - - .. note:: - - The role object is instantiated automatically as a dynamic pytest - fixture by the multihost plugin. You should not create the object - manually. - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - def kadmin(self, command: str) -> ProcessResult: - """ - Run kadmin command on the KDC. - - :param command: kadmin command - :type command: str - """ - result = self.host.conn.exec(["kadmin.local", "-q", command]) - - # Remove "Authenticating as principal root/admin@TEST with password." - # from the output and keep only output of the command itself. - result.stdout_lines = result.stdout_lines[1:] - result.stdout = "\n".join(result.stdout_lines) - - return result - - def list_principals(self) -> list[str]: - """ - List existing Kerberos principals. - - :return: List of Kerberos principals. - :rtype: list[str] - """ - result = self.kadmin("listprincs") - return result.stdout_lines - - def principal(self, name: str) -> KDCPrincipal: - """ - Get Kerberos principal object. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.KDC) - def test_example(client: Client, kdc: KDC): - kdc.principal('tuser').add() - - :param name: Principal name. - :type name: str - :return: New principal object. - :rtype: KDCPrincipal - """ - return KDCPrincipal(self, name) - - -class KDCPrincipal(object): - """ - Kerberos principals management. - """ - - def __init__(self, role: KDC, name: str) -> None: - """ - :param role: KDC role object. - :type role: KDC - :param name: Principal name. - :type name: str - """ - self.role: KDC = role - """KDC role.""" - - self.name: str = name - """Principal name.""" - - def add(self, *, password: str | None = "Secret123") -> KDCPrincipal: - """ - Add a new Kerberos principal. - - Random password is generated if ``password`` is ``None``. - - :param password: Principal's password, defaults to 'Secret123' - :type password: str | None - :return: Self. - :rtype: KDCPrincipal - """ - if password is not None: - self.role.kadmin(f'addprinc -pw "{password}" "{self.name}"') - else: - self.role.kadmin(f'addprinc -randkey "{self.name}"') - - return self - - def get(self) -> dict[str, str]: - """ - Retrieve principal information. - - :return: Principal information. - :rtype: dict[str, str] - """ - result = self.role.kadmin(f'getprinc "{self.name}"') - out = {} - for line in result.stdout_lines: - (key, value) = line.split(":", maxsplit=1) - out[key] = value.strip() - - return out - - def delete(self) -> None: - """ - Delete existing Kerberos principal. - """ - self.role.kadmin(f'delprinc -force "{self.name}"') - - def set_string(self, key: str, value: str) -> KDCPrincipal: - """ - Set principal's string attribute. - - :param key: Attribute name. - :type key: str - :param value: Atribute value. - :type value: str - :return: Self. - :rtype: KDCPrincipal - """ - self.role.kadmin(f'setstr "{self.name}" "{key}" "{value}"') - return self - - def get_strings(self) -> dict[str, str]: - """ - Get all principal's string attributes. - - :return: String attributes. - :rtype: dict[str, str] - """ - result = self.role.kadmin(f'getstrs "{self.name}"') - out = {} - for line in result.stdout_lines: - (key, value) = line.split(":", maxsplit=1) - out[key] = value.strip() - - return out - - def get_string(self, key: str) -> str | None: - """ - Set principal's string attribute. - - :param key: Attribute name. - :type key: str - :return: Attribute's value or None if not found. - :rtype: str | None - """ - attrs = self.get_strings() - - return attrs.get(key, None) diff --git a/example/lib/topology.py b/example/lib/topology.py deleted file mode 100644 index 5aee8af..0000000 --- a/example/lib/topology.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from enum import unique -from typing import final - -from pytest_mh import KnownTopologyBase, Topology, TopologyDomain, TopologyMark - - -@final -@unique -class KnownTopology(KnownTopologyBase): - """ - Well-known topologies that can be given to ``pytest.mark.topology`` - directly. It is expected to use these values in favor of providing - custom marker values. - - .. code-block:: python - :caption: Example usage - - @pytest.mark.topology(KnownTopology.KDC) - def test_kdc(client: Client, kdc: KDC): - assert True - """ - - KDC = TopologyMark( - name="kdc", - topology=Topology(TopologyDomain("test", client=1, kdc=1)), - fixtures=dict(client="test.client[0]", kdc="test.kdc[0]"), - ) diff --git a/example/mhc.yaml b/example/mhc.yaml index a8edf38..54bceb5 100644 --- a/example/mhc.yaml +++ b/example/mhc.yaml @@ -1,14 +1,16 @@ domains: -- id: test +- id: sudo hosts: + - hostname: master.ldap.test + conn: + type: ssh + host: 172.16.200.3 + role: ldap + - hostname: client.test + conn: + type: ssh + host: 172.16.200.4 role: client - config: - realm: TEST - krbdomain: test - kdc: kdc.test - - - hostname: kdc.test - role: kdc artifacts: - - /var/log/krb5kdc.log + - /var/log/sssd diff --git a/example/lib/__init__.py b/example/py.typed similarity index 100% rename from example/lib/__init__.py rename to example/py.typed diff --git a/example/pytest.ini b/example/pytest.ini new file mode 100644 index 0000000..0a30609 --- /dev/null +++ b/example/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --strict-markers +testpaths = tests diff --git a/example/readme.md b/example/readme.md new file mode 100644 index 0000000..1ba03fc --- /dev/null +++ b/example/readme.md @@ -0,0 +1,46 @@ +# Pytest-mh example code + +This directory contains an example show case of the pytest-mh plugin. This +example shows a basic framework and tests for `sudo`, since this is the tool +that every power user is familiar with and at the same time it is possible to +demonstrate many pytest-mh features, including advanced topology controllers and +topology parametrization. + +> [!NOTE] +> +> The example test framework is far from being perfect. In order to keep it +> simple, it lacks many features that would be needed to test sudo completely. +> Even the API that was implemented is intentionally limited and does not +> support every single format of the sudo rule. +> +> Some parts of the framework could have been implemented differently, even in a +> better way. However, optimal implementation was sacrificed on multiple places +> in order to demonstrate more pytest-mh features. + +## Running tests + +1. Start containers + +``` +sudo docker-compose -f containers/docker-compose.yml up --detach +``` + +2. Install test requirements + +``` +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install pytest-mh in editable mode from sources +pip3 install -e .. + +# Install test requirements +pip3 install -r ./requirements.txt +``` + +3. Run the tests + +``` +pytest --mh-config=./mhc.yaml +``` diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 0000000..1121bd0 --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,3 @@ +pytest +pytest-mh +python-ldap diff --git a/example/tests/test_group.py b/example/tests/test_group.py new file mode 100644 index 0000000..3da5210 --- /dev/null +++ b/example/tests/test_group.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest +from framework.roles.base import GenericProvider +from framework.roles.client import Client +from framework.roles.ldap import LDAP +from framework.topology import KnownTopologyGroup + + +@pytest.mark.topology(KnownTopologyGroup.AnyProvider) +def test_group__passwd(client: Client, provider: GenericProvider): + u = provider.user("tuser").add(password="Secret123") + g = provider.group("tgroup").add().add_member(u) + provider.sudorule("test-rule").add(user=g, command="ALL") + + # LDAP and SSSD topology uses SSSD for id and/or sudo rules + # Since sudo rules are fetch in periodic task, we must start SSSD after + # the rule is created in LDAP to avoid race conditions. + if isinstance(provider, LDAP): + client.svc.start("sssd.service") + + assert client.sudo.list(u.name, "Secret123", expected=["(root) ALL"]) + assert client.sudo.run(u.name, "Secret123", command="ls /root") + + +@pytest.mark.topology(KnownTopologyGroup.AnyProvider) +def test_group__nopasswd(client: Client, provider: GenericProvider): + u = provider.user("tuser").add(password="Secret123") + g = provider.group("tgroup").add().add_member(u) + provider.sudorule("test-rule").add(user=g, command="ALL", nopasswd=True) + + # LDAP and SSSD topology uses SSSD for id and/or sudo rules + # Since sudo rules are fetch in periodic task, we must start SSSD after + # the rule is created in LDAP to avoid race conditions. + if isinstance(provider, LDAP): + client.svc.start("sssd.service") + + assert client.sudo.list(u.name, "Secret123", expected=["(root) NOPASSWD: ALL"]) + assert client.sudo.run(u.name, command="ls /root") diff --git a/example/tests/test_kdc.py b/example/tests/test_kdc.py deleted file mode 100644 index 228c35b..0000000 --- a/example/tests/test_kdc.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -import pytest -from lib.roles.client import Client -from lib.roles.kdc import KDC -from lib.topology import KnownTopology - - -@pytest.mark.topology(KnownTopology.KDC) -def test_kinit(client: Client, kdc: KDC): - kdc.principal("user-1").add(password="Secret123") - - client.kinit("user-1", realm=client.realm, password="Secret123") - assert client.has_tgt(client.realm) - - client.kdestroy() - assert not client.has_tgt(client.realm) - - -@pytest.mark.topology(KnownTopology.KDC) -def test_kvno(client: Client, kdc: KDC): - kdc.principal("user-1").add(password="Secret123") - kdc.principal("host/myhost").add() - - client.kinit("user-1", realm=client.realm, password="Secret123") - assert client.has_tgt(client.realm) - - client.kvno("host/myhost", realm=client.realm) - assert "host/myhost" in client.klist().stdout diff --git a/example/tests/test_user.py b/example/tests/test_user.py new file mode 100644 index 0000000..5b1ee5f --- /dev/null +++ b/example/tests/test_user.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest +from framework.roles.base import GenericProvider +from framework.roles.client import Client +from framework.roles.ldap import LDAP +from framework.topology import KnownTopologyGroup + + +@pytest.mark.topology(KnownTopologyGroup.AnyProvider) +def test_user__passwd(client: Client, provider: GenericProvider): + u = provider.user("tuser").add(password="Secret123") + provider.sudorule("test-rule").add(user=u, command="ALL") + + # LDAP and SSSD topology uses SSSD for id and/or sudo rules + # Since sudo rules are fetch in periodic task, we must start SSSD after + # the rule is created in LDAP to avoid race conditions. + if isinstance(provider, LDAP): + client.svc.start("sssd.service") + + assert client.sudo.list(u.name, "Secret123", expected=["(root) ALL"]) + assert client.sudo.run(u.name, "Secret123", command="ls /root") + + +@pytest.mark.topology(KnownTopologyGroup.AnyProvider) +def test_user__nopasswd(client: Client, provider: GenericProvider): + u = provider.user("tuser").add(password="Secret123") + provider.sudorule("test-rule").add(user=u, command="ALL", nopasswd=True) + + # LDAP and SSSD topology uses SSSD for id and/or sudo rules + # Since sudo rules are fetch in periodic task, we must start SSSD after + # the rule is created in LDAP to avoid race conditions. + if isinstance(provider, LDAP): + client.svc.start("sssd.service") + + assert client.sudo.list(u.name, "Secret123", expected=["(root) NOPASSWD: ALL"]) + assert client.sudo.run(u.name, command="ls /root") diff --git a/pyproject.toml b/pyproject.toml index 186e853..8964ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ files = ["requirements.txt"] source = "vcs" [[tool.mypy.overrides]] -module = ["pylibsshext.*", "pytest_output.*"] +module = ["ldap.*", "pylibsshext.*", "pytest_output.*"] ignore_missing_imports = true [tool.isort] diff --git a/pytest_mh/_private/artifacts.py b/pytest_mh/_private/artifacts.py index b9c702a..cd381d1 100644 --- a/pytest_mh/_private/artifacts.py +++ b/pytest_mh/_private/artifacts.py @@ -16,6 +16,7 @@ from .multihost import MultihostHost +# +DOCS/MultihostArtifactsType MultihostArtifactsType: TypeAlias = Literal[ "pytest_setup", "pytest_teardown", "topology_setup", "topology_teardown", "test" ] @@ -28,6 +29,7 @@ * ``topology_teardown``: collected after :meth:`TopologyController.topology_teardown` * ``test``: collected after each test run """ +# -DOCS/MultihostArtifactsType MultihostArtifactsMode: TypeAlias = Literal["never", "on-failure", "always"] diff --git a/pytest_mh/utils/firewall.py b/pytest_mh/utils/firewall.py index ac7a396..00d0f30 100644 --- a/pytest_mh/utils/firewall.py +++ b/pytest_mh/utils/firewall.py @@ -7,7 +7,7 @@ from .. import MultihostHost, MultihostRole, MultihostUtility from ..conn import ProcessLogLevel -__all__ = ["Firewalld"] +__all__ = ["Firewall", "Firewalld", "WindowsFirewall"] HostSpec: TypeAlias = str | MultihostHost | MultihostRole