microbootstrap 1.5.5__tar.gz → 1.6.1__tar.gz
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.
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/PKG-INFO +67 -9
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/README.md +62 -4
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/__init__.py +20 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/fastapi.py +42 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/faststream.py +5 -1
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/litestar.py +131 -14
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/helpers.py +1 -1
- microbootstrap-1.6.1/microbootstrap/instruments/openapi_security_schemes.py +127 -0
- microbootstrap-1.6.1/microbootstrap/instruments/openapi_version_docs.py +86 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/opentelemetry_instrument.py +26 -10
- microbootstrap-1.6.1/microbootstrap/instruments/swagger_instrument.py +121 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/pyproject.toml +5 -5
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/pyproject.toml.orig +5 -5
- microbootstrap-1.5.5/microbootstrap/instruments/swagger_instrument.py +0 -30
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/__init__.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/base.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/bootstrappers/fastmcp.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/config/__init__.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/config/fastapi.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/config/fastmcp.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/config/faststream.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/config/litestar.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/console_writer.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/exceptions.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/granian_server.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/__init__.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/base.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/cors_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/health_checks_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/instrument_box.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/logging_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/prometheus_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/pyroscope_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/sentry_instrument.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments_setupper.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/middlewares/__init__.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/middlewares/fastapi.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/middlewares/fastmcp.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/middlewares/faststream.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/middlewares/litestar.py +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/py.typed +0 -0
- {microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: microbootstrap
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.1
|
|
4
4
|
Summary: Package for bootstrapping new micro-services
|
|
5
5
|
Keywords: python,microservice,bootstrap,opentelemetry,logging,error-tracing,litestar,fastapi,fastmcp,mcp
|
|
6
6
|
Author: community-of-python
|
|
@@ -43,16 +43,16 @@ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.54b1
|
|
|
43
43
|
Requires-Dist: opentelemetry-instrumentation-asyncio>=0.54b1
|
|
44
44
|
Requires-Dist: opentelemetry-instrumentation-asgi>=0.46b0
|
|
45
45
|
Requires-Dist: orjson>=3.10.18
|
|
46
|
-
Requires-Dist: fastapi>=0.
|
|
46
|
+
Requires-Dist: fastapi>=0.110.1 ; extra == 'fastapi'
|
|
47
47
|
Requires-Dist: fastapi-offline-docs>=1 ; extra == 'fastapi'
|
|
48
48
|
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.46b0 ; extra == 'fastapi'
|
|
49
|
-
Requires-Dist: prometheus-fastapi-instrumentator>=
|
|
49
|
+
Requires-Dist: prometheus-fastapi-instrumentator>=7.1 ; extra == 'fastapi'
|
|
50
50
|
Requires-Dist: fastmcp>=2,<4 ; extra == 'fastmcp'
|
|
51
51
|
Requires-Dist: prometheus-client>=0.20 ; extra == 'fastmcp'
|
|
52
|
-
Requires-Dist: faststream
|
|
52
|
+
Requires-Dist: faststream>=0.6.7,<0.8 ; extra == 'faststream'
|
|
53
53
|
Requires-Dist: prometheus-client>=0.20 ; extra == 'faststream'
|
|
54
54
|
Requires-Dist: granian[reload]>=1 ; extra == 'granian'
|
|
55
|
-
Requires-Dist: litestar>=2.
|
|
55
|
+
Requires-Dist: litestar>=2.21.1 ; extra == 'litestar'
|
|
56
56
|
Requires-Dist: litestar-offline-docs>=1 ; extra == 'litestar'
|
|
57
57
|
Requires-Dist: prometheus-client>=0.20 ; extra == 'litestar'
|
|
58
58
|
Requires-Python: >=3.10, <4
|
|
@@ -105,7 +105,7 @@ With <b>microbootstrap</b>, you receive an application with lightweight built-in
|
|
|
105
105
|
- `opentelemetry`
|
|
106
106
|
- `logging`
|
|
107
107
|
- `cors`
|
|
108
|
-
- `swagger` -
|
|
108
|
+
- `swagger` - offline UI assets, OpenAPI security definitions, and optional Accept-version documentation
|
|
109
109
|
- `health-checks`
|
|
110
110
|
|
|
111
111
|
Those instruments can be bootstrapped for:
|
|
@@ -505,7 +505,7 @@ class YourSettings(BaseServiceSettings):
|
|
|
505
505
|
opentelemetry_namespace: str | None = None
|
|
506
506
|
opentelemetry_insecure: bool = True
|
|
507
507
|
opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = []
|
|
508
|
-
opentelemetry_exclude_urls: list[str] = []
|
|
508
|
+
opentelemetry_exclude_urls: list[str] = ["/metrics"]
|
|
509
509
|
opentelemetry_baggage_span_attributes: dict[str, str] = {}
|
|
510
510
|
|
|
511
511
|
... # Other settings here
|
|
@@ -521,9 +521,9 @@ Parameters description:
|
|
|
521
521
|
- `opentelemetry_insecure` - is opentelemetry connection secure.
|
|
522
522
|
- `opentelemetry_container_name` - will be passed to the `Resource`.
|
|
523
523
|
- `opentelemetry_instrumentors` - a list of extra instrumentors.
|
|
524
|
-
- `opentelemetry_exclude_urls` - list of
|
|
524
|
+
- `opentelemetry_exclude_urls` - list of url regexes that produce no server spans (`["/metrics"]` by default). For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`).
|
|
525
525
|
- `opentelemetry_log_traces` - traces will be logged to stdout.
|
|
526
|
-
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`
|
|
526
|
+
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`; if `False`, `health_checks_path` is added to the excluded urls.
|
|
527
527
|
- `opentelemetry_baggage_span_attributes` - maps allowed baggage keys to attributes added to local server and consumer spans.
|
|
528
528
|
|
|
529
529
|
These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration.
|
|
@@ -659,6 +659,64 @@ Parameter descriptions:
|
|
|
659
659
|
- `swagger_offline_docs` - A boolean value that, when set to True, allows the Swagger JS bundles to be accessed offline. This is because the service starts to host via static.
|
|
660
660
|
- `swagger_extra_params` - Additional parameters to pass into the OpenAPI configuration.
|
|
661
661
|
|
|
662
|
+
#### OpenAPI security schemes
|
|
663
|
+
|
|
664
|
+
Security schemes are disabled by default. They add reusable OpenAPI definitions under
|
|
665
|
+
`components.securitySchemes`; they do not authenticate requests or add global or operation-level security requirements.
|
|
666
|
+
Keep requirements and authentication in your application routes and dependencies.
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
from microbootstrap import (
|
|
670
|
+
LitestarSettings,
|
|
671
|
+
OpenApiHttpSecurityScheme,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
class Settings(LitestarSettings):
|
|
676
|
+
security_schemes: dict[str, OpenApiHttpSecurityScheme] = {
|
|
677
|
+
"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"),
|
|
678
|
+
}
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported by `SwaggerConfig`. Annotating a consumer
|
|
682
|
+
setting with a concrete scheme class intentionally rejects other kinds for that consumer. Python field names and
|
|
683
|
+
OpenAPI aliases are accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and
|
|
684
|
+
`openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation
|
|
685
|
+
raises `ValueError`.
|
|
686
|
+
|
|
687
|
+
The definitions are added to the framework's normal schema. Repeated schema reads remain stable; after correcting
|
|
688
|
+
a configuration conflict, rebuild the application.
|
|
689
|
+
|
|
690
|
+
#### API-version documentation
|
|
691
|
+
|
|
692
|
+
Version documentation is disabled by default. It describes supported versions in OpenAPI without implementing
|
|
693
|
+
runtime version negotiation.
|
|
694
|
+
|
|
695
|
+
```python
|
|
696
|
+
from microbootstrap import LitestarSettings, OpenApiOperationVersionOverride, OpenApiVersionDocsConfig
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class Settings(LitestarSettings):
|
|
700
|
+
openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig(
|
|
701
|
+
vendor_media_type="application/vnd.example+json",
|
|
702
|
+
supported_versions=("1.0",),
|
|
703
|
+
operation_versions=(
|
|
704
|
+
OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)),
|
|
705
|
+
OpenApiOperationVersionOverride(path="/internal/widgets", method="get", supported_versions=()),
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Set `openapi_version_docs` to `None` to disable version documentation. A configured non-empty global
|
|
711
|
+
`supported_versions` list adds an `x-accept-versioning` extension and matching description text to each operation.
|
|
712
|
+
`operation_versions` replaces that list for one exact path and lower-case HTTP method; an explicit empty tuple skips
|
|
713
|
+
microbootstrap's additions for that operation without asserting that no service-owned version metadata exists. This does
|
|
714
|
+
not negotiate requests, add an `Accept` parameter, change response media types, or provide a Swagger UI version selector.
|
|
715
|
+
|
|
716
|
+
Repeated schema reads remain stable. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`;
|
|
717
|
+
correct the configuration and rebuild the application. Litestar supports its standard `Operation` type for version
|
|
718
|
+
documentation and rejects other custom operation subclasses.
|
|
719
|
+
|
|
662
720
|
#### FastStream AsyncAPI documentation
|
|
663
721
|
|
|
664
722
|
AsyncAPI documentation is available by default under `/asyncapi` route. You can change that by setting `asyncapi_path`:
|
|
@@ -40,7 +40,7 @@ With <b>microbootstrap</b>, you receive an application with lightweight built-in
|
|
|
40
40
|
- `opentelemetry`
|
|
41
41
|
- `logging`
|
|
42
42
|
- `cors`
|
|
43
|
-
- `swagger` -
|
|
43
|
+
- `swagger` - offline UI assets, OpenAPI security definitions, and optional Accept-version documentation
|
|
44
44
|
- `health-checks`
|
|
45
45
|
|
|
46
46
|
Those instruments can be bootstrapped for:
|
|
@@ -440,7 +440,7 @@ class YourSettings(BaseServiceSettings):
|
|
|
440
440
|
opentelemetry_namespace: str | None = None
|
|
441
441
|
opentelemetry_insecure: bool = True
|
|
442
442
|
opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = []
|
|
443
|
-
opentelemetry_exclude_urls: list[str] = []
|
|
443
|
+
opentelemetry_exclude_urls: list[str] = ["/metrics"]
|
|
444
444
|
opentelemetry_baggage_span_attributes: dict[str, str] = {}
|
|
445
445
|
|
|
446
446
|
... # Other settings here
|
|
@@ -456,9 +456,9 @@ Parameters description:
|
|
|
456
456
|
- `opentelemetry_insecure` - is opentelemetry connection secure.
|
|
457
457
|
- `opentelemetry_container_name` - will be passed to the `Resource`.
|
|
458
458
|
- `opentelemetry_instrumentors` - a list of extra instrumentors.
|
|
459
|
-
- `opentelemetry_exclude_urls` - list of
|
|
459
|
+
- `opentelemetry_exclude_urls` - list of url regexes that produce no server spans (`["/metrics"]` by default). For Litestar they are combined with `OTEL_PYTHON_LITESTAR_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS`).
|
|
460
460
|
- `opentelemetry_log_traces` - traces will be logged to stdout.
|
|
461
|
-
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`
|
|
461
|
+
- `opentelemetry_generate_health_check_spans` - generate spans for health check handlers if `True`; if `False`, `health_checks_path` is added to the excluded urls.
|
|
462
462
|
- `opentelemetry_baggage_span_attributes` - maps allowed baggage keys to attributes added to local server and consumer spans.
|
|
463
463
|
|
|
464
464
|
These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration.
|
|
@@ -594,6 +594,64 @@ Parameter descriptions:
|
|
|
594
594
|
- `swagger_offline_docs` - A boolean value that, when set to True, allows the Swagger JS bundles to be accessed offline. This is because the service starts to host via static.
|
|
595
595
|
- `swagger_extra_params` - Additional parameters to pass into the OpenAPI configuration.
|
|
596
596
|
|
|
597
|
+
#### OpenAPI security schemes
|
|
598
|
+
|
|
599
|
+
Security schemes are disabled by default. They add reusable OpenAPI definitions under
|
|
600
|
+
`components.securitySchemes`; they do not authenticate requests or add global or operation-level security requirements.
|
|
601
|
+
Keep requirements and authentication in your application routes and dependencies.
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
from microbootstrap import (
|
|
605
|
+
LitestarSettings,
|
|
606
|
+
OpenApiHttpSecurityScheme,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class Settings(LitestarSettings):
|
|
611
|
+
security_schemes: dict[str, OpenApiHttpSecurityScheme] = {
|
|
612
|
+
"serviceAuth": OpenApiHttpSecurityScheme(scheme="bearer", bearer_format="JWT"),
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
HTTP, API key, OAuth 2.0, and OpenID Connect definitions are supported by `SwaggerConfig`. Annotating a consumer
|
|
617
|
+
setting with a concrete scheme class intentionally rejects other kinds for that consumer. Python field names and
|
|
618
|
+
OpenAPI aliases are accepted; output uses canonical names such as `bearerFormat`, `in`, `tokenUrl`, and
|
|
619
|
+
`openIdConnectUrl`. A same-named definition must be identical to the service-owned definition or schema generation
|
|
620
|
+
raises `ValueError`.
|
|
621
|
+
|
|
622
|
+
The definitions are added to the framework's normal schema. Repeated schema reads remain stable; after correcting
|
|
623
|
+
a configuration conflict, rebuild the application.
|
|
624
|
+
|
|
625
|
+
#### API-version documentation
|
|
626
|
+
|
|
627
|
+
Version documentation is disabled by default. It describes supported versions in OpenAPI without implementing
|
|
628
|
+
runtime version negotiation.
|
|
629
|
+
|
|
630
|
+
```python
|
|
631
|
+
from microbootstrap import LitestarSettings, OpenApiOperationVersionOverride, OpenApiVersionDocsConfig
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class Settings(LitestarSettings):
|
|
635
|
+
openapi_version_docs: OpenApiVersionDocsConfig | None = OpenApiVersionDocsConfig(
|
|
636
|
+
vendor_media_type="application/vnd.example+json",
|
|
637
|
+
supported_versions=("1.0",),
|
|
638
|
+
operation_versions=(
|
|
639
|
+
OpenApiOperationVersionOverride(path="/widgets", method="post", supported_versions=("2.0",)),
|
|
640
|
+
OpenApiOperationVersionOverride(path="/internal/widgets", method="get", supported_versions=()),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Set `openapi_version_docs` to `None` to disable version documentation. A configured non-empty global
|
|
646
|
+
`supported_versions` list adds an `x-accept-versioning` extension and matching description text to each operation.
|
|
647
|
+
`operation_versions` replaces that list for one exact path and lower-case HTTP method; an explicit empty tuple skips
|
|
648
|
+
microbootstrap's additions for that operation without asserting that no service-owned version metadata exists. This does
|
|
649
|
+
not negotiate requests, add an `Accept` parameter, change response media types, or provide a Swagger UI version selector.
|
|
650
|
+
|
|
651
|
+
Repeated schema reads remain stable. A conflicting service-owned `x-accept-versioning` extension raises `ValueError`;
|
|
652
|
+
correct the configuration and rebuild the application. Litestar supports its standard `Operation` type for version
|
|
653
|
+
documentation and rejects other custom operation subclasses.
|
|
654
|
+
|
|
597
655
|
#### FastStream AsyncAPI documentation
|
|
598
656
|
|
|
599
657
|
AsyncAPI documentation is available by default under `/asyncapi` route. You can change that by setting `asyncapi_path`:
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
from microbootstrap.instruments.cors_instrument import CorsConfig
|
|
2
2
|
from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig
|
|
3
3
|
from microbootstrap.instruments.logging_instrument import LoggingConfig
|
|
4
|
+
from microbootstrap.instruments.openapi_security_schemes import (
|
|
5
|
+
OpenApiApiKeySecurityScheme,
|
|
6
|
+
OpenApiHttpSecurityScheme,
|
|
7
|
+
OpenApiOAuth2SecurityScheme,
|
|
8
|
+
OpenApiOAuthFlow,
|
|
9
|
+
OpenApiOAuthFlows,
|
|
10
|
+
OpenApiOpenIdConnectSecurityScheme,
|
|
11
|
+
)
|
|
12
|
+
from microbootstrap.instruments.openapi_version_docs import (
|
|
13
|
+
OpenApiOperationVersionOverride,
|
|
14
|
+
OpenApiVersionDocsConfig,
|
|
15
|
+
)
|
|
4
16
|
from microbootstrap.instruments.opentelemetry_instrument import (
|
|
5
17
|
FastStreamOpentelemetryConfig,
|
|
6
18
|
FastStreamTelemetryMiddlewareProtocol,
|
|
@@ -42,6 +54,14 @@ __all__ = (
|
|
|
42
54
|
"LitestarPrometheusConfig",
|
|
43
55
|
"LitestarSettings",
|
|
44
56
|
"LoggingConfig",
|
|
57
|
+
"OpenApiApiKeySecurityScheme",
|
|
58
|
+
"OpenApiHttpSecurityScheme",
|
|
59
|
+
"OpenApiOAuth2SecurityScheme",
|
|
60
|
+
"OpenApiOAuthFlow",
|
|
61
|
+
"OpenApiOAuthFlows",
|
|
62
|
+
"OpenApiOpenIdConnectSecurityScheme",
|
|
63
|
+
"OpenApiOperationVersionOverride",
|
|
64
|
+
"OpenApiVersionDocsConfig",
|
|
45
65
|
"OpentelemetryConfig",
|
|
46
66
|
"PyroscopeConfig",
|
|
47
67
|
"SentryConfig",
|
|
@@ -12,6 +12,8 @@ from microbootstrap.config.fastapi import FastApiConfig
|
|
|
12
12
|
from microbootstrap.instruments.cors_instrument import CorsInstrument
|
|
13
13
|
from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict
|
|
14
14
|
from microbootstrap.instruments.logging_instrument import LoggingInstrument
|
|
15
|
+
from microbootstrap.instruments.openapi_security_schemes import serialize_security_schemes
|
|
16
|
+
from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS
|
|
15
17
|
from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
|
|
16
18
|
from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument
|
|
17
19
|
from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
|
|
@@ -67,8 +69,48 @@ class FastApiSwaggerInstrument(SwaggerInstrument):
|
|
|
67
69
|
def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
|
|
68
70
|
if self.instrument_config.swagger_offline_docs:
|
|
69
71
|
enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path)
|
|
72
|
+
if self.instrument_config.openapi_version_docs is None and not self.instrument_config.security_schemes:
|
|
73
|
+
return application
|
|
74
|
+
|
|
75
|
+
original_openapi: typing.Final = application.openapi
|
|
76
|
+
|
|
77
|
+
def documented_openapi() -> dict[str, typing.Any]:
|
|
78
|
+
openapi_schema: typing.Final = original_openapi()
|
|
79
|
+
if self.instrument_config.security_schemes:
|
|
80
|
+
self._merge_security_schemes(openapi_schema)
|
|
81
|
+
if self.instrument_config.openapi_version_docs is not None:
|
|
82
|
+
self._document_operations(openapi_schema)
|
|
83
|
+
return openapi_schema
|
|
84
|
+
|
|
85
|
+
application.openapi = documented_openapi # type: ignore[method-assign] # FastAPI's public custom OpenAPI hook.
|
|
70
86
|
return application
|
|
71
87
|
|
|
88
|
+
def _merge_security_schemes(self, openapi_schema: dict[str, typing.Any]) -> None:
|
|
89
|
+
expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes)
|
|
90
|
+
components = openapi_schema.setdefault("components", {})
|
|
91
|
+
security_schemes = components.setdefault("securitySchemes", {})
|
|
92
|
+
self._validate_security_scheme_conflicts(security_schemes, expected_schemes)
|
|
93
|
+
security_schemes.update(
|
|
94
|
+
{name: scheme for name, scheme in expected_schemes.items() if name not in security_schemes}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _document_operations(self, openapi_schema: dict[str, typing.Any]) -> None:
|
|
98
|
+
for path, path_item in openapi_schema.get("paths", {}).items():
|
|
99
|
+
if path.startswith("x-"):
|
|
100
|
+
continue
|
|
101
|
+
for method, operation in path_item.items():
|
|
102
|
+
if method not in SUPPORTED_HTTP_METHODS:
|
|
103
|
+
continue
|
|
104
|
+
documentation = self._build_version_documentation(
|
|
105
|
+
path,
|
|
106
|
+
method,
|
|
107
|
+
operation.get("description"),
|
|
108
|
+
operation.get("x-accept-versioning"),
|
|
109
|
+
has_existing_extension="x-accept-versioning" in operation,
|
|
110
|
+
)
|
|
111
|
+
if documentation is not None:
|
|
112
|
+
operation["x-accept-versioning"], operation["description"] = documentation
|
|
113
|
+
|
|
72
114
|
|
|
73
115
|
@FastApiBootstrapper.use_instrument()
|
|
74
116
|
class FastApiCorsInstrument(CorsInstrument):
|
|
@@ -57,7 +57,11 @@ def _isolate_faststream_subscribers(application: AsgiFastStream) -> None:
|
|
|
57
57
|
class KwargsAsgiFastStream(AsgiFastStream):
|
|
58
58
|
def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401
|
|
59
59
|
# `broker` argument is positional-only
|
|
60
|
-
|
|
60
|
+
broker = kwargs.pop("broker", None)
|
|
61
|
+
if broker is None:
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
else:
|
|
64
|
+
super().__init__(broker, **kwargs)
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
class FastStreamBootstrapper(ApplicationBootstrapper[FastStreamSettings, AsgiFastStream, FastStreamConfig]):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
import dataclasses
|
|
2
3
|
import typing
|
|
3
4
|
|
|
4
5
|
import litestar
|
|
@@ -14,18 +15,27 @@ from litestar.openapi.plugins import SwaggerRenderPlugin
|
|
|
14
15
|
from litestar.types.asgi_types import ASGIApp, Scope
|
|
15
16
|
from litestar_offline_docs import generate_static_files_config
|
|
16
17
|
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
|
|
17
|
-
from opentelemetry.util.http import get_excluded_urls
|
|
18
|
+
from opentelemetry.util.http import ExcludeList, get_excluded_urls
|
|
18
19
|
from sentry_sdk.integrations.litestar import LitestarIntegration
|
|
19
20
|
|
|
20
21
|
from microbootstrap.bootstrappers.base import ApplicationBootstrapper
|
|
21
22
|
from microbootstrap.config.litestar import LitestarConfig
|
|
23
|
+
from microbootstrap.instruments import opentelemetry_instrument
|
|
22
24
|
from microbootstrap.instruments.cors_instrument import CorsInstrument
|
|
23
25
|
from microbootstrap.instruments.health_checks_instrument import (
|
|
24
26
|
HealthChecksInstrument,
|
|
25
27
|
HealthCheckTypedDict,
|
|
26
28
|
)
|
|
27
29
|
from microbootstrap.instruments.logging_instrument import LoggingInstrument
|
|
28
|
-
from microbootstrap.instruments.
|
|
30
|
+
from microbootstrap.instruments.openapi_security_schemes import (
|
|
31
|
+
OpenApiApiKeySecurityScheme,
|
|
32
|
+
OpenApiHttpSecurityScheme,
|
|
33
|
+
OpenApiOAuth2SecurityScheme,
|
|
34
|
+
OpenApiOpenIdConnectSecurityScheme,
|
|
35
|
+
_OpenApiSecurityScheme,
|
|
36
|
+
serialize_security_schemes,
|
|
37
|
+
)
|
|
38
|
+
from microbootstrap.instruments.openapi_version_docs import SUPPORTED_HTTP_METHODS
|
|
29
39
|
from microbootstrap.instruments.prometheus_instrument import (
|
|
30
40
|
LitestarPrometheusConfig,
|
|
31
41
|
PrometheusInstrument,
|
|
@@ -37,6 +47,17 @@ from microbootstrap.middlewares.litestar import build_litestar_logging_middlewar
|
|
|
37
47
|
from microbootstrap.settings import LitestarSettings
|
|
38
48
|
|
|
39
49
|
|
|
50
|
+
ApplicationT = typing.TypeVar("ApplicationT", bound=litestar.Litestar)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass
|
|
54
|
+
class AcceptVersionedOperation(openapi.spec.Operation):
|
|
55
|
+
accept_versioning: dict[str, str | list[str]] | None = dataclasses.field(
|
|
56
|
+
default=None,
|
|
57
|
+
metadata={"alias": "x-accept-versioning"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
40
61
|
if typing.TYPE_CHECKING:
|
|
41
62
|
from litestar.contrib.opentelemetry import OpenTelemetryConfig
|
|
42
63
|
from litestar.types import ASGIApp, Scope
|
|
@@ -102,6 +123,102 @@ class LitestarSwaggerInstrument(SwaggerInstrument):
|
|
|
102
123
|
]
|
|
103
124
|
return bootstrap_result
|
|
104
125
|
|
|
126
|
+
def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
|
|
127
|
+
if (self.instrument_config.openapi_version_docs is None and not self.instrument_config.security_schemes) or (
|
|
128
|
+
application.openapi_config is None
|
|
129
|
+
):
|
|
130
|
+
return application
|
|
131
|
+
if self.instrument_config.security_schemes:
|
|
132
|
+
self._merge_security_schemes(application.openapi_schema)
|
|
133
|
+
if self.instrument_config.openapi_version_docs is not None:
|
|
134
|
+
self._document_operations(application.openapi_schema)
|
|
135
|
+
return application
|
|
136
|
+
|
|
137
|
+
def _merge_security_schemes(self, openapi_schema: openapi.spec.OpenAPI) -> None:
|
|
138
|
+
expected_schemes: typing.Final = serialize_security_schemes(self.instrument_config.security_schemes)
|
|
139
|
+
security_schemes = openapi_schema.components.security_schemes
|
|
140
|
+
if security_schemes is not None:
|
|
141
|
+
canonical_schemes: typing.Final = {
|
|
142
|
+
name: scheme.to_schema() if isinstance(scheme, openapi.spec.SecurityScheme) else scheme
|
|
143
|
+
for name, scheme in security_schemes.items()
|
|
144
|
+
}
|
|
145
|
+
self._validate_security_scheme_conflicts(canonical_schemes, expected_schemes)
|
|
146
|
+
configured_schemes = {
|
|
147
|
+
scheme_name: self._build_litestar_security_scheme(security_scheme)
|
|
148
|
+
for scheme_name, security_scheme in self.instrument_config.security_schemes.items()
|
|
149
|
+
}
|
|
150
|
+
if security_schemes is None:
|
|
151
|
+
openapi_schema.components.security_schemes = typing.cast(
|
|
152
|
+
"dict[str, openapi.spec.SecurityScheme | openapi.spec.Reference]",
|
|
153
|
+
configured_schemes,
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
security_schemes.update(
|
|
157
|
+
{name: scheme for name, scheme in configured_schemes.items() if name not in security_schemes}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _document_operations(self, openapi_schema: openapi.spec.OpenAPI) -> None:
|
|
161
|
+
if openapi_schema.paths is None:
|
|
162
|
+
return
|
|
163
|
+
for path, path_item in openapi_schema.paths.items():
|
|
164
|
+
for method in SUPPORTED_HTTP_METHODS:
|
|
165
|
+
operation = getattr(path_item, method)
|
|
166
|
+
if operation is None:
|
|
167
|
+
continue
|
|
168
|
+
existing_extension = (
|
|
169
|
+
operation.accept_versioning if isinstance(operation, AcceptVersionedOperation) else None
|
|
170
|
+
)
|
|
171
|
+
documentation = self._build_version_documentation(
|
|
172
|
+
path,
|
|
173
|
+
method,
|
|
174
|
+
operation.description,
|
|
175
|
+
existing_extension,
|
|
176
|
+
has_existing_extension=existing_extension is not None,
|
|
177
|
+
)
|
|
178
|
+
if documentation is None:
|
|
179
|
+
continue
|
|
180
|
+
extension, description = documentation
|
|
181
|
+
if type(operation) is openapi.spec.Operation:
|
|
182
|
+
init_fields = {
|
|
183
|
+
field.name: getattr(operation, field.name)
|
|
184
|
+
for field in dataclasses.fields(openapi.spec.Operation)
|
|
185
|
+
if field.init
|
|
186
|
+
}
|
|
187
|
+
operation = AcceptVersionedOperation(**init_fields, accept_versioning=extension)
|
|
188
|
+
setattr(path_item, method, operation)
|
|
189
|
+
elif type(operation) is not AcceptVersionedOperation:
|
|
190
|
+
message = (
|
|
191
|
+
f"OpenAPI operation {type(operation).__name__} is not supported "
|
|
192
|
+
"for Accept version documentation."
|
|
193
|
+
)
|
|
194
|
+
raise TypeError(message)
|
|
195
|
+
operation.accept_versioning = extension
|
|
196
|
+
operation.description = description
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def _build_litestar_security_scheme(cls, security_scheme: _OpenApiSecurityScheme) -> openapi.spec.SecurityScheme:
|
|
200
|
+
if not isinstance(
|
|
201
|
+
security_scheme,
|
|
202
|
+
(
|
|
203
|
+
OpenApiHttpSecurityScheme,
|
|
204
|
+
OpenApiApiKeySecurityScheme,
|
|
205
|
+
OpenApiOAuth2SecurityScheme,
|
|
206
|
+
OpenApiOpenIdConnectSecurityScheme,
|
|
207
|
+
),
|
|
208
|
+
):
|
|
209
|
+
raise AssertionError("Unsupported OpenAPI security scheme.") # noqa: TRY004
|
|
210
|
+
scheme_data = security_scheme.model_dump(by_alias=False, exclude_none=True)
|
|
211
|
+
if "location" in scheme_data:
|
|
212
|
+
scheme_data["security_scheme_in"] = scheme_data.pop("location")
|
|
213
|
+
if "flows" in scheme_data:
|
|
214
|
+
flow_data = scheme_data["flows"]
|
|
215
|
+
if "resource_owner" in flow_data:
|
|
216
|
+
flow_data["password"] = flow_data.pop("resource_owner")
|
|
217
|
+
scheme_data["flows"] = openapi.spec.OAuthFlows(
|
|
218
|
+
**{flow_name: openapi.spec.OAuthFlow(**flow) for flow_name, flow in flow_data.items()}
|
|
219
|
+
)
|
|
220
|
+
return openapi.spec.SecurityScheme(**scheme_data)
|
|
221
|
+
|
|
105
222
|
|
|
106
223
|
@LitestarBootstrapper.use_instrument()
|
|
107
224
|
class LitestarCorsInstrument(CorsInstrument):
|
|
@@ -122,12 +239,6 @@ class LitestarCorsInstrument(CorsInstrument):
|
|
|
122
239
|
LitestarBootstrapper.use_instrument()(PyroscopeInstrument)
|
|
123
240
|
|
|
124
241
|
|
|
125
|
-
def build_span_name(method: str, route: str) -> str:
|
|
126
|
-
if not route:
|
|
127
|
-
return method
|
|
128
|
-
return f"{method} {route}"
|
|
129
|
-
|
|
130
|
-
|
|
131
242
|
def build_litestar_route_details_from_scope(
|
|
132
243
|
scope: Scope,
|
|
133
244
|
) -> tuple[str, dict[str, str]]:
|
|
@@ -144,18 +255,23 @@ def build_litestar_route_details_from_scope(
|
|
|
144
255
|
method: typing.Final = str(scope.get("method", "HTTP")).strip()
|
|
145
256
|
if path_template is not None:
|
|
146
257
|
path_template_stripped: typing.Final = path_template.strip()
|
|
147
|
-
|
|
258
|
+
span_name: typing.Final = opentelemetry_instrument.build_span_name(method, path_template_stripped)
|
|
259
|
+
return span_name, {"http.route": path_template_stripped}
|
|
148
260
|
|
|
149
261
|
path: typing.Final = scope.get("path")
|
|
150
262
|
if path is not None:
|
|
151
263
|
path_stripped: typing.Final = path.strip()
|
|
152
|
-
return build_span_name(method, path_stripped), {"http.route": path_stripped}
|
|
264
|
+
return opentelemetry_instrument.build_span_name(method, path_stripped), {"http.route": path_stripped}
|
|
153
265
|
return method, {}
|
|
154
266
|
|
|
155
267
|
|
|
156
268
|
class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
|
|
157
|
-
def __init__(self, config: OpenTelemetryConfig) -> None:
|
|
269
|
+
def __init__(self, config: OpenTelemetryConfig, exclude_urls: typing.Sequence[str] = ()) -> None:
|
|
158
270
|
self.config = config
|
|
271
|
+
self.excluded_urls = opentelemetry_instrument.CombinedExcludeList(
|
|
272
|
+
ExcludeList(exclude_urls),
|
|
273
|
+
get_excluded_urls(self.config.exclude_urls_env_key),
|
|
274
|
+
)
|
|
159
275
|
|
|
160
276
|
def create_open_telemetry_middleware(self, app: ASGIApp) -> OpenTelemetryMiddleware:
|
|
161
277
|
return OpenTelemetryMiddleware(
|
|
@@ -163,7 +279,7 @@ class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
|
|
|
163
279
|
client_request_hook=self.config.client_request_hook_handler,
|
|
164
280
|
client_response_hook=self.config.client_response_hook_handler,
|
|
165
281
|
default_span_details=build_litestar_route_details_from_scope,
|
|
166
|
-
excluded_urls=
|
|
282
|
+
excluded_urls=self.excluded_urls,
|
|
167
283
|
meter=self.config.meter,
|
|
168
284
|
meter_provider=self.config.meter_provider,
|
|
169
285
|
server_request_hook=self.config.server_request_hook_handler,
|
|
@@ -175,7 +291,7 @@ class LitestarOpenTelemetryInstrumentationMiddleware(ASGIMiddleware):
|
|
|
175
291
|
|
|
176
292
|
|
|
177
293
|
@LitestarBootstrapper.use_instrument()
|
|
178
|
-
class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
|
|
294
|
+
class LitestarOpentelemetryInstrument(opentelemetry_instrument.OpentelemetryInstrument):
|
|
179
295
|
def bootstrap_before(self) -> dict[str, typing.Any]:
|
|
180
296
|
return {
|
|
181
297
|
"middleware": [
|
|
@@ -183,7 +299,8 @@ class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
|
|
|
183
299
|
LitestarOpentelemetryConfig(
|
|
184
300
|
tracer_provider=self.tracer_provider,
|
|
185
301
|
middleware_class=LitestarOpenTelemetryInstrumentationMiddleware, # type: ignore[arg-type]
|
|
186
|
-
)
|
|
302
|
+
),
|
|
303
|
+
exclude_urls=self.define_exclude_urls(),
|
|
187
304
|
)
|
|
188
305
|
]
|
|
189
306
|
}
|
|
@@ -26,7 +26,7 @@ def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[s
|
|
|
26
26
|
if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE):
|
|
27
27
|
conversion_result[dataclass_field.name] = value
|
|
28
28
|
continue
|
|
29
|
-
if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[
|
|
29
|
+
if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[operator]
|
|
30
30
|
conversion_result[dataclass_field.name] = value
|
|
31
31
|
|
|
32
32
|
return conversion_result
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import typing
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
OpenApiApiKeyLocation: typing.TypeAlias = typing.Literal["header", "query", "cookie"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenApiSecuritySchemeModel(pydantic.BaseModel):
|
|
12
|
+
model_config = pydantic.ConfigDict(extra="forbid", populate_by_name=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpenApiHttpSecurityScheme(OpenApiSecuritySchemeModel):
|
|
16
|
+
type: typing.Literal["http"] = "http"
|
|
17
|
+
scheme: str
|
|
18
|
+
bearer_format: str | None = pydantic.Field(default=None, alias="bearerFormat")
|
|
19
|
+
description: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenApiApiKeySecurityScheme(OpenApiSecuritySchemeModel):
|
|
23
|
+
type: typing.Literal["apiKey"] = "apiKey"
|
|
24
|
+
name: str
|
|
25
|
+
location: OpenApiApiKeyLocation = pydantic.Field(alias="in")
|
|
26
|
+
description: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenApiOAuthFlow(OpenApiSecuritySchemeModel):
|
|
30
|
+
authorization_url: str | None = pydantic.Field(default=None, alias="authorizationUrl")
|
|
31
|
+
token_url: str | None = pydantic.Field(default=None, alias="tokenUrl")
|
|
32
|
+
refresh_url: str | None = pydantic.Field(default=None, alias="refreshUrl")
|
|
33
|
+
scopes: dict[str, str] = pydantic.Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@pydantic.field_validator("authorization_url", "token_url", "refresh_url")
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_url(cls, value: str | None) -> str | None:
|
|
38
|
+
return validate_openapi_url(value)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OpenApiOAuthFlows(OpenApiSecuritySchemeModel):
|
|
42
|
+
implicit: OpenApiOAuthFlow | None = None
|
|
43
|
+
resource_owner: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="password")
|
|
44
|
+
client_credentials: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="clientCredentials")
|
|
45
|
+
authorization_code: OpenApiOAuthFlow | None = pydantic.Field(default=None, alias="authorizationCode")
|
|
46
|
+
|
|
47
|
+
@pydantic.model_validator(mode="after")
|
|
48
|
+
def validate_required_urls(self) -> OpenApiOAuthFlows:
|
|
49
|
+
if all(
|
|
50
|
+
flow is None
|
|
51
|
+
for flow in (self.implicit, self.resource_owner, self.client_credentials, self.authorization_code)
|
|
52
|
+
):
|
|
53
|
+
message = "OAuth2 flows must configure at least one grant type."
|
|
54
|
+
raise ValueError(message)
|
|
55
|
+
self._validate_flow_urls("implicit", self.implicit, requires_authorization_url=True)
|
|
56
|
+
self._validate_flow_urls("password", self.resource_owner, requires_token_url=True)
|
|
57
|
+
self._validate_flow_urls("client credentials", self.client_credentials, requires_token_url=True)
|
|
58
|
+
self._validate_flow_urls(
|
|
59
|
+
"authorization code",
|
|
60
|
+
self.authorization_code,
|
|
61
|
+
requires_authorization_url=True,
|
|
62
|
+
requires_token_url=True,
|
|
63
|
+
)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _validate_flow_urls(
|
|
68
|
+
flow_name: str,
|
|
69
|
+
flow: OpenApiOAuthFlow | None,
|
|
70
|
+
*,
|
|
71
|
+
requires_authorization_url: bool = False,
|
|
72
|
+
requires_token_url: bool = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
if flow is None:
|
|
75
|
+
return
|
|
76
|
+
if requires_authorization_url and flow.authorization_url is None:
|
|
77
|
+
message = f"OAuth2 {flow_name} flow requires authorizationUrl."
|
|
78
|
+
raise ValueError(message)
|
|
79
|
+
if requires_token_url and flow.token_url is None:
|
|
80
|
+
message = f"OAuth2 {flow_name} flow requires tokenUrl."
|
|
81
|
+
raise ValueError(message)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OpenApiOAuth2SecurityScheme(OpenApiSecuritySchemeModel):
|
|
85
|
+
type: typing.Literal["oauth2"] = "oauth2"
|
|
86
|
+
flows: OpenApiOAuthFlows
|
|
87
|
+
description: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OpenApiOpenIdConnectSecurityScheme(OpenApiSecuritySchemeModel):
|
|
91
|
+
type: typing.Literal["openIdConnect"] = "openIdConnect"
|
|
92
|
+
open_id_connect_url: str = pydantic.Field(alias="openIdConnectUrl")
|
|
93
|
+
description: str | None = None
|
|
94
|
+
|
|
95
|
+
@pydantic.field_validator("open_id_connect_url")
|
|
96
|
+
@classmethod
|
|
97
|
+
def validate_url(cls, value: str) -> str:
|
|
98
|
+
validated_value = validate_openapi_url(value)
|
|
99
|
+
assert validated_value is not None # noqa: S101 - this field is required.
|
|
100
|
+
return validated_value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_OpenApiSecurityScheme: typing.TypeAlias = typing.Annotated[
|
|
104
|
+
OpenApiHttpSecurityScheme
|
|
105
|
+
| OpenApiApiKeySecurityScheme
|
|
106
|
+
| OpenApiOAuth2SecurityScheme
|
|
107
|
+
| OpenApiOpenIdConnectSecurityScheme,
|
|
108
|
+
pydantic.Field(discriminator="type"),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_openapi_url(value: str | None) -> str | None:
|
|
113
|
+
if value is None:
|
|
114
|
+
return value
|
|
115
|
+
if not value or any(character.isspace() or unicodedata.category(character) == "Cc" for character in value):
|
|
116
|
+
message = "OpenAPI URL values must be non-empty and contain no whitespace or control characters."
|
|
117
|
+
raise ValueError(message)
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def serialize_security_schemes(
|
|
122
|
+
security_schemes: typing.Mapping[str, _OpenApiSecurityScheme],
|
|
123
|
+
) -> dict[str, dict[str, typing.Any]]:
|
|
124
|
+
return {
|
|
125
|
+
scheme_name: security_scheme.model_dump(by_alias=True, exclude_none=True)
|
|
126
|
+
for scheme_name, security_scheme in security_schemes.items()
|
|
127
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SUPPORTED_HTTP_METHODS: typing.Final = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "trace"})
|
|
9
|
+
SAFE_MEDIA_TYPE_TOKEN: typing.Final[re.Pattern[str]] = re.compile(r"[!#$%&'*+\-.^_|~0-9A-Za-z]+\Z")
|
|
10
|
+
VENDOR_MEDIA_TYPE: typing.Final = re.compile(r"application/vnd\.([!#$%&'*+\-.^_|~0-9A-Za-z]+)\+json\Z")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenApiOperationVersionOverride(pydantic.BaseModel):
|
|
14
|
+
model_config = pydantic.ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
method: str
|
|
18
|
+
supported_versions: tuple[str, ...]
|
|
19
|
+
|
|
20
|
+
@pydantic.field_validator("path")
|
|
21
|
+
@classmethod
|
|
22
|
+
def validate_path(cls, value: str) -> str:
|
|
23
|
+
if not value.startswith("/") or "?" in value or "#" in value:
|
|
24
|
+
message = "Operation path must be an absolute path without a query string or fragment."
|
|
25
|
+
raise ValueError(message)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
@pydantic.field_validator("method")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_method(cls, value: str) -> str:
|
|
31
|
+
if value not in SUPPORTED_HTTP_METHODS:
|
|
32
|
+
message = f"Operation method must be one of: {', '.join(sorted(SUPPORTED_HTTP_METHODS))}."
|
|
33
|
+
raise ValueError(message)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
@pydantic.field_validator("supported_versions")
|
|
37
|
+
@classmethod
|
|
38
|
+
def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
39
|
+
return validate_versions(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenApiVersionDocsConfig(pydantic.BaseModel):
|
|
43
|
+
model_config = pydantic.ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
vendor_media_type: str
|
|
46
|
+
supported_versions: tuple[str, ...]
|
|
47
|
+
operation_versions: tuple[OpenApiOperationVersionOverride, ...] = ()
|
|
48
|
+
|
|
49
|
+
@pydantic.field_validator("vendor_media_type")
|
|
50
|
+
@classmethod
|
|
51
|
+
def validate_vendor_media_type(cls, value: str) -> str:
|
|
52
|
+
if VENDOR_MEDIA_TYPE.fullmatch(value) is None:
|
|
53
|
+
message = "Vendor media type must use the application/vnd.<name>+json form."
|
|
54
|
+
raise ValueError(message)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
@pydantic.field_validator("supported_versions")
|
|
58
|
+
@classmethod
|
|
59
|
+
def validate_supported_versions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
60
|
+
validated_versions = validate_versions(value)
|
|
61
|
+
if not validated_versions:
|
|
62
|
+
message = "OpenAPI version documentation requires at least one supported API version."
|
|
63
|
+
raise ValueError(message)
|
|
64
|
+
return validated_versions
|
|
65
|
+
|
|
66
|
+
@pydantic.field_validator("operation_versions")
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_operation_versions(
|
|
69
|
+
cls,
|
|
70
|
+
value: tuple[OpenApiOperationVersionOverride, ...],
|
|
71
|
+
) -> tuple[OpenApiOperationVersionOverride, ...]:
|
|
72
|
+
selectors = {(override.path, override.method) for override in value}
|
|
73
|
+
if len(value) != len(selectors):
|
|
74
|
+
message = "Operation version overrides must not contain duplicate path and method pairs."
|
|
75
|
+
raise ValueError(message)
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_versions(value: tuple[str, ...]) -> tuple[str, ...]:
|
|
80
|
+
if len(value) != len(set(value)):
|
|
81
|
+
message = "Supported API versions must not contain duplicates."
|
|
82
|
+
raise ValueError(message)
|
|
83
|
+
if any(SAFE_MEDIA_TYPE_TOKEN.fullmatch(version) is None for version in value):
|
|
84
|
+
message = "Each supported API version must be a non-empty safe media-type token."
|
|
85
|
+
raise ValueError(message)
|
|
86
|
+
return value
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/opentelemetry_instrument.py
RENAMED
|
@@ -19,6 +19,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
|
|
|
19
19
|
from opentelemetry.semconv.resource import ResourceAttributes
|
|
20
20
|
from opentelemetry.trace import SpanKind, format_span_id, get_current_span, set_tracer_provider
|
|
21
21
|
from opentelemetry.util._importlib_metadata import entry_points
|
|
22
|
+
from opentelemetry.util.http import ExcludeList
|
|
22
23
|
|
|
23
24
|
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
|
|
24
25
|
from microbootstrap.instruments.sentry_instrument import snapshot_sentry_opentelemetry_baggage
|
|
@@ -70,6 +71,21 @@ def opentelemetry_baggage_scope(
|
|
|
70
71
|
context.detach(token)
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
def build_span_name(method: str, route: str) -> str:
|
|
75
|
+
if not route:
|
|
76
|
+
return method
|
|
77
|
+
return f"{method} {route}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CombinedExcludeList(ExcludeList):
|
|
81
|
+
def __init__(self, *exclude_lists: ExcludeList) -> None:
|
|
82
|
+
super().__init__([])
|
|
83
|
+
self.exclude_lists = exclude_lists
|
|
84
|
+
|
|
85
|
+
def url_disabled(self, url: str) -> bool:
|
|
86
|
+
return any(one_exclude_list.url_disabled(url) for one_exclude_list in self.exclude_lists)
|
|
87
|
+
|
|
88
|
+
|
|
73
89
|
@dataclasses.dataclass()
|
|
74
90
|
class OpenTelemetryInstrumentor:
|
|
75
91
|
instrumentor: BaseInstrumentor
|
|
@@ -167,6 +183,16 @@ class BaseOpentelemetryInstrument(Instrument[OpentelemetryConfigT]):
|
|
|
167
183
|
LOGGER_OBJ.debug("Instrumenting failed", entry_point_name=entry_point.name)
|
|
168
184
|
raise
|
|
169
185
|
|
|
186
|
+
def define_exclude_urls(self) -> list[str]:
|
|
187
|
+
exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls]
|
|
188
|
+
if (
|
|
189
|
+
not self.instrument_config.opentelemetry_generate_health_check_spans
|
|
190
|
+
and self.instrument_config.health_checks_path
|
|
191
|
+
and self.instrument_config.health_checks_path not in exclude_urls
|
|
192
|
+
):
|
|
193
|
+
exclude_urls.append(self.instrument_config.health_checks_path)
|
|
194
|
+
return exclude_urls
|
|
195
|
+
|
|
170
196
|
def is_ready(self) -> bool:
|
|
171
197
|
return (
|
|
172
198
|
bool(self.instrument_config.opentelemetry_endpoint)
|
|
@@ -222,16 +248,6 @@ class BaseOpentelemetryInstrument(Instrument[OpentelemetryConfigT]):
|
|
|
222
248
|
|
|
223
249
|
|
|
224
250
|
class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]):
|
|
225
|
-
def define_exclude_urls(self) -> list[str]:
|
|
226
|
-
exclude_urls: typing.Final = [*self.instrument_config.opentelemetry_exclude_urls]
|
|
227
|
-
if (
|
|
228
|
-
not self.instrument_config.opentelemetry_generate_health_check_spans
|
|
229
|
-
and self.instrument_config.health_checks_path
|
|
230
|
-
and self.instrument_config.health_checks_path not in exclude_urls
|
|
231
|
-
):
|
|
232
|
-
exclude_urls.append(self.instrument_config.health_checks_path)
|
|
233
|
-
return exclude_urls
|
|
234
|
-
|
|
235
251
|
@classmethod
|
|
236
252
|
def get_config_type(cls) -> type[OpentelemetryConfig]:
|
|
237
253
|
return OpentelemetryConfig
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from microbootstrap.helpers import is_valid_path
|
|
8
|
+
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
|
|
9
|
+
from microbootstrap.instruments.openapi_security_schemes import _OpenApiSecurityScheme # noqa: TC001
|
|
10
|
+
from microbootstrap.instruments.openapi_version_docs import (
|
|
11
|
+
SUPPORTED_HTTP_METHODS,
|
|
12
|
+
OpenApiVersionDocsConfig,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SECURITY_SCHEME_NAME_PATTERN: typing.Final = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SwaggerConfig(BaseInstrumentConfig):
|
|
20
|
+
service_name: str = "micro-service"
|
|
21
|
+
service_description: str = "Micro service description"
|
|
22
|
+
service_version: str = "1.0.0"
|
|
23
|
+
|
|
24
|
+
service_static_path: str = "/static"
|
|
25
|
+
swagger_path: str = "/docs"
|
|
26
|
+
swagger_offline_docs: bool = False
|
|
27
|
+
swagger_extra_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
|
28
|
+
security_schemes: typing.Mapping[str, _OpenApiSecurityScheme] = pydantic.Field(default_factory=dict)
|
|
29
|
+
openapi_version_docs: OpenApiVersionDocsConfig | None = None
|
|
30
|
+
|
|
31
|
+
@pydantic.field_validator("security_schemes")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_security_scheme_names(
|
|
34
|
+
cls,
|
|
35
|
+
security_schemes: typing.Mapping[str, _OpenApiSecurityScheme],
|
|
36
|
+
) -> typing.Mapping[str, _OpenApiSecurityScheme]:
|
|
37
|
+
for scheme_name in security_schemes:
|
|
38
|
+
if SECURITY_SCHEME_NAME_PATTERN.fullmatch(scheme_name) is None:
|
|
39
|
+
message = "OpenAPI security scheme names must match ^[a-zA-Z0-9._-]+$."
|
|
40
|
+
raise ValueError(message)
|
|
41
|
+
return security_schemes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SwaggerInstrument(Instrument[SwaggerConfig]):
|
|
45
|
+
instrument_name = "Swagger"
|
|
46
|
+
ready_condition = "Provide valid swagger_path"
|
|
47
|
+
|
|
48
|
+
def is_ready(self) -> bool:
|
|
49
|
+
return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path)
|
|
50
|
+
|
|
51
|
+
def _build_version_documentation(
|
|
52
|
+
self,
|
|
53
|
+
path: str,
|
|
54
|
+
method: str,
|
|
55
|
+
description: object,
|
|
56
|
+
existing_extension: object,
|
|
57
|
+
*,
|
|
58
|
+
has_existing_extension: bool,
|
|
59
|
+
) -> tuple[dict[str, str | list[str]], str] | None:
|
|
60
|
+
configuration = self.instrument_config.openapi_version_docs
|
|
61
|
+
if configuration is None or method not in SUPPORTED_HTTP_METHODS:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
supported_versions = next(
|
|
65
|
+
(
|
|
66
|
+
override.supported_versions
|
|
67
|
+
for override in configuration.operation_versions
|
|
68
|
+
if override.path == path and override.method == method
|
|
69
|
+
),
|
|
70
|
+
configuration.supported_versions,
|
|
71
|
+
)
|
|
72
|
+
if not supported_versions:
|
|
73
|
+
return None
|
|
74
|
+
if description is not None and not isinstance(description, str):
|
|
75
|
+
message = f"OpenAPI operation {method.upper()} {path} has a non-string description."
|
|
76
|
+
raise ValueError(message)
|
|
77
|
+
|
|
78
|
+
expected_extension: typing.Final[dict[str, str | list[str]]] = {
|
|
79
|
+
"header": "Accept",
|
|
80
|
+
"mediaType": configuration.vendor_media_type,
|
|
81
|
+
"parameter": "version",
|
|
82
|
+
"supportedVersions": list(supported_versions),
|
|
83
|
+
}
|
|
84
|
+
if has_existing_extension and existing_extension != expected_extension:
|
|
85
|
+
message = "OpenAPI operation x-accept-versioning conflicts with configured Accept version documentation."
|
|
86
|
+
raise ValueError(message)
|
|
87
|
+
return expected_extension, self._format_version_documentation(description, configuration, supported_versions)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _format_version_documentation(
|
|
91
|
+
description: str | None,
|
|
92
|
+
configuration: OpenApiVersionDocsConfig,
|
|
93
|
+
supported_versions: tuple[str, ...],
|
|
94
|
+
) -> str:
|
|
95
|
+
media_types: typing.Final = tuple(
|
|
96
|
+
f"{configuration.vendor_media_type}; version={version}" for version in supported_versions
|
|
97
|
+
)
|
|
98
|
+
version_documentation: typing.Final = (
|
|
99
|
+
f"Supported API version: `{media_types[0]}`."
|
|
100
|
+
if len(media_types) == 1
|
|
101
|
+
else "Supported API versions:\n" + "\n".join(f"- `{media_type}`." for media_type in media_types)
|
|
102
|
+
)
|
|
103
|
+
if not description:
|
|
104
|
+
return version_documentation
|
|
105
|
+
if version_documentation in description:
|
|
106
|
+
return description
|
|
107
|
+
return f"{description}\n\n{version_documentation}"
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _validate_security_scheme_conflicts(
|
|
111
|
+
existing_schemes: typing.Mapping[str, object],
|
|
112
|
+
expected_schemes: typing.Mapping[str, object],
|
|
113
|
+
) -> None:
|
|
114
|
+
for scheme_name, expected_scheme in expected_schemes.items():
|
|
115
|
+
if scheme_name in existing_schemes and existing_schemes[scheme_name] != expected_scheme:
|
|
116
|
+
message = f"OpenAPI security scheme '{scheme_name}' conflicts with the configured security scheme."
|
|
117
|
+
raise ValueError(message)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def get_config_type(cls) -> type[SwaggerConfig]:
|
|
121
|
+
return SwaggerConfig
|
|
@@ -58,26 +58,26 @@ dependencies = [
|
|
|
58
58
|
"opentelemetry-instrumentation-asgi>=0.46b0",
|
|
59
59
|
"orjson>=3.10.18",
|
|
60
60
|
]
|
|
61
|
-
version = "1.
|
|
61
|
+
version = "1.6.1"
|
|
62
62
|
|
|
63
63
|
[[project.authors]]
|
|
64
64
|
name = "community-of-python"
|
|
65
65
|
|
|
66
66
|
[project.optional-dependencies]
|
|
67
67
|
fastapi = [
|
|
68
|
-
"fastapi>=0.
|
|
68
|
+
"fastapi>=0.110.1",
|
|
69
69
|
"fastapi-offline-docs>=1",
|
|
70
70
|
"opentelemetry-instrumentation-fastapi>=0.46b0",
|
|
71
|
-
"prometheus-fastapi-instrumentator>=
|
|
71
|
+
"prometheus-fastapi-instrumentator>=7.1",
|
|
72
72
|
]
|
|
73
73
|
litestar = [
|
|
74
|
-
"litestar>=2.
|
|
74
|
+
"litestar>=2.21.1",
|
|
75
75
|
"litestar-offline-docs>=1",
|
|
76
76
|
"prometheus-client>=0.20",
|
|
77
77
|
]
|
|
78
78
|
granian = ["granian[reload]>=1"]
|
|
79
79
|
faststream = [
|
|
80
|
-
"faststream
|
|
80
|
+
"faststream>=0.6.7,<0.8",
|
|
81
81
|
"prometheus-client>=0.20",
|
|
82
82
|
]
|
|
83
83
|
fastmcp = [
|
|
@@ -58,23 +58,23 @@ dependencies = [
|
|
|
58
58
|
"opentelemetry-instrumentation-asgi>=0.46b0",
|
|
59
59
|
"orjson>=3.10.18",
|
|
60
60
|
]
|
|
61
|
-
version = "1.
|
|
61
|
+
version = "1.6.1"
|
|
62
62
|
authors = [{ name = "community-of-python" }]
|
|
63
63
|
|
|
64
64
|
[project.optional-dependencies]
|
|
65
65
|
fastapi = [
|
|
66
|
-
"fastapi>=0.
|
|
66
|
+
"fastapi>=0.110.1",
|
|
67
67
|
"fastapi-offline-docs>=1",
|
|
68
68
|
"opentelemetry-instrumentation-fastapi>=0.46b0",
|
|
69
|
-
"prometheus-fastapi-instrumentator>=
|
|
69
|
+
"prometheus-fastapi-instrumentator>=7.1",
|
|
70
70
|
]
|
|
71
71
|
litestar = [
|
|
72
|
-
"litestar>=2.
|
|
72
|
+
"litestar>=2.21.1",
|
|
73
73
|
"litestar-offline-docs>=1",
|
|
74
74
|
"prometheus-client>=0.20",
|
|
75
75
|
]
|
|
76
76
|
granian = ["granian[reload]>=1"]
|
|
77
|
-
faststream = ["faststream
|
|
77
|
+
faststream = ["faststream>=0.6.7,<0.8", "prometheus-client>=0.20"]
|
|
78
78
|
fastmcp = ["fastmcp>=2,<4", "prometheus-client>=0.20"]
|
|
79
79
|
|
|
80
80
|
[dependency-groups]
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
import typing
|
|
3
|
-
|
|
4
|
-
import pydantic
|
|
5
|
-
|
|
6
|
-
from microbootstrap.helpers import is_valid_path
|
|
7
|
-
from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SwaggerConfig(BaseInstrumentConfig):
|
|
11
|
-
service_name: str = "micro-service"
|
|
12
|
-
service_description: str = "Micro service description"
|
|
13
|
-
service_version: str = "1.0.0"
|
|
14
|
-
|
|
15
|
-
service_static_path: str = "/static"
|
|
16
|
-
swagger_path: str = "/docs"
|
|
17
|
-
swagger_offline_docs: bool = False
|
|
18
|
-
swagger_extra_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class SwaggerInstrument(Instrument[SwaggerConfig]):
|
|
22
|
-
instrument_name = "Swagger"
|
|
23
|
-
ready_condition = "Provide valid swagger_path"
|
|
24
|
-
|
|
25
|
-
def is_ready(self) -> bool:
|
|
26
|
-
return bool(self.instrument_config.swagger_path) and is_valid_path(self.instrument_config.swagger_path)
|
|
27
|
-
|
|
28
|
-
@classmethod
|
|
29
|
-
def get_config_type(cls) -> type[SwaggerConfig]:
|
|
30
|
-
return SwaggerConfig
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/health_checks_instrument.py
RENAMED
|
File without changes
|
|
File without changes
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/logging_instrument.py
RENAMED
|
File without changes
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/prometheus_instrument.py
RENAMED
|
File without changes
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/pyroscope_instrument.py
RENAMED
|
File without changes
|
{microbootstrap-1.5.5 → microbootstrap-1.6.1}/microbootstrap/instruments/sentry_instrument.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|