├── .github
└── workflows
│ ├── release_docs.yml
│ └── workflow.yml
├── .gitignore
├── .pre-commit-config.yaml
├── Justfile
├── README.md
├── docs
├── .vuepress
│ └── config.js
├── README.md
└── get-started.md
├── examples
└── litestar_app.py
├── logo.svg
├── microbootstrap
├── __init__.py
├── bootstrappers
│ ├── __init__.py
│ ├── base.py
│ ├── fastapi.py
│ ├── faststream.py
│ └── litestar.py
├── config
│ ├── __init__.py
│ ├── fastapi.py
│ ├── faststream.py
│ └── litestar.py
├── console_writer.py
├── exceptions.py
├── granian_server.py
├── helpers.py
├── instruments
│ ├── __init__.py
│ ├── base.py
│ ├── cors_instrument.py
│ ├── health_checks_instrument.py
│ ├── instrument_box.py
│ ├── logging_instrument.py
│ ├── opentelemetry_instrument.py
│ ├── prometheus_instrument.py
│ ├── pyroscope_instrument.py
│ ├── sentry_instrument.py
│ └── swagger_instrument.py
├── instruments_setupper.py
├── middlewares
│ ├── __init__.py
│ ├── fastapi.py
│ └── litestar.py
├── py.typed
└── settings.py
├── package-lock.json
├── package.json
├── pyproject.toml
└── tests
├── __init__.py
├── bootstrappers
├── __init__.py
├── test_fastapi.py
├── test_faststream.py
└── test_litestar.py
├── conftest.py
├── instruments
├── __init__.py
├── test_cors.py
├── test_health_checks.py
├── test_instrument_box.py
├── test_logging.py
├── test_opentelemetry.py
├── test_prometheus.py
├── test_pyroscope.py
├── test_sentry.py
└── test_swagger.py
├── test_granian_server.py
├── test_helpers.py
├── test_instruments_setupper.py
└── test_settings.py
/.github/workflows/release_docs.yml:
--------------------------------------------------------------------------------
1 | name: Release microbootstrap documentation
2 |
3 | on: workflow_dispatch
4 |
5 | permissions:
6 | contents: write
7 |
8 | jobs:
9 | deploy-gh-pages:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v3
14 | with:
15 | fetch-depth: 0
16 | # if your docs needs submodules, uncomment the following line
17 | # submodules: true
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: 20
22 | cache: npm
23 | - name: Install Deps
24 | run: npm ci
25 | - name: Build Docs
26 | env:
27 | NODE_OPTIONS: --max_old_space_size=8192
28 | run: |-
29 | npm run docs:build
30 | > docs/.vuepress/dist/.nojekyll
31 | - name: Deploy Docs
32 | uses: JamesIves/github-pages-deploy-action@v4
33 | with:
34 | folder: docs/.vuepress/dist
35 |
--------------------------------------------------------------------------------
/.github/workflows/workflow.yml:
--------------------------------------------------------------------------------
1 | name: CI Pipeline
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 | release:
11 | types:
12 | - published
13 |
14 | jobs:
15 | ci:
16 | uses: community-of-python/community-workflow/.github/workflows/preset.yml@main
17 | with:
18 | python-version: '["3.9","3.10","3.11","3.12","3.13"]'
19 | secrets: inherit
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *~
3 | __pycache__/*
4 | *.swp
5 | *.sqlite3
6 | *.map
7 | .vscode
8 | .idea
9 | .DS_Store
10 | .env
11 | .mypy_cache
12 | .pytest_cache
13 | .ruff_cache
14 | .coverage
15 | htmlcov/
16 | coverage.xml
17 | pytest.xml
18 | dist/
19 | .python-version
20 | .venv
21 | uv.lock
22 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: local
3 | hooks:
4 | - id: lint
5 | name: lint
6 | entry: just
7 | args: [lint]
8 | language: system
9 | types: [python]
10 | pass_filenames: false
11 | - repo: https://github.com/pre-commit/pre-commit-hooks
12 | rev: v5.0.0
13 | hooks:
14 | - id: end-of-file-fixer
15 | - id: mixed-line-ending
16 |
--------------------------------------------------------------------------------
/Justfile:
--------------------------------------------------------------------------------
1 | default: install lint test
2 |
3 | install:
4 | uv lock --upgrade
5 | uv sync --all-extras --frozen
6 |
7 | lint:
8 | uv run ruff format
9 | uv run ruff check --fix
10 | uv run mypy .
11 |
12 | lint-ci:
13 | uv run ruff format --check
14 | uv run ruff check --no-fix
15 | uv run mypy .
16 |
17 | test *args:
18 | uv run --no-sync pytest {{ args }}
19 |
20 | publish:
21 | rm -rf dist
22 | uv build
23 | uv publish --token $PYPI_TOKEN
24 |
25 | hook:
26 | uv run pre-commit install
27 |
28 | unhook:
29 | uv run pre-commit uninstall
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | microbootstrap assists you in creating applications with all the necessary instruments already set up.
13 |
14 | ```python
15 | # settings.py
16 | from microbootstrap import LitestarSettings
17 |
18 |
19 | class YourSettings(LitestarSettings):
20 | ... # Your settings are stored here
21 |
22 |
23 | settings = YourSettings()
24 |
25 |
26 | # application.py
27 | import litestar
28 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
29 |
30 | from your_application.settings import settings
31 |
32 | # Use the Litestar application!
33 | application: litestar.Litestar = LitestarBootstrapper(settings).bootstrap()
34 | ```
35 |
36 | With microbootstrap, you receive an application with lightweight built-in support for:
37 |
38 | - `sentry`
39 | - `prometheus`
40 | - `opentelemetry`
41 | - `logging`
42 | - `cors`
43 | - `swagger` - with additional offline version support
44 | - `health-checks`
45 |
46 | Those instruments can be bootstrapped for:
47 |
48 | - `fastapi`,
49 | - `litestar`,
50 | - or `faststream` service,
51 | - or even a service that doesn't use one of these frameworks.
52 |
53 | Interested? Let's dive right in ⚡
54 |
55 | ## Table of Contents
56 |
57 | - [Installation](#installation)
58 | - [Quickstart](#quickstart)
59 | - [Settings](#settings)
60 | - [Service settings](#service-settings)
61 | - [Instruments](#instruments)
62 | - [Sentry](#sentry)
63 | - [Prometheus](#prometheus)
64 | - [Opentelemetry](#opentelemetry)
65 | - [Logging](#logging)
66 | - [CORS](#cors)
67 | - [Swagger](#swagger)
68 | - [Health checks](#health-checks)
69 | - [Configuration](#configuration)
70 | - [Instruments configuration](#instruments-configuration)
71 | - [Application configuration](#application-configuration)
72 | - [Advanced](#advanced)
73 |
74 | ## Installation
75 |
76 | Also, you can specify extras during installation for concrete framework:
77 |
78 | - `fastapi`
79 | - `litestar`
80 | - `faststream` (ASGI app)
81 |
82 | Also we have `granian` extra that is requires for `create_granian_server`.
83 |
84 | For uv:
85 |
86 | ```bash
87 | uv add "microbootstrap[fastapi]"
88 | ```
89 |
90 | For poetry:
91 |
92 | ```bash
93 | poetry add microbootstrap -E fastapi
94 | ```
95 |
96 | For pip:
97 |
98 | ```bash
99 | pip install "microbootstrap[fastapi]"
100 | ```
101 |
102 | ## Quickstart
103 |
104 | To configure your application, you can use the settings object.
105 |
106 | ```python
107 | from microbootstrap import LitestarSettings
108 |
109 |
110 | class YourSettings(LitestarSettings):
111 | # General settings
112 | service_debug: bool = False
113 | service_name: str = "my-awesome-service"
114 |
115 | # Sentry settings
116 | sentry_dsn: str = "your-sentry-dsn"
117 |
118 | # Prometheus settings
119 | prometheus_metrics_path: str = "/my-path"
120 |
121 | # Opentelemetry settings
122 | opentelemetry_container_name: str = "your-container"
123 | opentelemetry_endpoint: str = "/opentelemetry-endpoint"
124 |
125 |
126 |
127 | settings = YourSettings()
128 | ```
129 |
130 | Next, use the `Bootstrapper` object to create an application based on your settings.
131 |
132 | ```python
133 | import litestar
134 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
135 |
136 | application: litestar.Litestar = LitestarBootstrapper(settings).bootstrap()
137 | ```
138 |
139 | This approach will provide you with an application that has all the essential instruments already set up for you.
140 |
141 | ## Settings
142 |
143 | The settings object is the core of microbootstrap.
144 |
145 | All framework-related settings inherit from the `BaseServiceSettings` object. `BaseServiceSettings` defines parameters for the service and various instruments.
146 |
147 | However, the number of parameters is not confined to those defined in `BaseServiceSettings`. You can add as many as you need.
148 |
149 | These parameters can be sourced from your environment. By default, no prefix is added to these parameters.
150 |
151 | Example:
152 |
153 | ```python
154 | class YourSettings(BaseServiceSettings):
155 | service_debug: bool = True
156 | service_name: str = "micro-service"
157 |
158 | your_awesome_parameter: str = "really awesome"
159 |
160 | ... # Other settings here
161 | ```
162 |
163 | To source `your_awesome_parameter` from the environment, set the environment variable named `YOUR_AWESOME_PARAMETER`.
164 |
165 | If you prefer to use a prefix when sourcing parameters, set the `ENVIRONMENT_PREFIX` environment variable in advance.
166 |
167 | Example:
168 |
169 | ```bash
170 | $ export ENVIRONMENT_PREFIX=YOUR_PREFIX_
171 | ```
172 |
173 | Then the settings object will attempt to source the variable named `YOUR_PREFIX_YOUR_AWESOME_PARAMETER`.
174 |
175 | ## Service settings
176 |
177 | Each settings object for every framework includes service parameters that can be utilized by various instruments.
178 |
179 | You can configure them manually, or set the corresponding environment variables and let microbootstrap to source them automatically.
180 |
181 | ```python
182 | from microbootstrap.settings import BaseServiceSettings
183 |
184 |
185 | class ServiceSettings(BaseServiceSettings):
186 | service_debug: bool = True
187 | service_environment: str | None = None
188 | service_name: str = "micro-service"
189 | service_description: str = "Micro service description"
190 | service_version: str = "1.0.0"
191 |
192 | ... # Other settings here
193 |
194 | ```
195 |
196 | ## Instruments
197 |
198 | At present, the following instruments are supported for bootstrapping:
199 |
200 | - `sentry`
201 | - `prometheus`
202 | - `opentelemetry`
203 | - `pyroscope`
204 | - `logging`
205 | - `cors`
206 | - `swagger`
207 |
208 | Let's clarify the process required to bootstrap these instruments.
209 |
210 | ### [Sentry](https://sentry.io/)
211 |
212 | To bootstrap Sentry, you must provide at least the `sentry_dsn`.
213 | Additional parameters can also be supplied through the settings object.
214 |
215 | ```python
216 | from microbootstrap.settings import BaseServiceSettings
217 |
218 |
219 | class YourSettings(BaseServiceSettings):
220 | service_environment: str | None = None
221 |
222 | sentry_dsn: str | None = None
223 | sentry_traces_sample_rate: float | None = None
224 | sentry_sample_rate: float = pydantic.Field(default=1.0, le=1.0, ge=0.0)
225 | sentry_max_breadcrumbs: int = 15
226 | sentry_max_value_length: int = 16384
227 | sentry_attach_stacktrace: bool = True
228 | sentry_integrations: list[Integration] = []
229 | sentry_additional_params: dict[str, typing.Any] = {}
230 | sentry_tags: dict[str, str] | None = None
231 |
232 | ... # Other settings here
233 | ```
234 |
235 | These settings are subsequently passed to the [sentry-sdk](https://pypi.org/project/sentry-sdk/) package, finalizing your Sentry integration.
236 |
237 | ### [Prometheus](https://prometheus.io/)
238 |
239 | Prometheus integration presents a challenge because the underlying libraries for `FastAPI`, `Litestar` and `FastStream` differ significantly, making it impossible to unify them under a single interface. As a result, the Prometheus settings for `FastAPI`, `Litestar` and `FastStream` must be configured separately.
240 |
241 | #### FastAPI
242 |
243 | To bootstrap prometheus you have to provide `prometheus_metrics_path`
244 |
245 | ```python
246 | from microbootstrap.settings import FastApiSettings
247 |
248 |
249 | class YourSettings(FastApiSettings):
250 | service_name: str
251 |
252 | prometheus_metrics_path: str = "/metrics"
253 | prometheus_metrics_include_in_schema: bool = False
254 | prometheus_instrumentator_params: dict[str, typing.Any] = {}
255 | prometheus_instrument_params: dict[str, typing.Any] = {}
256 | prometheus_expose_params: dict[str, typing.Any] = {}
257 |
258 | ... # Other settings here
259 | ```
260 |
261 | Parameters description:
262 |
263 | - `service_name` - will be attached to metrics's names, but has to be named in [snake_case](https://en.wikipedia.org/wiki/Snake_case).
264 | - `prometheus_metrics_path` - path to metrics handler.
265 | - `prometheus_metrics_include_in_schema` - whether to include metrics route in OpenAPI schema.
266 | - `prometheus_instrumentator_params` - will be passed to `Instrumentor` during initialization.
267 | - `prometheus_instrument_params` - will be passed to `Instrumentor.instrument(...)`.
268 | - `prometheus_expose_params` - will be passed to `Instrumentor.expose(...)`.
269 |
270 | FastAPI prometheus bootstrapper uses [prometheus-fastapi-instrumentator](https://github.com/trallnag/prometheus-fastapi-instrumentator) that's why there are three different dict for parameters.
271 |
272 | #### Litestar
273 |
274 | To bootstrap prometheus you have to provide `prometheus_metrics_path`
275 |
276 | ```python
277 | from microbootstrap.settings import LitestarSettings
278 |
279 |
280 | class YourSettings(LitestarSettings):
281 | service_name: str
282 |
283 | prometheus_metrics_path: str = "/metrics"
284 | prometheus_additional_params: dict[str, typing.Any] = {}
285 |
286 | ... # Other settings here
287 | ```
288 |
289 | Parameters description:
290 |
291 | - `service_name` - will be attached to metric's names, there are no name restrictions.
292 | - `prometheus_metrics_path` - path to metrics handler.
293 | - `prometheus_additional_params` - will be passed to `litestar.contrib.prometheus.PrometheusConfig`.
294 |
295 | #### FastStream
296 |
297 | To bootstrap prometheus you have to provide `prometheus_metrics_path` and `prometheus_middleware_cls`:
298 |
299 | ```python
300 | from microbootstrap import FastStreamSettings
301 | from faststream.redis.prometheus import RedisPrometheusMiddleware
302 |
303 |
304 | class YourSettings(FastStreamSettings):
305 | service_name: str
306 |
307 | prometheus_metrics_path: str = "/metrics"
308 | prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = RedisPrometheusMiddleware
309 |
310 | ... # Other settings here
311 | ```
312 |
313 | Parameters description:
314 |
315 | - `service_name` - will be attached to metric's names, there are no name restrictions.
316 | - `prometheus_metrics_path` - path to metrics handler.
317 | - `prometheus_middleware_cls` - Prometheus middleware for your broker.
318 |
319 | ### [OpenTelemetry](https://opentelemetry.io/)
320 |
321 | To bootstrap OpenTelemetry, you must provide `opentelemetry_endpoint` or set `service_debug` to `True`. In debug mode traces are sent to the console.
322 |
323 | However, additional parameters can also be supplied if needed.
324 |
325 | ```python
326 | from microbootstrap.settings import BaseServiceSettings, FastStreamPrometheusMiddlewareProtocol
327 | from microbootstrap.instruments.opentelemetry_instrument import OpenTelemetryInstrumentor
328 |
329 |
330 | class YourSettings(BaseServiceSettings):
331 | service_name: str
332 | service_version: str
333 |
334 | opentelemetry_service_name: str | None = None
335 | opentelemetry_container_name: str | None = None
336 | opentelemetry_endpoint: str | None = None
337 | opentelemetry_namespace: str | None = None
338 | opentelemetry_insecure: bool = True
339 | opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = []
340 | opentelemetry_exclude_urls: list[str] = []
341 |
342 | ... # Other settings here
343 | ```
344 |
345 | Parameters description:
346 |
347 | - `service_name` - will be passed to the `Resource`.
348 | - `service_version` - will be passed to the `Resource`.
349 | - `opentelemetry_service_name` - if provided, will be passed to the `Resource` instead of `service_name`.
350 | - `opentelemetry_endpoint` - will be passed to `OTLPSpanExporter` as endpoint.
351 | - `opentelemetry_namespace` - will be passed to the `Resource`.
352 | - `opentelemetry_insecure` - is opentelemetry connection secure.
353 | - `opentelemetry_container_name` - will be passed to the `Resource`.
354 | - `opentelemetry_instrumentors` - a list of extra instrumentors.
355 | - `opentelemetry_exclude_urls` - list of ignored urls.
356 |
357 | These settings are subsequently passed to [opentelemetry](https://opentelemetry.io/), finalizing your Opentelemetry integration.
358 |
359 | #### FastStream
360 |
361 | For FastStream you also should pass `opentelemetry_middleware_cls` - OpenTelemetry middleware for your broker
362 |
363 | ```python
364 | from microbootstrap import FastStreamSettings, FastStreamTelemetryMiddlewareProtocol
365 | from faststream.redis.opentelemetry import RedisTelemetryMiddleware
366 |
367 |
368 | class YourSettings(FastStreamSettings):
369 | ...
370 | opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = RedisTelemetryMiddleware
371 | ...
372 | ```
373 |
374 | ### [Pyroscope](https://pyroscope.io)
375 |
376 | To integrate Pyroscope, specify the `pyroscope_endpoint`.
377 |
378 | - The `opentelemetry_service_name` will be used as the application name.
379 | - `service_namespace` tag will be added with `opentelemetry_namespace` value.
380 | - You can also set `pyroscope_sample_rate`, `pyroscope_auth_token`, `pyroscope_tags` and `pyroscope_additional_params` — params that will be passed to `pyroscope.configure`.
381 |
382 | When both Pyroscope and OpenTelemetry are enabled, profile span IDs will be included in traces using [`pyroscope-otel`](https://github.com/grafana/otel-profiling-python) for correlation.
383 |
384 | Note that Pyroscope integration is not supported on Windows.
385 |
386 | ### Logging
387 |
388 | microbootstrap provides in-memory JSON logging through the use of [structlog](https://pypi.org/project/structlog/).
389 | For more information on in-memory logging, refer to [MemoryHandler](https://docs.python.org/3/library/logging.handlers.html#memoryhandler).
390 |
391 | To utilize this feature, your application must be in non-debug mode, meaning `service_debug` should be set to `False`.
392 |
393 | ```python
394 | import logging
395 |
396 | from microbootstrap.settings import BaseServiceSettings
397 |
398 |
399 | class YourSettings(BaseServiceSettings):
400 | service_debug: bool = False
401 |
402 | logging_log_level: int = logging.INFO
403 | logging_flush_level: int = logging.ERROR
404 | logging_buffer_capacity: int = 10
405 | logging_unset_handlers: list[str] = ["uvicorn", "uvicorn.access"]
406 | logging_extra_processors: list[typing.Any] = []
407 | logging_exclude_endpoints: list[str] = ["/health/", "/metrics"]
408 | logging_turn_off_middleware: bool = False
409 | ```
410 |
411 | Parameters description:
412 |
413 | - `logging_log_level` - The default log level.
414 | - `logging_flush_level` - All messages will be flushed from the buffer when a log with this level appears.
415 | - `logging_buffer_capacity` - The number of messages your buffer will store before being flushed.
416 | - `logging_unset_handlers` - Unset logger handlers.
417 | - `logging_extra_processors` - Set additional structlog processors if needed.
418 | - `logging_exclude_endpoints` - Exclude logging on specific endpoints.
419 | - `logging_turn_off_middleware` - Turning off logging middleware.
420 |
421 | ### CORS
422 |
423 | ```python
424 | from microbootstrap.settings import BaseServiceSettings
425 |
426 |
427 | class YourSettings(BaseServiceSettings):
428 | cors_allowed_origins: list[str] = pydantic.Field(default_factory=list)
429 | cors_allowed_methods: list[str] = pydantic.Field(default_factory=list)
430 | cors_allowed_headers: list[str] = pydantic.Field(default_factory=list)
431 | cors_exposed_headers: list[str] = pydantic.Field(default_factory=list)
432 | cors_allowed_credentials: bool = False
433 | cors_allowed_origin_regex: str | None = None
434 | cors_max_age: int = 600
435 | ```
436 |
437 | Parameter descriptions:
438 |
439 | - `cors_allowed_origins` - A list of origins that are permitted.
440 | - `cors_allowed_methods` - A list of HTTP methods that are allowed.
441 | - `cors_allowed_headers` - A list of headers that are permitted.
442 | - `cors_exposed_headers` - A list of headers that are exposed via the 'Access-Control-Expose-Headers' header.
443 | - `cors_allowed_credentials` - A boolean value that dictates whether or not to set the 'Access-Control-Allow-Credentials' header.
444 | - `cors_allowed_origin_regex` - A regex used to match against origins.
445 | - `cors_max_age` - The response caching Time-To-Live (TTL) in seconds, defaults to 600.
446 |
447 | ### Swagger
448 |
449 | ```python
450 | from microbootstrap.settings import BaseServiceSettings
451 |
452 |
453 | class YourSettings(BaseServiceSettings):
454 | service_name: str = "micro-service"
455 | service_description: str = "Micro service description"
456 | service_version: str = "1.0.0"
457 | service_static_path: str = "/static"
458 |
459 | swagger_path: str = "/docs"
460 | swagger_offline_docs: bool = False
461 | swagger_extra_params: dict[str, Any] = {}
462 | ```
463 |
464 | Parameter descriptions:
465 |
466 | - `service_name` - The name of the service, which will be displayed in the documentation.
467 | - `service_description` - A brief description of the service, which will also be displayed in the documentation.
468 | - `service_version` - The current version of the service.
469 | - `service_static_path` - The path for static files in the service.
470 | - `swagger_path` - The path where the documentation can be found.
471 | - `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.
472 | - `swagger_extra_params` - Additional parameters to pass into the OpenAPI configuration.
473 |
474 | #### FastStream AsyncAPI documentation
475 |
476 | AsyncAPI documentation is available by default under `/asyncapi` route. You can change that by setting `asyncapi_path`:
477 |
478 | ```python
479 | from microbootstrap import FastStreamSettings
480 |
481 |
482 | class YourSettings(FastStreamSettings):
483 | asyncapi_path: str | None = None
484 | ```
485 |
486 | ### Health checks
487 |
488 | ```python
489 | from microbootstrap.settings import BaseServiceSettings
490 |
491 |
492 | class YourSettings(BaseServiceSettings):
493 | service_name: str = "micro-service"
494 | service_version: str = "1.0.0"
495 |
496 | health_checks_enabled: bool = True
497 | health_checks_path: str = "/health/"
498 | health_checks_include_in_schema: bool = False
499 | ```
500 |
501 | Parameter descriptions:
502 |
503 | - `service_name` - Will be displayed in health check response.
504 | - `service_version` - Will be displayed in health check response.
505 | - `health_checks_enabled` - Must be True to enable health checks.
506 | - `health_checks_path` - Path for health check handler.
507 | - `health_checks_include_in_schema` - Must be True to include `health_checks_path` (`/health/`) in OpenAPI schema.
508 |
509 | ## Configuration
510 |
511 | While settings provide a convenient mechanism, it's not always feasible to store everything within them.
512 |
513 | There may be cases where you need to configure a tool directly. Here's how it can be done.
514 |
515 | ### Instruments configuration
516 |
517 | To manually configure an instrument, you need to import one of the available configurations from microbootstrap:
518 |
519 | - `SentryConfig`
520 | - `OpentelemetryConfig`
521 | - `PrometheusConfig`
522 | - `LoggingConfig`
523 | - `SwaggerConfig`
524 | - `CorsConfig`
525 |
526 | These configurations can then be passed into the `.configure_instrument` or `.configure_instruments` bootstrapper methods.
527 |
528 | ```python
529 | import litestar
530 |
531 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
532 | from microbootstrap import SentryConfig, OpentelemetryConfig
533 |
534 |
535 | application: litestar.Litestar = (
536 | LitestarBootstrapper(settings)
537 | .configure_instrument(SentryConfig(sentry_dsn="https://new-dsn"))
538 | .configure_instrument(OpentelemetryConfig(opentelemetry_endpoint="/new-endpoint"))
539 | .bootstrap()
540 | )
541 | ```
542 |
543 | Alternatively,
544 |
545 | ```python
546 | import litestar
547 |
548 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
549 | from microbootstrap import SentryConfig, OpentelemetryConfig
550 |
551 |
552 | application: litestar.Litestar = (
553 | LitestarBootstrapper(settings)
554 | .configure_instruments(
555 | SentryConfig(sentry_dsn="https://examplePublicKey@o0.ingest.sentry.io/0"),
556 | OpentelemetryConfig(opentelemetry_endpoint="/new-endpoint")
557 | )
558 | .bootstrap()
559 | )
560 | ```
561 |
562 | ### Application configuration
563 |
564 | The application can be configured in a similar manner:
565 |
566 | ```python
567 | import litestar
568 |
569 | from microbootstrap.config.litestar import LitestarConfig
570 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
571 | from microbootstrap import SentryConfig, OpentelemetryConfig
572 |
573 |
574 | @litestar.get("/my-handler")
575 | async def my_handler() -> str:
576 | return "Ok"
577 |
578 | application: litestar.Litestar = (
579 | LitestarBootstrapper(settings)
580 | .configure_application(LitestarConfig(route_handlers=[my_handler]))
581 | .bootstrap()
582 | )
583 | ```
584 |
585 | > ### Important
586 | >
587 | > When configuring parameters with simple data types such as: `str`, `int`, `float`, etc., these variables overwrite previous values.
588 | >
589 | > Example:
590 | >
591 | > ```python
592 | > from microbootstrap import LitestarSettings, SentryConfig
593 | >
594 | >
595 | > class YourSettings(LitestarSettings):
596 | > sentry_dsn: str = "https://my-sentry-dsn"
597 | >
598 | >
599 | > application: litestar.Litestar = (
600 | > LitestarBootstrapper(YourSettings())
601 | > .configure_instrument(
602 | > SentryConfig(sentry_dsn="https://my-new-configured-sentry-dsn")
603 | > )
604 | > .bootstrap()
605 | > )
606 | > ```
607 | >
608 | > In this example, the application will be bootstrapped with the new `https://my-new-configured-sentry-dsn` Sentry DSN, replacing the old one.
609 | >
610 | > However, when you configure parameters with complex data types such as: `list`, `tuple`, `dict`, or `set`, they are expanded or merged.
611 | >
612 | > Example:
613 | >
614 | > ```python
615 | > from microbootstrap import LitestarSettings, PrometheusConfig
616 | >
617 | >
618 | > class YourSettings(LitestarSettings):
619 | > prometheus_additional_params: dict[str, Any] = {"first_value": 1}
620 | >
621 | >
622 | > application: litestar.Litestar = (
623 | > LitestarBootstrapper(YourSettings())
624 | > .configure_instrument(
625 | > PrometheusConfig(prometheus_additional_params={"second_value": 2})
626 | > )
627 | > .bootstrap()
628 | > )
629 | > ```
630 | >
631 | > In this case, Prometheus will receive `{"first_value": 1, "second_value": 2}` inside `prometheus_additional_params`. This is also true for `list`, `tuple`, and `set`.
632 |
633 | ### Using microbootstrap without a framework
634 |
635 | When working on projects that don't use Litestar or FastAPI, you can still take advantage of monitoring and logging capabilities using `InstrumentsSetupper`. This class sets up Sentry, OpenTelemetry, Pyroscope and Logging instruments in a way that's easy to integrate with your project.
636 |
637 | You can use `InstrumentsSetupper` as a context manager, like this:
638 |
639 | ```python
640 | from microbootstrap.instruments_setupper import InstrumentsSetupper
641 | from microbootstrap import InstrumentsSetupperSettings
642 |
643 |
644 | class YourSettings(InstrumentsSetupperSettings):
645 | ...
646 |
647 |
648 | with InstrumentsSetupper(YourSettings()):
649 | while True:
650 | print("doing something useful")
651 | time.sleep(1)
652 | ```
653 |
654 | Alternatively, you can use the `setup()` and `teardown()` methods instead of a context manager:
655 |
656 | ```python
657 | current_setupper = InstrumentsSetupper(YourSettings())
658 | current_setupper.setup()
659 | try:
660 | while True:
661 | print("doing something useful")
662 | time.sleep(1)
663 | finally:
664 | current_setupper.teardown()
665 | ```
666 |
667 | Like bootstrappers, you can reconfigure instruments using the `configure_instrument()` and `configure_instruments()` methods.
668 |
669 | ## Advanced
670 |
671 | If you miss some instrument, you can add your own.
672 | Essentially, `Instrument` is just a class with some abstractmethods.
673 | Every instrument uses some config, so that's first thing, you have to define.
674 |
675 | ```python
676 | from microbootstrap.instruments.base import BaseInstrumentConfig
677 |
678 |
679 | class MyInstrumentConfig(BaseInstrumentConfig):
680 | your_string_parameter: str
681 | your_list_parameter: list
682 | ```
683 |
684 | Next, you can create an instrument class that inherits from `Instrument` and accepts your configuration as a generic parameter.
685 |
686 | ```python
687 | from microbootstrap.instruments.base import Instrument
688 |
689 |
690 | class MyInstrument(Instrument[MyInstrumentConfig]):
691 | instrument_name: str
692 | ready_condition: str
693 |
694 | def is_ready(self) -> bool:
695 | pass
696 |
697 | def teardown(self) -> None:
698 | pass
699 |
700 | def bootstrap(self) -> None:
701 | pass
702 |
703 | @classmethod
704 | def get_config_type(cls) -> type[MyInstrumentConfig]:
705 | return MyInstrumentConfig
706 | ```
707 |
708 | Now, you can define the behavior of your instrument.
709 |
710 | Attributes:
711 |
712 | - `instrument_name` - This will be displayed in your console during bootstrap.
713 | - `ready_condition` - This will be displayed in your console during bootstrap if the instrument is not ready.
714 |
715 | Methods:
716 |
717 | - `is_ready` - This defines the readiness of the instrument for bootstrapping, based on its configuration values. This is required.
718 | - `teardown` - This allows for a graceful shutdown of the instrument during application shutdown. This is not required.
719 | - `bootstrap` - This is the main logic of the instrument. This is not required.
720 |
721 | Once you have the framework of the instrument, you can adapt it for any existing framework. For instance, let's adapt it for litestar.
722 |
723 | ```python
724 | import litestar
725 |
726 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
727 |
728 | @LitestarBootstrapper.use_instrument()
729 | class LitestarMyInstrument(MyInstrument):
730 | def bootstrap_before(self) -> dict[str, typing.Any]:
731 | pass
732 |
733 | def bootstrap_after(self, application: litestar.Litestar) -> dict[str, typing.Any]:
734 | pass
735 | ```
736 |
737 | To bind the instrument to a bootstrapper, use the `.use_instrument` decorator.
738 |
739 | To add extra parameters to the application, you can use:
740 |
741 | - `bootstrap_before` - This adds arguments to the application configuration before creation.
742 | - `bootstrap_after` - This adds arguments to the application after creation.
743 |
744 | Afterwards, you can use your instrument during the bootstrap process.
745 |
746 | ```python
747 | import litestar
748 |
749 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
750 | from microbootstrap import SentryConfig, OpentelemetryConfig
751 |
752 | from your_app import MyInstrumentConfig
753 |
754 |
755 | application: litestar.Litestar = (
756 | LitestarBootstrapper(settings)
757 | .configure_instrument(
758 | MyInstrumentConfig(
759 | your_string_parameter="very-nice-parameter",
760 | your_list_parameter=["very-special-list"],
761 | )
762 | )
763 | .bootstrap()
764 | )
765 | ```
766 |
767 | Alternatively, you can fill these parameters within your main settings object.
768 |
769 | ```python
770 | from microbootstrap import LitestarSettings
771 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
772 |
773 | from your_app import MyInstrumentConfig
774 |
775 |
776 | class YourSettings(LitestarSettings, MyInstrumentConfig):
777 | your_string_parameter: str = "very-nice-parameter"
778 | your_list_parameter: list = ["very-special-list"]
779 |
780 | settings = YourSettings()
781 |
782 | application: litestar.Litestar = LitestarBootstrapper(settings).bootstrap()
783 | ```
784 |
--------------------------------------------------------------------------------
/docs/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | import {defaultTheme} from "@vuepress/theme-default";
2 | import {defineUserConfig} from "vuepress/cli";
3 | import {viteBundler} from "@vuepress/bundler-vite";
4 |
5 | export default defineUserConfig({
6 | lang: "en-US",
7 |
8 | title: "VuePress",
9 | description: "My first VuePress Site",
10 | base: "/microbootstrap/",
11 |
12 | theme: defaultTheme({
13 | repo: "community-of-python/microbootstrap",
14 | repoLabel: "GitHub",
15 | repoDisplay: true,
16 | hostname: "https://community-of-python.github.io/",
17 |
18 | logo: "https://vuejs.press/images/hero.png",
19 |
20 | navbar: ["/", "/get-started"],
21 | }),
22 |
23 | bundler: viteBundler({
24 | viteOptions: {
25 | base: "https://community-of-python.github.io/assets/microbootstrap/",
26 | },
27 | }),
28 | });
29 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | home: true
3 | title: Home
4 | heroImage: https://vuejs.press/images/hero.png
5 | actions:
6 | - text: Get Started
7 | link: /getting-started.html
8 | type: primary
9 |
10 | - text: Introduction
11 | link: https://vuejs.press/guide/introduction.html
12 | type: secondary
13 |
14 | features:
15 | - title: Simplicity First
16 | details: Minimal setup with markdown-centered project structure helps you focus on writing.
17 | - title: Vue-Powered
18 | details: Enjoy the dev experience of Vue, use Vue components in markdown, and develop custom themes with Vue.
19 | - title: Performant
20 | details: VuePress generates pre-rendered static HTML for each page, and runs as an SPA once a page is loaded.
21 | - title: Themes
22 | details: Providing a default theme out of the box. You can also choose a community theme or create your own one.
23 | - title: Plugins
24 | details: Flexible plugin API, allowing plugins to provide lots of plug-and-play features for your site.
25 | - title: Bundlers
26 | details: Default bundler is Vite, while Webpack is also supported. Choose the one you like!
27 |
28 | footer: MIT Licensed | Copyright © 2018-present VuePress Community
29 | ---
30 |
31 | This is the content of home page. Check [Home Page Docs][default-theme-home] for more details.
32 |
33 | [default-theme-home]: https://vuejs.press/reference/default-theme/frontmatter.html#home-page
34 |
--------------------------------------------------------------------------------
/docs/get-started.md:
--------------------------------------------------------------------------------
1 | # Get Started
2 |
3 | This is a normal page, which contains VuePress basics.
4 |
5 | ## Pages
6 |
7 | You can add markdown files in your vuepress directory, every markdown file will be converted to a page in your site.
8 |
9 | See [routing][] for more details.
10 |
11 | ## Content
12 |
13 | Every markdown file [will be rendered to HTML, then converted to a Vue SFC][content].
14 |
15 | VuePress support basic markdown syntax and [some extensions][synatex-extensions], you can also [use Vue features][vue-feature] in it.
16 |
17 | ## Configuration
18 |
19 | VuePress use a `.vuepress/config.js`(or .ts) file as [site configuration][config], you can use it to config your site.
20 |
21 | For [client side configuration][client-config], you can create `.vuepress/client.js`(or .ts).
22 |
23 | Meanwhile, you can also add configuration per page with [frontmatter][].
24 |
25 | ## Layouts and customization
26 |
27 | Here are common configuration controlling layout of `@vuepress/theme-default`:
28 |
29 | - [navbar][]
30 | - [sidebar][]
31 |
32 | Check [default theme docs][default-theme] for full reference.
33 |
34 | You can [add extra style][style] with `.vuepress/styles/index.scss` file.
35 |
36 | [routing]: https://vuejs.press/guide/page.html#routing
37 | [content]: https://vuejs.press/guide/page.html#content
38 | [synatex-extensions]: https://vuejs.press/guide/markdown.html#syntax-extensions
39 | [vue-feature]: https://vuejs.press/guide/markdown.html#using-vue-in-markdown
40 | [config]: https://vuejs.press/guide/configuration.html#client-config-file
41 | [client-config]: https://vuejs.press/guide/configuration.html#client-config-file
42 | [frontmatter]: https://vuejs.press/guide/page.html#frontmatter
43 | [navbar]: https://vuejs.press/reference/default-theme/config.html#navbar
44 | [sidebar]: https://vuejs.press/reference/default-theme/config.html#sidebar
45 | [default-theme]: https://vuejs.press/reference/default-theme/
46 | [style]: https://vuejs.press/reference/default-theme/styles.html#style-file
47 |
--------------------------------------------------------------------------------
/examples/litestar_app.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import litestar
4 |
5 | from microbootstrap import LitestarSettings
6 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
7 | from microbootstrap.config.litestar import LitestarConfig
8 | from microbootstrap.granian_server import create_granian_server
9 |
10 |
11 | class Settings(LitestarSettings): ...
12 |
13 |
14 | settings = Settings()
15 |
16 |
17 | @litestar.get("/")
18 | async def hello_world() -> dict[str, str]:
19 | return {"hello": "world"}
20 |
21 |
22 | def create_app() -> litestar.Litestar:
23 | return (
24 | LitestarBootstrapper(settings).configure_application(LitestarConfig(route_handlers=[hello_world])).bootstrap()
25 | )
26 |
27 |
28 | if __name__ == "__main__":
29 | create_granian_server("examples.litestar_app:create_app", settings, factory=True).serve()
30 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
39 |
--------------------------------------------------------------------------------
/microbootstrap/__init__.py:
--------------------------------------------------------------------------------
1 | from microbootstrap.instruments.cors_instrument import CorsConfig
2 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig
3 | from microbootstrap.instruments.logging_instrument import LoggingConfig
4 | from microbootstrap.instruments.opentelemetry_instrument import (
5 | FastStreamOpentelemetryConfig,
6 | FastStreamTelemetryMiddlewareProtocol,
7 | OpentelemetryConfig,
8 | )
9 | from microbootstrap.instruments.prometheus_instrument import (
10 | FastApiPrometheusConfig,
11 | FastStreamPrometheusConfig,
12 | FastStreamPrometheusMiddlewareProtocol,
13 | LitestarPrometheusConfig,
14 | )
15 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig
16 | from microbootstrap.instruments.sentry_instrument import SentryConfig
17 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig
18 | from microbootstrap.settings import (
19 | FastApiSettings,
20 | FastStreamSettings,
21 | InstrumentsSetupperSettings,
22 | LitestarSettings,
23 | )
24 |
25 |
26 | __all__ = (
27 | "CorsConfig",
28 | "FastApiPrometheusConfig",
29 | "FastApiSettings",
30 | "FastStreamOpentelemetryConfig",
31 | "FastStreamPrometheusConfig",
32 | "FastStreamPrometheusMiddlewareProtocol",
33 | "FastStreamSettings",
34 | "FastStreamTelemetryMiddlewareProtocol",
35 | "HealthChecksConfig",
36 | "InstrumentsSetupperSettings",
37 | "LitestarPrometheusConfig",
38 | "LitestarSettings",
39 | "LoggingConfig",
40 | "OpentelemetryConfig",
41 | "PyroscopeConfig",
42 | "SentryConfig",
43 | "SwaggerConfig",
44 | )
45 |
--------------------------------------------------------------------------------
/microbootstrap/bootstrappers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/microbootstrap/bootstrappers/__init__.py
--------------------------------------------------------------------------------
/microbootstrap/bootstrappers/base.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import abc
3 | import typing
4 |
5 | from microbootstrap.console_writer import ConsoleWriter
6 | from microbootstrap.helpers import dataclass_to_dict_no_defaults, merge_dataclasses_configs, merge_dict_configs
7 | from microbootstrap.instruments.instrument_box import InstrumentBox
8 | from microbootstrap.settings import SettingsT
9 |
10 |
11 | if typing.TYPE_CHECKING:
12 | import typing_extensions
13 |
14 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT
15 |
16 |
17 | class DataclassInstance(typing.Protocol):
18 | __dataclass_fields__: typing.ClassVar[dict[str, typing.Any]]
19 |
20 |
21 | ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any)
22 | DataclassT = typing.TypeVar("DataclassT", bound=DataclassInstance)
23 |
24 |
25 | class ApplicationBootstrapper(abc.ABC, typing.Generic[SettingsT, ApplicationT, DataclassT]):
26 | application_type: type[ApplicationT]
27 | application_config: DataclassT
28 | console_writer: ConsoleWriter
29 | instrument_box: InstrumentBox
30 |
31 | def __init__(self, settings: SettingsT) -> None:
32 | self.settings = settings
33 | self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug)
34 |
35 | if not hasattr(self, "instrument_box"):
36 | self.instrument_box = InstrumentBox()
37 | self.instrument_box.initialize(self.settings)
38 |
39 | def configure_application(
40 | self,
41 | application_config: DataclassT,
42 | ) -> typing_extensions.Self:
43 | self.application_config = merge_dataclasses_configs(self.application_config, application_config)
44 | return self
45 |
46 | def configure_instrument(
47 | self,
48 | instrument_config: InstrumentConfigT,
49 | ) -> typing_extensions.Self:
50 | self.instrument_box.configure_instrument(instrument_config)
51 | return self
52 |
53 | def configure_instruments(
54 | self,
55 | *instrument_configs: InstrumentConfigT,
56 | ) -> typing_extensions.Self:
57 | for instrument_config in instrument_configs:
58 | self.configure_instrument(instrument_config)
59 | return self
60 |
61 | @classmethod
62 | def use_instrument(
63 | cls,
64 | ) -> typing.Callable[
65 | [type[Instrument[InstrumentConfigT]]],
66 | type[Instrument[InstrumentConfigT]],
67 | ]:
68 | if not hasattr(cls, "instrument_box"):
69 | cls.instrument_box = InstrumentBox()
70 | return cls.instrument_box.extend_instruments
71 |
72 | def bootstrap(self) -> ApplicationT:
73 | resulting_application_config: dict[str, typing.Any] = {}
74 | for instrument in self.instrument_box.instruments:
75 | if instrument.is_ready():
76 | instrument.bootstrap()
77 | resulting_application_config = merge_dict_configs(
78 | resulting_application_config,
79 | instrument.bootstrap_before(),
80 | )
81 | instrument.write_status(self.console_writer)
82 |
83 | resulting_application_config = merge_dict_configs(
84 | resulting_application_config,
85 | dataclass_to_dict_no_defaults(self.application_config),
86 | )
87 | application = self.application_type(
88 | **merge_dict_configs(resulting_application_config, self.bootstrap_before()),
89 | )
90 |
91 | for instrument in self.instrument_box.instruments:
92 | if instrument.is_ready():
93 | application = instrument.bootstrap_after(application)
94 |
95 | return self.bootstrap_after(application)
96 |
97 | def bootstrap_before(self) -> dict[str, typing.Any]:
98 | """Add some framework-related parameters to final bootstrap result before application creation."""
99 | return {}
100 |
101 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
102 | """Add some framework-related parameters to final bootstrap result after application creation."""
103 | return application
104 |
105 | def teardown(self) -> None:
106 | for instrument in self.instrument_box.instruments:
107 | if instrument.is_ready():
108 | instrument.teardown()
109 |
--------------------------------------------------------------------------------
/microbootstrap/bootstrappers/fastapi.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import typing
3 |
4 | import fastapi
5 | from fastapi.middleware.cors import CORSMiddleware
6 | from fastapi_offline_docs import enable_offline_docs
7 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
8 | from prometheus_fastapi_instrumentator import Instrumentator
9 |
10 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper
11 | from microbootstrap.config.fastapi import FastApiConfig
12 | from microbootstrap.instruments.cors_instrument import CorsInstrument
13 | from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict
14 | from microbootstrap.instruments.logging_instrument import LoggingInstrument
15 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
16 | from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig, PrometheusInstrument
17 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
18 | from microbootstrap.instruments.sentry_instrument import SentryInstrument
19 | from microbootstrap.instruments.swagger_instrument import SwaggerInstrument
20 | from microbootstrap.middlewares.fastapi import build_fastapi_logging_middleware
21 | from microbootstrap.settings import FastApiSettings
22 |
23 |
24 | ApplicationT = typing.TypeVar("ApplicationT", bound=fastapi.FastAPI)
25 |
26 |
27 | class FastApiBootstrapper(
28 | ApplicationBootstrapper[FastApiSettings, fastapi.FastAPI, FastApiConfig],
29 | ):
30 | application_config = FastApiConfig()
31 | application_type = fastapi.FastAPI
32 |
33 | @contextlib.asynccontextmanager
34 | async def _lifespan_manager(self, _: fastapi.FastAPI) -> typing.AsyncIterator[None]:
35 | try:
36 | self.console_writer.print_bootstrap_table()
37 | yield
38 | finally:
39 | self.teardown()
40 |
41 | @contextlib.asynccontextmanager
42 | async def _wrapped_lifespan_manager(self, app: fastapi.FastAPI) -> typing.AsyncIterator[None]:
43 | assert self.application_config.lifespan # noqa: S101
44 | async with self._lifespan_manager(app), self.application_config.lifespan(app):
45 | yield None
46 |
47 | def bootstrap_before(self) -> dict[str, typing.Any]:
48 | return {
49 | "debug": self.settings.service_debug,
50 | "lifespan": self._wrapped_lifespan_manager if self.application_config.lifespan else self._lifespan_manager,
51 | }
52 |
53 |
54 | FastApiBootstrapper.use_instrument()(SentryInstrument)
55 |
56 |
57 | @FastApiBootstrapper.use_instrument()
58 | class FastApiSwaggerInstrument(SwaggerInstrument):
59 | def bootstrap_before(self) -> dict[str, typing.Any]:
60 | return {
61 | "title": self.instrument_config.service_name,
62 | "description": self.instrument_config.service_description,
63 | "docs_url": self.instrument_config.swagger_path,
64 | "version": self.instrument_config.service_version,
65 | }
66 |
67 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
68 | if self.instrument_config.swagger_offline_docs:
69 | enable_offline_docs(application, static_files_handler=self.instrument_config.service_static_path)
70 | return application
71 |
72 |
73 | @FastApiBootstrapper.use_instrument()
74 | class FastApiCorsInstrument(CorsInstrument):
75 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
76 | application.add_middleware(
77 | CORSMiddleware,
78 | allow_origins=self.instrument_config.cors_allowed_origins,
79 | allow_methods=self.instrument_config.cors_allowed_methods,
80 | allow_headers=self.instrument_config.cors_allowed_headers,
81 | allow_credentials=self.instrument_config.cors_allowed_credentials,
82 | allow_origin_regex=self.instrument_config.cors_allowed_origin_regex,
83 | expose_headers=self.instrument_config.cors_exposed_headers,
84 | max_age=self.instrument_config.cors_max_age,
85 | )
86 | return application
87 |
88 |
89 | FastApiBootstrapper.use_instrument()(PyroscopeInstrument)
90 |
91 |
92 | @FastApiBootstrapper.use_instrument()
93 | class FastApiOpentelemetryInstrument(OpentelemetryInstrument):
94 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
95 | FastAPIInstrumentor.instrument_app(
96 | application,
97 | tracer_provider=self.tracer_provider,
98 | excluded_urls=",".join(self.define_exclude_urls()),
99 | )
100 | return application
101 |
102 |
103 | @FastApiBootstrapper.use_instrument()
104 | class FastApiLoggingInstrument(LoggingInstrument):
105 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
106 | if not self.instrument_config.logging_turn_off_middleware:
107 | application.add_middleware( # type: ignore[call-arg]
108 | build_fastapi_logging_middleware(self.instrument_config.logging_exclude_endpoints), # type: ignore[arg-type]
109 | )
110 | return application
111 |
112 |
113 | @FastApiBootstrapper.use_instrument()
114 | class FastApiPrometheusInstrument(PrometheusInstrument[FastApiPrometheusConfig]):
115 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
116 | Instrumentator(**self.instrument_config.prometheus_instrumentator_params).instrument(
117 | application,
118 | **self.instrument_config.prometheus_instrument_params,
119 | ).expose(
120 | application,
121 | endpoint=self.instrument_config.prometheus_metrics_path,
122 | include_in_schema=self.instrument_config.prometheus_metrics_include_in_schema,
123 | **self.instrument_config.prometheus_expose_params,
124 | )
125 | return application
126 |
127 | @classmethod
128 | def get_config_type(cls) -> type[FastApiPrometheusConfig]:
129 | return FastApiPrometheusConfig
130 |
131 |
132 | @FastApiBootstrapper.use_instrument()
133 | class FastApiHealthChecksInstrument(HealthChecksInstrument):
134 | def build_fastapi_health_check_router(self) -> fastapi.APIRouter:
135 | fastapi_router: typing.Final = fastapi.APIRouter(
136 | tags=["probes"],
137 | include_in_schema=self.instrument_config.health_checks_include_in_schema,
138 | )
139 |
140 | @fastapi_router.get(self.instrument_config.health_checks_path)
141 | async def health_check_handler() -> HealthCheckTypedDict:
142 | return self.render_health_check_data()
143 |
144 | return fastapi_router
145 |
146 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
147 | application.include_router(self.build_fastapi_health_check_router())
148 | return application
149 |
--------------------------------------------------------------------------------
/microbootstrap/bootstrappers/faststream.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import json
3 | import typing
4 |
5 | import prometheus_client
6 | import structlog
7 | import typing_extensions
8 | from faststream.asgi import AsgiFastStream, AsgiResponse
9 | from faststream.asgi import get as handle_get
10 |
11 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper
12 | from microbootstrap.config.faststream import FastStreamConfig
13 | from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument
14 | from microbootstrap.instruments.logging_instrument import LoggingInstrument
15 | from microbootstrap.instruments.opentelemetry_instrument import (
16 | BaseOpentelemetryInstrument,
17 | FastStreamOpentelemetryConfig,
18 | )
19 | from microbootstrap.instruments.prometheus_instrument import FastStreamPrometheusConfig, PrometheusInstrument
20 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
21 | from microbootstrap.instruments.sentry_instrument import SentryInstrument
22 | from microbootstrap.settings import FastStreamSettings
23 |
24 |
25 | class KwargsAsgiFastStream(AsgiFastStream):
26 | def __init__(self, **kwargs: typing.Any) -> None: # noqa: ANN401
27 | # `broker` argument is positional-only
28 | super().__init__(kwargs.pop("broker", None), **kwargs)
29 |
30 |
31 | class FastStreamBootstrapper(ApplicationBootstrapper[FastStreamSettings, AsgiFastStream, FastStreamConfig]):
32 | application_config = FastStreamConfig()
33 | application_type = KwargsAsgiFastStream
34 |
35 | def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
36 | return {
37 | "title": self.settings.service_name,
38 | "version": self.settings.service_version,
39 | "description": self.settings.service_description,
40 | "on_shutdown": [self.teardown],
41 | "on_startup": [self.console_writer.print_bootstrap_table],
42 | "asyncapi_path": self.settings.asyncapi_path,
43 | }
44 |
45 |
46 | FastStreamBootstrapper.use_instrument()(SentryInstrument)
47 | FastStreamBootstrapper.use_instrument()(PyroscopeInstrument)
48 |
49 |
50 | @FastStreamBootstrapper.use_instrument()
51 | class FastStreamOpentelemetryInstrument(BaseOpentelemetryInstrument[FastStreamOpentelemetryConfig]):
52 | def is_ready(self) -> bool:
53 | return bool(self.instrument_config.opentelemetry_middleware_cls and super().is_ready())
54 |
55 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override]
56 | if self.instrument_config.opentelemetry_middleware_cls and application.broker:
57 | application.broker.add_middleware(
58 | self.instrument_config.opentelemetry_middleware_cls(tracer_provider=self.tracer_provider)
59 | )
60 | return application
61 |
62 | @classmethod
63 | def get_config_type(cls) -> type[FastStreamOpentelemetryConfig]:
64 | return FastStreamOpentelemetryConfig
65 |
66 |
67 | @FastStreamBootstrapper.use_instrument()
68 | class FastStreamLoggingInstrument(LoggingInstrument):
69 | def bootstrap_before(self) -> dict[str, typing.Any]:
70 | return {"logger": structlog.get_logger("microbootstrap-faststream")}
71 |
72 |
73 | @FastStreamBootstrapper.use_instrument()
74 | class FastStreamPrometheusInstrument(PrometheusInstrument[FastStreamPrometheusConfig]):
75 | def is_ready(self) -> bool:
76 | return bool(self.instrument_config.prometheus_middleware_cls and super().is_ready())
77 |
78 | def bootstrap_before(self) -> dict[str, typing.Any]:
79 | self.collector_registry = prometheus_client.CollectorRegistry()
80 | return {
81 | "asgi_routes": (
82 | (
83 | self.instrument_config.prometheus_metrics_path,
84 | prometheus_client.make_asgi_app(self.collector_registry),
85 | ),
86 | )
87 | }
88 |
89 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override]
90 | if self.instrument_config.prometheus_middleware_cls and application.broker:
91 | application.broker.add_middleware(
92 | self.instrument_config.prometheus_middleware_cls(registry=self.collector_registry)
93 | )
94 | return application
95 |
96 | @classmethod
97 | def get_config_type(cls) -> type[FastStreamPrometheusConfig]:
98 | return FastStreamPrometheusConfig
99 |
100 |
101 | @FastStreamBootstrapper.use_instrument()
102 | class FastStreamHealthChecksInstrument(HealthChecksInstrument):
103 | def bootstrap(self) -> None: ...
104 | def bootstrap_before(self) -> dict[str, typing.Any]:
105 | @handle_get
106 | async def check_health(scope: typing.Any) -> AsgiResponse: # noqa: ANN401, ARG001
107 | return (
108 | AsgiResponse(
109 | json.dumps(self.render_health_check_data()).encode(), 200, headers={"content-type": "text/plain"}
110 | )
111 | if await self.define_health_status()
112 | else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "application/json"})
113 | )
114 |
115 | return {"asgi_routes": ((self.instrument_config.health_checks_path, check_health),)}
116 |
117 | async def define_health_status(self) -> bool:
118 | return await self.application.broker.ping(timeout=5) if self.application and self.application.broker else False
119 |
120 | def bootstrap_after(self, application: AsgiFastStream) -> AsgiFastStream: # type: ignore[override]
121 | self.application = application
122 | return application
123 |
--------------------------------------------------------------------------------
/microbootstrap/bootstrappers/litestar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import typing
3 |
4 | import litestar
5 | import litestar.exceptions
6 | import litestar.types
7 | import typing_extensions
8 | from litestar import openapi
9 | from litestar.config.cors import CORSConfig as LitestarCorsConfig
10 | from litestar.contrib.opentelemetry.config import OpenTelemetryConfig as LitestarOpentelemetryConfig
11 | from litestar.contrib.prometheus import PrometheusConfig, PrometheusController
12 | from litestar.openapi.plugins import SwaggerRenderPlugin
13 | from litestar_offline_docs import generate_static_files_config
14 | from sentry_sdk.integrations.litestar import LitestarIntegration
15 |
16 | from microbootstrap.bootstrappers.base import ApplicationBootstrapper
17 | from microbootstrap.config.litestar import LitestarConfig
18 | from microbootstrap.instruments.cors_instrument import CorsInstrument
19 | from microbootstrap.instruments.health_checks_instrument import HealthChecksInstrument, HealthCheckTypedDict
20 | from microbootstrap.instruments.logging_instrument import LoggingInstrument
21 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
22 | from microbootstrap.instruments.prometheus_instrument import LitestarPrometheusConfig, PrometheusInstrument
23 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
24 | from microbootstrap.instruments.sentry_instrument import SentryInstrument
25 | from microbootstrap.instruments.swagger_instrument import SwaggerInstrument
26 | from microbootstrap.middlewares.litestar import build_litestar_logging_middleware
27 | from microbootstrap.settings import LitestarSettings
28 |
29 |
30 | class LitestarBootstrapper(
31 | ApplicationBootstrapper[LitestarSettings, litestar.Litestar, LitestarConfig],
32 | ):
33 | application_config = LitestarConfig()
34 | application_type = litestar.Litestar
35 |
36 | def bootstrap_before(self: typing_extensions.Self) -> dict[str, typing.Any]:
37 | return {
38 | "debug": self.settings.service_debug,
39 | "on_shutdown": [self.teardown],
40 | "on_startup": [self.console_writer.print_bootstrap_table],
41 | }
42 |
43 |
44 | @LitestarBootstrapper.use_instrument()
45 | class LitestarSentryInstrument(SentryInstrument):
46 | def bootstrap(self) -> None:
47 | for sentry_integration in self.instrument_config.sentry_integrations:
48 | if isinstance(sentry_integration, LitestarIntegration):
49 | break
50 | else:
51 | self.instrument_config.sentry_integrations.append(LitestarIntegration())
52 | super().bootstrap()
53 |
54 |
55 | @LitestarBootstrapper.use_instrument()
56 | class LitestarSwaggerInstrument(SwaggerInstrument):
57 | def bootstrap_before(self) -> dict[str, typing.Any]:
58 | render_plugins: typing.Final = (
59 | (
60 | SwaggerRenderPlugin(
61 | js_url=f"{self.instrument_config.service_static_path}/swagger-ui-bundle.js",
62 | css_url=f"{self.instrument_config.service_static_path}/swagger-ui.css",
63 | standalone_preset_js_url=(
64 | f"{self.instrument_config.service_static_path}/swagger-ui-standalone-preset.js"
65 | ),
66 | ),
67 | )
68 | if self.instrument_config.swagger_offline_docs
69 | else (SwaggerRenderPlugin(),)
70 | )
71 |
72 | all_swagger_params: typing.Final = {
73 | "path": self.instrument_config.swagger_path,
74 | "title": self.instrument_config.service_name,
75 | "version": self.instrument_config.service_version,
76 | "description": self.instrument_config.service_description,
77 | "render_plugins": render_plugins,
78 | } | self.instrument_config.swagger_extra_params
79 |
80 | bootstrap_result: typing.Final[dict[str, typing.Any]] = {
81 | "openapi_config": openapi.OpenAPIConfig(**all_swagger_params),
82 | }
83 | if self.instrument_config.swagger_offline_docs:
84 | bootstrap_result["static_files_config"] = [
85 | generate_static_files_config(static_files_handler_path=self.instrument_config.service_static_path),
86 | ]
87 | return bootstrap_result
88 |
89 |
90 | @LitestarBootstrapper.use_instrument()
91 | class LitestarCorsInstrument(CorsInstrument):
92 | def bootstrap_before(self) -> dict[str, typing.Any]:
93 | return {
94 | "cors_config": LitestarCorsConfig(
95 | allow_origins=self.instrument_config.cors_allowed_origins,
96 | allow_methods=self.instrument_config.cors_allowed_methods, # type: ignore[arg-type]
97 | allow_headers=self.instrument_config.cors_allowed_headers,
98 | allow_credentials=self.instrument_config.cors_allowed_credentials,
99 | allow_origin_regex=self.instrument_config.cors_allowed_origin_regex,
100 | expose_headers=self.instrument_config.cors_exposed_headers,
101 | max_age=self.instrument_config.cors_max_age,
102 | ),
103 | }
104 |
105 |
106 | LitestarBootstrapper.use_instrument()(PyroscopeInstrument)
107 |
108 |
109 | @LitestarBootstrapper.use_instrument()
110 | class LitestarOpentelemetryInstrument(OpentelemetryInstrument):
111 | def bootstrap_before(self) -> dict[str, typing.Any]:
112 | return {
113 | "middleware": [
114 | LitestarOpentelemetryConfig(
115 | tracer_provider=self.tracer_provider,
116 | exclude=self.define_exclude_urls(),
117 | ).middleware,
118 | ],
119 | }
120 |
121 |
122 | @LitestarBootstrapper.use_instrument()
123 | class LitestarLoggingInstrument(LoggingInstrument):
124 | def bootstrap_before(self) -> dict[str, typing.Any]:
125 | if self.instrument_config.logging_turn_off_middleware:
126 | return {}
127 |
128 | return {"middleware": [build_litestar_logging_middleware(self.instrument_config.logging_exclude_endpoints)]}
129 |
130 |
131 | @LitestarBootstrapper.use_instrument()
132 | class LitestarPrometheusInstrument(PrometheusInstrument[LitestarPrometheusConfig]):
133 | def bootstrap_before(self) -> dict[str, typing.Any]:
134 | class LitestarPrometheusController(PrometheusController):
135 | path = self.instrument_config.prometheus_metrics_path
136 | include_in_schema = self.instrument_config.prometheus_metrics_include_in_schema
137 | openmetrics_format = True
138 |
139 | litestar_prometheus_config: typing.Final = PrometheusConfig(
140 | app_name=self.instrument_config.service_name,
141 | **self.instrument_config.prometheus_additional_params,
142 | )
143 |
144 | return {"route_handlers": [LitestarPrometheusController], "middleware": [litestar_prometheus_config.middleware]}
145 |
146 | @classmethod
147 | def get_config_type(cls) -> type[LitestarPrometheusConfig]:
148 | return LitestarPrometheusConfig
149 |
150 |
151 | @LitestarBootstrapper.use_instrument()
152 | class LitestarHealthChecksInstrument(HealthChecksInstrument):
153 | def build_litestar_health_check_router(self) -> litestar.Router:
154 | @litestar.get(media_type=litestar.MediaType.JSON)
155 | async def health_check_handler() -> HealthCheckTypedDict:
156 | return self.render_health_check_data()
157 |
158 | return litestar.Router(
159 | path=self.instrument_config.health_checks_path,
160 | route_handlers=[health_check_handler],
161 | tags=["probes"],
162 | include_in_schema=self.instrument_config.health_checks_include_in_schema,
163 | )
164 |
165 | def bootstrap_before(self) -> dict[str, typing.Any]:
166 | return {"route_handlers": [self.build_litestar_health_check_router()]}
167 |
--------------------------------------------------------------------------------
/microbootstrap/config/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/microbootstrap/config/__init__.py
--------------------------------------------------------------------------------
/microbootstrap/config/fastapi.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import dataclasses
3 | import typing
4 |
5 | from fastapi.datastructures import Default
6 | from fastapi.utils import generate_unique_id
7 | from starlette.responses import JSONResponse
8 |
9 |
10 | if typing.TYPE_CHECKING:
11 | from fastapi import Request, routing
12 | from fastapi.applications import AppType
13 | from fastapi.middleware import Middleware
14 | from fastapi.params import Depends
15 | from starlette.responses import Response
16 | from starlette.routing import BaseRoute
17 | from starlette.types import Lifespan
18 |
19 |
20 | @dataclasses.dataclass
21 | class FastApiConfig:
22 | debug: bool = False
23 | routes: list[BaseRoute] | None = None
24 | title: str = "FastAPI"
25 | summary: str | None = None
26 | description: str = ""
27 | version: str = "0.1.0"
28 | openapi_url: str | None = "/openapi.json"
29 | openapi_tags: list[dict[str, typing.Any]] | None = None
30 | servers: list[dict[str, str | typing.Any]] | None = None
31 | dependencies: typing.Sequence[Depends] | None = None
32 | default_response_class: type[Response] = dataclasses.field(default_factory=lambda: Default(JSONResponse))
33 | redirect_slashes: bool = True
34 | docs_url: str | None = "/docs"
35 | redoc_url: str | None = "/redoc"
36 | swagger_ui_oauth2_redirect_url: str | None = "/docs/oauth2-redirect"
37 | swagger_ui_init_oauth: dict[str, typing.Any] | None = None
38 | middleware: typing.Sequence[Middleware] | None = None
39 | exception_handlers: (
40 | dict[
41 | int | type[Exception],
42 | typing.Callable[[Request, typing.Any], typing.Coroutine[typing.Any, typing.Any, Response]],
43 | ]
44 | | None
45 | ) = None
46 | on_startup: typing.Sequence[typing.Callable[[], typing.Any]] | None = None
47 | on_shutdown: typing.Sequence[typing.Callable[[], typing.Any]] | None = None
48 | lifespan: Lifespan[AppType] | None = None # type: ignore[valid-type]
49 | terms_of_service: str | None = None
50 | contact: dict[str, str | typing.Any] | None = None
51 | license_info: dict[str, str | typing.Any] | None = None
52 | openapi_prefix: str = ""
53 | root_path: str = ""
54 | root_path_in_servers: bool = True
55 | responses: dict[int | str, dict[str, typing.Any]] | None = None
56 | callbacks: list[BaseRoute] | None = None
57 | webhooks: routing.APIRouter | None = None
58 | deprecated: bool | None = None
59 | include_in_schema: bool = True
60 | swagger_ui_parameters: dict[str, typing.Any] | None = None
61 | generate_unique_id_function: typing.Callable[[routing.APIRoute], str] = dataclasses.field(
62 | default_factory=lambda: Default(generate_unique_id),
63 | )
64 | separate_input_output_schemas: bool = True
65 |
--------------------------------------------------------------------------------
/microbootstrap/config/faststream.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import dataclasses
3 | import typing
4 |
5 |
6 | if typing.TYPE_CHECKING:
7 | import faststream.asyncapi.schema as asyncapi
8 | from faststream.asgi.types import ASGIApp
9 | from faststream.broker.core.usecase import BrokerUsecase
10 | from faststream.types import AnyDict, AnyHttpUrl, Lifespan
11 |
12 |
13 | @dataclasses.dataclass
14 | class FastStreamConfig:
15 | broker: BrokerUsecase[typing.Any, typing.Any] | None = None
16 | asgi_routes: typing.Sequence[tuple[str, ASGIApp]] = ()
17 | lifespan: Lifespan | None = None
18 | terms_of_service: AnyHttpUrl | None = None
19 | license: asyncapi.License | asyncapi.LicenseDict | AnyDict | None = None
20 | contact: asyncapi.Contact | asyncapi.ContactDict | AnyDict | None = None
21 | tags: typing.Sequence[asyncapi.Tag | asyncapi.TagDict | AnyDict] | None = None
22 | external_docs: asyncapi.ExternalDocs | asyncapi.ExternalDocsDict | AnyDict | None = None
23 | identifier: str | None = None
24 | on_startup: typing.Sequence[typing.Callable[..., typing.Any]] = ()
25 | after_startup: typing.Sequence[typing.Callable[..., typing.Any]] = ()
26 | on_shutdown: typing.Sequence[typing.Callable[..., typing.Any]] = ()
27 | after_shutdown: typing.Sequence[typing.Callable[..., typing.Any]] = ()
28 |
--------------------------------------------------------------------------------
/microbootstrap/config/litestar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import dataclasses
3 | import typing
4 |
5 | from litestar.config.app import AppConfig
6 |
7 |
8 | if typing.TYPE_CHECKING:
9 | from litestar.types import OnAppInitHandler
10 |
11 |
12 | @dataclasses.dataclass
13 | class LitestarConfig(AppConfig):
14 | on_app_init: typing.Sequence[OnAppInitHandler] | None = None
15 |
--------------------------------------------------------------------------------
/microbootstrap/console_writer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import dataclasses
3 | import typing
4 |
5 | from rich.console import Console
6 | from rich.rule import Rule
7 | from rich.table import Table
8 |
9 |
10 | @dataclasses.dataclass
11 | class ConsoleWriter:
12 | writer_enabled: bool = True
13 | rich_console: Console = dataclasses.field(init=False, default_factory=Console)
14 | rich_table: Table = dataclasses.field(init=False)
15 |
16 | def __post_init__(self) -> None:
17 | self.rich_table = Table(show_header=False, header_style="cyan")
18 | self.rich_table.add_column("Item", style="cyan")
19 | self.rich_table.add_column("Status")
20 | self.rich_table.add_column("Reason", style="yellow")
21 |
22 | def write_instrument_status(
23 | self,
24 | instrument_name: str,
25 | is_enabled: bool,
26 | disable_reason: str | None = None,
27 | ) -> None:
28 | is_enabled_value: typing.Final = "[green]Enabled[/green]" if is_enabled else "[red]Disabled[/red]"
29 | self.rich_table.add_row(rf"{instrument_name}", is_enabled_value, disable_reason or "")
30 |
31 | def print_bootstrap_table(self) -> None:
32 | if self.writer_enabled:
33 | self.rich_console.print(Rule("[yellow]Bootstrapping application[/yellow]", align="left"))
34 | self.rich_console.print(self.rich_table)
35 |
--------------------------------------------------------------------------------
/microbootstrap/exceptions.py:
--------------------------------------------------------------------------------
1 | class MicroBootstrapBaseError(Exception):
2 | """Base for all exceptions."""
3 |
4 |
5 | class ConfigMergeError(MicroBootstrapBaseError):
6 | """Raises when it's impossible to merge configs due to type mismatch."""
7 |
8 |
9 | class MissingInstrumentError(MicroBootstrapBaseError):
10 | """Raises when attempting to configure instrument, that is not supported yet."""
11 |
--------------------------------------------------------------------------------
/microbootstrap/granian_server.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import logging
3 | import typing
4 |
5 | import granian
6 | from granian.constants import Interfaces
7 | from granian.log import LogLevels
8 |
9 |
10 | if typing.TYPE_CHECKING:
11 | from microbootstrap.settings import ServerConfig
12 |
13 |
14 | GRANIAN_LOG_LEVELS_MAP = {
15 | logging.CRITICAL: LogLevels.critical,
16 | logging.ERROR: LogLevels.error,
17 | logging.WARNING: LogLevels.warning,
18 | logging.WARNING: LogLevels.warn,
19 | logging.INFO: LogLevels.info,
20 | logging.DEBUG: LogLevels.debug,
21 | }
22 |
23 |
24 | # TODO: create bootstrappers for application servers. granian/uvicorn # noqa: TD002
25 | def create_granian_server(
26 | target: str,
27 | settings: ServerConfig,
28 | **granian_options: typing.Any, # noqa: ANN401
29 | ) -> granian.Granian: # type: ignore[name-defined]
30 | return granian.Granian( # type: ignore[attr-defined]
31 | target=target,
32 | address=settings.server_host,
33 | port=settings.server_port,
34 | interface=Interfaces.ASGI,
35 | workers=settings.server_workers_count,
36 | log_level=GRANIAN_LOG_LEVELS_MAP[getattr(settings, "logging_log_level", logging.INFO)],
37 | reload=settings.server_reload,
38 | **granian_options,
39 | )
40 |
--------------------------------------------------------------------------------
/microbootstrap/helpers.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import re
3 | import typing
4 | from dataclasses import _MISSING_TYPE
5 |
6 | from microbootstrap import exceptions
7 |
8 |
9 | if typing.TYPE_CHECKING:
10 | from dataclasses import _DataclassT
11 |
12 | from pydantic import BaseModel
13 |
14 |
15 | PydanticConfigT = typing.TypeVar("PydanticConfigT", bound="BaseModel")
16 | VALID_PATH_PATTERN: typing.Final = r"^(/[a-zA-Z0-9_-]+)+/?$"
17 |
18 |
19 | def dataclass_to_dict_no_defaults(dataclass_to_convert: "_DataclassT") -> dict[str, typing.Any]:
20 | conversion_result: typing.Final = {}
21 | for dataclass_field in dataclasses.fields(dataclass_to_convert):
22 | value = getattr(dataclass_to_convert, dataclass_field.name)
23 | if isinstance(dataclass_field.default, _MISSING_TYPE):
24 | conversion_result[dataclass_field.name] = value
25 | continue
26 | if dataclass_field.default != value and isinstance(dataclass_field.default_factory, _MISSING_TYPE):
27 | conversion_result[dataclass_field.name] = value
28 | continue
29 | if value != dataclass_field.default and value != dataclass_field.default_factory(): # type: ignore[misc]
30 | conversion_result[dataclass_field.name] = value
31 |
32 | return conversion_result
33 |
34 |
35 | def merge_pydantic_configs(
36 | config_to_merge: PydanticConfigT,
37 | config_with_changes: PydanticConfigT,
38 | ) -> PydanticConfigT:
39 | initial_fields: typing.Final = dict(config_to_merge)
40 | changed_fields: typing.Final = {
41 | one_field_name: getattr(config_with_changes, one_field_name)
42 | for one_field_name in config_with_changes.model_fields_set
43 | }
44 | merged_fields: typing.Final = merge_dict_configs(initial_fields, changed_fields)
45 | return config_to_merge.model_copy(update=merged_fields)
46 |
47 |
48 | def merge_dataclasses_configs(
49 | config_to_merge: "_DataclassT",
50 | config_with_changes: "_DataclassT",
51 | ) -> "_DataclassT":
52 | config_class: typing.Final = config_to_merge.__class__
53 | resulting_dict_config: typing.Final = merge_dict_configs(
54 | dataclass_to_dict_no_defaults(config_to_merge),
55 | dataclass_to_dict_no_defaults(config_with_changes),
56 | )
57 | return config_class(**resulting_dict_config)
58 |
59 |
60 | def merge_dict_configs(
61 | config_dict: dict[str, typing.Any],
62 | changes_dict: dict[str, typing.Any],
63 | ) -> dict[str, typing.Any]:
64 | for change_key, change_value in changes_dict.items():
65 | config_value = config_dict.get(change_key)
66 |
67 | if isinstance(config_value, set):
68 | if not isinstance(change_value, set):
69 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
70 | config_dict[change_key] = {*config_value, *change_value}
71 | continue
72 |
73 | if isinstance(config_value, tuple):
74 | if not isinstance(change_value, tuple):
75 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
76 | config_dict[change_key] = (*config_value, *change_value)
77 | continue
78 |
79 | if isinstance(config_value, list):
80 | if not isinstance(change_value, list):
81 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
82 | config_dict[change_key] = [*config_value, *change_value]
83 | continue
84 |
85 | if isinstance(config_value, dict):
86 | if not isinstance(change_value, dict):
87 | raise exceptions.ConfigMergeError(f"Can't merge {config_value} and {change_value}")
88 | config_dict[change_key] = {**config_value, **change_value}
89 | continue
90 |
91 | config_dict[change_key] = change_value
92 |
93 | return config_dict
94 |
95 |
96 | def is_valid_path(maybe_path: str) -> bool:
97 | return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path))
98 |
99 |
100 | def optimize_exclude_paths(
101 | exclude_endpoints: typing.Iterable[str],
102 | ) -> typing.Collection[str]:
103 | # `in` operator is faster for tuples than for lists
104 | endpoints_to_ignore: typing.Collection[str] = tuple(exclude_endpoints)
105 |
106 | # 10 is just an empirical value, based of measuring the performance
107 | # iterating over a tuple of <10 elements is faster than hashing
108 | if len(endpoints_to_ignore) >= 10: # noqa: PLR2004
109 | endpoints_to_ignore = set(endpoints_to_ignore)
110 |
111 | return endpoints_to_ignore
112 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/microbootstrap/instruments/__init__.py
--------------------------------------------------------------------------------
/microbootstrap/instruments/base.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import abc
3 | import dataclasses
4 | import typing
5 |
6 | import pydantic
7 |
8 | from microbootstrap.helpers import merge_pydantic_configs
9 |
10 |
11 | if typing.TYPE_CHECKING:
12 | from microbootstrap.console_writer import ConsoleWriter
13 |
14 |
15 | InstrumentConfigT = typing.TypeVar("InstrumentConfigT", bound="BaseInstrumentConfig")
16 | ApplicationT = typing.TypeVar("ApplicationT", bound=typing.Any)
17 |
18 |
19 | class BaseInstrumentConfig(pydantic.BaseModel):
20 | model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
21 |
22 |
23 | @dataclasses.dataclass
24 | class Instrument(abc.ABC, typing.Generic[InstrumentConfigT]):
25 | instrument_config: InstrumentConfigT
26 | instrument_name: typing.ClassVar[str]
27 | ready_condition: typing.ClassVar[str]
28 |
29 | def configure_instrument(
30 | self,
31 | incoming_config: InstrumentConfigT,
32 | ) -> None:
33 | self.instrument_config = merge_pydantic_configs(self.instrument_config, incoming_config)
34 |
35 | def write_status(self, console_writer: ConsoleWriter) -> None:
36 | console_writer.write_instrument_status(
37 | self.instrument_name,
38 | is_enabled=self.is_ready(),
39 | disable_reason=None if self.is_ready() else self.ready_condition,
40 | )
41 |
42 | @abc.abstractmethod
43 | def is_ready(self) -> bool: ...
44 |
45 | @classmethod
46 | @abc.abstractmethod
47 | def get_config_type(cls) -> type[InstrumentConfigT]:
48 | raise NotImplementedError
49 |
50 | def bootstrap(self) -> None:
51 | return None
52 |
53 | def teardown(self) -> None:
54 | return None
55 |
56 | def bootstrap_before(self) -> dict[str, typing.Any]:
57 | """Add some framework-related parameters to final bootstrap result before application creation."""
58 | return {}
59 |
60 | def bootstrap_after(self, application: ApplicationT) -> ApplicationT:
61 | """Add some framework-related parameters to final bootstrap result after application creation."""
62 | return application
63 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/cors_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import pydantic
4 |
5 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
6 |
7 |
8 | class CorsConfig(BaseInstrumentConfig):
9 | cors_allowed_origins: list[str] = pydantic.Field(default_factory=list)
10 | cors_allowed_methods: list[str] = pydantic.Field(default_factory=list)
11 | cors_allowed_headers: list[str] = pydantic.Field(default_factory=list)
12 | cors_exposed_headers: list[str] = pydantic.Field(default_factory=list)
13 | cors_allowed_credentials: bool = False
14 | cors_allowed_origin_regex: str | None = None
15 | cors_max_age: int = 600
16 |
17 |
18 | class CorsInstrument(Instrument[CorsConfig]):
19 | instrument_name = "Cors"
20 | ready_condition = "Provide allowed origins or regex"
21 |
22 | def is_ready(self) -> bool:
23 | return bool(self.instrument_config.cors_allowed_origins) or bool(
24 | self.instrument_config.cors_allowed_origin_regex,
25 | )
26 |
27 | @classmethod
28 | def get_config_type(cls) -> type[CorsConfig]:
29 | return CorsConfig
30 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/health_checks_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import typing
3 |
4 | import typing_extensions
5 |
6 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
7 |
8 |
9 | class HealthCheckTypedDict(typing_extensions.TypedDict, total=False):
10 | service_version: typing.Optional[str] # noqa: UP007 (Litestar fails to build OpenAPI schema on Python 3.9)
11 | service_name: typing.Optional[str] # noqa: UP007 (Litestar fails to build OpenAPI schema on Python 3.9)
12 | health_status: bool
13 |
14 |
15 | class HealthChecksConfig(BaseInstrumentConfig):
16 | service_name: str = "micro-service"
17 | service_version: str = "1.0.0"
18 |
19 | health_checks_enabled: bool = True
20 | health_checks_path: str = "/health/"
21 | health_checks_include_in_schema: bool = False
22 |
23 |
24 | class HealthChecksInstrument(Instrument[HealthChecksConfig]):
25 | instrument_name = "Health checks"
26 | ready_condition = "Set health_checks_enabled to True"
27 |
28 | def render_health_check_data(self) -> HealthCheckTypedDict:
29 | return {
30 | "service_version": self.instrument_config.service_version,
31 | "service_name": self.instrument_config.service_name,
32 | "health_status": True,
33 | }
34 |
35 | def is_ready(self) -> bool:
36 | return self.instrument_config.health_checks_enabled
37 |
38 | @classmethod
39 | def get_config_type(cls) -> type[HealthChecksConfig]:
40 | return HealthChecksConfig
41 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/instrument_box.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import typing
3 |
4 | from microbootstrap import exceptions
5 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT
6 | from microbootstrap.settings import SettingsT
7 |
8 |
9 | @dataclasses.dataclass
10 | class InstrumentBox:
11 | __instruments__: list[type[Instrument[typing.Any]]] = dataclasses.field(default_factory=list)
12 | __initialized_instruments__: list[Instrument[typing.Any]] = dataclasses.field(default_factory=list)
13 |
14 | def initialize(self, settings: SettingsT) -> None:
15 | settings_dump = settings.model_dump()
16 | self.__initialized_instruments__ = [
17 | instrument_type(instrument_type.get_config_type()(**settings_dump))
18 | for instrument_type in self.__instruments__
19 | ]
20 |
21 | def configure_instrument(
22 | self,
23 | instrument_config: InstrumentConfigT,
24 | ) -> None:
25 | for instrument in self.__initialized_instruments__:
26 | if isinstance(instrument_config, instrument.get_config_type()):
27 | instrument.configure_instrument(instrument_config)
28 | return
29 |
30 | raise exceptions.MissingInstrumentError(
31 | f"Instrument for config {instrument_config.__class__.__name__} is not supported yet.",
32 | )
33 |
34 | def extend_instruments(
35 | self,
36 | instrument_class: type[Instrument[InstrumentConfigT]],
37 | ) -> type[Instrument[InstrumentConfigT]]:
38 | """Extend list of instruments, excluding one whose config is already in use."""
39 | self.__instruments__ = list(
40 | filter(
41 | lambda instrument: instrument.get_config_type() is not instrument_class.get_config_type(),
42 | self.__instruments__,
43 | ),
44 | )
45 | self.__instruments__.append(instrument_class)
46 | return instrument_class
47 |
48 | @property
49 | def instruments(self) -> list[Instrument[typing.Any]]:
50 | return self.__initialized_instruments__
51 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/logging_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import logging
3 | import logging.handlers
4 | import time
5 | import typing
6 | import urllib.parse
7 |
8 | import pydantic
9 | import structlog
10 | import typing_extensions
11 | from opentelemetry import trace
12 |
13 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
14 |
15 |
16 | if typing.TYPE_CHECKING:
17 | import fastapi
18 | import litestar
19 | from structlog.typing import EventDict, WrappedLogger
20 |
21 |
22 | ScopeType = typing.MutableMapping[str, typing.Any]
23 |
24 | access_logger: typing.Final = structlog.get_logger("api.access")
25 |
26 |
27 | def make_path_with_query_string(scope: ScopeType) -> str:
28 | path_with_query_string: typing.Final = urllib.parse.quote(scope["path"])
29 | if scope["query_string"]:
30 | return f"{path_with_query_string}?{scope['query_string'].decode('ascii')}"
31 | return path_with_query_string
32 |
33 |
34 | def fill_log_message(
35 | log_level: str,
36 | request: litestar.Request[typing.Any, typing.Any, typing.Any] | fastapi.Request,
37 | status_code: int,
38 | start_time: int,
39 | ) -> None:
40 | process_time: typing.Final = time.perf_counter_ns() - start_time
41 | url_with_query: typing.Final = make_path_with_query_string(typing.cast("ScopeType", request.scope))
42 | client_host: typing.Final = request.client.host if request.client is not None else None
43 | client_port: typing.Final = request.client.port if request.client is not None else None
44 | http_method: typing.Final = request.method
45 | http_version: typing.Final = request.scope["http_version"]
46 | log_on_correct_level: typing.Final = getattr(access_logger, log_level)
47 | log_on_correct_level(
48 | http={
49 | "url": url_with_query,
50 | "status_code": status_code,
51 | "method": http_method,
52 | "version": http_version,
53 | },
54 | network={"client": {"ip": client_host, "port": client_port}},
55 | duration=process_time,
56 | )
57 |
58 |
59 | def tracer_injection(_: WrappedLogger, __: str, event_dict: EventDict) -> EventDict:
60 | current_span = trace.get_current_span()
61 | if not current_span.is_recording():
62 | event_dict["tracing"] = {}
63 | return event_dict
64 |
65 | current_span_context = current_span.get_span_context()
66 | event_dict["tracing"] = {
67 | "span_id": trace.format_span_id(current_span_context.span_id),
68 | "trace_id": trace.format_trace_id(current_span_context.trace_id),
69 | }
70 | return event_dict
71 |
72 |
73 | DEFAULT_STRUCTLOG_PROCESSORS: typing.Final[list[typing.Any]] = [
74 | structlog.stdlib.filter_by_level,
75 | structlog.stdlib.add_log_level,
76 | structlog.stdlib.add_logger_name,
77 | tracer_injection,
78 | structlog.stdlib.PositionalArgumentsFormatter(),
79 | structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
80 | structlog.processors.StackInfoRenderer(),
81 | structlog.processors.format_exc_info,
82 | structlog.processors.UnicodeDecoder(),
83 | ]
84 | DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR: typing.Final = structlog.processors.JSONRenderer()
85 |
86 |
87 | class MemoryLoggerFactory(structlog.stdlib.LoggerFactory):
88 | def __init__(
89 | self,
90 | *args: typing.Any, # noqa: ANN401
91 | logging_buffer_capacity: int,
92 | logging_flush_level: int,
93 | logging_log_level: int,
94 | log_stream: typing.Any = None, # noqa: ANN401
95 | **kwargs: typing.Any, # noqa: ANN401
96 | ) -> None:
97 | super().__init__(*args, **kwargs)
98 | self.logging_buffer_capacity = logging_buffer_capacity
99 | self.logging_flush_level = logging_flush_level
100 | self.logging_log_level = logging_log_level
101 | self.log_stream = log_stream
102 |
103 | def __call__(self, *args: typing.Any) -> logging.Logger: # noqa: ANN401
104 | logger: typing.Final = super().__call__(*args)
105 | stream_handler: typing.Final = logging.StreamHandler(stream=self.log_stream)
106 | handler: typing.Final = logging.handlers.MemoryHandler(
107 | capacity=self.logging_buffer_capacity,
108 | flushLevel=self.logging_flush_level,
109 | target=stream_handler,
110 | )
111 | logger.addHandler(handler)
112 | logger.setLevel(self.logging_log_level)
113 | logger.propagate = False
114 | return logger
115 |
116 |
117 | class LoggingConfig(BaseInstrumentConfig):
118 | service_debug: bool = True
119 |
120 | logging_log_level: int = logging.INFO
121 | logging_flush_level: int = logging.ERROR
122 | logging_buffer_capacity: int = 10
123 | logging_extra_processors: list[typing.Any] = pydantic.Field(default_factory=list)
124 | logging_unset_handlers: list[str] = pydantic.Field(
125 | default_factory=lambda: ["uvicorn", "uvicorn.access"],
126 | )
127 | logging_exclude_endpoints: list[str] = pydantic.Field(default_factory=lambda: ["/health/", "/metrics"])
128 | logging_turn_off_middleware: bool = False
129 |
130 | @pydantic.model_validator(mode="after")
131 | def remove_trailing_slashes_from_logging_exclude_endpoints(self) -> typing_extensions.Self:
132 | self.logging_exclude_endpoints = [
133 | one_endpoint.removesuffix("/") for one_endpoint in self.logging_exclude_endpoints
134 | ]
135 | return self
136 |
137 |
138 | class LoggingInstrument(Instrument[LoggingConfig]):
139 | instrument_name = "Logging"
140 | ready_condition = "Works only in non-debug mode"
141 |
142 | def is_ready(self) -> bool:
143 | return not self.instrument_config.service_debug
144 |
145 | def teardown(self) -> None:
146 | structlog.reset_defaults()
147 |
148 | def bootstrap(self) -> None:
149 | for unset_handlers_logger in self.instrument_config.logging_unset_handlers:
150 | logging.getLogger(unset_handlers_logger).handlers = []
151 |
152 | structlog.configure(
153 | processors=[
154 | *DEFAULT_STRUCTLOG_PROCESSORS,
155 | *self.instrument_config.logging_extra_processors,
156 | DEFAULT_STRUCTLOG_FORMATTER_PROCESSOR,
157 | ],
158 | context_class=dict,
159 | logger_factory=MemoryLoggerFactory(
160 | logging_buffer_capacity=self.instrument_config.logging_buffer_capacity,
161 | logging_flush_level=self.instrument_config.logging_flush_level,
162 | logging_log_level=self.instrument_config.logging_log_level,
163 | ),
164 | wrapper_class=structlog.stdlib.BoundLogger,
165 | cache_logger_on_first_use=True,
166 | )
167 |
168 | @classmethod
169 | def get_config_type(cls) -> type[LoggingConfig]:
170 | return LoggingConfig
171 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/opentelemetry_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import dataclasses
3 | import os
4 | import threading
5 | import typing
6 |
7 | import pydantic
8 | from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
9 | from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] # noqa: TC002
10 | from opentelemetry.sdk import resources
11 | from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
12 | from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
13 | from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
14 | from opentelemetry.trace import format_span_id, set_tracer_provider
15 | from opentelemetry.instrumentation import auto_instrumentation
16 |
17 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
18 |
19 |
20 | try:
21 | import pyroscope # type: ignore[import-untyped]
22 | except ImportError: # pragma: no cover
23 | pyroscope = None
24 |
25 |
26 | if typing.TYPE_CHECKING:
27 | import faststream
28 | from opentelemetry.context import Context
29 | from opentelemetry.metrics import Meter, MeterProvider
30 | from opentelemetry.trace import TracerProvider
31 |
32 |
33 | OpentelemetryConfigT = typing.TypeVar("OpentelemetryConfigT", bound="OpentelemetryConfig")
34 |
35 |
36 | @dataclasses.dataclass()
37 | class OpenTelemetryInstrumentor:
38 | instrumentor: BaseInstrumentor
39 | additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
40 |
41 |
42 | class OpentelemetryConfig(BaseInstrumentConfig):
43 | service_debug: bool = True
44 | service_name: str = "micro-service"
45 | service_version: str = "1.0.0"
46 | health_checks_path: str = "/health/"
47 | pyroscope_endpoint: pydantic.HttpUrl | None = None
48 |
49 | opentelemetry_service_name: str | None = None
50 | opentelemetry_container_name: str | None = None
51 | opentelemetry_endpoint: str | None = None
52 | opentelemetry_namespace: str | None = None
53 | opentelemetry_insecure: bool = pydantic.Field(default=True)
54 | opentelemetry_instrumentors: list[OpenTelemetryInstrumentor] = pydantic.Field(default_factory=list)
55 | opentelemetry_exclude_urls: list[str] = pydantic.Field(default=["/metrics"])
56 |
57 |
58 | @typing.runtime_checkable
59 | class FastStreamTelemetryMiddlewareProtocol(typing.Protocol):
60 | def __init__(
61 | self,
62 | *,
63 | tracer_provider: TracerProvider | None = None,
64 | meter_provider: MeterProvider | None = None,
65 | meter: Meter | None = None,
66 | ) -> None: ...
67 | def __call__(self, msg: typing.Any | None) -> faststream.BaseMiddleware: ... # noqa: ANN401
68 |
69 |
70 | class FastStreamOpentelemetryConfig(OpentelemetryConfig):
71 | opentelemetry_middleware_cls: type[FastStreamTelemetryMiddlewareProtocol] | None = None
72 |
73 |
74 | def _format_span(readable_span: ReadableSpan) -> str:
75 | return typing.cast("str", readable_span.to_json(indent=None)) + os.linesep
76 |
77 |
78 | class BaseOpentelemetryInstrument(Instrument[OpentelemetryConfigT]):
79 | instrument_name = "Opentelemetry"
80 | ready_condition = "Provide all necessary config parameters"
81 |
82 | def is_ready(self) -> bool:
83 | return bool(self.instrument_config.opentelemetry_endpoint) or self.instrument_config.service_debug
84 |
85 | def teardown(self) -> None:
86 | for instrumentor_with_params in self.instrument_config.opentelemetry_instrumentors:
87 | instrumentor_with_params.instrumentor.uninstrument(**instrumentor_with_params.additional_params)
88 |
89 | def bootstrap(self) -> None:
90 | auto_instrumentation.initialize()
91 |
92 | attributes = {
93 | resources.SERVICE_NAME: self.instrument_config.opentelemetry_service_name
94 | or self.instrument_config.service_name,
95 | resources.TELEMETRY_SDK_LANGUAGE: "python",
96 | resources.SERVICE_VERSION: self.instrument_config.service_version,
97 | }
98 | if self.instrument_config.opentelemetry_namespace:
99 | attributes[resources.SERVICE_NAMESPACE] = self.instrument_config.opentelemetry_namespace
100 | if self.instrument_config.opentelemetry_container_name:
101 | attributes[resources.CONTAINER_NAME] = self.instrument_config.opentelemetry_container_name
102 | resource: typing.Final = resources.Resource.create(attributes=attributes)
103 |
104 | self.tracer_provider = SdkTracerProvider(resource=resource)
105 | if self.instrument_config.pyroscope_endpoint and pyroscope:
106 | self.tracer_provider.add_span_processor(PyroscopeSpanProcessor())
107 |
108 | if self.instrument_config.service_debug:
109 | self.tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(formatter=_format_span)))
110 | if self.instrument_config.opentelemetry_endpoint:
111 | self.tracer_provider.add_span_processor(
112 | BatchSpanProcessor(
113 | OTLPSpanExporter(
114 | endpoint=self.instrument_config.opentelemetry_endpoint,
115 | insecure=self.instrument_config.opentelemetry_insecure,
116 | ),
117 | ),
118 | )
119 | for opentelemetry_instrumentor in self.instrument_config.opentelemetry_instrumentors:
120 | opentelemetry_instrumentor.instrumentor.instrument(
121 | tracer_provider=self.tracer_provider,
122 | **opentelemetry_instrumentor.additional_params,
123 | )
124 | set_tracer_provider(self.tracer_provider)
125 |
126 |
127 | class OpentelemetryInstrument(BaseOpentelemetryInstrument[OpentelemetryConfig]):
128 | def define_exclude_urls(self) -> list[str]:
129 | exclude_urls = [*self.instrument_config.opentelemetry_exclude_urls]
130 | if self.instrument_config.health_checks_path and self.instrument_config.health_checks_path not in exclude_urls:
131 | exclude_urls.append(self.instrument_config.health_checks_path)
132 | return exclude_urls
133 |
134 | @classmethod
135 | def get_config_type(cls) -> type[OpentelemetryConfig]:
136 | return OpentelemetryConfig
137 |
138 |
139 | OTEL_PROFILE_ID_KEY: typing.Final = "pyroscope.profile.id"
140 | PYROSCOPE_SPAN_ID_KEY: typing.Final = "span_id"
141 | PYROSCOPE_SPAN_NAME_KEY: typing.Final = "span_name"
142 |
143 |
144 | def _is_root_span(span: ReadableSpan) -> bool:
145 | return span.parent is None or span.parent.is_remote
146 |
147 |
148 | # Extended `pyroscope-otel` span processor: https://github.com/grafana/otel-profiling-python/blob/990662d416943e992ab70036b35b27488c98336a/src/pyroscope/otel/__init__.py
149 | # Includes `span_name` to identify if it makes sense to go to profiles from traces.
150 | class PyroscopeSpanProcessor(SpanProcessor):
151 | def on_start(self, span: Span, parent_context: Context | None = None) -> None: # noqa: ARG002
152 | if _is_root_span(span):
153 | formatted_span_id = format_span_id(span.context.span_id)
154 | thread_id = threading.get_ident()
155 |
156 | span.set_attribute(OTEL_PROFILE_ID_KEY, formatted_span_id)
157 | pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, formatted_span_id)
158 | pyroscope.add_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name)
159 |
160 | def on_end(self, span: ReadableSpan) -> None:
161 | if _is_root_span(span):
162 | thread_id = threading.get_ident()
163 | pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_ID_KEY, format_span_id(span.context.span_id))
164 | pyroscope.remove_thread_tag(thread_id, PYROSCOPE_SPAN_NAME_KEY, span.name)
165 |
166 | def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: ARG002 # pragma: no cover
167 | return True
168 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/prometheus_instrument.py:
--------------------------------------------------------------------------------
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 | if typing.TYPE_CHECKING:
11 | import faststream
12 | import prometheus_client
13 |
14 |
15 | PrometheusConfigT = typing.TypeVar("PrometheusConfigT", bound="BasePrometheusConfig")
16 |
17 |
18 | class BasePrometheusConfig(BaseInstrumentConfig):
19 | service_name: str = "micro-service"
20 |
21 | prometheus_metrics_path: str = "/metrics"
22 | prometheus_metrics_include_in_schema: bool = False
23 |
24 |
25 | class LitestarPrometheusConfig(BasePrometheusConfig):
26 | prometheus_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
27 |
28 |
29 | class FastApiPrometheusConfig(BasePrometheusConfig):
30 | prometheus_instrumentator_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
31 | prometheus_instrument_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
32 | prometheus_expose_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
33 |
34 |
35 | @typing.runtime_checkable
36 | class FastStreamPrometheusMiddlewareProtocol(typing.Protocol):
37 | def __init__(
38 | self,
39 | *,
40 | registry: prometheus_client.CollectorRegistry,
41 | app_name: str = ...,
42 | metrics_prefix: str = "faststream",
43 | received_messages_size_buckets: typing.Sequence[float] | None = None,
44 | ) -> None: ...
45 | def __call__(self, msg: typing.Any | None) -> faststream.BaseMiddleware: ... # noqa: ANN401
46 |
47 |
48 | class FastStreamPrometheusConfig(BasePrometheusConfig):
49 | prometheus_middleware_cls: type[FastStreamPrometheusMiddlewareProtocol] | None = None
50 |
51 |
52 | class PrometheusInstrument(Instrument[PrometheusConfigT]):
53 | instrument_name = "Prometheus"
54 | ready_condition = "Provide metrics_path for metrics exposure"
55 |
56 | def is_ready(self) -> bool:
57 | return bool(self.instrument_config.prometheus_metrics_path) and is_valid_path(
58 | self.instrument_config.prometheus_metrics_path,
59 | )
60 |
61 | @classmethod
62 | def get_config_type(cls) -> type[PrometheusConfigT]:
63 | return BasePrometheusConfig # type: ignore[return-value]
64 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/pyroscope_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import typing
3 |
4 | import pydantic
5 |
6 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
7 |
8 |
9 | try:
10 | import pyroscope # type: ignore[import-untyped]
11 | except ImportError: # pragma: no cover
12 | pyroscope = None # Not supported on Windows
13 |
14 |
15 | class PyroscopeConfig(BaseInstrumentConfig):
16 | service_name: str = "micro-service"
17 | opentelemetry_service_name: str | None = None
18 | opentelemetry_namespace: str | None = None
19 |
20 | pyroscope_endpoint: pydantic.HttpUrl | None = None
21 | pyroscope_sample_rate: int = 100
22 | pyroscope_auth_token: str | None = None
23 | pyroscope_tags: dict[str, str] = pydantic.Field(default_factory=dict)
24 | pyroscope_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
25 |
26 |
27 | class PyroscopeInstrument(Instrument[PyroscopeConfig]):
28 | instrument_name = "Pyroscope"
29 | ready_condition = "Provide pyroscope_endpoint"
30 |
31 | def is_ready(self) -> bool:
32 | return all([self.instrument_config.pyroscope_endpoint, pyroscope])
33 |
34 | def teardown(self) -> None:
35 | pyroscope.shutdown()
36 |
37 | def bootstrap(self) -> None:
38 | pyroscope.configure(
39 | application_name=self.instrument_config.opentelemetry_service_name or self.instrument_config.service_name,
40 | server_address=str(self.instrument_config.pyroscope_endpoint),
41 | auth_token=self.instrument_config.pyroscope_auth_token or "",
42 | sample_rate=self.instrument_config.pyroscope_sample_rate,
43 | tags=(
44 | {"service_namespace": self.instrument_config.opentelemetry_namespace}
45 | if self.instrument_config.opentelemetry_namespace
46 | else {}
47 | )
48 | | self.instrument_config.pyroscope_tags,
49 | **self.instrument_config.pyroscope_additional_params,
50 | )
51 |
52 | @classmethod
53 | def get_config_type(cls) -> type[PyroscopeConfig]:
54 | return PyroscopeConfig
55 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/sentry_instrument.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import contextlib
3 | import typing
4 |
5 | import pydantic
6 | import sentry_sdk
7 | from sentry_sdk.integrations import Integration # noqa: TC002
8 |
9 | from microbootstrap.instruments.base import BaseInstrumentConfig, Instrument
10 |
11 |
12 | class SentryConfig(BaseInstrumentConfig):
13 | service_environment: str | None = None
14 |
15 | sentry_dsn: str | None = None
16 | sentry_traces_sample_rate: float | None = None
17 | sentry_sample_rate: float = pydantic.Field(default=1.0, le=1.0, ge=0.0)
18 | sentry_max_breadcrumbs: int = 15
19 | sentry_max_value_length: int = 16384
20 | sentry_attach_stacktrace: bool = True
21 | sentry_integrations: list[Integration] = pydantic.Field(default_factory=list)
22 | sentry_additional_params: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
23 | sentry_tags: dict[str, str] | None = None
24 |
25 |
26 | class SentryInstrument(Instrument[SentryConfig]):
27 | instrument_name = "Sentry"
28 | ready_condition = "Provide sentry_dsn"
29 |
30 | def is_ready(self) -> bool:
31 | return bool(self.instrument_config.sentry_dsn)
32 |
33 | def bootstrap(self) -> None:
34 | sentry_sdk.init(
35 | dsn=self.instrument_config.sentry_dsn,
36 | sample_rate=self.instrument_config.sentry_sample_rate,
37 | traces_sample_rate=self.instrument_config.sentry_traces_sample_rate,
38 | environment=self.instrument_config.service_environment,
39 | max_breadcrumbs=self.instrument_config.sentry_max_breadcrumbs,
40 | max_value_length=self.instrument_config.sentry_max_value_length,
41 | attach_stacktrace=self.instrument_config.sentry_attach_stacktrace,
42 | integrations=self.instrument_config.sentry_integrations,
43 | **self.instrument_config.sentry_additional_params,
44 | )
45 | if self.instrument_config.sentry_tags:
46 | # for sentry<2.1.0
47 | with contextlib.suppress(AttributeError):
48 | sentry_sdk.set_tags(self.instrument_config.sentry_tags)
49 |
50 | @classmethod
51 | def get_config_type(cls) -> type[SentryConfig]:
52 | return SentryConfig
53 |
--------------------------------------------------------------------------------
/microbootstrap/instruments/swagger_instrument.py:
--------------------------------------------------------------------------------
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
31 |
--------------------------------------------------------------------------------
/microbootstrap/instruments_setupper.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import typing
3 |
4 | from microbootstrap.console_writer import ConsoleWriter
5 | from microbootstrap.instruments.instrument_box import InstrumentBox
6 | from microbootstrap.instruments.logging_instrument import LoggingInstrument
7 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
8 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeInstrument
9 | from microbootstrap.instruments.sentry_instrument import SentryInstrument
10 |
11 |
12 | if typing.TYPE_CHECKING:
13 | import typing_extensions
14 |
15 | from microbootstrap.instruments.base import Instrument, InstrumentConfigT
16 | from microbootstrap.settings import InstrumentsSetupperSettings
17 |
18 |
19 | class InstrumentsSetupper:
20 | console_writer: ConsoleWriter
21 | instrument_box: InstrumentBox
22 |
23 | def __init__(self, settings: InstrumentsSetupperSettings) -> None:
24 | self.settings = settings
25 | self.console_writer = ConsoleWriter(writer_enabled=settings.service_debug)
26 | self.instrument_box.initialize(self.settings)
27 |
28 | def configure_instrument(self, instrument_config: InstrumentConfigT) -> typing_extensions.Self:
29 | self.instrument_box.configure_instrument(instrument_config)
30 | return self
31 |
32 | def configure_instruments(
33 | self,
34 | *instrument_configs: InstrumentConfigT,
35 | ) -> typing_extensions.Self:
36 | for instrument_config in instrument_configs:
37 | self.configure_instrument(instrument_config)
38 | return self
39 |
40 | @classmethod
41 | def use_instrument(
42 | cls,
43 | ) -> typing.Callable[
44 | [type[Instrument[InstrumentConfigT]]],
45 | type[Instrument[InstrumentConfigT]],
46 | ]:
47 | if not hasattr(cls, "instrument_box"):
48 | cls.instrument_box = InstrumentBox()
49 | return cls.instrument_box.extend_instruments
50 |
51 | def setup(self) -> None:
52 | for instrument in self.instrument_box.instruments:
53 | if instrument.is_ready():
54 | instrument.bootstrap()
55 | instrument.write_status(self.console_writer)
56 |
57 | def teardown(self) -> None:
58 | for instrument in self.instrument_box.instruments:
59 | if instrument.is_ready():
60 | instrument.teardown()
61 |
62 | def __enter__(self) -> None:
63 | self.setup()
64 |
65 | def __exit__(self, *args: object) -> None:
66 | self.teardown()
67 |
68 |
69 | InstrumentsSetupper.use_instrument()(LoggingInstrument)
70 | InstrumentsSetupper.use_instrument()(SentryInstrument)
71 | InstrumentsSetupper.use_instrument()(OpentelemetryInstrument)
72 | InstrumentsSetupper.use_instrument()(PyroscopeInstrument)
73 |
--------------------------------------------------------------------------------
/microbootstrap/middlewares/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/microbootstrap/middlewares/__init__.py
--------------------------------------------------------------------------------
/microbootstrap/middlewares/fastapi.py:
--------------------------------------------------------------------------------
1 | import time
2 | import typing
3 |
4 | import fastapi
5 | from fastapi import status
6 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
7 |
8 | from microbootstrap.helpers import optimize_exclude_paths
9 | from microbootstrap.instruments.logging_instrument import fill_log_message
10 |
11 |
12 | def build_fastapi_logging_middleware(
13 | exclude_endpoints: typing.Iterable[str],
14 | ) -> type[BaseHTTPMiddleware]:
15 | endpoints_to_ignore: typing.Collection[str] = optimize_exclude_paths(exclude_endpoints)
16 |
17 | class FastAPILoggingMiddleware(BaseHTTPMiddleware):
18 | async def dispatch(
19 | self,
20 | request: fastapi.Request,
21 | call_next: RequestResponseEndpoint,
22 | ) -> fastapi.Response:
23 | request_path: typing.Final = request.url.path.removesuffix("/")
24 |
25 | if request_path in endpoints_to_ignore:
26 | return await call_next(request)
27 |
28 | start_time: typing.Final = time.perf_counter_ns()
29 | try:
30 | response = await call_next(request)
31 | except Exception: # noqa: BLE001
32 | response = fastapi.Response(status_code=500)
33 |
34 | fill_log_message(
35 | "exception" if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR else "info",
36 | request,
37 | response.status_code,
38 | start_time,
39 | )
40 | return response
41 |
42 | return FastAPILoggingMiddleware
43 |
--------------------------------------------------------------------------------
/microbootstrap/middlewares/litestar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import time
3 | import typing
4 |
5 | import litestar
6 | import litestar.types
7 | from litestar.middleware.base import MiddlewareProtocol
8 | from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
9 |
10 | from microbootstrap.helpers import optimize_exclude_paths
11 | from microbootstrap.instruments.logging_instrument import fill_log_message
12 |
13 |
14 | def build_litestar_logging_middleware(
15 | exclude_endpoints: typing.Iterable[str],
16 | ) -> type[MiddlewareProtocol]:
17 | endpoints_to_ignore: typing.Collection[str] = optimize_exclude_paths(exclude_endpoints)
18 |
19 | class LitestarLoggingMiddleware(MiddlewareProtocol):
20 | def __init__(self, app: litestar.types.ASGIApp) -> None:
21 | self.app = app
22 |
23 | async def __call__(
24 | self,
25 | request_scope: litestar.types.Scope,
26 | receive: litestar.types.Receive,
27 | send_function: litestar.types.Send,
28 | ) -> None:
29 | request: typing.Final[litestar.Request] = litestar.Request(request_scope) # type: ignore[type-arg]
30 |
31 | request_path = request.url.path.removesuffix("/")
32 |
33 | if request_path in endpoints_to_ignore:
34 | await self.app(request_scope, receive, send_function)
35 | return
36 |
37 | start_time: typing.Final[int] = time.perf_counter_ns()
38 |
39 | async def log_message_wrapper(message: litestar.types.Message) -> None:
40 | if message["type"] == "http.response.start":
41 | status = message["status"]
42 | log_level: str = "info" if status < HTTP_500_INTERNAL_SERVER_ERROR else "exception"
43 | fill_log_message(log_level, request, status, start_time)
44 |
45 | await send_function(message)
46 |
47 | await self.app(request_scope, receive, log_message_wrapper)
48 |
49 | return LitestarLoggingMiddleware
50 |
--------------------------------------------------------------------------------
/microbootstrap/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/microbootstrap/py.typed
--------------------------------------------------------------------------------
/microbootstrap/settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import os
3 | import typing
4 |
5 | import pydantic
6 | import pydantic_settings
7 |
8 | from microbootstrap import (
9 | CorsConfig,
10 | FastApiPrometheusConfig,
11 | FastStreamOpentelemetryConfig,
12 | FastStreamPrometheusConfig,
13 | HealthChecksConfig,
14 | LitestarPrometheusConfig,
15 | LoggingConfig,
16 | OpentelemetryConfig,
17 | PyroscopeConfig,
18 | SentryConfig,
19 | SwaggerConfig,
20 | )
21 |
22 |
23 | SettingsT = typing.TypeVar("SettingsT", bound="BaseServiceSettings")
24 | ENV_PREFIX_VAR_NAME: typing.Final = "ENVIRONMENT_PREFIX"
25 | ENV_PREFIX: typing.Final = os.getenv(ENV_PREFIX_VAR_NAME, "")
26 |
27 |
28 | # TODO: add offline docs and cors support # noqa: TD002
29 | class BaseServiceSettings(
30 | pydantic_settings.BaseSettings,
31 | ):
32 | service_debug: bool = True
33 | service_environment: str | None = None
34 | service_name: str = pydantic.Field(
35 | "micro-service",
36 | validation_alias=pydantic.AliasChoices("SERVICE_NAME", f"{ENV_PREFIX}SERVICE_NAME"),
37 | )
38 | service_description: str = "Micro service description"
39 | service_version: str = pydantic.Field(
40 | "1.0.0",
41 | validation_alias=pydantic.AliasChoices("CI_COMMIT_TAG", f"{ENV_PREFIX}SERVICE_VERSION"),
42 | )
43 |
44 | model_config = pydantic_settings.SettingsConfigDict(
45 | env_file=".env",
46 | env_prefix=ENV_PREFIX,
47 | env_file_encoding="utf-8",
48 | populate_by_name=True,
49 | extra="allow",
50 | )
51 |
52 |
53 | class ServerConfig(pydantic.BaseModel):
54 | server_host: str = "0.0.0.0" # noqa: S104
55 | server_port: int = 8000
56 | server_reload: bool = True
57 | server_workers_count: int = 1
58 |
59 |
60 | class LitestarSettings( # type: ignore[misc]
61 | BaseServiceSettings,
62 | ServerConfig,
63 | LoggingConfig,
64 | OpentelemetryConfig,
65 | SentryConfig,
66 | LitestarPrometheusConfig,
67 | SwaggerConfig,
68 | CorsConfig,
69 | HealthChecksConfig,
70 | PyroscopeConfig,
71 | ):
72 | """Settings for a litestar botstrap."""
73 |
74 |
75 | class FastApiSettings( # type: ignore[misc]
76 | BaseServiceSettings,
77 | ServerConfig,
78 | LoggingConfig,
79 | OpentelemetryConfig,
80 | SentryConfig,
81 | FastApiPrometheusConfig,
82 | SwaggerConfig,
83 | CorsConfig,
84 | HealthChecksConfig,
85 | PyroscopeConfig,
86 | ):
87 | """Settings for a fastapi botstrap."""
88 |
89 |
90 | class FastStreamSettings( # type: ignore[misc]
91 | BaseServiceSettings,
92 | ServerConfig,
93 | LoggingConfig,
94 | FastStreamOpentelemetryConfig,
95 | SentryConfig,
96 | FastStreamPrometheusConfig,
97 | HealthChecksConfig,
98 | PyroscopeConfig,
99 | ):
100 | """Settings for a faststream bootstrap."""
101 |
102 | asyncapi_path: str | None = "/asyncapi"
103 |
104 |
105 | class InstrumentsSetupperSettings( # type: ignore[misc]
106 | BaseServiceSettings,
107 | LoggingConfig,
108 | OpentelemetryConfig,
109 | SentryConfig,
110 | PyroscopeConfig,
111 | ):
112 | """Settings for a vanilla service."""
113 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "microbootstrap-docs",
3 | "version": "1.0.0",
4 | "description": "Vuepress documentation for microbootstrap package",
5 | "license": "MIT",
6 | "type": "module",
7 | "scripts": {
8 | "docs:build": "vuepress build docs",
9 | "docs:clean-dev": "vuepress dev docs --clean-cache",
10 | "docs:dev": "vuepress dev docs",
11 | "docs:update-package": "npx vp-update"
12 | },
13 | "devDependencies": {
14 | "@vuepress/bundler-vite": "^2.0.0-rc.7",
15 | "@vuepress/theme-default": "^2.0.0-rc.11",
16 | "vue": "^3.4.0",
17 | "vuepress": "^2.0.0-rc.7"
18 | },
19 | "dependencies": {
20 | "vuepress-theme-hope": "^2.0.0-rc.52"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "microbootstrap"
3 | description = "Package for bootstrapping new micro-services"
4 | readme = "README.md"
5 | requires-python = ">=3.9,<4"
6 | keywords = [
7 | "python",
8 | "microservice",
9 | "bootstrap",
10 | "opentelemetry",
11 | "logging",
12 | "error-tracing",
13 | "litestar",
14 | "fastapi",
15 | ]
16 | classifiers = [
17 | "Typing :: Typed",
18 | "Topic :: Software Development :: Build Tools",
19 | "Operating System :: MacOS",
20 | "Operating System :: Microsoft",
21 | "Operating System :: POSIX :: Linux",
22 | "Intended Audience :: Developers",
23 | "Programming Language :: Python",
24 | "Programming Language :: Python :: 3",
25 | "Programming Language :: Python :: 3 :: Only",
26 | "Programming Language :: Python :: 3.9",
27 | "Programming Language :: Python :: 3.10",
28 | "Programming Language :: Python :: 3.11",
29 | "Programming Language :: Python :: 3.12",
30 | "Programming Language :: Python :: 3.13",
31 | ]
32 | dependencies = [
33 | "eval-type-backport>=0.2",
34 | "opentelemetry-api>=1.30.0",
35 | "opentelemetry-exporter-otlp>=1.15.0",
36 | "opentelemetry-exporter-prometheus-remote-write>=0.46b0",
37 | "opentelemetry-instrumentation>=0.46b0",
38 | "opentelemetry-instrumentation-system-metrics>=0.46b0",
39 | "opentelemetry-sdk>=1.30.0",
40 | "pydantic-settings>=2",
41 | "rich>=13",
42 | "sentry-sdk>=2.7",
43 | "structlog>=24",
44 | "pyroscope-io; platform_system != 'Windows'",
45 | "opentelemetry-distro[otlp]>=0.54b1",
46 | "opentelemetry-instrumentation-aio-pika>=0.54b1",
47 | "opentelemetry-instrumentation-aiohttp-client>=0.54b1",
48 | "opentelemetry-instrumentation-aiokafka>=0.54b1",
49 | "opentelemetry-instrumentation-asyncpg>=0.54b1",
50 | "opentelemetry-instrumentation-httpx>=0.54b1",
51 | "opentelemetry-instrumentation-logging>=0.54b1",
52 | "opentelemetry-instrumentation-redis>=0.54b1",
53 | "opentelemetry-instrumentation-psycopg>=0.54b1",
54 | "opentelemetry-instrumentation-sqlalchemy>=0.54b1",
55 | "opentelemetry-instrumentation-asyncio>=0.54b1",
56 | ]
57 | dynamic = ["version"]
58 | authors = [{ name = "community-of-python" }]
59 |
60 | [project.optional-dependencies]
61 | fastapi = [
62 | "fastapi>=0.100",
63 | "fastapi-offline-docs>=1",
64 | "opentelemetry-instrumentation-asgi>=0.46b0",
65 | "opentelemetry-instrumentation-fastapi>=0.46b0",
66 | "prometheus-fastapi-instrumentator>=6.1",
67 | ]
68 | litestar = [
69 | "litestar>=2.9",
70 | "litestar-offline-docs>=1",
71 | "opentelemetry-instrumentation-asgi>=0.46b0",
72 | "prometheus-client>=0.20",
73 | ]
74 | granian = ["granian[reload]>=1"]
75 | faststream = ["faststream~=0.5", "prometheus-client>=0.20"]
76 |
77 | [dependency-groups]
78 | dev = [
79 | "anyio>=4.8.0",
80 | "httpx>=0.28.1",
81 | "mypy>=1.14.1",
82 | "pre-commit>=4.0.1",
83 | "pytest>=8.3.4",
84 | "pytest-cov>=6.0.0",
85 | "pytest-mock>=3.14.0",
86 | "pytest-xdist>=3.6.1",
87 | "redis>=5.2.1",
88 | "ruff>=0.9.1",
89 | "trio>=0.28.0",
90 | "typing-extensions>=4.12.2",
91 | ]
92 |
93 | [build-system]
94 | requires = ["hatchling", "hatch-vcs"]
95 | build-backend = "hatchling.build"
96 |
97 | [tool.hatch.version]
98 | source = "vcs"
99 |
100 | [tool.mypy]
101 | plugins = ["pydantic.mypy"]
102 | files = ["microbootstrap", "tests"]
103 | python_version = "3.9"
104 | strict = true
105 | pretty = true
106 | show_error_codes = true
107 |
108 | [tool.ruff]
109 | target-version = "py39"
110 | line-length = 120
111 |
112 | [tool.ruff.format]
113 | docstring-code-format = true
114 |
115 | [tool.ruff.lint]
116 | select = ["ALL"]
117 | ignore = [
118 | "EM",
119 | "FBT",
120 | "TRY003",
121 | "FIX002",
122 | "TD003",
123 | "D1",
124 | "D106",
125 | "D203",
126 | "D213",
127 | "G004",
128 | "FA",
129 | "COM812",
130 | "ISC001",
131 | ]
132 |
133 | [tool.ruff.lint.isort]
134 | no-lines-before = ["standard-library", "local-folder"]
135 | lines-after-imports = 2
136 |
137 | [tool.ruff.lint.extend-per-file-ignores]
138 | "tests/*.py" = ["S101", "S311"]
139 | "examples/*.py" = ["INP001"]
140 |
141 | [tool.coverage.report]
142 | exclude_also = ["if typing.TYPE_CHECKING:", 'class \w+\(typing.Protocol\):']
143 | omit = ["tests/*"]
144 |
145 | [tool.pytest.ini_options]
146 | addopts = '--cov=. -p no:warnings --cov-report term-missing'
147 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/tests/__init__.py
--------------------------------------------------------------------------------
/tests/bootstrappers/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/tests/bootstrappers/__init__.py
--------------------------------------------------------------------------------
/tests/bootstrappers/test_fastapi.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from unittest.mock import MagicMock
3 |
4 | from fastapi import status
5 | from fastapi.testclient import TestClient
6 |
7 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper
8 | from microbootstrap.config.fastapi import FastApiConfig
9 | from microbootstrap.instruments.prometheus_instrument import FastApiPrometheusConfig
10 | from microbootstrap.settings import FastApiSettings
11 |
12 |
13 | def test_fastapi_configure_instrument() -> None:
14 | test_metrics_path: typing.Final = "/test-metrics-path"
15 |
16 | application: typing.Final = (
17 | FastApiBootstrapper(FastApiSettings())
18 | .configure_instrument(
19 | FastApiPrometheusConfig(prometheus_metrics_path=test_metrics_path),
20 | )
21 | .bootstrap()
22 | )
23 |
24 | response: typing.Final = TestClient(app=application).get(test_metrics_path)
25 | assert response.status_code == status.HTTP_200_OK
26 |
27 |
28 | def test_fastapi_configure_instruments() -> None:
29 | test_metrics_path: typing.Final = "/test-metrics-path"
30 | application: typing.Final = (
31 | FastApiBootstrapper(FastApiSettings())
32 | .configure_instruments(
33 | FastApiPrometheusConfig(prometheus_metrics_path=test_metrics_path),
34 | )
35 | .bootstrap()
36 | )
37 |
38 | response: typing.Final = TestClient(app=application).get(test_metrics_path)
39 | assert response.status_code == status.HTTP_200_OK
40 |
41 |
42 | def test_fastapi_configure_application() -> None:
43 | test_title: typing.Final = "new-title"
44 |
45 | application: typing.Final = (
46 | FastApiBootstrapper(FastApiSettings()).configure_application(FastApiConfig(title=test_title)).bootstrap()
47 | )
48 |
49 | assert application.title == test_title
50 |
51 |
52 | def test_fastapi_configure_application_lifespan(magic_mock: MagicMock) -> None:
53 | application: typing.Final = (
54 | FastApiBootstrapper(FastApiSettings()).configure_application(FastApiConfig(lifespan=magic_mock)).bootstrap()
55 | )
56 |
57 | with TestClient(app=application):
58 | assert magic_mock.called
59 |
--------------------------------------------------------------------------------
/tests/bootstrappers/test_faststream.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from unittest import mock
3 | from unittest.mock import MagicMock
4 |
5 | import faker
6 | import pytest
7 | from fastapi import status
8 | from fastapi.testclient import TestClient
9 | from faststream.redis import RedisBroker, TestRedisBroker
10 | from faststream.redis.opentelemetry import RedisTelemetryMiddleware
11 | from faststream.redis.prometheus import RedisPrometheusMiddleware
12 |
13 | from microbootstrap.bootstrappers.faststream import FastStreamBootstrapper
14 | from microbootstrap.config.faststream import FastStreamConfig
15 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig
16 | from microbootstrap.instruments.logging_instrument import LoggingConfig
17 | from microbootstrap.instruments.opentelemetry_instrument import FastStreamOpentelemetryConfig, OpentelemetryConfig
18 | from microbootstrap.instruments.prometheus_instrument import FastStreamPrometheusConfig
19 | from microbootstrap.settings import FastStreamSettings
20 |
21 |
22 | @pytest.fixture
23 | def broker() -> RedisBroker:
24 | return RedisBroker()
25 |
26 |
27 | async def test_faststream_configure_instrument(broker: RedisBroker) -> None:
28 | test_metrics_path: typing.Final = "/test-metrics-path"
29 |
30 | application: typing.Final = (
31 | FastStreamBootstrapper(FastStreamSettings())
32 | .configure_application(FastStreamConfig(broker=broker))
33 | .configure_instrument(
34 | FastStreamPrometheusConfig(
35 | prometheus_metrics_path=test_metrics_path, prometheus_middleware_cls=RedisPrometheusMiddleware
36 | ),
37 | )
38 | .bootstrap()
39 | )
40 |
41 | async with TestRedisBroker(broker):
42 | response: typing.Final = TestClient(app=application).get(test_metrics_path)
43 | assert response.status_code == status.HTTP_200_OK
44 |
45 |
46 | def test_faststream_configure_instruments(broker: RedisBroker) -> None:
47 | test_metrics_path: typing.Final = "/test-metrics-path"
48 | application: typing.Final = (
49 | FastStreamBootstrapper(FastStreamSettings())
50 | .configure_application(FastStreamConfig(broker=broker))
51 | .configure_instruments(
52 | FastStreamPrometheusConfig(
53 | prometheus_metrics_path=test_metrics_path, prometheus_middleware_cls=RedisPrometheusMiddleware
54 | ),
55 | )
56 | .bootstrap()
57 | )
58 |
59 | response: typing.Final = TestClient(app=application).get(test_metrics_path)
60 | assert response.status_code == status.HTTP_200_OK
61 |
62 |
63 | def test_faststream_configure_application_lifespan(broker: RedisBroker, magic_mock: MagicMock) -> None:
64 | application: typing.Final = (
65 | FastStreamBootstrapper(FastStreamSettings())
66 | .configure_application(FastStreamConfig(broker=broker, lifespan=magic_mock))
67 | .bootstrap()
68 | )
69 |
70 | with TestClient(app=application):
71 | assert magic_mock.called
72 |
73 |
74 | class TestFastStreamHealthCheck:
75 | def test_500(self, broker: RedisBroker) -> None:
76 | test_health_path: typing.Final = "/test-health-path"
77 | application: typing.Final = (
78 | FastStreamBootstrapper(FastStreamSettings())
79 | .configure_application(FastStreamConfig(broker=broker))
80 | .configure_instruments(HealthChecksConfig(health_checks_path=test_health_path))
81 | .bootstrap()
82 | )
83 |
84 | response: typing.Final = TestClient(app=application).get(test_health_path)
85 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
86 |
87 | async def test_ok(self, broker: RedisBroker) -> None:
88 | test_health_path: typing.Final = "/test-health-path"
89 | application: typing.Final = (
90 | FastStreamBootstrapper(FastStreamSettings())
91 | .configure_application(FastStreamConfig(broker=broker))
92 | .configure_instruments(HealthChecksConfig(health_checks_path=test_health_path))
93 | .bootstrap()
94 | )
95 |
96 | async with TestRedisBroker(broker):
97 | response: typing.Final = TestClient(app=application).get(test_health_path)
98 | assert response.status_code == status.HTTP_200_OK
99 |
100 |
101 | async def test_faststream_opentelemetry(
102 | monkeypatch: pytest.MonkeyPatch,
103 | faker: faker.Faker,
104 | broker: RedisBroker,
105 | minimal_opentelemetry_config: OpentelemetryConfig,
106 | ) -> None:
107 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", mock.Mock())
108 |
109 | FastStreamBootstrapper(FastStreamSettings()).configure_application(
110 | FastStreamConfig(broker=broker)
111 | ).configure_instruments(
112 | FastStreamOpentelemetryConfig(
113 | opentelemetry_middleware_cls=RedisTelemetryMiddleware, **minimal_opentelemetry_config.model_dump()
114 | )
115 | ).bootstrap()
116 |
117 | async with TestRedisBroker(broker):
118 | with mock.patch("opentelemetry.trace.use_span") as mock_capture_event:
119 | await broker.publish(faker.pystr(), channel=faker.pystr())
120 | assert mock_capture_event.called
121 |
122 |
123 | async def test_faststream_logging(broker: RedisBroker, minimal_logging_config: LoggingConfig) -> None:
124 | FastStreamBootstrapper(FastStreamSettings()).configure_application(
125 | FastStreamConfig(broker=broker)
126 | ).configure_instruments(minimal_logging_config).bootstrap()
127 |
--------------------------------------------------------------------------------
/tests/bootstrappers/test_litestar.py:
--------------------------------------------------------------------------------
1 | import typing # noqa: I001
2 | from unittest.mock import MagicMock
3 |
4 | import litestar
5 | import pytest
6 | from litestar import status_codes
7 | from litestar.middleware.base import MiddlewareProtocol
8 | from litestar.testing import AsyncTestClient
9 | from litestar.types import ASGIApp, Receive, Scope, Send
10 |
11 | from microbootstrap import LitestarSettings, LitestarPrometheusConfig
12 | from microbootstrap.bootstrappers.litestar import LitestarBootstrapper
13 | from microbootstrap.config.litestar import LitestarConfig
14 | from microbootstrap.bootstrappers.fastapi import FastApiBootstrapper # noqa: F401
15 |
16 |
17 | @pytest.mark.parametrize("logging_turn_off_middleware", [True, False])
18 | async def test_litestar_configure_instrument(logging_turn_off_middleware: bool) -> None:
19 | test_metrics_path: typing.Final = "/test-metrics-path"
20 |
21 | application: typing.Final = (
22 | LitestarBootstrapper(
23 | LitestarSettings(logging_turn_off_middleware=logging_turn_off_middleware, service_debug=False)
24 | )
25 | .configure_instrument(
26 | LitestarPrometheusConfig(prometheus_metrics_path=test_metrics_path),
27 | )
28 | .bootstrap()
29 | )
30 |
31 | async with AsyncTestClient(app=application) as async_client:
32 | response: typing.Final = await async_client.get(test_metrics_path)
33 | assert response.status_code == status_codes.HTTP_200_OK
34 |
35 |
36 | async def test_litestar_configure_instruments() -> None:
37 | test_metrics_path: typing.Final = "/test-metrics-path"
38 | application: typing.Final = (
39 | LitestarBootstrapper(LitestarSettings())
40 | .configure_instruments(
41 | LitestarPrometheusConfig(prometheus_metrics_path=test_metrics_path),
42 | )
43 | .bootstrap()
44 | )
45 |
46 | async with AsyncTestClient(app=application) as async_client:
47 | response: typing.Final = await async_client.get(test_metrics_path)
48 | assert response.status_code == status_codes.HTTP_200_OK
49 |
50 |
51 | async def test_litestar_configure_application_add_handler() -> None:
52 | test_handler_path: typing.Final = "/test-handler1"
53 | test_response: typing.Final = {"hello": "world"}
54 |
55 | @litestar.get(test_handler_path)
56 | async def test_handler() -> dict[str, str]:
57 | return test_response
58 |
59 | application: typing.Final = (
60 | LitestarBootstrapper(LitestarSettings())
61 | .configure_application(LitestarConfig(route_handlers=[test_handler]))
62 | .bootstrap()
63 | )
64 |
65 | async with AsyncTestClient(app=application) as async_client:
66 | response: typing.Final = await async_client.get(test_handler_path)
67 | assert response.status_code == status_codes.HTTP_200_OK
68 | assert response.json() == test_response
69 |
70 |
71 | async def test_litestar_configure_application_add_middleware(magic_mock: MagicMock) -> None:
72 | test_handler_path: typing.Final = "/test-handler"
73 |
74 | class TestMiddleware(MiddlewareProtocol):
75 | def __init__(self, app: ASGIApp) -> None:
76 | self.app = app
77 |
78 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
79 | magic_mock()
80 | await self.app(scope, receive, send)
81 |
82 | @litestar.get(test_handler_path)
83 | async def test_handler() -> str:
84 | return "Ok"
85 |
86 | application: typing.Final = (
87 | LitestarBootstrapper(LitestarSettings())
88 | .configure_application(LitestarConfig(route_handlers=[test_handler], middleware=[TestMiddleware]))
89 | .bootstrap()
90 | )
91 |
92 | async with AsyncTestClient(app=application) as async_client:
93 | response: typing.Final = await async_client.get(test_handler_path)
94 | assert response.status_code == status_codes.HTTP_200_OK
95 | assert magic_mock.called
96 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | import importlib
3 | import typing
4 | from unittest.mock import AsyncMock, MagicMock
5 |
6 | import litestar
7 | import pytest
8 | from sentry_sdk.transport import Transport as SentryTransport
9 |
10 | import microbootstrap.settings
11 | from microbootstrap import (
12 | FastApiPrometheusConfig,
13 | LitestarPrometheusConfig,
14 | LoggingConfig,
15 | OpentelemetryConfig,
16 | SentryConfig,
17 | )
18 | from microbootstrap.console_writer import ConsoleWriter
19 | from microbootstrap.instruments.cors_instrument import CorsConfig
20 | from microbootstrap.instruments.health_checks_instrument import HealthChecksConfig
21 | from microbootstrap.instruments.prometheus_instrument import BasePrometheusConfig
22 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig
23 | from microbootstrap.settings import BaseServiceSettings, ServerConfig
24 |
25 |
26 | if typing.TYPE_CHECKING:
27 | from sentry_sdk.envelope import Envelope as SentryEnvelope
28 |
29 |
30 | pytestmark = [pytest.mark.anyio]
31 |
32 |
33 | @pytest.fixture(scope="session", autouse=True)
34 | def anyio_backend() -> str:
35 | return "asyncio"
36 |
37 |
38 | @pytest.fixture
39 | def default_litestar_app() -> litestar.Litestar:
40 | return litestar.Litestar()
41 |
42 |
43 | class MockSentryTransport(SentryTransport):
44 | def capture_envelope(self, envelope: SentryEnvelope) -> None: ...
45 |
46 |
47 | @pytest.fixture
48 | def minimal_sentry_config() -> SentryConfig:
49 | return SentryConfig(
50 | sentry_dsn="https://examplePublicKey@o0.ingest.sentry.io/0",
51 | sentry_tags={"test": "test"},
52 | sentry_additional_params={"transport": MockSentryTransport()},
53 | )
54 |
55 |
56 | @pytest.fixture
57 | def minimal_logging_config() -> LoggingConfig:
58 | return LoggingConfig(service_debug=False)
59 |
60 |
61 | @pytest.fixture
62 | def minimal_base_prometheus_config() -> BasePrometheusConfig:
63 | return BasePrometheusConfig()
64 |
65 |
66 | @pytest.fixture
67 | def minimal_fastapi_prometheus_config() -> FastApiPrometheusConfig:
68 | return FastApiPrometheusConfig()
69 |
70 |
71 | @pytest.fixture
72 | def minimal_litestar_prometheus_config() -> LitestarPrometheusConfig:
73 | return LitestarPrometheusConfig()
74 |
75 |
76 | @pytest.fixture
77 | def minimal_swagger_config() -> SwaggerConfig:
78 | return SwaggerConfig()
79 |
80 |
81 | @pytest.fixture
82 | def minimal_cors_config() -> CorsConfig:
83 | return CorsConfig(cors_allowed_origins=["*"])
84 |
85 |
86 | @pytest.fixture
87 | def minimal_health_checks_config() -> HealthChecksConfig:
88 | return HealthChecksConfig()
89 |
90 |
91 | @pytest.fixture
92 | def minimal_opentelemetry_config() -> OpentelemetryConfig:
93 | return OpentelemetryConfig(
94 | opentelemetry_endpoint="/my-engdpoint",
95 | opentelemetry_namespace="namespace",
96 | opentelemetry_container_name="container-name",
97 | )
98 |
99 |
100 | @pytest.fixture
101 | def minimal_server_config() -> ServerConfig:
102 | return ServerConfig()
103 |
104 |
105 | @pytest.fixture
106 | def base_settings() -> BaseServiceSettings:
107 | return BaseServiceSettings()
108 |
109 |
110 | @pytest.fixture
111 | def magic_mock() -> MagicMock:
112 | return MagicMock()
113 |
114 |
115 | @pytest.fixture
116 | def async_mock() -> AsyncMock:
117 | return AsyncMock()
118 |
119 |
120 | @pytest.fixture
121 | def console_writer() -> ConsoleWriter:
122 | return ConsoleWriter(writer_enabled=False)
123 |
124 |
125 | @pytest.fixture
126 | def reset_reloaded_settings_module() -> typing.Iterator[None]:
127 | yield
128 | importlib.reload(microbootstrap.settings)
129 |
--------------------------------------------------------------------------------
/tests/instruments/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/community-of-python/microbootstrap/26b6ce37dfa647c51e3378ad9fa4d36cef1eefc3/tests/instruments/__init__.py
--------------------------------------------------------------------------------
/tests/instruments/test_cors.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import fastapi
4 | import litestar
5 | from fastapi.middleware import Middleware
6 | from fastapi.middleware.cors import CORSMiddleware
7 | from litestar.config.cors import CORSConfig as LitestarCorsConfig
8 |
9 | from microbootstrap import CorsConfig
10 | from microbootstrap.bootstrappers.fastapi import FastApiCorsInstrument
11 | from microbootstrap.bootstrappers.litestar import LitestarCorsInstrument
12 | from microbootstrap.instruments.cors_instrument import CorsInstrument
13 |
14 |
15 | def test_cors_is_ready(minimal_cors_config: CorsConfig) -> None:
16 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config)
17 | assert cors_instrument.is_ready()
18 |
19 |
20 | def test_cors_bootstrap_is_not_ready(minimal_cors_config: CorsConfig) -> None:
21 | minimal_cors_config.cors_allowed_origins = []
22 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config)
23 | assert not cors_instrument.is_ready()
24 |
25 |
26 | def test_cors_bootstrap_after(
27 | default_litestar_app: litestar.Litestar,
28 | minimal_cors_config: CorsConfig,
29 | ) -> None:
30 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config)
31 | assert cors_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
32 |
33 |
34 | def test_cors_teardown(
35 | minimal_cors_config: CorsConfig,
36 | ) -> None:
37 | cors_instrument: typing.Final = CorsInstrument(minimal_cors_config)
38 | assert cors_instrument.teardown() is None # type: ignore[func-returns-value]
39 |
40 |
41 | def test_litestar_cors_bootstrap() -> None:
42 | cors_config = CorsConfig(
43 | cors_allowed_origins=["localhost"],
44 | cors_allowed_headers=["my-allowed-header"],
45 | cors_allowed_credentials=True,
46 | cors_allowed_origin_regex="my-regex",
47 | cors_allowed_methods=["*"],
48 | cors_exposed_headers=["my-exposed-header"],
49 | cors_max_age=100,
50 | )
51 | cors_instrument: typing.Final = LitestarCorsInstrument(cors_config)
52 |
53 | cors_instrument.bootstrap()
54 | bootstrap_result: typing.Final = cors_instrument.bootstrap_before()
55 | assert "cors_config" in bootstrap_result
56 | assert isinstance(bootstrap_result["cors_config"], LitestarCorsConfig)
57 | assert bootstrap_result["cors_config"].allow_origins == cors_config.cors_allowed_origins
58 | assert bootstrap_result["cors_config"].allow_headers == cors_config.cors_allowed_headers
59 | assert bootstrap_result["cors_config"].allow_credentials == cors_config.cors_allowed_credentials
60 | assert bootstrap_result["cors_config"].allow_origin_regex == cors_config.cors_allowed_origin_regex
61 | assert bootstrap_result["cors_config"].allow_methods == cors_config.cors_allowed_methods
62 | assert bootstrap_result["cors_config"].expose_headers == cors_config.cors_exposed_headers
63 | assert bootstrap_result["cors_config"].max_age == cors_config.cors_max_age
64 |
65 |
66 | def test_fastapi_cors_bootstrap() -> None:
67 | cors_config = CorsConfig(
68 | cors_allowed_origins=["localhost"],
69 | cors_allowed_headers=["my-allowed-header"],
70 | cors_allowed_credentials=True,
71 | cors_allowed_origin_regex="my-regex",
72 | cors_allowed_methods=["*"],
73 | cors_exposed_headers=["my-exposed-header"],
74 | cors_max_age=100,
75 | )
76 | cors_instrument: typing.Final = FastApiCorsInstrument(cors_config)
77 | fastapi_application = cors_instrument.bootstrap_after(fastapi.FastAPI())
78 | assert len(fastapi_application.user_middleware) == 1
79 | assert isinstance(fastapi_application.user_middleware[0], Middleware)
80 | cors_middleware: typing.Final = fastapi_application.user_middleware[0]
81 | assert cors_middleware.cls is CORSMiddleware # type: ignore[comparison-overlap]
82 | assert cors_middleware.kwargs["allow_origins"] == cors_config.cors_allowed_origins
83 | assert cors_middleware.kwargs["allow_headers"] == cors_config.cors_allowed_headers
84 | assert cors_middleware.kwargs["allow_credentials"] == cors_config.cors_allowed_credentials
85 | assert cors_middleware.kwargs["allow_origin_regex"] == cors_config.cors_allowed_origin_regex
86 | assert cors_middleware.kwargs["allow_methods"] == cors_config.cors_allowed_methods
87 | assert cors_middleware.kwargs["expose_headers"] == cors_config.cors_exposed_headers
88 | assert cors_middleware.kwargs["max_age"] == cors_config.cors_max_age
89 |
--------------------------------------------------------------------------------
/tests/instruments/test_health_checks.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import fastapi
4 | import litestar
5 | from fastapi.testclient import TestClient as FastAPITestClient
6 | from litestar import status_codes
7 | from litestar.testing import TestClient as LitestarTestClient
8 |
9 | from microbootstrap.bootstrappers.fastapi import FastApiHealthChecksInstrument
10 | from microbootstrap.bootstrappers.litestar import LitestarHealthChecksInstrument
11 | from microbootstrap.instruments.health_checks_instrument import (
12 | HealthChecksConfig,
13 | HealthChecksInstrument,
14 | )
15 |
16 |
17 | def test_health_checks_is_ready(minimal_health_checks_config: HealthChecksConfig) -> None:
18 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config)
19 | assert health_checks_instrument.is_ready()
20 |
21 |
22 | def test_health_checks_bootstrap_is_not_ready(minimal_health_checks_config: HealthChecksConfig) -> None:
23 | minimal_health_checks_config.health_checks_enabled = False
24 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config)
25 | assert not health_checks_instrument.is_ready()
26 |
27 |
28 | def test_health_checks_bootstrap_after(
29 | default_litestar_app: litestar.Litestar,
30 | minimal_health_checks_config: HealthChecksConfig,
31 | ) -> None:
32 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config)
33 | assert health_checks_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
34 |
35 |
36 | def test_health_checks_teardown(
37 | minimal_health_checks_config: HealthChecksConfig,
38 | ) -> None:
39 | health_checks_instrument: typing.Final = HealthChecksInstrument(minimal_health_checks_config)
40 | assert health_checks_instrument.teardown() is None # type: ignore[func-returns-value]
41 |
42 |
43 | def test_litestar_health_checks_bootstrap() -> None:
44 | test_health_checks_path: typing.Final = "/test-path/"
45 | heatlh_checks_config: typing.Final = HealthChecksConfig(health_checks_path=test_health_checks_path)
46 | health_checks_instrument: typing.Final = LitestarHealthChecksInstrument(heatlh_checks_config)
47 |
48 | health_checks_instrument.bootstrap()
49 | litestar_application: typing.Final = litestar.Litestar(
50 | **health_checks_instrument.bootstrap_before(),
51 | )
52 |
53 | with LitestarTestClient(app=litestar_application) as async_client:
54 | response = async_client.get(heatlh_checks_config.health_checks_path)
55 | assert response.status_code == status_codes.HTTP_200_OK
56 |
57 |
58 | def test_fastapi_health_checks_bootstrap() -> None:
59 | test_health_checks_path: typing.Final = "/test-path/"
60 | heatlh_checks_config: typing.Final = HealthChecksConfig(health_checks_path=test_health_checks_path)
61 | health_checks_instrument: typing.Final = FastApiHealthChecksInstrument(heatlh_checks_config)
62 |
63 | health_checks_instrument.bootstrap()
64 | fastapi_application = fastapi.FastAPI(
65 | **health_checks_instrument.bootstrap_before(),
66 | )
67 | fastapi_application = health_checks_instrument.bootstrap_after(fastapi_application)
68 |
69 | response = FastAPITestClient(app=fastapi_application).get(heatlh_checks_config.health_checks_path)
70 | assert response.status_code == status_codes.HTTP_200_OK
71 |
--------------------------------------------------------------------------------
/tests/instruments/test_instrument_box.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import pytest
4 |
5 | from microbootstrap.exceptions import MissingInstrumentError
6 | from microbootstrap.instruments.base import Instrument
7 | from microbootstrap.instruments.instrument_box import InstrumentBox
8 | from microbootstrap.instruments.logging_instrument import LoggingInstrument
9 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
10 | from microbootstrap.instruments.prometheus_instrument import BasePrometheusConfig, PrometheusInstrument
11 | from microbootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument
12 | from microbootstrap.settings import BaseServiceSettings
13 |
14 |
15 | @pytest.mark.parametrize(
16 | "instruments_in_box",
17 | [
18 | [SentryInstrument, LoggingInstrument],
19 | [OpentelemetryInstrument],
20 | [PrometheusInstrument, LoggingInstrument],
21 | [PrometheusInstrument, LoggingInstrument, OpentelemetryInstrument, SentryInstrument],
22 | ],
23 | )
24 | def test_instrument_box_initialize(
25 | instruments_in_box: list[type[Instrument[typing.Any]]],
26 | base_settings: BaseServiceSettings,
27 | ) -> None:
28 | instrument_box: typing.Final = InstrumentBox()
29 | instrument_box.__instruments__ = instruments_in_box
30 | instrument_box.initialize(base_settings)
31 |
32 | assert len(instrument_box.instruments) == len(instruments_in_box)
33 | for initialized_instrument in instrument_box.instruments:
34 | assert isinstance(initialized_instrument, tuple(instruments_in_box))
35 |
36 |
37 | def test_instrument_box_configure_instrument(
38 | base_settings: BaseServiceSettings,
39 | ) -> None:
40 | instrument_box: typing.Final = InstrumentBox()
41 | instrument_box.__instruments__ = [SentryInstrument]
42 | instrument_box.initialize(base_settings)
43 | test_dsn: typing.Final = "my-test-dsn"
44 | instrument_box.configure_instrument(SentryConfig(sentry_dsn=test_dsn))
45 |
46 | assert len(instrument_box.instruments) == 1
47 | assert isinstance(instrument_box.instruments[0].instrument_config, SentryConfig)
48 | assert instrument_box.instruments[0].instrument_config.sentry_dsn == test_dsn
49 |
50 |
51 | def test_instrument_box_configure_instrument_error(
52 | base_settings: BaseServiceSettings,
53 | ) -> None:
54 | instrument_box: typing.Final = InstrumentBox()
55 | instrument_box.__instruments__ = [SentryInstrument]
56 | instrument_box.initialize(base_settings)
57 |
58 | with pytest.raises(MissingInstrumentError):
59 | instrument_box.configure_instrument(BasePrometheusConfig())
60 |
61 |
62 | def test_instrument_box_extend_instruments() -> None:
63 | class TestSentryInstrument(SentryInstrument):
64 | pass
65 |
66 | instrument_box: typing.Final = InstrumentBox()
67 | instrument_box.__instruments__ = [SentryInstrument]
68 | instrument_box.extend_instruments(TestSentryInstrument)
69 | assert len(instrument_box.__instruments__) == 1
70 | assert issubclass(instrument_box.__instruments__[0], TestSentryInstrument)
71 |
--------------------------------------------------------------------------------
/tests/instruments/test_logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import typing
3 | from io import StringIO
4 | from unittest import mock
5 |
6 | import fastapi
7 | import litestar
8 | import pytest
9 | from fastapi.testclient import TestClient as FastAPITestClient
10 | from litestar.testing import TestClient as LitestarTestClient
11 | from opentelemetry import trace
12 | from opentelemetry.sdk.trace import TracerProvider
13 | from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
14 |
15 | from microbootstrap import LoggingConfig
16 | from microbootstrap.bootstrappers.fastapi import FastApiLoggingInstrument
17 | from microbootstrap.bootstrappers.litestar import LitestarLoggingInstrument
18 | from microbootstrap.instruments.logging_instrument import LoggingInstrument, MemoryLoggerFactory
19 |
20 |
21 | def test_logging_is_ready(minimal_logging_config: LoggingConfig) -> None:
22 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config)
23 | assert logging_instrument.is_ready()
24 |
25 |
26 | def test_logging_bootstrap_is_not_ready(minimal_logging_config: LoggingConfig) -> None:
27 | minimal_logging_config.service_debug = True
28 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config)
29 | assert logging_instrument.bootstrap_before() == {}
30 |
31 |
32 | def test_logging_bootstrap_after(
33 | default_litestar_app: litestar.Litestar,
34 | minimal_logging_config: LoggingConfig,
35 | ) -> None:
36 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config)
37 | assert logging_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
38 |
39 |
40 | def test_logging_teardown(
41 | minimal_logging_config: LoggingConfig,
42 | ) -> None:
43 | logging_instrument: typing.Final = LoggingInstrument(minimal_logging_config)
44 | assert logging_instrument.teardown() is None # type: ignore[func-returns-value]
45 |
46 |
47 | def test_litestar_logging_bootstrap(minimal_logging_config: LoggingConfig) -> None:
48 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config)
49 | logging_instrument.bootstrap()
50 | bootsrap_result: typing.Final = logging_instrument.bootstrap_before()
51 | assert "middleware" in bootsrap_result
52 | assert isinstance(bootsrap_result["middleware"], list)
53 | assert len(bootsrap_result["middleware"]) == 1
54 |
55 |
56 | def test_litestar_logging_bootstrap_working(
57 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig
58 | ) -> None:
59 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config)
60 |
61 | @litestar.get("/test-handler")
62 | async def error_handler() -> str:
63 | return "Ok"
64 |
65 | logging_instrument.bootstrap()
66 | litestar_application: typing.Final = litestar.Litestar(
67 | route_handlers=[error_handler],
68 | **logging_instrument.bootstrap_before(),
69 | )
70 | monkeypatch.setattr("microbootstrap.middlewares.litestar.fill_log_message", fill_log_mock := mock.Mock())
71 |
72 | with LitestarTestClient(app=litestar_application) as test_client:
73 | test_client.get("/test-handler?test-query=1")
74 | test_client.get("/test-handler")
75 |
76 | assert fill_log_mock.call_count == 2 # noqa: PLR2004
77 |
78 |
79 | def test_litestar_logging_bootstrap_ignores_health(
80 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig
81 | ) -> None:
82 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config)
83 | logging_instrument.bootstrap()
84 | litestar_application: typing.Final = litestar.Litestar(**logging_instrument.bootstrap_before())
85 | monkeypatch.setattr("microbootstrap.middlewares.litestar.fill_log_message", fill_log_mock := mock.Mock())
86 |
87 | with LitestarTestClient(app=litestar_application) as test_client:
88 | test_client.get("/health")
89 |
90 | assert fill_log_mock.call_count == 0
91 |
92 |
93 | def test_litestar_logging_bootstrap_tracer_injection(minimal_logging_config: LoggingConfig) -> None:
94 | trace.set_tracer_provider(TracerProvider())
95 | tracer = trace.get_tracer(__name__)
96 | span_processor = SimpleSpanProcessor(ConsoleSpanExporter())
97 | trace.get_tracer_provider().add_span_processor(span_processor) # type: ignore[attr-defined]
98 | logging_instrument: typing.Final = LitestarLoggingInstrument(minimal_logging_config)
99 |
100 | @litestar.get("/test-handler")
101 | async def test_handler() -> str:
102 | return "Ok"
103 |
104 | logging_instrument.bootstrap()
105 | litestar_application: typing.Final = litestar.Litestar(
106 | route_handlers=[test_handler],
107 | **logging_instrument.bootstrap_before(),
108 | )
109 | with tracer.start_as_current_span("my_fake_span") as span:
110 | # Do some fake work inside the span
111 | span.set_attribute("example_attribute", "value")
112 | span.add_event("example_event", {"event_attr": 1})
113 | with LitestarTestClient(app=litestar_application) as test_client:
114 | test_client.get("/test-handler")
115 |
116 |
117 | def test_memory_logger_factory_info() -> None:
118 | test_capacity: typing.Final = 10
119 | test_flush_level: typing.Final = logging.ERROR
120 | test_stream: typing.Final = StringIO()
121 |
122 | logger_factory: typing.Final = MemoryLoggerFactory(
123 | logging_buffer_capacity=test_capacity,
124 | logging_flush_level=test_flush_level,
125 | logging_log_level=logging.INFO,
126 | log_stream=test_stream,
127 | )
128 | test_logger: typing.Final = logger_factory()
129 | test_message: typing.Final = "test message"
130 |
131 | for current_log_index in range(test_capacity):
132 | test_logger.info(test_message)
133 | log_contents = test_stream.getvalue()
134 | if current_log_index == test_capacity - 1:
135 | assert test_message in log_contents
136 | else:
137 | assert not log_contents
138 |
139 |
140 | def test_memory_logger_factory_error() -> None:
141 | test_capacity: typing.Final = 10
142 | test_flush_level: typing.Final = logging.ERROR
143 | test_stream: typing.Final = StringIO()
144 |
145 | logger_factory: typing.Final = MemoryLoggerFactory(
146 | logging_buffer_capacity=test_capacity,
147 | logging_flush_level=test_flush_level,
148 | logging_log_level=logging.INFO,
149 | log_stream=test_stream,
150 | )
151 | test_logger: typing.Final = logger_factory()
152 | error_message: typing.Final = "error message"
153 | test_logger.error(error_message)
154 | assert error_message in test_stream.getvalue()
155 |
156 |
157 | def test_fastapi_logging_bootstrap_working(
158 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig
159 | ) -> None:
160 | fastapi_application: typing.Final = fastapi.FastAPI()
161 |
162 | @fastapi_application.get("/test-handler")
163 | async def test_handler() -> str:
164 | return "Ok"
165 |
166 | logging_instrument: typing.Final = FastApiLoggingInstrument(minimal_logging_config)
167 | logging_instrument.bootstrap()
168 | logging_instrument.bootstrap_after(fastapi_application)
169 | monkeypatch.setattr("microbootstrap.middlewares.fastapi.fill_log_message", fill_log_mock := mock.Mock())
170 |
171 | with FastAPITestClient(app=fastapi_application) as test_client:
172 | test_client.get("/test-handler?test-query=1")
173 | test_client.get("/test-handler")
174 |
175 | assert fill_log_mock.call_count == 2 # noqa: PLR2004
176 |
177 |
178 | def test_fastapi_logging_bootstrap_ignores_health(
179 | monkeypatch: pytest.MonkeyPatch, minimal_logging_config: LoggingConfig
180 | ) -> None:
181 | fastapi_application: typing.Final = fastapi.FastAPI()
182 | logging_instrument: typing.Final = FastApiLoggingInstrument(minimal_logging_config)
183 | logging_instrument.bootstrap()
184 | logging_instrument.bootstrap_after(fastapi_application)
185 | monkeypatch.setattr("microbootstrap.middlewares.fastapi.fill_log_message", fill_log_mock := mock.Mock())
186 |
187 | with FastAPITestClient(app=fastapi_application) as test_client:
188 | test_client.get("/health")
189 |
190 | assert fill_log_mock.call_count == 0
191 |
--------------------------------------------------------------------------------
/tests/instruments/test_opentelemetry.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import typing
3 | from unittest.mock import AsyncMock, MagicMock, Mock, patch
4 |
5 | import fastapi
6 | import litestar
7 | import pytest
8 | from fastapi.testclient import TestClient as FastAPITestClient
9 | from litestar.middleware.base import DefineMiddleware
10 | from litestar.testing import TestClient as LitestarTestClient
11 |
12 | from microbootstrap import OpentelemetryConfig
13 | from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument
14 | from microbootstrap.bootstrappers.litestar import LitestarOpentelemetryInstrument
15 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryInstrument
16 |
17 |
18 | def test_opentelemetry_is_ready(
19 | minimal_opentelemetry_config: OpentelemetryConfig,
20 | ) -> None:
21 | opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
22 | assert opentelemetry_instrument.is_ready()
23 |
24 |
25 | def test_opentelemetry_bootstrap_is_not_ready(minimal_opentelemetry_config: OpentelemetryConfig) -> None:
26 | minimal_opentelemetry_config.service_debug = False
27 | minimal_opentelemetry_config.opentelemetry_endpoint = None
28 | opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
29 | assert not opentelemetry_instrument.is_ready()
30 |
31 |
32 | def test_opentelemetry_bootstrap_after(
33 | default_litestar_app: litestar.Litestar,
34 | minimal_opentelemetry_config: OpentelemetryConfig,
35 | ) -> None:
36 | opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
37 | assert opentelemetry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
38 |
39 |
40 | def test_opentelemetry_teardown(
41 | minimal_opentelemetry_config: OpentelemetryConfig,
42 | ) -> None:
43 | opentelemetry_instrument: typing.Final = OpentelemetryInstrument(minimal_opentelemetry_config)
44 | assert opentelemetry_instrument.teardown() is None # type: ignore[func-returns-value]
45 |
46 |
47 | def test_litestar_opentelemetry_bootstrap(
48 | minimal_opentelemetry_config: OpentelemetryConfig,
49 | magic_mock: MagicMock,
50 | ) -> None:
51 | minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock]
52 | opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
53 |
54 | opentelemetry_instrument.bootstrap()
55 | opentelemetry_bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before()
56 |
57 | assert opentelemetry_bootstrap_result
58 | assert "middleware" in opentelemetry_bootstrap_result
59 | assert isinstance(opentelemetry_bootstrap_result["middleware"], list)
60 | assert len(opentelemetry_bootstrap_result["middleware"]) == 1
61 | assert isinstance(opentelemetry_bootstrap_result["middleware"][0], DefineMiddleware)
62 |
63 |
64 | def test_litestar_opentelemetry_teardown(
65 | minimal_opentelemetry_config: OpentelemetryConfig,
66 | magic_mock: MagicMock,
67 | ) -> None:
68 | minimal_opentelemetry_config.opentelemetry_instrumentors = [magic_mock]
69 | opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
70 |
71 | opentelemetry_instrument.teardown()
72 |
73 |
74 | def test_litestar_opentelemetry_bootstrap_working(
75 | minimal_opentelemetry_config: OpentelemetryConfig,
76 | async_mock: AsyncMock,
77 | ) -> None:
78 | opentelemetry_instrument: typing.Final = LitestarOpentelemetryInstrument(minimal_opentelemetry_config)
79 | opentelemetry_instrument.bootstrap()
80 | opentelemetry_bootstrap_result: typing.Final = opentelemetry_instrument.bootstrap_before()
81 |
82 | opentelemetry_middleware = opentelemetry_bootstrap_result["middleware"][0]
83 | assert isinstance(opentelemetry_middleware, DefineMiddleware)
84 | async_mock.__name__ = "test-name"
85 | opentelemetry_middleware.middleware.__call__ = async_mock # type: ignore[operator]
86 |
87 | @litestar.get("/test-handler")
88 | async def test_handler() -> None:
89 | return None
90 |
91 | litestar_application: typing.Final = litestar.Litestar(
92 | route_handlers=[test_handler],
93 | **opentelemetry_bootstrap_result,
94 | )
95 | with LitestarTestClient(app=litestar_application) as test_client:
96 | # Silencing error, because we are mocking middleware call, so ASGI scope remains unchanged.
97 | with contextlib.suppress(AssertionError):
98 | test_client.get("/test-handler")
99 | assert async_mock.called
100 |
101 |
102 | def test_fastapi_opentelemetry_bootstrap_working(
103 | minimal_opentelemetry_config: OpentelemetryConfig, monkeypatch: pytest.MonkeyPatch
104 | ) -> None:
105 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock())
106 |
107 | opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config)
108 | opentelemetry_instrument.bootstrap()
109 | fastapi_application: typing.Final = opentelemetry_instrument.bootstrap_after(fastapi.FastAPI())
110 |
111 | @fastapi_application.get("/test-handler")
112 | async def test_handler() -> None:
113 | return None
114 |
115 | with patch("opentelemetry.trace.use_span") as mock_capture_event:
116 | FastAPITestClient(app=fastapi_application).get("/test-handler")
117 | assert mock_capture_event.called
118 |
--------------------------------------------------------------------------------
/tests/instruments/test_prometheus.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import fastapi
4 | import litestar
5 | from fastapi.testclient import TestClient as FastAPITestClient
6 | from litestar import status_codes
7 | from litestar.middleware.base import DefineMiddleware
8 | from litestar.testing import TestClient as LitestarTestClient
9 |
10 | from microbootstrap import FastApiPrometheusConfig, LitestarPrometheusConfig
11 | from microbootstrap.bootstrappers.fastapi import FastApiPrometheusInstrument
12 | from microbootstrap.bootstrappers.litestar import LitestarPrometheusInstrument
13 | from microbootstrap.instruments.prometheus_instrument import BasePrometheusConfig, PrometheusInstrument
14 |
15 |
16 | def test_prometheus_is_ready(minimal_base_prometheus_config: BasePrometheusConfig) -> None:
17 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config)
18 | assert prometheus_instrument.is_ready()
19 |
20 |
21 | def test_prometheus_bootstrap_is_not_ready(
22 | minimal_base_prometheus_config: BasePrometheusConfig,
23 | ) -> None:
24 | minimal_base_prometheus_config.prometheus_metrics_path = ""
25 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config)
26 | assert not prometheus_instrument.is_ready()
27 |
28 |
29 | def test_prometheus_bootstrap_after(
30 | default_litestar_app: litestar.Litestar,
31 | minimal_base_prometheus_config: BasePrometheusConfig,
32 | ) -> None:
33 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config)
34 | assert prometheus_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
35 |
36 |
37 | def test_prometheus_teardown(
38 | minimal_base_prometheus_config: BasePrometheusConfig,
39 | ) -> None:
40 | prometheus_instrument: typing.Final = PrometheusInstrument(minimal_base_prometheus_config)
41 | assert prometheus_instrument.teardown() is None # type: ignore[func-returns-value]
42 |
43 |
44 | def test_litestar_prometheus_bootstrap(minimal_litestar_prometheus_config: LitestarPrometheusConfig) -> None:
45 | prometheus_instrument: typing.Final = LitestarPrometheusInstrument(minimal_litestar_prometheus_config)
46 | prometheus_instrument.bootstrap()
47 | prometheus_bootstrap_result: typing.Final = prometheus_instrument.bootstrap_before()
48 |
49 | assert prometheus_bootstrap_result
50 | assert "route_handlers" in prometheus_bootstrap_result
51 | assert isinstance(prometheus_bootstrap_result["route_handlers"], list)
52 | assert len(prometheus_bootstrap_result["route_handlers"]) == 1
53 | assert "middleware" in prometheus_bootstrap_result
54 | assert isinstance(prometheus_bootstrap_result["middleware"], list)
55 | assert len(prometheus_bootstrap_result["middleware"]) == 1
56 | assert isinstance(prometheus_bootstrap_result["middleware"][0], DefineMiddleware)
57 |
58 |
59 | def test_litestar_prometheus_bootstrap_working(
60 | minimal_litestar_prometheus_config: LitestarPrometheusConfig,
61 | ) -> None:
62 | minimal_litestar_prometheus_config.prometheus_metrics_path = "/custom-metrics-path"
63 | prometheus_instrument: typing.Final = LitestarPrometheusInstrument(minimal_litestar_prometheus_config)
64 |
65 | prometheus_instrument.bootstrap()
66 | litestar_application: typing.Final = litestar.Litestar(
67 | **prometheus_instrument.bootstrap_before(),
68 | )
69 |
70 | with LitestarTestClient(app=litestar_application) as test_client:
71 | response: typing.Final = test_client.get(minimal_litestar_prometheus_config.prometheus_metrics_path)
72 | assert response.status_code == status_codes.HTTP_200_OK
73 | assert response.text
74 |
75 |
76 | def test_fastapi_prometheus_bootstrap_working(minimal_fastapi_prometheus_config: FastApiPrometheusConfig) -> None:
77 | minimal_fastapi_prometheus_config.prometheus_metrics_path = "/custom-metrics-path"
78 | prometheus_instrument: typing.Final = FastApiPrometheusInstrument(minimal_fastapi_prometheus_config)
79 |
80 | fastapi_application = fastapi.FastAPI()
81 | fastapi_application = prometheus_instrument.bootstrap_after(fastapi_application)
82 |
83 | response: typing.Final = FastAPITestClient(app=fastapi_application).get(
84 | minimal_fastapi_prometheus_config.prometheus_metrics_path
85 | )
86 | assert response.status_code == status_codes.HTTP_200_OK
87 | assert response.text
88 |
--------------------------------------------------------------------------------
/tests/instruments/test_pyroscope.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from unittest import mock
3 | from unittest.mock import Mock
4 |
5 | import fastapi
6 | import pydantic
7 | import pytest
8 | from fastapi.testclient import TestClient as FastAPITestClient
9 |
10 | from microbootstrap.bootstrappers.fastapi import FastApiOpentelemetryInstrument
11 | from microbootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig
12 | from microbootstrap.instruments.pyroscope_instrument import PyroscopeConfig, PyroscopeInstrument
13 |
14 |
15 | try:
16 | import pyroscope # type: ignore[import-untyped] # noqa: F401
17 | except ImportError: # pragma: no cover
18 | pytest.skip("pyroscope is not installed", allow_module_level=True)
19 |
20 |
21 | class TestPyroscopeInstrument:
22 | @pytest.fixture
23 | def minimal_pyroscope_config(self) -> PyroscopeConfig:
24 | return PyroscopeConfig(pyroscope_endpoint=pydantic.HttpUrl("http://localhost:4040"))
25 |
26 | def test_ok(self, minimal_pyroscope_config: PyroscopeConfig) -> None:
27 | instrument = PyroscopeInstrument(minimal_pyroscope_config)
28 | assert instrument.is_ready()
29 | instrument.bootstrap()
30 | instrument.teardown()
31 |
32 | def test_not_ready(self) -> None:
33 | instrument = PyroscopeInstrument(PyroscopeConfig(pyroscope_endpoint=None))
34 | assert not instrument.is_ready()
35 |
36 | def test_opentelemetry_includes_pyroscope_2(
37 | self, monkeypatch: pytest.MonkeyPatch, minimal_opentelemetry_config: OpentelemetryConfig
38 | ) -> None:
39 | monkeypatch.setattr("opentelemetry.sdk.trace.TracerProvider.shutdown", Mock())
40 | monkeypatch.setattr("pyroscope.add_thread_tag", add_thread_tag_mock := Mock())
41 | monkeypatch.setattr("pyroscope.remove_thread_tag", remove_thread_tag_mock := Mock())
42 |
43 | minimal_opentelemetry_config.pyroscope_endpoint = pydantic.HttpUrl("http://localhost:4040")
44 |
45 | opentelemetry_instrument: typing.Final = FastApiOpentelemetryInstrument(minimal_opentelemetry_config)
46 | opentelemetry_instrument.bootstrap()
47 | fastapi_application: typing.Final = opentelemetry_instrument.bootstrap_after(fastapi.FastAPI())
48 |
49 | @fastapi_application.get("/test-handler")
50 | async def test_handler() -> None: ...
51 |
52 | FastAPITestClient(app=fastapi_application).get("/test-handler")
53 |
54 | assert (
55 | add_thread_tag_mock.mock_calls
56 | == remove_thread_tag_mock.mock_calls
57 | == [mock.call(mock.ANY, "span_id", mock.ANY), mock.call(mock.ANY, "span_name", "GET /test-handler")]
58 | )
59 |
--------------------------------------------------------------------------------
/tests/instruments/test_sentry.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from unittest.mock import patch
3 |
4 | import litestar
5 | from litestar.testing import TestClient as LitestarTestClient
6 |
7 | from microbootstrap import SentryConfig
8 | from microbootstrap.bootstrappers.litestar import LitestarSentryInstrument
9 | from microbootstrap.instruments.sentry_instrument import SentryInstrument
10 |
11 |
12 | def test_sentry_is_ready(minimal_sentry_config: SentryConfig) -> None:
13 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config)
14 | assert sentry_instrument.is_ready()
15 |
16 |
17 | def test_sentry_bootstrap_is_not_ready(minimal_sentry_config: SentryConfig) -> None:
18 | minimal_sentry_config.sentry_dsn = ""
19 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config)
20 | assert not sentry_instrument.is_ready()
21 |
22 |
23 | def test_sentry_bootstrap_after(
24 | default_litestar_app: litestar.Litestar,
25 | minimal_sentry_config: SentryConfig,
26 | ) -> None:
27 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config)
28 | assert sentry_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
29 |
30 |
31 | def test_sentry_teardown(
32 | minimal_sentry_config: SentryConfig,
33 | ) -> None:
34 | sentry_instrument: typing.Final = SentryInstrument(minimal_sentry_config)
35 | assert sentry_instrument.teardown() is None # type: ignore[func-returns-value]
36 |
37 |
38 | def test_litestar_sentry_bootstrap(minimal_sentry_config: SentryConfig) -> None:
39 | sentry_instrument: typing.Final = LitestarSentryInstrument(minimal_sentry_config)
40 | sentry_instrument.bootstrap()
41 | assert sentry_instrument.bootstrap_before() == {}
42 |
43 |
44 | def test_litestar_sentry_bootstrap_catch_exception(
45 | minimal_sentry_config: SentryConfig,
46 | ) -> None:
47 | sentry_instrument: typing.Final = LitestarSentryInstrument(minimal_sentry_config)
48 |
49 | @litestar.get("/test-error-handler")
50 | async def error_handler() -> None:
51 | raise ValueError("I'm test error")
52 |
53 | sentry_instrument.bootstrap()
54 | litestar_application: typing.Final = litestar.Litestar(route_handlers=[error_handler])
55 | with patch("sentry_sdk.Scope.capture_event") as mock_capture_event:
56 | with LitestarTestClient(app=litestar_application) as test_client:
57 | test_client.get("/test-error-handler")
58 |
59 | assert mock_capture_event.called
60 |
--------------------------------------------------------------------------------
/tests/instruments/test_swagger.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | import fastapi
4 | import litestar
5 | from fastapi.testclient import TestClient as FastAPITestClient
6 | from litestar import openapi, status_codes
7 | from litestar.openapi import spec as litestar_openapi
8 | from litestar.openapi.plugins import ScalarRenderPlugin
9 | from litestar.static_files import StaticFilesConfig
10 | from litestar.testing import TestClient as LitestarTestClient
11 |
12 | from microbootstrap.bootstrappers.fastapi import FastApiSwaggerInstrument
13 | from microbootstrap.bootstrappers.litestar import LitestarSwaggerInstrument
14 | from microbootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument
15 |
16 |
17 | def test_swagger_is_ready(minimal_swagger_config: SwaggerConfig) -> None:
18 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config)
19 | assert swagger_instrument.is_ready()
20 |
21 |
22 | def test_swagger_bootstrap_is_not_ready(minimal_swagger_config: SwaggerConfig) -> None:
23 | minimal_swagger_config.swagger_path = ""
24 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config)
25 | assert not swagger_instrument.is_ready()
26 |
27 |
28 | def test_swagger_bootstrap_after(
29 | default_litestar_app: litestar.Litestar,
30 | minimal_swagger_config: SwaggerConfig,
31 | ) -> None:
32 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config)
33 | assert swagger_instrument.bootstrap_after(default_litestar_app) == default_litestar_app
34 |
35 |
36 | def test_swagger_teardown(
37 | minimal_swagger_config: SwaggerConfig,
38 | ) -> None:
39 | swagger_instrument: typing.Final = SwaggerInstrument(minimal_swagger_config)
40 | assert swagger_instrument.teardown() is None # type: ignore[func-returns-value]
41 |
42 |
43 | def test_litestar_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None:
44 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
45 |
46 | swagger_instrument.bootstrap()
47 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before()
48 | assert "openapi_config" in bootstrap_result
49 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig)
50 | assert bootstrap_result["openapi_config"].title == minimal_swagger_config.service_name
51 | assert bootstrap_result["openapi_config"].version == minimal_swagger_config.service_version
52 | assert bootstrap_result["openapi_config"].description == minimal_swagger_config.service_description
53 | assert "static_files_config" not in bootstrap_result
54 |
55 |
56 | def test_litestar_swagger_bootstrap_with_overridden_render_plugins(minimal_swagger_config: SwaggerConfig) -> None:
57 | new_render_plugins: typing.Final = [ScalarRenderPlugin()]
58 | minimal_swagger_config.swagger_extra_params["render_plugins"] = new_render_plugins
59 |
60 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
61 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before()
62 |
63 | assert "openapi_config" in bootstrap_result
64 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig)
65 | assert bootstrap_result["openapi_config"].render_plugins is new_render_plugins
66 |
67 |
68 | def test_litestar_swagger_bootstrap_extra_params_have_correct_types(minimal_swagger_config: SwaggerConfig) -> None:
69 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
70 | new_components: typing.Final = litestar_openapi.Components(
71 | security_schemes={"Bearer": litestar_openapi.SecurityScheme(type="http", scheme="Bearer")}
72 | )
73 | swagger_instrument.configure_instrument(
74 | minimal_swagger_config.model_copy(update={"swagger_extra_params": {"components": new_components}})
75 | )
76 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before()
77 |
78 | assert "openapi_config" in bootstrap_result
79 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig)
80 | assert type(bootstrap_result["openapi_config"].components) is litestar_openapi.Components
81 |
82 |
83 | def test_litestar_swagger_bootstrap_offline_docs(minimal_swagger_config: SwaggerConfig) -> None:
84 | minimal_swagger_config.swagger_offline_docs = True
85 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
86 |
87 | swagger_instrument.bootstrap()
88 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before()
89 | assert "openapi_config" in bootstrap_result
90 | assert isinstance(bootstrap_result["openapi_config"], openapi.OpenAPIConfig)
91 | assert bootstrap_result["openapi_config"].title == minimal_swagger_config.service_name
92 | assert bootstrap_result["openapi_config"].version == minimal_swagger_config.service_version
93 | assert bootstrap_result["openapi_config"].description == minimal_swagger_config.service_description
94 | assert "static_files_config" in bootstrap_result
95 | assert isinstance(bootstrap_result["static_files_config"], list)
96 | assert len(bootstrap_result["static_files_config"]) == 1
97 | assert isinstance(bootstrap_result["static_files_config"][0], StaticFilesConfig)
98 |
99 |
100 | def test_litestar_swagger_bootstrap_working_online_docs(
101 | minimal_swagger_config: SwaggerConfig,
102 | ) -> None:
103 | minimal_swagger_config.swagger_path = "/my-docs-path"
104 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
105 |
106 | swagger_instrument.bootstrap()
107 | litestar_application: typing.Final = litestar.Litestar(
108 | **swagger_instrument.bootstrap_before(),
109 | )
110 |
111 | with LitestarTestClient(app=litestar_application) as test_client:
112 | response: typing.Final = test_client.get(minimal_swagger_config.swagger_path)
113 | assert response.status_code == status_codes.HTTP_200_OK
114 |
115 |
116 | def test_litestar_swagger_bootstrap_working_offline_docs(
117 | minimal_swagger_config: SwaggerConfig,
118 | ) -> None:
119 | minimal_swagger_config.service_static_path = "/my-static-path"
120 | minimal_swagger_config.swagger_offline_docs = True
121 | swagger_instrument: typing.Final = LitestarSwaggerInstrument(minimal_swagger_config)
122 |
123 | swagger_instrument.bootstrap()
124 | litestar_application: typing.Final = litestar.Litestar(
125 | **swagger_instrument.bootstrap_before(),
126 | )
127 |
128 | with LitestarTestClient(app=litestar_application) as test_client:
129 | response = test_client.get(minimal_swagger_config.swagger_path)
130 | assert response.status_code == status_codes.HTTP_200_OK
131 | response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css")
132 | assert response.status_code == status_codes.HTTP_200_OK
133 |
134 |
135 | def test_fastapi_swagger_bootstrap_online_docs(minimal_swagger_config: SwaggerConfig) -> None:
136 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config)
137 | bootstrap_result: typing.Final = swagger_instrument.bootstrap_before()
138 | assert bootstrap_result["title"] == minimal_swagger_config.service_name
139 | assert bootstrap_result["description"] == minimal_swagger_config.service_description
140 | assert bootstrap_result["docs_url"] == minimal_swagger_config.swagger_path
141 | assert bootstrap_result["version"] == minimal_swagger_config.service_version
142 |
143 |
144 | def test_fastapi_swagger_bootstrap_working_online_docs(
145 | minimal_swagger_config: SwaggerConfig,
146 | ) -> None:
147 | minimal_swagger_config.swagger_path = "/my-docs-path"
148 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config)
149 |
150 | swagger_instrument.bootstrap()
151 | fastapi_application: typing.Final = fastapi.FastAPI(
152 | **swagger_instrument.bootstrap_before(),
153 | )
154 |
155 | response: typing.Final = FastAPITestClient(app=fastapi_application).get(minimal_swagger_config.swagger_path)
156 | assert response.status_code == status_codes.HTTP_200_OK
157 |
158 |
159 | def test_fastapi_swagger_bootstrap_working_offline_docs(
160 | minimal_swagger_config: SwaggerConfig,
161 | ) -> None:
162 | minimal_swagger_config.service_static_path = "/my-static-path"
163 | minimal_swagger_config.swagger_offline_docs = True
164 | swagger_instrument: typing.Final = FastApiSwaggerInstrument(minimal_swagger_config)
165 | fastapi_application = fastapi.FastAPI(
166 | **swagger_instrument.bootstrap_before(),
167 | )
168 | swagger_instrument.bootstrap_after(fastapi_application)
169 |
170 | with FastAPITestClient(app=fastapi_application) as test_client:
171 | response = test_client.get(minimal_swagger_config.swagger_path)
172 | assert response.status_code == status_codes.HTTP_200_OK
173 | response = test_client.get(f"{minimal_swagger_config.service_static_path}/swagger-ui.css")
174 | assert response.status_code == status_codes.HTTP_200_OK
175 |
--------------------------------------------------------------------------------
/tests/test_granian_server.py:
--------------------------------------------------------------------------------
1 | import granian
2 |
3 | from microbootstrap.granian_server import create_granian_server
4 | from microbootstrap.settings import ServerConfig
5 |
6 |
7 | def test_granian_server(minimal_server_config: ServerConfig) -> None:
8 | assert isinstance(create_granian_server("some:app", minimal_server_config), granian.Granian) # type: ignore[attr-defined]
9 |
--------------------------------------------------------------------------------
/tests/test_helpers.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import typing
3 |
4 | import pydantic
5 | import pytest
6 |
7 | from microbootstrap import exceptions, helpers
8 | from microbootstrap.helpers import optimize_exclude_paths
9 |
10 |
11 | @pytest.mark.parametrize(
12 | ("first_dict", "second_dict", "result", "is_error"),
13 | [
14 | ({"value": 1}, {"value": 2}, {"value": 2}, False),
15 | ({"value": 1, "value2": 2}, {"value": 1, "value2": 3}, {"value": 1, "value2": 3}, False),
16 | ({"value": 1}, {"value2": 1}, {"value": 1, "value2": 1}, False),
17 | ({"array": [1, 2]}, {"array": [3, 4]}, {"array": [1, 2, 3, 4]}, False),
18 | ({"tuple": (1, 2)}, {"tuple": (2, 3)}, {"tuple": (1, 2, 2, 3)}, False),
19 | ({"set": {1, 2}}, {"set": {2, 3}}, {"set": {1, 2, 3}}, False),
20 | ({"dict": {"value": 1}}, {"dict": {"value": 1}}, {"dict": {"value": 1}}, False),
21 | ({"dict": {"value": 1}}, {"dict": {"value": 2}}, {"dict": {"value": 2}}, False),
22 | (
23 | {"dict": {"value": 1, "value2": 2, "value4": 4}},
24 | {"dict": {"value": 5, "value2": 3, "value3": 2}},
25 | {"dict": {"value": 5, "value2": 3, "value3": 2, "value4": 4}},
26 | False,
27 | ),
28 | ({"array": [1, 2]}, {"array": {"val": 1}}, {}, True),
29 | ({"tuple": (2, 3)}, {"tuple": [1, 2]}, {}, True),
30 | ({"dict": {"value": 1}}, {"dict": [1, 2]}, {}, True),
31 | ({"set": {1, 2}}, {"set": [1, 2]}, {}, True),
32 | ],
33 | )
34 | def test_merge_config_dicts(
35 | first_dict: dict[str, typing.Any],
36 | second_dict: dict[str, typing.Any],
37 | result: dict[str, typing.Any],
38 | is_error: bool,
39 | ) -> None:
40 | if is_error:
41 | with pytest.raises(exceptions.ConfigMergeError):
42 | helpers.merge_dict_configs(first_dict, second_dict)
43 | else:
44 | assert result == helpers.merge_dict_configs(first_dict, second_dict)
45 |
46 |
47 | class PydanticConfig(pydantic.BaseModel):
48 | string_field: str
49 | array_field: list[typing.Any] = pydantic.Field(default_factory=list)
50 | dict_field: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
51 |
52 |
53 | @dataclasses.dataclass
54 | class InnerDataclass:
55 | string_field: str
56 |
57 |
58 | @pytest.mark.parametrize(
59 | ("first_model", "second_model", "result"),
60 | [
61 | (
62 | PydanticConfig(string_field="value1"),
63 | PydanticConfig(string_field="value2"),
64 | PydanticConfig(string_field="value2"),
65 | ),
66 | (
67 | PydanticConfig(string_field="value1", array_field=[1]),
68 | PydanticConfig(string_field="value2", array_field=[2]),
69 | PydanticConfig(string_field="value2", array_field=[1, 2]),
70 | ),
71 | (
72 | PydanticConfig(string_field="value1", dict_field={"value1": 1}),
73 | PydanticConfig(string_field="value2", dict_field={"value2": 2}),
74 | PydanticConfig(string_field="value2", dict_field={"value1": 1, "value2": 2}),
75 | ),
76 | (
77 | PydanticConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}),
78 | PydanticConfig(string_field="value2", array_field=[1, 3], dict_field={"value2": 2, "value3": 4}),
79 | PydanticConfig(
80 | string_field="value2",
81 | array_field=[1, 2, 1, 3],
82 | dict_field={"value1": 1, "value2": 2, "value3": 4},
83 | ),
84 | ),
85 | (
86 | PydanticConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}),
87 | PydanticConfig(
88 | string_field="value2",
89 | array_field=[InnerDataclass(string_field="hi")],
90 | dict_field={"value1": 1, "value2": 2, "value3": InnerDataclass(string_field="there")},
91 | ),
92 | PydanticConfig(
93 | string_field="value2",
94 | array_field=[1, 2, InnerDataclass(string_field="hi")],
95 | dict_field={"value1": 1, "value2": 2, "value3": InnerDataclass(string_field="there")},
96 | ),
97 | ),
98 | ],
99 | )
100 | def test_merge_pydantic_configs(
101 | first_model: PydanticConfig,
102 | second_model: PydanticConfig,
103 | result: PydanticConfig,
104 | ) -> None:
105 | assert result == helpers.merge_pydantic_configs(first_model, second_model)
106 |
107 |
108 | @dataclasses.dataclass
109 | class DataclassConfig:
110 | string_field: str
111 | array_field: list[typing.Any] = dataclasses.field(default_factory=list)
112 | dict_field: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
113 |
114 |
115 | @pytest.mark.parametrize(
116 | ("first_class", "second_class", "result"),
117 | [
118 | (
119 | DataclassConfig(string_field="value1"),
120 | DataclassConfig(string_field="value2"),
121 | DataclassConfig(string_field="value2"),
122 | ),
123 | (
124 | DataclassConfig(string_field="value1", array_field=[1]),
125 | DataclassConfig(string_field="value2", array_field=[2]),
126 | DataclassConfig(string_field="value2", array_field=[1, 2]),
127 | ),
128 | (
129 | DataclassConfig(string_field="value1", dict_field={"value1": 1}),
130 | DataclassConfig(string_field="value2", dict_field={"value2": 2}),
131 | DataclassConfig(string_field="value2", dict_field={"value1": 1, "value2": 2}),
132 | ),
133 | (
134 | DataclassConfig(string_field="value1", array_field=[1, 2], dict_field={"value1": 1, "value3": 3}),
135 | DataclassConfig(string_field="value2", array_field=[1, 3], dict_field={"value2": 2, "value3": 4}),
136 | DataclassConfig(
137 | string_field="value2",
138 | array_field=[1, 2, 1, 3],
139 | dict_field={"value1": 1, "value2": 2, "value3": 4},
140 | ),
141 | ),
142 | ],
143 | )
144 | def test_merge_dataclasses_configs(
145 | first_class: DataclassConfig,
146 | second_class: DataclassConfig,
147 | result: DataclassConfig,
148 | ) -> None:
149 | assert result == helpers.merge_dataclasses_configs(first_class, second_class)
150 |
151 |
152 | @pytest.mark.parametrize(
153 | "exclude_paths",
154 | [
155 | ["path"],
156 | ["path"] * 11,
157 | ],
158 | )
159 | def test_optimize_exclude_paths(exclude_paths: list[str]) -> None:
160 | optimize_exclude_paths(exclude_paths)
161 |
--------------------------------------------------------------------------------
/tests/test_instruments_setupper.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from unittest import mock
3 |
4 | import faker
5 | import pytest
6 |
7 | from microbootstrap.instruments.sentry_instrument import SentryConfig
8 | from microbootstrap.instruments_setupper import InstrumentsSetupper
9 | from microbootstrap.settings import InstrumentsSetupperSettings
10 |
11 |
12 | def test_instruments_setupper_initializes_instruments() -> None:
13 | settings: typing.Final = InstrumentsSetupperSettings()
14 | assert InstrumentsSetupper(settings).instrument_box.instruments
15 |
16 |
17 | def test_instruments_setupper_applies_new_config(monkeypatch: pytest.MonkeyPatch, faker: faker.Faker) -> None:
18 | monkeypatch.setattr("sentry_sdk.init", sentry_sdk_init_mock := mock.Mock())
19 | sentry_dsn: typing.Final = faker.pystr()
20 | current_setupper: typing.Final = InstrumentsSetupper(InstrumentsSetupperSettings()).configure_instruments(
21 | SentryConfig(sentry_dsn=sentry_dsn)
22 | )
23 |
24 | with current_setupper:
25 | pass
26 |
27 | assert len(sentry_sdk_init_mock.mock_calls) == 1
28 | assert sentry_sdk_init_mock.mock_calls[0].kwargs.get("dsn") == sentry_dsn
29 |
30 |
31 | def test_instruments_setupper_causes_instruments_lifespan() -> None:
32 | current_setupper: typing.Final = InstrumentsSetupper(InstrumentsSetupperSettings())
33 | instruments_count: typing.Final = len(current_setupper.instrument_box.instruments)
34 | current_setupper.instrument_box.__initialized_instruments__ = [mock.Mock() for _ in range(instruments_count)]
35 |
36 | with current_setupper:
37 | pass
38 |
39 | all_mock_calls: typing.Final = [
40 | one_mocked_instrument.mock_calls # type: ignore[attr-defined]
41 | for one_mocked_instrument in current_setupper.instrument_box.instruments
42 | ]
43 | expected_successful_instrument_calls: typing.Final = [
44 | mock.call.is_ready(),
45 | mock.call.bootstrap(),
46 | mock.call.write_status(current_setupper.console_writer),
47 | mock.call.teardown(),
48 | ]
49 | assert all_mock_calls == [expected_successful_instrument_calls] * instruments_count
50 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | import importlib
2 |
3 | import pytest
4 |
5 | import microbootstrap.settings
6 |
7 |
8 | pytestmark = [pytest.mark.usefixtures("reset_reloaded_settings_module")]
9 |
10 |
11 | @pytest.mark.parametrize("alias", ["SERVICE_NAME", "MY_SERVICE_SERVICE_NAME"])
12 | def test_settings_service_name_aliases(monkeypatch: pytest.MonkeyPatch, alias: str) -> None:
13 | monkeypatch.setenv("ENVIRONMENT_PREFIX", "MY_SERVICE_")
14 | monkeypatch.setenv(alias, "my service")
15 | importlib.reload(microbootstrap.settings)
16 |
17 | settings = microbootstrap.settings.BaseServiceSettings()
18 | assert settings.service_name == "my service"
19 |
20 |
21 | def test_settings_service_name_default() -> None:
22 | settings = microbootstrap.settings.BaseServiceSettings()
23 | assert settings.service_name == "micro-service"
24 |
25 |
26 | @pytest.mark.parametrize("alias", ["CI_COMMIT_TAG", "MY_SERVICE_SERVICE_VERSION"])
27 | def test_settings_service_version_aliases(monkeypatch: pytest.MonkeyPatch, alias: str) -> None:
28 | monkeypatch.setenv("ENVIRONMENT_PREFIX", "MY_SERVICE_")
29 | monkeypatch.setenv(alias, "1.2.3")
30 | importlib.reload(microbootstrap.settings)
31 |
32 | settings = microbootstrap.settings.BaseServiceSettings()
33 | assert settings.service_version == "1.2.3"
34 |
35 |
36 | def test_settings_service_version_default() -> None:
37 | settings = microbootstrap.settings.BaseServiceSettings()
38 | assert settings.service_version == "1.0.0"
39 |
--------------------------------------------------------------------------------