cuneus 0.2.1__py3-none-any.whl → 0.2.2__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/__init__.py CHANGED
@@ -15,15 +15,8 @@ Example:
15
15
  fastapi_app = app.build()
16
16
  """
17
17
 
18
- from cuneus.core.application import (
19
- BaseExtension,
20
- Extension,
21
- Settings,
22
- build_lifespan,
23
- get_settings,
24
- load_pyproject_config,
25
- )
26
- from cuneus.core.execptions import (
18
+ from .core.application import build_app
19
+ from .core.execptions import (
27
20
  AppException,
28
21
  BadRequest,
29
22
  Unauthorized,
@@ -37,16 +30,19 @@ from cuneus.core.execptions import (
37
30
  ExternalServiceError,
38
31
  ExceptionExtension,
39
32
  )
33
+ from .core.extensions import BaseExtension, Extension
34
+ from .core.settings import Settings
40
35
 
41
36
  __version__ = "0.2.1"
42
37
  __all__ = [
43
- # Core
38
+ # Core exported functions
39
+ # Application
40
+ "build_app",
41
+ # Extension
44
42
  "BaseExtension",
45
43
  "Extension",
44
+ # Settings
46
45
  "Settings",
47
- "build_lifespan",
48
- "get_settings",
49
- "load_pyproject_config",
50
46
  # Exceptions
51
47
  "AppException",
52
48
  "BadRequest",
cuneus/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """Base CLI that cuneus provides."""
2
+
3
+ import click
4
+ import importlib
5
+ import sys
6
+ from typing import Any, cast
7
+
8
+ from .core.settings import Settings
9
+
10
+
11
+ def import_from_string(import_str: str) -> Any:
12
+ """Import an object from a module:attribute string."""
13
+ module_path, _, attr = import_str.partition(":")
14
+ if not attr:
15
+ attr = "app" # default attribute name
16
+
17
+ module = importlib.import_module(module_path)
18
+ return getattr(module, attr)
19
+
20
+
21
+ def get_user_cli() -> click.Group | None:
22
+ """Attempt to load user's CLI from config."""
23
+ config = Settings()
24
+
25
+ try:
26
+ return cast(click.Group, import_from_string(config.cli_module))
27
+ except (ImportError, AttributeError) as e:
28
+ click.echo(
29
+ f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
30
+ )
31
+
32
+ return None
33
+
34
+
35
+ @click.group()
36
+ @click.pass_context
37
+ def cli(ctx: click.Context) -> None:
38
+ """Cuneus CLI - FastAPI application framework."""
39
+ ctx.ensure_object(dict)
40
+
41
+
42
+ @cli.command()
43
+ @click.option("--host", default="0.0.0.0", help="Bind host")
44
+ @click.option("--port", default=8000, type=int, help="Bind port")
45
+ def dev(host: str, port: int) -> None:
46
+ """Run the application server."""
47
+ import uvicorn
48
+
49
+ config = Settings()
50
+
51
+ uvicorn.run(
52
+ config.app_module,
53
+ host=host,
54
+ port=port,
55
+ reload=True,
56
+ )
57
+
58
+
59
+ @cli.command()
60
+ @click.option("--host", default="0.0.0.0", help="Bind host")
61
+ @click.option("--port", default=8000, type=int, help="Bind port")
62
+ @click.option("--workers", default=1, type=int, help="Number of workers")
63
+ def prod(host: str, port: int, workers: int) -> None:
64
+ """Run the application server."""
65
+ import uvicorn
66
+
67
+ config = Settings()
68
+
69
+ uvicorn.run(
70
+ config.app_module,
71
+ host=host,
72
+ port=port,
73
+ workers=workers,
74
+ )
75
+
76
+
77
+ @cli.command()
78
+ def routes() -> None:
79
+ """List all registered routes."""
80
+ config = Settings()
81
+ app = import_from_string(config.app_module)
82
+
83
+ for route in app.routes:
84
+ if hasattr(route, "methods"):
85
+ methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
86
+ click.echo(f"{methods:8} {route.path}")
87
+
88
+
89
+ class CuneusCLI(click.Group):
90
+ """Merges base cuneus commands with user's app CLI."""
91
+
92
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
93
+ super().__init__(*args, **kwargs)
94
+ self._user_cli: click.Group | None = None
95
+ self._user_cli_loaded = False
96
+
97
+ # Register base commands directly
98
+ self.add_command(dev)
99
+ self.add_command(prod)
100
+ self.add_command(routes)
101
+
102
+ @property
103
+ def user_cli(self) -> click.Group | None:
104
+ if not self._user_cli_loaded:
105
+ self._user_cli = get_user_cli()
106
+ self._user_cli_loaded = True
107
+ return self._user_cli
108
+
109
+ def list_commands(self, ctx: click.Context) -> list[str]:
110
+ commands = set(super().list_commands(ctx))
111
+ if self.user_cli:
112
+ commands.update(self.user_cli.list_commands(ctx))
113
+ return sorted(commands)
114
+
115
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
116
+ # User CLI takes priority
117
+ if self.user_cli:
118
+ cmd = self.user_cli.get_command(ctx, cmd_name)
119
+ if cmd:
120
+ return cmd
121
+ return super().get_command(ctx, cmd_name)
122
+
123
+
124
+ # This is the actual entry point
125
+ main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -6,152 +6,66 @@ Lightweight lifespan management for FastAPI applications.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import logging
10
- import tomllib
11
- import uuid
12
9
  from contextlib import AsyncExitStack, asynccontextmanager
