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.
pgdevkit/introspect.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import psycopg
6
+ from psycopg.rows import dict_row
7
+
8
+ from .models import (
9
+ ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
10
+ EnumDef, FunctionDef, IndexDef, TableDef, ViewDef,
11
+ )
12
+
13
+
14
+ def _q(conn: Any, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
15
+ with conn.cursor(row_factory=dict_row) as cur:
16
+ cur.execute(sql, params)
17
+ return cur.fetchall() # type: ignore[return-value]
18
+
19
+
20
+ def introspect_db(conninfo: str) -> DatabaseSchema:
21
+ with psycopg.connect(conninfo) as conn:
22
+ db = DatabaseSchema()
23
+ _load_schemas(conn, db)
24
+ _load_tables(conn, db)
25
+ _load_views(conn, db)
26
+ _load_functions(conn, db)
27
+ _load_enums(conn, db)
28
+ _load_composites(conn, db)
29
+ _load_indexes(conn, db)
30
+ return db
31
+
32
+
33
+ def _load_schemas(conn: Any, db: DatabaseSchema) -> None:
34
+ rows = _q(conn, (
35
+ "SELECT schema_name FROM information_schema.schemata "
36
+ "WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'"
37
+ ))
38
+ db.schemas = {r["schema_name"] for r in rows}
39
+
40
+
41
+ def _load_tables(conn: Any, db: DatabaseSchema) -> None:
42
+ tables = _q(conn, """
43
+ SELECT n.nspname AS schema, c.relname AS name, c.relispartition AS is_partition
44
+ FROM pg_catalog.pg_class c
45
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
46
+ WHERE c.relkind IN ('r', 'p')
47
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
48
+ AND n.nspname NOT LIKE 'pg_%'
49
+ """)
50
+
51
+ for row in tables:
52
+ tschema, tname = row["schema"], row["name"]
53
+ table = TableDef(schema=tschema, name=tname, is_partition=bool(row["is_partition"]))
54
+
55
+ for c in _q(conn, """
56
+ SELECT a.attname AS name,
57
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
58
+ NOT a.attnotnull AS is_nullable,
59
+ pg_get_expr(d.adbin, d.adrelid) AS col_default,
60
+ a.attgenerated != '' AS is_generated
61
+ FROM pg_catalog.pg_attribute a
62
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
63
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
64
+ LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
65
+ WHERE n.nspname = %(s)s AND c.relname = %(t)s
66
+ AND a.attnum > 0 AND NOT a.attisdropped
67
+ ORDER BY a.attnum
68
+ """, {"s": tschema, "t": tname}):
69
+ table.columns.append(ColumnDef(
70
+ name=c["name"],
71
+ data_type=c["data_type"],
72
+ is_nullable=bool(c["is_nullable"]),
73
+ default=c["col_default"],
74
+ is_generated=bool(c["is_generated"]),
75
+ ))
76
+
77
+ kind_map = {"p": "PRIMARY KEY", "u": "UNIQUE", "f": "FOREIGN KEY", "c": "CHECK"}
78
+ for c in _q(conn, """
79
+ SELECT conname AS name, contype AS kind,
80
+ pg_get_constraintdef(oid) AS definition
81
+ FROM pg_catalog.pg_constraint
82
+ WHERE conrelid = (
83
+ SELECT c.oid FROM pg_catalog.pg_class c
84
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
85
+ WHERE n.nspname = %(s)s AND c.relname = %(t)s
86
+ )
87
+ """, {"s": tschema, "t": tname}):
88
+ table.constraints.append(ConstraintDef(
89
+ name=c["name"],
90
+ kind=kind_map.get(c["kind"], c["kind"]),
91
+ definition=(c["definition"] or "").lower(),
92
+ ))
93
+
94
+ db.tables[table.qualified_name] = table
95
+
96
+
97
+ def _load_views(conn: Any, db: DatabaseSchema) -> None:
98
+ for r in _q(conn, """
99
+ SELECT n.nspname AS schema, c.relname AS name,
100
+ pg_get_viewdef(c.oid, true) AS definition
101
+ FROM pg_catalog.pg_class c
102
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
103
+ WHERE c.relkind = 'v'
104
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
105
+ AND n.nspname NOT LIKE 'pg_%'
106
+ """):
107
+ v = ViewDef(schema=r["schema"], name=r["name"], definition=(r["definition"] or "").lower())
108
+ db.views[v.qualified_name] = v
109
+
110
+
111
+ def _load_functions(conn: Any, db: DatabaseSchema) -> None:
112
+ for r in _q(conn, """
113
+ SELECT n.nspname AS schema, p.proname AS name,
114
+ pg_get_function_arguments(p.oid) AS args,
115
+ pg_get_function_result(p.oid) AS return_type,
116
+ l.lanname AS language, p.prosrc AS body,
117
+ CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END AS kind
118
+ FROM pg_catalog.pg_proc p
119
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
120
+ JOIN pg_catalog.pg_language l ON l.oid = p.prolang
121
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
122
+ AND n.nspname NOT LIKE 'pg_%'
123
+ """):
124
+ raw_body = r["body"] or ""
125
+ lines = [line.strip() for line in raw_body.splitlines()]
126
+ body = "\n".join(line.lower() for line in lines if line)
127
+ func = FunctionDef(
128
+ schema=r["schema"], name=r["name"],
129
+ args=(r["args"] or "").lower(),
130
+ return_type=(r["return_type"] or "").lower(),
131
+ language=r["language"], body=body, kind=r["kind"],
132
+ )
133
+ db.functions[func.qualified_name] = func
134
+
135
+
136
+ def _load_enums(conn: Any, db: DatabaseSchema) -> None:
137
+ for r in _q(conn, """
138
+ SELECT n.nspname AS schema, t.typname AS name,
139
+ array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
140
+ FROM pg_catalog.pg_type t
141
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
142
+ JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid
143
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
144
+ AND n.nspname NOT LIKE 'pg_%'
145
+ GROUP BY n.nspname, t.typname
146
+ """):
147
+ enum = EnumDef(schema=r["schema"], name=r["name"], values=list(r["values"]))
148
+ db.enums[enum.qualified_name] = enum
149
+
150
+
151
+ def _load_composites(conn: Any, db: DatabaseSchema) -> None:
152
+ composites: dict[str, CompositeTypeDef] = {}
153
+ for r in _q(conn, """
154
+ SELECT n.nspname AS schema, t.typname AS name,
155
+ a.attname AS field_name,
156
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS field_type
157
+ FROM pg_catalog.pg_type t
158
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
159
+ JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c'
160
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
161
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
162
+ AND n.nspname NOT LIKE 'pg_%'
163
+ ORDER BY n.nspname, t.typname, a.attnum
164
+ """):
165
+ key = f"{r['schema']}.{r['name']}"
166
+ if key not in composites:
167
+ composites[key] = CompositeTypeDef(schema=r["schema"], name=r["name"], fields=[])
168
+ composites[key].fields.append((r["field_name"], r["field_type"]))
169
+ db.composites = composites
170
+
171
+
172
+ def _load_indexes(conn: Any, db: DatabaseSchema) -> None:
173
+ for r in _q(conn, """
174
+ SELECT n.nspname AS schema, t.relname AS table_name,
175
+ i.relname AS index_name,
176
+ pg_get_indexdef(ix.indexrelid) AS definition
177
+ FROM pg_catalog.pg_index ix
178
+ JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid
179
+ JOIN pg_catalog.pg_class t ON t.oid = ix.indrelid
180
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
181
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
182
+ AND n.nspname NOT LIKE 'pg_%'
183
+ AND NOT ix.indisprimary
184
+ """):
185
+ idx = IndexDef(
186
+ schema=r["schema"], table=r["table_name"],
187
+ name=r["index_name"],
188
+ definition=(r["definition"] or "").lower(),
189
+ )
190
+ db.indexes[idx.qualified_name] = idx
pgdevkit/lakebase.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import time
6
+ import uuid
7
+ from typing import TYPE_CHECKING
8
+ from urllib import request as urllib_request
9
+ from urllib.error import HTTPError
10
+
11
+ if TYPE_CHECKING:
12
+ from azure.identity import DefaultAzureCredential
13
+
14
+ _DATABRICKS_RESOURCE_SCOPE = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default"
15
+ _REFRESH_MARGIN_SECONDS = 300
16
+
17
+ _credential: DefaultAzureCredential | None = None
18
+ _credential_cache: dict[tuple[str, str], tuple[str, float]] = {}
19
+ _dns_cache: dict[tuple[str, str], str] = {}
20
+
21
+
22
+ def _databricks_resource_token() -> str:
23
+ try:
24
+ from azure.identity import DefaultAzureCredential
25
+ except ImportError:
26
+ raise ImportError("Install azure-identity extra: pip install pgdevkit[azure]")
27
+ global _credential
28
+ if _credential is None:
29
+ _credential = DefaultAzureCredential()
30
+ return _credential.get_token(_DATABRICKS_RESOURCE_SCOPE).token
31
+
32
+
33
+ def _request_json(url: str, token: str, *, body: dict | None = None) -> dict:
34
+ data = json.dumps(body).encode("utf-8") if body is not None else None
35
+ req = urllib_request.Request(
36
+ url,
37
+ data=data,
38
+ method="POST" if body is not None else "GET",
39
+ headers={
40
+ "Authorization": f"Bearer {token}",
41
+ "Content-Type": "application/json",
42
+ },
43
+ )
44
+ try:
45
+ with urllib_request.urlopen(req, timeout=10) as resp:
46
+ return json.loads(resp.read().decode("utf-8"))
47
+ except HTTPError as e:
48
+ detail = e.read().decode("utf-8", errors="replace")
49
+ raise RuntimeError(f"Databricks API request failed [{e.code}]: {detail}") from e
50
+
51
+
52
+ def _parse_expiration(expiration_time: str) -> float:
53
+ return datetime.datetime.fromisoformat(expiration_time).timestamp()
54
+
55
+
56
+ def get_lakebase_password(workspace_host: str, instance_name: str) -> str:
57
+ """Return a valid short-lived Postgres password for the given Lakebase
58
+ instance, fetching/refreshing the credential as needed."""
59
+ key = (workspace_host, instance_name)
60
+ cached = _credential_cache.get(key)
61
+ if cached is not None:
62
+ token, expires_at = cached
63
+ if time.time() < expires_at - _REFRESH_MARGIN_SECONDS:
64
+ return token
65
+
66
+ entra_token = _databricks_resource_token()
67
+ response = _request_json(
68
+ f"{workspace_host.rstrip('/')}/api/2.0/database/credentials",
69
+ entra_token,
70
+ body={"instance_names": [instance_name], "request_id": str(uuid.uuid4())},
71
+ )
72
+ token = response["token"]
73
+ expires_at = _parse_expiration(response["expiration_time"])
74
+ _credential_cache[key] = (token, expires_at)
75
+ return token
76
+
77
+
78
+ def resolve_read_write_dns(workspace_host: str, instance_name: str) -> str:
79
+ """Resolve and cache the Postgres read/write DNS endpoint for a Lakebase instance."""
80
+ key = (workspace_host, instance_name)
81
+ if key in _dns_cache:
82
+ return _dns_cache[key]
83
+ entra_token = _databricks_resource_token()
84
+ response = _request_json(
85
+ f"{workspace_host.rstrip('/')}/api/2.0/database/instances/{instance_name}",
86
+ entra_token,
87
+ )
88
+ dns = response["read_write_dns"]
89
+ _dns_cache[key] = dns
90
+ return dns
pgdevkit/models.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class ColumnDef:
7
+ name: str
8
+ data_type: str
9
+ is_nullable: bool
10
+ default: str | None
11
+ is_generated: bool = False
12
+
13
+
14
+ @dataclass
15
+ class ConstraintDef:
16
+ name: str | None
17
+ kind: str # PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK
18
+ definition: str
19
+
20
+
21
+ @dataclass
22
+ class TableDef:
23
+ schema: str
24
+ name: str
25
+ columns: list[ColumnDef] = field(default_factory=list)
26
+ constraints: list[ConstraintDef] = field(default_factory=list)
27
+ is_partition: bool = False
28
+
29
+ @property
30
+ def qualified_name(self) -> str:
31
+ return f"{self.schema}.{self.name}"
32
+
33
+
34
+ @dataclass
35
+ class ViewDef:
36
+ schema: str
37
+ name: str
38
+ definition: str
39
+
40
+ @property
41
+ def qualified_name(self) -> str:
42
+ return f"{self.schema}.{self.name}"
43
+
44
+
45
+ @dataclass
46
+ class FunctionDef:
47
+ schema: str
48
+ name: str
49
+ args: str
50
+ return_type: str
51
+ language: str
52
+ body: str
53
+ kind: str = "function"
54
+
55
+ @property
56
+ def qualified_name(self) -> str:
57
+ return f"{self.schema}.{self.name}"
58
+
59
+
60
+ @dataclass
61
+ class EnumDef:
62
+ schema: str
63
+ name: str
64
+ values: list[str]
65
+
66
+ @property
67
+ def qualified_name(self) -> str:
68
+ return f"{self.schema}.{self.name}"
69
+
70
+
71
+ @dataclass
72
+ class CompositeTypeDef:
73
+ schema: str
74
+ name: str
75
+ fields: list[tuple[str, str]]
76
+
77
+ @property
78
+ def qualified_name(self) -> str:
79
+ return f"{self.schema}.{self.name}"
80
+
81
+
82
+ @dataclass
83
+ class IndexDef:
84
+ schema: str
85
+ table: str
86
+ name: str
87
+ definition: str
88
+
89
+ @property
90
+ def qualified_name(self) -> str:
91
+ return self.name
92
+
93
+
94
+ @dataclass
95
+ class DatabaseSchema:
96
+ schemas: set[str] = field(default_factory=set)
97
+ tables: dict[str, TableDef] = field(default_factory=dict)
98
+ views: dict[str, ViewDef] = field(default_factory=dict)
99
+ functions: dict[str, FunctionDef] = field(default_factory=dict)
100
+ enums: dict[str, EnumDef] = field(default_factory=dict)
101
+ composites: dict[str, CompositeTypeDef] = field(default_factory=dict)
102
+ indexes: dict[str, IndexDef] = field(default_factory=dict)