Skip to content

Commit bb334b5

Browse files
author
antoinegaston
committed
✨ Initial commit
0 parents  commit bb334b5

17 files changed

Lines changed: 1930 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- master
8+
pull_request:
9+
branches:
10+
- main
11+
- master
12+
13+
jobs:
14+
tests:
15+
name: Tests (uv + pytest + coverage)
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Install uv
23+
uses: astral-sh/setup-uv@v7
24+
with:
25+
enable-cache: true
26+
27+
- name: Install Python
28+
run: uv python install 3.13
29+
30+
- name: Install project with dev dependencies
31+
run: uv sync --locked --group dev
32+
33+
- name: Run tests with coverage
34+
run: uv run pytest
35+
36+
- name: Upload coverage to Codecov
37+
uses: codecov/codecov-action@v4
38+
with:
39+
token: ${{ secrets.CODECOV_TOKEN }}
40+

.github/workflows/publish.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Publish
2+
3+
on:
4+
push:
5+
tags:
6+
- v*
7+
8+
jobs:
9+
publish:
10+
runs-on: ubuntu-latest
11+
environment:
12+
name: pypi
13+
permissions:
14+
id-token: write
15+
contents: read
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Install uv
22+
uses: astral-sh/setup-uv@v7
23+
24+
- name: Install Python 3.13
25+
run: uv python install 3.13
26+
27+
- name: Build distributions
28+
run: uv build
29+
30+
- name: Smoke test (wheel)
31+
run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
32+
33+
- name: Smoke test (source distribution)
34+
run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
35+
36+
- name: Publish to PyPI
37+
run: uv publish
38+

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv
11+
12+
# Tests
13+
*coverage*

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