13
- from pathlib import Path
14
- from typing import Any, AsyncContextManager, AsyncIterator, Protocol, runtime_checkable
10
+ from typing import Any, AsyncIterator, Callable
15
11
 
12
+ import click
16
13
  import svcs
17
- from fastapi import FastAPI, Request
18
- from pydantic import model_validator
19
- from pydantic_settings import BaseSettings, SettingsConfigDict
20
- from starlette.types import ASGIApp
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
- DEFAULT_TOOL_NAME = "cuneus"
25
-
26
-
27
- def load_pyproject_config(
28
- tool_name: str = DEFAULT_TOOL_NAME,
29
- path: Path | None = None,
30
- ) -> dict[str, Any]:
31
- """Load configuration from pyproject.toml under [tool.{tool_name}]."""
32
- if path is None:
33
- path = Path.cwd()
34
-
35
- for parent in [path, *path.parents]:
36
- pyproject = parent / "pyproject.toml"
37
- if pyproject.exists():
38
- with open(pyproject, "rb") as f:
39
- data = tomllib.load(f)
40
- return data.get("tool", {}).get(tool_name, {})
41
-
42
- return {}
43
-
44
-
45
- class Settings(BaseSettings):
46
- """
47
- Base settings that loads from:
48
- 1. pyproject.toml [tool.cuneus] (lowest priority)
49
- 2. .env file
50
- 3. Environment variables (highest priority)
14
+ from fastapi import FastAPI
15
+ from starlette.middleware import Middleware
16
+
17
+ from .settings import Settings
18
+ from .execptions import ExceptionExtension
19
+ from .logging import LoggingExtension
20
+ from .extensions import Extension, HasCLI, HasMiddleware
21
+ from ..ext.health import HealthExtension
22
+
23
+
24
+ type ExtensionInput = Extension | Callable[..., Extension]
25
+
26
+ DEFAULT_EXTENSIONS = (
27
+ LoggingExtension,
28
+ HealthExtension,
29
+ ExceptionExtension,
30
+ )
31
+
32
+
33
+ def _instantiate_extension(
34
+ ext: ExtensionInput, settings: Settings | None = None
35
+ ) -> Extension:
36
+ if isinstance(ext, type):
37
+ # It's a class, instantiate it
38
+ return ext(settings)
39
+ if callable(ext):
40
+ # It's a factory function
41
+ return ext(settings)
42
+ # Already an instance
43
+ return ext
44
+
45
+
46
+ def build_app(
47
+ *extensions: ExtensionInput,
48
+ settings: Settings | None = None,
49
+ include_defaults: bool = True,
50
+ **fastapi_kwargs: Any,
51
+ ) -> tuple[FastAPI, click.Group]:
51
52
  """
