From 9f639852cfb3d9b18370497c65125e4cc72415d4 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 12 Aug 2026 01:02:09 +0100 Subject: [PATCH 1/4] Document the Docker environment variables the applications read (#3387) docs/source/docker.rst told users to set TARGET_MANAGER_BACKEND, which no application reads, with a value which has no scheme. Following the quickstart exactly left the VWS and VWQ containers permanently unhealthy: the health check probe constructs VWSSettings, which raises when target_manager_base_url is unset. Use TARGET_MANAGER_BASE_URL and http://vuforia-target-manager-mock:5000, document RESPONSE_DELAY_SECONDS and the three host variables the images set, and show the full response body which the target manager returns for a created cloud database. The console blocks in the documentation are checked for valid shell but never run, so nothing caught the drift. Add tests which compare the documented variables against the settings fields of the three applications, and the required section against the fields with no default. Closes #3387 Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/docker.rst | 57 +++++++-- ...cumentation-target-manager-base-url.change | 1 + tests/mock_vws/test_docker.py | 116 +++++++++++++++++- 3 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 newsfragments/docker-documentation-target-manager-base-url.change diff --git a/docs/source/docker.rst b/docs/source/docker.rst index 0e705caf5..a58d6e77f 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -13,7 +13,7 @@ One container mocks the VWS services, one container mocks the VWQ services and o Each of these containers run their services on port 5000. -The VWS and VWQ containers must point to the target manager container using the :envvar:`TARGET_MANAGER_BACKEND` variable. +The VWS and VWQ containers must point to the target manager container using the :envvar:`TARGET_MANAGER_BASE_URL` variable. .. _creating-containers: @@ -32,13 +32,13 @@ Creating containers $ docker run \ --detach \ --publish 5006:5000 \ - -e TARGET_MANAGER_BACKEND=vuforia-target-manager-mock:5000 \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vws-mock $ docker run \ --detach \ --publish 5007:5000 \ - -e TARGET_MANAGER_BACKEND=vuforia-target-manager-mock:5000 \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vwq-mock @@ -65,13 +65,23 @@ For example, with the containers set up as in :ref:`creating-containers`, use `` --data '{}' \ '127.0.0.1:5005/cloud_databases' { - "client_access_key": "2d61c1d17bb94694bee77c1f1f41e5d9", - "client_secret_key": "b73f8170cf7d42728fa8ce66221ad147", + "database_id": "ca6e48ed25a340d998905ac59747a1f8", "database_name": "e515df24ba944f43b8f7969bc98af107", "server_access_key": "cb1759871a504875ab5f96d6db5ff79b", "server_secret_key": "9b8533d912ad4aa79cb61b6ee197ece2", + "client_access_key": "2d61c1d17bb94694bee77c1f1f41e5d9", + "client_secret_key": "b73f8170cf7d42728fa8ce66221ad147", "state_name": "WORKING", - "targets": [] + "database_type_name": "CLOUD_RECO", + "targets": [], + "request_quota": 100000, + "reco_threshold": 1000, + "current_month_recos": 0, + "previous_month_recos": 0, + "total_recos": 0, + "target_quota": 1000, + "requests_per_second_limit": null, + "request_rate_limits": null } Deleting a database @@ -92,17 +102,34 @@ Configuration options Required configuration ^^^^^^^^^^^^^^^^^^^^^^ -.. envvar:: TARGET_MANAGER_BACKEND +.. envvar:: TARGET_MANAGER_BASE_URL This is required by the VWS mock and the VWQ mock containers. - This is the route to the target manager container from the other containers. + This is the base URL of the target manager container as seen from the other containers. + It must include a scheme, for example ``http://vuforia-target-manager-mock:5000``. Optional configuration ^^^^^^^^^^^^^^^^^^^^^^ +VWS and Query containers +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. envvar:: RESPONSE_DELAY_SECONDS + + The number of seconds to wait before sending each response. + + Default: ``0.0`` + Target manager container ~~~~~~~~~~~~~~~~~~~~~~~~ +.. envvar:: TARGET_MANAGER_HOST + + The host interface which the target manager container's server binds to. + The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. + + Default: ``0.0.0.0`` + .. envvar:: TARGET_RATER The rater to use for target tracking ratings. @@ -118,6 +145,13 @@ Target manager container Query container ~~~~~~~~~~~~~~~ +.. envvar:: VWQ_HOST + + The host interface which the VWQ container's server binds to. + The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. + + Default: ``0.0.0.0`` + .. envvar:: QUERY_IMAGE_MATCHER The matcher to use for the query endpoint. @@ -132,6 +166,13 @@ Query container VWS container ~~~~~~~~~~~~~ +.. envvar:: VWS_HOST + + The host interface which the VWS container's server binds to. + The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. + + Default: ``0.0.0.0`` + .. envvar:: PROCESSING_TIME_SECONDS The number of seconds to process each image for. diff --git a/newsfragments/docker-documentation-target-manager-base-url.change b/newsfragments/docker-documentation-target-manager-base-url.change new file mode 100644 index 000000000..6da3a25d1 --- /dev/null +++ b/newsfragments/docker-documentation-target-manager-base-url.change @@ -0,0 +1 @@ +Document the Docker containers' configuration with the environment variable names and values which the applications actually read, starting with ``TARGET_MANAGER_BASE_URL``. diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py index 3bf78f074..d9435bf7d 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -1,8 +1,9 @@ """Tests for running the mock server in Docker.""" import io +import re import uuid -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Mapping from http import HTTPStatus from typing import TYPE_CHECKING @@ -13,17 +14,35 @@ from docker.errors import BuildError, NotFound from docker.models.containers import Container from docker.models.networks import Network +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings from tenacity import retry from tenacity.retry import retry_if_exception_type from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed from vws import VWS, CloudRecoService +from mock_vws._flask_server.target_manager import TargetManagerSettings +from mock_vws._flask_server.vwq import VWQSettings +from mock_vws._flask_server.vws import VWSSettings from mock_vws.database import CloudDatabase if TYPE_CHECKING: from docker.models.images import Image +_SETTINGS_CLASSES: tuple[type[BaseSettings], ...] = ( + TargetManagerSettings, + VWQSettings, + VWSSettings, +) + +_ENVVAR_PATTERN = re.compile( + pattern=r"^\.\. envvar:: (\w+)$", + flags=re.MULTILINE, +) + +_OPTIONAL_CONFIGURATION_HEADING = "Optional configuration" + @retry( wait=wait_fixed(wait=0.5), @@ -274,3 +293,98 @@ def test_build_and_run( matching_targets = cloud_reco_client.query(image=high_quality_image) assert matching_targets[0].target_id == target_id + + +@pytest.fixture(name="docker_documentation") +def fixture_docker_documentation(request: pytest.FixtureRequest) -> str: + """Return the text of the Docker documentation.""" + documentation_path = request.config.rootpath / "docs/source/docker.rst" + return documentation_path.read_text(encoding="utf-8") + + +@beartype +def _documented_variables(*, documentation: str) -> set[str]: + """Return every environment variable which the given text + documents. + """ + return set(_ENVVAR_PATTERN.findall(string=documentation)) + + +@beartype +def _settings_fields( + *, + settings_class: type[BaseSettings], +) -> Mapping[str, FieldInfo]: + """Return the fields of a settings class.""" + fields: Mapping[str, FieldInfo] = settings_class.model_fields + return fields + + +@beartype +def _application_variables() -> set[str]: + """Return every environment variable the applications read.""" + return { + field_name.upper() + for settings_class in _SETTINGS_CLASSES + for field_name in _settings_fields(settings_class=settings_class) + } + + +@beartype +def _required_application_variables() -> set[str]: + """Return every environment variable an application cannot start + without. + """ + return { + field_name.upper() + for settings_class in _SETTINGS_CLASSES + for field_name, field in _settings_fields( + settings_class=settings_class, + ).items() + if field.is_required() + } + + +class TestDocumentedConfiguration: + """Tests for the documented environment variables. + + The ``console`` blocks in the documentation are checked for valid + shell but never run, so nothing else catches a documented variable + which no application reads. + """ + + @staticmethod + def test_all_variables_are_documented( + *, + docker_documentation: str, + ) -> None: + """Every setting the applications read is documented.""" + documented = _documented_variables(documentation=docker_documentation) + assert not _application_variables() - documented + + @staticmethod + def test_no_variables_are_invented( + *, + docker_documentation: str, + ) -> None: + """Every documented variable is a setting an application reads.""" + documented = _documented_variables(documentation=docker_documentation) + assert not documented - _application_variables() + + @staticmethod + def test_required_variables_are_documented_as_required( + *, + docker_documentation: str, + ) -> None: + """The required section holds exactly the settings with no default. + + Those are the settings without which a container cannot start. + """ + required_section, _ = docker_documentation.split( + sep=_OPTIONAL_CONFIGURATION_HEADING, + maxsplit=1, + ) + assert ( + _documented_variables(documentation=required_section) + == _required_application_variables() + ) From 9bf1efb76a1eb56401339f96ed5c9d843409e32b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 12 Aug 2026 08:33:46 +0100 Subject: [PATCH 2/4] Drop the documentation-scraping tests Comparing the prose in docker.rst against the settings classes with a regular expression is a weak way to keep the two together. The documentation is corrected either way; keeping it correct belongs in a Sphinx extension which generates it, which is being packaged separately. Also drop the three host variables, which were documented only so that the tests could require full coverage of the settings fields. The images set them and they are not configuration for users of the images. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/docker.rst | 21 ------ tests/mock_vws/test_docker.py | 116 +--------------------------------- 2 files changed, 1 insertion(+), 136 deletions(-) diff --git a/docs/source/docker.rst b/docs/source/docker.rst index a58d6e77f..ab6ffd720 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -123,13 +123,6 @@ VWS and Query containers Target manager container ~~~~~~~~~~~~~~~~~~~~~~~~ -.. envvar:: TARGET_MANAGER_HOST - - The host interface which the target manager container's server binds to. - The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. - - Default: ``0.0.0.0`` - .. envvar:: TARGET_RATER The rater to use for target tracking ratings. @@ -145,13 +138,6 @@ Target manager container Query container ~~~~~~~~~~~~~~~ -.. envvar:: VWQ_HOST - - The host interface which the VWQ container's server binds to. - The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. - - Default: ``0.0.0.0`` - .. envvar:: QUERY_IMAGE_MATCHER The matcher to use for the query endpoint. @@ -166,13 +152,6 @@ Query container VWS container ~~~~~~~~~~~~~ -.. envvar:: VWS_HOST - - The host interface which the VWS container's server binds to. - The provided images set this to ``0.0.0.0`` so that the server is reachable from outside the container. - - Default: ``0.0.0.0`` - .. envvar:: PROCESSING_TIME_SECONDS The number of seconds to process each image for. diff --git a/tests/mock_vws/test_docker.py b/tests/mock_vws/test_docker.py index d9435bf7d..3bf78f074 100644 --- a/tests/mock_vws/test_docker.py +++ b/tests/mock_vws/test_docker.py @@ -1,9 +1,8 @@ """Tests for running the mock server in Docker.""" import io -import re import uuid -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Iterable, Iterator from http import HTTPStatus from typing import TYPE_CHECKING @@ -14,35 +13,17 @@ from docker.errors import BuildError, NotFound from docker.models.containers import Container from docker.models.networks import Network -from pydantic.fields import FieldInfo -from pydantic_settings import BaseSettings from tenacity import retry from tenacity.retry import retry_if_exception_type from tenacity.stop import stop_after_delay from tenacity.wait import wait_fixed from vws import VWS, CloudRecoService -from mock_vws._flask_server.target_manager import TargetManagerSettings -from mock_vws._flask_server.vwq import VWQSettings -from mock_vws._flask_server.vws import VWSSettings from mock_vws.database import CloudDatabase if TYPE_CHECKING: from docker.models.images import Image -_SETTINGS_CLASSES: tuple[type[BaseSettings], ...] = ( - TargetManagerSettings, - VWQSettings, - VWSSettings, -) - -_ENVVAR_PATTERN = re.compile( - pattern=r"^\.\. envvar:: (\w+)$", - flags=re.MULTILINE, -) - -_OPTIONAL_CONFIGURATION_HEADING = "Optional configuration" - @retry( wait=wait_fixed(wait=0.5), @@ -293,98 +274,3 @@ def test_build_and_run( matching_targets = cloud_reco_client.query(image=high_quality_image) assert matching_targets[0].target_id == target_id - - -@pytest.fixture(name="docker_documentation") -def fixture_docker_documentation(request: pytest.FixtureRequest) -> str: - """Return the text of the Docker documentation.""" - documentation_path = request.config.rootpath / "docs/source/docker.rst" - return documentation_path.read_text(encoding="utf-8") - - -@beartype -def _documented_variables(*, documentation: str) -> set[str]: - """Return every environment variable which the given text - documents. - """ - return set(_ENVVAR_PATTERN.findall(string=documentation)) - - -@beartype -def _settings_fields( - *, - settings_class: type[BaseSettings], -) -> Mapping[str, FieldInfo]: - """Return the fields of a settings class.""" - fields: Mapping[str, FieldInfo] = settings_class.model_fields - return fields - - -@beartype -def _application_variables() -> set[str]: - """Return every environment variable the applications read.""" - return { - field_name.upper() - for settings_class in _SETTINGS_CLASSES - for field_name in _settings_fields(settings_class=settings_class) - } - - -@beartype -def _required_application_variables() -> set[str]: - """Return every environment variable an application cannot start - without. - """ - return { - field_name.upper() - for settings_class in _SETTINGS_CLASSES - for field_name, field in _settings_fields( - settings_class=settings_class, - ).items() - if field.is_required() - } - - -class TestDocumentedConfiguration: - """Tests for the documented environment variables. - - The ``console`` blocks in the documentation are checked for valid - shell but never run, so nothing else catches a documented variable - which no application reads. - """ - - @staticmethod - def test_all_variables_are_documented( - *, - docker_documentation: str, - ) -> None: - """Every setting the applications read is documented.""" - documented = _documented_variables(documentation=docker_documentation) - assert not _application_variables() - documented - - @staticmethod - def test_no_variables_are_invented( - *, - docker_documentation: str, - ) -> None: - """Every documented variable is a setting an application reads.""" - documented = _documented_variables(documentation=docker_documentation) - assert not documented - _application_variables() - - @staticmethod - def test_required_variables_are_documented_as_required( - *, - docker_documentation: str, - ) -> None: - """The required section holds exactly the settings with no default. - - Those are the settings without which a container cannot start. - """ - required_section, _ = docker_documentation.split( - sep=_OPTIONAL_CONFIGURATION_HEADING, - maxsplit=1, - ) - assert ( - _documented_variables(documentation=required_section) - == _required_application_variables() - ) From b041b2906090ecd07ac4c9d8707b13b1660cbdab Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 12 Aug 2026 08:41:59 +0100 Subject: [PATCH 3/4] Generate the Docker configuration documentation from the settings Each setting is now described with Field(description=...) where it is defined, and docs/source/settings_envvars.py turns those descriptions into the envvar entries in the configuration reference, in the same way that the endpoints on the same page come from autoflask. The extension also defines a |env-| substitution per variable, so the example docker run commands name variables which cannot be stale either, and rejects a substitution which survives into a page, which is what sphinx-substitution-extensions does with an undefined one in a code block. Four kinds of drift now fail the documentation build: a variable named in the documentation which nothing reads, a renamed setting, a new setting with no description, and a settings class which the configuration names but which does not exist. The extension takes the settings classes and the fields to leave undocumented from conf.py and imports nothing from this project, so it can move to a package of its own. Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/conf.py | 26 ++ docs/source/docker.rst | 76 +---- docs/source/settings_envvars.py | 289 +++++++++++++++++++ pyproject.toml | 3 + spelling_private_dict.txt | 1 + src/mock_vws/_flask_server/__init__.py | 14 + src/mock_vws/_flask_server/target_manager.py | 20 +- src/mock_vws/_flask_server/vwq.py | 36 ++- src/mock_vws/_flask_server/vws.py | 54 +++- 9 files changed, 430 insertions(+), 89 deletions(-) create mode 100644 docs/source/settings_envvars.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 7ffa7efca..5b50f4e64 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,11 +2,15 @@ """Configuration for Sphinx.""" import importlib.metadata +import sys from pathlib import Path from packaging.specifiers import SpecifierSet from sphinx_pyproject import SphinxConfig +# Make the local ``settings_envvars`` extension importable. +sys.path.insert(0, str(object=Path(__file__).parent)) + _pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" _pyproject_config = SphinxConfig( pyproject_file=_pyproject_file, @@ -27,6 +31,28 @@ "sphinxcontrib.towncrier.ext", "sphinxcontrib.autohttp.flask", "sphinx_toolbox.more_autodoc.autoprotocol", + # A local extension, in this directory, which documents pydantic + # settings as environment variables. + # It knows nothing about this project, so that it can move to a + # package of its own. + "settings_envvars", +] + +# The Docker configuration documentation is generated from these. +pydantic_envvars_settings = { + "the target manager container": ( + "mock_vws._flask_server.target_manager:TargetManagerSettings" + ), + "the VWS container": "mock_vws._flask_server.vws:VWSSettings", + "the Query container": "mock_vws._flask_server.vwq:VWQSettings", +} + +# The images set these, so they are not configuration for users of the +# images. +pydantic_envvars_undocumented = [ + "target_manager_host", + "vws_host", + "vwq_host", ] # Render the unreleased ``newsfragments/`` entries into diff --git a/docs/source/docker.rst b/docs/source/docker.rst index ab6ffd720..bf8956b20 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -21,6 +21,7 @@ Creating containers ^^^^^^^^^^^^^^^^^^^ .. code-block:: console + :substitutions: $ docker network create -d bridge vws-bridge-network $ docker run \ @@ -32,13 +33,13 @@ Creating containers $ docker run \ --detach \ --publish 5006:5000 \ - -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ + -e "|env-target-manager-base-url|=http://vuforia-target-manager-mock:5000" \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vws-mock $ docker run \ --detach \ --publish 5007:5000 \ - -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ + -e "|env-target-manager-base-url|=http://vuforia-target-manager-mock:5000" \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vwq-mock @@ -102,79 +103,12 @@ Configuration options Required configuration ^^^^^^^^^^^^^^^^^^^^^^ -.. envvar:: TARGET_MANAGER_BASE_URL - - This is required by the VWS mock and the VWQ mock containers. - This is the base URL of the target manager container as seen from the other containers. - It must include a scheme, for example ``http://vuforia-target-manager-mock:5000``. +.. pydantic-envvars:: required Optional configuration ^^^^^^^^^^^^^^^^^^^^^^ -VWS and Query containers -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. envvar:: RESPONSE_DELAY_SECONDS - - The number of seconds to wait before sending each response. - - Default: ``0.0`` - -Target manager container -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. envvar:: TARGET_RATER - - The rater to use for target tracking ratings. - - Options include: - - * ``brisque``: The rating is derived using the BRISQUE algorithm. - * ``perfect``: The rating is always 5. - * ``random``: The rating is random. - - Default: ``brisque`` - -Query container -~~~~~~~~~~~~~~~ - -.. envvar:: QUERY_IMAGE_MATCHER - - The matcher to use for the query endpoint. - - Options include: - - * ``exact``: The images must be exactly the same to match. - * ``structural_similarity``: The images must have a similar structural similarity to match. - - Default: ``structural_similarity`` - -VWS container -~~~~~~~~~~~~~ - -.. envvar:: PROCESSING_TIME_SECONDS - - The number of seconds to process each image for. - - Default: ``2.0`` - -.. envvar:: VWS_BASE_URL - - The base URL which clients use to reach the VWS container. - The download URL of a reco counts report is built from this URL. - - Default: ``https://vws.vuforia.com`` - -.. envvar:: DUPLICATES_IMAGE_MATCHER - - The matcher to use for the duplicates endpoint. - - Options include: - - * ``exact``: The images must be exactly the same to be duplicates. - * ``structural_similarity``: The images must have a similar structural similarity to be duplicates. - - Default: ``structural_similarity`` +.. pydantic-envvars:: optional Building images from source ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/settings_envvars.py b/docs/source/settings_envvars.py new file mode 100644 index 000000000..b6e688e0e --- /dev/null +++ b/docs/source/settings_envvars.py @@ -0,0 +1,289 @@ +"""A Sphinx extension which documents pydantic settings as environment +variables. + +``pydantic-settings`` derives an environment variable from each field of +a settings class. Generating the documentation from those classes means +that the two cannot drift, in the same way that ``autoflask`` generates +the endpoint documentation from the applications themselves. + +This provides: + +* A ``pydantic-envvars`` directive which documents either the required + or the optional environment variables as ``envvar`` entries. +* A ``|env-|`` substitution per environment variable, so + that example commands use generated names too. + +Configure it with ``pydantic_envvars_settings``, which maps a +description of what reads a settings class to that class as +``module:name``, and ``pydantic_envvars_undocumented``, which lists +fields to leave undocumented. + +This knows nothing about any particular application, so that it can move +to a package of its own. +""" + +import importlib +import re +import textwrap +from enum import StrEnum + +from docutils import nodes +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings +from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx.errors import ExtensionError +from sphinx.util.docutils import SphinxDirective +from sphinx.util.typing import ExtensionMetadata + +# The prefix of the substitution which each environment variable name is +# available under. +_SUBSTITUTION_PREFIX = "env-" +_SUBSTITUTION_PATTERN = re.compile( + pattern=rf"\|{_SUBSTITUTION_PREFIX}[a-z0-9-]+\|", +) + +_REQUIRED = "required" +_OPTIONAL = "optional" + + +class _EnvironmentVariable: + """An environment variable which one or more settings classes read.""" + + def __init__( + self, + *, + field_name: str, + field: FieldInfo, + description: str, + ) -> None: + """ + Args: + field_name: The name of the settings field. + field: The settings field. + description: The description of the setting. + """ + self.name = field_name.upper() + self.field = field + self.description = description + self.read_by: list[str] = [] + + @property + def is_required(self) -> bool: + """Whether the setting has no default.""" + return self.field.is_required() + + @property + def default(self) -> str: + """The default value, as it is shown in the documentation.""" + default = self.field.default + if isinstance(default, StrEnum): + return default.value + return str(object=default) + + @property + def read_by_sentence(self) -> str: + """A sentence naming what reads this environment variable.""" + *first, last = self.read_by + names = f"{', '.join(first)} and {last}" if first else last + return f"Read by {names}." + + @property + def substitution(self) -> str: + """The substitution which this variable's name is available + under. + """ + name = self.name.lower().replace("_", "-") + return f"|{_SUBSTITUTION_PREFIX}{name}|" + + +def _settings_class(*, target: str) -> type[BaseSettings]: + """Return the settings class which the given ``module:name`` target + names. + + Raises: + ExtensionError: The target does not name a settings class. + """ + module_name, class_name = target.split(sep=":", maxsplit=1) + module = importlib.import_module(name=module_name) + settings_class = vars(module).get(class_name) + if not isinstance(settings_class, type) or not issubclass( + settings_class, + BaseSettings, + ): + msg = ( + f"'{target}' in pydantic_envvars_settings does not name a " + "pydantic-settings class." + ) + raise ExtensionError(message=msg) + return settings_class + + +def _environment_variables(*, config: Config) -> list[_EnvironmentVariable]: + """Return every environment variable to document. + + Raises: + ExtensionError: A setting has no description and is not listed in + ``pydantic_envvars_undocumented``, or two settings classes + describe one environment variable differently. + """ + settings_targets: dict[str, str] = config.pydantic_envvars_settings + undocumented: list[str] = config.pydantic_envvars_undocumented + + variables: dict[str, _EnvironmentVariable] = {} + for read_by, target in settings_targets.items(): + settings_class = _settings_class(target=target) + fields: dict[str, FieldInfo] = dict(settings_class.model_fields) + for field_name, field in fields.items(): + if field_name in undocumented: + continue + if field.description is None: + msg = ( + f"{settings_class.__name__}.{field_name} has no " + "description, so it cannot be documented. Give it a " + "``Field(description=...)``, or add it to " + "``pydantic_envvars_undocumented``." + ) + raise ExtensionError(message=msg) + variable = variables.setdefault( + field_name, + _EnvironmentVariable( + field_name=field_name, + field=field, + description=field.description, + ), + ) + if variable.description != field.description: + msg = ( + f"{field_name.upper()} is described differently by " + f"{settings_class.__name__} and another settings class. " + "Share one description between them." + ) + raise ExtensionError(message=msg) + variable.read_by.append(read_by) + return list(variables.values()) + + +class _PydanticEnvVarsDirective(SphinxDirective): + """Document environment variables which pydantic settings classes read.""" + + required_arguments = 1 + + def run(self) -> list[nodes.Node]: + """Return the documentation for the matching environment variables. + + Returns: + Nodes documenting either the required or the optional + environment variables. + + Raises: + ExtensionError: The directive's argument is neither + ``required`` nor ``optional``. + """ + (requirement,) = self.arguments + if requirement not in {_REQUIRED, _OPTIONAL}: + msg = ( + f"{self.get_location()}: the pydantic-envvars directive " + f"takes '{_REQUIRED}' or '{_OPTIONAL}', not '{requirement}'." + ) + raise ExtensionError(message=msg) + + blocks: list[str] = [] + for variable in _environment_variables(config=self.config): + if variable.is_required != (requirement == _REQUIRED): + continue + body = f"{variable.description}\n{variable.read_by_sentence}\n" + if not variable.is_required: + body += f"\nDefault: ``{variable.default}``\n" + blocks.append( + f".. envvar:: {variable.name}\n\n" + + textwrap.indent(text=body, prefix=" "), + ) + + return self.parse_text_to_nodes("\n".join(blocks)) + + +def _add_environment_variable_substitutions( + _app: Sphinx, + config: Config, +) -> None: + """Define a substitution for each environment variable name. + + ``|env-target-manager-base-url|`` in the documentation becomes + ``TARGET_MANAGER_BASE_URL``, so example commands cannot name a + variable which no settings class reads. + """ + substitutions = "\n".join( + f".. {variable.substitution} replace:: {variable.name}" + for variable in _environment_variables(config=config) + ) + config.rst_prolog = f"{config.rst_prolog or ''}\n{substitutions}\n" + + +def _check_environment_variable_substitutions( + app: Sphinx, + docname: str, + source: list[str], +) -> None: + """Reject a reference to an environment variable which does not exist. + + ``sphinx-substitution-extensions`` leaves an undefined substitution + in a code block as it is rather than reporting it, so a renamed + setting would otherwise reach the rendered page as literal + ``|env-...|`` text. + + Raises: + ExtensionError: The document uses a ``|env-...|`` substitution + which no settings class defines. + """ + known = { + variable.substitution + for variable in _environment_variables(config=app.config) + } + used = set(_SUBSTITUTION_PATTERN.findall(string="\n".join(source))) + unknown = sorted(used - known) + if unknown: + msg = ( + f"{docname}: {', '.join(unknown)} " + "names an environment variable which nothing reads." + ) + raise ExtensionError(message=msg) + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the configuration values, the directive and the + substitutions. + + Args: + app: The Sphinx application. + + Returns: + Metadata for Sphinx. + """ + app.add_config_value( + name="pydantic_envvars_settings", + default={}, + rebuild="env", + types=frozenset({dict}), + description=( + "A map of a description of what reads a pydantic settings " + "class to that class, as ``module:name``." + ), + ) + app.add_config_value( + name="pydantic_envvars_undocumented", + default=[], + rebuild="env", + types=frozenset({list}), + description="Settings fields to leave undocumented.", + ) + app.add_directive(name="pydantic-envvars", cls=_PydanticEnvVarsDirective) + app.connect( + event="config-inited", + callback=_add_environment_variable_substitutions, + ) + app.connect( + event="source-read", + callback=_check_environment_variable_substitutions, + ) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/pyproject.toml b/pyproject.toml index 127ff05a0..93e163115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "numpy>=2.4.4", "opencv-contrib-python-headless>=5.0.0.93", "pillow>=12.2.0", + "pydantic>=2.10.2", "pydantic-settings>=2.6.1", "pyteenybrisque>=0.1.1", "requests>=2.32.3", @@ -396,6 +397,8 @@ ignore_names = [ "pytest_plugins", "pytest_set_filtered_exceptions", "REQUEST_QUOTA_REACHED", + # docutils directives + "required_arguments", "rst_prolog", "source_suffix", "spelling_word_list_filename", diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index ef4380bc2..09d2a1a6c 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -81,6 +81,7 @@ png pragma presigned processable +pydantic pyrefly pyright pytest diff --git a/src/mock_vws/_flask_server/__init__.py b/src/mock_vws/_flask_server/__init__.py index 81533727f..baefe525c 100644 --- a/src/mock_vws/_flask_server/__init__.py +++ b/src/mock_vws/_flask_server/__init__.py @@ -1 +1,15 @@ """Flask server for the mock Vuforia web service.""" + +# The Docker documentation is generated from the settings classes, so +# each setting is described where it is defined. Settings which more +# than one application reads are described here, once. +TARGET_MANAGER_BASE_URL_DESCRIPTION = """\ +The base URL of the target manager container, as seen from this container. + +This must include a scheme, for example +``http://vuforia-target-manager-mock:5000``. +""" + +RESPONSE_DELAY_SECONDS_DESCRIPTION = """\ +The number of seconds to wait before sending each response. +""" diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 651a8a83b..006f5ae6d 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -6,11 +6,12 @@ import json from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import assert_never +from typing import Annotated, assert_never from zoneinfo import ZoneInfo from beartype import beartype from flask import Flask, Response, request +from pydantic import Field from pydantic_settings import BaseSettings from mock_vws.database import CloudDatabase, VuMarkDatabase @@ -58,8 +59,23 @@ def to_target_rater( class TargetManagerSettings(BaseSettings): """Settings for the Target Manager Flask app.""" + # The host interface which the server binds to. + # The images set this, so it is not documented as configuration. target_manager_host: str = "" - target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE + target_rater: Annotated[ + _TargetRaterChoice, + Field( + description="""\ +The rater to use for target tracking ratings. + +Options include: + +* ``brisque``: The rating is derived using the BRISQUE algorithm. +* ``perfect``: The rating is always 5. +* ``random``: The rating is random. +""", + ), + ] = _TargetRaterChoice.BRISQUE @TARGET_MANAGER_FLASK_APP.route( diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index 79b8252a5..45f2a7d43 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -8,13 +8,18 @@ import time from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import assert_never +from typing import Annotated, assert_never import requests from beartype import beartype from flask import Flask, Response, request +from pydantic import Field from pydantic_settings import BaseSettings +from mock_vws._flask_server import ( + RESPONSE_DELAY_SECONDS_DESCRIPTION, + TARGET_MANAGER_BASE_URL_DESCRIPTION, +) from mock_vws._query_tools import ( get_query_match_response_text, ) @@ -55,12 +60,31 @@ def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: class VWQSettings(BaseSettings): """Settings for the VWQ Flask app.""" + # The host interface which the server binds to. + # The images set this, so it is not documented as configuration. vwq_host: str = "" - target_manager_base_url: str - query_image_matcher: _ImageMatcherChoice = ( - _ImageMatcherChoice.STRUCTURAL_SIMILARITY - ) - response_delay_seconds: float = 0.0 + target_manager_base_url: Annotated[ + str, + Field(description=TARGET_MANAGER_BASE_URL_DESCRIPTION), + ] + query_image_matcher: Annotated[ + _ImageMatcherChoice, + Field( + description="""\ +The matcher to use for the query endpoint. + +Options include: + +* ``exact``: The images must be exactly the same to match. +* ``structural_similarity``: The images must have a similar structural + similarity to match. +""", + ), + ] = _ImageMatcherChoice.STRUCTURAL_SIMILARITY + response_delay_seconds: Annotated[ + float, + Field(description=RESPONSE_DELAY_SECONDS_DESCRIPTION), + ] = 0.0 @beartype diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index f0548de2d..d0904a967 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -12,11 +12,12 @@ import uuid from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import assert_never +from typing import Annotated, assert_never import requests from beartype import beartype from flask import Flask, Response, request +from pydantic import Field from pydantic_settings import BaseSettings from werkzeug.exceptions import MethodNotAllowed, NotFound @@ -28,6 +29,10 @@ TargetStatuses, ) from mock_vws._database_matchers import get_database_matching_server_keys +from mock_vws._flask_server import ( + RESPONSE_DELAY_SECONDS_DESCRIPTION, + TARGET_MANAGER_BASE_URL_DESCRIPTION, +) from mock_vws._flask_server.target_manager import TARGET_MANAGER from mock_vws._mock_common import RequestData, json_dump, sorted_targets from mock_vws._model_target_web_api import ( @@ -95,16 +100,45 @@ def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: class VWSSettings(BaseSettings): """Settings for the VWS Flask app.""" - target_manager_base_url: str - processing_time_seconds: float = 2.0 + target_manager_base_url: Annotated[ + str, + Field(description=TARGET_MANAGER_BASE_URL_DESCRIPTION), + ] + processing_time_seconds: Annotated[ + float, + Field(description="The number of seconds to process each image for."), + ] = 2.0 + # The host interface which the server binds to. + # The images set this, so it is not documented as configuration. vws_host: str = "" - # The base URL which clients use to reach this application. - # Generated reco counts reports are served from this URL. - vws_base_url: str = "https://vws.vuforia.com" - duplicates_image_matcher: _ImageMatcherChoice = ( - _ImageMatcherChoice.STRUCTURAL_SIMILARITY - ) - response_delay_seconds: float = 0.0 + vws_base_url: Annotated[ + str, + Field( + description="""\ +The base URL which clients use to reach the VWS container. + +The download URL of a reco counts report is built from this URL. +""", + ), + ] = "https://vws.vuforia.com" + duplicates_image_matcher: Annotated[ + _ImageMatcherChoice, + Field( + description="""\ +The matcher to use for the duplicates endpoint. + +Options include: + +* ``exact``: The images must be exactly the same to be duplicates. +* ``structural_similarity``: The images must have a similar structural + similarity to be duplicates. +""", + ), + ] = _ImageMatcherChoice.STRUCTURAL_SIMILARITY + response_delay_seconds: Annotated[ + float, + Field(description=RESPONSE_DELAY_SECONDS_DESCRIPTION), + ] = 0.0 @beartype From c8076057450bba4f46b976a7f64f17642f0dce48 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 12 Aug 2026 11:46:33 +0100 Subject: [PATCH 4/4] Revert "Generate the Docker configuration documentation from the settings" This reverts commit b041b2906090ecd07ac4c9d8707b13b1660cbdab. Generating the reference from the settings classes lost the grouping by container, which is the question a reader of this page has: not "what reads TARGET_RATER" but "what do I set on the target manager container". A "Read by ..." line at the end of each entry carries the same information but only to someone who reads every entry. The documentation is written by hand, grouped by container as before, with the corrected variable names and values. --- docs/source/conf.py | 26 -- docs/source/docker.rst | 76 ++++- docs/source/settings_envvars.py | 289 ------------------- pyproject.toml | 3 - spelling_private_dict.txt | 1 - src/mock_vws/_flask_server/__init__.py | 14 - src/mock_vws/_flask_server/target_manager.py | 20 +- src/mock_vws/_flask_server/vwq.py | 36 +-- src/mock_vws/_flask_server/vws.py | 54 +--- 9 files changed, 89 insertions(+), 430 deletions(-) delete mode 100644 docs/source/settings_envvars.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 5b50f4e64..7ffa7efca 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,15 +2,11 @@ """Configuration for Sphinx.""" import importlib.metadata -import sys from pathlib import Path from packaging.specifiers import SpecifierSet from sphinx_pyproject import SphinxConfig -# Make the local ``settings_envvars`` extension importable. -sys.path.insert(0, str(object=Path(__file__).parent)) - _pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml" _pyproject_config = SphinxConfig( pyproject_file=_pyproject_file, @@ -31,28 +27,6 @@ "sphinxcontrib.towncrier.ext", "sphinxcontrib.autohttp.flask", "sphinx_toolbox.more_autodoc.autoprotocol", - # A local extension, in this directory, which documents pydantic - # settings as environment variables. - # It knows nothing about this project, so that it can move to a - # package of its own. - "settings_envvars", -] - -# The Docker configuration documentation is generated from these. -pydantic_envvars_settings = { - "the target manager container": ( - "mock_vws._flask_server.target_manager:TargetManagerSettings" - ), - "the VWS container": "mock_vws._flask_server.vws:VWSSettings", - "the Query container": "mock_vws._flask_server.vwq:VWQSettings", -} - -# The images set these, so they are not configuration for users of the -# images. -pydantic_envvars_undocumented = [ - "target_manager_host", - "vws_host", - "vwq_host", ] # Render the unreleased ``newsfragments/`` entries into diff --git a/docs/source/docker.rst b/docs/source/docker.rst index bf8956b20..ab6ffd720 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -21,7 +21,6 @@ Creating containers ^^^^^^^^^^^^^^^^^^^ .. code-block:: console - :substitutions: $ docker network create -d bridge vws-bridge-network $ docker run \ @@ -33,13 +32,13 @@ Creating containers $ docker run \ --detach \ --publish 5006:5000 \ - -e "|env-target-manager-base-url|=http://vuforia-target-manager-mock:5000" \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vws-mock $ docker run \ --detach \ --publish 5007:5000 \ - -e "|env-target-manager-base-url|=http://vuforia-target-manager-mock:5000" \ + -e TARGET_MANAGER_BASE_URL=http://vuforia-target-manager-mock:5000 \ --network vws-bridge-network \ ghcr.io/vws-python/vuforia-vwq-mock @@ -103,12 +102,79 @@ Configuration options Required configuration ^^^^^^^^^^^^^^^^^^^^^^ -.. pydantic-envvars:: required +.. envvar:: TARGET_MANAGER_BASE_URL + + This is required by the VWS mock and the VWQ mock containers. + This is the base URL of the target manager container as seen from the other containers. + It must include a scheme, for example ``http://vuforia-target-manager-mock:5000``. Optional configuration ^^^^^^^^^^^^^^^^^^^^^^ -.. pydantic-envvars:: optional +VWS and Query containers +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. envvar:: RESPONSE_DELAY_SECONDS + + The number of seconds to wait before sending each response. + + Default: ``0.0`` + +Target manager container +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. envvar:: TARGET_RATER + + The rater to use for target tracking ratings. + + Options include: + + * ``brisque``: The rating is derived using the BRISQUE algorithm. + * ``perfect``: The rating is always 5. + * ``random``: The rating is random. + + Default: ``brisque`` + +Query container +~~~~~~~~~~~~~~~ + +.. envvar:: QUERY_IMAGE_MATCHER + + The matcher to use for the query endpoint. + + Options include: + + * ``exact``: The images must be exactly the same to match. + * ``structural_similarity``: The images must have a similar structural similarity to match. + + Default: ``structural_similarity`` + +VWS container +~~~~~~~~~~~~~ + +.. envvar:: PROCESSING_TIME_SECONDS + + The number of seconds to process each image for. + + Default: ``2.0`` + +.. envvar:: VWS_BASE_URL + + The base URL which clients use to reach the VWS container. + The download URL of a reco counts report is built from this URL. + + Default: ``https://vws.vuforia.com`` + +.. envvar:: DUPLICATES_IMAGE_MATCHER + + The matcher to use for the duplicates endpoint. + + Options include: + + * ``exact``: The images must be exactly the same to be duplicates. + * ``structural_similarity``: The images must have a similar structural similarity to be duplicates. + + Default: ``structural_similarity`` Building images from source ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/settings_envvars.py b/docs/source/settings_envvars.py deleted file mode 100644 index b6e688e0e..000000000 --- a/docs/source/settings_envvars.py +++ /dev/null @@ -1,289 +0,0 @@ -"""A Sphinx extension which documents pydantic settings as environment -variables. - -``pydantic-settings`` derives an environment variable from each field of -a settings class. Generating the documentation from those classes means -that the two cannot drift, in the same way that ``autoflask`` generates -the endpoint documentation from the applications themselves. - -This provides: - -* A ``pydantic-envvars`` directive which documents either the required - or the optional environment variables as ``envvar`` entries. -* A ``|env-|`` substitution per environment variable, so - that example commands use generated names too. - -Configure it with ``pydantic_envvars_settings``, which maps a -description of what reads a settings class to that class as -``module:name``, and ``pydantic_envvars_undocumented``, which lists -fields to leave undocumented. - -This knows nothing about any particular application, so that it can move -to a package of its own. -""" - -import importlib -import re -import textwrap -from enum import StrEnum - -from docutils import nodes -from pydantic.fields import FieldInfo -from pydantic_settings import BaseSettings -from sphinx.application import Sphinx -from sphinx.config import Config -from sphinx.errors import ExtensionError -from sphinx.util.docutils import SphinxDirective -from sphinx.util.typing import ExtensionMetadata - -# The prefix of the substitution which each environment variable name is -# available under. -_SUBSTITUTION_PREFIX = "env-" -_SUBSTITUTION_PATTERN = re.compile( - pattern=rf"\|{_SUBSTITUTION_PREFIX}[a-z0-9-]+\|", -) - -_REQUIRED = "required" -_OPTIONAL = "optional" - - -class _EnvironmentVariable: - """An environment variable which one or more settings classes read.""" - - def __init__( - self, - *, - field_name: str, - field: FieldInfo, - description: str, - ) -> None: - """ - Args: - field_name: The name of the settings field. - field: The settings field. - description: The description of the setting. - """ - self.name = field_name.upper() - self.field = field - self.description = description - self.read_by: list[str] = [] - - @property - def is_required(self) -> bool: - """Whether the setting has no default.""" - return self.field.is_required() - - @property - def default(self) -> str: - """The default value, as it is shown in the documentation.""" - default = self.field.default - if isinstance(default, StrEnum): - return default.value - return str(object=default) - - @property - def read_by_sentence(self) -> str: - """A sentence naming what reads this environment variable.""" - *first, last = self.read_by - names = f"{', '.join(first)} and {last}" if first else last - return f"Read by {names}." - - @property - def substitution(self) -> str: - """The substitution which this variable's name is available - under. - """ - name = self.name.lower().replace("_", "-") - return f"|{_SUBSTITUTION_PREFIX}{name}|" - - -def _settings_class(*, target: str) -> type[BaseSettings]: - """Return the settings class which the given ``module:name`` target - names. - - Raises: - ExtensionError: The target does not name a settings class. - """ - module_name, class_name = target.split(sep=":", maxsplit=1) - module = importlib.import_module(name=module_name) - settings_class = vars(module).get(class_name) - if not isinstance(settings_class, type) or not issubclass( - settings_class, - BaseSettings, - ): - msg = ( - f"'{target}' in pydantic_envvars_settings does not name a " - "pydantic-settings class." - ) - raise ExtensionError(message=msg) - return settings_class - - -def _environment_variables(*, config: Config) -> list[_EnvironmentVariable]: - """Return every environment variable to document. - - Raises: - ExtensionError: A setting has no description and is not listed in - ``pydantic_envvars_undocumented``, or two settings classes - describe one environment variable differently. - """ - settings_targets: dict[str, str] = config.pydantic_envvars_settings - undocumented: list[str] = config.pydantic_envvars_undocumented - - variables: dict[str, _EnvironmentVariable] = {} - for read_by, target in settings_targets.items(): - settings_class = _settings_class(target=target) - fields: dict[str, FieldInfo] = dict(settings_class.model_fields) - for field_name, field in fields.items(): - if field_name in undocumented: - continue - if field.description is None: - msg = ( - f"{settings_class.__name__}.{field_name} has no " - "description, so it cannot be documented. Give it a " - "``Field(description=...)``, or add it to " - "``pydantic_envvars_undocumented``." - ) - raise ExtensionError(message=msg) - variable = variables.setdefault( - field_name, - _EnvironmentVariable( - field_name=field_name, - field=field, - description=field.description, - ), - ) - if variable.description != field.description: - msg = ( - f"{field_name.upper()} is described differently by " - f"{settings_class.__name__} and another settings class. " - "Share one description between them." - ) - raise ExtensionError(message=msg) - variable.read_by.append(read_by) - return list(variables.values()) - - -class _PydanticEnvVarsDirective(SphinxDirective): - """Document environment variables which pydantic settings classes read.""" - - required_arguments = 1 - - def run(self) -> list[nodes.Node]: - """Return the documentation for the matching environment variables. - - Returns: - Nodes documenting either the required or the optional - environment variables. - - Raises: - ExtensionError: The directive's argument is neither - ``required`` nor ``optional``. - """ - (requirement,) = self.arguments - if requirement not in {_REQUIRED, _OPTIONAL}: - msg = ( - f"{self.get_location()}: the pydantic-envvars directive " - f"takes '{_REQUIRED}' or '{_OPTIONAL}', not '{requirement}'." - ) - raise ExtensionError(message=msg) - - blocks: list[str] = [] - for variable in _environment_variables(config=self.config): - if variable.is_required != (requirement == _REQUIRED): - continue - body = f"{variable.description}\n{variable.read_by_sentence}\n" - if not variable.is_required: - body += f"\nDefault: ``{variable.default}``\n" - blocks.append( - f".. envvar:: {variable.name}\n\n" - + textwrap.indent(text=body, prefix=" "), - ) - - return self.parse_text_to_nodes("\n".join(blocks)) - - -def _add_environment_variable_substitutions( - _app: Sphinx, - config: Config, -) -> None: - """Define a substitution for each environment variable name. - - ``|env-target-manager-base-url|`` in the documentation becomes - ``TARGET_MANAGER_BASE_URL``, so example commands cannot name a - variable which no settings class reads. - """ - substitutions = "\n".join( - f".. {variable.substitution} replace:: {variable.name}" - for variable in _environment_variables(config=config) - ) - config.rst_prolog = f"{config.rst_prolog or ''}\n{substitutions}\n" - - -def _check_environment_variable_substitutions( - app: Sphinx, - docname: str, - source: list[str], -) -> None: - """Reject a reference to an environment variable which does not exist. - - ``sphinx-substitution-extensions`` leaves an undefined substitution - in a code block as it is rather than reporting it, so a renamed - setting would otherwise reach the rendered page as literal - ``|env-...|`` text. - - Raises: - ExtensionError: The document uses a ``|env-...|`` substitution - which no settings class defines. - """ - known = { - variable.substitution - for variable in _environment_variables(config=app.config) - } - used = set(_SUBSTITUTION_PATTERN.findall(string="\n".join(source))) - unknown = sorted(used - known) - if unknown: - msg = ( - f"{docname}: {', '.join(unknown)} " - "names an environment variable which nothing reads." - ) - raise ExtensionError(message=msg) - - -def setup(app: Sphinx) -> ExtensionMetadata: - """Register the configuration values, the directive and the - substitutions. - - Args: - app: The Sphinx application. - - Returns: - Metadata for Sphinx. - """ - app.add_config_value( - name="pydantic_envvars_settings", - default={}, - rebuild="env", - types=frozenset({dict}), - description=( - "A map of a description of what reads a pydantic settings " - "class to that class, as ``module:name``." - ), - ) - app.add_config_value( - name="pydantic_envvars_undocumented", - default=[], - rebuild="env", - types=frozenset({list}), - description="Settings fields to leave undocumented.", - ) - app.add_directive(name="pydantic-envvars", cls=_PydanticEnvVarsDirective) - app.connect( - event="config-inited", - callback=_add_environment_variable_substitutions, - ) - app.connect( - event="source-read", - callback=_check_environment_variable_substitutions, - ) - return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/pyproject.toml b/pyproject.toml index de92759ef..3eab24d55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ dependencies = [ "numpy>=2.4.4", "opencv-contrib-python-headless>=5.0.0.93", "pillow>=12.2.0", - "pydantic>=2.10.2", "pydantic-settings>=2.6.1", "pyteenybrisque>=0.1.1", "requests>=2.32.3", @@ -397,8 +396,6 @@ ignore_names = [ "pytest_plugins", "pytest_set_filtered_exceptions", "REQUEST_QUOTA_REACHED", - # docutils directives - "required_arguments", "rst_prolog", "source_suffix", "spelling_word_list_filename", diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index 09d2a1a6c..ef4380bc2 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -81,7 +81,6 @@ png pragma presigned processable -pydantic pyrefly pyright pytest diff --git a/src/mock_vws/_flask_server/__init__.py b/src/mock_vws/_flask_server/__init__.py index baefe525c..81533727f 100644 --- a/src/mock_vws/_flask_server/__init__.py +++ b/src/mock_vws/_flask_server/__init__.py @@ -1,15 +1 @@ """Flask server for the mock Vuforia web service.""" - -# The Docker documentation is generated from the settings classes, so -# each setting is described where it is defined. Settings which more -# than one application reads are described here, once. -TARGET_MANAGER_BASE_URL_DESCRIPTION = """\ -The base URL of the target manager container, as seen from this container. - -This must include a scheme, for example -``http://vuforia-target-manager-mock:5000``. -""" - -RESPONSE_DELAY_SECONDS_DESCRIPTION = """\ -The number of seconds to wait before sending each response. -""" diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 006f5ae6d..651a8a83b 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -6,12 +6,11 @@ import json from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import Annotated, assert_never +from typing import assert_never from zoneinfo import ZoneInfo from beartype import beartype from flask import Flask, Response, request -from pydantic import Field from pydantic_settings import BaseSettings from mock_vws.database import CloudDatabase, VuMarkDatabase @@ -59,23 +58,8 @@ def to_target_rater( class TargetManagerSettings(BaseSettings): """Settings for the Target Manager Flask app.""" - # The host interface which the server binds to. - # The images set this, so it is not documented as configuration. target_manager_host: str = "" - target_rater: Annotated[ - _TargetRaterChoice, - Field( - description="""\ -The rater to use for target tracking ratings. - -Options include: - -* ``brisque``: The rating is derived using the BRISQUE algorithm. -* ``perfect``: The rating is always 5. -* ``random``: The rating is random. -""", - ), - ] = _TargetRaterChoice.BRISQUE + target_rater: _TargetRaterChoice = _TargetRaterChoice.BRISQUE @TARGET_MANAGER_FLASK_APP.route( diff --git a/src/mock_vws/_flask_server/vwq.py b/src/mock_vws/_flask_server/vwq.py index 45f2a7d43..79b8252a5 100644 --- a/src/mock_vws/_flask_server/vwq.py +++ b/src/mock_vws/_flask_server/vwq.py @@ -8,18 +8,13 @@ import time from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import Annotated, assert_never +from typing import assert_never import requests from beartype import beartype from flask import Flask, Response, request -from pydantic import Field from pydantic_settings import BaseSettings -from mock_vws._flask_server import ( - RESPONSE_DELAY_SECONDS_DESCRIPTION, - TARGET_MANAGER_BASE_URL_DESCRIPTION, -) from mock_vws._query_tools import ( get_query_match_response_text, ) @@ -60,31 +55,12 @@ def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: class VWQSettings(BaseSettings): """Settings for the VWQ Flask app.""" - # The host interface which the server binds to. - # The images set this, so it is not documented as configuration. vwq_host: str = "" - target_manager_base_url: Annotated[ - str, - Field(description=TARGET_MANAGER_BASE_URL_DESCRIPTION), - ] - query_image_matcher: Annotated[ - _ImageMatcherChoice, - Field( - description="""\ -The matcher to use for the query endpoint. - -Options include: - -* ``exact``: The images must be exactly the same to match. -* ``structural_similarity``: The images must have a similar structural - similarity to match. -""", - ), - ] = _ImageMatcherChoice.STRUCTURAL_SIMILARITY - response_delay_seconds: Annotated[ - float, - Field(description=RESPONSE_DELAY_SECONDS_DESCRIPTION), - ] = 0.0 + target_manager_base_url: str + query_image_matcher: _ImageMatcherChoice = ( + _ImageMatcherChoice.STRUCTURAL_SIMILARITY + ) + response_delay_seconds: float = 0.0 @beartype diff --git a/src/mock_vws/_flask_server/vws.py b/src/mock_vws/_flask_server/vws.py index 86d3a8367..d77969368 100644 --- a/src/mock_vws/_flask_server/vws.py +++ b/src/mock_vws/_flask_server/vws.py @@ -12,12 +12,11 @@ import uuid from enum import StrEnum, auto from http import HTTPMethod, HTTPStatus -from typing import Annotated, assert_never +from typing import assert_never import requests from beartype import beartype from flask import Flask, Response, request -from pydantic import Field from pydantic_settings import BaseSettings from werkzeug.exceptions import MethodNotAllowed, NotFound @@ -29,10 +28,6 @@ TargetStatuses, ) from mock_vws._database_matchers import get_database_matching_server_keys -from mock_vws._flask_server import ( - RESPONSE_DELAY_SECONDS_DESCRIPTION, - TARGET_MANAGER_BASE_URL_DESCRIPTION, -) from mock_vws._flask_server.target_manager import TARGET_MANAGER from mock_vws._mock_common import RequestData, json_dump, sorted_targets from mock_vws._model_target_web_api import ( @@ -100,45 +95,16 @@ def to_image_matcher(self: _ImageMatcherChoice) -> ImageMatcher: class VWSSettings(BaseSettings): """Settings for the VWS Flask app.""" - target_manager_base_url: Annotated[ - str, - Field(description=TARGET_MANAGER_BASE_URL_DESCRIPTION), - ] - processing_time_seconds: Annotated[ - float, - Field(description="The number of seconds to process each image for."), - ] = 2.0 - # The host interface which the server binds to. - # The images set this, so it is not documented as configuration. + target_manager_base_url: str + processing_time_seconds: float = 2.0 vws_host: str = "" - vws_base_url: Annotated[ - str, - Field( - description="""\ -The base URL which clients use to reach the VWS container. - -The download URL of a reco counts report is built from this URL. -""", - ), - ] = "https://vws.vuforia.com" - duplicates_image_matcher: Annotated[ - _ImageMatcherChoice, - Field( - description="""\ -The matcher to use for the duplicates endpoint. - -Options include: - -* ``exact``: The images must be exactly the same to be duplicates. -* ``structural_similarity``: The images must have a similar structural - similarity to be duplicates. -""", - ), - ] = _ImageMatcherChoice.STRUCTURAL_SIMILARITY - response_delay_seconds: Annotated[ - float, - Field(description=RESPONSE_DELAY_SECONDS_DESCRIPTION), - ] = 0.0 + # The base URL which clients use to reach this application. + # Generated reco counts reports are served from this URL. + vws_base_url: str = "https://vws.vuforia.com" + duplicates_image_matcher: _ImageMatcherChoice = ( + _ImageMatcherChoice.STRUCTURAL_SIMILARITY + ) + response_delay_seconds: float = 0.0 @beartype