dataplat 0.2.2__tar.gz → 0.2.3__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 (166) hide show
  1. {dataplat-0.2.2 → dataplat-0.2.3}/CHANGELOG.md +37 -0
  2. dataplat-0.2.3/CONTRIBUTING.md +177 -0
  3. {dataplat-0.2.2 → dataplat-0.2.3}/PKG-INFO +11 -1
  4. {dataplat-0.2.2 → dataplat-0.2.3}/README.md +10 -0
  5. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/role.py +19 -9
  6. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/describe.py +22 -7
  7. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/role.py +10 -2
  8. {dataplat-0.2.2 → dataplat-0.2.3}/pyproject.toml +8 -1
  9. dataplat-0.2.3/tests/integration/redshift/__init__.py +19 -0
  10. dataplat-0.2.3/tests/integration/redshift/conftest.py +1191 -0
  11. dataplat-0.2.3/tests/integration/redshift/test_conformance.py +281 -0
  12. dataplat-0.2.3/tests/integration/redshift/test_harness.py +415 -0
  13. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_describe.py +36 -3
  14. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_role.py +38 -1
  15. {dataplat-0.2.2 → dataplat-0.2.3}/uv.lock +1 -1
  16. {dataplat-0.2.2 → dataplat-0.2.3}/.github/workflows/ci.yml +0 -0
  17. {dataplat-0.2.2 → dataplat-0.2.3}/.github/workflows/release.yml +0 -0
  18. {dataplat-0.2.2 → dataplat-0.2.3}/.gitignore +0 -0
  19. {dataplat-0.2.2 → dataplat-0.2.3}/.python-version +0 -0
  20. {dataplat-0.2.2 → dataplat-0.2.3}/LICENSE +0 -0
  21. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/__init__.py +0 -0
  22. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/__init__.py +0 -0
  23. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/_lazy.py +0 -0
  24. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/_missing.py +0 -0
  25. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/_options.py +0 -0
  26. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/_prompt.py +0 -0
  27. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/_render.py +0 -0
  28. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/bi/__init__.py +0 -0
  29. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/bi/app.py +0 -0
  30. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/bi/superset.py +0 -0
  31. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ci/__init__.py +0 -0
  32. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ci/app.py +0 -0
  33. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ci/github/__init__.py +0 -0
  34. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ci/github/app.py +0 -0
  35. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ci/github/runner.py +0 -0
  36. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/__init__.py +0 -0
  37. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/app.py +0 -0
  38. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/__init__.py +0 -0
  39. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/_common.py +0 -0
  40. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/app.py +0 -0
  41. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/rds.py +0 -0
  42. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/redshift.py +0 -0
  43. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/cloud/aws/secrets.py +0 -0
  44. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/config.py +0 -0
  45. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/__init__.py +0 -0
  46. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/_common.py +0 -0
  47. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/_report.py +0 -0
  48. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/dbt_orphans.py +0 -0
  49. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/describe.py +0 -0
  50. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/long_queries.py +0 -0
  51. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/role_create.py +0 -0
  52. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/role_drop.py +0 -0
  53. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/role_list.py +0 -0
  54. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/db/top_tables.py +0 -0
  55. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/__init__.py +0 -0
  56. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/__init__.py +0 -0
  57. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/_common.py +0 -0
  58. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/_cursor.py +0 -0
  59. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/_resource.py +0 -0
  60. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/app.py +0 -0
  61. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/connections.py +0 -0
  62. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/definitions.py +0 -0
  63. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/destinations.py +0 -0
  64. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/enums.py +0 -0
  65. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/jobs.py +0 -0
  66. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/sources.py +0 -0
  67. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/tags.py +0 -0
  68. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/templates.py +0 -0
  69. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/tui.py +0 -0
  70. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/airbyte/workspaces.py +0 -0
  71. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/ingest/app.py +0 -0
  72. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/open.py +0 -0
  73. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/cli/status.py +0 -0
  74. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/core/__init__.py +0 -0
  75. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/core/deps.py +0 -0
  76. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/core/envrc.py +0 -0
  77. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/core/errors.py +0 -0
  78. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/core/registry.py +0 -0
  79. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/main.py +0 -0
  80. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/__init__.py +0 -0
  81. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/__init__.py +0 -0
  82. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/_resource.py +0 -0
  83. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/client.py +0 -0
  84. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/connections.py +0 -0
  85. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/definitions.py +0 -0
  86. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/destinations.py +0 -0
  87. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/jobs.py +0 -0
  88. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/sources.py +0 -0
  89. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/tags.py +0 -0
  90. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/airbyte/workspaces.py +0 -0
  91. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/aws/__init__.py +0 -0
  92. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/aws/auth.py +0 -0
  93. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/__init__.py +0 -0
  94. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/_like.py +0 -0
  95. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/connection.py +0 -0
  96. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/long_queries.py +0 -0
  97. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/orphans.py +0 -0
  98. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/role_admin.py +0 -0
  99. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/role_dialects.py +0 -0
  100. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/targets.py +0 -0
  101. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/db/top_tables.py +0 -0
  102. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/superset/__init__.py +0 -0
  103. {dataplat-0.2.2 → dataplat-0.2.3}/dataplat/services/superset/client.py +0 -0
  104. {dataplat-0.2.2 → dataplat-0.2.3}/tests/__init__.py +0 -0
  105. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/__init__.py +0 -0
  106. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_airbyte_commands.py +0 -0
  107. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_airbyte_cursor_logic.py +0 -0
  108. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_airbyte_guards.py +0 -0
  109. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_airbyte_tui.py +0 -0
  110. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_aws_secrets.py +0 -0
  111. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_aws_secrets_write.py +0 -0
  112. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_cli_smoke.py +0 -0
  113. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_config.py +0 -0
  114. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_db_common.py +0 -0
  115. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_db_long_queries.py +0 -0
  116. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_db_query.py +0 -0
  117. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_dbt_orphans.py +0 -0
  118. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_describe.py +0 -0
  119. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_github_runner.py +0 -0
  120. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_missing_deps.py +0 -0
  121. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_open.py +0 -0
  122. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_prompt.py +0 -0
  123. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_rds.py +0 -0
  124. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_redshift.py +0 -0
  125. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_regression.py +0 -0
  126. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_render.py +0 -0
  127. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_role.py +0 -0
  128. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_role_create.py +0 -0
  129. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_role_drop.py +0 -0
  130. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_status.py +0 -0
  131. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_superset.py +0 -0
  132. {dataplat-0.2.2 → dataplat-0.2.3}/tests/cli/test_top_tables.py +0 -0
  133. {dataplat-0.2.2 → dataplat-0.2.3}/tests/conftest.py +0 -0
  134. {dataplat-0.2.2 → dataplat-0.2.3}/tests/core/__init__.py +0 -0
  135. {dataplat-0.2.2 → dataplat-0.2.3}/tests/core/test_deps.py +0 -0
  136. {dataplat-0.2.2 → dataplat-0.2.3}/tests/core/test_envrc.py +0 -0
  137. {dataplat-0.2.2 → dataplat-0.2.3}/tests/core/test_registry.py +0 -0
  138. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/__init__.py +0 -0
  139. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/conftest.py +0 -0
  140. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_describe_pg.py +0 -0
  141. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_harness.py +0 -0
  142. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_long_queries_pg.py +0 -0
  143. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_orphans_pg.py +0 -0
  144. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_roles_pg.py +0 -0
  145. {dataplat-0.2.2 → dataplat-0.2.3}/tests/integration/test_top_tables_pg.py +0 -0
  146. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/__init__.py +0 -0
  147. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/__init__.py +0 -0
  148. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_client.py +0 -0
  149. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_connections.py +0 -0
  150. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_definitions.py +0 -0
  151. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_destinations.py +0 -0
  152. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_jobs.py +0 -0
  153. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_sources.py +0 -0
  154. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/airbyte/test_workspaces.py +0 -0
  155. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/aws/__init__.py +0 -0
  156. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/aws/test_auth.py +0 -0
  157. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/__init__.py +0 -0
  158. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_connection.py +0 -0
  159. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_long_queries.py +0 -0
  160. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_orphans.py +0 -0
  161. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_role_admin.py +0 -0
  162. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_role_dialects.py +0 -0
  163. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_targets.py +0 -0
  164. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/db/test_top_tables.py +0 -0
  165. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/superset/__init__.py +0 -0
  166. {dataplat-0.2.2 → dataplat-0.2.3}/tests/services/superset/test_client.py +0 -0
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3
4
+
5
+ Redshift-only fixes. Nothing changes for PostgreSQL targets.
6
+
7
+ ### Fixed
8
+
9
+ - `dp db role show` no longer claims every Redshift user has no password. The
10
+ attribute query reported `password_set=False` unconditionally, but
11
+ `pg_user.passwd` is masked to `'********'` there just as `pg_roles.rolpassword`
12
+ is on PostgreSQL — so it asserted "this login has no password" for every user,
13
+ the same falsehood 0.2.2 fixed on the PostgreSQL side. It now reports
14
+ `unknown`, with the reason. A Redshift *group* still reports `no`, because a
15
+ group has no password to hold.
16
+
17
+ - `dp db describe <schema>` now reports `USAGE` grants on Redshift. The query
18
+ read `information_schema.usage_privileges` filtered to `object_type = 'SCHEMA'`,
19
+ which the SQL standard defines over domains, collations and sequences — never
20
+ schemas — so it returned nothing on every server. It now scans
21
+ `has_schema_privilege`, mirroring how the same query has always reported
22
+ `CREATE` on that path. As with `CREATE`, a privilege scan cannot report a
23
+ grantor or a grant option, so both stay empty; the PostgreSQL path reads the
24
+ ACL and does better on both counts.
25
+
26
+ Both fixes rest on documented behaviour and internal precedent rather than a
27
+ live cluster — Redshift cannot be containerized, so CI cannot cover it. See
28
+ below.
29
+
30
+ ### Added
31
+
32
+ - A Redshift conformance harness (`tests/integration/redshift/`) for anyone who
33
+ runs dataplat against a real cluster. The read-only tier is safe to point at a
34
+ warehouse in use — a guard refuses anything that is not plainly a read before
35
+ it reaches the server — and it interrogates the assumptions the two fixes above
36
+ depend on, printing what your cluster answered. `CONTRIBUTING.md` documents
37
+ both tiers and the evidence rules for changing SQL that runs on a dialect CI
38
+ cannot reach.
39
+
3
40
  ## 0.2.2
