postgres-component 0.1.0__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.
- postgres_component/__init__.py +10 -0
- postgres_component/_routes.py +15 -0
- postgres_component/component.py +109 -0
- postgres_component/migrations.py +16 -0
- postgres_component/py.typed +0 -0
- postgres_component-0.1.0.dist-info/METADATA +111 -0
- postgres_component-0.1.0.dist-info/RECORD +9 -0
- postgres_component-0.1.0.dist-info/WHEEL +4 -0
- postgres_component-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from postgres_component.component import PostgresComponent
|
|
4
|
+
from postgres_component.migrations import MigrationWiring, create_migration_engine
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"MigrationWiring",
|
|
8
|
+
"PostgresComponent",
|
|
9
|
+
"create_migration_engine",
|
|
10
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_health_router(get_status: Callable[[], dict[str, Any]]) -> APIRouter:
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
@router.get("/health")
|
|
12
|
+
async def health() -> dict[str, Any]:
|
|
13
|
+
return get_status()
|
|
14
|
+
|
|
15
|
+
return router
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
from python_components import Component
|
|
9
|
+
from sqlalchemy import URL, make_url, text
|
|
10
|
+
from sqlalchemy.ext.asyncio import (
|
|
11
|
+
AsyncEngine,
|
|
12
|
+
AsyncSession,
|
|
13
|
+
async_sessionmaker,
|
|
14
|
+
create_async_engine,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from postgres_component._routes import build_health_router
|
|
18
|
+
from postgres_component.migrations import MigrationWiring
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PostgresComponent(Component):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
url: str | None = None,
|
|
26
|
+
host: str | None = None,
|
|
27
|
+
port: int | None = None,
|
|
28
|
+
user: str | None = None,
|
|
29
|
+
password: str | None = None,
|
|
30
|
+
database: str | None = None,
|
|
31
|
+
**engine_kwargs: Any,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.using([])
|
|
34
|
+
self._url = url or self._build_url(host, port, user, password, database)
|
|
35
|
+
self._engine_kwargs = engine_kwargs
|
|
36
|
+
self._engine: AsyncEngine | None = None
|
|
37
|
+
self._connected = False
|
|
38
|
+
self._last_error: str | None = None
|
|
39
|
+
self.session_factory: async_sessionmaker[AsyncSession] | None = None
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _build_url(
|
|
43
|
+
host: str | None,
|
|
44
|
+
port: int | None,
|
|
45
|
+
user: str | None,
|
|
46
|
+
password: str | None,
|
|
47
|
+
database: str | None,
|
|
48
|
+
) -> str:
|
|
49
|
+
if host is None or user is None or database is None:
|
|
50
|
+
raise TypeError(
|
|
51
|
+
"PostgresComponent requires either `url` or `host`/`user`/`database`"
|
|
52
|
+
)
|
|
53
|
+
return URL.create(
|
|
54
|
+
"postgresql+asyncpg",
|
|
55
|
+
username=user,
|
|
56
|
+
password=password,
|
|
57
|
+
host=host,
|
|
58
|
+
port=port or 5432,
|
|
59
|
+
database=database,
|
|
60
|
+
).render_as_string(hide_password=False)
|
|
61
|
+
|
|
62
|
+
def _sanitize_error(self, message: str) -> str:
|
|
63
|
+
password = make_url(self._url).password
|
|
64
|
+
if password:
|
|
65
|
+
return message.replace(password, "***")
|
|
66
|
+
return message
|
|
67
|
+
|
|
68
|
+
async def start(self) -> None:
|
|
69
|
+
if self._engine is not None:
|
|
70
|
+
await self._engine.dispose()
|
|
71
|
+
engine = create_async_engine(self._url, **self._engine_kwargs)
|
|
72
|
+
try:
|
|
73
|
+
async with engine.connect() as conn:
|
|
74
|
+
await conn.execute(text("SELECT 1"))
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
await engine.dispose()
|
|
77
|
+
self._last_error = self._sanitize_error(type(exc).__name__)
|
|
78
|
+
raise
|
|
79
|
+
self._engine = engine
|
|
80
|
+
self.session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
81
|
+
self._connected = True
|
|
82
|
+
self._last_error = None
|
|
83
|
+
|
|
84
|
+
async def shutdown(self) -> None:
|
|
85
|
+
if self._engine is not None:
|
|
86
|
+
await self._engine.dispose()
|
|
87
|
+
self._connected = False
|
|
88
|
+
self.session_factory = None
|
|
89
|
+
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def session(self) -> AsyncIterator[AsyncSession]:
|
|
92
|
+
if self.session_factory is None:
|
|
93
|
+
raise RuntimeError("PostgresComponent.session() called before start()")
|
|
94
|
+
async with self.session_factory() as session:
|
|
95
|
+
try:
|
|
96
|
+
async with session.begin():
|
|
97
|
+
yield session
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
self._last_error = self._sanitize_error(type(exc).__name__)
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
def routes(self) -> APIRouter:
|
|
103
|
+
return build_health_router(self.get_status)
|
|
104
|
+
|
|
105
|
+
def get_status(self) -> dict[str, Any]:
|
|
106
|
+
return {"connected": self._connected, "last_error": self._last_error}
|
|
107
|
+
|
|
108
|
+
def migration_wiring(self) -> MigrationWiring:
|
|
109
|
+
return MigrationWiring(url=self._url, engine_kwargs=dict(self._engine_kwargs))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class MigrationWiring:
|
|
11
|
+
url: str
|
|
12
|
+
engine_kwargs: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_migration_engine(wiring: MigrationWiring) -> AsyncEngine:
|
|
16
|
+
return create_async_engine(wiring.url, **wiring.engine_kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: postgres-component
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Postgres connection-lifecycle component for python-components
|
|
5
|
+
Project-URL: Homepage, https://github.com/fabriciooml/postgres-component
|
|
6
|
+
Project-URL: Repository, https://github.com/fabriciooml/postgres-component
|
|
7
|
+
Project-URL: Issues, https://github.com/fabriciooml/postgres-component/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/fabriciooml/postgres-component/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Fabricio Lima
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asyncio,asyncpg,postgres,postgresql,python-components,sqlalchemy
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: asyncpg<1,>=0.29
|
|
21
|
+
Requires-Dist: fastapi>=0.115
|
|
22
|
+
Requires-Dist: python-components<0.5,>=0.4.0
|
|
23
|
+
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# postgres-component
|
|
27
|
+
|
|
28
|
+
Async Postgres connection-lifecycle component for [python-components](https://github.com/lucassant95/python-components).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add postgres-component
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python >= 3.11
|
|
39
|
+
- A running Postgres server
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
|
|
46
|
+
from python_components import System
|
|
47
|
+
from postgres_component import PostgresComponent
|
|
48
|
+
|
|
49
|
+
database = PostgresComponent(url="postgresql+asyncpg://user:pass@localhost:5432/mydb")
|
|
50
|
+
|
|
51
|
+
system = System({"database": database})
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
async with system:
|
|
55
|
+
async with database.session() as session:
|
|
56
|
+
await session.execute(...) # commits on success, rolls back on exception
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`PostgresComponent` can also be built from individual kwargs instead of a URL:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
PostgresComponent(host="localhost", port=5432, user="user", password="pass", database="mydb")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`url` takes priority over the kwargs when both are given.
|
|
68
|
+
|
|
69
|
+
### Health route
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi_component import create_app
|
|
73
|
+
|
|
74
|
+
app = create_app(system) # RouteProvider discovery adds GET /health automatically
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Migrations
|
|
78
|
+
|
|
79
|
+
`PostgresComponent` never runs migrations itself. `migration_wiring()` hands your
|
|
80
|
+
own Alembic `env.py` the same connection config so it doesn't have to be
|
|
81
|
+
re-derived:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# alembic/env.py
|
|
85
|
+
from postgres_component import create_migration_engine
|
|
86
|
+
|
|
87
|
+
engine = create_migration_engine(database.migration_wiring())
|
|
88
|
+
# use `engine` with Alembic's async run_sync() pattern
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Semantics and caveats
|
|
92
|
+
|
|
93
|
+
- **Fail-fast startup.** `start()` runs an explicit `SELECT 1` before considering itself connected — a dead database makes `start()` raise rather than "starting" successfully and failing later on first query.
|
|
94
|
+
- **Managed Session is a unit of work.** `database.session()` begins a transaction, commits on clean exit, and rolls back on exception — one call is one unit of work. For manual control (long-lived reads, streaming), use the raw `database.session_factory` directly.
|
|
95
|
+
- **Errors propagate raw.** SQLAlchemy exceptions (`OperationalError`, `IntegrityError`, etc.) are never wrapped; `get_status()["last_error"]` is updated as a string for introspection, but the original exception type always reaches the caller.
|
|
96
|
+
- **No ORM, no migrations.** The component owns connection lifecycle only. Models and Alembic setup belong to the consuming app; `migration_wiring()` only removes the boilerplate of re-deriving connection config.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv sync --all-groups
|
|
102
|
+
uv run pytest
|
|
103
|
+
uv run ruff format --check .
|
|
104
|
+
uv run ruff check .
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Integration tests spin up a real Postgres instance via [testcontainers](https://testcontainers.com/modules/postgres/). For local development against a persistent instance instead, run `docker compose up -d`.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
postgres_component/__init__.py,sha256=rxVd737FJ8JArAJF2f_sz1yy9fOuESwx4PBrMGcbr84,272
|
|
2
|
+
postgres_component/_routes.py,sha256=Hw607vvqEICruIcQ-5wcNWMwKk16wdrOjdbh84loeZs,324
|
|
3
|
+
postgres_component/component.py,sha256=dPFaFf8wARCr8zPzUt0qVdP4GVuSlXOKl8DFPpPzPXs,3672
|
|
4
|
+
postgres_component/migrations.py,sha256=G149lyepn2ZLo7mVUzCaEyjTdZc7spFQrB7BM5rx3-s,396
|
|
5
|
+
postgres_component/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
postgres_component-0.1.0.dist-info/METADATA,sha256=NJY4p4Gbn9UUFodkHxPOGp2WOqktqyeQeDhBXhhJOT0,3905
|
|
7
|
+
postgres_component-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
postgres_component-0.1.0.dist-info/licenses/LICENSE,sha256=B-SYSQHSY79FyOS46ZHL43ywEGFd_VyMLDiBw-9cQYk,1070
|
|
9
|
+
postgres_component-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fabricio Lima
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|