cuneus 0.2.8__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
@@ -1,118 +1,30 @@
1
- """Base CLI that cuneus provides."""
1
+ """Cuneus CLI entry point."""
2
2
 
3
- from pathlib import Path
4
-
5
- import importlib
6
- import sys
7
3
  from typing import Any, cast
8
4
 
9
5
  import click
10
6
 
11
- from .core.settings import Settings
12
-
13
-
14
- def import_from_string(import_str: str) -> Any:
15
- """Import an object from a module:attribute string."""
16
- # Ensure cwd is in path for local imports
17
- cwd = str(Path.cwd())
18
- if cwd not in sys.path:
19
- sys.path.insert(0, cwd)
20
-
21
- module_path, _, attr = import_str.partition(":")
22
- if not attr:
23
- raise ValueError(
24
- f"module_path missing function {import_str} expecting 'module.path:name'"
25
- )
26
-
27
- module = importlib.import_module(module_path)
28
- return getattr(module, attr)
29
-
7
+ from .core.settings import Settings, ensure_project_in_path
8
+ from .utils import import_from_string
30
9
 
31
- def get_user_cli() -> click.Group | None:
32
- """Attempt to load user's CLI from config."""
33
- config = Settings()
34
10
 
11
+ def get_user_cli(config: Settings = Settings()) -> click.Group | None:
12
+ """Load CLI from config."""
35
13
  try:
36
14
  return cast(click.Group, import_from_string(config.cli_module))
37
15
  except (ImportError, AttributeError) as e:
38
- click.echo(
39
- f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
40
- )
41
-
42
- return None
43
-
44
-
45
- @click.group()
46
- @click.pass_context
47
- def cli(ctx: click.Context) -> None: # pragma: no cover
48
- """Cuneus CLI - FastAPI application framework."""
49
- ctx.ensure_object(dict)
50
-
51
-
52
- @cli.command()
53
- @click.option("--host", default="0.0.0.0", help="Bind host")
54
- @click.option("--port", default=8000, type=int, help="Bind port")
55
- def dev(host: str, port: int) -> None:
56
- """Run the application server."""
57
- import uvicorn
58
-
59
- config = Settings()
60
-
61
- uvicorn.run(
62
- config.app_module,
63
- host=host,
64
- port=port,
65
- reload=True,
66
- log_config=None,
67
- server_header=False,
68
- )
69
-
70
-
71
- @cli.command()
72
- @click.option("--host", default="0.0.0.0", help="Bind host")
73
- @click.option("--port", default=8000, type=int, help="Bind port")
74
- @click.option("--workers", default=1, type=int, help="Number of workers")
75
- def prod(host: str, port: int, workers: int) -> None:
76
- """Run the application server."""
77
- import uvicorn
78
-
79
- config = Settings()
80
-
81
- uvicorn.run(
82
- config.app_module,
83
- host=host,
84
- port=port,
85
- workers=workers,
86
- log_config=None,
87
- server_header=False,
88
- )
89
-
90
-
91
- @cli.command()
92
- def routes() -> None:
93
- """List all registered routes."""
94
- config = Settings()
95
- app = import_from_string(config.app_module)
96
-
97
- for route in app.routes:
98
- if hasattr(route, "methods"): # pragma: no branch
99
- methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
100
- click.echo(f"{methods:8} {route.path}")
16
+ click.echo(f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True)
17
+ return None
101
18
 
102
19
 
103
20
  class CuneusCLI(click.Group):
104
- """Merges base cuneus commands with user's app CLI."""
21
+ """Delegates to the app's CLI from config."""
105
22
 
106
23
  def __init__(self, *args: Any, **kwargs: Any) -> None:
107
24
  super().__init__(*args, **kwargs)
108
25
  self._user_cli: click.Group | None = None
109
26
  self._user_cli_loaded = False
110
27
 
111
- # Register base commands directly
112
- self.add_command(dev)
113
- self.add_command(prod)
114
- self.add_command(routes)
115
-
116
28
  @property
117
29
  def user_cli(self) -> click.Group | None:
118
30
  if not self._user_cli_loaded:
@@ -121,21 +33,17 @@ class CuneusCLI(click.Group):
121
33
  return self._user_cli