4
41
 
5
42
  Closes the six defects 0.2.1's integration suite found and pinned as expected
@@ -0,0 +1,177 @@
1
+ # Contributing to dataplat
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ git clone https://github.com/hanslemm/dataplat
7
+ cd dataplat
8
+ uv sync --group dev --all-extras
9
+ ```
10
+
11
+ ## Checks
12
+
13
+ The four gates CI runs, across Python 3.12 and 3.13:
14
+
15
+ ```bash
16
+ uv run pytest
17
+ uv run ruff check .
18
+ uv run ruff format --check .
19
+ uv run mypy dataplat
20
+ ```
21
+
22
+ `uv run pytest` is green without Docker: the database-backed tests skip. To run
23
+ them, start a server and point the suite at it:
24
+
25
+ ```bash
26
+ docker run -d --name dp-pg-test \
27
+ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=dataplat_test \
28
+ -p 55432:5432 postgres:16 -c shared_preload_libraries=pg_stat_statements
29
+ docker exec dp-pg-test psql -U postgres -d dataplat_test \
30
+ -c 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements'
31
+
32
+ DP_TEST_PG_REQUIRED=1 uv run pytest # everything
33
+ uv run pytest -m "not integration" # skip the database half
34
+ docker rm -f -v dp-pg-test # -v, or the volume dangles
35
+ ```
36
+
37
+ `DP_TEST_PG_REQUIRED=1` turns an unreachable server into an error instead of a
38
+ skip. CI sets it; without it a broken database would make the whole suite skip
39
+ and still report success.
40
+
41
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
42
+
43
+ ## Testing against a real Redshift cluster
44
+
45
+ Redshift is a managed service, so there is no container and CI cannot cover it.
46
+ If you have a cluster, you can. The suite is in `tests/integration/redshift/` and
47
+ is split into two tiers, because they need different permission to run:
48
+
49
+ | Marker | Mutates? | Needs |
50
+ | --- | --- | --- |
51
+ | `redshift` | no — read-only, safe against a warehouse in use | a reachable cluster |
52
+ | `redshift_ddl` | **yes** | a cluster you can throw away |
53
+
54
+ Credentials come from an ordinary dataplat target, so they stay in your own
55
+ `.envrc` and never reach the repo:
56
+
57
+ ```bash
58
+ export DP_TARGETS=warehouse WAREHOUSE_ENGINE=redshift \
59
+ WAREHOUSE_HOST=... WAREHOUSE_USER=... WAREHOUSE_DATABASE=... \
60
+ WAREHOUSE_PASSWORD=...
61
+ export DP_TEST_RS_TARGET=warehouse # or DP_TEST_RS_DSN=... as an escape hatch
62
+
63
+ DP_TEST_RS_REQUIRED=1 uv run pytest -m redshift # read-only tier
64
+ ```
65
+
66
+ | Variable | Effect |
67
+ | --- | --- |
68
+ | `DP_TEST_RS_TARGET` | a dataplat target name, resolved by the tool's own config |
69
+ | `DP_TEST_RS_DSN` | a raw libpq URL, if you would rather not declare a target |
70
+ | `DP_TEST_RS_REQUIRED` | an unreachable cluster becomes an error instead of a skip |
71
+ | `DP_TEST_RS_DISPOSABLE` | **required** before any `redshift_ddl` test will run |
72
+ | `DP_TEST_RS_SCHEMA` | a schema the read-only tier may inspect (otherwise discovered) |
73
+
74
+ A plain `uv run pytest` is unaffected: with nothing configured, both tiers skip.
75
+
76
+ ### Why there is a client-side read-only guard
77
+
78
+ `rs_cursor` refuses anything that is not plainly a read *before it is sent*, on
79
+ top of the server-side `READ ONLY` transaction. Two layers, because the cluster
80
+ may be production: Redshift roles are cluster-wide, and its transactional-DDL
81
+ semantics differ from PostgreSQL's, so the rollback-per-test isolation the
82
+ PostgreSQL harness relies on cannot be assumed to clean up a mistake. A
83
+ server-side check would refuse the statement too — but only after it crossed the
84
+ network to a warehouse someone depends on.
85
+
86
+ It denies by default: only `SELECT`, `WITH … SELECT`, `EXPLAIN` and `SHOW` pass.
87
+ It is not fooled by a leading comment, case, a stray semicolon, a second
88
+ statement smuggled after a `SELECT`, a data-modifying CTE, `SELECT … INTO`, or a
89
+ side-effecting builtin such as `pg_terminate_backend`. The statement splitter is
90
+ hand-written rather than regex-based because a regex that ignores quoting can
91
+ *hide* a statement — naive comment stripping turns `SELECT '--' ; DROP TABLE t`
92
+ into a harmless-looking fragment plus a `DROP` the server will happily run. The
93
+ one hole it cannot close is an unlisted side-effecting UDF; that is what the
94
+ server-side layer is for, and `assert_read_only`'s docstring says so.
95
+
96
+ ### What a run does and does not prove
97
+
98
+ A green read-only run proves dataplat's `SELECT`s are **valid Redshift SQL
99
+ against a real server, returning results that unpack** — which is precisely the
100
+ class of defect the PostgreSQL suite found repeatedly in these same functions
101
+ (an empty `pg_partition_tree`, a masked column, a view that does not cover
102
+ schemas). It cannot tell you anything about `GRANT`, `DROP`, `RENAME` or session
103
+ termination; those need the DDL tier and a disposable cluster.
104
+
105
+ The run also prints a conformance table of what the cluster answered, because
106
+ the point is learning what the engine does — a green run that recorded nothing
107
+ has taught nobody anything.
108
+
109
+ ## Dialect changes: what counts as evidence
110
+
111
+ `dataplat/services/db` targets PostgreSQL and Redshift. PostgreSQL has a real
112
+ integration suite behind it. Redshift has none and cannot get one cheaply — it
113
+ is a managed service, so there is no container to run in CI.
114
+
115
+ For a while the rule was simply "don't touch SQL that runs on Redshift, because
116
+ you can't test it." That is a good instinct and a bad rule. Applied literally it
117
+ blocked seven known defects, and when they were finally looked at one at a time,
118
+ six were fixable and only one genuinely needed Redshift-specific SQL. Five did
119
+ not touch Redshift SQL at all, and the sixth turned out to use a construct the
120
+ codebase was already shipping to Redshift elsewhere.
121
+
122
+ So the question is not "can I test this?" but **"what evidence do I have?"** A
123
+ change affecting the Redshift path needs at least one of the following, in
124
+ descending order of strength:
125
+
126
+ 0. **A conformance run confirmed it against a real cluster.** Strongest, and the
127
+ only one that is evidence rather than inference — see the section above. A fix
128
+ currently resting on class 2 should be upgraded to class 0 when someone runs
129
+ the suite, and revisited if the run *refutes* it. `test_conformance.py` names
130
+ the assumptions each shipped fix depends on for exactly this reason.
131
+
132
+ 1. **It changes no Redshift SQL.** The fix is pure Python, or touches only a
133
+ `_*_SQL_POSTGRES` constant. Dialect risk is zero by construction — verify
134
+ that claim honestly, then go ahead. Aggregation bugs, error handling, and
135
+ return-value shaping usually land here.
136
+
137
+ 2. **The construct is already in production on the Redshift path.** Cite the
138
+ file and line. `ESCAPE '\'` was safe to add to `orphans.py` because
139
+ `top_tables.py` had always sent it to both engines;
140
+ `has_schema_privilege(...)` is safe in `describe.py`'s Redshift branch
141
+ because that branch already calls it. Internal precedent beats
142
+ documentation: it is the same server, the same driver, and code someone is
143
+ already running.
144
+
145
+ 3. **It withdraws a claim rather than making one.** Replacing a confidently
146
+ wrong value with "unknown" cannot be more wrong than what it replaced. See
147
+ the standing rule below.
148
+
149
+ 4. **Documented Redshift behaviour, cited, plus a fake-cursor test** pinning the
150
+ SQL the Redshift branch emits. Weakest of the four, because documentation and
151
+ deployed reality drift. Use it when the change is worth the residual risk,
152
+ and say so in the commit.
153
+
154
+ If none of the four applies, **do not guess.** Leave the defect, and record it
155
+ in a comment next to the code it affects — not in a tracker nobody reads. The
156
+ comment is what lets the next person re-evaluate instead of rediscovering.
157
+
158
+ ### Requirements either way
159
+
160
+ - **Keep the engine constants split.** Never edit a `_*_SQL_REDSHIFT` constant
161
+ to fix a PostgreSQL bug. If a shared statement needs to diverge, split it and
162
+ leave the Redshift half byte-for-byte as it was.
163
+ - **Add a fake-cursor test** asserting what the Redshift branch emits. It is the
164
+ only mechanism that covers that path at all, and it catches the common
165
+ accident of "fixed both branches when I meant one".
166
+ - **Record the evidence in the code**, not just the commit message. A future
167
+ reader deciding whether they may touch the line needs to see why it is the way
168
+ it is.
169
+
170
+ ### Standing rule: prefer "unknown" to a confident falsehood
171
+
172
+ `dp db role show` used to print `Password set: yes` for every role, including
173
+ passwordless ones, because the column it read is masked to `'********'` and can
174
+ never be NULL. A report that states something false is worse than one that
175
+ admits a gap — especially a report someone is using for an audit. When the
176
+ server will not tell you, say so, and say why in the same breath: a bare
177
+ "unknown" reads as a tool defect.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataplat
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -324,6 +324,9 @@ dp ingest airbyte connections set-cursor -c <connection-id> --xmin 0 --yes
324
324
 
