dataplat 0.2.1__tar.gz → 0.2.2__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 (161) hide show
  1. {dataplat-0.2.1 → dataplat-0.2.2}/CHANGELOG.md +61 -0
  2. {dataplat-0.2.1 → dataplat-0.2.2}/PKG-INFO +1 -1
  3. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/dbt_orphans.py +63 -15
  4. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role.py +19 -2
  5. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/describe.py +54 -5
  6. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/long_queries.py +29 -2
  7. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/orphans.py +75 -6
  8. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role.py +64 -5
  9. {dataplat-0.2.1 → dataplat-0.2.2}/pyproject.toml +1 -1
  10. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role.py +5 -0
  11. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_describe_pg.py +84 -27
  12. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_long_queries_pg.py +219 -0
  13. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_orphans_pg.py +146 -16
  14. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_roles_pg.py +51 -24
  15. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_describe.py +29 -1
  16. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_long_queries.py +94 -0
  17. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_orphans.py +49 -3
  18. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role.py +72 -18
  19. {dataplat-0.2.1 → dataplat-0.2.2}/uv.lock +1 -1
  20. {dataplat-0.2.1 → dataplat-0.2.2}/.github/workflows/ci.yml +0 -0
  21. {dataplat-0.2.1 → dataplat-0.2.2}/.github/workflows/release.yml +0 -0
  22. {dataplat-0.2.1 → dataplat-0.2.2}/.gitignore +0 -0
  23. {dataplat-0.2.1 → dataplat-0.2.2}/.python-version +0 -0
  24. {dataplat-0.2.1 → dataplat-0.2.2}/LICENSE +0 -0
  25. {dataplat-0.2.1 → dataplat-0.2.2}/README.md +0 -0
  26. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/__init__.py +0 -0
  27. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/__init__.py +0 -0
  28. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_lazy.py +0 -0
  29. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_missing.py +0 -0
  30. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_options.py +0 -0
  31. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_prompt.py +0 -0
  32. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/_render.py +0 -0
  33. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/__init__.py +0 -0
  34. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/app.py +0 -0
  35. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/bi/superset.py +0 -0
  36. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/__init__.py +0 -0
  37. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/app.py +0 -0
  38. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/__init__.py +0 -0
  39. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/app.py +0 -0
  40. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ci/github/runner.py +0 -0
  41. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/__init__.py +0 -0
  42. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/app.py +0 -0
  43. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/__init__.py +0 -0
  44. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/_common.py +0 -0
  45. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/app.py +0 -0
  46. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/rds.py +0 -0
  47. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/redshift.py +0 -0
  48. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/cloud/aws/secrets.py +0 -0
  49. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/config.py +0 -0
  50. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/__init__.py +0 -0
  51. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/_common.py +0 -0
  52. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/_report.py +0 -0
  53. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/describe.py +0 -0
  54. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/long_queries.py +0 -0
  55. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_create.py +0 -0
  56. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_drop.py +0 -0
  57. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/role_list.py +0 -0
  58. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/db/top_tables.py +0 -0
  59. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/__init__.py +0 -0
  60. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/__init__.py +0 -0
  61. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_common.py +0 -0
  62. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_cursor.py +0 -0
  63. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/_resource.py +0 -0
  64. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/app.py +0 -0
  65. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/connections.py +0 -0
  66. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/definitions.py +0 -0
  67. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/destinations.py +0 -0
  68. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/enums.py +0 -0
  69. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/jobs.py +0 -0
  70. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/sources.py +0 -0
  71. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/tags.py +0 -0
  72. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/templates.py +0 -0
  73. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/tui.py +0 -0
  74. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/airbyte/workspaces.py +0 -0
  75. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/ingest/app.py +0 -0
  76. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/open.py +0 -0
  77. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/cli/status.py +0 -0
  78. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/__init__.py +0 -0
  79. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/deps.py +0 -0
  80. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/envrc.py +0 -0
  81. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/errors.py +0 -0
  82. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/core/registry.py +0 -0
  83. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/main.py +0 -0
  84. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/__init__.py +0 -0
  85. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/__init__.py +0 -0
  86. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/_resource.py +0 -0
  87. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/client.py +0 -0
  88. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/connections.py +0 -0
  89. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/definitions.py +0 -0
  90. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/destinations.py +0 -0
  91. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/jobs.py +0 -0
  92. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/sources.py +0 -0
  93. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/tags.py +0 -0
  94. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/airbyte/workspaces.py +0 -0
  95. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/aws/__init__.py +0 -0
  96. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/aws/auth.py +0 -0
  97. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/__init__.py +0 -0
  98. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/_like.py +0 -0
  99. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/connection.py +0 -0
  100. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role_admin.py +0 -0
  101. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/role_dialects.py +0 -0
  102. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/targets.py +0 -0
  103. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/db/top_tables.py +0 -0
  104. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/superset/__init__.py +0 -0
  105. {dataplat-0.2.1 → dataplat-0.2.2}/dataplat/services/superset/client.py +0 -0
  106. {dataplat-0.2.1 → dataplat-0.2.2}/tests/__init__.py +0 -0
  107. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/__init__.py +0 -0
  108. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_commands.py +0 -0
  109. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_cursor_logic.py +0 -0
  110. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_guards.py +0 -0
  111. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_airbyte_tui.py +0 -0
  112. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_aws_secrets.py +0 -0
  113. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_aws_secrets_write.py +0 -0
  114. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_cli_smoke.py +0 -0
  115. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_config.py +0 -0
  116. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_common.py +0 -0
  117. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_long_queries.py +0 -0
  118. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_db_query.py +0 -0
  119. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_dbt_orphans.py +0 -0
  120. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_describe.py +0 -0
  121. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_github_runner.py +0 -0
  122. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_missing_deps.py +0 -0
  123. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_open.py +0 -0
  124. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_prompt.py +0 -0
  125. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_rds.py +0 -0
  126. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_redshift.py +0 -0
  127. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_regression.py +0 -0
  128. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_render.py +0 -0
  129. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role_create.py +0 -0
  130. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_role_drop.py +0 -0
  131. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_status.py +0 -0
  132. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_superset.py +0 -0
  133. {dataplat-0.2.1 → dataplat-0.2.2}/tests/cli/test_top_tables.py +0 -0
  134. {dataplat-0.2.1 → dataplat-0.2.2}/tests/conftest.py +0 -0
  135. {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/__init__.py +0 -0
  136. {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_deps.py +0 -0
  137. {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_envrc.py +0 -0
  138. {dataplat-0.2.1 → dataplat-0.2.2}/tests/core/test_registry.py +0 -0
  139. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/__init__.py +0 -0
  140. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/conftest.py +0 -0
  141. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_harness.py +0 -0
  142. {dataplat-0.2.1 → dataplat-0.2.2}/tests/integration/test_top_tables_pg.py +0 -0
  143. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/__init__.py +0 -0
  144. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/__init__.py +0 -0
  145. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_client.py +0 -0
  146. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_connections.py +0 -0
  147. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_definitions.py +0 -0
  148. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_destinations.py +0 -0
  149. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_jobs.py +0 -0
  150. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_sources.py +0 -0
  151. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/airbyte/test_workspaces.py +0 -0
  152. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/aws/__init__.py +0 -0
  153. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/aws/test_auth.py +0 -0
  154. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/__init__.py +0 -0
  155. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_connection.py +0 -0
  156. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role_admin.py +0 -0
  157. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_role_dialects.py +0 -0
  158. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_targets.py +0 -0
  159. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/db/test_top_tables.py +0 -0
  160. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/superset/__init__.py +0 -0
  161. {dataplat-0.2.1 → dataplat-0.2.2}/tests/services/superset/test_client.py +0 -0
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ Closes the six defects 0.2.1's integration suite found and pinned as expected
6
+ failures. No expected failures remain.
7
+
8
+ ### Fixed
9
+
10
+ - `dp db role show` claimed `Password set: yes` for every role, including a
11
+ passwordless `NOLOGIN` group. The attribute query read `rolpassword` from
12
+ `pg_roles`, whose view definition returns the literal `'********'` and can
13
+ never be NULL. Only `pg_authid` holds the real verifier and it is
14
+ superuser-only, so the field is now tri-state and reports `unknown` — with a
15
+ hint saying why — when the connecting role cannot read it. The privilege is
16
+ probed before the query rather than discovered by catching an error, since a
17
+ permission failure would abort the surrounding transaction.
18
+
19
+ - `dp db describe <schema>` never reported `USAGE` grants. The query read
20
+ `information_schema.usage_privileges`, which on PostgreSQL does not cover
21
+ schemas at all, so the USAGE half of the union always returned nothing. The
22
+ PostgreSQL path now reads the schema ACL directly.
23
+
24
+ - `dp db role show` under-counted a role's tables when it owned a partitioned
25
+ table. Both `relkind` `'r'` and `'p'` map to the label "table" and the
26
+ aggregation assigned rather than accumulated, so one group overwrote the
27
+ other while the total was summed separately and stayed right — the per-schema
28
+ breakdown contradicted its own total.
29
+
30
+ - `dp db dbt-orphans purge` aborted the whole batch when a single relation had
31
+ vanished between scan and purge. The generated statement now uses
32
+ `IF EXISTS`, matching `top-tables`, so a missing relation is a no-op. When a
33
+ dependent object genuinely blocks a drop the purge still stops — that
34
+ all-or-nothing property is deliberate for a destructive batch — but it now
35
+ names the relation and everything depending on it instead of surfacing a raw
36
+ driver error, and records the blockage in the audit log.
37
+
38
+ - `dp db describe <relation>` raised nothing but returned an invalid result for
39
+ a non-view relation: `pg_get_viewdef()` yields NULL rather than erroring, and
40
+ the PostgreSQL branch returned a view definition whose `sql` was `None`,
41
+ violating its own annotation. It now raises, as the Redshift branch already
42
+ did.
43
+
44
+ - `dp db long-queries --history` died with a driver traceback on any server
45
+ where `pg_stat_statements` is installed but not preloaded — the most common
46
+ misconfiguration. The guard that was meant to catch this probed the view's
47
+ columns, which PostgreSQL answers from the view definition without invoking
48
+ the extension, so the probe always succeeded. The failure is now reported as
49
+ an actionable error naming `shared_preload_libraries` and the required
50
+ restart.
51
+
52
+ ### Changed
53
+
54
+ - `RoleAttributes.password_set` widened from `bool` to `bool | None`, where
55
+ `None` means "not determinable by this connection". Relevant only if you
56
+ import the dataclass; the CLI renders the third state as `unknown`.
57
+
58
+ - On PostgreSQL, `dp db describe <schema>` privileges now come from the schema
59
+ ACL rather than a `has_schema_privilege` scan. A role holding `CREATE` only
60
+ through membership in a granted role is no longer listed as its own row, and
61
+ `grantor` and `WITH GRANT OPTION` are now real values where the previous
62
+ CREATE half hardcoded them.
63
+
3
64
  ## 0.2.1
4
65
 
5
66
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataplat
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: One command to manage any shape of data platform: databases, ingestion, BI, cloud, and CI.
5
5
  Project-URL: Homepage, https://github.com/hanslemm/dataplat
6
6
  Project-URL: Repository, https://github.com/hanslemm/dataplat
@@ -20,6 +20,8 @@ from dataplat.services.db.connection import SqlEngine
20
20
  from dataplat.services.db.orphans import (
21
21
  DEPRECATED_SUFFIX,
22
22
  LIVE_STATUSES,
23
+ BlockedEntry,
24
+ DependentObjectsError,
23
25
  DropEntry,
24
26
  ObjectKind,
25
27
  RenameEntry,
@@ -661,7 +663,19 @@ def purge_cmd(
661
663
  help="Path to a file with one exclusion token per line.",
662
664
  ),
663
665
  ) -> None:
664
- """Permanently drop every object ending in _deprecated (irreversible)."""
666
+ """Permanently drop every object ending in _deprecated (irreversible).
667
+
668
+ All-or-nothing per target, by design: every drop for one warehouse runs
669
+ inside a single transaction, so if the warehouse refuses one of them
670
+ nothing is dropped for that target and the error names the blocking
671
+ relation. Keeping the drops that already succeeded would need either a
672
+ SAVEPOINT per object (Redshift has none) or a commit per object (which
673
+ would break the guarantee that --dry-run writes nothing) — and for a
674
+ destructive batch, stopping on the first surprise is the safer default. No
675
+ CASCADE is issued either, so a live view or foreign key still pointing at a
676
+ _deprecated relation is a real signal: resolve the dependents the error
677
+ lists, then re-run.
678
+ """
665
679
  if log is None:
666
680
  log = _timestamped_log_path(PURGE_LOG_PREFIX)
667
681
 
@@ -691,24 +705,48 @@ def purge_cmd(
691
705
  )
692
706
 
693
707
  all_drops: list[DropEntry] = []
708
+ blocked: list[BlockedEntry] = []
694
709
  try:
695
710
  for label, engine, env_prefix in engines:
696
- all_drops.extend(
697
- _purge_for_engine(
698
- label,
699
- engine,
700
- env_prefix=env_prefix,
701
- excluded_user_schemas=excluded_user_schemas,
702
- excluded_user_relations=excluded_user_relations,
703
- dry_run=dry_run,
704
- renamed_at=renamed_at,
705
- cutoff=cutoff,
706
- include_unknown=include_unknown,
711
+ try:
712
+ all_drops.extend(
713
+ _purge_for_engine(
714
+ label,
715
+ engine,
716
+ env_prefix=env_prefix,
717
+ excluded_user_schemas=excluded_user_schemas,
718
+ excluded_user_relations=excluded_user_relations,
719
+ dry_run=dry_run,
720
+ renamed_at=renamed_at,
721
+ cutoff=cutoff,
722
+ include_unknown=include_unknown,
723
+ )
707
724
  )
708
- )
725
+ except DependentObjectsError as exc:
726
+ # The engine's transaction is already rolled back, so the drops
727
+ # it had made are (correctly) absent from all_drops. Record the
728
+ # refused attempt so the audit log shows why this target did
729
+ # nothing, then re-raise with the [<engine>] prefix every other
730
+ # error from this command carries.
731
+ blocked.append(
732
+ BlockedEntry(
733
+ database=label,
734
+ schema=exc.schema,
735
+ name=exc.name,
736
+ kind=exc.kind,
737
+ dependents=exc.dependents,
738
+ )
739
+ )
740
+ raise ServiceError(f"[{label}] {exc}") from exc
709
741
  except ServiceError as exc:
710
- _write_purge_log(log, all_drops, dry_run=dry_run)
742
+ _write_purge_log(log, all_drops, dry_run=dry_run, blocked=blocked)
711
743
  console.print(f"[red]{esc(exc)}[/red]")
744
+ if blocked:
745
+ console.print(
746
+ "[yellow]Nothing was dropped for that target: the purge is one "
747
+ "transaction, so a refused drop rolls the whole batch "
748
+ "back.[/yellow]"
749
+ )
712
750
  console.print(
713
751
  f"[yellow]Partial purge log written to {esc(log)} "
714
752
  f"({len(all_drops)} entries).[/yellow]"
@@ -839,12 +877,22 @@ def _drop_one(
839
877
  return DropEntry(database=label, schema=schema, name=name, kind=kind)
840
878
 
841
879
 
842
- def _write_purge_log(log_path: str, entries: list[DropEntry], *, dry_run: bool) -> None:
880
+ def _write_purge_log(
881
+ log_path: str,
882
+ entries: list[DropEntry],
883
+ *,
884
+ dry_run: bool,
885
+ blocked: list[BlockedEntry] | None = None,
886
+ ) -> None:
843
887
  payload = {
844
888
  "generated_at": datetime.now(UTC).isoformat(),
845
889
  "dry_run": dry_run,
846
890
  "source": "dbt-orphans-purge",
847
891
  "drops": entries,
892
+ # A refused drop rolls its target's transaction back, so "drops" cannot
893
+ # hold the attempt; recorded separately or the log would show a purge
894
+ # that raised as having done nothing at all.
895
+ "blocked": blocked or [],
848
896
  }
849
897
  with open(log_path, "w") as f:
850
898
  json.dump(payload, f, indent=4)
@@ -72,6 +72,19 @@ def _more_line(hidden: int) -> str:
72
72
  return f" [dim italic]… and {hidden} more (raise --limit to see all).[/dim italic]"
73
73
 
74
74
 
75
+ def _password_set_value(password_set: bool | None) -> str:
76
+ """Render the tri-state ``password_set`` as markup.
77
+
78
+ ``None`` means the server refused to say (pg_authid is superuser-only), and
79
+ the reason travels with the word: a bare "unknown" in a security report
80
+ reads as a tool defect, and rendering it as "no" would be a false negative
81
+ on exactly the field an auditor came for.
82
+ """
83
+ if password_set is None:
84
+ return "unknown [dim](needs superuser to read pg_authid)[/dim]"
85
+ return "yes" if password_set else "no"
86
+
87
+
75
88
  def _attributes_metadata(attrs: RoleAttributes) -> list[tuple[str, str]]:
76
89
  flags: list[str] = []
77
90
  if attrs.superuser:
@@ -97,7 +110,11 @@ def _attributes_metadata(attrs: RoleAttributes) -> list[tuple[str, str]]:
97
110
  # The title card renders metadata values as markup, and this one is
98
111
  # warehouse data (rolvaliduntil rendered by the driver).
99
112
  metadata.append(("Valid until", esc(attrs.valid_until)))
100
- if attrs.password_set:
113
+ # `is True` on purpose: password_set is tri-state, and the card has room
114
+ # only for a bare word. An unexplained "unknown" chip is worse than no
115
+ # chip, so the unknown case is left to the Attributes table, which has
116
+ # room to say why.
117
+ if attrs.password_set is True:
101
118
  metadata.append(("Password", "set"))
102
119
  return metadata
103
120
 
@@ -122,7 +139,7 @@ def _render_attributes(
122
139
  "Connection limit",
123
140
  "unlimited" if attrs.connection_limit < 0 else str(attrs.connection_limit),
124
141
  )
125
- table.add_row("Password set", "yes" if attrs.password_set else "no")
142
+ table.add_row("Password set", _password_set_value(attrs.password_set))
126
143
  table.add_row("Valid until", cell(attrs.valid_until or "—"))
127
144
  console.print(_indent(table))
128
145
 
@@ -665,6 +665,12 @@ def fetch_view_definition(cursor: Any, oid: int, engine: SqlEngine) -> ViewDefin
665
665
  if row is None:
666
666
  raise TargetNotFoundError(f"view with oid {oid} not found")
667
667
  sql, is_updatable, check_option = row
668
+ # pg_get_viewdef() does not error on a non-view oid, it returns NULL, so a
669
+ # table's oid produces a row whose definition is missing. Reject it the way
670
+ # the Redshift branch above does: returning ViewDefinition(sql=None) would
671
+ # break this dataclass's own `sql: str` contract for every caller.
672
+ if sql is None:
673
+ raise TargetNotFoundError(f"view with oid {oid} not found")
668
674
  updatable = isinstance(is_updatable, str) and is_updatable.upper() == "YES"
669
675
  if check_option is None or check_option == "NONE":
670
676
  check_option_value: str | None = None
@@ -752,7 +758,40 @@ ORDER BY c.relkind, c.relname
752
758
  """
753
759
 
754
760
 
755
- _SCHEMA_PRIVILEGES_SQL = """
761
+ # PostgreSQL exposes schema ACLs only through pg_namespace.nspacl; there is no
762
+ # information_schema view for them. usage_privileges reports DOMAIN, COLLATION,
763
+ # FDW, foreign server and sequence object types and never 'SCHEMA', so the
764
+ # USAGE half of the old shared query silently matched zero rows and every
765
+ # GRANT USAGE ON SCHEMA was missing from the report.
766
+ #
767
+ # The COALESCE is load-bearing: nspacl stays NULL until the first GRANT or
768
+ # REVOKE touches the schema, and NULL means "the built-in default" -- the owner
769
+ # holding USAGE + CREATE -- not "nobody holds anything". acldefault('n', owner)
770
+ # materialises that default so a freshly created schema still reports its
771
+ # owner's privileges.
772
+ #
773
+ # Reading the ACL also makes grantor and WITH GRANT OPTION truthful: the CREATE
774
+ # half of the old query derived grantees from has_schema_privilege() and had to
775
+ # hardcode grantor='' and with_grant_option=false.
776
+ _SCHEMA_PRIVILEGES_SQL_POSTGRES = """
777
+ SELECT
778
+ CASE WHEN (acl).grantee = 0 THEN 'PUBLIC'
779
+ ELSE pg_get_userbyid((acl).grantee) END AS grantee,
780
+ (acl).privilege_type AS privilege_type,
781
+ (acl).is_grantable AS with_grant_option,
782
+ pg_get_userbyid((acl).grantor) AS grantor
783
+ FROM pg_namespace n
784
+ CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) AS acl
785
+ WHERE n.nspname = %s
786
+ ORDER BY grantee, privilege_type
787
+ """
788
+
789
+
790
+ # Unchanged from when this query was shared by both engines, and deliberately
791
+ # NOT switched to the aclexplode form above: Redshift has no aclexplode() and
792
+ # no cluster is available to test a replacement against. Its USAGE reporting is
793
+ # therefore still unverified and probably as empty as PostgreSQL's was.
794
+ _SCHEMA_PRIVILEGES_SQL_REDSHIFT = """
756
795
  SELECT grantee, privilege_type, is_grantable = 'YES', grantor
757
796
  FROM information_schema.usage_privileges
758
797
  WHERE object_schema = %s AND object_type = 'SCHEMA'
@@ -820,9 +859,19 @@ def fetch_schema_contents(
820
859
  ]
821
860
 
822
861
 
823
- def fetch_schema_privileges(cursor: Any, schema: str) -> list[PrivilegeGrant]:
824
- """Return USAGE + CREATE grants for the schema."""
825
- cursor.execute(_SCHEMA_PRIVILEGES_SQL, (schema, schema))
862
+ def fetch_schema_privileges(
863
+ cursor: Any, schema: str, engine: SqlEngine
864
+ ) -> list[PrivilegeGrant]:
865
+ """Return USAGE + CREATE grants for the schema.
866
+
867
+ On PostgreSQL these come from ``pg_namespace.nspacl``, which lists explicit
868
+ ACL entries plus the owner's implicit default; a role that only holds the
869
+ privilege through membership in a granted role is not a separate row.
870
+ """
871
+ if engine == SqlEngine.redshift:
872
+ cursor.execute(_SCHEMA_PRIVILEGES_SQL_REDSHIFT, (schema, schema))
873
+ else:
874
+ cursor.execute(_SCHEMA_PRIVILEGES_SQL_POSTGRES, (schema,))
826
875
  return [PrivilegeGrant(*row) for row in cursor.fetchall()]
827
876
 
828
877
 
@@ -1044,7 +1093,7 @@ def describe_schema(
1044
1093
  ) -> SchemaDescription:
1045
1094
  """Compose a full schema description by invoking each fetcher."""
1046
1095
  header = fetch_schema_header(cursor, ref.schema)
1047
- privileges = fetch_schema_privileges(cursor, ref.schema)
1096
+ privileges = fetch_schema_privileges(cursor, ref.schema, engine)
1048
1097
  default_privileges = fetch_schema_default_privileges(cursor, ref.schema, engine)
1049
1098
  contents = fetch_schema_contents(cursor, ref.schema, engine)
1050
1099
  return SchemaDescription(
@@ -294,6 +294,21 @@ class QueryHistoryRow:
294
294
  query_text: str
295
295
 
296
296
 
297
+ # SQLSTATE 55000 (object_not_in_prerequisite_state). pg_stat_statements' C
298
+ # function raises it — "must be loaded via shared_preload_libraries" — when the
299
+ # extension exists in the catalog but was never preloaded. Matched by code
300
+ # rather than by message text or by exception class: the message is not part of
301
+ # any contract, and this module deliberately never imports psycopg so it stays
302
+ # drivable by the fake cursors the unit suite uses.
303
+ _NOT_LOADED_SQLSTATE = "55000"
304
+
305
+ _PGSS_NOT_LOADED = (
306
+ "pg_stat_statements is installed but not loaded. Add it to "
307
+ "shared_preload_libraries in postgresql.conf and RESTART the server "
308
+ "(a config reload is not enough — it is a preloaded library)."
309
+ )
310
+
311
+
297
312
  def fetch_query_history_postgres(
298
313
  cursor: Any,
299
314
  *,
@@ -305,7 +320,8 @@ def fetch_query_history_postgres(
305
320
  Column names changed across pg_stat_statements versions
306
321
  (``total_time`` -> ``total_exec_time``), so they are discovered from the
307
322
  result-set description. Raises :class:`ValidationError` when the
308
- extension's columns are missing/incompatible.
323
+ extension's columns are missing/incompatible, and when the extension is
324
+ present but was never preloaded.
309
325
  """
310
326
  cursor.execute("SELECT * FROM pg_stat_statements LIMIT 0")
311
327
  column_names = {desc.name.lower() for desc in (cursor.description or [])}
@@ -335,7 +351,18 @@ def fetch_query_history_postgres(
335
351
  ORDER BY {_quote(max_col)} DESC
336
352
  LIMIT %s
337
353
  """
338
- cursor.execute(sql, (min_seconds, limit))
354
+ # The column probe above cannot detect a non-preloaded extension: Postgres
355
+ # answers `SELECT * FROM pg_stat_statements LIMIT 0` from the view
356
+ # definition alone, never invoking the extension's C function, so discovery
357
+ # returns the full and correct column list and only this read fails. Left
358
+ # unhandled it reaches the user as a traceback instead of the actionable
359
+ # error the probe was meant to produce.
360
+ try:
361
+ cursor.execute(sql, (min_seconds, limit))
362
+ except Exception as exc: # narrowed to SQLSTATE 55000 immediately below
363
+ if getattr(exc, "sqlstate", None) != _NOT_LOADED_SQLSTATE:
364
+ raise
365
+ raise ValidationError(_PGSS_NOT_LOADED) from exc
339
366
  return [
340
367
  QueryHistoryRow(
341
368
  calls=int(row[0]),
@@ -17,7 +17,7 @@ from typing import Any, Literal, TypedDict
17
17
  import psycopg
18
18
  from psycopg import sql
19
19
 
20
- from dataplat.core.errors import ConfigError
20
+ from dataplat.core.errors import ConfigError, ServiceError
21
21
  from dataplat.services.db._like import LIKE_ESCAPE_CLAUSE, like_escape
22
22
  from dataplat.services.db.connection import (
23
23
  DbConnectionParams,
@@ -105,6 +105,45 @@ class DropEntry(TypedDict):
105
105
  kind: ObjectKind
106
106
 
107
107
 
108
+ class BlockedEntry(TypedDict):
109
+ """Serialized record of a drop the warehouse refused, written to the purge log.
110
+
111
+ A refused drop rolls its whole purge transaction back, so the attempt
112
+ leaves no trace in ``drops``. Without this the audit log would show the
113
+ purge as a silent no-op for that warehouse.
114
+ """
115
+
116
+ database: str
117
+ schema: str
118
+ name: str
119
+ kind: ObjectKind
120
+ dependents: str
121
+
122
+
123
+ class DependentObjectsError(ServiceError):
124
+ """A ``DROP`` was refused because other objects still depend on the relation.
125
+
126
+ Carries the blocked relation and the server's own dependency listing so the
127
+ caller can name both in its error output and its audit log, instead of
128
+ surfacing a bare ``psycopg.errors.DependentObjectsStillExist`` that says
129
+ nothing the user can act on.
130
+ """
131
+
132
+ def __init__(
133
+ self, schema: str, name: str, kind: ObjectKind, dependents: str
134
+ ) -> None:
135
+ self.schema = schema
136
+ self.name = name
137
+ self.kind = kind
138
+ self.dependents = dependents
139
+ blockers = f" ({dependents})" if dependents else ""
140
+ super().__init__(
141
+ f"Cannot drop {kind} {schema}.{name}: other objects still depend "
142
+ f"on it{blockers}. Drop or repoint the dependent object(s), then "
143
+ f"re-run the purge."
144
+ )
145
+
146
+
108
147
  def resolve_orphans_connection_params(
109
148
  engine: SqlEngine, *, env_prefix: str
110
149
  ) -> DbConnectionParams | None:
@@ -238,15 +277,22 @@ def rename_object(
238
277
  )
239
278
 
240
279
 
280
+ # ``IF EXISTS`` is dialect-safe: PostgreSQL and Redshift both accept it on all
281
+ # three kinds this drops, so the keyword can be shared. It is load-bearing
282
+ # because the purge runs every drop of one warehouse inside a single
283
+ # transaction — without it, a relation that vanished between the scan and its
284
+ # DROP raised UndefinedTable and rolled back the drops that had already
285
+ # succeeded. ``IF EXISTS`` only suppresses "does not exist"; a wrong-kind
286
+ # keyword still errors, so each kind keeps its own DROP.
241
287
  _DROP_KEYWORDS: dict[ObjectKind, str] = {
242
- "table": "DROP TABLE",
243
- "view": "DROP VIEW",
244
- "matview": "DROP MATERIALIZED VIEW",
288
+ "table": "DROP TABLE IF EXISTS",
289
+ "view": "DROP VIEW IF EXISTS",
290
+ "matview": "DROP MATERIALIZED VIEW IF EXISTS",
245
291
  }
246
292
 
247
293
 
248
294
  def build_drop_statement(schema: str, name: str, kind: ObjectKind) -> sql.Composed:
249
- """Build a ``DROP …`` statement for the given object kind."""
295
+ """Build a ``DROP … IF EXISTS`` statement for the given object kind."""
250
296
  return sql.SQL("{keyword} {schema}.{name}").format(
251
297
  keyword=sql.SQL(_DROP_KEYWORDS[kind]),
252
298
  schema=sql.Identifier(schema),
@@ -254,8 +300,31 @@ def build_drop_statement(schema: str, name: str, kind: ObjectKind) -> sql.Compos
254
300
  )
255
301
 
256
302
 
303
+ def _dependents_detail(exc: psycopg.Error) -> str:
304
+ """Flatten the server's dependency ``DETAIL`` onto one line.
305
+
306
+ The server already names every dependent object, so no extra catalog query
307
+ (which would have to be written twice for Redshift) is needed. The DETAIL
308
+ is one line per dependent, and the CLI prints errors as single Rich lines.
309
+ """
310
+ detail = (exc.diag.message_detail or "").strip()
311
+ return "; ".join(line.strip() for line in detail.splitlines() if line.strip())
312
+
313
+
257
314
  def drop_object(cur: Any, schema: str, name: str, kind: ObjectKind) -> None:
258
- cur.execute(build_drop_statement(schema, name, kind))
315
+ """Drop ``schema.name``, reporting a dependency refusal in actionable terms.
316
+
317
+ No ``CASCADE`` is issued, so a live view or foreign key still pointing at
318
+ the relation is a legitimate blocker rather than something to bulldoze —
319
+ but the raw ``DependentObjectsStillExist`` names neither the relation nor
320
+ the blockers once it has bubbled up through the purge, so translate it.
321
+ """
322
+ try:
323
+ cur.execute(build_drop_statement(schema, name, kind))
324
+ except psycopg.errors.DependentObjectsStillExist as exc:
325
+ raise DependentObjectsError(
326
+ schema, name, kind, _dependents_detail(exc)
327
+ ) from exc
259
328
 
260
329
 
261
330
  def fetch_deprecated_objects(
@@ -93,15 +93,51 @@ class RoleAttributes:
93
93
  replication: bool
94
94
  bypass_rls: bool
95
95
  connection_limit: int
96
- password_set: bool
96
+ # Tri-state on PostgreSQL: True / False when the real password store was
97
+ # readable, None for "cannot determine" when it was not. Only pg_authid
98
+ # knows, and it is superuser-only, so a plain bool would have to guess --
99
+ # and for a field an auditor reads, "unknown" beats a confident guess.
100
+ password_set: bool | None
97
101
  valid_until: str | None
98
102
 
99
103
 
104
+ # pg_roles.rolpassword is the literal '********' for every row -- the view
105
+ # definition hard-codes it -- so `rolpassword IS NOT NULL` asked there is
106
+ # always true and says nothing about whether a password exists. pg_authid
107
+ # holds the real verifier and is superuser-only by default.
108
+ #
109
+ # Naming pg_authid at all is what fails: the executor checks privileges for
110
+ # every range table before it runs anything, so no CASE branch or unexecuted
111
+ # subquery can hide the reference. The resulting error aborts the transaction,
112
+ # which would also kill the membership / ownership / privilege queries
113
+ # describe_role issues after this one. Asking first avoids the error entirely:
114
+ # has_table_privilege() is an ordinary function call needing no privileges. It
115
+ # also answers the real question rather than "am I superuser" -- a role that
116
+ # was explicitly GRANTed SELECT on pg_authid gets the true answer.
117
+ _PG_AUTHID_READABLE_SQL = """
118
+ SELECT has_table_privilege('pg_authid', 'SELECT')
119
+ """
120
+
100
121
  _ATTRS_SQL_POSTGRES = """
122
+ SELECT r.rolcanlogin, r.rolsuper, r.rolcreatedb, r.rolcreaterole,
123
+ r.rolinherit, r.rolreplication, r.rolbypassrls,
124
+ r.rolconnlimit,
125
+ (a.rolpassword IS NOT NULL) AS password_set,
126
+ r.rolvaliduntil::text
127
+ FROM pg_roles r
128
+ JOIN pg_authid a ON a.oid = r.oid
129
+ WHERE r.rolname = %s
130
+ """
131
+
132
+ # Same columns in the same order as _ATTRS_SQL_POSTGRES so one unpack serves
133
+ # both, with password_set pinned to NULL. pg_roles is a view over pg_authid
134
+ # and exposes every row of it, so dropping the join loses nothing but the one
135
+ # column this session may not read.
136
+ _ATTRS_SQL_POSTGRES_NO_AUTHID = """
101
137
  SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
102
138
  rolinherit, rolreplication, rolbypassrls,
103
139
  rolconnlimit,
104
- (rolpassword IS NOT NULL) AS password_set,
140
+ NULL::boolean AS password_set,
105
141
  rolvaliduntil::text
106
142
  FROM pg_roles
107
143
  WHERE rolname = %s
@@ -114,6 +150,17 @@ WHERE usename = %s
114
150
  """
115
151
 
116
152
 
153
+ def _pg_authid_readable(cursor: Any) -> bool:
154
+ """True when this session may ``SELECT`` from ``pg_authid``.
155
+
156
+ Split out so the "cannot determine" branch of ``fetch_attributes`` is
157
+ reachable in a test without giving up superuser on the connection.
158
+ """
159
+ cursor.execute(_PG_AUTHID_READABLE_SQL)
160
+ row = cursor.fetchone()
161
+ return bool(row and row[0])
162
+
163
+
117
164
  def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttributes:
118
165
  if engine == SqlEngine.redshift:
119
166
  cursor.execute(_ATTRS_SQL_REDSHIFT, (name,))
@@ -145,7 +192,11 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
145
192
  password_set=False,
146
193
  valid_until=str(valid_until) if valid_until is not None else None,
147
194
  )
148
- cursor.execute(_ATTRS_SQL_POSTGRES, (name,))
195
+ authid_readable = _pg_authid_readable(cursor)
196
+ cursor.execute(
197
+ _ATTRS_SQL_POSTGRES if authid_readable else _ATTRS_SQL_POSTGRES_NO_AUTHID,
198
+ (name,),
199
+ )
149
200
  row = cursor.fetchone()
150
201
  if row is None:
151
202
  raise RoleNotFoundError(f'role "{name}" not found')
@@ -170,7 +221,9 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
170
221
  replication=bool(replication),
171
222
  bypass_rls=bool(bypass_rls),
172
223
  connection_limit=int(conn_limit),
173
- password_set=bool(password_set),
224
+ # Keyed off the probe, not off the value: only the probe distinguishes
225
+ # "the store says no password" from "we were not allowed to look".
226
+ password_set=bool(password_set) if authid_readable else None,
174
227
  valid_until=valid_until,
175
228
  )
176
229
 
@@ -355,7 +408,13 @@ def fetch_owned_objects(
355
408
  total = 0
356
409
  for schema, relkind, count in cursor.fetchall():
357
410
  kind_label = _KIND_LABEL.get(relkind, relkind)
358
- by_schema.setdefault(schema, {})[kind_label] = int(count)
411
+ kinds = by_schema.setdefault(schema, {})
412
+ # Accumulate, never assign: the query groups by relkind while
413
+ # _KIND_LABEL folds several relkinds onto one label ('r' and 'p' are
414
+ # both "table"), so one schema can produce two rows for the same label.
415
+ # Assigning let the second row overwrite the first, which under-counted
416
+ # the breakdown and made it disagree with total_relations below.
417
+ kinds[kind_label] = kinds.get(kind_label, 0) + int(count)
359
418
  total += int(count)
360
419
  return OwnedObjectsSummary(
361
420
  schemas=schemas,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataplat"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "One command to manage any shape of data platform: databases, ingestion, BI, cloud, and CI."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -142,6 +142,9 @@ def test_db_role_cli_smoke() -> None:
142
142
  cursor.fetchone.side_effect = [
143
143
  # resolve_role
144
144
  (16384, True, False),
145
+ # pg_authid readable? password_set is only knowable to a superuser, so
146
+ # fetch_attributes probes for the privilege before choosing its query.
147
+ (True,),
145
148
  # attributes
146
149
  (True, False, True, True, True, False, False, -1, False, None),
147
150
  ]
@@ -250,6 +253,8 @@ def test_db_role_cli_json_flag() -> None:
250
253
  cursor = MagicMock()
251
254
  cursor.fetchone.side_effect = [
252
255
  (16384, True, False),
256
+ # pg_authid privilege probe (see the smoke test above).
257
+ (True,),
253
258
  (True, False, True, True, True, False, False, -1, False, None),
254
259
  ]
255
260
  cursor.fetchall.side_effect = [