pgdevkit 0.1.0__tar.gz → 0.2.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 (82) hide show
  1. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/PKG-INFO +9 -1
  2. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/README.md +8 -0
  3. pgdevkit-0.2.0/pgdevkit/connection.py +101 -0
  4. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/__init__.py +2 -0
  5. pgdevkit-0.1.0/skills/pgdevkit/references/complex_helper.py → pgdevkit-0.2.0/pgdevkit/db/complex_types.py +59 -78
  6. pgdevkit-0.2.0/pgdevkit/db/connection.py +133 -0
  7. pgdevkit-0.2.0/pgdevkit/db/crud.py +311 -0
  8. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/api.py +2 -2
  9. pgdevkit-0.2.0/pgdevkit/testdb/constants.py +28 -0
  10. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/container.py +2 -8
  11. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/schema.py +74 -7
  12. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pyproject.toml +1 -1
  13. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/SKILL.md +8 -3
  14. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/conftest.py +10 -4
  15. pgdevkit-0.2.0/tests/db/test_complex_types.py +97 -0
  16. pgdevkit-0.2.0/tests/db/test_connection.py +139 -0
  17. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/test_crud.py +80 -2
  18. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_connection.py +16 -2
  19. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_fetch_missing.py +2 -2
  20. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_fetch_missing_cli.py +2 -2
  21. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +2 -0
  22. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/002_unsafe_drop_gadget_note.sql +3 -0
  23. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/tables/gadget.sql +6 -0
  24. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +1 -0
  25. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/types/dimensions.sql +11 -0
  26. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/types/mood.sql +8 -0
  27. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_api.py +1 -1
  28. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_container.py +1 -4
  29. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_query.py +2 -2
  30. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_schema.py +72 -3
  31. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/uv.lock +1 -1
  32. pgdevkit-0.1.0/pgdevkit/connection.py +0 -56
  33. pgdevkit-0.1.0/pgdevkit/db/connection.py +0 -74
  34. pgdevkit-0.1.0/pgdevkit/db/crud.py +0 -184
  35. pgdevkit-0.1.0/pgdevkit/testdb/constants.py +0 -11
  36. pgdevkit-0.1.0/tests/db/test_connection.py +0 -58
  37. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.github/workflows/python-publish.yml +0 -0
  38. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.github/workflows/python-test.yml +0 -0
  39. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.gitignore +0 -0
  40. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.python-version +0 -0
  41. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/docs/database-layout.md +0 -0
  42. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/__init__.py +0 -0
  43. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/cli.py +0 -0
  44. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/loader.py +0 -0
  45. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/model.py +0 -0
  46. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/diff.py +0 -0
  47. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/fetch_missing.py +0 -0
  48. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/introspect.py +0 -0
  49. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/lakebase.py +0 -0
  50. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/models.py +0 -0
  51. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/parser.py +0 -0
  52. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/__init__.py +0 -0
  53. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/config.py +0 -0
  54. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/naming.py +0 -0
  55. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/query.py +0 -0
  56. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  57. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  58. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/__init__.py +0 -0
  59. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/__init__.py +0 -0
  60. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/test_loader.py +0 -0
  61. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/01_schema.sql +0 -0
  62. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/02_types.sql +0 -0
  63. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/03_tables.sql +0 -0
  64. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/04_views.sql +0 -0
  65. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/05_functions.sql +0 -0
  66. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/06_indexes.sql +0 -0
  67. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_cli_compare.py +0 -0
  68. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_compare.py +0 -0
  69. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_lakebase.py +0 -0
  70. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/__init__.py +0 -0
  71. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/conftest.py +0 -0
  72. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  73. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  74. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  75. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  76. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  77. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  78. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  79. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_cli.py +0 -0
  80. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_config.py +0 -0
  81. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_constants.py +0 -0
  82. {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_naming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgdevkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: psycopg[binary]>=3.2.0
@@ -93,6 +93,14 @@ is already reachable at that address and skips container management if so.
93
93
  Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
94
94
  that check too.
95
95
 
96
+ To point at a local Postgres install instead of the podman container —
97
+ useful when podman isn't available, or you'd rather use peer authentication
98
+ as the current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
99
+ directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
100
+ The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
101
+ (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
102
+ auth for local connections (Debian/Ubuntu Postgres ships this by default).
103
+
96
104
  ## `pgdevkit.db` — helpers for application code
97
105
 
98
106
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -76,6 +76,14 @@ is already reachable at that address and skips container management if so.
76
76
  Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
77
77
  that check too.
78
78
 
79
+ To point at a local Postgres install instead of the podman container —
80
+ useful when podman isn't available, or you'd rather use peer authentication
81
+ as the current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
82
+ directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
83
+ The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
84
+ (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
85
+ auth for local connections (Debian/Ubuntu Postgres ships this by default).
86
+
79
87
  ## `pgdevkit.db` — helpers for application code
80
88
 
81
89
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+ from urllib.parse import quote, urlparse, urlunparse
5
+
6
+ _LAKEBASE_HOST_SUFFIXES = (
7
+ ".database.azuredatabricks.net",
8
+ ".database.cloud.databricks.com",
9
+ )
10
+ _AZURE_POSTGRES_HOST_SUFFIXES = (
11
+ ".postgres.database.azure.com",
12
+ ".postgres.cosmos.azure.com",
13
+ )
14
+
15
+ _default_credential = None
16
+ _managed_identity_credential = None
17
+
18
+
19
+ def detect_provider(host: str) -> Literal["azure_postgres", "databricks_lakebase"]:
20
+ """Classify a Postgres hostname for Entra ID auth: Databricks Lakebase
21
+ (needs credential exchange) or the default Azure Postgres AAD token
22
+ flow — the latter is the fallback for any non-Lakebase host, since
23
+ `entra_user` is itself the caller's assertion that Entra auth applies.
24
+ For a strict "is this actually Azure Database for PostgreSQL" check
25
+ (e.g. to decide PgBouncer-aware pooling), use `is_azure_postgres_host`
26
+ instead."""
27
+ if any(host.endswith(suffix) for suffix in _LAKEBASE_HOST_SUFFIXES):
28
+ return "databricks_lakebase"
29
+ return "azure_postgres"
30
+
31
+
32
+ def is_azure_postgres_host(host: str) -> bool:
33
+ """True only for actual Azure Database for PostgreSQL hostnames —
34
+ unlike `detect_provider`, this is not a fallback default."""
35
+ return any(host.endswith(suffix) for suffix in _AZURE_POSTGRES_HOST_SUFFIXES)
36
+
37
+
38
+ def get_azure_postgres_password(
39
+ *,
40
+ managed_identity: bool = False,
41
+ exclude_interactive_browser_credential: bool = True,
42
+ ) -> str:
43
+ """Fetch an Entra ID token to use as an Azure Postgres password.
44
+
45
+ `managed_identity=True` uses `ManagedIdentityCredential` (for workloads
46
+ running under an Azure-assigned identity); otherwise
47
+ `DefaultAzureCredential`, whose credential chain already falls back to
48
+ managed identity when no other credential is available. Both credential
49
+ objects are process-cached so repeated calls (one per new pooled
50
+ connection) don't re-probe the credential chain every time."""
51
+ try:
52
+ from azure.identity import DefaultAzureCredential, ManagedIdentityCredential
53
+ except ImportError:
54
+ raise ImportError("Install azure-identity extra: pip install pgdevkit[azure]")
55
+ global _default_credential, _managed_identity_credential
56
+ if managed_identity:
57
+ if _managed_identity_credential is None:
58
+ _managed_identity_credential = ManagedIdentityCredential()
59
+ credential = _managed_identity_credential
60
+ else:
61
+ if _default_credential is None:
62
+ _default_credential = DefaultAzureCredential(
63
+ exclude_interactive_browser_credential=exclude_interactive_browser_credential
64
+ )
65
+ credential = _default_credential
66
+ token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default")
67
+ return token.token
68
+
69
+
70
+ def build_conninfo(
71
+ url: str,
72
+ entra_user: str | None = None,
73
+ *,
74
+ databricks_workspace_host: str | None = None,
75
+ databricks_instance: str | None = None,
76
+ managed_identity: bool = False,
77
+ exclude_interactive_browser_credential: bool = True,
78
+ ) -> str:
79
+ if entra_user is None:
80
+ return url
81
+
82
+ parsed = urlparse(url)
83
+ host = parsed.hostname or ""
84
+ port = f":{parsed.port}" if parsed.port else ""
85
+
86
+ if detect_provider(host) == "databricks_lakebase":
87
+ if not databricks_workspace_host or not databricks_instance:
88
+ raise ValueError(
89
+ "Lakebase host detected — pass --databricks-workspace-host and --databricks-instance"
90
+ )
91
+ from .lakebase import get_lakebase_password
92
+
93
+ password = get_lakebase_password(databricks_workspace_host, databricks_instance)
94
+ else:
95
+ password = get_azure_postgres_password(
96
+ managed_identity=managed_identity,
97
+ exclude_interactive_browser_credential=exclude_interactive_browser_credential,
98
+ )
99
+
100
+ netloc = f"{quote(entra_user, safe='')}:{quote(password, safe='')}@{host}{port}"
101
+ return urlunparse(parsed._replace(netloc=netloc))
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .complex_types import ComplexHelper
3
4
  from .connection import PgPool
4
5
  from .crud import (
5
6
  pg_delete,
@@ -19,6 +20,7 @@ from .loader import SqlLoader
19
20
  from .model import PostgresTableModel
20
21
 
21
22
  __all__ = [
23
+ "ComplexHelper",
22
24
  "PgPool",
23
25
  "PostgresTableModel",
24
26
  "SqlLoader",
@@ -1,51 +1,53 @@
1
- """
2
- ComplexHelper — psycopg adapter for PostgreSQL composite types, enums, and JSONB.
1
+ from __future__ import annotations
3
2
 
4
- Use this when your project has custom PostgreSQL types (composite types, enums)
5
- that need to be registered with psycopg before inserting test data. Plain columns
6
- and JSONB are handled automatically by pgdevkit.testdb.schema's _insert_test_data;
7
- you only need this class for USER-DEFINED types, which that function does not
8
- yet support natively — wrap the connection it's given before the INSERT:
9
-
10
- from pgdevkit.testdb import schema # for reference, not a public extension point yet
11
- from your_project.pg_complex_helper import ComplexHelper
12
-
13
- async def insert_test_data_with_complex_types(json_file, table, force_reset, con):
14
- helper = ComplexHelper(con)
15
- schema_name, table_name = table.split(".")
16
- complex_types = await helper.load_all_complex_types((schema_name, table_name))
17
- rows = json.loads(json_file.read_text(encoding="utf-8"))
18
- for row in rows:
19
- for col, info in complex_types.items():
20
- if col in row:
21
- row[col] = await helper.recursive_convert(row[col], info, con)
22
- # then insert `rows` the same way schema.py's _insert_test_data does
23
- """
3
+ from typing import Any, Callable
24
4
 
25
5
  from psycopg import AsyncConnection
26
6
  from psycopg.rows import dict_row
27
- from typing import Any
28
7
  from psycopg.sql import Identifier
29
8
  from psycopg.types.composite import CompositeInfo, register_composite
30
- from psycopg.types.json import Jsonb
31
9
  from psycopg.types.enum import EnumInfo, register_enum
10
+ from psycopg.types.json import Jsonb
11
+
12
+ ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | None
32
13
 
33
14
 
34
15
  class ComplexHelper:
35
- complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = {}
16
+ """psycopg adapter for PostgreSQL composite types, enums, and JSONB.
36
17
 
37
- def __init__(self, con: AsyncConnection):
38
- self.con = con
39
- self.system_complex_type_dict = None
18
+ Detects a table's non-scalar columns (composite types, enums, JSONB) and
19
+ converts plain dict/list Python values into the psycopg-registered types
20
+ those columns need, recursing into nested composite fields.
40
21
 
22
+ `normalizers` lets a caller reshape a composite value before conversion,
23
+ keyed by composite type name (e.g. a project with a `locale_labels`
24
+ composite type that needs locale-key backfilling before it's built) —
25
+ this is intentionally the only project-specific extension point; nothing
26
+ else about a project's types is hardcoded here.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ con: AsyncConnection,
32
+ normalizers: dict[str, Callable[[dict], dict]] | None = None,
33
+ ) -> None:
34
+ self.con = con
35
+ self.system_complex_type_dict: dict[Any, tuple[str, str]] | None = None
36
+ # Instance-scoped: a CompositeInfo/EnumInfo carries OIDs from a
37
+ # specific connection/database, so caching it on the class (shared
38
+ # across every connection) would leak stale OIDs across databases
39
+ # that happen to reuse the same type name — exactly the case for
40
+ # pgdevkit's per-worktree isolated test databases.
41
+ self.complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = {}
41
42
  self.registered: set[CompositeInfo | EnumInfo] = set()
43
+ self._normalizers = normalizers or {}
42
44
 
43
- async def load_complex_type_dict(self):
45
+ async def load_complex_type_dict(self) -> None:
44
46
  async with self.con.cursor(row_factory=dict_row) as cur:
45
47
  await cur.execute("""
46
48
  SELECT t.oid,
47
49
  pg_catalog.format_type ( t.oid, NULL ) AS obj_name,
48
- t.typtype
50
+ t.typtype
49
51
  FROM pg_catalog.pg_type t
50
52
  JOIN pg_catalog.pg_namespace n
51
53
  ON n.oid = t.typnamespace
@@ -57,13 +59,9 @@ class ComplexHelper:
57
59
  AND n.nspname <> 'information_schema'
58
60
  AND n.nspname !~ '^pg_toast'""")
59
61
  system_complex_types = await cur.fetchall()
60
- self.system_complex_type_dict = {
61
- r["oid"]: (r["obj_name"], r["typtype"]) for r in system_complex_types
62
- }
62
+ self.system_complex_type_dict = {r["oid"]: (r["obj_name"], r["typtype"]) for r in system_complex_types}
63
63
 
64
- async def _load_complex_type_from_colinfos(
65
- self, res: dict[str, Any] | None
66
- ) -> CompositeInfo | EnumInfo | type[Jsonb] | None:
64
+ async def _load_complex_type_from_colinfos(self, res: dict[str, Any] | None) -> ComplexTypeInfo:
67
65
  if not res:
68
66
  return None
69
67
  if res["data_type"].lower() == "jsonb":
@@ -78,15 +76,13 @@ class ComplexHelper:
78
76
  return None
79
77
  udt_schema: str = res["udt_schema"]
80
78
  udt_name: str = res["udt_name"]
81
- c = await self._get_complex_type(
82
- f"{udt_schema}.{udt_name}", res["is_enum"], self.con
83
- )
79
+ c = await self._get_complex_type(f"{udt_schema}.{udt_name}", res["is_enum"], self.con)
84
80
  await self._recurse_register(c, self.con)
85
81
  return c
86
82
 
87
83
  async def load_all_complex_types(
88
84
  self, table_name: tuple[str, str], include_generated: bool = False
89
- ) -> dict[str, CompositeInfo | type[Jsonb] | EnumInfo | None]:
85
+ ) -> dict[str, ComplexTypeInfo]:
90
86
  if self.system_complex_type_dict is None:
91
87
  await self.load_complex_type_dict()
92
88
  colquery = """
@@ -100,7 +96,8 @@ class ComplexHelper:
100
96
  udt_schema, udt_name,
101
97
  e.enum_name is not null as is_enum
102
98
  from information_schema.columns c
103
- left join enum_types e on e.enum_schema=c.udt_schema and e.enum_name=c.udt_name
99
+ left join enum_types e on e.enum_schema=c.udt_schema
100
+ and (e.enum_name=c.udt_name or (c.data_type='ARRAY' and c.udt_name='_'||e.enum_name))
104
101
  where table_schema=%(schema)s and table_name = %(tbl)s and (is_generated <> 'ALWAYS' or %(include_generated)s)"""
105
102
  async with self.con.cursor(row_factory=dict_row) as cur:
106
103
  await cur.execute(
@@ -112,14 +109,9 @@ class ComplexHelper:
112
109
  },
113
110
  )
114
111
  res = await cur.fetchall()
115
- return {
116
- r["column_name"]: await self._load_complex_type_from_colinfos(r)
117
- for r in res
118
- }
112
+ return {r["column_name"]: await self._load_complex_type_from_colinfos(r) for r in res}
119
113
 
120
- async def load_complex_type(
121
- self, table_name: tuple[str, str], col_name: str
122
- ) -> CompositeInfo | type[Jsonb] | EnumInfo | None:
114
+ async def load_complex_type(self, table_name: tuple[str, str], col_name: str) -> ComplexTypeInfo:
123
115
  if self.system_complex_type_dict is None:
124
116
  await self.load_complex_type_dict()
125
117
  colquery = """
@@ -133,7 +125,8 @@ class ComplexHelper:
133
125
  udt_schema, udt_name,
134
126
  e.enum_name is not null as is_enum
135
127
  from information_schema.columns c
136
- left join enum_types e on e.enum_schema=c.udt_schema and e.enum_name=c.udt_name
128
+ left join enum_types e on e.enum_schema=c.udt_schema
129
+ and (e.enum_name=c.udt_name or (c.data_type='ARRAY' and c.udt_name='_'||e.enum_name))
137
130
  where table_schema=%(schema)s and table_name = %(tbl)s
138
131
  and column_name = %(col)s"""
139
132
  async with self.con.cursor(row_factory=dict_row) as cur:
@@ -142,18 +135,13 @@ class ComplexHelper:
142
135
  {"schema": table_name[0], "tbl": table_name[1], "col": col_name},
143
136
  )
144
137
  res = await cur.fetchone()
145
-
146
138
  return await self._load_complex_type_from_colinfos(res)
147
139
 
148
- async def _get_complex_type(
149
- self, name: str, is_enum: bool, con: AsyncConnection
150
- ) -> CompositeInfo | EnumInfo:
140
+ async def _get_complex_type(self, name: str, is_enum: bool, con: AsyncConnection) -> CompositeInfo | EnumInfo:
151
141
  if name.endswith("[]"):
152
142
  name = name[:-2]
153
- schema, type_name = name.split(".")
154
- if type_name.startswith(
155
- "_"
156
- ): # the array type in PostgreSQL starts with an underscore
143
+ schema, type_name = name.split(".") if "." in name else ("public", name)
144
+ if type_name.startswith("_"): # the array type in PostgreSQL starts with an underscore
157
145
  type_name = type_name[1:]
158
146
  if is_enum:
159
147
  ci = await EnumInfo.fetch(con, Identifier(schema, type_name))
@@ -165,12 +153,8 @@ class ComplexHelper:
165
153
  self.complex_types[(schema, type_name)] = ci
166
154
  return self.complex_types[(schema, type_name)]
167
155
 
168
- async def _recurse_register(
169
- self, info: CompositeInfo | EnumInfo, con: AsyncConnection
170
- ):
171
- assert self.system_complex_type_dict is not None, (
172
- "System complex type dictionary not loaded"
173
- )
156
+ async def _recurse_register(self, info: CompositeInfo | EnumInfo, con: AsyncConnection) -> None:
157
+ assert self.system_complex_type_dict is not None, "System complex type dictionary not loaded"
174
158
  if info not in self.registered:
175
159
  if isinstance(info, EnumInfo):
176
160
  register_enum(info, con)
@@ -188,7 +172,7 @@ class ComplexHelper:
188
172
  async def recursive_convert(
189
173
  self,
190
174
  value: Any,
191
- info: CompositeInfo | EnumInfo | type[Jsonb] | None,
175
+ info: ComplexTypeInfo,
192
176
  con: AsyncConnection,
193
177
  ) -> Any:
194
178
  if info is None:
@@ -197,20 +181,21 @@ class ComplexHelper:
197
181
  return None
198
182
  if self.system_complex_type_dict is None:
199
183
  await self.load_complex_type_dict()
184
+ if info == Jsonb:
185
+ # A JSONB column's value is wrapped whole, even if it's a list —
186
+ # only an array-of-composite/enum column recurses per element.
187
+ return Jsonb(value)
200
188
  if isinstance(value, list):
201
189
  return [await self.recursive_convert(item, info, con) for item in value]
202
190
  prms = {}
203
- if info == Jsonb:
204
- return Jsonb(value)
205
191
  if isinstance(value, str):
206
192
  assert isinstance(info, EnumInfo), f"Expected EnumInfo, got {type(info)}"
207
193
  return getattr(info.enum, value) # Enum
208
- assert isinstance(info, CompositeInfo), (
209
- f"Expected CompositeInfo, got {type(info)}"
210
- )
211
- assert self.system_complex_type_dict is not None, (
212
- "System complex type dictionary not loaded"
213
- )
194
+ assert isinstance(info, CompositeInfo), f"Expected CompositeInfo, got {type(info)}"
195
+ assert self.system_complex_type_dict is not None, "System complex type dictionary not loaded"
196
+ normalizer = self._normalizers.get(info.name)
197
+ if normalizer is not None:
198
+ value = normalizer(value)
214
199
  for k, v in value.items():
215
200
  if v is None:
216
201
  prms[k] = None
@@ -221,14 +206,10 @@ class ComplexHelper:
221
206
  name, typtype = self.system_complex_type_dict[type_oid]
222
207
  ci = await self._get_complex_type(name, typtype == "e", con)
223
208
  if name.endswith("[]"):
224
- prms[k] = [
225
- await self.recursive_convert(item, ci, con) for item in v
226
- ]
209
+ prms[k] = [await self.recursive_convert(item, ci, con) for item in v]
227
210
  else:
228
211
  prms[k] = await self.recursive_convert(v, ci, con)
229
212
  else:
230
213
  prms[k] = v
231
- assert info.python_type is not None, (
232
- f"Python type for {info.name} is null, maybe an array?"
233
- )
214
+ assert info.python_type is not None, f"Python type for {info.name} is null, maybe an array?"
234
215
  return info.python_type(**prms) if prms else None
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from typing import Literal
6
+
7
+ from psycopg_pool import AsyncConnectionPool, AsyncNullConnectionPool
8
+
9
+ from ..connection import detect_provider, get_azure_postgres_password, is_azure_postgres_host
10
+ from ..lakebase import get_lakebase_password
11
+
12
+
13
+ class PgPool:
14
+ """A connection pool keyed off `{env_prefix}HOST/PORT/DB/USER/PASSWORD`
15
+ environment variables (e.g. env_prefix="MDM_POSTGRES_").
16
+
17
+ Pass `entra_user` to authenticate via Entra ID instead of a static
18
+ password. The Postgres host is inspected to pick the Azure Postgres AAD
19
+ token flow or Databricks Lakebase credential exchange; for the latter
20
+ also set `{env_prefix}DATABRICKS_WORKSPACE_HOST` and
21
+ `{env_prefix}DATABRICKS_INSTANCE`.
22
+
23
+ `use_null_pool="auto"` (the default) switches to a null pool — no local
24
+ pooling — when the host is Azure Postgres, since Azure's own PgBouncer
25
+ (transaction-mode) does the pooling; `connection_kwargs` then
26
+ auto-includes `prepare_threshold=None` too, since PgBouncer transaction
27
+ mode doesn't support server-side prepared statements. Pass an explicit
28
+ `use_null_pool`/`connection_kwargs` to override either.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ env_prefix: str = "POSTGRES_",
34
+ max_size: int = 40,
35
+ *,
36
+ entra_user: str | None = None,
37
+ credential_kind: Literal["default_azure", "managed_identity"] = "default_azure",
38
+ exclude_interactive_browser_credential: bool = True,
39
+ max_lifetime: float | None = None,
40
+ use_null_pool: bool | Literal["auto"] = "auto",
41
+ connection_kwargs: dict | None = None,
42
+ dsn_params: dict[str, str] | None = None,
43
+ ) -> None:
44
+ self._env_prefix = env_prefix
45
+ self._max_size = max_size
46
+ self._entra_user = entra_user
47
+ self._credential_kind = credential_kind
48
+ self._exclude_interactive_browser_credential = exclude_interactive_browser_credential
49
+ self._max_lifetime = max_lifetime
50
+ self._use_null_pool = use_null_pool
51
+ self._connection_kwargs = connection_kwargs
52
+ self._dsn_params = dsn_params or {}
53
+ self._pool: AsyncConnectionPool | AsyncNullConnectionPool | None = None
54
+
55
+ def _is_azure_postgres(self, host: str) -> bool:
56
+ return is_azure_postgres_host(host)
57
+
58
+ async def _dsn(self) -> str:
59
+ p = self._env_prefix
60
+ host = os.environ[p + "HOST"]
61
+ port = os.environ[p + "PORT"]
62
+ dbname = os.environ[p + "DB"]
63
+
64
+ if self._entra_user is None:
65
+ user = os.environ[p + "USER"]
66
+ password = os.environ[p + "PASSWORD"]
67
+ else:
68
+ user = self._entra_user
69
+ if detect_provider(host) == "databricks_lakebase":
70
+ workspace_host = os.environ[p + "DATABRICKS_WORKSPACE_HOST"]
71
+ instance_name = os.environ[p + "DATABRICKS_INSTANCE"]
72
+ password = await asyncio.to_thread(get_lakebase_password, workspace_host, instance_name)
73
+ else:
74
+ password = await asyncio.to_thread(
75
+ get_azure_postgres_password,
76
+ managed_identity=self._credential_kind == "managed_identity",
77
+ exclude_interactive_browser_credential=self._exclude_interactive_browser_credential,
78
+ )
79
+
80
+ dsn = f"host={host} port={port} dbname={dbname} user={user} password={password}"
81
+ for key, value in self._dsn_params.items():
82
+ dsn += f" {key}={value}"
83
+ return dsn
84
+
85
+ async def open(self) -> None:
86
+ if self._pool is None:
87
+ host = os.environ[self._env_prefix + "HOST"]
88
+ is_azure_postgres = self._is_azure_postgres(host)
89
+ use_null_pool = is_azure_postgres if self._use_null_pool == "auto" else self._use_null_pool
90
+ connection_kwargs = self._connection_kwargs
91
+ if connection_kwargs is None:
92
+ # PgBouncer (transaction mode) doesn't support prepared statements
93
+ connection_kwargs = {"prepare_threshold": None} if is_azure_postgres else {}
94
+ max_lifetime = 3600.0 if self._max_lifetime is None else self._max_lifetime
95
+ if use_null_pool:
96
+ self._pool = AsyncNullConnectionPool(
97
+ conninfo=self._dsn,
98
+ open=False,
99
+ max_size=self._max_size,
100
+ max_lifetime=max_lifetime,
101
+ check=AsyncNullConnectionPool.check_connection,
102
+ kwargs=connection_kwargs,
103
+ )
104
+ else:
105
+ self._pool = AsyncConnectionPool(
106
+ conninfo=self._dsn,
107
+ open=False,
108
+ max_size=self._max_size,
109
+ max_lifetime=max_lifetime,
110
+ check=AsyncConnectionPool.check_connection,
111
+ kwargs=connection_kwargs,
112
+ )
113
+ if not self._pool._opened:
114
+ await self._pool.open()
115
+
116
+ async def close(self) -> None:
117
+ if self._pool is not None:
118
+ await self._pool.close()
119
+
120
+ def connection(self):
121
+ """Return an async connection context manager from the pool."""
122
+ if self._pool is None:
123
+ raise RuntimeError("Call open() first (e.g. in app startup).")
124
+ return self._pool.connection()
125
+
126
+ @property
127
+ def raw_pool(self) -> AsyncConnectionPool | AsyncNullConnectionPool:
128
+ """The underlying psycopg_pool object — for integrating with other
129
+ pool-consuming libraries (e.g. Procrastinate's `open_async(pool=...)`)
130
+ that need it directly rather than going through `connection()`."""
131
+ if self._pool is None:
132
+ raise RuntimeError("Call open() first (e.g. in app startup).")
133
+ return self._pool