52
-
53
- model_config = SettingsConfigDict(
54
- env_file=".env",
55
- env_file_encoding="utf-8",
56
- extra="ignore",
57
- )
58
-
59
- app_name: str = "app"
60
- app_module: str = "app.main:app"
61
- debug: bool = False
62
- version: str | None = None
63
-
64
- # logging
65
- log_level: str = "INFO"
66
- log_json: bool = False
67
- log_server_errors: bool = True
68
-
69
- # health
70
- health_enabled: bool = True
71
- health_prefix: str = "/healthz"
72
-
73
- @model_validator(mode="before")
74
- @classmethod
75
- def load_from_pyproject(cls, data: dict[str, Any]) -> dict[str, Any]:
76
- pyproject_config = load_pyproject_config()
77
- return {**pyproject_config, **data}
78
-
79
-
80
- @runtime_checkable
81
- class Extension(Protocol):
82
- """
83
- Protocol for extensions that hook into app lifecycle.
84
-
85
- Extensions can:
86
- - Register services with svcs
87
- - Add routes via app.include_router()
88
- - Add exception handlers via app.add_exception_handler()
89
- - Return state to merge into lifespan state
90
- """
91
-
92
- def register(
93
- self, registry: svcs.Registry, app: FastAPI
94
- ) -> AsyncContextManager[dict[str, Any]]:
95
- """
96
- Async context manager for lifecycle.
97
-
98
- - Enter: startup (register services, add routes, etc.)
99
- - Yield: dict of state to merge into lifespan state
100
- - Exit: shutdown (cleanup resources)
101
- """
102
- ...
103
-
104
-
105
- class BaseExtension:
106
- """
107
- Base class for extensions with explicit startup/shutdown hooks.
108
-
109
- For simple extensions, override startup() and shutdown().
110
- For full control, override register() directly.
111
- """
112
-
113
- async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
114
- """
115
- Override to setup resources during app startup.
116
-
117
- You can call app.include_router(), app.add_exception_handler(), etc.
118
- Returns a dict of state to merge into lifespan state.
119
- """
120
- return {}
121
-
122
- async def shutdown(self, app: FastAPI) -> None:
123
- """Override to cleanup resources during app shutdown."""
124
- pass
125
-
126
- @asynccontextmanager
127
- async def register(
128
- self, registry: svcs.Registry, app: FastAPI
129
- ) -> AsyncIterator[dict[str, Any]]:
130
- """Wraps startup/shutdown into async context manager."""
131
- state = await self.startup(registry, app)
132
- try:
133
- yield state
134
- finally:
135
- await self.shutdown(app)
136
-
137
-
138
- def build_lifespan(settings: Settings, *extensions: Extension):
139
- """
140
- Create a lifespan context manager for FastAPI.
53
+ Build a FastAPI with extensions preconfigured.
141
54
 
142
55
  The returned lifespan has a `.registry` attribute for testing overrides.
143
56
 
144
57
  Usage:
145
- from cuneus import build_lifespan, Settings
58
+ from cuneus import build_app, Settings, SettingsExtension
146
59
  from myapp.extensions import DatabaseExtension
147
60
 
148
61
  settings = Settings()
149
- lifespan = build_lifespan(
150
- settings,
62
+ app, cli = build_app(
63
+ SettingsExtension(settings),
151
64
  DatabaseExtension(settings),
65
+ title="Args are passed to FastAPI",
152
66
  )
153
67
 
154
- app = FastAPI(lifespan=lifespan, title="My App")
68
+ __all__ = ["app", "cli"]
155
69
 
156
70
  Testing:
157
71
  from myapp import app, lifespan
@@ -159,8 +73,29 @@ def build_lifespan(settings: Settings, *extensions: Extension):
159
73
  def test_with_mock_db(client):
160
74
  mock_db = Mock(spec=Database)
161
75
  lifespan.registry.register_value(Database, mock_db)
162
- # ...
163
76
  """
