cuneus 0.2.1__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.
@@ -0,0 +1,18 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .coverage
9
+
10
+ # Virtual environments
11
+ .venv
12
+
13
+ # Settings
14
+ .env*
15
+
16
+ # Node
17
+ node_modules
18
+ .tanstack/
@@ -0,0 +1 @@
1
+ 3.14
cuneus-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuneus
3
+ Version: 0.2.1
4
+ Summary: ASGI application wrapper
5
+ Project-URL: Homepage, https://github.com/rmyers/cuneus
6
+ Project-URL: Documentation, https://github.com/rmyers/cuneus#readme
7
+ Project-URL: Repository, https://github.com/rmyers/cuneus
8
+ Author-email: Robert Myers <robert@julython.org>
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: click>=8.0
11
+ Requires-Dist: fastapi>=0.109.0
12
+ Requires-Dist: pydantic-settings>=2.0
13
+ Requires-Dist: pydantic>=2.0
14
+ Requires-Dist: structlog>=24.1.0
15
+ Requires-Dist: svcs>=24.1.0
16
+ Requires-Dist: uvicorn[standard]>=0.27.0
17
+ Provides-Extra: all
18
+ Requires-Dist: alembic>=1.13.0; extra == 'all'
19
+ Requires-Dist: asyncpg>=0.29.0; extra == 'all'
20
+ Requires-Dist: redis>=5.0; extra == 'all'
21
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
22
+ Provides-Extra: database
23
+ Requires-Dist: alembic>=1.13.0; extra == 'database'
24
+ Requires-Dist: asyncpg>=0.29.0; extra == 'database'
25
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'database'
26
+ Provides-Extra: dev
27
+ Requires-Dist: alembic>=1.13.0; extra == 'dev'
28
+ Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
29
+ Requires-Dist: asyncpg>=0.29.0; extra == 'dev'
30
+ Requires-Dist: httpx>=0.27; extra == 'dev'
31
+ Requires-Dist: mypy>=1.8; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
33
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0; extra == 'dev'
35
+ Requires-Dist: redis>=5.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.3; extra == 'dev'
37
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
38
+ Provides-Extra: redis
39
+ Requires-Dist: redis>=5.0; extra == 'redis'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # cuneus
43
+
44
+ > _The wedge stone that locks the arch together_
45
+
46
+ **cuneus** is a lightweight lifespan manager for FastAPI applications. It provides a simple pattern for composing extensions that handle startup/shutdown and service registration.
47
+
48
+ The name comes from Roman architecture: a _cuneus_ is the wedge-shaped stone in a Roman arch. Each stone is simple on its own, but together they lock under pressure to create structures that have stood for millennia—no rebar required.
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ uv add cuneus
54
+ ```
55
+
56
+ or
57
+
58
+ ```bash
59
+ pip install cuneus
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```python
65
+ # app.py
66
+ from fastapi import FastAPI
67
+ from cuneus import build_lifespan, Settings
68
+ from cuneus.middleware.logging import LoggingMiddleware
69
+
70
+ from myapp.extensions import DatabaseExtension
71
+
72
+ settings = Settings()
73
+ lifespan = build_lifespan(
74
+ settings,
75
+ DatabaseExtension(settings),
76
+ )
77
+
78
+ app = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
79
+
80
+ # Add middleware directly to FastAPI
81
+ app.add_middleware(LoggingMiddleware)
82
+ ```
83
+
84
+ That's it. Extensions handle their lifecycle, FastAPI handles the rest.
85
+
86
+ ## Creating Extensions
87
+
88
+ Use `BaseExtension` for simple cases:
89
+
90
+ ```python
91
+ from cuneus import BaseExtension
92
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
93
+ import svcs
94
+
95
+ class DatabaseExtension(BaseExtension):
96
+ def __init__(self, settings):
97
+ self.settings = settings
98
+ self.engine: AsyncEngine | None = None
99
+
100
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
101
+ self.engine = create_async_engine(self.settings.database_url)
102
+
103
+ # Register with svcs for dependency injection
104
+ registry.register_value(AsyncEngine, self.engine)
105
+
106
+ # Add routes
107
+ app.include_router(health_router, prefix="/health")
108
+
109
+ # Add exception handlers
110
+ app.add_exception_handler(DBError, self.handle_db_error)
111
+
112
+ # Return state (accessible via request.state.db)
113
+ return {"db": self.engine}
114
+
115
+ async def shutdown(self, app: FastAPI) -> None:
116
+ if self.engine:
117
+ await self.engine.dispose()
118
+ ```
119
+
120
+ For full control, override `register()` directly:
121
+
122
+ ```python
123
+ from contextlib import asynccontextmanager
124
+
125
+ class RedisExtension(BaseExtension):
126
+ def __init__(self, settings):
127
+ self.settings = settings
128
+
129
+ @asynccontextmanager
130
+ async def register(self, registry: svcs.Registry, app: FastAPI):
131
+ redis = await aioredis.from_url(self.settings.redis_url)
132
+ registry.register_value(Redis, redis)
133
+
134
+ try:
135
+ yield {"redis": redis}
136
+ finally:
137
+ await redis.close()
138
+ ```
139
+
140
+ ## Testing
141
+
142
+ The lifespan exposes a `.registry` attribute for test overrides:
143
+
144
+ ```python
145
+ # test_app.py
146
+ from unittest.mock import Mock
147
+ from starlette.testclient import TestClient
148
+ from myapp import app, lifespan, Database
149
+
150
+ def test_db_error_handling():
151
+ with TestClient(app) as client:
152
+ # Override after app startup
153
+ mock_db = Mock(spec=Database)
154
+ mock_db.get_user.side_effect = Exception("boom")
155
+ lifespan.registry.register_value(Database, mock_db)
156
+
157
+ resp = client.get("/users/42")
158
+ assert resp.status_code == 500
159
+ ```
160
+
161
+ ## Settings
162
+
163
+ cuneus includes a base `Settings` class that loads from multiple sources:
164
+
165
+ ```python
166
+ from cuneus import Settings
167
+
168
+ class AppSettings(Settings):
169
+ database_url: str = "sqlite+aiosqlite:///./app.db"
170
+ redis_url: str = "redis://localhost"
171
+
172
+ model_config = SettingsConfigDict(env_prefix="APP_")
173
+ ```
174
+
175
+ Load priority (highest wins):
176
+
177
+ 1. Environment variables
178
+ 2. `.env` file
179
+ 3. `pyproject.toml` under `[tool.cuneus]`
180
+
181
+ ## API Reference
182
+
183
+ ### `build_lifespan(settings, *extensions)`
184
+
185
+ Creates a lifespan context manager for FastAPI.
186
+
187
+ - `settings`: Your settings instance (subclass of `Settings`)
188
+ - `*extensions`: Extension instances to register
189
+
190
+ Returns a lifespan with a `.registry` attribute for testing.
191
+
192
+ ### `BaseExtension`
193
+
194
+ Base class with `startup()` and `shutdown()` hooks:
195
+
196
+ - `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
197
+ - `shutdown(app) -> None`: Cleanup resources
198
+
199
+ ### `Extension` Protocol
200
+
201
+ For full control, implement the protocol directly:
202
+
203
+ ```python
204
+ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager[dict[str, Any]]
205
+ ```
206
+
207
+ ### Accessors
208
+
209
+ - `aget(request, *types)` - Async get services from svcs
210
+ - `get(request, *types)` - Sync get services from svcs
211
+ - `get_settings(request)` - Get settings from request state
212
+ - `get_request_id(request)` - Get request ID from request state
213
+
214
+ ## Why cuneus?
215
+
216
+ - **Simple** — one function, `build_lifespan()`, does what you need
217
+ - **No magic** — middleware added directly to FastAPI, not hidden
218
+ - **Testable** — registry exposed via `lifespan.registry`
219
+ - **Composable** — extensions are just async context managers
220
+ - **Built on svcs** — proper dependency injection, not global state
221
+
222
+ ## License
223
+
224
+ MIT
cuneus-0.2.1/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # cuneus
2
+
3
+ > _The wedge stone that locks the arch together_
4
+
5
+ **cuneus** is a lightweight lifespan manager for FastAPI applications. It provides a simple pattern for composing extensions that handle startup/shutdown and service registration.
6
+
7
+ The name comes from Roman architecture: a _cuneus_ is the wedge-shaped stone in a Roman arch. Each stone is simple on its own, but together they lock under pressure to create structures that have stood for millennia—no rebar required.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add cuneus
13
+ ```
14
+
15
+ or
16
+
17
+ ```bash
18
+ pip install cuneus
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```python
24
+ # app.py
25
+ from fastapi import FastAPI
26
+ from cuneus import build_lifespan, Settings
27
+ from cuneus.middleware.logging import LoggingMiddleware
28
+
29
+ from myapp.extensions import DatabaseExtension
30
+
31
+ settings = Settings()
32
+ lifespan = build_lifespan(
33
+ settings,
34
+ DatabaseExtension(settings),
35
+ )
36
+
37
+ app = FastAPI(lifespan=lifespan, title="My App", version="1.0.0")
38
+
39
+ # Add middleware directly to FastAPI
40
+ app.add_middleware(LoggingMiddleware)
41
+ ```
42
+
43
+ That's it. Extensions handle their lifecycle, FastAPI handles the rest.
44
+
45
+ ## Creating Extensions
46
+
47
+ Use `BaseExtension` for simple cases:
48
+
49
+ ```python
50
+ from cuneus import BaseExtension
51
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
52
+ import svcs
53
+
54
+ class DatabaseExtension(BaseExtension):
55
+ def __init__(self, settings):
56
+ self.settings = settings
57
+ self.engine: AsyncEngine | None = None
58
+
59
+ async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
60
+ self.engine = create_async_engine(self.settings.database_url)
61
+
62
+ # Register with svcs for dependency injection
63
+ registry.register_value(AsyncEngine, self.engine)
64
+
65
+ # Add routes
66
+ app.include_router(health_router, prefix="/health")
67
+
68
+ # Add exception handlers
69
+ app.add_exception_handler(DBError, self.handle_db_error)
70
+
71
+ # Return state (accessible via request.state.db)
72
+ return {"db": self.engine}
73
+
74
+ async def shutdown(self, app: FastAPI) -> None:
75
+ if self.engine:
76
+ await self.engine.dispose()
77
+ ```
78
+
79
+ For full control, override `register()` directly:
80
+
81
+ ```python
82
+ from contextlib import asynccontextmanager
83
+
84
+ class RedisExtension(BaseExtension):
85
+ def __init__(self, settings):
86
+ self.settings = settings
87
+
88
+ @asynccontextmanager
89
+ async def register(self, registry: svcs.Registry, app: FastAPI):
90
+ redis = await aioredis.from_url(self.settings.redis_url)
91
+ registry.register_value(Redis, redis)
92
+
93
+ try:
94
+ yield {"redis": redis}
95
+ finally:
96
+ await redis.close()
97
+ ```
98
+
99
+ ## Testing
100
+
101
+ The lifespan exposes a `.registry` attribute for test overrides:
102
+
103
+ ```python
104
+ # test_app.py
105
+ from unittest.mock import Mock
106
+ from starlette.testclient import TestClient
107
+ from myapp import app, lifespan, Database
108
+
109
+ def test_db_error_handling():
110
+ with TestClient(app) as client:
111
+ # Override after app startup
112
+ mock_db = Mock(spec=Database)
113
+ mock_db.get_user.side_effect = Exception("boom")
114
+ lifespan.registry.register_value(Database, mock_db)
115
+
116
+ resp = client.get("/users/42")
117
+ assert resp.status_code == 500
118
+ ```
119
+
120
+ ## Settings
121
+
122
+ cuneus includes a base `Settings` class that loads from multiple sources:
123
+
124
+ ```python
125
+ from cuneus import Settings
126
+
127
+ class AppSettings(Settings):
128
+ database_url: str = "sqlite+aiosqlite:///./app.db"
129
+ redis_url: str = "redis://localhost"
130
+
131
+ model_config = SettingsConfigDict(env_prefix="APP_")
132
+ ```
133
+
134
+ Load priority (highest wins):
135
+
136
+ 1. Environment variables
137
+ 2. `.env` file
138
+ 3. `pyproject.toml` under `[tool.cuneus]`
139
+
140
+ ## API Reference
141
+
142
+ ### `build_lifespan(settings, *extensions)`
143
+
144
+ Creates a lifespan context manager for FastAPI.
145
+
146
+ - `settings`: Your settings instance (subclass of `Settings`)
147
+ - `*extensions`: Extension instances to register
148
+
149
+ Returns a lifespan with a `.registry` attribute for testing.
150
+
151
+ ### `BaseExtension`
152
+
153
+ Base class with `startup()` and `shutdown()` hooks:
154
+
155
+ - `startup(registry, app) -> dict[str, Any]`: Setup resources, return state
156
+ - `shutdown(app) -> None`: Cleanup resources
157
+
158
+ ### `Extension` Protocol
159
+
160
+ For full control, implement the protocol directly:
161
+
162
+ ```python
163
+ def register(self, registry: svcs.Registry, app: FastAPI) -> AsyncContextManager[dict[str, Any]]
164
+ ```
165
+
166
+ ### Accessors
167
+
168
+ - `aget(request, *types)` - Async get services from svcs
169
+ - `get(request, *types)` - Sync get services from svcs
170
+ - `get_settings(request)` - Get settings from request state
171
+ - `get_request_id(request)` - Get request ID from request state
172
+
173
+ ## Why cuneus?
174
+
175
+ - **Simple** — one function, `build_lifespan()`, does what you need
176
+ - **No magic** — middleware added directly to FastAPI, not hidden
177
+ - **Testable** — registry exposed via `lifespan.registry`
178
+ - **Composable** — extensions are just async context managers
179
+ - **Built on svcs** — proper dependency injection, not global state
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "cuneus"
3
+ version = "0.2.1"
4
+ description = "ASGI application wrapper"
5
+ readme = "README.md"
6
+ authors = [{ name = "Robert Myers", email = "robert@julython.org" }]
7
+ requires-python = ">=3.10"
8
+
9
+ dependencies = [
10
+ "fastapi>=0.109.0",
11
+ "pydantic>=2.0",
12
+ "pydantic-settings>=2.0",
13
+ "click>=8.0",
14
+ "uvicorn[standard]>=0.27.0",
15
+ # Core dependencies we build on
16
+ "svcs>=24.1.0",
17
+ "structlog>=24.1.0",
18
+ ]
19
+
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
+ dev = [
25
+ "cuneus[all]",
26
+ "pytest>=8.0",
27
+ "pytest-asyncio>=0.23",
28
+ "pytest-cov>=4.0",
29
+ "httpx>=0.27",
30
+ "asgi-lifespan>=2.1.0",
31
+ "ruff>=0.3",
32
+ "mypy>=1.8",
33
+ ]
34
+
35
+ [project.scripts]
36
+ cuneus = "cuneus.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/rmyers/cuneus"
40
+ Documentation = "https://github.com/rmyers/cuneus#readme"
41
+ Repository = "https://github.com/rmyers/cuneus"
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/cuneus"]
49
+
50
+ [tool.ruff]
51
+ target-version = "py311"
52
+ line-length = 100
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.11"
59
+ strict = true
60
+ warn_return_any = true
61
+ warn_unused_ignores = true
62
+
63
+ [tool.pytest.ini_options]
64
+ asyncio_mode = "auto"
65
+ testpaths = ["tests"]
66
+
67
+ [tool.coverage.run]
68
+ source = ["src/cuneus"]
69
+ branch = true
@@ -0,0 +1,63 @@
1
+ """
2
+ qtip - A wrapper for FastAPI applications, like the artist.
3
+
4
+ Example:
5
+ from qtip import Application, Settings
6
+ from qtip.ext.database import DatabaseExtension
7
+
8
+ class AppSettings(Settings):
9
+ database_url: str
10
+
11
+ settings = AppSettings()
12
+ app = Application(settings)
13
+ app.add_extension(DatabaseExtension(settings))
14
+
15
+ fastapi_app = app.build()
16
+ """
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 (
27
+ AppException,
28
+ BadRequest,
29
+ Unauthorized,
30
+ Forbidden,
31
+ NotFound,
32
+ Conflict,
33
+ RateLimited,
34
+ ServiceUnavailable,
35
+ DatabaseError,
36
+ RedisError,
37
+ ExternalServiceError,
38
+ ExceptionExtension,
39
+ )
40
+
41
+ __version__ = "0.2.1"
42
+ __all__ = [
43
+ # Core
44
+ "BaseExtension",
45
+ "Extension",
46
+ "Settings",
47
+ "build_lifespan",
48
+ "get_settings",
49
+ "load_pyproject_config",
50
+ # Exceptions
51
+ "AppException",
52
+ "BadRequest",
53
+ "Unauthorized",
54
+ "Forbidden",
55
+ "NotFound",
56
+ "Conflict",
57
+ "RateLimited",
58
+ "ServiceUnavailable",
59
+ "DatabaseError",
60
+ "RedisError",
61
+ "ExternalServiceError",
62
+ "ExceptionExtension",
63
+ ]