cuneus 0.2.1__tar.gz → 0.2.2__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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuneus
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
7
7
  Project-URL: Repository, https://github.com/rmyers/cuneus
8
8
  Author-email: Robert Myers <robert@julython.org>
9
- Requires-Python: >=3.10
9
+ Requires-Python: >=3.11
10
10
  Requires-Dist: click>=8.0
11
11
  Requires-Dist: fastapi>=0.109.0
12
12
  Requires-Dist: pydantic-settings>=2.0
@@ -62,26 +62,26 @@ pip install cuneus
62
62
  ## Quick Start
63
63
 
64
64
  ```python
65
- # app.py
65
+ # app/main.py
66
66
  from fastapi import FastAPI
67
- from cuneus import build_lifespan, Settings
68
- from cuneus.middleware.logging import LoggingMiddleware
67
+ from cuneus import build_app, Settings
69
68
 
70
69
  from myapp.extensions import DatabaseExtension
71
70
 
72
- settings = Settings()
73
- lifespan = build_lifespan(
74
- settings,
75
- DatabaseExtension(settings),
71
+ class MyAppSettings(Settings):
72
+ my_mood: str = "extatic"
73
+
74
+ app, cli = build_app(
75
+ DatabaseExtension,
76
+ settings=MyAppSettings(),
76
77
  )
77
78
 
78
- app = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
79
+ app.include_router(my_router)
79
80
 
80
- # Add middleware directly to FastAPI
81
- app.add_middleware(LoggingMiddleware)
81
+ __all__ = ["app", "cli"]
82
82
  ```
83
83
 
84
- That's it. Extensions handle their lifecycle, FastAPI handles the rest.
84
+ That's it. Extensions handle their lifecycle, registration, and middleware.
85
85
 
86
86
  ## Creating Extensions
87
87
 
@@ -115,6 +115,14 @@ class DatabaseExtension(BaseExtension):
115
115
  async def shutdown(self, app: FastAPI) -> None:
116
116
  if self.engine:
117
117
  await self.engine.dispose()
118
+
119
+ def middleware(self) -> list[Middleware]:
120
+ return [Middleware(DatabaseLoggingMiddleware, level=INFO)]
121
+
122
+ def register_cli(self, app_cli: click.Group) -> None:
123
+ @app_cli.command()
124
+ @click.option("--workers", default=1, type=int, help="Number of workers")
125
+ def blow_up_db(workers: int): ...
118
126
  ```
119
127
 
120
128
  For full control, override `register()` directly:
@@ -195,6 +203,8 @@ Base class with `startup()` and `shutdown()` hooks:
195
203
 
196
204
  - `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
197
205
  - `shutdown(app) -> None`: Cleanup resources
206
+ - `middleware() -> list[Middleware]`: Optional middleware to configure
207
+ - `register_cli(group) -> None`: Optional hook to add click commands
198
208
 
199
209
  ### `Extension` Protocol
200
210
 
@@ -213,8 +223,7 @@ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager
213
223
 
214
224
  ## Why cuneus?
215
225
 
216
- - **Simple** — one function, `build_lifespan()`, does what you need
217
- - **No magic** — middleware added directly to FastAPI, not hidden
226
+ - **Simple** — one function, `build_app()`, does what you need
218
227
  - **Testable** — registry exposed via `lifespan.registry`
219
228
  - **Composable** — extensions are just async context managers
220
229
  - **Built on svcs** — proper dependency injection, not global state
@@ -21,26 +21,26 @@ pip install cuneus
21
21
  ## Quick Start
22
22
 
23
23
  ```python
24
- # app.py
24
+ # app/main.py
25
25
  from fastapi import FastAPI
26
- from cuneus import build_lifespan, Settings
27
- from cuneus.middleware.logging import LoggingMiddleware
26
+ from cuneus import build_app, Settings
28
27
 
29
28
  from myapp.extensions import DatabaseExtension
30
29
 
31
- settings = Settings()
32
- lifespan = build_lifespan(
33
- settings,
34
- DatabaseExtension(settings),
30
+ class MyAppSettings(Settings):
31
+ my_mood: str = "extatic"
32
+
33
+ app, cli = build_app(
34
+ DatabaseExtension,
35
+ settings=MyAppSettings(),
35
36
  )
36
37
 
37
- app = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
38
+ app.include_router(my_router)
38
39
 
39
- # Add middleware directly to FastAPI
40
- app.add_middleware(LoggingMiddleware)
40
+ __all__ = ["app", "cli"]
41
41
  ```
42
42
 
43
- That's it. Extensions handle their lifecycle, FastAPI handles the rest.
43
+ That's it. Extensions handle their lifecycle, registration, and middleware.
44
44
 
