Skip to content

Add a directives for general purpose documentation in downstream proj… #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
Expand Down
140 changes: 140 additions & 0 deletions django_setup_configuration/documentation/setup_config_usage.py
Original file line number Diff line number Diff line change
@@ -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("", "<dynamic>")
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, "<dynamic>")

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}:", "<dynamic>")
if show_steps_autodoc:
rst.append(f".. autoclass:: {step.module_path}", "<dynamic>")
rst.append(" :noindex:", "<dynamic>")
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, "<dynamic>")

rst.append(f".. setup-config-example:: {step.module_path}", "<dynamic>")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@swrichards something else I ran into just now: it might be useful to add anchors to the steps, that way you can still reference specific steps from other pages

.. _setup_configuration_Baz:

.. setup-config-example:: foo.bar.Baz

not entirely sure what the name of the reference should be though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now rewritten this so the step sections are always RST sections, which mean they will always have an anchor id, which I've changed to ref-step-{full_step_module_path} for easier linking.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the only way to cross-reference is by adding an explicit reference like .. _ref-config-step-...: unfortunately 😬 (https://www.sphinx-doc.org/en/master/usage/referencing.html#cross-referencing-arbitrary-locations)

I tried linking to this another way, the only thing that seems to work is spelling out the path of the file + the anchor (https://github.com/open-zaak/open-notificaties/pull/228/files#diff-056134d467e07b8255b1205455315b00fd959de8fe75ce378dc3b0698587c7a8L82), but that reference isn't validated when building the docs, so that link would break if the path/anchor change and you wouldn't be notified about it by a failing build


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)
42 changes: 42 additions & 0 deletions django_setup_configuration/documentation/templates/config_doc.rst
Original file line number Diff line number Diff line change
@@ -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
<https://django-setup-configuration.readthedocs.io/en/{{ package_version }}/quickstart.html#command-usage>`_ 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 %}

This file was deleted.

1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 80 additions & 15 deletions docs/config_docs.rst
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ tests = [
"isort",
"black",
"flake8",
"python-decouple"
"sphinx",
"beautifulsoup4",
"approvaltests"
]
coverage = [
"pytest-cov",
Expand Down
3 changes: 3 additions & 0 deletions tests/approvaltests_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"subdirectory": "fixtures"
}
Loading