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

Add management command for generating configuration docs #9

Merged
merged 7 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ jobs:

name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }})

services:
postgres:
image: postgres
env:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -34,6 +48,8 @@ jobs:
env:
PYTHON_VERSION: ${{ matrix.python }}
DJANGO: ${{ matrix.django }}
POSTGRES_USER: postgres
POSTGRES_HOST: localhost

- name: Publish coverage report
uses: codecov/codecov-action@v3
Expand Down
5 changes: 5 additions & 0 deletions django_setup_configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .base import ConfigSettingsModel

__all__ = [
"ConfigSettingsModel",
]
168 changes: 168 additions & 0 deletions django_setup_configuration/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 OneToOneField

from .constants import basic_field_description


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


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


class ConfigSettingsModel:
model: models.Model
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
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_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)

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"

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
annashamray marked this conversation as resolved.
Show resolved Hide resolved
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,
one-to-one relations (`OneToOneField`) are handled recursively

`ForeignKey` and `ManyToManyField` are currently not supported (these require
special care to avoid recursion errors)
"""

model_fields = self.get_concrete_model_fields(model)

for model_field in model_fields:
if isinstance(model_field, 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),
field_description=self.get_field_description(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:
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
self.config_fields.all.add(config_field)

def get_required_settings(self) -> tuple[str, ...]:
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
return tuple(
self.get_setting_name(field) for field in self.config_fields.required
)

def get_config_mapping(self) -> dict[str, ConfigField]:
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
return {self.get_setting_name(field): field for field in self.config_fields.all}
28 changes: 28 additions & 0 deletions django_setup_configuration/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.contrib import postgres
from django.db import models

basic_field_description = {
postgres.fields.ArrayField: "string, comma-delimited ('foo,bar,baz')",
models.BooleanField: "True, False",
models.CharField: "string",
models.FileField: (
"string represeting the (absolute) path to a file, "
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
"including file extension: {example}".format(
example="/absolute/path/to/file.xml"
)
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
),
models.ImageField: (
"string represeting the (absolute) path to an image file, "
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
"including file extension: {example}".format(
example="/absolute/path/to/image.png"
)
),
models.IntegerField: "string representing an integer",
models.JSONField: "Mapping: {example}".format(example="{'some_key': 'Some value'}"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could also be a string, float, int, None or list... 😬

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split remark off into a separate issue

models.PositiveIntegerField: "string representing a positive integer",
models.TextField: "text (string)",
models.URLField: "string (URL)",
models.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 ...exceptions import DocumentationCheckFailed
from .generate_config_docs import ConfigDocBaseCommand


class Command(ConfigDocBaseCommand):
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
help = "Check that changes to configuration setup classes are reflected in the docs"

def check_doc(self, config_option: str) -> None:
SOURCE_DIR = settings.DJANGO_SETUP_CONFIG_DOC_DIR

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.config_model_keys

for option in supported_options:
self.check_doc(option)
Loading
Loading