From 135558190066b57d370ed7749a531b6beabde8a0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 12 Sep 2024 20:56:28 +0300 Subject: [PATCH] init --- .github/workflows/ci.yml | 49 +++++++++++ .github/workflows/publish.yml | 24 +++++ .gitignore | 22 +++++ Justfile | 24 +++++ README.md | 16 ++++ lite_bootstrap/__init__.py | 0 lite_bootstrap/fastapi_bootstrap.py | 34 ++++++++ lite_bootstrap/opentelemetry_bootstrap.py | 46 ++++++++++ lite_bootstrap/py.typed | 0 lite_bootstrap/sentry_bootstrap.py | 32 +++++++ pyproject.toml | 102 ++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 27 ++++++ tests/test_fastapi_bootstrap.py | 39 +++++++++ tests/test_opentelemetry_bootstrap.py | 21 +++++ tests/test_sentry_bootstrap.py | 9 ++ 16 files changed, 445 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 README.md create mode 100644 lite_bootstrap/__init__.py create mode 100644 lite_bootstrap/fastapi_bootstrap.py create mode 100644 lite_bootstrap/opentelemetry_bootstrap.py create mode 100644 lite_bootstrap/py.typed create mode 100644 lite_bootstrap/sentry_bootstrap.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_fastapi_bootstrap.py create mode 100644 tests/test_opentelemetry_bootstrap.py create mode 100644 tests/test_sentry_bootstrap.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b305056 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: main + +on: + push: + branches: + - main + pull_request: {} + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: extractions/setup-just@v2 + - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: just install lint-ci + + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: extractions/setup-just@v2 + - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: just install test . --cov=. --cov-report xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4.0.1 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./coverage.xml + flags: unittests + name: codecov-${{ matrix.python-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4a422bb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,24 @@ +name: Publish Package + +on: + release: + types: + - published + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: publish-${{ hashFiles('pyproject.toml') }} + - uses: extractions/setup-just@v2 + - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: just publish + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..068012f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Generic things +*.pyc +*~ +__pycache__/* +*.swp +*.sqlite3 +*.map +.vscode +.idea +.DS_Store +.env +.mypy_cache +.pytest_cache +.ruff_cache +.coverage +htmlcov/ +coverage.xml +pytest.xml +dist/ +.python-version +.venv +uv.lock diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..e8f2238 --- /dev/null +++ b/Justfile @@ -0,0 +1,24 @@ +default: install lint test + +install: + uv lock --upgrade + uv sync --all-extras --frozen + +lint: + uv run ruff format . + uv run ruff check . --fix + uv run mypy . + +lint-ci: + uv run ruff format . --check + uv run ruff check . --no-fix + uv run mypy . + +test *args: + uv run pytest {{ args }} + +publish: + rm -rf dist/* + uv tool run --from build python -m build --installer uv + uv tool run twine check dist/* + uv tool run twine upload dist/* --username __token__ --password $PYPI_TOKEN diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2f5fa7 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +Lite-Bootstrap package +== +[![Test Coverage](https://codecov.io/gh/modern-python/lite-bootstrap/branch/main/graph/badge.svg)](https://codecov.io/gh/modern-python/lite-bootstrap) +[![MyPy Strict](https://img.shields.io/badge/mypy-strict-blue)](https://mypy.readthedocs.io/en/stable/getting_started.html#strict-mode-and-configuration) +[![Supported versions](https://img.shields.io/pypi/pyversions/lite-bootstrap.svg)](https://pypi.python.org/pypi/lite-bootstrap) +[![downloads](https://img.shields.io/pypi/dm/lite-bootstrap.svg)](https://pypistats.org/packages/lite-bootstrap) +[![GitHub stars](https://img.shields.io/github/stars/modern-python/lite-bootstrap)](https://github.com/modern-python/lite-bootstrap/stargazers) + +This package helps to build new microservices + +## Quickstart: +### Installation + +```shell +$ pip install lite-bootstrap +``` diff --git a/lite_bootstrap/__init__.py b/lite_bootstrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lite_bootstrap/fastapi_bootstrap.py b/lite_bootstrap/fastapi_bootstrap.py new file mode 100644 index 0000000..1b4c6be --- /dev/null +++ b/lite_bootstrap/fastapi_bootstrap.py @@ -0,0 +1,34 @@ +import dataclasses + +import fastapi +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware + +from lite_bootstrap.opentelemetry_bootstrap import OpenTelemetryBootstrap +from lite_bootstrap.sentry_bootstrap import SentryBootstrap + + +@dataclasses.dataclass(kw_only=True, slots=True) +class FastAPIBootstrap: + app: fastapi.FastAPI + opentelemetry: OpenTelemetryBootstrap + sentry: SentryBootstrap + opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list) + + def bootstrap_init(self) -> None: + if self.sentry.sentry_dsn: + self.sentry.start_tracing() + self.app.add_middleware(SentryAsgiMiddleware) + + self.opentelemetry.start_tracing() + if self.opentelemetry.endpoint: + FastAPIInstrumentor.instrument_app( + app=self.app, + tracer_provider=self.opentelemetry.tracer_provider, + excluded_urls=",".join(self.opentelemetry_excluded_urls), + ) + + def teardown(self) -> None: + self.opentelemetry.teardown() + if self.opentelemetry.endpoint: + FastAPIInstrumentor.uninstrument_app(self.app) diff --git a/lite_bootstrap/opentelemetry_bootstrap.py b/lite_bootstrap/opentelemetry_bootstrap.py new file mode 100644 index 0000000..cceca12 --- /dev/null +++ b/lite_bootstrap/opentelemetry_bootstrap.py @@ -0,0 +1,46 @@ +import dataclasses + +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] +from opentelemetry.sdk import resources +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import set_tracer_provider + + +@dataclasses.dataclass(kw_only=True) +class OpenTelemetryBootstrap: + endpoint: str + service_name: str + instruments: list[BaseInstrumentor] = dataclasses.field(default_factory=list) + tracer_provider: TracerProvider | None = dataclasses.field(init=False) + + def start_tracing(self) -> None: + if not self.endpoint: + return + + self.tracer_provider: TracerProvider = TracerProvider( + resource=resources.Resource.create({resources.SERVICE_NAME: self.service_name}), + ) + self.tracer_provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter( + endpoint=self.endpoint, + insecure=True, + ), + ), + ) + + for instrument in self.instruments: + instrument.instrument( + tracer_provider=self.tracer_provider, + ) + + set_tracer_provider(self.tracer_provider) + + def teardown(self) -> None: + if not self.endpoint: + return + + for instrument in self.instruments: + instrument.uninstrument() diff --git a/lite_bootstrap/py.typed b/lite_bootstrap/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/lite_bootstrap/sentry_bootstrap.py b/lite_bootstrap/sentry_bootstrap.py new file mode 100644 index 0000000..978556e --- /dev/null +++ b/lite_bootstrap/sentry_bootstrap.py @@ -0,0 +1,32 @@ +import dataclasses +import typing + +import sentry_sdk + + +@dataclasses.dataclass(kw_only=True) +class SentryBootstrap: + sentry_dsn: str + environment: str | None = None + release: str | None = None + max_breadcrumbs: int = 15 + attach_stacktrace: bool = True + default_integrations: bool = True + sentry_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + tags: dict[str, str] | None = None + + def start_tracing(self) -> None: + if not self.sentry_dsn: + return + + sentry_sdk.init( + dsn=self.sentry_dsn, + environment=self.environment, + max_breadcrumbs=self.max_breadcrumbs, + attach_stacktrace=self.attach_stacktrace, + default_integrations=self.default_integrations, + release=self.release, + **self.sentry_params, + ) + tags: dict[str, str] = self.tags or {} + sentry_sdk.set_tags(tags) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4a5702 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "lite-bootstrap" +description = "Lite package for bootstrapping new microservices" +authors = [ + { name = "Artur Shiriev", email = "me@shiriev.ru" }, +] +readme = "README.md" +requires-python = ">=3.10,<4" +license = "MIT" +keywords = [ + "python", + "microservice", + "bootstrap", + "opentelemetry", + "sentry", + "error-tracing", + "fastapi", +] +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Typing :: Typed", + "Topic :: Software Development :: Libraries", +] +dynamic = ["version"] +packages = [ + { include = "lite_bootstrap" }, +] + +[project.urls] +repository = "https://github.com/modern-python/lite-bootstrap" + +[project.optional-dependencies] +tracing = [ + "sentry-sdk", + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp", + "opentelemetry-instrumentation", +] +fastapi = [ + "fastapi", + "sentry-sdk", + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp", + "opentelemetry-instrumentation", + "opentelemetry-instrumentation-fastapi", +] + +[tool.uv] +dev-dependencies = [ + "pytest", + "pytest-cov", + "httpx", # for test client + "mypy", + "ruff", +] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[tool.mypy] +python_version = "3.10" +strict = true + +[tool.ruff] +fix = true +unsafe-fixes = true +line-length = 120 +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D1", # allow missing docstrings + "S101", # allow asserts + "TCH", # ignore flake8-type-checking + "FBT", # allow boolean args + "ANN101", # missing-type-self + "ANN102", # missing-type-cls + "D203", # "one-blank-line-before-class" conflicting with D211 + "D213", # "multi-line-summary-second-line" conflicting with D212 + "COM812", # flake8-commas "Trailing comma missing" + "ISC001", # flake8-implicit-str-concat +] +isort.lines-after-imports = 2 +isort.no-lines-before = ["standard-library", "local-folder"] + +[tool.pytest.ini_options] +addopts = "--cov=. --cov-report term-missing" + +[tool.coverage.report] +exclude_also = ["if typing.TYPE_CHECKING:"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..41b97aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import typing + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.responses import JSONResponse +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore[attr-defined] + + +class CustomInstrumentor(BaseInstrumentor): # type: ignore[misc] + def instrumentation_dependencies(self) -> typing.Collection[str]: + return [] + + def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None: + pass + + +@pytest.fixture +def fastapi_app() -> FastAPI: + app: typing.Final = FastAPI() + router: typing.Final = APIRouter() + + @router.get("/test") + async def for_test_endpoint() -> JSONResponse: + return JSONResponse(content={"key": "value"}) + + app.include_router(router) + return app diff --git a/tests/test_fastapi_bootstrap.py b/tests/test_fastapi_bootstrap.py new file mode 100644 index 0000000..50a985b --- /dev/null +++ b/tests/test_fastapi_bootstrap.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette import status + +from lite_bootstrap.fastapi_bootstrap import FastAPIBootstrap +from lite_bootstrap.opentelemetry_bootstrap import OpenTelemetryBootstrap +from lite_bootstrap.sentry_bootstrap import SentryBootstrap +from tests.conftest import CustomInstrumentor + + +def test_fastapi_bootstrap(fastapi_app: FastAPI) -> None: + fastapi_bootstrap = FastAPIBootstrap( + app=fastapi_app, + opentelemetry=OpenTelemetryBootstrap( + endpoint="localhost", + service_name="test_service", + instruments=[CustomInstrumentor()], + ), + sentry=SentryBootstrap(sentry_dsn="https://testdsn@test.sentry.com/1"), + ) + fastapi_bootstrap.bootstrap_init() + fastapi_bootstrap.teardown() + + +def test_fastapi_bootstrap_with_request(fastapi_app: FastAPI) -> None: + fastapi_bootstrap = FastAPIBootstrap( + app=fastapi_app, + opentelemetry=OpenTelemetryBootstrap( + endpoint="", + service_name="test_service", + instruments=[CustomInstrumentor()], + ), + sentry=SentryBootstrap(sentry_dsn=""), + ) + fastapi_bootstrap.bootstrap_init() + response = TestClient(fastapi_app).get("/test") + assert response.status_code == status.HTTP_200_OK + + fastapi_bootstrap.teardown() diff --git a/tests/test_opentelemetry_bootstrap.py b/tests/test_opentelemetry_bootstrap.py new file mode 100644 index 0000000..3cabb64 --- /dev/null +++ b/tests/test_opentelemetry_bootstrap.py @@ -0,0 +1,21 @@ +from lite_bootstrap.opentelemetry_bootstrap import OpenTelemetryBootstrap +from tests.conftest import CustomInstrumentor + + +def test_bootstrap_opentelemetry() -> None: + opentelemetry = OpenTelemetryBootstrap( + endpoint="localhost", + service_name="test_service", + instruments=[CustomInstrumentor()], + ) + opentelemetry.start_tracing() + opentelemetry.teardown() + + +def test_bootstrap_opentelemetry_empty_instruments() -> None: + opentelemetry = OpenTelemetryBootstrap( + endpoint="localhost", + service_name="test_service", + ) + opentelemetry.start_tracing() + opentelemetry.teardown() diff --git a/tests/test_sentry_bootstrap.py b/tests/test_sentry_bootstrap.py new file mode 100644 index 0000000..91e0907 --- /dev/null +++ b/tests/test_sentry_bootstrap.py @@ -0,0 +1,9 @@ +from lite_bootstrap.sentry_bootstrap import SentryBootstrap + + +def test_sentry_bootstrap() -> None: + SentryBootstrap(sentry_dsn="https://testdsn@test.sentry.com/1", tags={"tag": "value"}).start_tracing() + + +def test_sentry_bootstrap_empty_dsn() -> None: + SentryBootstrap(sentry_dsn="").start_tracing()