122
34
 
123
35
  def list_commands(self, ctx: click.Context) -> list[str]:
124
- commands = set(super().list_commands(ctx))
125
36
  if self.user_cli:
126
- commands.update(self.user_cli.list_commands(ctx))
127
- return sorted(commands)
37
+ return self.user_cli.list_commands(ctx)
38
+ return []
128
39
 
129
40
  def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
130
- # User CLI takes priority
131
41
  if self.user_cli:
132
- cmd = self.user_cli.get_command(ctx, cmd_name)
133
- if cmd:
134
- return cmd
135
- return super().get_command(ctx, cmd_name)
42
+ return self.user_cli.get_command(ctx, cmd_name)
43
+ return None
136
44
 
137
45
 
138
- # This is the actual entry point
46
+ ensure_project_in_path()
139
47
  main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
140
48
 
141
49
 
@@ -19,8 +19,9 @@ from starlette.middleware import Middleware
19
19
  from .settings import Settings
20
20
  from .exceptions import ExceptionExtension
21
21
  from .logging import LoggingExtension
22
- from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware
22
+ from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware, HasRoutes
23
23
  from ..ext.health import HealthExtension
24
+ from ..ext.server import ServerExtension
24
25
 
25
26
  logger = structlog.stdlib.get_logger("cuneus")
26
27
 
@@ -30,6 +31,7 @@ DEFAULTS = (
30
31
  LoggingExtension,
31
32
  HealthExtension,
32
33
  ExceptionExtension,
34
+ ServerExtension,
33
35
  )
34
36
 
35
37
 
@@ -39,9 +41,7 @@ class ExtensionConflictError(Exception):
39
41
  pass
40
42
 
41
43
 
42
- def _instantiate_extension(
43
- ext: ExtensionInput, settings: Settings | None = None
44
- ) -> Extension:
44
+ def _instantiate_extension(ext: ExtensionInput, settings: Settings | None = None) -> Extension:
45
45
  if isinstance(ext, type) or callable(ext):
46
46
  try:
47
47
  return ext(settings=settings)
@@ -56,7 +56,7 @@ def build_app(
56
56
  settings: Settings | None = None,
57
57
  include_defaults: bool = True,
58
58
  **fastapi_kwargs: Any,
59
- ) -> tuple[FastAPI, click.Group]:
59
+ ) -> tuple[FastAPI, click.Group, svcs.fastapi.lifespan]:
60
60
  """
61
61
  Build a FastAPI with extensions preconfigured.
62
62
 
@@ -93,17 +93,9 @@ def build_app(
93
93
 
94
94
  all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
95
95
 
96
- @click.group()
97
- @click.pass_context
98
- def app_cli(ctx: click.Context) -> None:
99
- """Application CLI."""
100
- ctx.ensure_object(dict)
101
-
102
96
  @svcs.fastapi.lifespan
103
97
  @asynccontextmanager
104
- async def lifespan(
105
- app: FastAPI, registry: svcs.Registry
106
- ) -> AsyncIterator[dict[str, Any]]:
98
+ async def lifespan(app: FastAPI, registry: svcs.Registry) -> AsyncIterator[dict[str, Any]]:
107
99
  async with AsyncExitStack() as stack:
108
100
  state: dict[str, Any] = {}
109
101
 
@@ -121,6 +113,7 @@ def build_app(
121
113
 
122
114
  # Parse extensions for middleware and cli commands
123
115
  middleware: list[Middleware] = []
116
+ app_cli = click.Group()
124
117
 
125
118
  for ext in all_extensions:
126
119
  ext_name = ext.__class__.__name__
@@ -139,5 +132,8 @@ def build_app(
139
132
  if isinstance(ext, HasExceptionHandler):
140
133
  logger.debug(f"Loading exception handlers from {ext_name}")
141
134
  ext.add_exception_handler(app)
135
+ if isinstance(ext, HasRoutes):
136
+ logger.debug(f"Loading routes from {ext_name}")
137
+ ext.add_routes(app)
142
138
 
143
- return app, app_cli
139
+ return app, app_cli, lifespan
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/extensions.py CHANGED
@@ -67,6 +67,13 @@ class HasExceptionHandler(Protocol):
67
67
  def add_exception_handler(self, app: FastAPI) -> None: ... # pragma: no cover
68
68
 
69
69
 
70
+ @runtime_checkable
71
+ class HasRoutes(Protocol):
72
+ """Extension that provides routes."""
73
+
74
+ def add_routes(self, app: FastAPI) -> None: ... # pragma: no cover
75
+
76
+
70
77
  class BaseExtension:
71
78
  """
