cuneus 0.2.9__py3-none-any.whl → 0.2.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cuneus/cli.py CHANGED
@@ -13,9 +13,7 @@ def get_user_cli(config: Settings = Settings()) -> click.Group | None:
13
13
  try:
14
14
  return cast(click.Group, import_from_string(config.cli_module))
15
15
  except (ImportError, AttributeError) as e:
16
- click.echo(
17
- f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
18
- )
16
+ click.echo(f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True)
19
17
  return None
20
18
 
21
19
 
@@ -41,9 +41,7 @@ class ExtensionConflictError(Exception):
41
41
  pass
42
42
 
43
43
 
44
- def _instantiate_extension(
45
- ext: ExtensionInput, settings: Settings | None = None
46
- ) -> Extension:
44
+ def _instantiate_extension(ext: ExtensionInput, settings: Settings | None = None) -> Extension:
47
45
  if isinstance(ext, type) or callable(ext):
48
46
  try:
49
47
  return ext(settings=settings)
@@ -97,9 +95,7 @@ def build_app(
97
95
 
98
96
  @svcs.fastapi.lifespan
99
97
  @asynccontextmanager
100
- async def lifespan(
101
- app: FastAPI, registry: svcs.Registry
102
- ) -> AsyncIterator[dict[str, Any]]:
98
+ async def lifespan(app: FastAPI, registry: svcs.Registry) -> AsyncIterator[dict[str, Any]]:
103
99
  async with AsyncExitStack() as stack:
104
100
  state: dict[str, Any] = {}
105
101
 
cuneus/core/exceptions.py CHANGED
@@ -160,9 +160,7 @@ class ExceptionExtension(BaseExtension):
160
160
  app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
161
161
  app.add_exception_handler(Exception, self._handle_unexpected_exception)
162
162
 
163
- def _handle_app_exception(
164
- self, request: Request, exc: AppException
165
- ) -> JSONResponse:
163
+ def _handle_app_exception(self, request: Request, exc: AppException) -> JSONResponse:
166
164
  if exc.status_code >= 500 and self.settings.log_server_errors:
167
165
  log.exception("server_error", error_code=exc.error_code)
168
166
  else:
@@ -180,9 +178,7 @@ class ExceptionExtension(BaseExtension):
180
178
  headers=headers,
181
179
  )
182
180
 
183
- def _handle_unexpected_exception(
184
- self, request: Request, exc: Exception
185
- ) -> JSONResponse:
181
+ def _handle_unexpected_exception(self, request: Request, exc: Exception) -> JSONResponse:
186
182
  log.exception("unexpected_error", exc_info=exc)
