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 all 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
14 changes: 14 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 Down
240 changes: 240 additions & 0 deletions django_setup_configuration/config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
from dataclasses import dataclass
from typing import 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 ForeignKey, OneToOneField
from django.utils.module_loading import import_string

from .constants import basic_field_descriptions
from .exceptions import ImproperlyConfigured


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


class ConfigSettings:
"""
annashamray marked this conversation as resolved.
Show resolved Hide resolved
Settings for configuration steps, also used to generate documentation.

Attributes:
enable_setting (`str`): the setting for enabling the associated configuration
step
namespace (`str`): the namespace of configuration variables for a given
configuration
file_name (`str`): the name of the file where the documentation is stored
models (`list`): a list of models from which documentation is retrieved
required_settings (`list`): required settings for a configuration step
optional_settings (`list`): optional settings for a configuration step
independent (`bool`): if `True` (the default), the documentation will be
created in its own file; set to `False` if you want to avoid this and
plan to embed the docs in another file (see `related_config_settings`)
related_config_settings (`list`): optional list of `ConfigSettings` from
related configuration steps; used for embedding documentation
update_fields (`bool`): if `True`, custom model fields
(along with their descriptions) are loaded via the settings variable
`DJANGO_SETUP_CONFIG_CUSTOM_FIELDS`
additional_info (`dict`): information for configuration settings which are
not associated with a particular model field
config_fields (`list`): a list of `ConfigField` objects containing information
about Django model fields

Example:
Given a configuration step `FooConfigurationStep`: ::

FooConfigurationStep(BaseConfigurationStep):
verbose_name = "Configuration step for Foo"
config_settings = ConfigSettings(
enable_setting = "FOO_CONFIG_ENABLE"
namespace="FOO",
file_name="foo",
models=["FooConfigurationModel"],
required_settings=[
"FOO_SOME_SETTING",
"FOO_SOME_OTHER_SETTING",
],
optional_settings=[
"FOO_SOME_OPT_SETTING",
"FOO_SOME_OTHER_OPT_SETTING",
],
related_config_settings=[
"BarRelatedConfigurationStep.config_settings",
],
additional_info={
"example_non_model_field": {
"variable": "FOO_EXAMPLE_NON_MODEL_FIELD",
"description": "Documentation for a field that cannot
be retrievend from a model",
"possible_values": "string (URL)",
},
},
)
"""

def __init__(
self,
*args,
enable_setting: str,
namespace: str,
file_name: str | None = None,
models: list[Type[models.Model]] | None = None,
required_settings: list[str],
optional_settings: list[str] | None = None,
independent: bool = True,
related_config_settings: list["ConfigSettings"] | None = None,
additional_info: dict[str, dict[str, str]] | None = None,
update_fields: bool = False,
**kwargs,
):
self.enable_setting = enable_setting
self.namespace = namespace
self.file_name = file_name or self.namespace.lower()
self.models = models
self.required_settings = required_settings
self.optional_settings = optional_settings or []
self.independent = independent
self.related_config_settings = related_config_settings or []
self.additional_info = additional_info or {}
self.update_fields = update_fields
self.config_fields: list[ConfigField] = []

if not self.models:
return

if update_fields:
self.load_additional_fields()

for model in self.models:
self.create_config_fields(model=model)

@staticmethod
def get_default_value(field: models.Field) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure why, but my type checker complains about the usage of models.Field and models.Model in type hints, does this happen for you as well? I think it can be fixed by importing them directly from django.db.models import Field, Model and using them Type[Field]

Screenshot from 2024-06-28 10-17-42

Copy link
Contributor

Choose a reason for hiding this comment

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

Pycharm doesn't complain here

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 load_additional_fields() -> None:
"""
Add custom fields + descriptions defined in settings to
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
`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)
if field_type in basic_field_descriptions.keys():
return basic_field_descriptions.get(field_type, "")

return "No information available"

def create_config_fields(
self,
model: Type[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`

Basic fields (`CharField`, `IntegerField` etc) constitute the base case,
relations (`ForeignKey`, `OneToOneField`) are handled recursively
"""

for model_field in model._meta.fields:
if isinstance(model_field, (ForeignKey, OneToOneField)):
# avoid recursion error when following ForeignKey
if model_field.name in ("parent", "owner"):
continue

self.create_config_fields(
model=model_field.related_model,
relating_field=model_field,
)
else:
# model field name could be "api_root",
# but we need "xyz_service_api_root" (or similar) for consistency
# when dealing with relations
if relating_field:
config_field_name = f"{relating_field.name}_{model_field.name}"
else:
config_field_name = model_field.name

config_setting = self.get_config_variable(config_field_name)

if not (
config_setting in self.required_settings
or config_setting in self.optional_settings
):
continue

config_field = ConfigField(
name=config_field_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),
)
self.config_fields.append(config_field)

#
# convenience methods/properties for formatting
#
def get_config_variable(self, setting: str) -> str:
return f"{self.namespace}_{setting.upper()}"
10 changes: 5 additions & 5 deletions django_setup_configuration/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

from django.conf import settings

from .config_settings import ConfigSettings
from .exceptions import PrerequisiteFailed


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

def __repr__(self):
return self.verbose_name
Expand All @@ -22,7 +22,7 @@ def validate_requirements(self) -> None:
"""
missing = [
var
for var in self.required_settings
for var in self.config_settings.required_settings
if getattr(settings, var, None) in [None, ""]
]
if missing:
Expand All @@ -36,10 +36,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
21 changes: 21 additions & 0 deletions django_setup_configuration/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db import models

basic_field_descriptions = {
models.BooleanField: "True, False",
models.CharField: "string",
models.EmailField: "string representing an Email address ([email protected])",
models.FileField: (
"string representing the (absolute) path to a file, "
"including file extension: /absolute/path/to/file.xml"
),
models.ImageField: (
"string representing the (absolute) path to an image file, "
"including file extension: /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 (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
"""
Loading
Loading