72
79
  Base class for extensions with explicit startup/shutdown hooks.
cuneus/core/logging.py CHANGED
@@ -43,7 +43,7 @@ def configure_structlog(settings: Settings | None = None) -> None:
43
43
  term_width = shutil.get_terminal_size().columns
44
44
  pad_event = term_width - 36
45
45
  renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
46
- colors=True, pad_event=pad_event
46
+ colors=True, pad_event_to=pad_event
47
47
  )
48
48
 
49
49
  # Configure structlog
cuneus/core/settings.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
3
+ import pathlib
4
+ import sys
4
5
  from pydantic_settings import (
5
6
  BaseSettings,
6
7
  PydanticBaseSettingsSource,
@@ -8,8 +9,6 @@ from pydantic_settings import (
8
9
  SettingsConfigDict,
9
10
  )
10
11
 
11
- logger = logging.getLogger(__name__)
12
-
13
12
  DEFAULT_TOOL_NAME = "cuneus"
14
13
 
15
14
 
@@ -40,7 +39,6 @@ class CuneusBaseSettings(BaseSettings):
40
39
 
41
40
 
42
41
  class Settings(CuneusBaseSettings):
43
-
44
42
  model_config = SettingsConfigDict(
45
43
  env_file=".env",
46
44
  env_file_encoding="utf-8",
@@ -64,3 +62,21 @@ class Settings(CuneusBaseSettings):
64
62
  # health
65
63
  health_enabled: bool = True
66
64
  health_prefix: str = "/healthz"
65
+
66
+ @classmethod
67
+ def get_project_root(cls) -> pathlib.Path:
68
+ """
69
+ Get the project root by inspecting where pydantic-settings
70
+ found the pyproject.toml file.
71
+ """
72
+ source = PyprojectTomlConfigSettingsSource(
73
+ cls,
74
+ )
75
+ return source.toml_file_path.parent
76
+
77
+
78
+ def ensure_project_in_path() -> None:
79
+ """Add project root to sys.path if not already present."""
80
+ project_root = str(Settings.get_project_root())
81
+ if project_root not in sys.path: # pragma: no branch
82
+ sys.path.insert(0, project_root)
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
@@ -9,13 +9,14 @@ from typing import Any
9
9
 
10
10
  import structlog
11
11
  import svcs
12
- from fastapi import APIRouter, FastAPI
12
+ from fastapi import APIRouter, FastAPI, Request
13
13
  from pydantic import BaseModel
14
14
 
15
15
  from ..core.extensions import BaseExtension
16
16
  from ..core.settings import Settings
17
17
 
18
18
  log = structlog.get_logger()
19
+ health_router = APIRouter()
19
20
 
20
21
 
21
22
  class HealthStatus(str, Enum):
@@ -35,95 +36,91 @@ class HealthResponse(BaseModel):
35
36
  services: list[ServiceHealth] = []
36
37
 
37
38
 
39
+ @health_router.get("", response_model=HealthResponse)
40
+ async def health(services: svcs.fastapi.DepContainer, request: Request) -> HealthResponse:
41
+ """Full health check - pings all registered services."""
42
+ pings = services.get_pings()
43
+
44
+ _services: list[ServiceHealth] = []
45
+ overall_healthy = True
46
+
47
+ for ping in pings:
48
+ try:
49
+ await ping.aping()
50
+ _services.append(
51
+ ServiceHealth(
52
+ name=ping.name,
53
+ status=HealthStatus.HEALTHY,
54
+ )
55
+ )
56
+ except Exception as e:
57
+ log.warning("health_check_failed", service=ping.name, error=str(e))
58
+ _services.append(
59
+ ServiceHealth(
60
+ name=ping.name,
61
+ status=HealthStatus.UNHEALTHY,
62
+ message=str(e),
63
+ )
64
+ )
65
+ overall_healthy = False
66
+
67
+ return HealthResponse(
68
+ status=(HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY),
69
+ version=getattr(request.state, "version", "NA"),
70
+ services=_services,
71
+ )
72
+
73
+
74
+ @health_router.get("/live")
75
+ async def liveness() -> dict[str, str]:
76
+ """Liveness probe - is the process running?"""
77
+ return {"status": "ok"}
78
+
79
+
80
+ @health_router.get("/ready")
81
+ async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
82
+ """Readiness probe - can we serve traffic?"""
83
+ from fastapi import HTTPException
84
+
85
+ pings = services.get_pings()
86
+
87
+ for ping in pings:
88
+ try:
89
+ await ping.aping()
90
+ except Exception as e:
91
+ log.warning("readiness_check_failed", service=ping.name, error=str(e))
92
+ raise HTTPException(status_code=503, detail=f"{ping.name} unhealthy")
93
+
94
+ return {"status": "ok"}
95
+
96
+
38
97
  class HealthExtension(BaseExtension):
39
98
  """
40
99
  Health check extension using svcs pings.
41
100
 
42
101
  Adds:
43
- GET /health - Full health check using svcs pings
44
- GET /health/live - Liveness probe (always 200)
45
- GET /health/ready - Readiness probe (503 if unhealthy)
46
-
47
- Usage:
48
- from qtip import build_app
49
- from qtip.ext.health import HealthExtension, HealthSettings
50
-
51
- app = build_app(
52
- settings,
53
- extensions=[HealthExtension(settings)],
54
- )
102
+ GET /healthz - Full health check using svcs pings
103
+ GET /healthz/live - Liveness probe (always 200)
104
+ GET /healthz/ready - Readiness probe (503 if unhealthy)
105
+
106
+ Settings:
107
+ - `health_enabled`: bool to disable health routes, default True
108
+ - `health_prefix`: prefix to include routes default: '/healthz'
109
+ - `version`: used to display in the response
55
110
  """
56
111
 
57
112
  def __init__(self, settings: Settings | None = None) -> None:
58
113
  self.settings = settings or Settings()
59
114
 
60
115
  async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
61
- if not self.settings.health_enabled:
62
- return {}
63
-
64
- router = APIRouter(prefix=self.settings.health_prefix, tags=["health"])
65
- version = self.settings.version
66
-
67
- @router.get("", response_model=HealthResponse)
68
- async def health(services: svcs.fastapi.DepContainer) -> HealthResponse:
69
- """Full health check - pings all registered services."""
70
- pings = services.get_pings()
71
-
72
- _services: list[ServiceHealth] = []
73
- overall_healthy = True
74
-
75
- for ping in pings:
76
- try:
77
- await ping.aping()
78
- _services.append(
79
- ServiceHealth(
80
- name=ping.name,
81
- status=HealthStatus.HEALTHY,
82
- )
83
- )
84
- except Exception as e:
85
- log.warning("health_check_failed", service=ping.name, error=str(e))
86
- _services.append(
87
- ServiceHealth(
88
- name=ping.name,
89
- status=HealthStatus.UNHEALTHY,
90
- message=str(e),
91
- )
92
- )
93
- overall_healthy = False
94
-
95
- return HealthResponse(
96
- status=(
97
- HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY
98
- ),
99
- version=version,
100
- services=_services,
116
+ state = await super().startup(registry, app)
117
+ state["version"] = self.settings.version
118
+ return state
119
+
120
+ def add_routes(self, app: FastAPI) -> None:
121
+ if self.settings.health_enabled:
122
+ app.include_router(
123
+ health_router,
124
+ prefix=self.settings.health_prefix,
125
+ tags=["health"],
101
126
  )
102
-
103
- @router.get("/live")
104
- async def liveness() -> dict[str, str]:
105
- """Liveness probe - is the process running?"""
106
- return {"status": "ok"}
107
-
108
- @router.get("/ready")
109
- async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
110
- """Readiness probe - can we serve traffic?"""
111
- from fastapi import HTTPException
112
-
113
- pings = services.get_pings()
114
-
115
- for ping in pings:
116
- try:
117
- await ping.aping()
118
- except Exception as e:
119
- log.warning(
120
- "readiness_check_failed", service=ping.name, error=str(e)
121
- )
122
- raise HTTPException(
123
- status_code=503, detail=f"{ping.name} unhealthy"
124
- )
125
-
126
- return {"status": "ok"}
127
-
128
- app.include_router(router)
129
- return {}
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/ext/server.py ADDED
@@ -0,0 +1,54 @@
1
+ import click
2
+
3
+ from ..core.extensions import BaseExtension
4
+ from ..utils import import_from_string
5
+
6
+
7
+ class ServerExtension(BaseExtension):
8
+ """Provides dev/prod/routes CLI commands."""
9
+
10
+ def register_cli(self, cli_group: click.Group) -> None:
11
+ settings = self.settings
12
+
13
+ @cli_group.command()
14
+ @click.option("--host", default="0.0.0.0", help="Bind host")
15
+ @click.option("--port", default=8000, type=int, help="Bind port")
16
+ def dev(host: str, port: int) -> None:
17
+ """Run the application in development mode with reload."""
18
+ import uvicorn
19
+
20
+ uvicorn.run(
21
+ settings.app_module,
22
+ host=host,
23
+ port=port,
24
+ reload=True,
25
+ log_config=None,
26
+ server_header=False,
27
+ )
28
+
29
+ @cli_group.command()
30
+ @click.option("--host", default="0.0.0.0", help="Bind host")
31
+ @click.option("--port", default=8000, type=int, help="Bind port")
32
+ @click.option("--workers", default=1, type=int, help="Number of workers")
33
+ def prod(host: str, port: int, workers: int) -> None:
34
+ """Run the application in production mode."""
35
+ import uvicorn
36
+
37
+ uvicorn.run(
38
+ settings.app_module,
39
+ host=host,
40
+ port=port,
41
+ workers=workers,
42
+ log_config=None,
43
+ server_header=False,
44
+ )
45
+
46
+ @cli_group.command()
47
+ def routes() -> None:
48
+ """List all registered routes."""
49
+ app = import_from_string(settings.app_module)
50
+
51
+ for route in app.routes:
52
+ if hasattr(route, "methods"): # pragma: no branch
53
+ methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
54
+ click.echo(f"{methods:8} {route.path}")
cuneus/utils.py ADDED
@@ -0,0 +1,12 @@
1
+ import importlib
2
+ import typing
3
+
4
+
5
+ def import_from_string(import_str: str) -> typing.Any:
6
+ """Import an object from a module:attribute string."""
7
+ module_path, _, attr = import_str.partition(":")
8
+ if not attr:
9
+ raise ValueError(f"module_path missing function {import_str} expecting 'module.path:name'")
10
+
11
+ module = importlib.import_module(module_path)
12
+ return getattr(module, attr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.8
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,15 +0,0 @@
1
- cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
2
- cuneus/cli.py,sha256=rcJDqU28FMPSw7tkmKUz95ERKzgYv43Y2kKVZw24rLo,3864
3
- cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- cuneus/core/application.py,sha256=Xoqs3fREJ-KPzSilq4ua65JzKv6w5LgugGaMklDELdU,4376
6
- cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
7
- cuneus/core/extensions.py,sha256=wqN2rbaqhiTkfK_fA5AkFlefOAV2GJr6zTLf9Oi-n4c,2771
8
- cuneus/core/logging.py,sha256=jWwtPJTNYtvOSb4etg7pglWHAKfV4iRuw4Npoqne_00,4578
9
- cuneus/core/settings.py,sha256=PaYXQ_ubeSt3AFpxNNErii-h1_ehHYPrajFWRT42mTI,1703
10
- cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- cuneus/ext/health.py,sha256=5dWVVEPFL1tWFBhQwZv8C-IvZRzg28V-4sk_g1jJ0vc,3854
12
- cuneus-0.2.8.dist-info/METADATA,sha256=Y3d-3jUnaPU_GkOeHpYaxM9AmiEfnjc4WVIo4OZrYjM,6837
13
- cuneus-0.2.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
- cuneus-0.2.8.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
15
- cuneus-0.2.8.dist-info/RECORD,,