187
183
  response: dict[str, Any] = {
188
184
  "error": {
cuneus/core/settings.py CHANGED
@@ -39,7 +39,6 @@ class CuneusBaseSettings(BaseSettings):
39
39
 
40
40
 
41
41
  class Settings(CuneusBaseSettings):
42
-
43
42
  model_config = SettingsConfigDict(
44
43
  env_file=".env",
45
44
  env_file_encoding="utf-8",
cuneus/dependencies.py ADDED
@@ -0,0 +1,79 @@
1
+ # cuneus/core/dependencies.py
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class Dependency:
13
+ """A required dependency with install hint."""
14
+
15
+ import_name: str
16
+ package_name: str | None = None # pip package name if different from import
17
+
18
+ @property
19
+ def pip_name(self) -> str:
20
+ return self.package_name or self.import_name
21
+
22
+
23
+ class MissingDependencyError(ImportError):
24
+ """Raised when required dependencies are not installed."""
25
+
26
+ def __init__(self, extension: str, missing: list[Dependency]):
27
+ self.extension = extension
28
+ self.missing = missing
29
+ packages = " ".join(d.pip_name for d in missing)
30
+ super().__init__(
31
+ f"{extension} requires additional dependencies. Install with: uv add {packages}"
32
+ )
33
+
34
+
35
+ def check_dependencies(extension: str, *deps: Dependency) -> None:
36
+ """
37
+ Check that dependencies are installed, raise helpful error if not.
38
+
39
+ Usage:
40
+ from cuneus.core.dependencies import check_dependencies, Dependency
41
+
42
+ check_dependencies(
43
+ "DatabaseExtension",
44
+ Dependency("sqlalchemy"),
45
+ Dependency("asyncpg"),
46
+ )
47
+ """
48
+ missing = []
49
+ for dep in deps:
50
+ try:
51
+ importlib.import_module(dep.import_name)
52
+ except ImportError:
53
+ missing.append(dep)
54
+
55
+ if missing:
56
+ raise MissingDependencyError(extension, missing)
57
+
58
+
59
+ def warn_missing(extension: str, *deps: Dependency) -> list[Dependency]:
60
+ """
61
+ Check dependencies but only warn, don't raise. Returns list of missing.
62
+
63
+ Useful for optional features within an extension.
64
+ """
65
+ missing = []
66
+ for dep in deps:
67
+ try:
68
+ importlib.import_module(dep.import_name)
69
+ except ImportError:
70
+ missing.append(dep)
71
+
72
+ if missing:
73
+ packages = " ".join(d.pip_name for d in missing)
74
+ logger.warning(
75
+ f"{extension}: optional dependencies not installed. "
76
+ f"Some features disabled. Install with: uv add {packages}"
77
+ )
78
+
79
+ return missing
cuneus/ext/database.py ADDED
@@ -0,0 +1,278 @@
1
+ # cuneus/ext/database.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any, AsyncIterator
8
+
9
+ import click
10
+ import svcs
11
+ from fastapi import FastAPI
12
+ from pydantic import Field, SecretStr, computed_field
13
+ from pydantic_settings import SettingsConfigDict
14
+ from structlog.stdlib import get_logger
15
+
16
+ from ..core.extensions import BaseExtension, HasCLI
17
+ from ..core.settings import CuneusBaseSettings, DEFAULT_TOOL_NAME
18
+ from ..dependencies import Dependency, check_dependencies
19
+
20
+ check_dependencies(
21
+ "cuneus.ext.database",
22
+ Dependency("sqlalchemy"),
23
+ )
24
+
25
+ from sqlalchemy import URL, make_url, text
26
+ from sqlalchemy.ext.asyncio import (
27
+ AsyncEngine,
28
+ AsyncSession,
29
+ async_sessionmaker,
30
+ create_async_engine,
31
+ )
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ class DatabaseSettings(CuneusBaseSettings):
37
+ """Database configuration."""
38
+
39
+ model_config = SettingsConfigDict(
40
+ env_prefix="DATABASE_",
41
+ env_file=".env",
42
+ env_file_encoding="utf-8",
43
+ extra="ignore",
44
+ pyproject_toml_depth=2,
45
+ pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME, "database"),
46
+ )
47
+
48
+ # Option 1: Full URL (takes precedence if set)
49
+ url: str | None = None
50
+
51
+ # Option 2: Individual parts
52
+ driver: str = "postgresql+asyncpg"
53
+ host: str = "localhost"
54
+ port: int = 5432
55
+ name: str = "app"
56
+ username: str | None = None
57
+ password: SecretStr | None = None
58
+
59
+ # Pool settings
60
+ pool_size: int = 5
61
+ pool_max_overflow: int = 10
62
+ pool_recycle: int = 3600
63
+ echo: bool = False
64
+
65
+ # Alembic
66
+ alembic_config: Path = Path("alembic.ini")
67
+
68
+ @computed_field
69
+ @property
70
+ def url_parsed(self) -> URL:
71
+ """Get SQLAlchemy URL, either from url string or constructed from parts."""
72
+ if self.url:
73
+ return make_url(self.url)
74
+
75
+ needs_opts = "sqlite" not in self.driver
76
+ password_value = self.password.get_secret_value() if self.password else None
77
+ password = password_value if needs_opts else None
78
+
79
+ return URL.create(
80
+ drivername=self.driver,
81
+ username=self.username if needs_opts else None,
82
+ password=password,
83
+ host=self.host if needs_opts else None,
84
+ port=self.port if needs_opts else None,
85
+ database=self.name,
86
+ )
87
+
88
+ @computed_field
89
+ @property
90
+ def url_redacted(self) -> str:
91
+ """URL safe for logging (password hidden)."""
92
+ return self.url_parsed.render_as_string(hide_password=True)
93
+
94
+
95
+ class DatabaseExtension(BaseExtension, HasCLI):
96
+ """
97
+ Database extension providing AsyncSession via svcs.
98
+
99
+ Registers:
100
+ - AsyncEngine: The SQLAlchemy async engine
101
+ - async_sessionmaker: Factory for creating sessions
102
+ - AsyncSession: Request-scoped session (via factory)
103
+
104
+ CLI Commands:
105
+ - db upgrade [revision]: Run migrations
106
+ - db downgrade [revision]: Rollback migrations
107
+ - db revision -m "message": Create new migration
108
+ - db current: Show current revision
109
+ - db history: Show migration history
110
+ - db check: Check database connectivity
111
+
112
+ Configuration (env or pyproject.toml [tool.cuneus.database]):
113
+ DATABASE_URL: Connection string
114
+ DATABASE_POOL_SIZE: Connection pool size (default: 5)
115
+ DATABASE_POOL_MAX_OVERFLOW: Max overflow connections (default: 10)
116
+ DATABASE_POOL_RECYCLE: Connection recycle time in seconds (default: 3600)
117
+ DATABASE_ECHO: Echo SQL statements (default: false)
118
+ DATABASE_ALEMBIC_CONFIG: Path to alembic.ini (default: alembic.ini)
119
+ """
120
+
121
+ _session_factory: async_sessionmaker[AsyncSession]
122
+ _engine: AsyncEngine
123
+
124
+ def __init__(self, settings: DatabaseSettings | None = None):
125
+ self.settings = settings or DatabaseSettings()
126
+
127
+ @asynccontextmanager
128
+ async def register(
129
+ self, registry: svcs.Registry, app: FastAPI
130
+ ) -> AsyncIterator[dict[str, Any]]:
131
+ self._engine = create_async_engine(
132
+ self.settings.url_parsed,
133
+ # pool_size=self.settings.pool_size,
134
+ # max_overflow=self.settings.pool_max_overflow,
135
+ pool_recycle=self.settings.pool_recycle,
136
+ echo=self.settings.echo,
137
+ )
138
+
139
+ self._session_factory = async_sessionmaker(
140
+ self._engine,
141
+ class_=AsyncSession,
142
+ expire_on_commit=False,
143
+ )
144
+
145
+ registry.register_value(AsyncEngine, self._engine, ping=self._check)
146
+ registry.register_value(async_sessionmaker, self._session_factory)
147
+
148
+ @asynccontextmanager
149
+ async def session_factory() -> AsyncIterator[AsyncSession]:
150
+ async with self._session_factory() as session:
151
+ try:
152
+ yield session
153
+ await session.commit()
154
+ except Exception:
155
+ await session.rollback()
156
+ raise
157
+
158
+ registry.register_factory(AsyncSession, session_factory)
159
+
160
+ logger.info("Database started", extra={"url": self.settings.url_redacted})
161
+
162
+ try:
163
+ yield {
164
+ "db_engine": self._engine,
165
+ "db_session_factory": self._session_factory,
166
+ }
167
+ finally:
168
+ await self._engine.dispose()
169
+ logger.info("Database shutdown")
170
+
171
+ async def _check(self):
172
+ engine = create_async_engine(self.settings.url_parsed)
173
+ try:
174
+ async with engine.connect() as conn:
175
+ await conn.execute(text("SELECT 1"))
176
+ finally:
177
+ await engine.dispose()
178
+
179
+ def register_cli(self, cli_group: click.Group) -> None:
180
+ settings = self.settings
181
+
182
+ @cli_group.group()
183
+ def db():
184
+ """Database management commands."""
185
+ pass
186
+
187
+ @db.command()
188
+ @click.argument("revision", default="head")
189
+ def upgrade(revision: str):
190
+ """Upgrade database to revision (default: head)."""
191
+ _run_alembic_cmd("upgrade", settings.alembic_config, revision=revision)
192
+
193
+ @db.command()
194
+ @click.argument("revision", default="-1")
195
+ def downgrade(revision: str):
196
+ """Downgrade database to revision (default: -1)."""
197
+ _run_alembic_cmd("downgrade", settings.alembic_config, revision=revision)
198
+
199
+ @db.command()
200
+ @click.option("-m", "--message", required=True, help="Migration message")
201
+ @click.option("--autogenerate/--no-autogenerate", default=True)
202
+ def revision(message: str, autogenerate: bool):
203
+ """Create a new migration revision."""
204
+ _run_alembic_cmd(
205
+ "revision",
206
+ settings.alembic_config,
207
+ message=message,
208
+ autogenerate=autogenerate,
209
+ )
210
+
211
+ @db.command()
212
+ def current():
213
+ """Show current database revision."""
214
+ _run_alembic_cmd("current", settings.alembic_config)
215
+
216
+ @db.command()
217
+ def history():
218
+ """Show migration history."""
219
+ _run_alembic_cmd("history", settings.alembic_config)
220
+
221
+ @db.command()
222
+ @click.argument("template", default="async")
223
+ def init():
224
+ """
225
+ Create a new alembic setup by default this will use the async template
226
+ """
227
+
228
+ @db.command()
229
+ @click.pass_context
230
+ def check(ctx: click.Context):
231
+ """Check database connectivity."""
232
+ import asyncio
233
+
234
+ async def _check():
235
+ engine = create_async_engine(settings.url_parsed)
236
+ try:
237
+ async with engine.connect() as conn:
238
+ await conn.execute(text("SELECT 1"))
239
+ click.echo("✓ Database connection OK")
240
+ except Exception as e:
241
+ print(e)
242
+ click.echo(f"✗ Database connection failed: {e}", err=True)
243
+ ctx.exit(1)
244
+ finally:
245
+ await engine.dispose()
246
+
247
+ asyncio.run(_check())
248
+
249
+
250
+ def _run_alembic_cmd(
251
+ cmd: str,
252
+ config_path: Path,
253
+ revision: str | None = None,
254
+ message: str | None = None,
255
+ autogenerate: bool = False,
256
+ ) -> None:
257
+ """Run an alembic command."""
258
+ from alembic import command
259
+ from alembic.config import Config
260
+
261
+ if not config_path.exists():
262
+ raise click.ClickException(f"Alembic config not found: {config_path}")
263
+
264
+ cfg = Config(str(config_path))
265
+
266
+ match cmd:
267
+ case "upgrade":
268
+ command.upgrade(cfg, revision or "head")
269
+ case "downgrade":
270
+ command.downgrade(cfg, revision or "-1")
271
+ case "revision":
272
+ command.revision(cfg, message=message, autogenerate=autogenerate)
273
+ case "current":
274
+ command.current(cfg)
275
+ case "history":
276
+ command.history(cfg)
277
+ case _:
278
+ raise click.ClickException(f"Unknown command: {cmd}")
cuneus/ext/health.py CHANGED
@@ -37,9 +37,7 @@ class HealthResponse(BaseModel):
37
37
 
38
38
 
39
39
  @health_router.get("", response_model=HealthResponse)
40
- async def health(
41
- services: svcs.fastapi.DepContainer, request: Request
42
- ) -> HealthResponse:
40
+ async def health(services: svcs.fastapi.DepContainer, request: Request) -> HealthResponse:
43
41
  """Full health check - pings all registered services."""
44
42
  pings = services.get_pings()
45
43
 
cuneus/ext/otel.py ADDED
@@ -0,0 +1,279 @@
1
+ # cuneus/ext/otel.py
2
+ from __future__ import annotations
3
+
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, AsyncIterator, Callable
6
+
7
+ import structlog
8
+ import svcs
9
+ from fastapi import FastAPI, Request, Response
10
+ from pydantic import Field
11
+ from pydantic_settings import SettingsConfigDict
12
+ from starlette.middleware import Middleware
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+
15
+ from ..core.extensions import BaseExtension, HasMiddleware
16
+ from ..core.settings import CuneusBaseSettings, DEFAULT_TOOL_NAME
17
+ from ..dependencies import Dependency, check_dependencies
18
+
19
+ check_dependencies(
20
+ "cuneus.ext.otel",
21
+ Dependency("opentelemetry.sdk", "opentelemetry-sdk"),
22
+ Dependency("opentelemetry.trace", "opentelemetry-api"),
23
+ )
24
+
25
+ from opentelemetry import trace, metrics
26
+ from opentelemetry.sdk.trace import TracerProvider, SpanProcessor
27
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
28
+ from opentelemetry.sdk.metrics import MeterProvider
29
+ from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
30
+ from opentelemetry.trace import Tracer, Status, StatusCode
31
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
32
+ from opentelemetry.propagate import set_global_textmap
33
+
34
+ logger = structlog.stdlib.get_logger(__name__)
35
+
36
+
37
+ class OTelSettings(CuneusBaseSettings):
38
+ """OpenTelemetry configuration."""
39
+
40
+ model_config = SettingsConfigDict(
41
+ env_prefix="OTEL_",
42
+ env_file=".env",
43
+ env_file_encoding="utf-8",
44
+ extra="ignore",
45
+ pyproject_toml_depth=2,
46
+ pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME, "otel"),
47
+ )
48
+
49
+ # Service identity
50
+ service_name: str = "unknown-service"
51
+ service_version: str = "0.0.0"
52
+ environment: str = "development"
53
+
54
+ # Feature flags
55
+ enabled: bool = True
56
+ traces_enabled: bool = True
57
+ metrics_enabled: bool = True
58
+
59
+ # Auto-instrumentation
60
+ instrument_fastapi: bool = True
61
+ instrument_sqlalchemy: bool = True
62
+ instrument_httpx: bool = True
63
+ instrument_redis: bool = True
64
+ instrument_logging: bool = True
65
+
66
+ # Exporter
67
+ exporter_otlp_endpoint: str | None = None
68
+
69
+ # Sampling
70
+ sample_rate: float = 1.0
71
+
72
+ # Excluded paths
73
+ excluded_paths: list[str] = Field(
74
+ default_factory=lambda: ["/health", "/ready", "/metrics", "/favicon.ico"]
75
+ )
76
+
77
+
78
+ class OTelExtension(BaseExtension, HasMiddleware):
79
+ """
80
+ OpenTelemetry extension providing distributed tracing and metrics.
81
+
82
+ Registers:
83
+ - TracerProvider: The global tracer provider
84
+ - Tracer: A tracer instance for the service
85
+ - MeterProvider: The global meter provider (if metrics enabled)
86
+
87
+ Auto-instrumentation (configurable):
88
+ - FastAPI/Starlette
89
+ - SQLAlchemy
90
+ - HTTPX
91
+ - Redis
92
+ - Logging
93
+
94
+ Configuration (env with OTEL_ prefix or pyproject.toml [tool.cuneus.otel]):
95
+ service_name: Service name for traces
96
+ service_version: Service version
97
+ environment: Deployment environment
98
+ enabled: Enable/disable OTel entirely (default: true)
99
+ traces_enabled: Enable tracing (default: true)
100
+ metrics_enabled: Enable metrics (default: true)
101
+ instrument_*: Enable specific auto-instrumentation
102
+ exporter_otlp_endpoint: OTLP exporter endpoint
103
+ sample_rate: Sampling rate 0.0-1.0 (default: 1.0)
104
+ excluded_paths: Paths to exclude from tracing
105
+ """
106
+
107
+ _tracer_provider: TracerProvider
108
+
109
+ def __init__(
110
+ self,
111
+ settings: OTelSettings | None = None,
112
+ span_exporters: list[SpanExporter] | None = None,
113
+ span_processors: list[SpanProcessor] | None = None,
114
+ ):
115
+ self.settings = settings or OTelSettings()
116
+ self._span_exporters = span_exporters or []
117
+ self._span_processors = span_processors or []
118
+
119
+ @asynccontextmanager
120
+ async def register(
121
+ self, registry: svcs.Registry, app: FastAPI
122
+ ) -> AsyncIterator[dict[str, Any]]:
123
+ if not self.settings.enabled:
124
+ logger.info("OpenTelemetry disabled")
125
+ yield {}
126
+ return
127
+
128
+ resource = Resource.create(
129
+ {
130
+ SERVICE_NAME: self.settings.service_name,
131
+ SERVICE_VERSION: self.settings.service_version,
132
+ "deployment.environment": self.settings.environment,
133
+ }
134
+ )
135
+
136
+ if self.settings.traces_enabled:
137
+ self._tracer_provider = TracerProvider(resource=resource)
138
+
139
+ for processor in self._span_processors:
140
+ self._tracer_provider.add_span_processor(processor)
141
+
142
+ for exporter in self._span_exporters:
143
+ self._tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
144
+
145
+ trace.set_tracer_provider(self._tracer_provider)
146
+ set_global_textmap(TraceContextTextMapPropagator())
147
+
148
+ registry.register_value(TracerProvider, self._tracer_provider)
149
+ registry.register_value(
150
+ Tracer,
151
+ self._tracer_provider.get_tracer(self.settings.service_name),
152
+ )
153
+
154
+ self._setup_auto_instrumentation()
155
+
156
+ logger.info(
157
+ "OpenTelemetry tracing started",
158
+ extra={
159
+ "service": self.settings.service_name,
160
+ "exporters": len(self._span_exporters),
161
+ },
162
+ )
163
+
164
+ if self.settings.metrics_enabled:
165
+ meter_provider = MeterProvider(resource=resource)
166
+ metrics.set_meter_provider(meter_provider)
167
+ registry.register_value(MeterProvider, meter_provider)
168
+
169
+ try:
170
+ yield {"tracer_provider": self._tracer_provider}
171
+ finally:
172
+ if self._tracer_provider:
173
+ self._tracer_provider.shutdown()
174
+ logger.info("OpenTelemetry shutdown")
175
+
176
+ def middleware(self) -> list[Middleware]:
177
+ if not self.settings.enabled or not self.settings.traces_enabled:
178
+ return []
179
+
180
+ return [
181
+ Middleware(
182
+ OTelMiddleware,
183
+ excluded_paths=set(self.settings.excluded_paths),
184
+ )
185
+ ]
186
+
187
+ def _setup_auto_instrumentation(self) -> None:
188
+ """Setup auto-instrumentation based on settings."""
189
+ instrumentors = [
190
+ (
191
+ self.settings.instrument_fastapi,
192
+ "opentelemetry.instrumentation.fastapi",
193
+ "FastAPIInstrumentor",
194
+ ),
195
+ (
196
+ self.settings.instrument_sqlalchemy,
197
+ "opentelemetry.instrumentation.sqlalchemy",
198
+ "SQLAlchemyInstrumentor",
199
+ ),
200
+ (
201
+ self.settings.instrument_httpx,
202
+ "opentelemetry.instrumentation.httpx",
203
+ "HTTPXClientInstrumentor",
204
+ ),
205
+ (
206
+ self.settings.instrument_redis,
207
+ "opentelemetry.instrumentation.redis",
208
+ "RedisInstrumentor",
209
+ ),
210
+ ]
211
+
212
+ for enabled, module, class_name in instrumentors:
213
+ if enabled:
214
+ self._try_instrument(module, class_name)
215
+
216
+ def _try_instrument(self, module: str, class_name: str) -> None:
217
+ try:
218
+ import importlib
219
+
220
+ mod = importlib.import_module(module)
221
+ instrumentor = getattr(mod, class_name)()
222
+ instrumentor.instrument()
223
+ logger.debug(f"{class_name} auto-instrumentation enabled")
224
+ except ImportError:
225
+ logger.debug(f"{class_name} instrumentation not available")
226
+ except Exception as e:
227
+ logger.warning(f"{class_name} instrumentation failed: {e}")
228
+
229
+
230
+ class OTelMiddleware(BaseHTTPMiddleware):
231
+ """Middleware to enrich spans and logs with trace context."""
232
+
233
+ def __init__(self, app, excluded_paths: set[str] | None = None):
234
+ super().__init__(app)
235
+ self.excluded_paths = excluded_paths or set()
236
+
237
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
238
+ if request.url.path in self.excluded_paths:
239
+ return await call_next(request)
240
+
241
+ span = trace.get_current_span()
242
+ ctx = span.get_span_context()
243
+
244
+ # Bind trace context to structlog for this request
245
+ if ctx.is_valid:
246
+ structlog.contextvars.bind_contextvars(
247
+ trace_id=format(ctx.trace_id, "032x"),
248
+ span_id=format(ctx.span_id, "016x"),
249
+ )
250
+
251
+ if span.is_recording():
252
+ # span.set_attribute("http.client_ip", _get_client_ip(request))
253
+ span.set_attribute("http.user_agent", request.headers.get("user-agent", ""))
254
+
255
+ if request.url.query:
256
+ span.set_attribute("http.query_string", str(request.url.query))
257
+
258
+ if request_id := request.headers.get("x-request-id"):
259
+ span.set_attribute("http.request_id", request_id)
260
+
261
+ try:
262
+ response = await call_next(request)
263
+
264
+ if span.is_recording():
265
+ if content_length := response.headers.get("content-length"):
266
+ span.set_attribute(
267
+ "http.response_content_length", int(content_length)
268
+ )
269
+
270
+ return response
271
+
272
+ except Exception as exc:
273
+ if span.is_recording():
274
+ span.set_status(Status(StatusCode.ERROR, str(exc)))
275
+ span.record_exception(exc)
276
+ raise
277
+
278
+ finally:
279
+ structlog.contextvars.unbind_contextvars("trace_id", "span_id")
cuneus/utils.py CHANGED
@@ -6,9 +6,7 @@ def import_from_string(import_str: str) -> typing.Any:
6
6
  """Import an object from a module:attribute string."""
7
7
  module_path, _, attr = import_str.partition(":")
8
8
  if not attr:
9
- raise ValueError(
10
- f"module_path missing function {import_str} expecting 'module.path:name'"
11
- )
9
+ raise ValueError(f"module_path missing function {import_str} expecting 'module.path:name'")
12
10
 
13
11
  module = importlib.import_module(module_path)
14
12
  return getattr(module, attr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: ASGI application wrapper
5
5
  Project-URL: Homepage, https://github.com/rmyers/cuneus
6
6
  Project-URL: Documentation, https://github.com/rmyers/cuneus#readme
@@ -24,11 +24,15 @@ Requires-Dist: alembic>=1.13.0; extra == 'database'
24
24
  Requires-Dist: asyncpg>=0.29.0; extra == 'database'
25
25
  Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'database'
26
26
  Provides-Extra: dev
27
+ Requires-Dist: aiosqlite[dev]>=0.22.1; extra == 'dev'
27
28
  Requires-Dist: alembic>=1.13.0; extra == 'dev'
28
29
  Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
29
30
  Requires-Dist: asyncpg>=0.29.0; extra == 'dev'
30
31
  Requires-Dist: httpx>=0.27; extra == 'dev'
31
32
  Requires-Dist: mypy>=1.8; extra == 'dev'
33
+ Requires-Dist: opentelemetry-api; extra == 'dev'
34
+ Requires-Dist: opentelemetry-instrumentation; extra == 'dev'
35
+ Requires-Dist: opentelemetry-sdk; extra == 'dev'
32
36
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
37
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
34
38
  Requires-Dist: pytest-mock; extra == 'dev'
@@ -0,0 +1,20 @@
1
+ cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
2
+ cuneus/cli.py,sha256=Me84yXEWpsa5QMDcESKQPZhBgZnFJ3iKBshAXtlj2AM,1490
3
+ cuneus/dependencies.py,sha256=97VAZKdL7MmP-wKAmwQ-AzADYeG4gYAnys_ru-PaqVg,2160
4
+ cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cuneus/utils.py,sha256=A2GN2gWjf3MdJi3bcFRxFWWDEy2t8bQOYdu8N0ZhqAE,401
6
+ cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ cuneus/core/application.py,sha256=AFBzaAMX-jJZvapSe-5prvIeaRf2ynJwrKk9Tvk1nkc,4469
8
+ cuneus/core/exceptions.py,sha256=GRtMz4tZZzpA2-Zwx4nBEc5SGcWrrFf0OWFYeHCpU0I,5468
9
+ cuneus/core/extensions.py,sha256=fO9RaJ00Bw9s0VsnCQAAdo-zR9N8573uO3YP2fYUQc4,2934
10
+ cuneus/core/logging.py,sha256=Ql2VDQesZaDRdhua2HLYzvNhkIPVsTGUqxwYBkUI2dc,4581
11
+ cuneus/core/settings.py,sha256=75NRttKkLpT6AxVd6dj3boYq05j5td32-c7fAlTjLsg,2247
12
+ cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ cuneus/ext/database.py,sha256=BED5A73U-yVVPxUJe1nuzOBee7D0QnMvcmk8V_3hUNI,8987
14
+ cuneus/ext/health.py,sha256=l561FKnOhlsuCxJF2lSxwiEagdakixoaD78MGz4SL1s,3590
15
+ cuneus/ext/otel.py,sha256=ye4N-WL6xQpxTN8RMj06Q3yQA1CFLrY4J1xsjQjGvUY,9565
16
+ cuneus/ext/server.py,sha256=wyQMiHFZEFYt1a4wC4IjorEp70kH7fF2aLx3_X5t5RI,1869
17
+ cuneus-0.2.10.dist-info/METADATA,sha256=99NJ0rkBSUoc9y-agUxJ5on_oDBdx6s77Fz33rxUQ5Q,7051
18
+ cuneus-0.2.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
+ cuneus-0.2.10.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
20
+ cuneus-0.2.10.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
2
- cuneus/cli.py,sha256=c8QbGj9QLcr-XJNUgCiSeaO1n5oY3DWBOMj2ruAxUwM,1512
3
- cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- cuneus/utils.py,sha256=WyykPUXwxJWpFny5uL87_Fqd2R34za7gXUOf4IyeC0w,423
5
- cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- cuneus/core/application.py,sha256=LHFLqdQvIbjsN1iCVkFJkPFFoIWNGWP2DAPjmI3IWaY,4489
7
- cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
8
- cuneus/core/extensions.py,sha256=fO9RaJ00Bw9s0VsnCQAAdo-zR9N8573uO3YP2fYUQc4,2934
9
- cuneus/core/logging.py,sha256=Ql2VDQesZaDRdhua2HLYzvNhkIPVsTGUqxwYBkUI2dc,4581
10
- cuneus/core/settings.py,sha256=cHHqdKtAjcTPBKXnfG9_GMJiTr7t-iX7rPvIQ480UkI,2248
11
- cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- cuneus/ext/health.py,sha256=6JG25foR5ln2tnRuGYuTVju8bBv16iVR4xKpdf2OVa0,3596
13
- cuneus/ext/server.py,sha256=wyQMiHFZEFYt1a4wC4IjorEp70kH7fF2aLx3_X5t5RI,1869
14
- cuneus-0.2.9.dist-info/METADATA,sha256=90bfNg2zE9iFbH9Rd6luS1txjL6PRutr8e6BZosyyiA,6837
15
- cuneus-0.2.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
- cuneus-0.2.9.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
17
- cuneus-0.2.9.dist-info/RECORD,,