Skip to content

Commit

Permalink
[#8] Integrate cration of docs with config steps + other feedback
Browse files Browse the repository at this point in the history
    - delete registry, store config_settings as class_attributes
      on configuration steps
    - add static method to update model field descriptions with
      custom fields
    - modify template to show required settings section only if
      required settings are present
  • Loading branch information
pi-sigma committed May 17, 2024
1 parent b7a5383 commit 8d96e01
Show file tree
Hide file tree
Showing 17 changed files with 223 additions and 327 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ jobs:
env:
PYTHON_VERSION: ${{ matrix.python }}
DJANGO: ${{ matrix.django }}
POSTGRES_USER: postgres
POSTGRES_HOST: localhost
DB_USER: postgres
DB_HOST: localhost

- name: Publish coverage report
uses: codecov/codecov-action@v3
Expand Down
72 changes: 53 additions & 19 deletions django_setup_configuration/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from dataclasses import dataclass, field
from typing import Iterator, Mapping, Sequence
from typing import Iterator, Mapping, Sequence, Type

from django.conf import settings
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 OneToOneField
from django.utils.module_loading import import_string

from .constants import basic_field_description
from .constants import basic_field_descriptions
from .exceptions import ImproperlyConfigured


@dataclass(frozen=True, slots=True)
Expand All @@ -26,26 +29,34 @@ class Fields:


class ConfigSettingsModel:
model: models.Model
models: list[Type[models.Model], ...]
display_name: str
namespace: str
required_fields = tuple()
all_fields = tuple()
excluded_fields = ("id",)

def __init__(self):
def __init__(self, *args, **kwargs):
self.config_fields = Fields()

self.create_config_fields(
require=self.required_fields,
exclude=self.excluded_fields,
include=self.all_fields,
model=self.model,
)
for key, value in kwargs.items():
setattr(self, key, value)

self.update_field_descriptions()

if not self.models:
return

@classmethod
def get_setting_name(cls, field: ConfigField) -> str:
return f"{cls.namespace}_" + field.name.upper()
for model in self.models:
self.create_config_fields(
require=self.required_fields,
exclude=self.excluded_fields,
include=self.all_fields,
model=model,
)

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

@staticmethod
def get_default_value(field: models.Field) -> str:
Expand Down Expand Up @@ -77,19 +88,42 @@ def get_default_value(field: models.Field) -> str:

return default

@staticmethod
def update_field_descriptions() -> None:
"""
Add custom fields + descriptions defined in settings to
`basic_field_descriptions`
"""
custom_fields = getattr(settings, "DJANGO_SETUP_CONFIG_CUSTOM_FIELDS", None)
if not custom_fields:
return

for mapping in custom_fields:
try:
field = import_string(mapping["field"])
except ImportError as exc:
raise ImproperlyConfigured(
"\n\nSomething went wrong when importing {field}.\n"
"Check your settings for django-setup-configuration".format(
field=mapping["field"]
)
) from exc
else:
description = mapping["description"]
basic_field_descriptions[field] = description

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

# other fields
field_type = type(field)
match field_type:
case item if item in basic_field_description.keys():
return basic_field_description.get(item)
case _:
return "No information available"
if field_type in basic_field_descriptions.keys():
return basic_field_descriptions.get(field_type)
return "No information available"

def get_concrete_model_fields(self, model) -> Iterator[models.Field]:
"""
Expand All @@ -107,7 +141,7 @@ def create_config_fields(
require: tuple[str, ...],
exclude: tuple[str, ...],
include: tuple[str, ...],
model: models.Model,
model: Type[models.Model],
relating_field: models.Field | None = None,
) -> None:
"""
Expand Down
9 changes: 6 additions & 3 deletions django_setup_configuration/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

from django.conf import settings

from .base import ConfigSettingsModel
from .exceptions import PrerequisiteFailed


class BaseConfigurationStep(ABC):
verbose_name: str
required_settings: list[str] = []
enable_setting: str = ""
config_settings: ConfigSettingsModel

def __repr__(self):
return self.verbose_name
Expand All @@ -20,9 +22,10 @@ def validate_requirements(self) -> None:
:raises: :class: `django_setup_configuration.exceptions.PrerequisiteFailed`
if prerequisites are missing
"""
required_settings = self.config_settings.get_required_settings()
missing = [
var
for var in self.required_settings
for var in required_settings
if getattr(settings, var, None) in [None, ""]
]
if missing:
Expand All @@ -36,10 +39,10 @@ def is_enabled(self) -> bool:
By default all steps are enabled
"""
if not self.enable_setting:
if not self.config_settings.enable_setting:
return True

return getattr(settings, self.enable_setting, True)
return getattr(settings, self.config_settings.enable_setting, True)

@abstractmethod
def is_configured(self) -> bool:
Expand Down
7 changes: 4 additions & 3 deletions django_setup_configuration/constants.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from django.contrib import postgres
from django.db import models

basic_field_description = {
basic_field_descriptions = {
postgres.fields.ArrayField: "string, comma-delimited ('foo,bar,baz')",
models.BooleanField: "True, False",
models.CharField: "string",
models.EmailField: "string representing an Email address ([email protected])",
models.FileField: (
"string represeting the (absolute) path to a file, "
"string representing the (absolute) path to a file, "
"including file extension: {example}".format(
example="/absolute/path/to/file.xml"
)
),
models.ImageField: (
"string represeting the (absolute) path to an image file, "
"string representing the (absolute) path to an image file, "
"including file extension: {example}".format(
example="/absolute/path/to/image.png"
)
Expand Down

This file was deleted.

100 changes: 46 additions & 54 deletions django_setup_configuration/management/commands/generate_config_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,20 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from django.template import loader
from django.utils.module_loading import import_string

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


class ConfigDocBaseCommand(BaseCommand):
class ConfigDocBase:
"""
Base class encapsulating the functionality for generating + checking documentation.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.registry = ConfigurationRegistry()
Defined independently of `BaseCommand` for more flexibility (the class could be
used without running a Django management command).
"""

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]]:
def get_detailed_info(self, config: ConfigSettingsModel) -> list[list[str]]:
ret = []
for field in config.config_fields.all:
part = []
Expand All @@ -44,29 +35,29 @@ def format_display_name(self, 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)

def render_doc(self, config_settings: str) -> None:
required_settings = [
config.get_setting_name(field) for field in config.config_fields.required
config_settings.get_setting_name(field)
for field in config_settings.config_fields.required
]
required_settings.sort()

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

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

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

template = loader.get_template(settings.DJANGO_SETUP_CONFIG_TEMPLATE_NAME)
Expand All @@ -75,39 +66,40 @@ def render_doc(self, config_option: str) -> None:
return rendered


class Command(ConfigDocBaseCommand):
help = "Create documentation for configuration setup steps"
class Command(ConfigDocBase, BaseCommand):
help = "Generate documentation for configuration setup steps"

def add_arguments(self, parser):
parser.add_argument("config_option", nargs="?")
def content_is_up_to_date(self, rendered_content: str, doc_path: str) -> bool:
"""
Check that documentation at `doc_path` exists and that its content matches
that of `rendered_content`
"""
try:
with open(doc_path, "r") as file:
file_content = file.read()
except FileNotFoundError:
return False

def write_doc(self, config_option: str) -> None:
rendered = self.render_doc(config_option)
if not file_content == rendered_content:
return False

TARGET_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR
return True

pathlib.Path(TARGET_DIR).mkdir(parents=True, exist_ok=True)
def handle(self, *args, **kwargs) -> str:
target_dir = settings.DJANGO_SETUP_CONFIG_DOC_DIRECTORY

output_path = f"{TARGET_DIR}/{config_option}.rst"
# create directory for docs if it doesn't exist
pathlib.Path(target_dir).mkdir(parents=True, exist_ok=True)

with open(output_path, "w+") as output:
output.write(rendered)
for config_string in settings.SETUP_CONFIGURATION_STEPS:
config_step = import_string(config_string)
config_settings = config_step.config_settings

return rendered
doc_path = f"{target_dir}/{config_settings.file_name}.rst"
rendered_content = self.render_doc(config_settings)

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

supported_options = self.registry.config_model_keys

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:
rendered = self.write_doc(config_option)
else:
for option in supported_options:
rendered = self.write_doc(option)
return rendered
if not self.content_is_up_to_date(rendered_content, doc_path):
with open(doc_path, "w+") as output:
output.write(rendered_content)

return rendered_content
Loading

0 comments on commit 8d96e01

Please sign in to comment.