325
325
  ## Development
326
326
 
327
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the integration suite and the rules
328
+ for changing SQL that runs on Redshift.
329
+
327
330
  ```bash
328
331
  git clone https://github.com/hanslemm/dataplat
329
332
  cd dataplat
@@ -337,6 +340,13 @@ uv run mypy dataplat
337
340
  CI runs those four across Python 3.12 and 3.13 — the floor the wheel
338
341
  advertises as well as the pinned dev version.
339
342
 
343
+ Redshift cannot be containerized, so CI cannot cover it. If you run dataplat
344
+ against a Redshift cluster, you can verify your own deployment: point
345
+ `DP_TEST_RS_TARGET` at one of your targets and run the read-only tier
346
+ (`uv run pytest -m redshift`). It only issues `SELECT`s — a guard refuses
347
+ anything else before it reaches the server — and prints what your cluster
348
+ answered. See [CONTRIBUTING.md](CONTRIBUTING.md#testing-against-a-real-redshift-cluster).
349
+
340
350
  ### Integration tests against a real PostgreSQL
341
351
 
342
352
  Most of the suite drives a fake database cursor. That proves a code path
@@ -279,6 +279,9 @@ dp ingest airbyte connections set-cursor -c <connection-id> --xmin 0 --yes
279
279
 
280
280
  ## Development
281
281
 
282
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the integration suite and the rules
283
+ for changing SQL that runs on Redshift.
284
+
282
285
  ```bash
