From d49dd6c07583dedd83e047d528d4d35214a7beeb Mon Sep 17 00:00:00 2001 From: Sidney Richards Date: Thu, 23 Jan 2025 20:14:44 +0100 Subject: [PATCH] Add a directives for general purpose documentation in downstream projects --- MANIFEST.in | 1 + .../documentation/setup_config_usage.py | 106 ++++++++++++++++++ .../documentation/templates/config_doc.rst | 35 ++++++ .../documentation/templates/config_step.rst | 4 + .../django_setup_configuration/config_doc.rst | 50 --------- docs/conf.py | 1 + docs/config_docs.rst | 76 ++++++++++--- pyproject.toml | 3 +- tests/conftest.py | 8 ++ .../usage_directive_html_partial.html | 52 +++++++++ .../sphinx-roots/test-usage-directive/conf.py | 20 ++++ .../test-usage-directive/index.rst | 1 + tests/test_documentation.py | 92 +++++++++++++-- 13 files changed, 374 insertions(+), 75 deletions(-) create mode 100644 django_setup_configuration/documentation/setup_config_usage.py create mode 100644 django_setup_configuration/documentation/templates/config_doc.rst create mode 100644 django_setup_configuration/documentation/templates/config_step.rst delete mode 100644 django_setup_configuration/templates/django_setup_configuration/config_doc.rst create mode 100644 tests/fixtures/usage_directive_html_partial.html create mode 100644 tests/sphinx-roots/test-usage-directive/conf.py create mode 100644 tests/sphinx-roots/test-usage-directive/index.rst 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..c79b907 --- /dev/null +++ b/django_setup_configuration/documentation/setup_config_usage.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass +from pathlib import Path + +from django.template import Template +from django.template.context import Context +from django.utils.module_loading import import_string +from django.utils.text import slugify + +from docutils import nodes +from docutils.parsers.rst import Directive +from docutils.statemachine import ViewList + +from django_setup_configuration.configuration import BaseConfigurationStep + +_TEMPLATES_PATH = Path(__file__).parent / "templates" + + +@dataclass(frozen=True) +class StepInfo: + title: str + anchor_id: str + module_path: str + step_cls: BaseConfigurationStep + + +class SetupConfigUsageDirective(Directive): + has_content = True + + def run(self): + 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() + step_template = self._load_step_template() + steps = self._load_steps(configured_steps) + + rst = ViewList() + rst.append("", "") + usage_rst = usage_template.render(context=Context({"steps": steps})) + lines = usage_rst.split("\n") + for line in lines: + rst.append(line, "") + + containing_node = nodes.section() + containing_node["ids"] = ["django-setup-config"] + containing_node += nodes.title( + text="Setting up your application with django-setup-config" + ) + self.state.nested_parse(rst, 0, containing_node) + + for step in steps: + rst = ViewList() + step_node = nodes.section(ids=[step.anchor_id]) + step_node += nodes.title(text=step.title) + + step_rst = step_template.render(context=Context({"step": step})) + lines = step_rst.split("\n") + rst.append("", "") + for line in lines: + rst.append(line, "") + + self.state.nested_parse(rst, 0, step_node) + containing_node += step_node + + return [containing_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_step_template(self): + return Template((_TEMPLATES_PATH / "config_step.rst").read_text()) + + def _load_usage_template(self): + return Template((_TEMPLATES_PATH / "config_doc.rst").read_text()) + + def _load_steps(self, configured_steps): + steps_info = [] + for step_path in configured_steps: + step_cls = import_string(step_path) + step_info = StepInfo( + title=step_cls.verbose_name, + anchor_id=slugify(step_cls.verbose_name), + module_path=step_path, + step_cls=step_cls, + ) + 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..ce4a1db --- /dev/null +++ b/django_setup_configuration/documentation/templates/config_doc.rst @@ -0,0 +1,35 @@ +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 +`_ project page. + +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): + +{% for step in steps %} +- `{{ step.title }} <#{{ step.anchor_id }}>`_ +{% endfor %} diff --git a/django_setup_configuration/documentation/templates/config_step.rst b/django_setup_configuration/documentation/templates/config_step.rst new file mode 100644 index 0000000..6070c20 --- /dev/null +++ b/django_setup_configuration/documentation/templates/config_step.rst @@ -0,0 +1,4 @@ +.. autoclass:: {{ step.module_path }} + :noindex: + +.. setup-config-example:: {{ step.module_path }} 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..ff1318c 100644 --- a/docs/config_docs.rst +++ b/docs/config_docs.rst @@ -1,39 +1,85 @@ .. _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. 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 expected YAML configurations. + +.. note:: + + For clarity, create a separate RST file (e.g., ``setup-configuration.rst``) containing only this directive. + Sphinx will include the subsections for each step in your documentation's navigation area. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f2198fc..2252c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,8 @@ tests = [ "isort", "black", "flake8", - "python-decouple" + "python-decouple", + "sphinx" ] coverage = [ "pytest-cov", 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/usage_directive_html_partial.html b/tests/fixtures/usage_directive_html_partial.html new file mode 100644 index 0000000..754d3fe --- /dev/null +++ b/tests/fixtures/usage_directive_html_partial.html @@ -0,0 +1,52 @@ +
+

Setting up your application with django-setup-config

+

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 project page.

+

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
+
+
+
+
\ No newline at end of file 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 1f277a0..9d31fce 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -1,6 +1,8 @@ import difflib import textwrap +from pathlib import Path from typing import Literal, Union +from unittest import mock from unittest.mock import patch import pytest @@ -14,12 +16,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"]) @@ -108,12 +111,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() @@ -127,7 +136,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 """ @@ -282,9 +292,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( ( @@ -303,7 +312,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 """ @@ -315,3 +325,67 @@ 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) + + +@pytest.mark.sphinx( + "html", + testroot="usage-directive", + confoverrides={"option_emphasise_placeholders": True}, +) +def test_usage_directive_outputs_expected_html_with_sphinx(app): + app.build() + + output_fixture = ( + Path(__file__).parent / "fixtures" / "usage_directive_html_partial.html" + ).read_text() + content = (app.outdir / "index.html").read_text(encoding="utf8") + assert output_fixture in content + + +@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." + )