README.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
## pygres
2+
3+
[![codecov](https://codecov.io/gh/OWNER/REPO/branch/main/graph/badge.svg)](https://codecov.io/gh/OWNER/REPO)
4+
5+
**pygres** is a small helper library around SQLAlchemy that focuses on:
6+
7+
- **Declarative base and registry**: a ready‑to‑use `Base` and `registry` for your models.
8+
- **PostgreSQL materialized views**: a `MaterializedView` base class and helpers to create, refresh, and drop materialized views.
9+
- **Schema diffing**: utilities to compare your SQLAlchemy models with an existing PostgreSQL database and render SQL statements to bring the schema in sync.
10+
11+
The goal is to keep the API **minimal, explicit, and easy to reason about** while staying close to plain SQLAlchemy.
12+
13+
---
14+
15+
### Installation
16+
17+
Install via `pip` (or any PEP 621/pyproject-aware tool like `uv`, `pip-tools`, etc.):
18+
19+
```bash
20+
pip install pygres
21+
```
22+
23+
Or, in a `uv`‑managed project:
24+
25+
```bash
26+
uv add pygres
27+
```
28+
29+
You are expected to install and configure **SQLAlchemy** and a **PostgreSQL driver** (e.g. `psycopg2`) separately, as usual.
30+
31+
---
32+
33+
### Quick start
34+
35+
#### 1. Define models using the shared `Base` and `registry`
36+
37+
```python
38+
from sqlalchemy import Column, ForeignKey, Integer, String, Table
39+
from sqlalchemy.orm import Mapped, mapped_column, relationship
40+
41+
from pygres import Base, registry
42+
43+
44+
class User(Base):
45+
__tablename__ = "users"
46+
id: Mapped[int] = mapped_column(primary_key=True)
47+
name: Mapped[str] = mapped_column(String(120))
48+
49+
50+
class AuditLog:
51+
pass
52+
53+
54+
audit_logs_table = Table(
55+
"audit_logs",
56+
Base.metadata,
57+
Column("id", Integer, primary_key=True),
58+
Column("action", String(120), nullable=False),
59+
)
60+
registry.map_imperatively(AuditLog, audit_logs_table)
61+
```
62+
63+
You can mix **declarative** mappings (via `Base`) and **imperative** mappings (via `registry.map_imperatively`) in the same metadata.
64+
65+
---
66+
67+
#### 2. Define a materialized view
68+
69+
`pygres` provides a `MaterializedView` base class. You declare a materialized view by:
70+
71+
- Giving it a `__tablename__`
72+
- Providing a SQLAlchemy `select()` query in `__mv_query__`
73+
- Declaring the primary key column names in `__mv_primary_key__`
74+
75+
```python
76+
from sqlalchemy import select
77+
from pygres import MaterializedView
78+
79+
80+
class UserSummaryMV(MaterializedView):
81+
__tablename__ = "mv_user_summary"
82+
__mv_query__ = select(User.id.label("id"), User.name.label("name"))
83+
__mv_primary_key__ = ("id",)
84+
```
85+
86+
The base class will:
87+
88+
- Create a `Table` with the right columns and types derived from the query.
89+
- Mark the mapped table with `info["is_materialized_view"] = True` so that schema diffing and helpers know how to treat it.
90+
91+
You can control schema and “WITH DATA” behavior with:
92+
93+
- `__mv_schema__`: optional schema name.
94+
- `__mv_with_data__`: `True` (default) or `False` to create the view `WITH NO DATA`.
95+
96+
---
97+
98+
#### 3. Create and compare the schema against PostgreSQL
99+
100+
Below is the full example previously implemented in `example.py`, showing how to:
101+
102+
- Spin up a test PostgreSQL instance with `testcontainers`.
103+
- Create tables and materialized views.
104+
- Evolve the models.
105+
- Compute and print SQL statements to migrate the live database schema.
106+
107+
```python
108+
from sqlalchemy import Column, ForeignKey, Integer, String, Table, create_engine, select
109+
from sqlalchemy.orm import Mapped, mapped_column, relationship
110+
111+
from pygres import Base, registry, compare_database_schema, MaterializedView
112+
from testcontainers.postgres import PostgresContainer
113+
114+
115+
class User(Base):
116+
__tablename__ = "users"
117+
id: Mapped[int] = mapped_column(primary_key=True)
118+
name: Mapped[str] = mapped_column(String(120))
119+
120+
121+
class AuditLog:
122+
pass
123+
124+
125+
audit_logs_table = Table(
126+
"audit_logs",
127+
Base.metadata,
128+
Column("id", Integer, primary_key=True),
129+
Column("action", String(120), nullable=False),
130+
)
131+
registry.map_imperatively(AuditLog, audit_logs_table)
132+
133+
134+
class UserSummaryMV(MaterializedView):
135+
__tablename__ = "mv_user_summary"
136+
__mv_query__ = select(User.id.label("id"), User.name.label("name"))
137+
__mv_primary_key__ = ("id",)
138+
139+
140+
if __name__ == "__main__":
141+
# Start a temporary PostgreSQL instance
142+
with PostgresContainer("postgres:latest") as postgres:
143+
engine = create_engine(postgres.get_connection_url())
144+
145+
# Create the initial schema (tables + materialized views) in the database
146+
registry.metadata.create_all(engine)
147+
148+
# Evolve the models: add columns and a new table
149+
class UserV2(Base):
150+
__tablename__ = "users"
151+
id: Mapped[int] = mapped_column(primary_key=True)
152+
name: Mapped[str] = mapped_column(String(120))
153+
email: Mapped[str] = mapped_column(String(120))
154+
family_id: Mapped[int] = mapped_column(ForeignKey("families.id"))
155+
family: Mapped["Family"] = relationship("Family", back_populates="users")
156+
__table_args__ = {"extend_existing": True}
157+
158+
class Family(Base):
159+
__tablename__ = "families"
160+
id: Mapped[int] = mapped_column(primary_key=True)
161+
name: Mapped[str] = mapped_column(String(120))
162+
users: Mapped[list[UserV2]] = relationship("UserV2", back_populates="family")
163+
164+
# Compare metadata with the live database and get SQL migration statements
165+
sql_statements = compare_database_schema(
166+
engine,
167+
compare_server_default=False,
168+
return_sql=True,
169+
)
170+
for stmt in sql_statements:
171+
print(stmt)
172+
```
173+
174+
Running this script will print SQL `ALTER`/`CREATE`/`DROP` statements that would bring the PostgreSQL schema in line with your current SQLAlchemy models and materialized views.
175+
176+
---
177+
178+
### API overview
179+
180+
- **`pygres.Base`**: Declarative base for your models.
181+
- **`pygres.registry`**: SQLAlchemy registry used to create `Base` and for imperative mappings.
182+
- **`pygres.MaterializedView`**: Base class for PostgreSQL materialized view mappings.
183+
- **`pygres.create_materialized_view(bind, model, if_not_exists=True)`**: Execute and return the `CREATE MATERIALIZED VIEW ...` SQL for the given mapped view.
184+
- **`pygres.refresh_materialized_view(bind, model, concurrently=False, with_data=True)`**: Execute and return the `REFRESH MATERIALIZED VIEW ...` SQL.
185+
- **`pygres.drop_materialized_view(bind, model, if_exists=True, cascade=False)`**: Execute and return the `DROP MATERIALIZED VIEW ...` SQL.
186+
- **`pygres.compare_database_schema(bind, **options)`**:
187+
- With `return_sql=False` (default): returns a list of `SchemaDiff` objects.
188+
- With `return_sql=True`: returns a list of SQL strings that can be applied to migrate the schema.
189+
- **`pygres.render_compensation_sql(diffs, dialect)`**: Convert a list of `SchemaDiff` instances into SQL strings for a specific dialect.
190+
191+
All APIs are intentionally thin wrappers around SQLAlchemy primitives so that you can always drop down to plain SQLAlchemy when needed.
192+
193+
---
194+
195+
### Notes
196+
197+
- Materialized view support currently targets **PostgreSQL**; attempting to use it with other dialects will raise a `ValueError`.
198+
- `compare_database_schema` is designed for migration/compensation SQL generation, not as a full migration framework. You can integrate the generated SQL into your own deployment/migration tooling as you see fit.

pyproject.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[project]
2+
name = "pygres"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"psycopg2>=2.9.11",
9+
"sqlalchemy",
10+
"testcontainers>=4.14.1",
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"pytest>=8.0.0",
16+
"pytest-cov>=4.1.0",
17+
]
18+
19+
[build-system]
20+
requires = ["uv_build>=0.9.5,<0.10.0"]
21+
build-backend = "uv_build"
22+
23+
[tool.uv.build-backend]
24+
module-name = "pygres"
25+
module-root = "src"
26+
27+
[tool.pytest.ini_options]
28+
addopts = "-q --cov=pygres --cov-report=term-missing --cov-report=xml"
29+
testpaths = ["tests"]
30+
31+
[tool.coverage.run]
32+
source = ["pygres"]
33+
branch = true
34+
35+
[tool.coverage.report]
36+
show_missing = true
37+
skip_covered = true

src/pygres/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pygres.base import Base, registry
2+
from pygres.materialized_view import (
3+
MaterializedView,
4+
create_materialized_view,
5+
drop_materialized_view,
6+
refresh_materialized_view,
7+
)
8+
from pygres.schema_diff import SchemaDiff, compare_database_schema, render_compensation_sql
9+
10+
__all__ = [
11+
"Base",
12+
"MaterializedView",
13+
"registry",
14+
"SchemaDiff",
15+
"compare_database_schema",
16+
"render_compensation_sql",
17+
"create_materialized_view",
18+
"refresh_materialized_view",
19+
"drop_materialized_view",
20+
]

src/pygres/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from contextlib import contextmanager
4+
from typing import Iterator
5+
6+
from sqlalchemy.engine import Connection, Engine
7+
from sqlalchemy.orm import registry as sa_registry
8+
9+
registry = sa_registry()
10+
Base = registry.generate_base()
11+
12+
13+
@contextmanager
14+
def bind_connection(bind: Engine | Connection) -> Iterator[Connection]:
15+
if isinstance(bind, Engine):
16+
connection = bind.connect()
17+
try:
18+
yield connection
19+
finally:
20+
connection.close()
21+
return
22+
yield bind

0 commit comments

Comments
 (0)