Skip to content
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

🚧 Custom sphinx directive to document step #35

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
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
Empty file.
301 changes: 301 additions & 0 deletions django_setup_configuration/documentation/model_directive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import importlib
import io
import textwrap
from dataclasses import dataclass
from enum import Enum
from typing import Annotated, Any, Dict, Type, Union, get_args, get_origin, Literal

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):
example_data.yaml_set_comment_before_after_key(
field_name,
after=(
"This value is polymorphic, the possible values are divided by "
"dashes and only one of them can be commented out.\n"
),
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.parse_obj(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)
3 changes: 3 additions & 0 deletions django_setup_configuration/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,43 @@
# 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()

django.setup()


# -- Project information -----------------------------------------------------

Expand All @@ -37,6 +67,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.
Expand Down
12 changes: 2 additions & 10 deletions docs/sites_config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ classifiers = [
]
requires-python = ">=3.10"
dependencies = [
"django>=3.2",
"django>=3.2,<5.0",
"pydantic>=2",
"pydantic-settings[yaml]>=2.2"
"pydantic-settings[yaml]>=2.2",
"ruamel.yaml>=0.18.10",
]

[project.urls]
Expand Down
Loading