diff --git a/MANIFEST.in b/MANIFEST.in index 670ae6b..c388005 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include *.rst include LICENSE include django_setup_configuration/py.typed +include django_setup_configuration/documentation/templates/*.rst recursive-include django_setup_configuration *.html recursive-include django_setup_configuration *.txt recursive-include django_setup_configuration *.po diff --git a/django_setup_configuration/documentation/setup_config_usage.py b/django_setup_configuration/documentation/setup_config_usage.py new file mode 100644 index 0000000..313f05c --- /dev/null +++ b/django_setup_configuration/documentation/setup_config_usage.py @@ -0,0 +1,140 @@ +from importlib.metadata import version +from pathlib import Path + +from django.template import Template +from django.template.context import Context +from django.utils.module_loading import import_string + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from docutils.statemachine import ViewList +from pydantic.dataclasses import dataclass + +from django_setup_configuration.configuration import BaseConfigurationStep + +_TEMPLATES_PATH = Path(__file__).parent / "templates" + + +def _parse_bool(argument): + value = directives.choice(argument, ("true", "false", "yes", "no", "1", "0")) + return value in ("true", "yes", "1") + + +@dataclass(frozen=True) +class StepInfo: + title: str + description: str + anchor_id: str + module_path: str + step_cls: type[BaseConfigurationStep] + + +class SetupConfigUsageDirective(Directive): + has_content = True + + option_spec = { + "show_command_usage": _parse_bool, + "show_steps": _parse_bool, + "show_steps_toc": _parse_bool, + "show_steps_autodoc": _parse_bool, + } + + def run(self): + show_command_usage = self.options.get("show_command_usage", True) + show_steps = self.options.get("show_steps", True) + show_steps_toc = self.options.get("show_steps_toc", True) + show_steps_autodoc = self.options.get("show_steps_autodoc", True) + + if not (settings := self._get_django_settings()): + raise ValueError( + "Unable to load Django settings. Is DJANGO_SETTINGS_MODULE set?" + ) + + if not (configured_steps := settings.get("SETUP_CONFIGURATION_STEPS")): + raise ValueError( + "No steps configured. Set SETUP_CONFIGURATION_STEPS via your " + "Django settings." + ) + + usage_template = self._load_usage_template() + steps = self._load_steps(configured_steps) + + rst = ViewList() + rst.append("", "") + usage_rst = usage_template.render( + context=Context( + { + "steps": steps, + "show_toc": show_steps_toc, + "show_steps": show_steps, + "package_version": version("django_setup_configuration"), + } + ) + ) + lines = usage_rst.split("\n") + for line in lines: + rst.append(line, "") + + usage_node: nodes.section | None = None + if show_command_usage: + usage_node = nodes.section() + usage_node["ids"] = ["django-setup-config"] + usage_node += nodes.title( + text="Using the setup_configuration management command" + ) + self.state.nested_parse(rst, 0, usage_node) + + step_sections: list[nodes.section] = [] + if show_steps: + for step in steps: + step_node = nodes.section(ids=[step.module_path]) + step_node += nodes.title(text=step.title) + + rst = ViewList() + rst.append(f".. _{step.anchor_id}:", "") + if show_steps_autodoc: + rst.append(f".. autoclass:: {step.module_path}", "") + rst.append(" :noindex:", "") + else: + # Explicitly display the docstring if there's no autodoc to serve + # as the step description. + for line in step.description.splitlines(): + rst.append(line, "") + + rst.append(f".. setup-config-example:: {step.module_path}", "") + + self.state.nested_parse(rst, 0, step_node) + step_sections.append(step_node) + + return [node for node in (usage_node, *step_sections) if node] + + @classmethod + def _get_django_settings(cls): + from django.conf import settings + from django.core.exceptions import AppRegistryNotReady, ImproperlyConfigured + + try: + return settings._wrapped.__dict__ if hasattr(settings, "_wrapped") else {} + except (AppRegistryNotReady, AttributeError, ImproperlyConfigured): + return {} + + def _load_usage_template(self): + return Template((_TEMPLATES_PATH / "config_doc.rst").read_text()) + + def _load_steps(self, configured_steps) -> list[StepInfo]: # -> list: + steps_info: list[StepInfo] = [] + for step_path in configured_steps: + step_cls = import_string(step_path) + step_info = StepInfo( + title=step_cls.verbose_name, + anchor_id=f"ref_step_{step_path}", + module_path=step_path, + step_cls=step_cls, + description=step_cls.__doc__ or "", + ) + steps_info.append(step_info) + return steps_info + + +def setup(app): + app.add_directive("setup-config-usage", SetupConfigUsageDirective) diff --git a/django_setup_configuration/documentation/templates/config_doc.rst b/django_setup_configuration/documentation/templates/config_doc.rst new file mode 100644 index 0000000..cba3075 --- /dev/null +++ b/django_setup_configuration/documentation/templates/config_doc.rst @@ -0,0 +1,42 @@ +You can use the included ``setup_configuration`` management command to configure your +instance from a yaml file as follows: + +.. code-block:: bash + + python manage.py setup_configuration --yaml-file /path/to/config.yaml + +You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the ``validate-only`` flag: + +.. code-block:: bash + + python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only + +Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. + +Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. + +.. note:: All steps are disabled by default. You only have to explicitly include the + flag to enable a step, not to disable it, though you may do so if you wish to + have an explicit record of what steps are disabled. + +Further information can be found at the `django-setup-configuration +`_ documentation. + +{% if show_toc %} + +{% if show_steps %} +This projects includes the following configuration steps (click on each step for a +brief descripion and an example YAML you can include in your config file): +{% else %} +This projects includes the following configuration steps: +{% endif %} + +{% for step in steps %} +- {% if show_steps %}`{{ step.title }} <#{{ step.anchor_id }}>`_{% else %} {{ step.title }} {% endif %} +{% endfor %} +{% endif %} diff --git a/django_setup_configuration/templates/django_setup_configuration/config_doc.rst b/django_setup_configuration/templates/django_setup_configuration/config_doc.rst deleted file mode 100644 index ae6d494..0000000 --- a/django_setup_configuration/templates/django_setup_configuration/config_doc.rst +++ /dev/null @@ -1,50 +0,0 @@ -{% block link %}{{ link }}{% endblock %} - -{% block title %}{{ title }}{% endblock %} - -Settings Overview -================= - -{% if enable_setting %} -Enable/Disable configuration: -""""""""""""""""""""""""""""" - -:: - - {% spaceless %} - {{ enable_setting }} - {% endspaceless %} -{% endif %} - -{% if required_settings %} -Required: -""""""""" - -:: - - {% spaceless %} - {% for setting in required_settings %}{{ setting }} - {% endfor %} - {% endspaceless %} -{% endif %} - -All settings: -""""""""""""" - -:: - - {% spaceless %} - {% for setting in all_settings %}{{ setting }} - {% endfor %} - {% endspaceless %} - -Detailed Information -==================== - -:: - - {% spaceless %} - {% for detail in detailed_info %} - {% for part in detail %}{{ part|safe }} - {% endfor %}{% endfor %} - {% endspaceless %} diff --git a/docs/conf.py b/docs/conf.py index dd9a8fd..0f92f0c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,6 +56,7 @@ "sphinx.ext.autodoc", "sphinx.ext.todo", "django_setup_configuration.documentation.setup_config_example", + "django_setup_configuration.documentation.setup_config_usage", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/config_docs.rst b/docs/config_docs.rst index 13c7fed..4fba503 100644 --- a/docs/config_docs.rst +++ b/docs/config_docs.rst @@ -1,39 +1,104 @@ .. _config_docs: -Configuration documentation +Configuration Documentation =========================== -The library provides a Sphinx directive that generates (and validates) an example configuration -file in YAML format for a given ``ConfigurationStep``, containing information about the names of the fields, -possible values, default values, and a short description. This helps clients determine -what can be configured with the help of the library and how. +The library provides two Sphinx directives: +1. ``setup-config-example`` - Generates (and validates) an example configuration file in YAML format for a given ``ConfigurationStep``. This includes information about field names, possible values, default values, and descriptions, helping clients understand available configuration options. -Setup -""""" +2. ``setup-config-usage`` - Generates basic usage information and lists all configured steps with metadata and example YAMLs (it does this by wrapping ``setup-config-example`` so the examples will be validated as well). This provides a complete overview for users who want to bootstrap their installation. -Start by adding the following extension to ``conf.py`` in the documentation directory: +Using setup-config-example +-------------------------- -:: +First, add the extension and its requirements to ``conf.py`` in your documentation directory: + +.. code-block:: python extensions = [ ... + "sphinx.ext.autodoc", "django_setup_configuration.documentation.setup_config_example", ... ] -And then display a YAML example by using the directive: -:: +Then display a YAML example using the directive: + +.. code-block:: rst .. setup-config-example:: path.to.your.ConfigurationStep -which will produce something like the following example (in the case of the ``SitesConfigurationStep`` provided by this library): +This will produce output similar to the following example (using the ``SitesConfigurationStep`` provided by this library): .. setup-config-example:: django_setup_configuration.contrib.sites.steps.SitesConfigurationStep .. warning:: - Not all possible configurations are supported by this directive currently. - More complex type annotations like ``list[ComplexObject | ComplexObject]`` will raise errors when - trying to build the documentation + Not all configurations are currently supported by this directive. + Complex type annotations like ``list[ComplexObject | ComplexObject]`` will raise errors during documentation build. + +Using setup-config-usage +------------------------ + +First, add the extension and its requirements to ``conf.py`` in your documentation directory: + +.. code-block:: python + + extensions = [ + ... + "sphinx.ext.autodoc", + "django_setup_configuration.documentation.setup_config_example", + "django_setup_configuration.documentation.setup_config_usage", + ... + ] + +To use this directive, you'll also have to ensure Django is configured and initialized +in your Sphinx `conf.py` file, for instance like this: + +.. code-block:: python + + # docs/conf.py + + # ... + import django + from django.conf import settings + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "your_settings_module") + django.setup() + + # ... + # extensions = [...] + +Then display usage information using the directive: + +.. code-block:: rst + + .. setup-config-usage:: + + +This generates a "how to" introduction for invoking the management command, +followed by sections for each configured step with example YAML configurations. + +By default, the directive will output a full documentation page, but you can hide individual +sections using the following options: + +- ``show_command_usage``: whether to include basic usage information on how to invoke the management command +- ``show_steps``: whether to display information about the configured steps +- ``show_steps_toc``: whether to include a short table of contents of all configured steps, before displaying the individual step sections +- ``show_steps_autodoc``: whether to include an ``autodoc`` section showing the full path to the step module + +For example, to hide the usage section, show the steps without autodoc: + +.. code-block:: rst + + .. setup-config-usage:: + :show_command_usage: false + :show_steps_autodoc: false + + +.. note:: + + The titles for the step sections will be taken from the step's ``verbose_title`` field, + whereas the descriptions are taken from the step class's docstring (if present). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f2198fc..545e33e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,9 @@ tests = [ "isort", "black", "flake8", - "python-decouple" + "sphinx", + "beautifulsoup4", + "approvaltests" ] coverage = [ "pytest-cov", diff --git a/tests/approvaltests_config.json b/tests/approvaltests_config.json new file mode 100644 index 0000000..cab57ef --- /dev/null +++ b/tests/approvaltests_config.json @@ -0,0 +1,3 @@ +{ + "subdirectory": "fixtures" +} diff --git a/tests/conftest.py b/tests/conftest.py index 58c8a45..f145885 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import functools +from pathlib import Path from unittest import mock from django.contrib.auth.models import User @@ -12,6 +13,13 @@ from django_setup_configuration.runner import SetupConfigurationRunner from testapp.configuration import BaseConfigurationStep +pytest_plugins = ("sphinx.testing.fixtures",) + + +@pytest.fixture(scope="session") +def rootdir() -> Path: + return Path(__file__).resolve().parent / "sphinx-roots" + @pytest.fixture def yaml_file_factory(tmp_path_factory): diff --git a/tests/fixtures/accept_all.sh b/tests/fixtures/accept_all.sh new file mode 100755 index 0000000..7739c48 --- /dev/null +++ b/tests/fixtures/accept_all.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Convert all "received" snapshots to "approved" snapshots for approvaltests output: +# this makes it easy to bulk-accept expected changes, but use with caution and be sure +# to inspect your new snapshots for correctness. + +find . -type f -name "*.received.*" -exec sh -c 'for f; do mv "$f" "$(echo "$f" | sed "s/received/approved/")"; done' _ {} + diff --git a/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.complete.approved.html b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.complete.approved.html new file mode 100644 index 0000000..a453a52 --- /dev/null +++ b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.complete.approved.html @@ -0,0 +1,133 @@ +
+
+

+ Using the setup_configuration management command + + ¶ + +

+

+ You can use the included + + + setup_configuration + + + management command to configure your +instance from a yaml file as follows: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml
+
+
+
+

+ You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the + + + validate-only + + + flag: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only
+
+
+
+

+ Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. +

+

+ Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. +

+
+

+ Note +

+

+ All steps are disabled by default. You only have to explicitly include the +flag to enable a step, not to disable it, though you may do so if you wish to +have an explicit record of what steps are disabled. +

+
+

+ Further information can be found at the + + django-setup-configuration + + documentation. +

+

+ This projects includes the following configuration steps (click on each step for a +brief descripion and an example YAML you can include in your config file): +

+ +
+
+

+ User Configuration + + ¶ + +

+
+
+ + + class + + + + + + + testapp.configuration. + + + + + UserConfigurationStep + + +
+
+

+ Set up an initial user. +

+
+
+
+
+
user_configuration_enabled: true
+user_configuration:
+
+  # DESCRIPTION: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
+  # only.
+  # REQUIRED: true
+  username: example_string
+
+  # REQUIRED: true
+  password: example_string
+
+
+
+
+
+
+
diff --git a/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_autodoc_disabled.approved.html b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_autodoc_disabled.approved.html new file mode 100644 index 0000000..92bbe8a --- /dev/null +++ b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_autodoc_disabled.approved.html @@ -0,0 +1,114 @@ +
+
+

+ Using the setup_configuration management command + + ¶ + +

+

+ You can use the included + + + setup_configuration + + + management command to configure your +instance from a yaml file as follows: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml
+
+
+
+

+ You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the + + + validate-only + + + flag: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only
+
+
+
+

+ Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. +

+

+ Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. +

+
+

+ Note +

+

+ All steps are disabled by default. You only have to explicitly include the +flag to enable a step, not to disable it, though you may do so if you wish to +have an explicit record of what steps are disabled. +

+
+

+ Further information can be found at the + + django-setup-configuration + + documentation. +

+

+ This projects includes the following configuration steps (click on each step for a +brief descripion and an example YAML you can include in your config file): +

+ +
+
+

+ User Configuration + + ¶ + +

+
+
+

+ Set up an initial user. +

+
+
+
+
+
user_configuration_enabled: true
+user_configuration:
+
+  # DESCRIPTION: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
+  # only.
+  # REQUIRED: true
+  username: example_string
+
+  # REQUIRED: true
+  password: example_string
+
+
+
+
+
+
+
diff --git a/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_disabled_toc_enabled.approved.html b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_disabled_toc_enabled.approved.html new file mode 100644 index 0000000..44796e7 --- /dev/null +++ b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_disabled_toc_enabled.approved.html @@ -0,0 +1,81 @@ +
+
+

+ Using the setup_configuration management command + + ¶ + +

+

+ You can use the included + + + setup_configuration + + + management command to configure your +instance from a yaml file as follows: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml
+
+
+
+

+ You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the + + + validate-only + + + flag: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only
+
+
+
+

+ Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. +

+

+ Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. +

+
+

+ Note +

+

+ All steps are disabled by default. You only have to explicitly include the +flag to enable a step, not to disable it, though you may do so if you wish to +have an explicit record of what steps are disabled. +

+
+

+ Further information can be found at the + + django-setup-configuration + + documentation. +

+

+ This projects includes the following configuration steps: +

+
    +
  • +

    + User Configuration +

    +
  • +
+
+
+
+
diff --git a/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_fully_disabled.approved.html b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_fully_disabled.approved.html new file mode 100644 index 0000000..44796e7 --- /dev/null +++ b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_fully_disabled.approved.html @@ -0,0 +1,81 @@ +
+
+

+ Using the setup_configuration management command + + ¶ + +

+

+ You can use the included + + + setup_configuration + + + management command to configure your +instance from a yaml file as follows: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml
+
+
+
+

+ You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the + + + validate-only + + + flag: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only
+
+
+
+

+ Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. +

+

+ Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. +

+
+

+ Note +

+

+ All steps are disabled by default. You only have to explicitly include the +flag to enable a step, not to disable it, though you may do so if you wish to +have an explicit record of what steps are disabled. +

+
+

+ Further information can be found at the + + django-setup-configuration + + documentation. +

+

+ This projects includes the following configuration steps: +

+
    +
  • +

    + User Configuration +

    +
  • +
+
+
+
+
diff --git a/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_toc_disabled.approved.html b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_toc_disabled.approved.html new file mode 100644 index 0000000..781db27 --- /dev/null +++ b/tests/fixtures/test_documentation.test_usage_directive_outputs_expected_html_with_sphinx.steps_toc_disabled.approved.html @@ -0,0 +1,120 @@ +
+
+

+ Using the setup_configuration management command + + ¶ + +

+

+ You can use the included + + + setup_configuration + + + management command to configure your +instance from a yaml file as follows: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml
+
+
+
+

+ You can also validate that the configuration source can be successfully loaded, +without actually running the steps, by adding the + + + validate-only + + + flag: +

+
+
+
python manage.py setup_configuration --yaml-file /path/to/config.yaml --validate-only
+
+
+
+

+ Both commands will either return 0 and a success message if the configuration file can +be loaded without issues, otherwise it will return a non-zero exit code and print any +validation errors. +

+

+ Your YAML file should contain both a flag indicating whether the step is enabled or +disabled, as well as an object containing the actual configuration values under the +appropriate key. +

+
+

+ Note +

+

+ All steps are disabled by default. You only have to explicitly include the +flag to enable a step, not to disable it, though you may do so if you wish to +have an explicit record of what steps are disabled. +

+
+

+ Further information can be found at the + + django-setup-configuration + + documentation. +

+
+
+

+ User Configuration + + ¶ + +

+
+
+ + + class + + + + + + + testapp.configuration. + + + + + UserConfigurationStep + + +
+
+

+ Set up an initial user. +

+
+
+
+
+
user_configuration_enabled: true
+user_configuration:
+
+  # DESCRIPTION: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
+  # only.
+  # REQUIRED: true
+  username: example_string
+
+  # REQUIRED: true
+  password: example_string
+
+
+
+
+
+
+
diff --git a/tests/sphinx-roots/test-usage-directive/conf.py b/tests/sphinx-roots/test-usage-directive/conf.py new file mode 100644 index 0000000..7338bf5 --- /dev/null +++ b/tests/sphinx-roots/test-usage-directive/conf.py @@ -0,0 +1,20 @@ +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Test Project" +copyright = "2000-2042, The Test Project Authors" +author = "The Authors" +version = release = "4.16" + +# -- 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.todo", + "sphinx.ext.intersphinx", + "django_setup_configuration.documentation.setup_config_example", + "django_setup_configuration.documentation.setup_config_usage", +] diff --git a/tests/sphinx-roots/test-usage-directive/index.rst b/tests/sphinx-roots/test-usage-directive/index.rst new file mode 100644 index 0000000..ad0e80f --- /dev/null +++ b/tests/sphinx-roots/test-usage-directive/index.rst @@ -0,0 +1 @@ +.. setup-config-usage:: \ No newline at end of file diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 4e3f0b4..4785751 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -1,9 +1,13 @@ import difflib import textwrap from typing import Literal, Union +from unittest import mock from unittest.mock import patch +import approvaltests import pytest +from approvaltests.namer.default_namer_factory import NamerFactory +from bs4 import BeautifulSoup from docutils import nodes from docutils.frontend import get_default_settings from docutils.parsers.rst import Parser, directives @@ -14,12 +18,13 @@ from django_setup_configuration.documentation.setup_config_example import ( SetupConfigExampleDirective, ) +from django_setup_configuration.documentation.setup_config_usage import ( + SetupConfigUsageDirective, +) from django_setup_configuration.fields import DjangoModelRef from django_setup_configuration.models import ConfigurationModel from testapp.models import DjangoModel -parser = Parser() - class NestedConfigurationModel(ConfigurationModel): foo: str = Field(description="Nested description", default="bar", examples=["baz"]) @@ -110,12 +115,18 @@ class UnsupportedConfigStep(BaseConfigurationStep[UnsupportedConfigModel]): enable_setting = "unsupported_test_config_enable" -@pytest.fixture +@pytest.fixture() +def parser(): + return Parser() + + +@pytest.fixture() def register_directive(): directives.register_directive("setup-config-example", SetupConfigExampleDirective) + directives.register_directive("setup-config-usage", SetupConfigUsageDirective) -@pytest.fixture +@pytest.fixture() def docutils_document(): """Fixture to create a new docutils document with complete settings.""" settings = get_default_settings() @@ -129,7 +140,8 @@ def docutils_document(): return document -def test_directive_output(register_directive, docutils_document): +@pytest.mark.usefixtures("register_directive") +def test_directive_output(parser, docutils_document): rst_content = """ .. setup-config-example:: tests.test_documentation.ConfigStep """ @@ -291,9 +303,8 @@ def test_directive_output(register_directive, docutils_document): assert_example(result[0].astext(), expected) -def test_directive_output_invalid_example_raises_error( - register_directive, docutils_document -): +@pytest.mark.usefixtures("register_directive") +def test_directive_output_invalid_example_raises_error(parser, docutils_document): # The example for `ConfigModel` will not be valid if every example is a string with patch( ( @@ -312,7 +323,8 @@ def test_directive_output_invalid_example_raises_error( parser.parse(rst_content, docutils_document) -def test_unsupported_fields(register_directive, docutils_document): +@pytest.mark.usefixtures("register_directive") +def test_unsupported_fields(parser, docutils_document): rst_content = """ .. setup-config-example:: tests.test_documentation.UnsupportedConfigStep """ @@ -324,3 +336,110 @@ def test_unsupported_fields(register_directive, docutils_document): "Could not generate example for `list_of_primitive_and_complex`. " "This directive does not support unions inside lists." ) + + +@pytest.mark.usefixtures("register_directive") +def test_usage_directive_output_is_parseable(parser, docutils_document): + rst_content = """ + .. setup-config-usage:: + """ + parser.parse(rst_content, docutils_document) + + +def _extract_body(html_content: str): + soup = BeautifulSoup(html_content, "html.parser") + main_div = soup.find("div", {"class": "body", "role": "main"}) + return str(main_div) if main_div else None + + +@pytest.mark.sphinx("html", testroot="usage-directive") +@pytest.mark.parametrize( + "enabled_options,disabled_options", + [ + pytest.param(set(), set(), id="complete"), + pytest.param( + set(), + {"show_steps"}, + id="steps_fully_disabled", + ), + pytest.param( + set(), + {"show_steps_toc"}, + id="steps_toc_disabled", + ), + pytest.param( + set(), + {"show_steps_autodoc"}, + id="steps_autodoc_disabled", + ), + pytest.param( + {"show_steps_toc"}, + {"show_steps"}, + id="steps_disabled_toc_enabled", + ), + ], +) +def test_usage_directive_outputs_expected_html_with_sphinx( + app, disabled_options, enabled_options, request +): + # Build the rst with options + rst = ".. setup-config-usage::\n" + for enabled_option in enabled_options: + rst += f" :{enabled_option}: true\n" + + for disabled_option in disabled_options: + rst += f" :{disabled_option}: false\n" + + (app.srcdir / "index.rst").write_text(rst) + + # Run Sphinx + app.build() + + # Validate + content = (app.outdir / "index.html").read_text(encoding="utf8") + + approvaltests.verify_html( + _extract_body(content), + options=NamerFactory.with_parameters(request.node.callspec.id), + ) + + +@pytest.mark.usefixtures("register_directive") +@mock.patch( + "django_setup_configuration.documentation.setup_config_usage." + "SetupConfigUsageDirective._get_django_settings" +) +def test_usage_directive_output_with_no_settings_module_raises( + m, parser, docutils_document +): + rst_content = """ + .. setup-config-usage:: + """ + + m.return_value = {} + with pytest.raises(ValueError) as excinfo: + parser.parse(rst_content, docutils_document) + + assert ( + str(excinfo.value) + == "Unable to load Django settings. Is DJANGO_SETTINGS_MODULE set?" + ) + + +@pytest.mark.usefixtures("register_directive") +@mock.patch( + "django_setup_configuration.documentation.setup_config_usage." + "SetupConfigUsageDirective._get_django_settings" +) +def test_usage_directive_output_with_missing_steps_raises(m, parser, docutils_document): + rst_content = """ + .. setup-config-usage:: + """ + m.return_value = {"NOT_SETUP_CONFIGURATION_STEPS": []} + + with pytest.raises(ValueError) as excinfo: + parser.parse(rst_content, docutils_document) + + assert str(excinfo.value) == ( + "No steps configured. Set SETUP_CONFIGURATION_STEPS via your Django settings." + )