cuneus 0.2.9__tar.gz → 0.2.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {cuneus-0.2.9 → cuneus-0.2.10}/PKG-INFO +5 -1
  2. {cuneus-0.2.9 → cuneus-0.2.10}/pyproject.toml +9 -5
  3. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/cli.py +1 -3
  4. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/application.py +2 -6
  5. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/exceptions.py +2 -6
  6. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/settings.py +0 -1
  7. cuneus-0.2.10/src/cuneus/dependencies.py +79 -0
  8. cuneus-0.2.10/src/cuneus/ext/database.py +278 -0
  9. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/ext/health.py +1 -3
  10. cuneus-0.2.10/src/cuneus/ext/otel.py +279 -0
  11. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/utils.py +1 -3
  12. {cuneus-0.2.9 → cuneus-0.2.10}/tests/cli/test_cli.py +2 -6
  13. cuneus-0.2.10/tests/ext/test_database.py +96 -0
  14. cuneus-0.2.10/tests/ext/test_otel.py +130 -0
  15. cuneus-0.2.10/tests/test_dependencies.py +111 -0
  16. {cuneus-0.2.9 → cuneus-0.2.10}/tests/test_integration.py +0 -3
  17. {cuneus-0.2.9 → cuneus-0.2.10}/uv.lock +786 -114
  18. {cuneus-0.2.9 → cuneus-0.2.10}/.gitignore +0 -0
  19. {cuneus-0.2.9 → cuneus-0.2.10}/.python-version +0 -0
  20. {cuneus-0.2.9 → cuneus-0.2.10}/Makefile +0 -0
  21. {cuneus-0.2.9 → cuneus-0.2.10}/README.md +0 -0
  22. {cuneus-0.2.9 → cuneus-0.2.10}/examples/my_app/__init__.py +0 -0
  23. {cuneus-0.2.9 → cuneus-0.2.10}/examples/my_app/main.py +0 -0
  24. {cuneus-0.2.9 → cuneus-0.2.10}/examples/pyproject.toml +0 -0
  25. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/__init__.py +0 -0
  26. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/__init__.py +0 -0
  27. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/extensions.py +0 -0
  28. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/core/logging.py +0 -0
  29. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/ext/__init__.py +0 -0
  30. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/ext/server.py +0 -0
  31. {cuneus-0.2.9 → cuneus-0.2.10}/src/cuneus/py.typed +0 -0
  32. {cuneus-0.2.9 → cuneus-0.2.10}/tests/cli/testapp/__init__.py +0 -0
  33. {cuneus-0.2.9 → cuneus-0.2.10}/tests/cli/testapp/main.py +0 -0
  34. {cuneus-0.2.9 → cuneus-0.2.10}/tests/cli/testapp/pyproject.toml +0 -0
  35. {cuneus-0.2.9 → cuneus-0.2.10}/tests/ext/test_health.py +0 -0
  36. {cuneus-0.2.9 → cuneus-0.2.10}/tests/test_exceptions.py +0 -0
  37. {cuneus-0.2.9 → cuneus-0.2.10}/tests/test_extensions.py +0 -0
  38. {cuneus-0.2.9 → cuneus-0.2.10}/tests/test_utils.py +0 -0
@@ -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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cuneus"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  description = "ASGI application wrapper"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
@@ -18,20 +18,24 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
- database = ["sqlalchemy[asyncio]>=2.0", "asyncpg>=0.29.0", "alembic>=1.13.0"]
22
- redis = ["redis>=5.0"]
23
- all = ["cuneus[database,redis]"]
24
21
  dev = [
22
+ "asgi-lifespan>=2.1.0",
23
+ "aiosqlite[dev]>=0.22.1",
25
24
  "cuneus[all]",
25
+ "opentelemetry-sdk",
26
+ "opentelemetry-api",
27
+ "opentelemetry-instrumentation",
26
28
  "pytest>=8.0",
27
29
  "pytest-asyncio>=0.23",
28
30
  "pytest-cov>=4.0",
29
31
  "pytest-mock",
30
32
  "httpx>=0.27",
31
- "asgi-lifespan>=2.1.0",
32
33
  "ruff>=0.3",
33
34
  "mypy>=1.8",
34
35
  ]
36
+ database = ["sqlalchemy[asyncio]>=2.0", "asyncpg>=0.29.0", "alembic>=1.13.0"]
37
+ redis = ["redis>=5.0"]
38
+ all = ["cuneus[database,redis]"]
35
39
 
36
40
  [project.scripts]
37
41
  cuneus = "cuneus.cli:main"
@@ -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
 
@@ -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": {
@@ -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",
@@ -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
@@ -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}")
@@ -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