diff --git a/django_setup_configuration/base.py b/django_setup_configuration/base.py new file mode 100644 index 0000000..c716ad8 --- /dev/null +++ b/django_setup_configuration/base.py @@ -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} diff --git a/django_setup_configuration/constants.py b/django_setup_configuration/constants.py new file mode 100644 index 0000000..57c1dea --- /dev/null +++ b/django_setup_configuration/constants.py @@ -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)" + ) + ) diff --git a/django_setup_configuration/exceptions.py b/django_setup_configuration/exceptions.py index d8aac32..f7efa4f 100644 --- a/django_setup_configuration/exceptions.py +++ b/django_setup_configuration/exceptions.py @@ -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 + """ diff --git a/django_setup_configuration/management/commands/check_config_docs.py b/django_setup_configuration/management/commands/check_config_docs.py new file mode 100644 index 0000000..f2e88a6 --- /dev/null +++ b/django_setup_configuration/management/commands/check_config_docs.py @@ -0,0 +1,42 @@ +from django.conf import settings + +from ...exceptions import DocumentationCheckFailed +from .generate_config_docs import ConfigDocBaseCommand + +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) diff --git a/django_setup_configuration/management/commands/generate_config_docs.py b/django_setup_configuration/management/commands/generate_config_docs.py new file mode 100644 index 0000000..8ff7765 --- /dev/null +++ b/django_setup_configuration/management/commands/generate_config_docs.py @@ -0,0 +1,102 @@ +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) diff --git a/django_setup_configuration/registry.py b/django_setup_configuration/registry.py new file mode 100644 index 0000000..517e087 --- /dev/null +++ b/django_setup_configuration/registry.py @@ -0,0 +1,44 @@ +from django.conf import settings +from django.utils.module_loading import import_string + +from .exceptions import ImproperlyConfigured +from .typing import ConfigSettingsModel + + +class ConfigurationRegistry: + def __init__(self): + if not getattr(settings, "DJANGO_SETUP_CONFIG_REGISTER", None): + raise ImproperlyConfigured("DJANGO_SETUP_CONFIG_REGISTER is not defined") + + if not all((entry.get("model") for entry in settings.DJANGO_SETUP_CONFIG_REGISTER)): + raise ImproperlyConfigured( + "Each entry for the DJANGO_SETUP_CONFIG_REGISTER setting " + "must specify a configuration model" + ) + + self.register_config_models() + + def register_config_models(self) -> None: + """ + Load config models specified in settings and set them as attributes on the instance + """ + for mapping in settings.DJANGO_SETUP_CONFIG_REGISTER: + file_name = mapping.get("file_name") or mapping["model"].split(".")[-1] + + try: + model = import_string(mapping["model"]) + except ImportError as exc: + exc.add_note( + "\nHint: check your settings for django-setup-configuration" + ) + raise + else: + setattr(self, file_name, model) + + @property + def fields(self) -> tuple[ConfigSettingsModel, ...]: + return tuple(getattr(self, key) for key in vars(self).keys()) + + @property + def field_names(self) -> tuple[str, ...]: + return tuple(key for key in vars(self).keys()) diff --git a/django_setup_configuration/typing.py b/django_setup_configuration/typing.py new file mode 100644 index 0000000..8eee372 --- /dev/null +++ b/django_setup_configuration/typing.py @@ -0,0 +1,5 @@ +from typing import TypeAlias + +from .base import ConfigSettingsBase + +ConfigSettingsModel: TypeAlias = ConfigSettingsBase