csrd-versioning 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. csrd/versioning/__init__.py +99 -0
  2. csrd/versioning/_constants.py +28 -0
  3. csrd/versioning/_core.py +240 -0
  4. csrd/versioning/_dependencies.py +67 -0
  5. csrd/versioning/_dependency_wiring.py +272 -0
  6. csrd/versioning/_dispatch.py +203 -0
  7. csrd/versioning/_docs.py +495 -0
  8. csrd/versioning/_fastapi_types.py +44 -0
  9. csrd/versioning/_helpers.py +149 -0
  10. csrd/versioning/_orchestration.py +399 -0
  11. csrd/versioning/_redoc.py +98 -0
  12. csrd/versioning/_settings.py +88 -0
  13. csrd/versioning/_swagger_ui_version.py +12 -0
  14. csrd/versioning/_types.py +14 -0
  15. csrd/versioning/actuator/README.md +261 -0
  16. csrd/versioning/actuator/__init__.py +3 -0
  17. csrd/versioning/actuator/actuator.py +111 -0
  18. csrd/versioning/actuator/plugins/__init__.py +65 -0
  19. csrd/versioning/actuator/plugins/base.py +77 -0
  20. csrd/versioning/actuator/plugins/env/__init__.py +31 -0
  21. csrd/versioning/actuator/plugins/env/plugin.py +145 -0
  22. csrd/versioning/actuator/plugins/env/providers.py +105 -0
  23. csrd/versioning/actuator/plugins/env/registry.py +211 -0
  24. csrd/versioning/actuator/plugins/health/__init__.py +37 -0
  25. csrd/versioning/actuator/plugins/health/auto.py +244 -0
  26. csrd/versioning/actuator/plugins/health/indicators.py +137 -0
  27. csrd/versioning/actuator/plugins/health/plugin.py +271 -0
  28. csrd/versioning/actuator/plugins/info.py +120 -0
  29. csrd/versioning/actuator/tools/README.md +54 -0
  30. csrd/versioning/actuator/tools/__init__.py +0 -0
  31. csrd/versioning/actuator/tools/generate_service_info_from_git.py +126 -0
  32. csrd/versioning/exception_handlers.py +126 -0
  33. csrd/versioning/py.typed +0 -0
  34. csrd/versioning/swagger_plugins/__init__.py +15 -0
  35. csrd/versioning/swagger_plugins/_base.py +153 -0
  36. csrd/versioning/swagger_plugins/file_upload/__init__.py +35 -0
  37. csrd/versioning/swagger_plugins/file_upload/_body_factory.py +121 -0
  38. csrd/versioning/swagger_plugins/file_upload/_schema_patcher.py +165 -0
  39. csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.css +181 -0
  40. csrd/versioning/swagger_plugins/file_upload/file_upload_plugin.js +529 -0
  41. csrd/versioning/templates/__init__.py +0 -0
  42. csrd/versioning/templates/favicon.png +0 -0
  43. csrd/versioning/templates/swagger_ui.css +616 -0
  44. csrd/versioning/templates/swagger_ui.html +43 -0
  45. csrd/versioning/templates/swagger_ui.js +131 -0
  46. csrd_versioning-0.1.0.dist-info/METADATA +15 -0
  47. csrd_versioning-0.1.0.dist-info/RECORD +48 -0
  48. csrd_versioning-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,120 @@