45
45
  ## Creating Extensions
46
46
 
@@ -74,6 +74,14 @@ class DatabaseExtension(BaseExtension):
74
74
  async def shutdown(self, app: FastAPI) -> None:
75
75
  if self.engine:
76
76
  await self.engine.dispose()
77
+
78
+ def middleware(self) -> list[Middleware]:
79
+ return [Middleware(DatabaseLoggingMiddleware, level=INFO)]
80
+
81
+ def register_cli(self, app_cli: click.Group) -> None:
82
+ @app_cli.command()
83
+ @click.option("--workers", default=1, type=int, help="Number of workers")
84
+ def blow_up_db(workers: int): ...
77
85
  ```
78
86
 
79
87
  For full control, override `register()` directly:
@@ -154,6 +162,8 @@ Base class with `startup()` and `shutdown()` hooks:
154
162
 
155
163
  - `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
156
164
  - `shutdown(app) -> None`: Cleanup resources
165
+ - `middleware() -> list[Middleware]`: Optional middleware to configure
166
+ - `register_cli(group) -> None`: Optional hook to add click commands
157
167
 
158
168
  ### `Extension` Protocol
159
169
 
@@ -172,8 +182,7 @@ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager
172
182
 
173
183
  ## Why cuneus?
174
184
 
175
- - **Simple** — one function, `build_lifespan()`, does what you need
176
- - **No magic** — middleware added directly to FastAPI, not hidden
185
+ - **Simple** — one function, `build_app()`, does what you need
177
186
  - **Testable** — registry exposed via `lifespan.registry`
178
187
  - **Composable** — extensions are just async context managers
179
188
  - **Built on svcs** — proper dependency injection, not global state
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "cuneus"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "ASGI application wrapper"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
7
- requires-python = ">=3.10"
7
+ requires-python = ">=3.11"
8
8
 
9
9
  dependencies = [
10
10
  "fastapi>=0.109.0",
@@ -48,14 +48,14 @@ build-backend = "hatchling.build"
48
48
  packages = ["src/cuneus"]
49
49
 
50
50
  [tool.ruff]
51
- target-version = "py311"
51
+ target-version = "py312"
52
52
  line-length = 100
53
53
 
54
54
  [tool.ruff.lint]
55
55
  select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
56
56
 
57
57
  [tool.mypy]
58
- python_version = "3.11"
58
+ python_version = "3.12"
59
59
  strict = true
60
60
  warn_return_any = true
61
61
  warn_unused_ignores = true
@@ -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",
@@ -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()
@@ -0,0 +1,127 @@
1
+ """
2
+ cuneus - The wedge stone that locks the arch together.
3
+
4
+ Lightweight lifespan management for FastAPI applications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import AsyncExitStack, asynccontextmanager
10
+ from typing import Any, AsyncIterator, Callable
11
+
12
+ import click
13
+ import svcs
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]:
52
+ """
53
+ Build a FastAPI with extensions preconfigured.
54
+
55
+ The returned lifespan has a `.registry` attribute for testing overrides.
56
+
57
+ Usage:
58
+ from cuneus import build_app, Settings, SettingsExtension
59
+ from myapp.extensions import DatabaseExtension
60
+
61
+ settings = Settings()
62
+ app, cli = build_app(
63
+ SettingsExtension(settings),
64
+ DatabaseExtension(settings),
65
+ title="Args are passed to FastAPI",
66
+ )
67
+
68
+ __all__ = ["app", "cli"]
69
+
70
+ Testing:
71
+ from myapp import app, lifespan
72
+
73
+ def test_with_mock_db(client):
74
+ mock_db = Mock(spec=Database)
75
+ lifespan.registry.register_value(Database, mock_db)
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)
99
+
100
+ @svcs.fastapi.lifespan
101
+ @asynccontextmanager
102
+ async def lifespan(
103
+ app: FastAPI, registry: svcs.Registry
104
+ ) -> AsyncIterator[dict[str, Any]]:
105
+ async with AsyncExitStack() as stack:
106
+ state: dict[str, Any] = {}
107
+
108
+ for ext in all_extensions:
109
+ ext_state = await stack.enter_async_context(ext.register(registry, app))
110
+ if ext_state:
111
+ if overlap := state.keys() & ext_state.keys():
112
+ raise ValueError(f"Extension state key collision: {overlap}")
113
+ state.update(ext_state)
114
+
115
+ yield state
116
+
117
+ # Parse extensions for middleware and cli commands
118
+ middleware: list[Middleware] = []
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)
125
+
126
+ app = FastAPI(lifespan=lifespan, middleware=middleware, **fastapi_kwargs)
127
+ return app, app_cli
@@ -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)