Skip to content

Commit

Permalink
Add management commands for generating + checking configuration docs
Browse files Browse the repository at this point in the history
  • Loading branch information
pi-sigma committed May 6, 2024
1 parent 0c55577 commit f2a9715
Show file tree
Hide file tree
Showing 7 changed files with 408 additions and 0 deletions.
166 changes: 166 additions & 0 deletions django_setup_configuration/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from dataclasses import dataclass, field
from typing import Iterator, Mapping, Sequence

from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.db.models.fields import NOT_PROVIDED
from django.db.models.fields.json import JSONField
from django.db.models.fields.related import ForeignKey, OneToOneField

from .constants import BasicFieldDescription


@dataclass(frozen=True, slots=True)
class ConfigField:
name: str
verbose_name: str
description: str
default_value: str
values: str


@dataclass
class Fields:
all: set[ConfigField] = field(default_factory=set)
required: set[ConfigField] = field(default_factory=set)


class ConfigSettingsBase:
model: models.Model
display_name: str
namespace: str
required_fields = tuple()
all_fields = tuple()
excluded_fields = ("id",)

def __init__(self):
self.config_fields = Fields()

self.create_config_fields(
require=self.required_fields,
exclude=self.excluded_fields,
include=self.all_fields,
model=self.model,
)

@classmethod
def get_setting_name(cls, field: ConfigField) -> str:
return f"{cls.namespace}_" + field.name.upper()

@staticmethod
def get_default_value(field: models.Field) -> str:
default = field.default

if default is NOT_PROVIDED:
return "No default"

# needed to make `generate_config_docs` idempotent
# because UUID's are randomly generated
if isinstance(field, models.UUIDField):
return "random UUID string"

# if default is a function, call the function to retrieve the value;
# we don't immediately return because we need to check the type first
# and cast to another type if necessary (e.g. list is unhashable)
if callable(default):
default = default()

if isinstance(default, Mapping):
return str(default)

# check for field type as well to avoid splitting values from CharField
if isinstance(field, (JSONField, ArrayField)) and isinstance(default, Sequence):
try:
return ", ".join(str(item) for item in default)
except TypeError:
return str(default)

return default

@staticmethod
def get_example_values(field: models.Field) -> str:
# fields with choices
if choices := field.choices:
values = [choice[0] for choice in choices]
return ", ".join(values)

# other fields
field_type = field.get_internal_type()
match field_type:
case item if item in BasicFieldDescription.names:
return getattr(BasicFieldDescription, field_type)
case _:
return "No information available"

def get_concrete_model_fields(self, model) -> Iterator[models.Field]:
"""
Get all concrete fields for a given `model`, skipping over backreferences like
`OneToOneRel` and fields that are blacklisted
"""
return (
field
for field in model._meta.concrete_fields
if field.name not in self.excluded_fields
)

def create_config_fields(
self,
require: tuple[str, ...],
exclude: tuple[str, ...],
include: tuple[str, ...],
model: models.Model,
relating_field: models.Field | None = None,
) -> None:
"""
Create a `ConfigField` instance for each field of the given `model` and
add it to `self.fields.all` and `self.fields.required`
Basic fields (`CharField`, `IntegerField` etc) constitute the base case,
relations (`ForeignKey`, `OneToOneField`) are handled recursively
"""

model_fields = self.get_concrete_model_fields(model)

for model_field in model_fields:
if isinstance(model_field, (ForeignKey, OneToOneField)):
self.create_config_fields(
require=require,
exclude=exclude,
include=include,
model=model_field.related_model,
relating_field=model_field,
)
else:
if model_field.name in self.excluded_fields:
continue

# model field name could be "api_root",
# but we need "xyz_service_api_root" (or similar) for consistency
if relating_field:
name = f"{relating_field.name}_{model_field.name}"
else:
name = model_field.name

config_field = ConfigField(
name=name,
verbose_name=model_field.verbose_name,
description=model_field.help_text,
default_value=self.get_default_value(model_field),
values=self.get_example_values(model_field),
)

if config_field.name in self.required_fields:
self.config_fields.required.add(config_field)

# if all_fields is empty, that means we're filtering by blacklist,
# hence the config_field is included by default
if not self.all_fields or config_field.name in self.all_fields:
self.config_fields.all.add(config_field)

def get_required_settings(self) -> tuple[str, ...]:
return tuple(
self.get_setting_name(field) for field in self.config_fields.required
)

def get_config_mapping(self) -> dict[str, ConfigField]:
return {self.get_setting_name(field): field for field in self.config_fields.all}
33 changes: 33 additions & 0 deletions django_setup_configuration/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from django.db import models


class BasicFieldDescription(models.TextChoices):
"""
Description of the values for basic Django model fields
"""

