postgres-component 0.1.0__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.
Files changed (26) hide show
  1. postgres_component-0.1.0/.github/workflows/ci.yml +23 -0
  2. postgres_component-0.1.0/.github/workflows/publish-pypi.yml +31 -0
  3. postgres_component-0.1.0/.gitignore +6 -0
  4. postgres_component-0.1.0/.python-version +1 -0
  5. postgres_component-0.1.0/CHANGELOG.md +5 -0
  6. postgres_component-0.1.0/CONTEXT.md +35 -0
  7. postgres_component-0.1.0/LICENSE +21 -0
  8. postgres_component-0.1.0/PKG-INFO +111 -0
  9. postgres_component-0.1.0/README.md +86 -0
  10. postgres_component-0.1.0/docker-compose.yml +9 -0
  11. postgres_component-0.1.0/docs/adr/0001-sqlalchemy-async-with-asyncpg.md +34 -0
  12. postgres_component-0.1.0/docs/adr/0002-migrations-stay-app-owned.md +31 -0
  13. postgres_component-0.1.0/docs/superpowers/plans/2026-09-30-postgres-component.md +991 -0
  14. postgres_component-0.1.0/postgres_component/__init__.py +10 -0
  15. postgres_component-0.1.0/postgres_component/_routes.py +15 -0
  16. postgres_component-0.1.0/postgres_component/component.py +109 -0
  17. postgres_component-0.1.0/postgres_component/migrations.py +16 -0
  18. postgres_component-0.1.0/postgres_component/py.typed +0 -0
  19. postgres_component-0.1.0/postgres_component/tests/__init__.py +0 -0
  20. postgres_component-0.1.0/postgres_component/tests/conftest.py +15 -0
  21. postgres_component-0.1.0/postgres_component/tests/test_component.py +262 -0
  22. postgres_component-0.1.0/postgres_component/tests/test_migrations.py +16 -0
  23. postgres_component-0.1.0/postgres_component/tests/test_routes.py +21 -0
  24. postgres_component-0.1.0/postgres_component/tests/test_system_integration.py +31 -0
  25. postgres_component-0.1.0/pyproject.toml +46 -0
  26. postgres_component-0.1.0/pytest.ini +7 -0
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v3
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: uv sync --all-groups
21
+ - run: uv run pytest
22
+ - run: uv run ruff format --check .
23
+ - run: uv run ruff check .
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+ inputs:
8
+ target:
9
+ description: "PyPI target"
10
+ required: true
11
+ default: "pypi"
12
+ type: choice
13
+ options: ["pypi", "testpypi"]
14
+
15
+ jobs:
16
+ ci:
17
+ uses: ./.github/workflows/ci.yml
18
+
19
+ publish:
20
+ needs: ci
21
+ runs-on: ubuntu-latest
22
+ environment: ${{ github.event.inputs.target || 'pypi' }}
23
+ permissions:
24
+ id-token: write
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: astral-sh/setup-uv@v3
28
+ - run: uv build
29
+ - uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ repository-url: ${{ (github.event.inputs.target == 'testpypi') && 'https://test.pypi.org/legacy/' || '' }}
@@ -0,0 +1,6 @@
1
+ .superpowers/
2
+ *.egg-info/
3
+ __pycache__/
4
+ .venv/
5
+ dist/
6
+ uv.lock
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: `PostgresComponent`, `MigrationWiring`, `create_migration_engine`.
@@ -0,0 +1,35 @@
1
+ # postgres-component
2
+
3
+ Component library for Postgres connection lifecycle, built on python-components'
4
+ `Component` contract. Wraps a SQLAlchemy async engine so it participates in a
5
+ `System`'s dependency graph, without owning schema or query concerns.
6
+
7
+ ## Language
8
+
9
+ **PostgresComponent**:
10
+ A `Component` that manages a SQLAlchemy async engine's lifecycle — verifies
11
+ connectivity on `start()`, disposes the engine on `shutdown()` — and hands out
12
+ session access to consumers. Owns connection lifecycle only: no models, no
13
+ migrations, no query API of its own.
14
+ _Avoid_: Database component, Postgres client, DB wrapper
15
+
16
+ **SessionFactory**:
17
+ The raw `async_sessionmaker` exposed by `PostgresComponent` (`session_factory`
18
+ attribute) for callers who need manual control over a session's transaction
19
+ boundaries — long-lived reads, streaming, anything that doesn't fit one
20
+ begin/commit cycle.
21
+ _Avoid_: raw session, sessionmaker, engine session
22
+
23
+ **Managed Session**:
24
+ The `session()` async context manager `PostgresComponent` exposes: begins a
25
+ transaction, commits on clean exit, rolls back on exception. The default way
26
+ consumers should talk to the database — one call, one unit of work. Distinct
27
+ from a `SessionFactory` session, which commits nothing on its own.
28
+ _Avoid_: session(), auto-commit session, transaction wrapper, unit of work
29
+
30
+ **Migration Wiring**:
31
+ The optional helper `PostgresComponent` exposes so a consuming app's own
32
+ Alembic `env.py` can reuse the same async engine and driver already
33
+ configured on the component. `PostgresComponent` never runs migrations
34
+ itself — this only removes the boilerplate of re-deriving connection info.
35
+ _Avoid_: auto-migrate, migration runner, schema sync
@@ -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.
@@ -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,86 @@
1
+ # postgres-component
2
+
3
+ Async Postgres connection-lifecycle component for [python-components](https://github.com/lucassant95/python-components).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv add postgres-component
9
+ ```
10
+
11
+ ## Requirements
12
+
13
+ - Python >= 3.11
14
+ - A running Postgres server
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ import asyncio
20
+
21
+ from python_components import System
22
+ from postgres_component import PostgresComponent
23
+
24
+ database = PostgresComponent(url="postgresql+asyncpg://user:pass@localhost:5432/mydb")
25
+
26
+ system = System({"database": database})
27
+
28
+ async def main():
29
+ async with system:
30
+ async with database.session() as session:
31
+ await session.execute(...) # commits on success, rolls back on exception
32
+
33
+ asyncio.run(main())
34
+ ```
35
+
36
+ `PostgresComponent` can also be built from individual kwargs instead of a URL:
37
+
38
+ ```python
39
+ PostgresComponent(host="localhost", port=5432, user="user", password="pass", database="mydb")
40
+ ```
41
+
42
+ `url` takes priority over the kwargs when both are given.
43
+
44
+ ### Health route
45
+
46
+ ```python
47
+ from fastapi_component import create_app
48
+
49
+ app = create_app(system) # RouteProvider discovery adds GET /health automatically
50
+ ```
51
+
52
+ ### Migrations
53
+
54
+ `PostgresComponent` never runs migrations itself. `migration_wiring()` hands your
55
+ own Alembic `env.py` the same connection config so it doesn't have to be
56
+ re-derived:
57
+
58
+ ```python
59
+ # alembic/env.py
60
+ from postgres_component import create_migration_engine
61
+
62
+ engine = create_migration_engine(database.migration_wiring())
63
+ # use `engine` with Alembic's async run_sync() pattern
64
+ ```
65
+
66
+ ## Semantics and caveats
67
+
68
+ - **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.
69
+ - **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.
70
+ - **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.
71
+ - **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.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ uv sync --all-groups
77
+ uv run pytest
78
+ uv run ruff format --check .
79
+ uv run ruff check .
80
+ ```
81
+
82
+ 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`.
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,9 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ ports:
5
+ - "5432:5432"
6
+ environment:
7
+ POSTGRES_USER: postgres
8
+ POSTGRES_PASSWORD: postgres
9
+ POSTGRES_DB: postgres
@@ -0,0 +1,34 @@
1
+ ---
2
+ status: accepted
3
+ ---
4
+
5
+ # SQLAlchemy async engine with asyncpg, eager connectivity check on start()
6
+
7
+ postgres-component needs to pick a layer for talking to Postgres. We chose
8
+ **SQLAlchemy's async engine** over a raw driver (asyncpg directly, matching
9
+ kafka-component's raw-`aiokafka` precedent) because it buys ORM mapping and
10
+ Alembic migration tooling for consumers, and the one real Postgres user in
11
+ this codebase (`k9`) already models its data with SQLAlchemy's ORM. Under
12
+ SQLAlchemy we picked **asyncpg** over psycopg3-async as the DBAPI: it's the
13
+ more common pairing in SQLAlchemy's own async docs and examples.
14
+
15
+ SQLAlchemy engines are lazy by default — no connection opens until first use.
16
+ That breaks the family's documented fail-fast startup contract (a failing
17
+ `start()` rolls back already-started components and aborts the server before
18
+ it serves traffic), so `PostgresComponent.start()` runs an explicit
19
+ connectivity check (`SELECT 1`) rather than trusting the engine's laziness.
20
+ Query-time exceptions (`OperationalError`, `IntegrityError`, etc.) propagate
21
+ raw — SQLAlchemy's own exception hierarchy is already the right vocabulary,
22
+ and there's no behavioral policy here (unlike kafka's `ErrorPolicy`) to
23
+ justify a wrapping layer.
24
+
25
+ ## Considered Options
26
+
27
+ - Raw asyncpg, no SQLAlchemy (rejected: no ORM/migration story, and k9's
28
+ existing Postgres usage is already ORM-shaped)
29
+ - psycopg3-async as the DBAPI (rejected: asyncpg is the more common
30
+ SQLAlchemy-async pairing, no concrete reason here to deviate)
31
+ - Lazy engine, no startup connectivity check (rejected: silently breaks the
32
+ family's fail-fast startup guarantee — a dead DB would "start" successfully)
33
+ - Wrapping SQLAlchemy exceptions in component-specific error types (rejected:
34
+ no behavioral difference to encode, just translation boilerplate)
@@ -0,0 +1,31 @@
1
+ ---
2
+ status: accepted
3
+ ---
4
+
5
+ # Migrations stay app-owned; component ships wiring only
6
+
7
+ `PostgresComponent` does not run, own, or auto-trigger schema migrations.
8
+ Consuming apps run their own Alembic setup against the same connection info.
9
+ The component's only concession is an optional **Migration Wiring** helper
10
+ that lets an app's `env.py` reuse the same async engine/driver instead of
11
+ re-deriving it — targeting Alembic's async template (`run_sync`) so no second,
12
+ sync-only driver dependency (e.g. `psycopg2`) is needed alongside `asyncpg`.
13
+
14
+ The alternative — auto-running migrations inside `start()` — was rejected
15
+ mainly because it's unsafe under concurrent startup: multiple replicas
16
+ booting together would race on the same migration unless the component also
17
+ implemented a distributed lock (e.g. a Postgres advisory lock), which is real
18
+ scope the component doesn't need to own. It would also be the first component
19
+ in the family to mix deploy-time schema DDL into a runtime lifecycle hook —
20
+ none of kafka-component (doesn't create topics) or prometheus-component
21
+ (doesn't push metrics) do anything comparable.
22
+
23
+ ## Considered Options
24
+
25
+ - Auto-run migrations in `start()`, opt-in flag (rejected: races under
26
+ multi-replica startup without a distributed lock the component would have
27
+ to implement; no precedent elsewhere in the family)
28
+ - Fully hands-off, no helper at all (rejected: leaves every consumer
29
+ re-deriving the same connection wiring in its own `env.py` for no benefit)
30
+ - Component ships its own lightweight SQL-file migrator instead of Alembic
31
+ (rejected: reinvents a weaker version of an existing, well-known tool)