sqlpush 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.
File without changes
@@ -0,0 +1,19 @@
1
+ # src/sqlpush/core/classify.py
2
+ from __future__ import annotations
3
+
4
+ from sqlpush.types import RiskClass
5
+
6
+ SAFE = frozenset({"add_table", "add_column", "add_constraint", "create_hypertable"})
7
+ DESTRUCTIVE = frozenset({"drop_column", "drop_table", "drop_index", "drop_constraint"})
8
+
9
+
10
+ def classify(op_type: str) -> RiskClass:
11
+ """Standalone add_index targets an EXISTING table
12
+ (indexes of new tables ride along with add_table), so it is risky:
13
+ a plain CREATE INDEX takes a SHARE lock that blocks writes."""
14
+ if op_type in DESTRUCTIVE:
15
+ return RiskClass.DESTRUCTIVE
16
+ if op_type in SAFE:
17
+ return RiskClass.SAFE
18
+ # modify_*, add_index, raw_sql, and anything unknown
19
+ return RiskClass.RISKY
sqlpush/core/diff.py ADDED
@@ -0,0 +1,205 @@
1
+ """The ONLY module in sqlpush that imports alembic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import io
7
+ from collections.abc import Sequence
8
+
9
+ from alembic.autogenerate import produce_migrations
10
+ from alembic.migration import MigrationContext
11
+ from alembic.operations import Operations
12
+ from alembic.operations.ops import AlterColumnOp, OpContainer
13
+ from sqlalchemy import MetaData, text
14
+ from sqlalchemy.engine import Engine
15
+
16
+ from sqlpush.core.classify import classify
17
+ from sqlpush.types import Plan, PlannedOperation
18
+
19
+ _SYSTEM_SCHEMAS = ("_timescaledb%", "information_schema", "pg_%")
20
+ _SYSTEM_TABLES = ("alembic_version", "spatial_ref_sys")
21
+
22
+ # Leaf op class -> sqlpush op type. Class names follow alembic 1.19.1
23
+ # autogen output as observed in docs/notes/alembic-notes.md and probes:
24
+ # table create is CreateTableOp (not "AddTableOp"), column modify is
25
+ # AlterColumnOp (not "ModifyColumnOp"), index create is CreateIndexOp,
26
+ # and constraint creates are the concrete Create*ConstraintOp subclasses
27
+ # The AddConstraintOp base class is never emitted as a leaf op.
28
+ # AlterColumnOp is absent ON PURPOSE: its label is derived per-op (see
29
+ # _alter_column_label) because default-only, nullable-only and type
30
+ # changes all arrive as the same class.
31
+ _OP_TYPE = {
32
+ "CreateTableOp": "add_table",
33
+ "DropTableOp": "drop_table",
34
+ "AddColumnOp": "add_column",
35
+ "DropColumnOp": "drop_column",
36
+ "CreateIndexOp": "add_index",
37
+ "DropIndexOp": "drop_index",
38
+ "CreateUniqueConstraintOp": "add_constraint",
39
+ "CreateForeignKeyOp": "add_constraint",
40
+ "CreatePrimaryKeyOp": "add_constraint",
41
+ "CreateCheckConstraintOp": "add_constraint",
42
+ "DropConstraintOp": "drop_constraint",
43
+ }
44
+
45
+
46
+ def _alter_column_label(op: AlterColumnOp) -> str:
47
+ """Precise op type for AlterColumnOp (alembic-notes Pattern C).
48
+
49
+ Sentinel semantics: ``False`` and ``None`` both mean "leave
50
+ unchanged"; any other value is the new setting. Default-only and
51
+ nullable-only drift arrive as AlterColumnOp just like type changes,
52
+ so disambiguate on the attributes.
53
+ """
54
+ if op.modify_server_default not in (False, None):
55
+ return "modify_default"
56
+ if op.modify_nullable not in (False, None):
57
+ return "modify_nullable"
58
+ return "modify_type"
59
+
60
+
61
+ def _is_system_schema(schema: str) -> bool:
62
+ return any(fnmatch.fnmatch(schema, pat) for pat in _SYSTEM_SCHEMAS)
63
+
64
+
65
+ def _make_include_name(schemas: frozenset[str], default_schema: str):
66
+ def include_name(name, type_, parent_names):
67
+ # Prune schemas (and everything inside them) BEFORE reflection:
68
+ # system catalogs (timescale et al.) are then never reflected at
69
+ # all. Schema filtering routes through include_name: alembic
70
+ # 1.19 never calls include_object with type_ == "schema", and
71
+ # its return value must be a real bool (falsy = exclude).
72
+ # NB: alembic substitutes None for the default schema name in
73
+ # the schema pass (compare/schema.py), so map None back.
74
+ if type_ == "schema":
75
+ schema = default_schema if name is None else name
76
+ else:
77
+ schema = parent_names.get("schema_name") or default_schema
78
+ return schema in schemas and not _is_system_schema(schema)
79
+
80
+ return include_name
81
+
82
+
83
+ def _make_include(schemas: frozenset[str], default_schema: str):
84
+ def include_object(obj, name, type_, reflected, compare_to):
85
+ # Bound the diff to the target schemas on every branch: derive
86
+ # the object's effective schema and require membership, for
87
+ # reflected-only, metadata-only and both-present objects alike.
88
+ # Column/Index/Constraint objects have no `.schema` of their own
89
+ # (SQLAlchemy 2.0.52): they resolve it through the parent table.
90
+ schema = (
91
+ getattr(obj, "schema", None)
92
+ or getattr(getattr(obj, "table", None), "schema", None)
93
+ or default_schema
94
+ )
95
+ if schema not in schemas or _is_system_schema(schema):
96
+ return False
97
+ if reflected and compare_to is None:
98
+ # DB-only object: skip system tables. NB: alembic also
99
+ # auto-excludes its own version table from autogen; the
100
+ # name check here is defense in depth.
101
+ return name not in _SYSTEM_TABLES
102
+ return True
103
+
104
+ return include_object
105
+
106
+
107
+ def _flatten(ops):
108
+ # D1 (alembic-notes): ops targeting existing tables arrive wrapped in
109
+ # ModifyTableOps containers; Operations.invoke crashes on containers,
110
+ # so recurse into anything that is an OpContainer before invoking.
111
+ for op in ops:
112
+ if isinstance(op, OpContainer):
113
+ yield from _flatten(op.ops)
114
+ else:
115
+ yield op
116
+
117
+
118
+ def _render_op_sql(op, engine: Engine) -> str:
119
+ buf = io.StringIO()
120
+ offline = MigrationContext.configure(
121
+ dialect=engine.dialect, opts={"as_sql": True, "output_buffer": buf}
122
+ )
123
+ operations = Operations(offline)
124
+ operations.invoke(op)
125
+ # offline render terminates each op with ";\n\n"; normalize so each
126
+ # PlannedOperation.sql is a single clean statement
127
+ return buf.getvalue().strip().rstrip(";")
128
+
129
+
130
+ class DiffEngine:
131
+ def plan(
132
+ self,
133
+ metadata: MetaData,
134
+ engine: Engine,
135
+ *,
136
+ schemas: Sequence[str] | None = None,
137
+ exclude: Sequence[str] = (),
138
+ ) -> Plan:
139
+ if schemas is None:
140
+ with engine.connect() as conn:
141
+ search_path = conn.execute(text("SHOW search_path")).scalar()
142
+ # a live PG session always reports a search_path
143
+ assert search_path is not None
144
+ schemas = [
145
+ s.strip()
146
+ for s in search_path.split(",")
147
+ if s.strip() and s.strip() != '"$user"'
148
+ ]
149
+ # New binding rather than a param reassignment: frozenset is a
150
+ # Set, not a Sequence, and the helpers below declare the exact
151
+ # type they take.
152
+ schema_set = frozenset(schemas)
153
+ exclude = tuple(exclude)
154
+ # Typed `str | None` by SQLAlchemy; a dialect without a default
155
+ # schema is meaningless for this PostgreSQL-only tool; "public"
156
+ # matches every supported server config.
157
+ default_schema = engine.dialect.default_schema_name or "public"
158
+
159
+ opts = {
160
+ "compare_type": True,
161
+ "compare_server_default": True,
162
+ "include_name": _make_include_name(schema_set, default_schema),
163
+ "include_object": _make_include(schema_set, default_schema),
164
+ "include_schemas": True,
165
+ }
166
+ with engine.connect() as conn:
167
+ ctx = MigrationContext.configure(conn, opts=opts)
168
+ script = produce_migrations(ctx, metadata)
169
+
170
+ # produce_migrations always builds the upgrade bundle
171
+ assert script.upgrade_ops is not None
172
+ ops: list[PlannedOperation] = []
173
+ for op in _flatten(script.upgrade_ops.ops):
174
+ ops.extend(self._translate(op, engine, exclude))
175
+ return Plan(operations=tuple(ops))
176
+
177
+ def _translate(self, op, engine: Engine, exclude: tuple[str, ...]) -> list[PlannedOperation]:
178
+ op_type = _OP_TYPE.get(type(op).__name__, "raw_sql")
179
+ if isinstance(op, AlterColumnOp):
180
+ op_type = _alter_column_label(op)
181
+ sql_text = _render_op_sql(op, engine)
182
+ table = getattr(getattr(op, "table", None), "name", None) or getattr(op, "table_name", None)
183
+ # AddColumnOp carries a real Column in `.column`; DropColumnOp and
184
+ # AlterColumnOp only expose the plain string `column_name`
185
+ # (alembic-notes op reference).
186
+ column = getattr(getattr(op, "column", None), "name", None) or getattr(
187
+ op, "column_name", None
188
+ )
189
+ # table-level patterns reach column-level ops too; a qualified
190
+ # table.column pattern suppresses only that specific column op
191
+ if table and any(fnmatch.fnmatch(table, pat) for pat in exclude):
192
+ return []
193
+ if table and column:
194
+ full = f"{table}.{column}"
195
+ if any(fnmatch.fnmatch(full, pat) for pat in exclude):
196
+ return []
197
+ return [
198
+ PlannedOperation(
199
+ type=op_type,
200
+ risk=classify(op_type),
201
+ sql=sql_text,
202
+ table=table,
203
+ column=column,
204
+ )
205
+ ]
sqlpush/core/render.py ADDED
@@ -0,0 +1,21 @@
1
+ # src/sqlpush/core/render.py
2
+ from __future__ import annotations
3
+
4
+ from sqlpush.types import Plan, RiskClass
5
+
6
+ _HEADERS = {
7
+ RiskClass.SAFE: "-- safe",
8
+ RiskClass.RISKY: "-- risky",
9
+ RiskClass.DESTRUCTIVE: "-- destructive",
10
+ }
11
+
12
+
13
+ def render(plan: Plan) -> str:
14
+ if not plan.operations:
15
+ return ""
16
+ sections: list[str] = []
17
+ for risk in (RiskClass.SAFE, RiskClass.RISKY, RiskClass.DESTRUCTIVE):
18
+ sqls = [op.sql for op in plan.operations if op.risk is risk]
19
+ if sqls:
20
+ sections.append(_HEADERS[risk] + "\n" + ";\n".join(sqls) + ";")
21
+ return "\n\n".join(sections)
File without changes
@@ -0,0 +1,79 @@
1
+ # src/sqlpush/directives/timescale.py
2
+ from __future__ import annotations
3
+
4
+ from sqlalchemy import MetaData, text
5
+ from sqlalchemy.engine import Connection, Engine
6
+ from sqlalchemy.exc import ProgrammingError
7
+
8
+ from sqlpush.annotations import HYPERTABLE_KEY
9
+ from sqlpush.types import PlannedOperation, RiskClass
10
+
11
+
12
+ def _is_hypertable(conn: Connection, table_name: str) -> bool:
13
+ try:
14
+ return bool(
15
+ conn.execute(
16
+ text(
17
+ "SELECT 1 FROM timescaledb_information.hypertables "
18
+ "WHERE hypertable_name = :name"
19
+ ),
20
+ {"name": table_name},
21
+ ).scalar()
22
+ )
23
+ except ProgrammingError:
24
+ # timescaledb_information views exist only where the extension
25
+ # is installed: on a non-timescale DB nothing can be a
26
+ # hypertable. Emit the op and let apply surface the server's
27
+ # own error: annotated models on non-timescale DBs are user
28
+ # error, not a state probe failure.
29
+ return False
30
+
31
+
32
+ def _lit(value: str) -> str:
33
+ # single-quoted SQL literal escaping: ' -> '' (defense in depth
34
+ # against quote break-out via table/column names from user metadata)
35
+ return value.replace("'", "''")
36
+
37
+
38
+ def hypertable_operations(
39
+ metadata: MetaData, engine: Engine | None = None
40
+ ) -> list[PlannedOperation]:
41
+ """Plan ``create_hypertable`` ops for ``@hypertable``-annotated tables.
42
+
43
+ ``engine=None`` emits unconditionally (DB-free preview/tests keep
44
+ the previous behavior). With ``engine``, a table already registered
45
+ in ``timescaledb_information.hypertables`` emits nothing: push stays
46
+ idempotent and ``check()`` reports clean on a synced annotated
47
+ schema: directives are state-aware like the diff.
48
+ """
49
+ pending = [
50
+ table for table in metadata.tables.values() if table.info.get(HYPERTABLE_KEY) is not None
51
+ ]
52
+ if engine is not None and pending:
53
+ with engine.connect() as conn:
54
+ pending = [t for t in pending if not _is_hypertable(conn, t.name)]
55
+ ops: list[PlannedOperation] = []
56
+ for table in pending:
57
+ info = table.info[HYPERTABLE_KEY]
58
+ name = _lit(table.name)
59
+ time_column = _lit(info.time_column)
60
+ parts = [f"SELECT create_hypertable('{name}', '{time_column}'"]
61
+ if info.chunk_time_interval:
62
+ parts.append(f", chunk_time_interval => INTERVAL '{_lit(info.chunk_time_interval)}'")
63
+ parts.append(", migrate_data => true")
64
+ # if_not_exists: race insurance between the state check above
65
+ # and the apply. create_default_indexes=false: timescale's
66
+ # implicit time-column index is invisible to metadata, so the
67
+ # default would drift forever (destructive drop_index) on a
68
+ # fully synced schema; users declare wanted indexes in the
69
+ # Table instead (metadata is the source of truth).
70
+ parts.append(", if_not_exists => true, create_default_indexes => false)")
71
+ ops.append(
72
+ PlannedOperation(
73
+ type="create_hypertable",
74
+ risk=RiskClass.SAFE,
75
+ sql="".join(parts) + ";",
76
+ table=table.name,
77
+ )
78
+ )
79
+ return ops
sqlpush/types.py ADDED
@@ -0,0 +1,100 @@
1
+ # src/sqlpush/types.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+
9
+ class RiskClass(str, Enum):
10
+ SAFE = "safe"
11
+ RISKY = "risky"
12
+ DESTRUCTIVE = "destructive"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PlannedOperation:
17
+ type: str
18
+ risk: RiskClass
19
+ sql: str
20
+ table: str | None = None
21
+ column: str | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Plan:
26
+ operations: tuple[PlannedOperation, ...] = ()
27
+
28
+ @property
29
+ def drift(self) -> bool:
30
+ return bool(self.operations)
31
+
32
+ @property
33
+ def has_destructive(self) -> bool:
34
+ return any(op.risk is RiskClass.DESTRUCTIVE for op in self.operations)
35
+
36
+ def to_json_dict(self) -> dict[str, Any]:
37
+ return {
38
+ "version": 1,
39
+ "drift": self.drift,
40
+ "operations": [
41
+ {
42
+ "type": op.type,
43
+ "risk": op.risk.value,
44
+ "table": op.table,
45
+ "column": op.column,
46
+ "sql": op.sql,
47
+ }
48
+ for op in self.operations
49
+ ],
50
+ "sql": ";\n".join(op.sql for op in self.operations),
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CheckResult:
56
+ clean: bool
57
+ drift: bool
58
+ has_destructive: bool
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AppliedOperation:
63
+ type: str
64
+ # execution outcome only: "applied" | "failed". Blocked operations
65
+ # are refused by the gate before execution and live in
66
+ # Report.blocked instead.
67
+ status: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Report:
72
+ # `applied` is the EXECUTION TRAIL of the run: it may contain
73
+ # "failed" entries (a CONCURRENTLY op that errored, see
74
+ # partial_failure), not only successes.
75
+ applied: tuple[AppliedOperation, ...] = ()
76
+ # `blocked`: destructive ops refused by the gate (allow_destructive=False)
77
+ # Hard stop, CLI maps this to exit 1.
78
+ # `skipped`: ops declined by policy (safe_only), informational only,
79
+ # the run proceeds without them.
80
+ blocked: tuple[PlannedOperation, ...] = ()
81
+ skipped: tuple[PlannedOperation, ...] = ()
82
+ partial_failure: bool = False
83
+ duration: float = 0.0
84
+
85
+
86
+ class SqlpushError(Exception):
87
+ pass
88
+
89
+
90
+ class ConnectFailed(SqlpushError):
91
+ pass
92
+
93
+
94
+ class MetadataImportError(SqlpushError):
95
+ pass
96
+
97
+
98
+ class DestructiveBlocked(SqlpushError):
99
+ """Reserved for future use: the destructive gate currently reports
100
+ through ``Report.blocked`` instead of raising."""
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlpush
3
+ Version: 0.1.0
4
+ Summary: Schema lifecycle tool for production PostgreSQL/TimescaleDB on SQLAlchemy 2.0 (SQLModel included): diff, push and check schema drift straight from your models
5
+ Keywords: sqlalchemy,alembic,postgresql,timescaledb,prisma,schema,migrations,database,drift,cli
6
+ Author: Juan Miguel Contreras
7
+ Author-email: Juan Miguel Contreras <19253629+juanmicl@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Dist: alembic>=1.18,<2
20
+ Requires-Dist: psycopg[binary]>=3.2
21
+ Requires-Dist: sqlalchemy>=2.0
22
+ Requires-Dist: typer>=0.12
23
+ Requires-Python: >=3.10
24
+ Project-URL: Homepage, https://github.com/juanmicl/sqlpush
25
+ Project-URL: Repository, https://github.com/juanmicl/sqlpush
26
+ Project-URL: Issues, https://github.com/juanmicl/sqlpush/issues
27
+ Project-URL: Changelog, https://github.com/juanmicl/sqlpush/blob/main/CHANGELOG.md
28
+ Description-Content-Type: text/markdown
29
+
30
+ # sqlpush
31
+
32
+ [![CI](https://github.com/juanmicl/sqlpush/actions/workflows/ci.yml/badge.svg?style=for-the-badge)](https://github.com/juanmicl/sqlpush/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/sqlpush?style=for-the-badge)](https://pypi.org/project/sqlpush/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
36
+
37
+ **Prisma `db push` for SQLAlchemy.** Apply your models (SQLAlchemy,
38
+ SQLModel, anything built on `MetaData`) to a live PostgreSQL / TimescaleDB
39
+ database directly, no migration files. sqlpush
40
+ diffs your models against the real schema, classifies every operation by
41
+ risk (safe / risky / destructive), and applies the plan atomically. Drift
42
+ checks exit with codes your CI can gate on.
43
+
44
+ ```console
45
+ sqlpush diff "myapp.models:metadata" # see the SQL, ordered by risk
46
+ sqlpush check "myapp.models:metadata" # CI gate: exit 0/2/3
47
+ sqlpush push "myapp.models:metadata" # apply (destructive gated)
48
+ ```
49
+
50
+ If you've ever run `Base.metadata.create_all()` in production and known it
51
+ was wrong, then sighed at the migration-script treadmill when you reached
52
+ for alembic: sqlpush is for you.
53
+
54
+ ## Why
55
+
56
+ Declarative models are already the source of truth. Migration files
57
+ re-encode what the models say, drift from them, and pile up forever.
58
+ sqlpush closes the loop the way Prisma's `db push` does for its schema
59
+ language, but for the SQLAlchemy ecosystem (SQLModel included):
60
+
61
+ - **No migration files, ever.** The diff *is* the migration: computed fresh
62
+ from models vs. live database on every run, via alembic's autogenerate
63
+ engine used as a library.
64
+ - **Risk-aware by default.** Every operation is classified `safe` /
65
+ `risky` / `destructive`. Destructive ops (drops) are **blocked until
66
+ `--allow-destructive`**: nothing executes at all while any is present.
67
+ - **Drift detection built for CI.** `check` plans once and exits `0` clean /
68
+ `2` drift / `3` destructive drift, scriptable without parsing output.
69
+ `--json` emits a stable versioned contract.
70
+ - **Safe under concurrency.** An advisory lock (keyed to the database, not
71
+ the DSN) coordinates workers: one pusher at a time, losers wait bounded
72
+ and re-verify, so deploy pipelines can race without corrupting anything.
73
+ - **Hypertables without hand-written SQL.** Decorate a model with
74
+ `@hypertable` and the `create_hypertable` directive is planned
75
+ state-aware: idempotent pushes, clean checks, no false drift.
76
+
77
+ PostgreSQL only, by design.
78
+
79
+ ## Install
80
+
81
+ ```console
82
+ pip install sqlpush
83
+ ```
84
+
85
+ Or from source:
86
+
87
+ ```console
88
+ git clone https://github.com/juanmicl/sqlpush && cd sqlpush && uv sync
89
+ ```
90
+
91
+ ## The 30-second tour
92
+
93
+ Point sqlpush at your metadata (`module:attribute`) and a database
94
+ (`--dsn` or `$DATABASE_URL`):
95
+
96
+ ```console
97
+ $ export DATABASE_URL="postgresql+psycopg://user:pass@host:5432/db"
98
+
99
+ $ sqlpush diff "myapp.models:metadata"
100
+ -- safe
101
+
102
+ CREATE TABLE hero (
103
+ id SERIAL NOT NULL PRIMARY KEY,
104
+ name VARCHAR(50) NOT NULL
105
+ );
106
+
107
+ -- risky
108
+
109
+ CREATE INDEX ix_hero_name ON hero (name);
110
+ ```
111
+
112
+ Push it (the destructive gate is on by default):
113
+
114
+ ```console
115
+ $ sqlpush push "myapp.models:metadata"
116
+ 1 destructive operation(s) blocked; re-run with --allow-destructive
117
+ $ echo $?
118
+ 1
119
+
120
+ $ sqlpush push "myapp.models:metadata" --allow-destructive
121
+ $ echo $?
122
+ 0
123
+ ```
124
+
125
+ In CI, check drift and fail loudly (see exit codes below). Limit scope with
126
+ repeated `--schema` / `--exclude` options.
127
+
128
+ ## Exit codes
129
+
130
+ | verb | 0 | 1 | 2 | 3 |
131
+ | --- | --- | --- | --- | --- |
132
+ | `diff` | always | | | |
133
+ | `check` | clean | | drift | destructive drift |
134
+ | `push` | applied | destructive blocked | error (incl. partial failure) | |
135
+
136
+ `push --safe-only` runs only safe operations and skips the rest
137
+ informationally (exit `0`). A failed `CREATE INDEX CONCURRENTLY` marks the
138
+ run as partial failure (exit `2`) instead of silently half-applying.
139
+
140
+ ## FastAPI / SQLModel: replace `create_all`
141
+
142
+ ```python
143
+ from contextlib import asynccontextmanager
144
+ from sqlpush import aensure_schema
145
+
146
+
147
+ @asynccontextmanager
148
+ async def lifespan(app):
149
+ await aensure_schema(SQLModel.metadata, engine, mode="check")
150
+ yield
151
+ ```
152
+
153
+ Push in the deploy pipeline, check at startup.
154
+
155
+ ## How it works
156
+
157
+ ```mermaid
158
+ flowchart LR
159
+ models["SQLAlchemy MetaData"] --> diff["diff<br>alembic autogenerate, scoped"]
160
+ db[("live PostgreSQL")] --> diff
161
+ diff --> risk["risk classification<br>safe / risky / destructive"]
162
+ risk --> plan["plan"]
163
+ plan --> render["render"]
164
+ render --> apply["apply<br>atomic txn · CONCURRENTLY split · advisory lock"]
165
+ apply --> report["report"]
166
+ ```
167
+
168
+ - **Diff engine** scopes reflection to your target schemas (default: the
169
+ session's real `search_path`) and prunes system catalogs (TimescaleDB
170
+ internals included) before reflection even starts.
171
+ - **Classifier** maps each operation to a risk class; unknown operations
172
+ are `risky`, never silently safe.
173
+ - **Executor** splits the plan: `CONCURRENTLY` statements run one-per-
174
+ transaction on autocommit, everything else applies in a single atomic
175
+ transaction with a bounded `lock_timeout`.
176
+ - **Typed errors**: only `SqlpushError` / `ConnectFailed` /
177
+ `MetadataImportError` escape the API, never raw driver exceptions.
178
+
179
+ ## Comparison
180
+
181
+ An honest view of the neighborhood (stars as of 2026-08):
182
+
183
+ | | migration files | source of truth | risk gate | CI drift exit codes | TimescaleDB |
184
+ | --- | --- | --- | --- | --- | --- |
185
+ | **sqlpush** | none (the diff is the migration) | SQLAlchemy `MetaData` | classified safe/risky/destructive, destructive blocked by default | `check` 0/2/3 | `@hypertable` directives |
186
+ | [alembic](https://github.com/sqlalchemy/alembic) (4.4k★) | yes | migration scripts (autogenerate assists) | no | no | no |
187
+ | [atlas](https://github.com/ariga/atlas) (8.7k★) | optional (HCL) | HCL / SQL (ORMs via providers) | lint policies | yes | no |
188
+ | [prisma `db push`](https://www.prisma.io/docs/orm/reference/prisma-cli-reference) (47k★) | none | Prisma schema (Node/TS) | no | no | no |
189
+ | [migra](https://github.com/djrobstep/migra) (3.1k★) | diff only | SQL | n/a | partial | no (*deprecated*) |
190
+
191
+ sqlpush is narrower than atlas and younger than alembic, deliberately.
192
+ It is one tool for one job: keep a PostgreSQL schema in lockstep with
193
+ SQLAlchemy models, safely enough to run from CI.
194
+
195
+ Coming from [migra](https://github.com/djrobstep/migra) (now
196
+ deprecated)? There is a [migration guide](docs/migrating-from-migra.md).
197
+
198
+ ## Design notes
199
+
200
+ - `import sqlpush` stays light: the public API loads lazily, so the
201
+ annotations module carries none of alembic/typer/psycopg.
202
+ - The advisory-lock key derives from the database OID: two DSN spellings
203
+ of the same database contend for the same lock.
204
+ - `--json` output is a versioned contract (`"version": 1`) meant for
205
+ tooling; additive changes only within a version.
206
+
207
+ ## Roadmap (0.1.x)
208
+
209
+ - `CREATE INDEX CONCURRENTLY` by default for indexes on existing tables
210
+ - asyncpg DSN translation in `ensure_schema(AsyncEngine)`
211
+ - jsonschema-validated `--json` output
212
+
213
+ ## License
214
+
215
+ [MIT](LICENSE) · © 2026 Juan Miguel Contreras
@@ -0,0 +1,17 @@
1
+ sqlpush/__init__.py,sha256=Px0jJFHbwwM1-UHtDwD3xQlRifEMW4StSXQs5DD9z6w,1697
2
+ sqlpush/annotations.py,sha256=AKK4t30lRznTFUfDYaH9r8WslHzzVs57GNtZGHxS8LM,1222
3
+ sqlpush/api.py,sha256=n4NcrdmUWqu8TJluph61F2swEIJkZs0PQcFExzcebqU,5291
4
+ sqlpush/apply/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sqlpush/apply/executor.py,sha256=-HgmscUO6M_xQ5HPBQhJkUo6XwEPPqBig7_G9aWQJAU,7242
6
+ sqlpush/cli.py,sha256=UeyBnixEz1s6nf4Wv4Y7zrmce8y02pqfHCy2_brjg_Q,7388
7
+ sqlpush/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ sqlpush/core/classify.py,sha256=3NgoaheRgO6AfjtXm9ndvvUq-Ei1BvYKX03o3kkdgZA,718
9
+ sqlpush/core/diff.py,sha256=g5e2kwKdu88k24S6lFNSPxxNDxeq55Yo22lLTPIXnvU,8381
10
+ sqlpush/core/render.py,sha256=9SNEyHOGmMxKUfIjvo3x0MNVQ6N2o2fl3noWT1EWO3M,609
11
+ sqlpush/directives/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ sqlpush/directives/timescale.py,sha256=Z1X7A6o8-wVTTe8aJLzXC_ko2OP_i8-LaEmgSzy7mYg,3210
13
+ sqlpush/types.py,sha256=D2FV3eJZt0znXpZLV8ZRFQr7mIoNKjai4C7zPL71fdE,2502
14
+ sqlpush-0.1.0.dist-info/WHEEL,sha256=ZFFp7t7R4RYQ5KYZkmiFWoQvHay7SrTmn-6ZYfoFZ3U,80
15
+ sqlpush-0.1.0.dist-info/entry_points.txt,sha256=VYha3PJSvklRgpcDAZYJdE66EXM79MSKQf0z8igyAOI,46
16
+ sqlpush-0.1.0.dist-info/METADATA,sha256=gqo6pjZ06bLySmE8MRXze-_5uiu2-ZYS8mpy7iJw7wI,8535
17
+ sqlpush-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sqlpush = sqlpush.cli:main
3
+