283
286
  git clone https://github.com/hanslemm/dataplat
284
287
  cd dataplat
@@ -292,6 +295,13 @@ uv run mypy dataplat
292
295
  CI runs those four across Python 3.12 and 3.13 — the floor the wheel
293
296
  advertises as well as the pinned dev version.
294
297
 
298
+ Redshift cannot be containerized, so CI cannot cover it. If you run dataplat
299
+ against a Redshift cluster, you can verify your own deployment: point
300
+ `DP_TEST_RS_TARGET` at one of your targets and run the read-only tier
301
+ (`uv run pytest -m redshift`). It only issues `SELECT`s — a guard refuses
302
+ anything else before it reaches the server — and prints what your cluster
303
+ answered. See [CONTRIBUTING.md](CONTRIBUTING.md#testing-against-a-real-redshift-cluster).
304
+
295
305
  ### Integration tests against a real PostgreSQL
296
306
 
297
307
  Most of the suite drives a fake database cursor. That proves a code path
@@ -72,16 +72,23 @@ 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:
75
+ def _password_set_value(password_set: bool | None, engine: SqlEngine) -> str:
76
76
  """Render the tri-state ``password_set`` as markup.
77
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.
78
+ ``None`` means the server would not say, and the reason travels with the
79
+ word: a bare "unknown" in a security report reads as a tool defect, and
80
+ rendering it as "no" would be a false negative on exactly the field an
81
+ auditor came for. The reason differs by engine, and naming pg_authid on
82
+ Redshift — which has no such relation — would send the reader somewhere
83
+ that does not exist.
82
84
  """
83
85
  if password_set is None:
84
- return "unknown [dim](needs superuser to read pg_authid)[/dim]"
86
+ reason = (
87
+ "no readable password catalog on Redshift"
88
+ if engine == SqlEngine.redshift
89
+ else "needs superuser to read pg_authid"
90
+ )
91
+ return f"unknown [dim]({reason})[/dim]"
85
92
  return "yes" if password_set else "no"
86
93
 
87
94
 
@@ -120,7 +127,10 @@ def _attributes_metadata(attrs: RoleAttributes) -> list[tuple[str, str]]:
120
127
 
121
128
 
122
129
  def _render_attributes(
123
- console: Console, counter: _SectionCounter, attrs: RoleAttributes
130
+ console: Console,
131
+ counter: _SectionCounter,
132
+ attrs: RoleAttributes,
133
+ engine: SqlEngine,
124
134
  ) -> None:
125
135
  _print_section_heading(
126
136
  console, counter, "Attributes", "Role flags, login, and limits."
@@ -139,7 +149,7 @@ def _render_attributes(
139
149
  "Connection limit",
140
150
  "unlimited" if attrs.connection_limit < 0 else str(attrs.connection_limit),
141
151
  )
142
- table.add_row("Password set", _password_set_value(attrs.password_set))
152
+ table.add_row("Password set", _password_set_value(attrs.password_set, engine))
143
153
  table.add_row("Valid until", cell(attrs.valid_until or "—"))
144
154
  console.print(_indent(table))
145
155
 
@@ -345,7 +355,7 @@ def render_role_description(
345
355
  console.print()
346
356
 
347
357
  counter = _SectionCounter()
348
- _render_attributes(console, counter, desc.attributes)
358
+ _render_attributes(console, counter, desc.attributes, engine)
349
359
  parent_caption = (
350
360
  "Roles this role is a member of (single-level; Redshift groups)."
351
361
  if engine == SqlEngine.redshift
@@ -787,14 +787,29 @@ ORDER BY grantee, privilege_type
787
787
  """
788
788
 
789
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.
790
+ # Not the aclexplode form above: Redshift has no aclexplode(), and no cluster is
791
+ # available to test a replacement against.
792
+ #
793
+ # The USAGE half used to read information_schema.usage_privileges filtered to
794
+ # object_type = 'SCHEMA', which the SQL standard defines over domains,
795
+ # collations and sequences — never schemas. It returned zero rows on every
796
+ # server, so a GRANT USAGE ON SCHEMA was invisible here exactly as it was on
797
+ # PostgreSQL. It is replaced by a has_schema_privilege() scan mirroring the
798
+ # CREATE half directly below, which this same query has always run against
799
+ # Redshift: same function, same catalog, same shape, only the privilege string
800
+ # differs (CONTRIBUTING, evidence 2 — internal precedent on this very path).
801
+ #
802
+ # Known limitation, shared with the CREATE half and unchanged: a privilege scan
803
+ # cannot report a grantor or a grant option, so both are reported empty, and
804
+ # roles holding the privilege only through membership appear as their own rows.
805
+ # The PostgreSQL path reads the ACL and does better on both counts.
794
806
  _SCHEMA_PRIVILEGES_SQL_REDSHIFT = """
795
- SELECT grantee, privilege_type, is_grantable = 'YES', grantor
796
- FROM information_schema.usage_privileges
797
- WHERE object_schema = %s AND object_type = 'SCHEMA'
807
+ SELECT grantee, 'USAGE', false, ''
808
+ FROM (
809
+ SELECT r.rolname AS grantee
810
+ FROM pg_roles r
811
+ WHERE has_schema_privilege(r.rolname, %s, 'USAGE')
812
+ ) u
798
813
  UNION ALL
799
814
  SELECT grantee, 'CREATE', false, ''
800
815
  FROM (
@@ -166,7 +166,9 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
166
166
  cursor.execute(_ATTRS_SQL_REDSHIFT, (name,))
167
167
  row = cursor.fetchone()
168
168
  if row is None:
169
- # Group — no login, no attributes table.
169
+ # Group — no login, no attributes table. password_set=False is a
170
+ # real answer here rather than a guess: a Redshift group has no
171
+ # password to hold.
170
172
  return RoleAttributes(
171
173
  can_login=False,
172
174
  superuser=False,
@@ -189,7 +191,13 @@ def fetch_attributes(cursor: Any, name: str, engine: SqlEngine) -> RoleAttribute
189
191
  replication=False,
190
192
  bypass_rls=False,
191
193
  connection_limit=-1,
192
- password_set=False,
194
+ # Unknown, not False. This is a Redshift *user*, and pg_user.passwd
195
+ # is masked to '********' exactly as pg_roles.rolpassword is on
196
+ # PostgreSQL — so False asserted "this login has no password" for
197
+ # every user, which is the same falsehood that bug fixed there.
198
+ # Reporting unknown withdraws the claim without inventing Redshift
199
+ # SQL nobody here can execute (see CONTRIBUTING, evidence 1 and 3).
200
+ password_set=None,
193
201
  valid_until=str(valid_until) if valid_until is not None else None,
194
202
  )
195
203
  authid_readable = _pg_authid_readable(cursor)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataplat"
3
- version = "0.2.2"
3
+ version = "0.2.3"
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"
@@ -75,6 +75,13 @@ markers = [
75
75
  # PostgreSQL (DP_TEST_PG_DSN). Registered so `-m 'not integration'`
76
76
  # selects against a known marker instead of warning about an unknown one.
77
77
  "integration: requires a live PostgreSQL server",
78
+ # Redshift cannot be containerized, so these two run only when someone
79
+ # points them at a cluster. Read-only and mutating are separate markers
80
+ # because they need different permission to run: `redshift` is safe against
81
+ # a warehouse in use, `redshift_ddl` is not, and keeping them selectable
82
+ # apart is what lets a production cluster be used for the first tier only.
83
+ "redshift: requires a live Redshift cluster; read-only",
84
+ "redshift_ddl: requires a DISPOSABLE Redshift cluster; mutates it",
78
85
  ]
79
86
 
80
87
  [tool.ruff]
@@ -0,0 +1,19 @@
1
+ """Integration tests that run against a live Amazon Redshift cluster.
2
+
3
+ Two tiers, two markers, because the two have very different risk profiles:
4
+
5
+ ``redshift``
6
+ Needs a reachable cluster and is **read-only**. Every statement passes a
7
+ client-side guard before it reaches the server, and the session asks the
8
+ server for a READ ONLY transaction on top of that, so this tier is safe to
9
+ point at a warehouse somebody depends on.
10
+
11
+ ``redshift_ddl``
12
+ Needs a **disposable** cluster and mutates it. Refuses to run unless
13
+ ``DP_TEST_RS_DISPOSABLE`` says out loud that the cluster is expendable.
14
+
15
+ See ``tests/integration/redshift/conftest.py`` for the fixture contract and the
16
+ ``DP_TEST_RS_*`` environment variables. Everything here is also marked
17
+ ``integration`` by ``tests/integration/conftest.py``, so ``pytest -m 'not
18
+ integration'`` excludes it.
19
+ """