From 1df1eb1e878943674addb4ce76c0ab6a6182b548 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Fri, 10 Jan 2025 11:38:52 +0100 Subject: [PATCH] :sparkles: [#45] Sphinx directive to generate YAML examples for config models it accepts a path to a ConfigurationStep and will display a commented YAML example for the config model associated with that step --- .../documentation/__init__.py | 0 .../documentation/model_directive.py | 303 ++++++++++++++++++ django_setup_configuration/fields.py | 3 + docs/conf.py | 29 ++ docs/sites_config.rst | 12 +- 5 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 django_setup_configuration/documentation/__init__.py create mode 100644 django_setup_configuration/documentation/model_directive.py diff --git a/django_setup_configuration/documentation/__init__.py b/django_setup_configuration/documentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_setup_configuration/documentation/model_directive.py b/django_setup_configuration/documentation/model_directive.py new file mode 100644 index 0000000..c60dc75 --- /dev/null +++ b/django_setup_configuration/documentation/model_directive.py @@ -0,0 +1,303 @@ +import importlib +import io +import textwrap +from dataclasses import dataclass +from enum import Enum +from typing import Annotated, Any, Dict, Literal, Type, Union, get_args, get_origin + +import ruamel.yaml +from docutils import nodes +from docutils.parsers.rst import Directive +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined +from ruamel.yaml.comments import CommentedMap + + +@dataclass +class PolymorphicExample: + example: Any + commented_out_examples: list[Any] + + +def get_default_from_field_info(field_info: FieldInfo) -> Any: + if field_info.default != PydanticUndefined and field_info.default: + if isinstance(field_info.default, Enum): + return field_info.default.value + return field_info.default + elif field_info.default_factory and (default := field_info.default_factory()): + return default + + +def yaml_set_comment_with_max_length( + commented_map: CommentedMap, + key: str, + comment: str, + max_line_length: int, + indent: int, + before: bool = True, +): + """ + Adds a comment to the specified key in the commented map, wrapping it to fit within + the max_line_length. + + :param commented_map: The CommentedMap object. + :param key: The key where the comment should be placed. + :param comment: The comment string to be added. + :param max_line_length: The maximum allowed line length for the comment. + :param before: Whether to place the comment before or after the key. + Defaults to `True` (before). + """ + # Split the comment into lines with the specified max line length + wrapped_comment = textwrap.fill(comment, width=max_line_length) + + # If before is True, add the comment before the key + if before: + commented_map.yaml_set_comment_before_after_key( + key, before=wrapped_comment, after=None, indent=indent + ) + else: + # Otherwise, add it after the key + commented_map.yaml_set_comment_before_after_key( + key, before=None, after=wrapped_comment, indent=indent + ) + + +def insert_example_with_comments( + example_data: CommentedMap, + field_name: str, + field_info: FieldInfo, + example: Any, + depth: int, +): + example_data[field_name] = example + # TODO adding a newline after keys is difficult apparently + example_data.yaml_set_comment_before_after_key(field_name, before="\n") + if field_info.description: + yaml_set_comment_with_max_length( + example_data, + field_name, + f"DESCRIPTION: {field_info.description}", + 80, + indent=depth * 2, + ) + + if default := get_default_from_field_info(field_info): + example_data.yaml_set_comment_before_after_key( + field_name, f"DEFAULT VALUE: {default}", indent=depth * 2 + ) + + if get_origin(field_info.annotation) == Literal: + example_data.yaml_set_comment_before_after_key( + field_name, + f"POSSIBLE VALUES: {get_args(field_info.annotation)}", + indent=depth * 2, + ) + + example_data.yaml_set_comment_before_after_key( + field_name, f"REQUIRED: {field_info.is_required()}", indent=depth * 2 + ) + + +def insert_as_full_comment( + example_data: CommentedMap, field_name: str, example: Any, depth: int +): + yaml = ruamel.yaml.YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + + output = io.StringIO() + yaml.dump(example, output) + yaml_example = output.getvalue() + example_data.yaml_set_comment_before_after_key( + field_name, + after=f"{yaml_example}\n", + indent=depth * 2, + ) + + +def generate_model_example(model: Type[BaseModel], depth: int = 0) -> Dict[str, Any]: + example_data = CommentedMap() + + # Loop through the annotations of the model to create example data + for field_name, field_info in model.model_fields.items(): + _data = process_field_type( + field_info.annotation, field_info, field_name, depth + 1 + ) + # TODO refactor this + if isinstance(_data, PolymorphicExample): + example = _data.example + else: + example = _data + + insert_example_with_comments( + example_data, field_name, field_info, example, depth + ) + + if isinstance(_data, PolymorphicExample): + yaml_set_comment_with_max_length( + example_data, + field_name, + ( + "This value is polymorphic, the possible values are divided by " + "dashes and only one of them can be commented out.\n" + ), + 80, + indent=depth * 2, + ) + for i, commented_example in enumerate(_data.commented_out_examples): + example_data.yaml_set_comment_before_after_key( + field_name, + after=(f"-------------OPTION {i+1}-------------"), + indent=depth * 2, + ) + insert_as_full_comment( + example_data, field_name, commented_example, depth + ) + example_data.yaml_set_comment_before_after_key( + field_name, + after=(f"-------------OPTION {i+2}-------------"), + indent=depth * 2, + ) + return example_data + + +def process_field_type( + field_type: Any, field_info: FieldInfo, field_name: str, depth: int +) -> Any: + """ + Processes a field type and generates example data based on its type. + """ + # Handle basic types + if example := generate_basic_example(field_type, field_info): + return example + + # Step 1: Handle Annotated + if get_origin(field_type) == Annotated: + # Extract the first argument from Annotated, which could be a Union + annotated_type = get_args(field_type)[0] + + # Process the unwrapped type + return process_field_type(annotated_type, field_info, field_name, depth) + + # Handle Union + if get_origin(field_type) == Union: + union_types = get_args(field_type) + + # Generate example for the first type in the Union + primary_type = union_types[0] + data = process_field_type(primary_type, field_info, field_name, depth) + # TODO only tackle complex types here? e.g. pydantic models or otherwise + other = [ + process_field_type(type, field_info, field_name, 0) + for type in union_types[1:] + ] + return PolymorphicExample(example=data, commented_out_examples=other) + + # Handle lists + if get_origin(field_type) == list: + list_type = get_args(field_type)[0] + return [process_field_type(list_type, field_info, field_name, depth + 1)] + + # Handle Pydantic models + if isinstance(field_type, type) and issubclass(field_type, BaseModel): + return generate_model_example(field_type, depth=depth) + + +def generate_basic_example(field_type: Any, field_info: FieldInfo) -> Any: + """ + Generates a basic example for simple types like str, int, bool, etc. + """ + if field_info.examples: + return field_info.examples[0] + elif default := get_default_from_field_info(field_info): + return default + elif field_type == str: + return "example_string" + elif field_type == int: + return 123 + elif field_type == bool: + return True + elif field_type == float: + return 123.45 + elif field_type == list: + return [] + elif field_type == dict: + return {} + else: + return None # Placeholder for unsupported types + + +# Custom directive for generating a YAML example from a Pydantic model +class PydanticModelExampleDirective(Directive): + has_content = False + required_arguments = ( + 1 # Accept the full import path of the step class as an argument + ) + + def run(self): + step_class_path = self.arguments[0] + + # Dynamically import the step class + try: + # Split the step class path into module and class name + module_name, class_name = step_class_path.rsplit(".", 1) + + # Import the module and get the step class + module = importlib.import_module(module_name) + step_class = getattr(module, class_name) + + # Ensure the class has the config_model attribute + if not hasattr(step_class, "config_model"): + raise ValueError( + f"The step class '{step_class}' does not " + "have a 'config_model' attribute." + ) + + config_model = step_class.config_model + + # Ensure the config_model is a Pydantic model + if not issubclass(config_model, BaseModel): + raise ValueError( + f"The config_model '{config_model}' is not a valid Pydantic model." + ) + + except (ValueError, AttributeError, ImportError) as e: + raise ValueError( + f"Step class '{step_class_path}' could not be found or is invalid." + ) from e + + # Derive the `namespace` and `enable_setting` from the step class + namespace = getattr(step_class, "namespace", None) + enable_setting = getattr(step_class, "enable_setting", None) + + # Generate the model example data + example_data = generate_model_example(config_model, depth=1) + + data = {} + if enable_setting: + data[enable_setting] = True + if namespace: + data[namespace] = example_data + + yaml = ruamel.yaml.YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + + output = io.StringIO() + yaml.dump(data, output) + yaml_example = output.getvalue() + + # Create a code block with YAML formatting + literal_block = nodes.literal_block(yaml_example, yaml_example) + literal_block["language"] = "yaml" + + # Validate that the model can parse the example YAML + model_class = step_class.config_model + data = yaml.load(yaml_example) + model_class.model_validate(data[step_class.namespace]) + + # Return the node to be inserted into the document + return [literal_block] + + +def setup(app): + app.add_directive("pydantic-model-example", PydanticModelExampleDirective) diff --git a/django_setup_configuration/fields.py b/django_setup_configuration/fields.py index 7987de9..8ae1f49 100644 --- a/django_setup_configuration/fields.py +++ b/django_setup_configuration/fields.py @@ -138,6 +138,9 @@ def __init__( "validate_return" ] = validate_defaults + if examples := kwargs.get("examples"): + field_info_creation_kwargs["examples"] = examples + return super().__init__(**field_info_creation_kwargs) @staticmethod diff --git a/docs/conf.py b/docs/conf.py index 6b3afc1..55218e0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,13 +11,41 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import sys +import os from pathlib import Path +import django + current_dir = Path(__file__).parents[1] code_directory = current_dir / "django_setup_configuration" sys.path.insert(0, str(code_directory)) +import os +import django +from django.conf import settings + +# Mock the Django settings +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mock_settings') + +if not settings.configured: + settings.configure( + INSTALLED_APPS=[ + 'django.contrib.contenttypes', # Required by Django models + 'django.contrib.sites', # Required by Django models + 'django_setup_configuration', # Required by Django models + # Add minimal apps required by your library + ], + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', # Use an in-memory database + } + } + ) + +django.setup() + # -- Project information ----------------------------------------------------- @@ -37,6 +65,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.todo", + "django_setup_configuration.documentation.model_directive", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/sites_config.rst b/docs/sites_config.rst index f340dca..3b39580 100644 --- a/docs/sites_config.rst +++ b/docs/sites_config.rst @@ -13,17 +13,9 @@ To make use of this, add the step to your ``SETUP_CONFIGURATION_STEPS``: ... ] -Create or update your YAML configuration file with your settings: +Create or update your YAML configuration file with syour settings: -.. code-block:: yaml - - sites_config_enable: true - sites_config: - items: - - domain: example.com - name: Example site - - domain: test.example.com - name: Test site +.. pydantic-model-example:: django_setup_configuration.contrib.sites.steps.SitesConfigurationStep .. note:: The first item in the list will be used to update the current ``Site`` instance,