diff --git a/diracx-core/src/diracx/core/config/sources.py b/diracx-core/src/diracx/core/config/sources.py index f16fffa82..fbf3bbed6 100644 --- a/diracx-core/src/diracx/core/config/sources.py +++ b/diracx-core/src/diracx/core/config/sources.py @@ -7,7 +7,6 @@ import asyncio import logging -import os from abc import ABCMeta, abstractmethod from datetime import datetime, timezone from pathlib import Path @@ -162,7 +161,12 @@ def __init_subclass__(cls) -> None: @classmethod def create(cls): - return cls.create_from_url(backend_url=os.environ["DIRACX_CONFIG_BACKEND_URL"]) + # Avoid circular import + from diracx.core.settings import FactorySettings + + return cls.create_from_url( + backend_url=FactorySettings().diracx_config_backend_url + ) @classmethod def create_from_url( diff --git a/diracx-core/src/diracx/core/settings.py b/diracx-core/src/diracx/core/settings.py index dd55a88a8..22912f41e 100644 --- a/diracx-core/src/diracx/core/settings.py +++ b/diracx-core/src/diracx/core/settings.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Self, TypeVar, cast +import dotenv from aiobotocore.session import get_session from botocore.config import Config from botocore.errorfactory import ClientError @@ -32,16 +33,21 @@ SecretStr, TypeAdapter, UrlConstraints, + create_model, model_validator, ) from pydantic_settings import BaseSettings, SettingsConfigDict +from .config.sources import ConfigSourceUrl +from .extensions import DiracEntryPoint, select_from_extension from .properties import SecurityProperty from .s3 import s3_bucket_exists +from .utils import dotenv_files_from_environment if TYPE_CHECKING: from types_aiobotocore_s3.client import S3Client + T = TypeVar("T") @@ -358,3 +364,140 @@ def s3_client(self) -> S3Client: if self._client is None: raise RuntimeError("S3 client accessed before lifetime function") return self._client + + +def _build_factory_settings_model() -> type[ServiceSettingsBase]: + + class _EnabledServicesBase(ServiceSettingsBase): + model_config = SettingsConfigDict( + frozen=True, + use_attribute_docstrings=True, + ) + + enabled_services_field: dict[str, tuple[Any, Any]] = {} + + for entry_point in select_from_extension(group=DiracEntryPoint.SERVICES): + if "well-known" in entry_point.name: + continue + enabled_services_field[f"{entry_point.name}"] = ( + bool, + Field( + default=True, + validation_alias=f"DIRACX_SERVICE_{entry_point.name.upper()}_ENABLED", + description=f"Enable the {entry_point.name.upper()} router", + ), + ) + + EnabledServices = create_model( # noqa: N806 + "EnabledServices", + __doc__="Enabled services", + __base__=_EnabledServicesBase, + **cast(dict[str, Any], enabled_services_field), + ) + + class _OpenSearchDBSettingsBase(ServiceSettingsBase): + model_config = SettingsConfigDict( + frozen=True, + use_attribute_docstrings=True, + ) + + opensearch_db_field: dict[str, tuple[Any, Any]] = {} + + for entry_point in select_from_extension(group=DiracEntryPoint.OS_DB): + db_name = entry_point.name + opensearch_db_field[f"{db_name}"] = ( + str, + Field( + default="", + validation_alias=f"DIRACX_OS_DB_{db_name.upper()}", + description="A JSON-encoded dictionary of connection keyword arguments" + f" for the OpenSearch database {db_name}.", + ), + ) + + OpenSearchDBSettings = create_model( # noqa: N806 + "OpenSearchDBSettings", + __doc__="OpenSearch database settings", + __base__=_OpenSearchDBSettingsBase, + **cast(dict[str, Any], opensearch_db_field), + ) + + class _SqlDBSettingsBase(ServiceSettingsBase): + model_config = SettingsConfigDict( + frozen=True, + use_attribute_docstrings=True, + ) + + sql_db_field: dict[str, tuple[Any, Any]] = {} + + for entry_point in select_from_extension(group=DiracEntryPoint.SQL_DB): + db_name = entry_point.name + sql_db_field[f"{db_name}"] = ( + str, + Field( + default="", + validation_alias=f"DIRACX_DB_URL_{db_name.upper()}", + description=f"The URL for the SQL database {db_name}.", + ), + ) + + SqlDBSettings = create_model( # noqa: N806 + "SqlDBSettings", + __doc__="SQL database settings", + __base__=_SqlDBSettingsBase, + **cast(dict[str, Any], sql_db_field), + ) + + class _BaseFactorySettings(ServiceSettingsBase): + """Factory settings. + + Settings which do not fit into dedicated classes, + or are dynamically generated. + """ + + model_config = SettingsConfigDict(use_attribute_docstrings=True) + + diracx_config_backend_url: ConfigSourceUrl + """The URL of the configuration backend. + """ + + diracx_legacy_exchange_hashed_api_key: str = "" + """The hashed API key for the legacy exchange endpoint. + """ + + @model_validator(mode="before") + @classmethod + def load_dotenv_files(cls, data: Any) -> Any: + """Load dotenv files before reading settings from environment.""" + for env_file in dotenv_files_from_environment("DIRACX_SERVICE_DOTENV"): + if not dotenv.load_dotenv(env_file): + raise NotImplementedError(f"Could not load dotenv file {env_file}") + return data + + enabled_services: EnabledServices = Field( + default_factory=EnabledServices, + description="""The following environment variables dictates which routers are enabled.""", + ) + + opensearch_dbs: OpenSearchDBSettings = Field( + default_factory=OpenSearchDBSettings, + description="""The following environment variables configure the OpenSearch database connections.""", + ) + + sql_dbs: SqlDBSettings = Field( + default_factory=SqlDBSettings, + description="""The following environment variables configure the SQL database connections.""", + ) + + fields: dict[str, tuple[Any, Any]] = {} + + new_mod = create_model( + "FactorySettings", + __doc__=_BaseFactorySettings.__doc__, + __base__=_BaseFactorySettings, + **cast(dict[str, Any], fields), + ) + return new_mod + + +FactorySettings = _build_factory_settings_model() diff --git a/diracx-db/src/diracx/db/os/utils.py b/diracx-db/src/diracx/db/os/utils.py index 1d80b9958..f58509dca 100644 --- a/diracx-db/src/diracx/db/os/utils.py +++ b/diracx-db/src/diracx/db/os/utils.py @@ -3,7 +3,6 @@ import contextlib import json import logging -import os from abc import ABCMeta, abstractmethod from collections.abc import AsyncIterator from contextvars import ContextVar @@ -14,6 +13,7 @@ from diracx.core.exceptions import InvalidQueryError from diracx.core.extensions import DiracEntryPoint, select_from_extension +from diracx.core.settings import FactorySettings from diracx.db.exceptions import DBUnavailableError logger = logging.getLogger(__name__) @@ -38,7 +38,8 @@ class BaseOSDB(metaclass=ABCMeta): This method returns a dictionary of database names to connection parameters. The available databases are determined by the `diracx.dbs.os` entrypoint in the `pyproject.toml` file and the connection parameters are taken from the - environment variables prefixed with `DIRACX_OS_DB_{DB_NAME}`. + `opensearch_dbs` field in FactorySettings, which reads from environment variables + prefixed with `DIRACX_OS_DB_{DB_NAME}`. If extensions to DiracX are being used, there can be multiple implementations of the same database. To list the available implementations use @@ -104,19 +105,26 @@ def available_implementations(cls, db_name: str) -> list[type[BaseOSDB]]: def available_urls(cls) -> dict[str, dict[str, Any]]: """Return a dict of available OpenSearch database urls. - The list of available URLs is determined by environment variables + The list of available URLs is determined by the opensearch_dbs field + in FactorySettings, which reads from environment variables prefixed with ``DIRACX_OS_DB_{DB_NAME}``. """ + factory_settings = FactorySettings() + opensearch_dbs = factory_settings.opensearch_dbs + conn_kwargs: dict[str, dict[str, Any]] = {} for entry_point in select_from_extension(group=DiracEntryPoint.OS_DB): db_name = entry_point.name - var_name = f"DIRACX_OS_DB_{entry_point.name.upper()}" - if var_name in os.environ: - try: - conn_kwargs[db_name] = json.loads(os.environ[var_name]) - except Exception: - logger.error("Error loading connection parameters for %s", db_name) - raise + # Get the field value from the OpenSearchDBSettings model + if field_value := getattr(opensearch_dbs, db_name, None): + if field_value: + try: + conn_kwargs[db_name] = json.loads(field_value) + except Exception: + logger.error( + "Error loading connection parameters for %s", db_name + ) + raise return conn_kwargs @classmethod diff --git a/diracx-db/src/diracx/db/sql/utils/base.py b/diracx-db/src/diracx/db/sql/utils/base.py index 4a8ddfd65..d9141d577 100644 --- a/diracx-db/src/diracx/db/sql/utils/base.py +++ b/diracx-db/src/diracx/db/sql/utils/base.py @@ -2,7 +2,6 @@ import contextlib import logging -import os import re from abc import ABCMeta from collections.abc import AsyncIterator @@ -53,8 +52,9 @@ class BaseSQLDB(metaclass=ABCMeta): The available databases are discovered by calling `BaseSQLDB.available_urls`. This method returns a mapping of database names to connection URLs. The available databases are determined by the `diracx.dbs.sql` entrypoint in the - `pyproject.toml` file and the connection URLs are taken from the environment - variables of the form `DIRACX_DB_URL_`. + `pyproject.toml` file and the connection URLs are taken from the + `sql_dbs` field in FactorySettings, which reads from environment variables + of the form `DIRACX_DB_URL_`. If extensions to DiracX are being used, there can be multiple implementations of the same database. To list the available implementations use @@ -125,37 +125,45 @@ def available_implementations(cls, db_name: str) -> list[type["BaseSQLDB"]]: def available_urls(cls) -> dict[str, str]: """Return a dict of available database urls. - The list of available URLs is determined by environment variables + The list of available URLs is determined by the sql_dbs field + in FactorySettings, which reads from environment variables prefixed with ``DIRACX_DB_URL_{DB_NAME}``. """ + from diracx.core.settings import FactorySettings + + factory_settings = FactorySettings() + sql_dbs = factory_settings.sql_dbs + db_urls: dict[str, str] = {} for entry_point in select_from_extension(group=DiracEntryPoint.SQL_DB): db_name = entry_point.name - var_name = f"DIRACX_DB_URL_{entry_point.name.upper()}" - if var_name in os.environ: - try: - db_url = os.environ[var_name] - if db_url == "sqlite+aiosqlite:///:memory:": - db_urls[db_name] = db_url - # pydantic does not allow for underscore in scheme - # so we do a special case - elif "_" in db_url.split(":")[0]: - # Validate the URL with a fake schema, and then store - # the original one - scheme_id = db_url.find(":") - fake_url = ( - db_url[:scheme_id].replace("_", "-") + db_url[scheme_id:] - ) - TypeAdapter(SqlalchemyDsn).validate_python(fake_url) - db_urls[db_name] = db_url - - else: - db_urls[db_name] = str( - TypeAdapter(SqlalchemyDsn).validate_python(db_url) - ) - except Exception: - logger.error("Error loading URL for %s", db_name) - raise + # Get the field value from the SqlDBSettings model + if hasattr(sql_dbs, db_name): + db_url = getattr(sql_dbs, db_name) + if db_url: + try: + if db_url == "sqlite+aiosqlite:///:memory:": + db_urls[db_name] = db_url + # pydantic does not allow for underscore in scheme + # so we do a special case + elif "_" in db_url.split(":")[0]: + # Validate the URL with a fake schema, and then store + # the original one + scheme_id = db_url.find(":") + fake_url = ( + db_url[:scheme_id].replace("_", "-") + + db_url[scheme_id:] + ) + TypeAdapter(SqlalchemyDsn).validate_python(fake_url) + db_urls[db_name] = db_url + + else: + db_urls[db_name] = str( + TypeAdapter(SqlalchemyDsn).validate_python(db_url) + ) + except Exception: + logger.error("Error loading URL for %s", db_name) + raise return db_urls @classmethod diff --git a/diracx-routers/src/diracx/routers/factory.py b/diracx-routers/src/diracx/routers/factory.py index 342e6d5cd..4761b02c4 100644 --- a/diracx-routers/src/diracx/routers/factory.py +++ b/diracx-routers/src/diracx/routers/factory.py @@ -6,7 +6,6 @@ import inspect import logging -import os from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence from functools import partial from http import HTTPStatus @@ -14,7 +13,6 @@ from logging import Formatter, StreamHandler from typing import Any, TypeVar, cast -import dotenv from cachetools import TTLCache from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status from fastapi.dependencies.models import Dependant @@ -24,15 +22,13 @@ from fastapi.responses import JSONResponse, Response from fastapi.routing import APIRoute from packaging.version import InvalidVersion, parse -from pydantic import TypeAdapter from starlette.middleware.base import BaseHTTPMiddleware from uvicorn.logging import AccessFormatter, DefaultFormatter from diracx.core.config import ConfigSource from diracx.core.exceptions import DiracError, DiracHttpResponseError, NotReadyError from diracx.core.extensions import DiracEntryPoint, select_from_extension -from diracx.core.settings import ServiceSettingsBase -from diracx.core.utils import dotenv_files_from_environment +from diracx.core.settings import FactorySettings, ServiceSettingsBase from diracx.db.exceptions import DBUnavailableError from diracx.db.os.utils import BaseOSDB from diracx.db.sql.utils import BaseSQLDB @@ -346,17 +342,16 @@ def create_app() -> DiracFastAPI: We attempt to load each setting classes to make sure that the settings are correctly defined. """ - for env_file in dotenv_files_from_environment("DIRACX_SERVICE_DOTENV"): - logger.debug("Loading dotenv file: %s", env_file) - if not dotenv.load_dotenv(env_file): - raise NotImplementedError(f"Could not load dotenv file {env_file}") - # Load all available routers enabled_systems = set() settings_classes = set() + factory_settings = FactorySettings() for entry_point in select_from_extension(group=DiracEntryPoint.SERVICES): - env_var = f"DIRACX_SERVICE_{entry_point.name.upper()}_ENABLED" - enabled = TypeAdapter(bool).validate_json(os.environ.get(env_var, "true")) + enabled = getattr( + factory_settings.enabled_services, + entry_point.name, + True, + ) logger.debug("Found service %r: enabled=%s", entry_point, enabled) if not enabled: continue diff --git a/diracx-tasks/src/diracx/tasks/plumbing/factory.py b/diracx-tasks/src/diracx/tasks/plumbing/factory.py index 17187a056..c27982094 100644 --- a/diracx-tasks/src/diracx/tasks/plumbing/factory.py +++ b/diracx-tasks/src/diracx/tasks/plumbing/factory.py @@ -3,7 +3,6 @@ import asyncio import inspect import logging -import os from collections.abc import AsyncIterator, Iterable from contextlib import AsyncExitStack, asynccontextmanager from functools import partial @@ -15,6 +14,7 @@ from fastapi.dependencies.utils import get_dependant from diracx.core.extensions import select_from_extension +from diracx.core.settings import FactorySettings from ._redis_types import LockCoordinator from .base_task import BaseTask @@ -313,7 +313,7 @@ async def setup_dependency_overrides( overrides[os_db_class.session] = partial(_db_context, os_db) # --- Config --- - config_url = os.environ.get("DIRACX_CONFIG_BACKEND_URL") + config_url = FactorySettings().diracx_config_backend_url if config_url: from diracx.core.config import ConfigSource diff --git a/diracx-tasks/src/diracx/tasks/task_run.py b/diracx-tasks/src/diracx/tasks/task_run.py index a9971cfe9..48ab37287 100644 --- a/diracx-tasks/src/diracx/tasks/task_run.py +++ b/diracx-tasks/src/diracx/tasks/task_run.py @@ -22,6 +22,8 @@ from enum import StrEnum from typing import TYPE_CHECKING, Any, Iterable +from diracx.core.settings import FactorySettings + if TYPE_CHECKING: from .plumbing._redis_types import LockCoordinator @@ -240,7 +242,7 @@ async def start_scheduler(redis_url: str) -> None: task_classes = load_task_registry() config = None - config_url = os.environ.get("DIRACX_CONFIG_BACKEND_URL") + config_url = FactorySettings().diracx_config_backend_url if config_url: from diracx.core.config import ConfigSource diff --git a/docs/admin/reference/env-variables.md b/docs/admin/reference/env-variables.md index ea9a04a49..fc94cee7e 100644 --- a/docs/admin/reference/env-variables.md +++ b/docs/admin/reference/env-variables.md @@ -4,18 +4,109 @@ ## Core +### `DIRACX_SERVICE_DOTENV` + +The variable points to .env files where configuration may be placed. There could be more than one file, with suffixes +\_X, where X is a number. The files will be loaded in order. + +## FactorySettings + +Factory settings. + +``` + Settings which do not fit into dedicated classes, + or are dynamically generated. +``` + ### `DIRACX_CONFIG_BACKEND_URL` +**Required** The URL of the configuration backend. -### `DIRACX_SERVICE_DOTENV` +### `DIRACX_LEGACY_EXCHANGE_HASHED_API_KEY` -The variable points to .env files where configuration may be placed. There could be more than one file, with suffixes -\_X, where X is a number. The files will be loaded in order. +*Optional*, default value: \`\` +The hashed API key for the legacy exchange endpoint. + +### `ENABLED_SERVICES` + +*Optional* +The following environment variables dictates which routers are enabled. + +#### `DIRACX_SERVICE_AUTH_ENABLED` + +*Optional*, default value: `True` +Enable the AUTH router + +#### `DIRACX_SERVICE_CONFIG_ENABLED` + +*Optional*, default value: `True` +Enable the CONFIG router + +#### `DIRACX_SERVICE_HEALTH_ENABLED` + +*Optional*, default value: `True` +Enable the HEALTH router + +#### `DIRACX_SERVICE_JOBS_ENABLED` + +*Optional*, default value: `True` +Enable the JOBS router + +### `OPENSEARCH_DBS` + +*Optional* +The following environment variables configure the OpenSearch database connections. + +#### `DIRACX_OS_DB_JOBPARAMETERSDB` + +*Optional*, default value: \`\` +A JSON-encoded dictionary of connection keyword arguments for the OpenSearch database JobParametersDB. + +### `SQL_DBS` + +*Optional* +The following environment variables configure the SQL database connections. + +#### `DIRACX_DB_URL_TASKDB` + +*Optional*, default value: \`\` +The URL for the SQL database TaskDB. + +#### `DIRACX_DB_URL_AUTHDB` + +*Optional*, default value: \`\` +The URL for the SQL database AuthDB. + +#### `DIRACX_DB_URL_JOBDB` + +*Optional*, default value: \`\` +The URL for the SQL database JobDB. + +#### `DIRACX_DB_URL_JOBLOGGINGDB` -### `DIRACX_SERVICE_JOBS_ENABLED` +*Optional*, default value: \`\` +The URL for the SQL database JobLoggingDB. + +#### `DIRACX_DB_URL_PILOTAGENTSDB` + +*Optional*, default value: \`\` +The URL for the SQL database PilotAgentsDB. -Determines whether the jobs service is enabled. +#### `DIRACX_DB_URL_RESOURCESTATUSDB` + +*Optional*, default value: \`\` +The URL for the SQL database ResourceStatusDB. + +#### `DIRACX_DB_URL_SANDBOXMETADATADB` + +*Optional*, default value: \`\` +The URL for the SQL database SandboxMetadataDB. + +#### `DIRACX_DB_URL_TASKQUEUEDB` + +*Optional*, default value: \`\` +The URL for the SQL database TaskQueueDB. ## AuthSettings @@ -138,10 +229,6 @@ Set of security properties available in this DIRAC installation. These properties define various authorization capabilities and are used for access control decisions. Defaults to all available security properties. -### `DIRACX_LEGACY_EXCHANGE_HASHED_API_KEY` - -The hashed API key for the legacy exchange endpoint. - ## SandboxStoreSettings Settings for the sandbox store. @@ -218,16 +305,6 @@ Maximum number of concurrent DB delete chunks during cleaning. Controls parallelism of database DELETE operations. -## Databases - -### `DIRACX_DB_URL_` - -The URL for the SQL database ``. - -### `DIRACX_OS_DB_` - -A JSON-encoded dictionary of connection keyword arguments for the OpenSearch database ``. - ## OTELSettings Settings for the Open Telemetry Configuration. diff --git a/docs/admin/reference/env-variables.md.j2 b/docs/admin/reference/env-variables.md.j2 index 29cb4f078..8f17665e1 100644 --- a/docs/admin/reference/env-variables.md.j2 +++ b/docs/admin/reference/env-variables.md.j2 @@ -1,35 +1,20 @@ -{% from '_render_class.jinja' import render_class %} +{% from '_render_class.jinja' import render_class,render_factory_settings %} # List of environment variables *This page is auto-generated from the settings classes in `diracx.core.settings`.* ## Core -### `DIRACX_CONFIG_BACKEND_URL` -The URL of the configuration backend. ### `DIRACX_SERVICE_DOTENV` The variable points to .env files where configuration may be placed. There could be more than one file, with suffixes _X, where X is a number. The files will be loaded in order. -### `DIRACX_SERVICE_JOBS_ENABLED` -Determines whether the jobs service is enabled. +{{ render_factory_settings() }} -{{ render_class('AuthSettings') }} -### `DIRACX_LEGACY_EXCHANGE_HASHED_API_KEY` -The hashed API key for the legacy exchange endpoint. +{{ render_class('AuthSettings') }} {{ render_class('SandboxStoreSettings') }} -## Databases - -### `DIRACX_DB_URL_` - -The URL for the SQL database ``. - -### `DIRACX_OS_DB_` - -A JSON-encoded dictionary of connection keyword arguments for the OpenSearch database ``. - {{ render_class('OTELSettings') }} diff --git a/docs/templates/_render_class.jinja b/docs/templates/_render_class.jinja index 242d6fb4b..c7875d12a 100644 --- a/docs/templates/_render_class.jinja +++ b/docs/templates/_render_class.jinja @@ -38,3 +38,117 @@ Available classes: {{ classes.keys() | map(attribute='__name__') | list | join(' {% endif %} {% endmacro %} + +{% macro render_factory_settings() %} +{# Find the FactorySettings class #} +{% set cls = namespace(found=none) %} +{% for c, field_list in classes.items() %} + {% if c.__name__ == 'FactorySettings' %} + {% set cls.found = c %} + {% endif %} +{% endfor %} + +{% if cls.found %} +## {{ cls.found.__name__ }} + +{{ cls.found.__doc__ or "*No description available.*" }} + +{# Render all fields manually #} +{% for field_name, field_info in cls.found.model_fields.items() %} + {% set env_prefix = cls.found.model_config.get('env_prefix', '') %} + {% set env_name = (env_prefix ~ field_name).upper() %} + {% if field_info.validation_alias %} + {% set env_name = field_info.validation_alias %} + {% endif %} + + {# Render the field heading #} +{% if not loop.first %} + +{% endif %}### `{{ env_name|upper }}` + +*{% if field_info.is_required() %}*Required*{% else %}Optional{% endif %}*{% if has_default_value(field_info) %}, default value: `{{ fix_str_enum_value(field_info.default) }}`{% endif %} + +{% if field_info.description %} +{{ field_info.description }} +{% endif %} + + {# Special handling for enabled_services - render nested fields one level deeper #} + {% if field_name == 'enabled_services' and field_info.annotation %} + {% set nested_model = field_info.annotation %} + {% if nested_model.model_fields is defined %} + + {% for nested_field_name, nested_field_info in nested_model.model_fields.items() %} + {% if nested_field_info.validation_alias %} + {% set nested_env_name = nested_field_info.validation_alias %} + {% else %} + {% set nested_env_prefix = nested_model.model_config.get('env_prefix', '') %} + {% set nested_env_name = (nested_env_prefix ~ nested_field_name).upper() %} + {% endif %} + +#### `{{ nested_env_name|upper }}` + +*{% if nested_field_info.is_required() %}*Required*{% else %}Optional{% endif %}*{% if nested_field_info.default is not none %}, default value: `{{ nested_field_info.default }}`{% endif %} + +{% if nested_field_info.description %} +{{ nested_field_info.description }} +{% endif %} + {% endfor %} + {% endif %} + {% endif %} + + {# Special handling for opensearch_dbs - render nested fields one level deeper #} + {% if field_name == 'opensearch_dbs' and field_info.annotation %} + {% set nested_model = field_info.annotation %} + {% if nested_model.model_fields is defined %} + + {% for nested_field_name, nested_field_info in nested_model.model_fields.items() %} + {% if nested_field_info.validation_alias %} + {% set nested_env_name = nested_field_info.validation_alias %} + {% else %} + {% set nested_env_prefix = nested_model.model_config.get('env_prefix', '') %} + {% set nested_env_name = (nested_env_prefix ~ nested_field_name).upper() %} + {% endif %} + +#### `{{ nested_env_name|upper }}` + +*{% if nested_field_info.is_required() %}*Required*{% else %}Optional{% endif %}*{% if nested_field_info.default is not none %}, default value: `{{ nested_field_info.default }}`{% endif %} + +{% if nested_field_info.description %} +{{ nested_field_info.description }} +{% endif %} + {% endfor %} + {% endif %} + {% endif %} + + {# Special handling for sql_dbs - render nested fields one level deeper #} + {% if field_name == 'sql_dbs' and field_info.annotation %} + {% set nested_model = field_info.annotation %} + {% if nested_model.model_fields is defined %} + + {% for nested_field_name, nested_field_info in nested_model.model_fields.items() %} + {% if nested_field_info.validation_alias %} + {% set nested_env_name = nested_field_info.validation_alias %} + {% else %} + {% set nested_env_prefix = nested_model.model_config.get('env_prefix', '') %} + {% set nested_env_name = (nested_env_prefix ~ nested_field_name).upper() %} + {% endif %} + +#### `{{ nested_env_name|upper }}` + +*{% if nested_field_info.is_required() %}*Required*{% else %}Optional{% endif %}*{% if nested_field_info.default is not none %}, default value: `{{ nested_field_info.default }}`{% endif %} + +{% if nested_field_info.description %} +{{ nested_field_info.description }} +{% endif %} + {% endfor %} + {% endif %} + {% endif %} +{% endfor %} +{% else %} +{# Class not found - provide a helpful error #} +**Error: FactorySettings class not found in diracx.core.settings** + +Available classes: {{ classes.keys() | map(attribute='__name__') | list | join(', ') }} +{% endif %} + +{% endmacro %} diff --git a/scripts/generate_settings_docs.py b/scripts/generate_settings_docs.py index 27e34558f..91f6995b2 100644 --- a/scripts/generate_settings_docs.py +++ b/scripts/generate_settings_docs.py @@ -194,6 +194,7 @@ def validate_documentation( # Exclude ServiceSettingsBase as it's the base class undocumented.discard("ServiceSettingsBase") + undocumented.discard("FactorySettings") if undocumented: all_documented = False