ArrayField = "string, comma-delimited ('foo,bar,baz')"
BooleanField = "True, False"
CharField = "string"
FileField = (
"string represeting the (absolute) path to a file, "
"including file extension: {example}".format(
example="/absolute/path/to/file.xml"
)
)
ImageField = (
"string represeting the (absolute) path to an image file, "
"including file extension: {example}".format(
example="/absolute/path/to/image.png"
)
)
IntegerField = "string representing an integer"
JSONField = "Mapping: {example}".format(example="{'some_key': 'Some value'}")
PositiveIntegerField = "string representing a positive integer"
TextField = "text (string)"
URLField = "string (URL)"
UUIDField = (
"UUID string {example}".format(
example="(e.g. f6b45142-0c60-4ec7-b43d-28ceacdc0b34)"
)
)
13 changes: 13 additions & 0 deletions django_setup_configuration/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@ class SelfTestFailed(ConfigurationException):
"""
Raises an error for failed configuration self-tests.
"""


class ImproperlyConfigured(ConfigurationException):
"""
Raised when the library is not properly configured
"""


class DocumentationCheckFailed(ConfigurationException):
"""
Raised when the documentation based on the configuration models
is not up to date
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from django.conf import settings

from .generate_config_docs import ConfigDocBaseCommand
from ...exceptions import DocumentationCheckFailed


SOURCE_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR


class Command(ConfigDocBaseCommand):
help = "Check that changes to configuration setup classes are reflected in the docs"

def check_doc(self, config_option: str) -> None:
source_path = f"{SOURCE_DIR}/{config_option}.rst"

try:
with open(source_path, "r") as file:
file_content = file.read()
except FileNotFoundError as exc:
msg = (
"\nNo documentation was found for {config}\n"
"Did you forget to run generate_config_docs?\n".format(
config=self.get_config(config_option, class_name_only=True)
)
)
raise DocumentationCheckFailed(msg) from exc
else:
rendered_content = self.render_doc(config_option)

if rendered_content != file_content:
raise DocumentationCheckFailed(
"Class {config} has changes which are not reflected in the documentation ({source_path}). "
"Did you forget to run generate_config_docs?\n".format(
config=self.get_config(config_option, class_name_only=True),
source_path=f"{SOURCE_DIR}/{config_option}.rst",
)
)

def handle(self, *args, **kwargs) -> None:
supported_options = self.registry.field_names

for option in supported_options:
self.check_doc(option)
103 changes: 103 additions & 0 deletions django_setup_configuration/management/commands/generate_config_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from django.template import loader

from ...exceptions import ConfigurationException
from ...registry import ConfigurationRegistry
from ...typing import ConfigSettingsModel


TEMPLATE_NAME = settings.DJANGO_SETUP_CONFIG_TEMPLATE_NAME
TARGET_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR


class ConfigDocBaseCommand(BaseCommand):
registry = ConfigurationRegistry()

def get_config(self, config_option: str, class_name_only=False) -> ConfigSettingsModel:
config_model = getattr(self.registry, config_option, None)
if class_name_only:
return config_model.__name__

config_instance = config_model()
return config_instance

def get_detailed_info(self, config: ConfigSettingsModel) -> list[list[str]]:
ret = []
for field in config.config_fields.all:
part = []
part.append(f"{'Variable':<20}{config.get_setting_name(field)}")
part.append(f"{'Setting':<20}{field.verbose_name}")
part.append(f"{'Description':<20}{field.description or 'No description'}")
part.append(f"{'Possible values':<20}{field.values}")
part.append(f"{'Default value':<20}{field.default_value}")
ret.append(part)
return ret

def format_display_name(self, display_name):
"""Surround title with '=' to display as heading in rst file"""

heading_bar = "=" * len(display_name)
display_name_formatted = f"{heading_bar}\n{display_name}\n{heading_bar}"
return display_name_formatted

def render_doc(self, config_option: str) -> None:
config = self.get_config(config_option)

required_settings = [
config.get_setting_name(field) for field in config.config_fields.required
]
required_settings.sort()

all_settings = [
config.get_setting_name(field) for field in config.config_fields.all
]
all_settings.sort()

detailed_info = self.get_detailed_info(config)
detailed_info.sort()

template_variables = {
"enable_settings": f"{config.namespace}_CONFIG_ENABLE",
"required_settings": required_settings,
"all_settings": all_settings,
"detailed_info": detailed_info,
"link": f".. _{config_option}:",
"title": self.format_display_name(config.display_name),
}

template = loader.get_template(TEMPLATE_NAME)
rendered = template.render(template_variables)

return rendered


class Command(ConfigDocBaseCommand):
help = "Create documentation for configuration setup steps"

def add_arguments(self, parser):
parser.add_argument("config_option", nargs="?")

def write_doc(self, config_option: str) -> None:
rendered = self.render_doc(config_option)

output_path = TARGET_DIR / f"{config_option}.rst"

with open(output_path, "w") as output:
output.write(rendered)

def handle(self, *args, **kwargs) -> None:
config_option = kwargs["config_option"]

supported_options = self.registry.field_names

if config_option and config_option not in supported_options:
raise ConfigurationException(
f"Unsupported config option ({config_option})\n"
f"Supported: {', '.join(supported_options)}"
)
elif config_option:
self.write_doc(config_option)
else:
for option in supported_options:
self.write_doc(option)
Loading

0 comments on commit f2a9715

Please sign in to comment.