-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add management commands for generating + checking configuration docs
- Loading branch information
Showing
7 changed files
with
408 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | ||
) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
django_setup_configuration/management/commands/check_config_docs.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
103
django_setup_configuration/management/commands/generate_config_docs.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.