1
+ """Default actuator plugin that serves build-time service metadata."""
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter, FastAPI
10
+
11
+ from .base import ActuatorLink, BaseActuatorPlugin
12
+
13
+ _DEFAULT_INFO_PATH = "service_info.json"
14
+ _INFO_SECTIONS = ("git", "build")
15
+
16
+ # Nested key-order schema — mirrors the JSON shape so preferred insertion
17
+ # order is readable at a glance. ``None`` leaves mean "no child ordering".
18
+ _KEY_ORDER: dict[str, dict | None] = {
19
+ "git": {
20
+ "branch": None,
21
+ "commit": {
22
+ "time": None,
23
+ "message": {"full": None, "short": None},
24
+ "id": {"describe": None, "abbrev": None, "full": None},
25
+ "user": {"email": None, "name": None},
26
+ },
27
+ "build": {
28
+ "version": None,
29
+ "user": {"name": None, "email": None},
30
+ "host": None,
31
+ },
32
+ "dirty": None,
33
+ "tags": None,
34
+ "total": {"commit": {"count": None}},
35
+ "closest": {"tag": {"commit": {"count": None}, "name": None}},
36
+ "remote": {"origin": {"url": None}},
37
+ },
38
+ "build": {
39
+ "artifact": None,
40
+ "name": None,
41
+ "time": None,
42
+ "version": None,
43
+ "group": None,
44
+ },
45
+ }
46
+
47
+
48
+ def _reorder(value: Any, order: dict | None = None) -> Any:
49
+ """Recursively reorder dict keys according to *order*, preserving extras."""
50
+ if isinstance(value, dict):
51
+ result: dict[str, Any] = {}
52
+ if order:
53
+ for key in order:
54
+ if key in value:
55
+ result[key] = _reorder(value[key], order[key])
56
+ for key, child in value.items():
57
+ if key not in result:
58
+ result[key] = _reorder(child)
59
+ return result
60
+
61
+ if isinstance(value, list):
62
+ return [_reorder(item) for item in value]
63
+
64
+ return value
65
+
66
+
67
+ def _build_info_payload(loaded: dict[str, Any]) -> dict[str, Any]:
68
+ payload: dict[str, Any] = {}
69
+
70
+ for section in _INFO_SECTIONS:
71
+ section_value = loaded.get(section)
72
+ if isinstance(section_value, dict):
73
+ payload[section] = section_value
74
+
75
+ return dict(_reorder(payload, _KEY_ORDER))
76
+
77
+
78
+ class InfoActuatorPlugin(BaseActuatorPlugin):
79
+ """Expose Spring-style ``/actuator/info`` from a baked JSON file."""
80
+
81
+ name = "info"
82
+
83
+ def register(
84
+ self,
85
+ router: APIRouter,
86
+ *,
87
+ app: FastAPI,
88
+ prefix: str,
89
+ ) -> Mapping[str, ActuatorLink]:
90
+ path = self._resolve_info_path()
91
+
92
+ if not path.exists():
93
+ return {}
94
+
95
+ @router.get("/info", include_in_schema=False)
96
+ async def info() -> dict[str, Any]:
97
+ return self._load_info(path)
98
+
99
+ return {"info": self.build_link(prefix=prefix, route_path="/info")}
100
+
101
+ def _resolve_info_path(self) -> Path:
102
+ path_value = os.getenv("ACTUATOR_INFO_PATH")
103
+ return Path(path_value or _DEFAULT_INFO_PATH)
104
+
105
+ def _load_info(self, path: Path) -> dict[str, Any]:
106
+ def _load() -> dict[str, Any]:
107
+ if not path.exists():
108
+ return {}
109
+
110
+ try:
111
+ loaded = json.loads(path.read_text(encoding="utf-8"))
112
+ except (json.JSONDecodeError, OSError):
113
+ return {}
114
+
115
+ if isinstance(loaded, dict):
116
+ return _build_info_payload(loaded)
117
+
118
+ return {}
119
+
120
+ return self.memoized_payload(cache_key=str(path), loader=_load)
@@ -0,0 +1,54 @@
1
+ # Actuator Tools
2
+
3
+ This folder contains build-time tools that generate metadata consumed by
4
+ runtime actuator plugins.
5
+
6
+ Current tool:
7
+
8
+ - `generate_service_info_from_git.py`
9
+ - Source: git repository metadata
10
+ - Output: `ACTUATOR_INFO_OUTPUT_PATH` (defaults to `service_info.json`)
11
+ - Runtime consumer: `InfoActuatorPlugin` (`/actuator/info`)
12
+
13
+ This script is Docker-build-only. It exits unless
14
+ `ACTUATOR_INFO_DOCKER_BUILD=1` is set.
15
+
16
+ Use this script during Docker build stages where `.git` is available, then copy
17
+ `service_info.json` into the runtime image.
18
+
19
+ ## Docker Build Pattern
20
+
21
+ Expected usage is a multi-stage Docker build that generates `service_info.json`
22
+ while `.git` is still available, then copies that file into the runtime image.
23
+
24
+ The CI pipeline injects `BUILD_VERSION` as a Docker build-arg. Pass it through
25
+ to the script via `--build-version` so the release version is recorded in
26
+ `build.version` and `git.build.version`. The argument is optional — when
27
+ omitted the script falls back to `git describe --tags --always`.
28
+
29
+ Example (drop-in for a consuming repository):
30
+
31
+ ```dockerfile
32
+ FROM python:3.13-slim AS service-info
33
+ ARG BUILD_VERSION=""
34
+ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
35
+ WORKDIR /app
36
+
37
+ # Install runtime dependencies for the service, including api_versioning.
38
+ # Use the install flow your repository already uses.
39
+ COPY pyproject.toml poetry.lock* ./
40
+ RUN pip install --no-cache-dir poetry \
41
+ && poetry config virtualenvs.create false \
42
+ && poetry install --only main --no-interaction --no-ansi
43
+
44
+ COPY .git ./.git
45
+ RUN ACTUATOR_INFO_DOCKER_BUILD=1 \
46
+ python -m api_versioning.fastapi.versioning.actuator.tools.generate_service_info_from_git \
47
+ --build-version="${BUILD_VERSION}"
48
+
49
+ FROM base AS runtime
50
+ COPY --from=service-info /app/service_info.json ./
51
+ ```
52
+
53
+ If you place the file somewhere else at runtime, set `ACTUATOR_INFO_PATH`
54
+ so `InfoActuatorPlugin` can resolve it.
File without changes
@@ -0,0 +1,126 @@
1
+ """Generate service metadata from git for the actuator ``/actuator/info`` endpoint.
2
+
3
+ Run this script at build time to write a JSON file consumed by
4
+ ``InfoActuatorPlugin``.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import subprocess
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ _DEFAULT_OUTPUT_PATH = "service_info.json"
16
+ _DOCKER_BUILD_ENV = "ACTUATOR_INFO_DOCKER_BUILD"
17
+ _DOCKER_BUILD_ENV_VALUE = "1"
18
+
19
+
20
+ def _git(*cmd: str, default: str = "") -> str:
21
+ try:
22
+ return subprocess.check_output(["git", *cmd], stderr=subprocess.DEVNULL, text=True).strip()
23
+ except (subprocess.CalledProcessError, FileNotFoundError):
24
+ return default
25
+
26
+
27
+ def _iso_utc(value: str) -> str:
28
+ if not value:
29
+ return ""
30
+ try:
31
+ parsed = datetime.fromisoformat(value)
32
+ except ValueError:
33
+ return ""
34
+ return parsed.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
35
+
36
+
37
+ def _resolve_output_path() -> Path:
38
+ return Path(os.getenv("ACTUATOR_INFO_OUTPUT_PATH", _DEFAULT_OUTPUT_PATH))
39
+
40
+
41
+ def _normalize_build_version(build_version: str) -> str:
42
+ return build_version.strip()
43
+
44
+
45
+ def _assert_docker_build_context() -> None:
46
+ if os.getenv(_DOCKER_BUILD_ENV) != _DOCKER_BUILD_ENV_VALUE:
47
+ raise SystemExit(
48
+ "generate_service_info_from_git.py is build-only and must run from Docker "
49
+ f"with {_DOCKER_BUILD_ENV}={_DOCKER_BUILD_ENV_VALUE}."
50
+ )
51
+
52
+
53
+ def _build_info(build_version: str = "") -> dict[str, Any]:
54
+ normalized_build_version = _normalize_build_version(build_version)
55
+ commit_time = _iso_utc(_git("log", "-1", "--format=%cI"))
56
+ commit_full = _git("rev-parse", "HEAD")
57
+ commit_abbrev = _git("rev-parse", "--short", "HEAD")
58
+ remote_url = _git("remote", "get-url", "origin")
59
+ service_name = remote_url.rsplit("/", 1)[-1].removesuffix(".git") if remote_url else ""
60
+
61
+ resolved_version = normalized_build_version or _git("describe", "--tags", "--always")
62
+
63
+ git_info: dict[str, Any] = {
64
+ "branch": _git("rev-parse", "--abbrev-ref", "HEAD"),
65
+ "commit": {
66
+ "time": commit_time,
67
+ "message": {
68
+ "full": _git("log", "-1", "--format=%B"),
69
+ "short": _git("log", "-1", "--format=%s"),
70
+ },
71
+ "id": {
72
+ "abbrev": commit_abbrev,
73
+ "full": commit_full,
74
+ "describe": _git("describe", "--tags"),
75
+ },
76
+ "user": {
77
+ "name": _git("log", "-1", "--format=%an"),
78
+ "email": _git("log", "-1", "--format=%ae"),
79
+ },
80
+ },
81
+ "dirty": str(bool(_git("status", "--porcelain"))).lower(),
82
+ "remote": {"origin": {"url": remote_url}},
83
+ }
84
+
85
+ if normalized_build_version:
86
+ git_info["build"] = {"version": normalized_build_version}
87
+
88
+ return {
89
+ "git": git_info,
90
+ "build": {
91
+ "artifact": service_name,
92
+ "name": service_name,
93
+ "group": "com.example",
94
+ "version": resolved_version,
95
+ "time": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
96
+ },
97
+ }
98
+
99
+
100
+ def main() -> None:
101
+ parser = argparse.ArgumentParser(
102
+ description="Generate service metadata from git for InfoActuatorPlugin."
103
+ )
104
+ parser.add_argument(
105
+ "--build-version",
106
+ default="",
107
+ help=(
108
+ "Explicit release version injected by the CI pipeline "
109
+ '(e.g. --build-version="${BUILD_VERSION}"). '
110
+ "Populates build.version and git.build.version. "
111
+ "Falls back to `git describe --tags --always` when omitted."
112
+ ),
113
+ )
114
+ args = parser.parse_args()
115
+ _assert_docker_build_context()
116
+
117
+ info = _build_info(build_version=args.build_version)
118
+ output_path = _resolve_output_path()
119
+ output_path.parent.mkdir(parents=True, exist_ok=True)
120
+ output_path.write_text(json.dumps(info, indent=2), encoding="utf-8")
121
+ abbrev = info.get("git", {}).get("commit", {}).get("id", {}).get("abbrev", "")
122
+ print(f"Wrote {output_path} (commit {abbrev})")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,126 @@
1
+ import datetime
2
+ import re
3
+ import uuid
4
+ from datetime import UTC
5
+
6
+ from fastapi import HTTPException, Request, status
7
+ from fastapi.exceptions import RequestValidationError
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from csrd.context import get_api_version
11
+ from csrd.context.middleware._logging import REQUEST_SCOPE_KEY
12
+ from csrd.models.errors import APIErrorResponse, APIVersion, ErrorMeta
13
+ from csrd.versioning._constants import API_VERSION_HEADER_NAME
14
+
15
+
16
+ def _display_api_version(version: str | None) -> str | None:
17
+ if version is None:
18
+ return None
19
+ if str(version).lower() == "unv":
20
+ return "Unversioned"
21
+ return version
22
+
23
+
24
+ _VERSION_SEGMENT_RE = re.compile(
25
+ r"(unv|unversioned|latest|v\d+(?:[._-]\d+)*)(?:/|$)",
26
+ flags=re.IGNORECASE,
27
+ )
28
+
29
+
30
+ def _get_served_api_version(request: Request) -> str | None:
31
+ # 1. Scope key set by VersionDispatchMiddleware
32
+ scope = getattr(request, "scope", None)
33
+ if isinstance(scope, dict):
34
+ scoped_version = scope.get("api_version")
35
+ if isinstance(scoped_version, str):
36
+ return _display_api_version(scoped_version)
37
+
38
+ # 2. Contextvar set by set_api_version_context()
39
+ ctx_version = get_api_version()
40
+ if ctx_version is not None:
41
+ return _display_api_version(ctx_version)
42
+
43
+ # 3. Path params resolved by FastAPI routing
44
+ path_params = getattr(request, "path_params", None)
45
+ if isinstance(path_params, dict):
46
+ version = path_params.get("version")
47
+ if isinstance(version, str):
48
+ return _display_api_version(version)
49
+
50
+ # 4. Best-effort path parsing (prefix-agnostic)
51
+ path = getattr(getattr(request, "url", None), "path", "")
52
+ if isinstance(path, str):
53
+ for segment in path.strip("/").split("/"):
54
+ if _VERSION_SEGMENT_RE.fullmatch(segment):
55
+ return _display_api_version(segment)
56
+
57
+ return None
58
+
59
+
60
+ def _api_version_from_request(request: Request) -> APIVersion:
61
+ requested = None
62
+ headers = getattr(request, "headers", None)
63
+ if headers is not None:
64
+ requested_value = headers.get(API_VERSION_HEADER_NAME)
65
+ requested = requested_value if isinstance(requested_value, str) else None
66
+
67
+ served = _get_served_api_version(request)
68
+ return APIVersion(served=served, requested=requested)
69
+
70
+
71
+ def _get_request_id(request: Request):
72
+ try:
73
+ return request.scope[REQUEST_SCOPE_KEY]["hit_id"]
74
+ except KeyError:
75
+ return str(uuid.uuid4())
76
+
77
+
78
+ async def http_exception_handler(request: Request, exc: HTTPException):
79
+ return JSONResponse(
80
+ status_code=exc.status_code,
81
+ content=APIErrorResponse(
82
+ meta=ErrorMeta(
83
+ status=exc.status_code,
84
+ error=exc.detail or "Unknown Error",
85
+ method=request.method,
86
+ path=request.url.path,
87
+ request_id=_get_request_id(request),
88
+ timestamp=datetime.datetime.now(UTC),
89
+ api_version=_api_version_from_request(request),
90
+ ),
91
+ errors=[],
92
+ ).as_dict(by_alias=True, mode="json"),
93
+ )
94
+
95
+
96
+ async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
97
+ return JSONResponse(
98
+ status_code=status.HTTP_400_BAD_REQUEST,
99
+ content=APIErrorResponse(
100
+ meta=ErrorMeta(
101
+ status=status.HTTP_400_BAD_REQUEST,
102
+ error="Validation Error",
103
+ method=request.method,
104
+ path=request.url.path,
105
+ request_id=_get_request_id(request),
106
+ timestamp=datetime.datetime.now(UTC),
107
+ api_version=_api_version_from_request(request),
108
+ ),
109
+ errors=[],
110
+ ).as_dict(by_alias=True, mode="json"),
111
+ )
112
+
113
+
114
+ EXCEPTION_HANDLERS = {
115
+ # the HTTPException is being intercepted for 404s likely in the instana middleware
116
+ # force a 404 to be handled by the http_exception_handler
117
+ 404: http_exception_handler,
118
+ HTTPException: http_exception_handler,
119
+ RequestValidationError: request_validation_exception_handler,
120
+ }
121
+
122
+ __all__ = (
123
+ "EXCEPTION_HANDLERS",
124
+ "http_exception_handler",
125
+ "request_validation_exception_handler",
126
+ )
File without changes
@@ -0,0 +1,15 @@
1
+ """Swagger UI plugin system for extending the custom docs rendering pipeline."""
2
+
3
+ from ._base import (
4
+ SchemaContext,
5
+ SwaggerPlugin,
6
+ SwaggerPluginContribution,
7
+ apply_schema_patchers,
8
+ )
9
+
10
+ __all__ = [
11
+ "SchemaContext",
12
+ "SwaggerPlugin",
13
+ "SwaggerPluginContribution",
14
+ "apply_schema_patchers",
15
+ ]
@@ -0,0 +1,153 @@
1
+ """Swagger UI plugin protocol, contribution dataclass, and resolution logic."""
2
+
3
+ import logging
4
+ import re
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from fastapi import FastAPI
10
+
11
+ from csrd.versioning._types import VersionKey
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SchemaContext:
18
+ """Read-only metadata passed to schema patchers."""
19
+
20
+ app: FastAPI
21
+ """The versioned sub-app whose schema is being built."""
22
+
23
+ version_key: VersionKey
24
+ """The version key (e.g. ``Versions.V1``) being rendered."""
25
+
26
+ prefix: str
27
+ """Normalised API prefix, e.g. ``"/api"``."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SwaggerPluginContribution:
32
+ """Static data returned by :meth:`SwaggerPlugin.contribute`."""
33
+
34
+ extra_css: str | None = None
35
+ extra_js: str = ""
36
+ bundle_plugins: tuple[str, ...] = ()
37
+ schema_patcher: Callable[[dict, SchemaContext], dict] | None = None
38
+
39
+
40
+ @runtime_checkable
41
+ class SwaggerPlugin(Protocol):
42
+ """Protocol for Swagger UI plugins."""
43
+
44
+ name: str
45
+
46
+ def contribute(self) -> SwaggerPluginContribution: ...
47
+
48
+
49
+ def _extract_css_class_names(css: str) -> set[str]:
50
+ return set(re.findall(r"\.([\w-]+)", css))
51
+
52
+
53
+ def _warn_css_conflicts(
54
+ contributions: dict[str, SwaggerPluginContribution],
55
+ ) -> None:
56
+ plugin_classes: dict[str, set[str]] = {}
57
+ for name, contrib in contributions.items():
58
+ if contrib.extra_css:
59
+ plugin_classes[name] = _extract_css_class_names(contrib.extra_css)
60
+
61
+ names = list(plugin_classes.keys())
62
+ for i, name_a in enumerate(names):
63
+ for name_b in names[i + 1 :]:
64
+ overlap = plugin_classes[name_a] & plugin_classes[name_b]
65
+ if overlap:
66
+ logger.warning(
67
+ "SwaggerPlugins '%s' and '%s' both define CSS rules for: %s",
68
+ name_a,
69
+ name_b,
70
+ ", ".join(f".{cls}" for cls in sorted(overlap)),
71
+ )
72
+
73
+
74
+ def _resolve_swagger_plugins(
75
+ global_plugins: list[SwaggerPlugin] | None,
76
+ per_version_plugins: list[SwaggerPlugin] | None = None,
77
+ ) -> tuple[SwaggerPlugin, ...]:
78
+
79
+ if global_plugins is not None and len(global_plugins) == 0:
80
+ return ()
81
+
82
+ resolved: dict[str, SwaggerPlugin] = {p.name: p for p in _default_swagger_plugins()}
83
+ if global_plugins:
84
+ for p in global_plugins:
85
+ resolved[p.name] = p
86
+
87
+ if per_version_plugins is not None and len(per_version_plugins) == 0:
88
+ return ()
89
+ if per_version_plugins:
90
+ for p in per_version_plugins:
91
+ resolved[p.name] = p
92
+
93
+ return tuple(resolved.values())
94
+
95
+
96
+ def _default_swagger_plugins() -> list[SwaggerPlugin]:
97
+ from .file_upload import FileUploadExamplesPlugin
98
+
99
+ return [FileUploadExamplesPlugin()]
100
+
101
+
102
+ def _collect_contributions(
103
+ plugins: tuple[SwaggerPlugin, ...],
104
+ ) -> dict[str, SwaggerPluginContribution]:
105
+ contributions: dict[str, SwaggerPluginContribution] = {}
106
+ for plugin in plugins:
107
+ try:
108
+ contributions[plugin.name] = plugin.contribute()
109
+ except Exception:
110
+ logger.warning(
111
+ "SwaggerPlugin '%s' failed in contribute()",
112
+ plugin.name,
113
+ exc_info=True,
114
+ )
115
+ return contributions
116
+
117
+
118
+ def _aggregate_css(contributions: dict[str, SwaggerPluginContribution]) -> str:
119
+ parts = [c.extra_css for c in contributions.values() if c.extra_css]
120
+ return "\n".join(parts)
121
+
122
+
123
+ def _aggregate_js(contributions: dict[str, SwaggerPluginContribution]) -> str:
124
+ parts = [c.extra_js for c in contributions.values() if c.extra_js]
125
+ return "\n".join(parts)
126
+
127
+
128
+ def _aggregate_bundle_plugins(
129
+ contributions: dict[str, SwaggerPluginContribution],
130
+ ) -> str:
131
+ names: list[str] = []
132
+ for c in contributions.values():
133
+ names.extend(c.bundle_plugins)
134
+ return ", ".join(names)
135
+
136
+
137
+ def _collect_schema_patchers(
138
+ contributions: dict[str, SwaggerPluginContribution],
139
+ ) -> list[Callable[[dict, SchemaContext], dict]]:
140
+ return [c.schema_patcher for c in contributions.values() if c.schema_patcher]
141
+
142
+
143
+ def apply_schema_patchers(
144
+ schema: dict,
145
+ ctx: SchemaContext,
146
+ patchers: list[Callable[[dict, SchemaContext], dict]],
147
+ ) -> dict:
148
+ for patcher in patchers:
149
+ try:
150
+ schema = patcher(schema, ctx)
151
+ except Exception:
152
+ logger.warning("schema_patcher from plugin failed", exc_info=True)
153
+ return schema
@@ -0,0 +1,35 @@
1
+ """File Upload Examples — built-in Swagger UI plugin."""
2
+
3
+ from importlib.resources import files
4
+
5
+ from .._base import SwaggerPluginContribution
6
+ from ._body_factory import file_upload_body, unwrap_json_schema
7
+ from ._schema_patcher import patch_file_upload_examples
8
+
9
+ __all__ = [
10
+ "FileUploadExamplesPlugin",
11
+ "file_upload_body",
12
+ "unwrap_json_schema",
13
+ ]
14
+
15
+ _PACKAGE = "csrd.versioning.swagger_plugins.file_upload"
16
+
17
+
18
+ class FileUploadExamplesPlugin:
19
+ """Built-in plugin that adds file upload example dropdowns to Swagger UI."""
20
+
21
+ name = "file_upload_examples"
22
+
23
+ def __init__(self) -> None:
24
+ pkg = files(_PACKAGE)
25
+ self._js = pkg.joinpath("file_upload_plugin.js").read_text(encoding="utf-8")
26
+ css_path = pkg.joinpath("file_upload_plugin.css")
27
+ self._css = css_path.read_text(encoding="utf-8")
28
+
29
+ def contribute(self) -> SwaggerPluginContribution:
30
+ return SwaggerPluginContribution(
31
+ extra_css=self._css,
32
+ extra_js=self._js,
33
+ bundle_plugins=("FileUploadExamplesPlugin",),
34
+ schema_patcher=patch_file_upload_examples,
35
+ )