pgdevkit 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.
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ _INVALID_CHARS = re.compile(r"[^a-z0-9_]+")
9
+ _MAX_SLUG_LEN = 30
10
+
11
+
12
+ def slugify(value: str) -> str:
13
+ """Lowercase, replace invalid chars with '_', truncate+hash if too long."""
14
+ slug = _INVALID_CHARS.sub("_", value.lower()).strip("_")
15
+ if not slug:
16
+ slug = "x"
17
+ if len(slug) <= _MAX_SLUG_LEN:
18
+ return slug
19
+ digest = hashlib.sha256(slug.encode()).hexdigest()[:8]
20
+ return f"{slug[:_MAX_SLUG_LEN]}_{digest}"
21
+
22
+
23
+ def current_branch(cwd: Path | None = None) -> str:
24
+ """Return the branch checked out in the git worktree rooted at cwd."""
25
+ result = subprocess.run(
26
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
27
+ cwd=cwd,
28
+ capture_output=True,
29
+ text=True,
30
+ check=True,
31
+ )
32
+ return result.stdout.strip()
33
+
34
+
35
+ def workspace_db_name(project_name: str, branch: str) -> str:
36
+ """Compute a Postgres-safe, collision-resistant database name for this
37
+ project+branch. A second slugify pass over the joined string guarantees
38
+ the result stays under Postgres's 63-byte identifier limit even when
39
+ both inputs are already at the per-component truncation limit."""
40
+ joined = f"{slugify(project_name)}_{slugify(branch)}"
41
+ return slugify(joined)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import LiteralString, cast
5
+
6
+ import psycopg
7
+ from psycopg.rows import dict_row
8
+
9
+ _DOLLAR_TAG = re.compile(r"\$[A-Za-z_]*\$")
10
+
11
+
12
+ def _split_statements(sql: str) -> list[str]:
13
+ """Split on ';', but treat single/double-quoted strings and dollar-quoted
14
+ bodies (e.g. a plpgsql function's $$ ... $$) as opaque so an embedded ';'
15
+ inside them doesn't split the statement in two."""
16
+ statements: list[str] = []
17
+ buf: list[str] = []
18
+ i, n = 0, len(sql)
19
+ while i < n:
20
+ ch = sql[i]
21
+ if ch == "$" and (m := _DOLLAR_TAG.match(sql, i)):
22
+ tag = m.group()
23
+ end = sql.find(tag, m.end())
24
+ end = n if end == -1 else end + len(tag)
25
+ buf.append(sql[i:end])
26
+ i = end
27
+ continue
28
+ if ch in ("'", '"'):
29
+ end = i + 1
30
+ while end < n:
31
+ if sql[end] == ch:
32
+ end += 1
33
+ if sql[end : end + 1] == ch: # doubled quote = escaped literal quote
34
+ end += 1
35
+ continue
36
+ break
37
+ end += 1
38
+ buf.append(sql[i:end])
39
+ i = end
40
+ continue
41
+ if ch == ";":
42
+ statements.append("".join(buf))
43
+ buf = []
44
+ i += 1
45
+ continue
46
+ buf.append(ch)
47
+ i += 1
48
+ statements.append("".join(buf))
49
+ return [s.strip() for s in statements if s.strip()]
50
+
51
+
52
+ async def execute(dsn: str, sql: str) -> list[dict] | None:
53
+ """Run one or more statements against dsn. Returns the rows of the final
54
+ statement if it produced any, else None."""
55
+ statements = _split_statements(sql)
56
+ last_rows: list[dict] | None = None
57
+ async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as con:
58
+ for stmt in statements:
59
+ async with con.cursor(row_factory=dict_row) as cur:
60
+ # stmt is arbitrary, caller-provided SQL text (a .sql file or
61
+ # --sql argument) — not a compile-time literal, but this
62
+ # function's entire purpose is to run it as-is.
63
+ await cur.execute(cast(LiteralString, stmt))
64
+ last_rows = await cur.fetchall() if cur.description else None
65
+ return last_rows
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import psycopg
11
+ import sqlglot
12
+ import sqlglot.expressions as exp
13
+ from psycopg.rows import dict_row
14
+ from psycopg.sql import SQL, Identifier, Placeholder
15
+
16
+ logger = logging.getLogger(__name__)
17
+ logging.getLogger("sqlglot").setLevel(logging.ERROR)
18
+
19
+ # The `database/` folder convention (layer dirs, object-type subfolders and
20
+ # their apply order, file-naming rules) is documented in
21
+ # docs/database-layout.md — keep that table in sync with this dict.
22
+ _TYPE_ORDER = {
23
+ "schema": 1,
24
+ "types": 2,
25
+ "tables": 3,
26
+ "scalar_functions": 4,
27
+ "functions": 5,
28
+ "views": 6,
29
+ "table_functions": 7,
30
+ "procedures": 8,
31
+ "permissions": 100,
32
+ "indexes": 101,
33
+ }
34
+
35
+
36
+ def _get_type_order(path: Path) -> int:
37
+ filename = re.sub(r"^\d+(\.\d+)?", "", path.name).removeprefix("_").removesuffix(".sql")
38
+ if filename in _TYPE_ORDER:
39
+ return _TYPE_ORDER[filename]
40
+ if path.parent.name in _TYPE_ORDER:
41
+ return _TYPE_ORDER[path.parent.name]
42
+ raise ValueError(f"Unknown SQL type for {path.name} in {path.parent.name}")
43
+
44
+
45
+ def _strip_layer_prefix(schema_name: str) -> str:
46
+ """Strip a leading numeric layer prefix (e.g. "1_dim" -> "dim") so it
47
+ matches the unprefixed schema name used in SQL identifiers."""
48
+ if re.match(r"^\d+_", schema_name):
49
+ return schema_name.split("_", 1)[1]
50
+ return schema_name
51
+
52
+
53
+ _SCHEMA_QUALIFIED_TYPES = {
54
+ "types",
55
+ "tables",
56
+ "scalar_functions",
57
+ "functions",
58
+ "views",
59
+ "table_functions",
60
+ "procedures",
61
+ }
62
+
63
+
64
+ def _get_sql_deps(sql: str) -> set[str]:
65
+ exprs = sqlglot.parse(sql, dialect="postgres")
66
+ deps: set[str] = set()
67
+ for e in exprs:
68
+ if e is None:
69
+ continue
70
+ for t in e.find_all(exp.Table):
71
+ if t.args.get("this") is not None and t.args.get("db") is not None:
72
+ deps.add(str(t))
73
+ return deps
74
+
75
+
76
+ def _iter_sql_files(database_dir: Path):
77
+ """Yield (Path, sql_content) pairs in dependency-safe execution order."""
78
+ files: list[Path] = []
79
+ for root, _, dbfiles in os.walk(database_dir):
80
+ if "_migration_scripts" in root or "migrations" in root:
81
+ continue
82
+ for file in dbfiles:
83
+ if file in ("all.sql", "100_permissions.sql"):
84
+ continue
85
+ if file.endswith(".sql") and ".prod" not in file:
86
+ files.append(Path(root) / file)
87
+
88
+ delivered: set[str] = set()
89
+ delayed: list[tuple[str | None, Path, str]] = []
90
+ all_declared: set[str] = set()
91
+
92
+ for file in sorted(files, key=lambda p: (_get_type_order(p), p.name)):
93
+ content = file.read_text(encoding="utf-8")
94
+ deps = _get_sql_deps(content)
95
+ if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
96
+ schema = _strip_layer_prefix(file.parent.parent.name)
97
+ full_name = f"{schema}.{file.stem}"
98
+ deps.discard(full_name) # the file's own CREATE target is not a real dependency
99
+ all_declared.add(full_name)
100
+ if not deps or all(d in delivered for d in deps):
101
+ delivered.add(full_name)
102
+ yield file, content
103
+ else:
104
+ delayed.append((full_name, file, content))
105
+ continue
106
+ if not deps or all(d in delivered for d in deps):
107
+ yield file, content
108
+ else:
109
+ delayed.append((None, file, content))
110
+
111
+ while delayed:
112
+ progressed = False
113
+ for i in range(len(delayed) - 1, -1, -1):
114
+ declared_name, file, content = delayed[i]
115
+ deps = _get_sql_deps(content)
116
+ if declared_name:
117
+ deps.discard(declared_name)
118
+ if all(d in delivered or d not in all_declared for d in deps):
119
+ if declared_name:
120
+ delivered.add(declared_name)
121
+ yield file, content
122
+ delayed.pop(i)
123
+ progressed = True
124
+ if not progressed:
125
+ raise ValueError(f"Circular or missing SQL dependencies: {[f[1] for f in delayed]}")
126
+
127
+
128
+ async def _insert_test_data(
129
+ json_file: Path, table: str, force_reset: bool, con: psycopg.AsyncConnection
130
+ ) -> None:
131
+ if not json_file.exists():
132
+ return
133
+ rows: list[dict[str, Any]] = json.loads(json_file.read_text(encoding="utf-8"))
134
+ if not rows:
135
+ return
136
+
137
+ schema, table_name = table.split(".")
138
+ async with con.cursor(row_factory=dict_row) as cur:
139
+ if not force_reset:
140
+ await cur.execute(SQL("SELECT count(*) AS cnt FROM {t}").format(t=Identifier(schema, table_name)))
141
+ row = await cur.fetchone()
142
+ if row and row["cnt"] == len(rows):
143
+ return
144
+
145
+ col_names = list(rows[0].keys())
146
+ for row in rows:
147
+ for col in col_names:
148
+ if isinstance(row[col], (dict, list)):
149
+ row[col] = json.dumps(row[col])
150
+
151
+ await cur.execute(SQL("DELETE FROM {t}").format(t=Identifier(schema, table_name)))
152
+ insert_sql = SQL("INSERT INTO {t} ({cols}) VALUES ({vals})").format(
153
+ t=Identifier(schema, table_name),
154
+ cols=SQL(", ").join(Identifier(c) for c in col_names),
155
+ vals=SQL(", ").join(Placeholder(c) for c in col_names),
156
+ )
157
+ await cur.executemany(insert_sql, rows)
158
+
159
+
160
+ async def apply_schema(
161
+ con: psycopg.AsyncConnection,
162
+ database_dir: Path,
163
+ extensions: tuple[str, ...] = (),
164
+ force_reset: bool = False,
165
+ ) -> None:
166
+ """Apply every .sql file under database_dir (in dependency-safe order)
167
+ and seed any matching .test_data.json files. Safe to call repeatedly."""
168
+ await con.set_autocommit(True)
169
+ for extension in extensions:
170
+ await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
171
+
172
+ async def _apply(file: Path, sql: str) -> None:
173
+ await con.execute(cast(Any, sql))
174
+ json_file = file.with_suffix(".test_data.json")
175
+ if json_file.exists():
176
+ schema_name = _strip_layer_prefix(file.parent.parent.name)
177
+ await _insert_test_data(json_file, f"{schema_name}.{file.stem}", force_reset, con)
178
+
179
+ failures: list[tuple[Path, str]] = []
180
+ for file, sql in _iter_sql_files(database_dir):
181
+ try:
182
+ await _apply(file, sql)
183
+ except Exception as e: # noqa: BLE001
184
+ logger.warning("Error executing %s (will retry): %s", file, e)
185
+ failures.append((file, sql))
186
+
187
+ for file, sql in failures:
188
+ await _apply(file, sql)
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgdevkit
3
+ Version: 0.1.0
4
+ Summary: A helper for developing with Postgres
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: psycopg[binary]>=3.2.0
7
+ Requires-Dist: sqlglot[c]>=30.11.0
8
+ Provides-Extra: azure
9
+ Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
10
+ Provides-Extra: cli
11
+ Requires-Dist: rich>=13.0.0; extra == 'cli'
12
+ Requires-Dist: typer>=0.26.7; extra == 'cli'
13
+ Provides-Extra: db
14
+ Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
15
+ Requires-Dist: pydantic>=2.0; extra == 'db'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pgdevkit
19
+
20
+ A helper for developing with Postgres.
21
+
22
+ ## `pgdb compare`
23
+
24
+ Compare a directory of SQL scripts (see the `database-in-source` layout
25
+ convention) against a live database and report differences:
26
+
27
+ ```bash
28
+ pgdb compare --url postgresql://user:pass@host:port/db path/to/database/
29
+ ```
30
+
31
+ ### Entra ID auth (Azure Postgres / Databricks Lakebase)
32
+
33
+ Pass `--entra-user <identity>` to `pgdb compare` to authenticate with an
34
+ Entra ID token instead of a static password. Which token flow is used is
35
+ auto-detected from the database hostname:
36
+
37
+ - **Azure Database for PostgreSQL** (`*.postgres.database.azure.com`,
38
+ `*.postgres.cosmos.azure.com`) — the default: fetches a token via
39
+ `DefaultAzureCredential` and uses it directly as the password. Requires
40
+ the `azure` extra: `pip install pgdevkit[azure]`.
41
+ - **Databricks Lakebase** (`*.database.azuredatabricks.net`,
42
+ `*.database.cloud.databricks.com`) — fetches a Databricks-scoped Entra
43
+ token, then exchanges it for a short-lived Postgres credential via the
44
+ Databricks workspace API. Also requires `--databricks-workspace-host`
45
+ and `--databricks-instance`:
46
+
47
+ ```bash
48
+ pgdb compare --url postgresql://instance-abc.database.azuredatabricks.net:5432/databricks_postgres \
49
+ --entra-user alice@example.com \
50
+ --databricks-workspace-host https://adb-123456789.azuredatabricks.net \
51
+ --databricks-instance myinstance \
52
+ path/to/database/
53
+ ```
54
+
55
+ (`--url`'s own user/password, if any, are discarded and replaced — `--entra-user`
56
+ plus the fetched token become the connection's actual credentials.)
57
+
58
+ ## `pgdb testdb`
59
+
60
+ Manages a single shared, Podman-backed Postgres container for local tests
61
+ across all your projects — no more one-container-per-project-per-worktree.
62
+ Isolation between projects and worktrees is per-database, inside one
63
+ container.
64
+
65
+ Add to `pyproject.toml`:
66
+
67
+ ```toml
68
+ [tool.pgdevkit]
69
+ name = "myproject" # optional; defaults to the repo directory name
70
+ database_dir = "database" # optional; defaults to "database"
71
+ ```
72
+
73
+ Add to `conftest.py`:
74
+
75
+ ```python
76
+ import os
77
+ import pytest
78
+ from pgdevkit.testdb import ensure_testdb
79
+
80
+ @pytest.fixture(scope="session", autouse=True)
81
+ def ensure_test_postgres():
82
+ for k, v in ensure_testdb().items():
83
+ os.environ[k] = v
84
+ ```
85
+
86
+ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
87
+
88
+ Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
89
+ be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
90
+ `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching
91
+ podman/docker, pgdevkit first checks (with a short timeout) whether Postgres
92
+ is already reachable at that address and skips container management if so.
93
+ Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
94
+ that check too.
95
+
96
+ ## `pgdevkit.db` — helpers for application code
97
+
98
+ Install with the `db` extra: `pip install pgdevkit[db]`.
99
+
100
+ - **`PostgresTableModel`** — a `pydantic.BaseModel` base class for models
101
+ that map 1:1 to a table row. Implement `get_table_name()` (returns
102
+ `(schema, table)`) and `get_primary_key()` on each model.
103
+ - **`PgPool`** — an async connection pool keyed off
104
+ `{env_prefix}HOST/PORT/DB/USER/PASSWORD` env vars. Call `await pool.open()`
105
+ once at startup, then use `async with pool.connection() as con:`.
106
+ Pass `entra_user` to authenticate via Entra ID instead of a static
107
+ password — same host-based auto-detection as `pgdb compare`'s
108
+ `--entra-user`. For Lakebase hosts, also set the
109
+ `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
110
+ env vars.
111
+ - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
112
+ `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
113
+ `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
114
+ `pg_delete_dict` — typed (`PostgresTableModel`-based) or dict-based CRUD
115
+ against a table, built on `psycopg` for safe identifier/value handling.
116
+ - **`SqlLoader`** — loads and caches `.sql` files from
117
+ `{root}/<topic>/<name>.sql`, for keeping hand-written queries out of
118
+ Python source.
119
+
120
+ ```python
121
+ from pgdevkit.db import PgPool, PostgresTableModel, pg_retrieve, pg_upsert
122
+
123
+ class Widget(PostgresTableModel):
124
+ id: int
125
+ name: str
126
+
127
+ @staticmethod
128
+ def get_table_name() -> tuple[str, str]:
129
+ return ("public", "widget")
130
+
131
+ @staticmethod
132
+ def get_primary_key() -> list[str]:
133
+ return ["id"]
134
+
135
+ pool = PgPool(env_prefix="POSTGRES_")
136
+ await pool.open()
137
+ async with pool.connection() as con:
138
+ widget = await pg_retrieve(con, Widget, {"id": 1})
139
+ await pg_upsert(con, Widget(id=1, name="thing"), Widget)
140
+ ```
@@ -0,0 +1,31 @@
1
+ pgdevkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pgdevkit/cli.py,sha256=71z8wBVhj51N_ZC34S4NxVCyE58esXdVyarDphI_d4o,8309
3
+ pgdevkit/connection.py,sha256=UaHzF4QaYTf7v5-LWscHJcTV11byfVGGlSdb5S4CvfM,1901
4
+ pgdevkit/diff.py,sha256=oZhkMWWtsI1UAo9eJ7LJ-PbBGab01Rh1iTIA7iaTLXw,9550
5
+ pgdevkit/fetch_missing.py,sha256=1-2NBDCQNtirywO9Ucm775UKSACfAzcnQlp-4Jh2weE,8136
6
+ pgdevkit/introspect.py,sha256=QYIlRuZk4lBHGxaW7zb_kFRIuNfi3AxH0Q2F_GKzlVI,7870
7
+ pgdevkit/lakebase.py,sha256=0PABOk4p-2klLTY8QAUibQf9QeyNHKywUWEWGdkUY8k,3165
8
+ pgdevkit/models.py,sha256=dAZ8f7pgDsxoLVrDWsM4h0qwJ0pOzXDdWMGsnjrW80E,2103
9
+ pgdevkit/parser.py,sha256=B3NRtcSBuEUcQNp5lxEMi6ApVedkbNUNdklCea-Ts40,12021
10
+ pgdevkit/db/__init__.py,sha256=H8muiCuNr_ogJo4tg3iLxxfR2jTckSrxxrVQyTd_3I4,701
11
+ pgdevkit/db/connection.py,sha256=yIj1pBS_vX4CFNZvidGf1AIIKlXblfQKejNYEO5KfVA,2606
12
+ pgdevkit/db/crud.py,sha256=uWVTgHI-JJezXufP-WDHs6Q8LPtSw8YEk7IErVmyDe4,7223
13
+ pgdevkit/db/loader.py,sha256=0Swmp0h0s7I4acG0Dv3cem2YD4lFvq8aFEbFQPPQrxg,616
14
+ pgdevkit/db/model.py,sha256=sAE4KMrDApxCiDvIRz2spa4YNJYq-fxyb5R1Cxt19ro,663
15
+ pgdevkit/testdb/__init__.py,sha256=-APwuluorU3iRCUsiJPK7Bc1AS5_IXxCdKB4zuaQ868,178
16
+ pgdevkit/testdb/api.py,sha256=MEaH9zH542jWSm9w1SseTk8vNIqgI5fwRKd8iVMH3vM,4930
17
+ pgdevkit/testdb/config.py,sha256=v0KFSkWhhZ8vy9167nqHqPsuCjilVrIQ6iqo8m_9-Hk,1545
18
+ pgdevkit/testdb/constants.py,sha256=-pa05wdqXkSszl9xlO2wMLGpkGzGz9vrWFxLgs8Ep3Y,465
19
+ pgdevkit/testdb/container.py,sha256=s3G8v6z_BMQF6SPtixUcbBC0xjMak9TzKt_EJWsUEg8,3162
20
+ pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
21
+ pgdevkit/testdb/query.py,sha256=e4UbgdCKKIot3a8Qixg-OX-Af0rlQ4a8ODhJBD8mtVM,2318
22
+ pgdevkit/testdb/schema.py,sha256=WsA3-czg5DhyEbNj-TBTpU9w4MdgwFHb02TowuvYzJo,6572
23
+ pgdevkit/docs/database-layout.md,sha256=dNvDH956xSXuAnLqkOopyOfaIm_A-RtUd57VTaZ1GQU,6035
24
+ pgdevkit/skills/pgdevkit/SKILL.md,sha256=P6CojgqlF3oOEfcETLwN22YvyZCPK4ofPn2DMx70fFE,11324
25
+ pgdevkit/skills/pgdevkit/references/complex_helper.py,sha256=0qQ-35ae2gUGdUXM8vL5mHRkVph-NNOBaJbQQvU8Stk,10062
26
+ pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
27
+ pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
28
+ pgdevkit-0.1.0.dist-info/METADATA,sha256=MhTnLQk9TdbMax7XXtS4njJjq1w4Yps95oIQ-HqcSNk,5128
29
+ pgdevkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
30
+ pgdevkit-0.1.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
31
+ pgdevkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pgdb = pgdevkit.cli:app