77
+ if "lifespan" in fastapi_kwargs:
78
+ raise AttributeError("cannot set lifespan with build_app")
79
+ if "middleware" in fastapi_kwargs:
80
+ raise AttributeError("cannot set middleware with build_app")
81
+
82
+ settings = settings or Settings()
83
+
84
+ if include_defaults:
85
+ # Allow users to override a default extension
86
+ user_types = {type(ext) for ext in extensions}
87
+ defaults = [ext for ext in DEFAULT_EXTENSIONS if type(ext) not in user_types]
88
+ all_inputs = (*defaults, *extensions)
89
+ else:
90
+ all_inputs = extensions
91
+
92
+ all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
93
+
94
+ @click.group()
95
+ @click.pass_context
96
+ def app_cli(ctx: click.Context) -> None:
97
+ """Application CLI."""
98
+ ctx.ensure_object(dict)
164
99
 
165
100
  @svcs.fastapi.lifespan
166
101
  @asynccontextmanager
@@ -168,9 +103,9 @@ def build_lifespan(settings: Settings, *extensions: Extension):
168
103
  app: FastAPI, registry: svcs.Registry
169
104
  ) -> AsyncIterator[dict[str, Any]]:
170
105
  async with AsyncExitStack() as stack:
171
- state: dict[str, Any] = {"settings": settings}
106
+ state: dict[str, Any] = {}
172
107
 
173
- for ext in extensions:
108
+ for ext in all_extensions:
174
109
  ext_state = await stack.enter_async_context(ext.register(registry, app))
175
110
  if ext_state:
176
111
  if overlap := state.keys() & ext_state.keys():
@@ -179,52 +114,14 @@ def build_lifespan(settings: Settings, *extensions: Extension):
179
114
 
180
115
  yield state
181
116
 
182
- return lifespan
183
-
184
-
185
- class RequestIDMiddleware:
186
- """
187
- Middleware that adds a unique request_id to each request.
188
-
189
- Access via request.state.request_id
190
- """
191
-
192
- def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID"):
193
- self.app = app
194
- self.header_name = header_name
195
-
196
- async def __call__(self, scope, receive, send):
197
- if scope["type"] != "http":
198
- await self.app(scope, receive, send)
199
- return
200
-
201
- # Check for existing request ID in headers, or generate new one
202
- headers = dict(scope.get("headers", []))
203
- request_id = headers.get(
204
- self.header_name.lower().encode(), str(uuid.uuid4())[:8].encode()
205
- ).decode()
206
-
207
- # Store in scope state
208
- if "state" not in scope:
209
- scope["state"] = {}
210
- scope["state"]["request_id"] = request_id
211
-
212
- # Add request ID to response headers
213
- async def send_with_request_id(message):
214
- if message["type"] == "http.response.start":
215
- headers = list(message.get("headers", []))
216
- headers.append((self.header_name.encode(), request_id.encode()))
217
- message["headers"] = headers
218
- await send(message)
219
-
220
- await self.app(scope, receive, send_with_request_id)
221
-
222
-
223
- def get_settings(request: Request) -> Settings:
224
- """Get settings from request state."""
225
- return request.state.settings
117
+ # Parse extensions for middleware and cli commands
118
+ middleware: list[Middleware] = []
226
119
 
120
+ for ext in all_extensions:
121
+ if isinstance(ext, HasMiddleware):
122
+ middleware.extend(ext.middleware())
123
+ if isinstance(ext, HasCLI):
124
+ ext.register_cli(app_cli)
227
125
 
228
- def get_request_id(request: Request) -> str:
229
- """Get the request ID from request state."""
230
- return request.state.request_id
126
+ app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
127
+ return app, app_cli
cuneus/core/execptions.py CHANGED
@@ -12,7 +12,8 @@ from fastapi import FastAPI, Request
12
12
  from fastapi.responses import JSONResponse
13
13
  from pydantic import BaseModel
14
14
 
