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,121 @@
1
+ """Annotate Pydantic models for use alongside ``UploadFile``.
2
+
3
+ Provides :func:`file_upload_body` which builds a three-layer ``Annotated``
4
+ type that preserves model fields in OpenAPI, attaches examples for the
5
+ Swagger UI plugin, and aliases the form field with the model name.
6
+
7
+ Also provides :func:`unwrap_json_schema` which fixes Pydantic's
8
+ ``Json[Model]`` wrapping so Swagger UI renders the model's fields instead
9
+ of a plain string input.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ from pydantic import BaseModel
15
+ from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue
16
+ from pydantic_core import CoreSchema
17
+
18
+
19
+ def unwrap_json_schema(example: dict | None = None):
20
+ """Pydantic JSON schema override that unwraps ``contentSchema``.
21
+
22
+ When a field is typed as ``Json[Model]``, Pydantic wraps the schema
23
+ in a ``contentSchema`` envelope. Swagger UI doesn't understand that
24
+ wrapper and renders a plain string input. This override inlines the
25
+ inner schema so Swagger renders the model's fields directly.
26
+ """
27
+
28
+ class _NotAsJson:
29
+ @classmethod
30
+ def __get_pydantic_json_schema__(
31
+ cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
32
+ ) -> JsonSchemaValue:
33
+ json_schema = handler(core_schema)
34
+ return {
35
+ **(json_schema.get("contentSchema", json_schema)),
36
+ **({"example": example} if example is not None else {}),
37
+ }
38
+
39
+ return _NotAsJson
40
+
41
+
42
+ def file_upload_body(
43
+ model: type[BaseModel],
44
+ examples: dict,
45
+ default_key: str | None = None,
46
+ name: str | None = None,
47
+ ):
48
+ """Annotate a Pydantic model for use alongside ``UploadFile``.
49
+
50
+ When a FastAPI endpoint accepts both a JSON body **and** a file
51
+ upload, the request must be sent as ``multipart/form-data``.
52
+ FastAPI requires the JSON portion to be wrapped in ``Json[...]``
53
+ and sent as a form field, but that breaks Swagger UI's schema
54
+ rendering and example dropdown. This factory builds the correct
55
+ three-layer ``Annotated`` type that:
56
+
57
+ 1. Preserves the model's fields in the OpenAPI schema
58
+ (via :func:`unwrap_json_schema`)
59
+ 2. Attaches ``openapi_examples`` so the file upload plugin can
60
+ render them in Swagger UI
61
+ 3. Aliases the form field with the model name (camelCase)
62
+
63
+ Parameters
64
+ ----------
65
+ model:
66
+ The Pydantic model class for the JSON body.
67
+ examples:
68
+ A dict of ``{key: Example(...)}`` for the Swagger UI dropdown.
69
+ default_key:
70
+ Key in *examples* whose ``value`` is shown as the inline
71
+ schema example before the user opens the dropdown. ``None``
72
+ omits the inline example.
73
+ name:
74
+ Schema name for the generated body wrapper in OpenAPI's
75
+ ``components/schemas``. If ``None`` (default), derived from
76
+ the model class name (e.g. ``DocumentRequest`` →
77
+ ``DocumentRequestBody``). The schema patcher renames the
78
+ auto-generated ``Body_*`` key to this value.
79
+
80
+ Returns
81
+ -------
82
+ type
83
+ An ``Annotated`` type suitable for use as an endpoint parameter.
84
+
85
+ Example
86
+ -------
87
+ ::
88
+
89
+ from fastapi.openapi.models import Example
90
+
91
+ my_examples = {
92
+ "basic": Example(
93
+ summary="Basic request",
94
+ value={"name": "Acme"},
95
+ ),
96
+ }
97
+ MyRequest = file_upload_body(MyModel, my_examples, "basic")
98
+
99
+ @router.post("/upload")
100
+ async def upload(
101
+ request: MyRequest,
102
+ file: Optional[UploadFile] = None,
103
+ ):
104
+ ...
105
+ """
106
+ from fastapi import Body, Form
107
+ from pydantic import Json
108
+
109
+ schema_name = name or f"{model.__name__}Body"
110
+ field_alias = model.__name__[0].lower() + model.__name__[1:]
111
+ default_value = dict(examples[default_key]).get("value") if default_key else None
112
+ body = Annotated[model, Body(openapi_examples=examples)] # type: ignore[valid-type]
113
+ return Annotated[ # type: ignore[return-value]
114
+ Json[body], # type: ignore[valid-type,misc]
115
+ unwrap_json_schema(default_value),
116
+ Form(
117
+ alias=field_alias,
118
+ openapi_examples=examples,
119
+ json_schema_extra={"x-schema-name": schema_name},
120
+ ),
121
+ ]
@@ -0,0 +1,165 @@
1
+ """Auto-detect and inject multipart/form-data examples into OpenAPI schemas.
2
+
3
+ This module provides the schema patcher for the file upload examples plugin.
4
+ It auto-discovers routes whose endpoint parameters use
5
+ ``Form(openapi_examples=...)`` and injects the examples at the media-type
6
+ level where Swagger UI can render them via the companion JS plugin.
7
+ """
8
+
9
+ import json
10
+ import typing
11
+
12
+ from fastapi import FastAPI
13
+
14
+ from .._base import SchemaContext
15
+
16
+
17
+ def _attr(obj: object, name: str, default: object = None) -> object:
18
+ """Read *name* from an object (attr) or dict (key).
19
+
20
+ FastAPI's ``Example(...)`` returns a plain dict, not an object, so
21
+ we need to handle both access patterns.
22
+ """
23
+ try:
24
+ return getattr(obj, name)
25
+ except AttributeError:
26
+ return obj.get(name, default) if isinstance(obj, dict) else default
27
+
28
+
29
+ def _discover(app: FastAPI) -> dict[str, dict]:
30
+ """Find routes whose endpoint params have ``Form(openapi_examples=...)``.
31
+
32
+ Returns a dict mapping route paths to dicts with ``examples`` and
33
+ an optional ``schema_name`` derived from the ``x-schema-name``
34
+ marker set by :func:`file_upload_body`.
35
+ """
36
+ from fastapi.params import Body, Form
37
+ from fastapi.routing import APIRoute
38
+
39
+ result: dict[str, dict] = {}
40
+ for route in app.routes:
41
+ if not isinstance(route, APIRoute):
42
+ continue
43
+ examples: dict = {}
44
+ schema_name: str | None = None
45
+ try:
46
+ hints = typing.get_type_hints(route.endpoint, include_extras=True)
47
+ except Exception:
48
+ continue
49
+ for hint in hints.values():
50
+ if not hasattr(hint, "__metadata__"):
51
+ continue
52
+ for meta in hint.__metadata__:
53
+ if isinstance(meta, (Form, Body)):
54
+ ex = getattr(meta, "openapi_examples", None)
55
+ if ex:
56
+ examples.update(ex)
57
+ extra = getattr(meta, "json_schema_extra", None)
58
+ if isinstance(extra, dict) and "x-schema-name" in extra:
59
+ schema_name = extra["x-schema-name"]
60
+ if examples:
61
+ result[route.path] = {
62
+ "examples": examples,
63
+ "schema_name": schema_name,
64
+ }
65
+ return result
66
+
67
+
68
+ def _inject(schema: dict, path_data: dict[str, dict], prefix: str = "") -> None:
69
+ """Mutate *schema* in-place, adding examples and renaming schemas.
70
+
71
+ Supports POST, PUT, and PATCH methods. Route paths discovered from
72
+ the sub-app are un-prefixed (e.g. ``/documents``), but the OpenAPI
73
+ schema may already have the normalised prefix applied
74
+ (e.g. ``/api/documents``). We check both variants.
75
+ """
76
+ schema_paths = schema.get("paths", {})
77
+ renames: dict[str, str] = {} # old_name → new_name
78
+ for route_path, data in path_data.items():
79
+ examples = data["examples"]
80
+ schema_name = data.get("schema_name")
81
+ # The sub-app route is un-prefixed; the schema may be prefixed.
82
+ if route_path in schema_paths:
83
+ schema_path = route_path
84
+ elif prefix and f"{prefix}{route_path}" in schema_paths:
85
+ schema_path = f"{prefix}{route_path}"
86
+ else:
87
+ continue
88
+ for method in ("post", "put", "patch"):
89
+ content = (
90
+ schema_paths[schema_path].get(method, {}).get("requestBody", {}).get("content", {})
91
+ )
92
+ mp = content.get("multipart/form-data")
93
+ if mp is None:
94
+ continue
95
+ mp["examples"] = {
96
+ key: {
97
+ "summary": _attr(ex, "summary", key),
98
+ "description": _attr(ex, "description", ""),
99
+ "value": json.dumps(_attr(ex, "value"), indent=2, sort_keys=True),
100
+ }
101
+ for key, ex in examples.items()
102
+ }
103
+ # Collect rename: resolve the $ref to find the old schema key
104
+ if schema_name:
105
+ ref = mp.get("schema", {}).get("$ref", "")
106
+ if ref.startswith("#/components/schemas/"):
107
+ old_name = ref.split("/")[-1]
108
+ if old_name != schema_name:
109
+ renames[old_name] = schema_name
110
+ # Apply renames: update schema keys and all $ref pointers
111
+ if renames:
112
+ _rename_schemas(schema, renames)
113
+
114
+
115
+ def _rename_schemas(schema: dict, renames: dict[str, str]) -> None:
116
+ """Rename schema keys in ``components/schemas`` and update all ``$ref``s.
117
+
118
+ When multiple Body wrappers map to the same target name (e.g. two
119
+ routes sharing the same ``file_upload_body`` type), a numeric suffix
120
+ is appended to avoid collisions (``DocumentRequestBody2``, etc.).
121
+ """
122
+ schemas = schema.get("components", {}).get("schemas", {})
123
+ used: set[str] = set()
124
+ for old_name, desired_name in renames.items():
125
+ if old_name not in schemas:
126
+ continue
127
+ new_name = desired_name
128
+ if new_name in schemas or new_name in used:
129
+ counter = 2
130
+ while f"{desired_name}{counter}" in schemas or f"{desired_name}{counter}" in used:
131
+ counter += 1
132
+ new_name = f"{desired_name}{counter}"
133
+ used.add(new_name)
134
+ schemas[new_name] = schemas.pop(old_name)
135
+ schemas[new_name]["title"] = new_name
136
+ _update_refs(
137
+ schema,
138
+ f"#/components/schemas/{old_name}",
139
+ f"#/components/schemas/{new_name}",
140
+ )
141
+
142
+
143
+ def _update_refs(obj: object, old_ref: str, new_ref: str) -> None:
144
+ """Recursively replace ``$ref`` values throughout the schema."""
145
+ if isinstance(obj, dict):
146
+ if obj.get("$ref") == old_ref:
147
+ obj["$ref"] = new_ref
148
+ for v in obj.values():
149
+ _update_refs(v, old_ref, new_ref)
150
+ elif isinstance(obj, list):
151
+ for item in obj:
152
+ _update_refs(item, old_ref, new_ref)
153
+
154
+
155
+ def patch_file_upload_examples(schema: dict, ctx: SchemaContext) -> dict:
156
+ """Schema patcher: discover file-upload routes and inject examples.
157
+
158
+ This is the transformer function passed as ``schema_patcher`` in the
159
+ plugin's :meth:`contribute` return value. It follows the
160
+ ``(schema, ctx) -> schema`` transformer pattern.
161
+ """
162
+ path_data = _discover(ctx.app)
163
+ if path_data:
164
+ _inject(schema, path_data, prefix=ctx.prefix)
165
+ return schema
@@ -0,0 +1,181 @@
1
+ /* ── File Upload Examples Plugin CSS ─────────────────────────────── */
2
+
3
+ /* Dropdown uses native .examples-select (margin-bottom: .75em built in) */
4
+
5
+ /* Stable wrapper for post-click dropdown + description */
6
+ .swagger-ui .multipart-postclick-controls {
7
+ padding: 0;
8
+ }
9
+
10
+ /* Post-click tabs (Example Value | Schema) inside the controls block */
11
+ .swagger-ui .multipart-postclick-controls > .tab {
12
+ margin-top: .75em;
13
+ padding-bottom: 1em;
14
+ }
15
+
16
+ /* Extra gap in the preview (before "Try it out") where tab bar is tighter */
17
+ .swagger-ui .multipart-body-preview .examples-select {
18
+ margin-bottom: .75em;
19
+ }
20
+
21
+ /* The preview tab bar needs its own margins (native .tab gets them from context) */
22
+ .swagger-ui .multipart-body-preview > .tab {
23
+ margin-top: .75em;
24
+ padding-bottom: 1em;
25
+ }
26
+
27
+ /* Preview body container — inherits padding from .opblock-description-wrapper */
28
+ .swagger-ui .multipart-body-preview {
29
+ padding: 0;
30
+ }
31
+
32
+ /* File upload placeholder in the preview (before "Try it out") */
33
+ .swagger-ui .multipart-preview-file {
34
+ margin-top: 1.5em;
35
+ }
36
+
37
+ /* Required tag on pre-click file row — matches Swagger UI native styling */
38
+ .swagger-ui .multipart-preview-file .multipart-required-tag {
39
+ color: var(--dark-text-warn);
40
+ font-size: 10px;
41
+ font-weight: 700;
42
+ padding: 5px;
43
+ position: relative;
44
+ top: -6px;
45
+ }
46
+ .swagger-ui .multipart-preview-file .multipart-required-star {
47
+ color: var(--dark-text-warn);
48
+ }
49
+
50
+ /* Example Description — uses native .example__section (margin-top: 1.5em built in) */
51
+ .swagger-ui .multipart-preview-description p {
52
+ color: #3b4151;
53
+ font-size: 14px;
54
+ font-family: sans-serif;
55
+ margin: .25em 0 0;
56
+ }
57
+ body.dark .swagger-ui .multipart-preview-description p {
58
+ color: #e4e6e6;
59
+ }
60
+
61
+
62
+ /* Strip doubled top padding from the table container */
63
+ .swagger-ui .multipart-form-table {
64
+ padding-top: 0 !important;
65
+ }
66
+
67
+ /* Post-click wrapper: flex column so we can reorder description before file row */
68
+ .swagger-ui .multipart-examples-wrapper {
69
+ display: flex;
70
+ flex-direction: column;
71
+ }
72
+
73
+ /* Order the flattened flex children:
74
+ controls (0) → form/schema (1) → description (2) → file row (3) → execute (4) */
75
+ .swagger-ui .multipart-postclick-controls { order: 0; }
76
+ .swagger-ui .multipart-examples-wrapper .opblock-section-header { order: 1; }
77
+ .swagger-ui .multipart-form-table tr { order: 1; }
78
+ .swagger-ui .multipart-examples-wrapper .multipart-schema-pane { order: 1; width: 100%; }
79
+ .swagger-ui .multipart-postclick-description { order: 2; padding: 0; }
80
+ .swagger-ui .multipart-form-table tr:has(input[type="file"]) { order: 3; }
81
+ .swagger-ui .multipart-examples-wrapper .execute-wrapper { order: 4; }
82
+
83
+ /* Strip inner table padding — the .table-container wrapper provides it */
84
+ .swagger-ui .multipart-form-table table {
85
+ display: block;
86
+ width: 100%;
87
+ padding: 0;
88
+ }
89
+
90
+ .swagger-ui .multipart-form-table tbody,
91
+ .swagger-ui .multipart-form-table tr {
92
+ display: block;
93
+ width: 100%;
94
+ }
95
+
96
+ /* File row: lay out name + picker horizontally, push to end via order */
97
+ .swagger-ui .multipart-form-table tr:has(input[type="file"]) {
98
+ order: 3;
99
+ display: flex;
100
+ align-items: flex-start;
101
+ gap: 20px;
102
+ margin-top: 18px;
103
+ }
104
+
105
+ /* Use display:contents on intermediate wrappers so flex order reaches the rows */
106
+ .swagger-ui .multipart-examples-wrapper > .opblock-body,
107
+ .swagger-ui .multipart-examples-wrapper .opblock-section-request-body,
108
+ .swagger-ui .multipart-examples-wrapper .opblock-description-wrapper,
109
+ .swagger-ui .multipart-examples-wrapper .multipart-form-table,
110
+ .swagger-ui .multipart-examples-wrapper .multipart-form-table table,
111
+ .swagger-ui .multipart-examples-wrapper .multipart-form-table tbody {
112
+ display: contents;
113
+ }
114
+
115
+ /* Hide the native "required" badge on the section header — the plugin
116
+ shows required at the field level instead. */
117
+ .swagger-ui .opblock-body:has(.multipart-examples-wrapper) .opblock-section-header label {
118
+ display: none;
119
+ }
120
+ .swagger-ui .opblock-body:has(.multipart-body-preview) .opblock-section-header label {
121
+ display: none;
122
+ }
123
+
124
+ /* Hide the column headers (Name | Description) */
125
+ .swagger-ui .multipart-form-table thead {
126
+ display: none;
127
+ }
128
+
129
+ /* Hide all name cells by default */
130
+ .swagger-ui .multipart-form-table .parameters-col_name {
131
+ display: none;
132
+ }
133
+
134
+ /* Make description cells fill the full width */
135
+ .swagger-ui .multipart-form-table .parameters-col_description {
136
+ display: block;
137
+ width: 100% !important;
138
+ max-width: none;
139
+ margin-bottom: 0;
140
+ }
141
+
142
+ .swagger-ui .multipart-form-table tr:has(input[type="file"]) > .parameters-col_name {
143
+ display: block;
144
+ width: auto !important;
145
+ flex-shrink: 0;
146
+ padding-bottom: 0;
147
+ }
148
+
149
+ /* ── Dark mode overrides ─────────────────────────────────────────── */
150
+
151
+ body.dark .swagger-ui .examples-select__section-label,
152
+ body.dark .swagger-ui .example__section-header,
153
+ body.dark .swagger-ui .example__section {
154
+ color: var(--dark-text-color) !important;
155
+ }
156
+
157
+ body.dark .swagger-ui .multipart-body-preview .highlight-code pre {
158
+ background-color: var(--dark-bg-color-2);
159
+ color: var(--dark-text-color);
160
+ }
161
+
162
+ body.dark .swagger-ui .multipart-body-preview .parameter__name {
163
+ color: var(--dark-text-color);
164
+ }
165
+
166
+ body.dark .swagger-ui .multipart-body-preview .parameter__type {
167
+ color: var(--dark-text-color-2);
168
+ }
169
+
170
+ body.dark .swagger-ui .multipart-examples-wrapper .multipart-form-table .parameters-col_description textarea {
171
+ background-color: var(--dark-bg-color-2);
172
+ color: var(--dark-text-color);
173
+ }
174
+
175
+ /* Per-verb dark mode blocks for the preview pane */
176
+ body.dark .swagger-ui .opblock.opblock-post .multipart-body-preview,
177
+ body.dark .swagger-ui .opblock.opblock-put .multipart-body-preview,
178
+ body.dark .swagger-ui .opblock.opblock-patch .multipart-body-preview {
179
+ background-color: inherit;
180
+ color: var(--dark-text-color);
181
+ }