├── .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 | 2 | 3 | Created with Pixso. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 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 | --------------------------------------------------------------------------------