15
- from cuneus.core.application import BaseExtension, Settings
15
+ from .extensions import BaseExtension
16
+ from .settings import Settings
16
17
 
17
18
  log = structlog.get_logger()
18
19
 
@@ -103,7 +104,7 @@ class RateLimited(AppException):
103
104
  error_code = "rate_limited"
104
105
  message = "Too many requests"
105
106
 
106
- def __init__(self, retry_after: int | None = None, **kwargs):
107
+ def __init__(self, retry_after: int | None = None, **kwargs: Any) -> None:
107
108
  super().__init__(**kwargs)
108
109
  self.retry_after = retry_after
109
110
 
@@ -162,11 +163,11 @@ class ExceptionExtension(BaseExtension):
162
163
  )
163
164
  """
164
165
 
165
- def __init__(self, settings: Settings) -> None:
166
- self.settings = settings
166
+ def __init__(self, settings: Settings | None = None) -> None:
167
+ self.settings = settings or Settings()
167
168
 
168
169
  async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
169
- app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[]
170
+ app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
170
171
  app.add_exception_handler(Exception, self._handle_unexpected_exception)
171
172
  return {}
172
173
 
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from contextlib import asynccontextmanager
6
+ from typing import (
7
+ Any,
8
+ AsyncContextManager,
9
+ AsyncIterator,
10
+ Protocol,
11
+ runtime_checkable,
12
+ )
13
+
14
+ import svcs
15
+ from click import Group
16
+ from fastapi import FastAPI
17
+ from starlette.middleware import Middleware
18
+
19
+ from .settings import Settings
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @runtime_checkable
25
+ class Extension(Protocol):
26
+ """
27
+ Protocol for extensions that hook into app lifecycle.
28
+
29
+ Extensions can:
30
+ - Register services with svcs
31
+ - Add routes via app.include_router()
32
+ - Add exception handlers via app.add_exception_handler()
33
+ - Return state to merge into lifespan state
34
+ """
35
+
36
+ def __init__(self, settings: Settings | None = None) -> None: ...
37
+
38
+ def register(
39
+ self, registry: svcs.Registry, app: FastAPI
40
+ ) -> AsyncContextManager[dict[str, Any]]:
41
+ """
42
+ Async context manager for lifecycle.
43
+
44
+ - Enter: startup (register services, add routes, etc.)
45
+ - Yield: dict of state to merge into lifespan state
46
+ - Exit: shutdown (cleanup resources)
47
+ """
48
+ ...
49
+
50
+
51
+ @runtime_checkable
52
+ class HasMiddleware(Protocol):
53
+ """Extension that provides middleware."""
54
+
55
+ def middleware(self) -> list[Middleware]: ...
56
+
57
+
58
+ @runtime_checkable
59
+ class HasCLI(Protocol):
60
+ """Extension that provides CLI commands."""
61
+
62
+ def register_cli(self, cli_group: Group) -> None: ...
63
+
64
+
65
+ class BaseExtension:
66
+ """
67
+ Base class for extensions with explicit startup/shutdown hooks.
68
+
69
+ For simple extensions, override startup() and shutdown().
70
+ For full control, override register() directly.
71
+ """
72
+
73
+ def __init__(self, settings: Settings | None = None):
74
+ self.settings = settings or Settings()
75
+
76
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
77
+ """
78
+ Override to setup resources during app startup.
79
+
80
+ You can call app.include_router(), app.add_exception_handler(), etc.
81
+ Returns a dict of state to merge into lifespan state.
82
+ """
83
+ return {}
84
+
85
+ async def shutdown(self, app: FastAPI) -> None:
86
+ """Override to cleanup resources during app shutdown."""
87
+ pass
88
+
89
+ @asynccontextmanager
90
+ async def register(
91
+ self, registry: svcs.Registry, app: FastAPI
92
+ ) -> AsyncIterator[dict[str, Any]]:
93
+ """Wraps startup/shutdown into async context manager."""
94
+ state = await self.startup(registry, app)
95
+ try:
96
+ yield state
97
+ finally:
98
+ await self.shutdown(app)