phlo-postgres 0.14.0__tar.gz → 0.15.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 (38) hide show
  1. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/PKG-INFO +2 -2
  2. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/pyproject.toml +2 -2
  3. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/__init__.py +7 -1
  4. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/authorization.py +6 -1
  5. phlo_postgres-0.15.2/src/phlo_postgres/checkpoints.py +309 -0
  6. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/cli.py +19 -101
  7. phlo_postgres-0.15.2/src/phlo_postgres/cli_plugin.py +24 -0
  8. phlo_postgres-0.15.2/src/phlo_postgres/continuity.py +168 -0
  9. phlo_postgres-0.15.2/src/phlo_postgres/dataset_state_store.py +426 -0
  10. phlo_postgres-0.15.2/src/phlo_postgres/plugin.py +231 -0
  11. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/publish_target.py +3 -17
  12. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/resource.py +20 -74
  13. phlo_postgres-0.15.2/src/phlo_postgres/security_readiness.py +46 -0
  14. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/settings.py +33 -48
  15. phlo_postgres-0.15.2/src/phlo_postgres/volume_setup.yaml +13 -0
  16. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/PKG-INFO +2 -2
  17. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/SOURCES.txt +7 -0
  18. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/requires.txt +1 -1
  19. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_authorization.py +21 -77
  20. phlo_postgres-0.15.2/tests/test_checkpoints.py +221 -0
  21. phlo_postgres-0.15.2/tests/test_continuity.py +137 -0
  22. phlo_postgres-0.15.2/tests/test_dataset_state_store.py +348 -0
  23. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_postgres_cli.py +6 -1
  24. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_postgres_plugin.py +53 -6
  25. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_resource.py +4 -6
  26. phlo_postgres-0.14.0/src/phlo_postgres/cli_plugin.py +0 -17
  27. phlo_postgres-0.14.0/src/phlo_postgres/plugin.py +0 -141
  28. phlo_postgres-0.14.0/src/phlo_postgres/volume_setup.yaml +0 -24
  29. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/README.md +0 -0
  30. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/setup.cfg +0 -0
  31. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/exporter_service.yaml +0 -0
  32. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/service.yaml +0 -0
  33. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres/settings_store.py +0 -0
  34. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/dependency_links.txt +0 -0
  35. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/entry_points.txt +0 -0
  36. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/src/phlo_postgres.egg-info/top_level.txt +0 -0
  37. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_integration_postgres.py +0 -0
  38. {phlo_postgres-0.14.0 → phlo_postgres-0.15.2}/tests/test_settings_store.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phlo-postgres
3
- Version: 0.14.0
3
+ Version: 0.15.2
4
4
  Summary: Postgres service plugin for Phlo
5
5
  Author-email: Phlo Team <team@phlo.dev>
6
6
  License: MIT
7
7
  Requires-Python: >=3.11
8
8
  Description-Content-Type: text/plain
9
- Requires-Dist: phlo<0.15,>=0.14.0
9
+ Requires-Dist: phlo<0.17,>=0.16.0
10
10
  Requires-Dist: psycopg2-binary>=2.9.11
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -7,13 +7,13 @@ requires = [
7
7
 
8
8
  [project]
9
9
  dependencies = [
10
- "phlo>=0.14.0,<0.15",
10
+ "phlo>=0.16.0,<0.17",
11
11
  "psycopg2-binary>=2.9.11",
12
12
  ]
13
13
  description = "Postgres service plugin for Phlo"
14
14
  name = "phlo-postgres"
15
15
  requires-python = ">=3.11"
16
- version = "0.14.0"
16
+ version = "0.15.2"
17
17
 
18
18
  [[project.authors]]
19
19
  email = "team@phlo.dev"
@@ -15,6 +15,9 @@ Example:
15
15
 
16
16
  """
17
17
 
18
+ from importlib.metadata import version
19
+
20
+ from phlo_postgres.checkpoints import PostgresIngestionCheckpointStore
18
21
  from phlo_postgres.plugin import PostgresServicePlugin
19
22
  from phlo_postgres.publish_target import PostgresPublishTarget
20
23
  from phlo_postgres.resource import PostgresResource
@@ -22,6 +25,7 @@ from phlo_postgres.settings import PostgresSettings, get_settings
22
25
  from phlo_postgres.settings_store import PostgresSettingsStore
23
26
 
24
27
  __all__ = [
28
+ "PostgresIngestionCheckpointStore",
25
29
  "PostgresPublishTarget",
26
30
  "PostgresResource",
27
31
  "PostgresServicePlugin",
@@ -29,4 +33,6 @@ __all__ = [
29
33
  "PostgresSettingsStore",
30
34
  "get_settings",
31
35
  ]
32
- __version__ = "0.14.0"
36
+
37
+
38
+ __version__ = version("phlo-postgres")
@@ -1,4 +1,8 @@
1
- """phlo_postgres CLI authorization table."""
1
+ """Authorization surface table for the phlo-postgres CLI.
2
+
3
+ Declares which postgres commands mutate state plus their dataset resources and
4
+ required actions; the shared CLI surface adapter enforces these mappings.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
@@ -38,4 +42,5 @@ PostgresCliSurfaceAdapter = cli_surface_adapter_class(
38
42
 
39
43
 
40
44
  def get_postgres_cli_adapter() -> CliSurfaceAdapter:
45
+ """Return the shared Postgres CLI surface adapter instance."""
41
46
  return PostgresCliSurfaceAdapter.get_instance()
@@ -0,0 +1,309 @@
1
+ """Durable ingestion checkpoints backed by Phlo PostgreSQL.
2
+
3
+ Implements the neutral :class:`~phlo.capabilities.interfaces.IngestionCheckpointStore`
4
+ contract on the platform's existing PostgreSQL service so stream consumers
5
+ (Kafka today) can persist claimed offset ranges, bind them to output Iceberg
6
+ snapshots, and commit only after the snapshot is audited and promoted.
7
+
8
+ Claims are serialized per idempotency key with a PostgreSQL advisory
9
+ transaction lock plus a unique partial index, so two workers racing on the
10
+ same range cannot both hold an open claim.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ from phlo.capabilities.interfaces import (
19
+ CheckpointRecord,
20
+ IngestionCheckpointStore,
21
+ SourceOffsetRange,
22
+ )
23
+ from phlo.logging import get_logger
24
+ from phlo_postgres.resource import PostgresResource
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ _SCHEMA_NAME = "phlo"
29
+ _TABLE_NAME = "ingestion_checkpoints"
30
+ _OPEN_STATUSES = ("claimed", "staged", "failed")
31
+ _RESTAGABLE_STATUSES = ("claimed", "staged", "failed")
32
+
33
+ _DDL_STATEMENTS = (
34
+ f"CREATE SCHEMA IF NOT EXISTS {_SCHEMA_NAME}",
35
+ f"""
36
+ CREATE TABLE IF NOT EXISTS {_SCHEMA_NAME}.{_TABLE_NAME} (
37
+ checkpoint_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
38
+ source_id TEXT NOT NULL,
39
+ target_table TEXT NOT NULL,
40
+ status TEXT NOT NULL,
41
+ ranges JSONB NOT NULL DEFAULT '[]'::jsonb,
42
+ snapshot_id BIGINT,
43
+ release_id TEXT,
44
+ idempotency_key TEXT,
45
+ failure_reason TEXT,
46
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
47
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
48
+ )
49
+ """,
50
+ f"""
51
+ CREATE UNIQUE INDEX IF NOT EXISTS {_TABLE_NAME}_idempotency_key_idx
52
+ ON {_SCHEMA_NAME}.{_TABLE_NAME} (idempotency_key)
53
+ WHERE idempotency_key IS NOT NULL
54
+ """,
55
+ f"""
56
+ CREATE INDEX IF NOT EXISTS {_TABLE_NAME}_source_status_idx
57
+ ON {_SCHEMA_NAME}.{_TABLE_NAME} (source_id, status)
58
+ """,
59
+ )
60
+
61
+ _SELECT_COLUMNS = (
62
+ "checkpoint_id, source_id, target_table, status, ranges, snapshot_id, "
63
+ "release_id, idempotency_key, failure_reason, updated_at"
64
+ )
65
+
66
+
67
+ def _ranges_to_json(ranges: list[SourceOffsetRange]) -> list[dict[str, Any]]:
68
+ return [
69
+ {
70
+ "topic": item.topic,
71
+ "partition": item.partition,
72
+ "start_offset": item.start_offset,
73
+ "end_offset": item.end_offset,
74
+ }
75
+ for item in ranges
76
+ ]
77
+
78
+
79
+ def _ranges_from_json(raw: Any) -> tuple[SourceOffsetRange, ...]:
80
+ if not raw:
81
+ return ()
82
+ return tuple(
83
+ SourceOffsetRange(
84
+ topic=item["topic"],
85
+ partition=int(item["partition"]),
86
+ start_offset=int(item["start_offset"]),
87
+ end_offset=int(item["end_offset"]),
88
+ )
89
+ for item in raw
90
+ )
91
+
92
+
93
+ def _record_from_row(row: tuple) -> CheckpointRecord:
94
+ (
95
+ checkpoint_id,
96
+ source_id,
97
+ target_table,
98
+ status,
99
+ ranges,
100
+ snapshot_id,
101
+ release_id,
102
+ idempotency_key,
103
+ failure_reason,
104
+ updated_at,
105
+ ) = row
106
+ return CheckpointRecord(
107
+ checkpoint_id=str(checkpoint_id),
108
+ source_id=source_id,
109
+ target_table=target_table,
110
+ status=status,
111
+ ranges=_ranges_from_json(ranges),
112
+ snapshot_id=snapshot_id,
113
+ release_id=release_id,
114
+ idempotency_key=idempotency_key,
115
+ failure_reason=failure_reason,
116
+ updated_at=updated_at,
117
+ )
118
+
119
+
120
+ class PostgresIngestionCheckpointStore(IngestionCheckpointStore):
121
+ """PostgreSQL-backed durable checkpoint store for stream ingestion."""
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ resource: PostgresResource | None = None,
127
+ table_ensured: bool = False,
128
+ ) -> None:
129
+ self._resource = resource
130
+ self._table_ensured = table_ensured
131
+
132
+ def _ensure_resource(self) -> PostgresResource:
133
+ if self._resource is None:
134
+ self._resource = PostgresResource()
135
+ return self._resource
136
+
137
+ def _ensure_table(self) -> None:
138
+ if self._table_ensured:
139
+ return
140
+ resource = self._ensure_resource()
141
+ with resource.transactional_cursor() as cursor:
142
+ for statement in _DDL_STATEMENTS:
143
+ cursor.execute(statement)
144
+ self._table_ensured = True
145
+
146
+ def claim(
147
+ self,
148
+ *,
149
+ source_id: str,
150
+ target_table: str,
151
+ ranges: list[SourceOffsetRange],
152
+ idempotency_key: str | None = None,
153
+ ) -> CheckpointRecord:
154
+ """Record an exclusive claim on the supplied ranges.
155
+
156
+ Existing claims with the same idempotency key are returned unchanged
157
+ so a retry after a crash resumes the same checkpoint instead of
158
+ double-claiming the range.
159
+ """
160
+ self._ensure_table()
161
+ resource = self._ensure_resource()
162
+ lock_key = idempotency_key or f"{source_id}:{target_table}"
163
+ with resource.transactional_cursor() as cursor:
164
+ # The advisory lock serialises concurrent claims for the same key
165
+ # before the existence check, closing the check-then-insert race.
166
+ cursor.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (lock_key,))
167
+ if idempotency_key:
168
+ cursor.execute(
169
+ f"SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME} "
170
+ "WHERE idempotency_key = %s",
171
+ (idempotency_key,),
172
+ )
173
+ row = cursor.fetchone()
174
+ if row:
175
+ return _record_from_row(row)
176
+ cursor.execute(
177
+ f"""
178
+ INSERT INTO {_SCHEMA_NAME}.{_TABLE_NAME}
179
+ (source_id, target_table, status, ranges, idempotency_key)
180
+ VALUES (%s, %s, 'claimed', %s, %s)
181
+ RETURNING {_SELECT_COLUMNS}
182
+ """,
183
+ (
184
+ source_id,
185
+ target_table,
186
+ json.dumps(_ranges_to_json(ranges)),
187
+ idempotency_key,
188
+ ),
189
+ )
190
+ return _record_from_row(cursor.fetchone())
191
+
192
+ def record_snapshot(
193
+ self,
194
+ *,
195
+ checkpoint_id: str,
196
+ snapshot_id: int | str,
197
+ release_id: str | None = None,
198
+ ) -> CheckpointRecord:
199
+ """Bind the claimed ranges to the output snapshot that represents them.
200
+
201
+ A ``failed`` checkpoint may re-stage once its blocking issue (for
202
+ example a schema migration) is resolved; the failure reason is
203
+ cleared on the transition.
204
+ """
205
+ self._ensure_table()
206
+ resource = self._ensure_resource()
207
+ with resource.transactional_cursor() as cursor:
208
+ cursor.execute(
209
+ f"""
210
+ UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
211
+ SET status = 'staged', snapshot_id = %s, release_id = %s,
212
+ failure_reason = NULL, updated_at = NOW()
213
+ WHERE checkpoint_id = %s AND status IN %s
214
+ RETURNING {_SELECT_COLUMNS}
215
+ """,
216
+ (int(snapshot_id), release_id, checkpoint_id, _RESTAGABLE_STATUSES),
217
+ )
218
+ row = cursor.fetchone()
219
+ if row is None:
220
+ raise ValueError(
221
+ f"Checkpoint {checkpoint_id!r} is not open; refusing to bind a snapshot."
222
+ )
223
+ return _record_from_row(row)
224
+
225
+ def commit(self, *, checkpoint_id: str) -> CheckpointRecord:
226
+ """Mark a snapshot-bound checkpoint as durably committed."""
227
+ self._ensure_table()
228
+ resource = self._ensure_resource()
229
+ with resource.transactional_cursor() as cursor:
230
+ cursor.execute(
231
+ f"""
232
+ UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
233
+ SET status = 'committed', updated_at = NOW()
234
+ WHERE checkpoint_id = %s AND status = 'staged' AND snapshot_id IS NOT NULL
235
+ RETURNING {_SELECT_COLUMNS}
236
+ """,
237
+ (checkpoint_id,),
238
+ )
239
+ row = cursor.fetchone()
240
+ if row is None:
241
+ raise ValueError(
242
+ f"Checkpoint {checkpoint_id!r} has no audited snapshot; refusing to commit."
243
+ )
244
+ return _record_from_row(row)
245
+
246
+ def fail(self, *, checkpoint_id: str, reason: str) -> CheckpointRecord:
247
+ """Mark a checkpoint failed while retaining its claimed ranges."""
248
+ self._ensure_table()
249
+ resource = self._ensure_resource()
250
+ with resource.transactional_cursor() as cursor:
251
+ cursor.execute(
252
+ f"""
253
+ UPDATE {_SCHEMA_NAME}.{_TABLE_NAME}
254
+ SET status = 'failed', failure_reason = %s, updated_at = NOW()
255
+ WHERE checkpoint_id = %s AND status IN %s
256
+ RETURNING {_SELECT_COLUMNS}
257
+ """,
258
+ (reason, checkpoint_id, _OPEN_STATUSES),
259
+ )
260
+ row = cursor.fetchone()
261
+ if row is None:
262
+ raise ValueError(f"Checkpoint {checkpoint_id!r} is not open; cannot mark failed.")
263
+ return _record_from_row(row)
264
+
265
+ def latest_committed(self, *, source_id: str, target_table: str) -> CheckpointRecord | None:
266
+ """Return the newest committed checkpoint for one source/table pair."""
267
+ self._ensure_table()
268
+ resource = self._ensure_resource()
269
+ with resource.cursor() as cursor:
270
+ cursor.execute(
271
+ f"""
272
+ SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME}
273
+ WHERE source_id = %s AND target_table = %s AND status = 'committed'
274
+ ORDER BY updated_at DESC
275
+ LIMIT 1
276
+ """,
277
+ (source_id, target_table),
278
+ )
279
+ row = cursor.fetchone()
280
+ return _record_from_row(row) if row else None
281
+
282
+ def find_by_idempotency_key(self, *, idempotency_key: str) -> CheckpointRecord | None:
283
+ """Resolve an existing claim by its deterministic idempotency key."""
284
+ self._ensure_table()
285
+ resource = self._ensure_resource()
286
+ with resource.cursor() as cursor:
287
+ cursor.execute(
288
+ f"SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME} "
289
+ "WHERE idempotency_key = %s",
290
+ (idempotency_key,),
291
+ )
292
+ row = cursor.fetchone()
293
+ return _record_from_row(row) if row else None
294
+
295
+ def list_open(self, *, source_id: str) -> list[CheckpointRecord]:
296
+ """List claimed-but-uncommitted checkpoints needing reconciliation."""
297
+ self._ensure_table()
298
+ resource = self._ensure_resource()
299
+ with resource.cursor() as cursor:
300
+ cursor.execute(
301
+ f"""
302
+ SELECT {_SELECT_COLUMNS} FROM {_SCHEMA_NAME}.{_TABLE_NAME}
303
+ WHERE source_id = %s AND status IN %s
304
+ ORDER BY updated_at ASC
305
+ """,
306
+ (source_id, _OPEN_STATUSES),
307
+ )
308
+ rows = cursor.fetchall()
309
+ return [_record_from_row(row) for row in rows]
@@ -43,26 +43,10 @@ from phlo_postgres.settings import get_settings
43
43
 
44
44
 
45
45
  def _read_sql(*, query: str | None, file: Path | None) -> str:
46
- """Read SQL from inline query string or file path.
47
-
48
- Validates that exactly one of query or file is provided and returns the
49
- SQL content. Handles empty file detection and encoding issues.
50
-
51
- Args:
52
- query: Inline SQL query string.
53
- file: Path to SQL file to read.
54
-
55
- Returns:
56
- str: The SQL content to execute.
57
-
58
- Raises:
59
- click.ClickException: If both query and file are provided, neither is
60
- provided, or the file is empty/cannot be read.
61
-
62
- Example:
63
- >>> sql = _read_sql(query="SELECT 1")
64
- >>> sql = _read_sql(file=Path("query.sql"))
46
+ """Read SQL from an inline query string or file path.
65
47
 
48
+ Exactly one of ``query`` and ``file`` must be provided. Raise ClickException
49
+ when both or neither are given, or the file is empty or unreadable.
66
50
  """
67
51
  if query and file:
68
52
  raise exclusive_options_error("an inline query", "--file")
@@ -85,22 +69,14 @@ def _require_container_backend() -> None:
85
69
 
86
70
 
87
71
  def _postgres_exec_base(*, tty: bool) -> list[str]:
88
- """Build the docker compose exec base command for PostgreSQL container.
89
-
90
- Constructs the initial portion of the docker compose exec command including
91
- project name and service name. Used as a base for all container operations.
72
+ """Build the docker compose exec base command for the PostgreSQL container.
92
73
 
93
- Args:
94
- tty: Whether to allocate a TTY (-t flag). Disable for non-interactive
95
- commands that capture output.
96
-
97
- Returns:
98
- list[str]: Base command as a list of strings ready for subprocess.
74
+ Includes project and service names; ``tty=False`` drops the ``-t`` flag for
75
+ commands that capture output.
99
76
 
100
77
  Example:
101
78
  >>> cmd = _postgres_exec_base(tty=True)
102
79
  >>> # Returns: ['docker', 'compose', '-p', 'phlo', '-f', '...', 'exec', '-t', 'postgres']
103
-
104
80
  """
105
81
  phlo_dir = ensure_compose_project()
106
82
  project_name = get_project_name()
@@ -113,25 +89,13 @@ def _postgres_exec_base(*, tty: bool) -> list[str]:
113
89
 
114
90
 
115
91
  def _postgres_identity(*, user: str | None, database: str | None) -> tuple[str, str]:
116
- """Resolve PostgreSQL connection identity (user and database).
117
-
118
- Returns explicit values if provided, otherwise falls back to settings
119
- defaults. This allows CLI commands to use configured defaults while
120
- permitting overrides.
92
+ """Resolve PostgreSQL user and database, falling back to settings defaults.
121
93
 
122
- Args:
123
- user: Database username override, or None to use settings default.
124
- database: Database name override, or None to use settings default.
125
-
126
- Returns:
127
- tuple[str, str]: Tuple of (resolved_user, resolved_database).
94
+ Returns a ``(resolved_user, resolved_database)`` tuple.
128
95
 
129
96
  Example:
130
- >>> user, db = _postgres_identity(user=None, database=None)
131
- >>> # Uses settings.postgres_user and settings.postgres_db
132
97
  >>> user, db = _postgres_identity(user="admin", database=None)
133
98
  >>> # Uses "admin" for user, settings default for database
134
-
135
99
  """
136
100
  settings = get_settings()
137
101
  return user or settings.postgres_user, database or settings.postgres_db
@@ -146,21 +110,15 @@ def _postgres_identity(*, user: str | None, database: str | None) -> tuple[str,
146
110
  def postgres_group(ctx: click.Context, postgres_args: tuple[str, ...]) -> None:
147
111
  """Run psql or PostgreSQL helper commands against the project database.
148
112
 
149
- This is the main entry point for PostgreSQL CLI operations. It supports:
150
- - Interactive psql sessions (default if no subcommand)
151
- - Subcommands: query, dump, restore, vacuum
152
- - Direct psql arguments passthrough
153
-
154
- Args:
155
- ctx: Click context object.
156
- postgres_args: Additional arguments passed to psql or subcommands.
113
+ With no subcommand, opens an interactive psql session; subcommands cover
114
+ query, dump, restore, and vacuum, and remaining arguments pass through to
115
+ psql.
157
116
 
158
117
  Example:
159
118
  $ phlo postgres # Interactive psql
160
119
  $ phlo postgres -c "SELECT 1" # One-off query via psql
161
120
  $ phlo postgres query "SELECT * FROM users"
162
121
  $ phlo postgres dump --file backup.sql.gz
163
-
164
122
  """
165
123
  if postgres_args and postgres_args[0] == "query":
166
124
  postgres_query.main(
@@ -221,23 +179,12 @@ def postgres_query(
221
179
  ) -> None:
222
180
  """Execute a SQL query against the running PostgreSQL service.
223
181
 
224
- Executes a SQL query inside the PostgreSQL container and prints results
225
- to stdout. Supports inline queries or reading from a file.
226
-
227
- Args:
228
- query: SQL query string to execute.
229
- query_file: Path to file containing SQL query.
230
- user: Database user (default from settings).
231
- database: Database name (default from settings).
232
- timeout_seconds: Maximum time to wait for query completion.
233
-
234
- Raises:
235
- click.ClickException: If the query fails or times out.
182
+ Accepts inline SQL or a query file and prints results to stdout. Raise
183
+ ClickException when the query fails or times out.
236
184
 
237
185
  Example:
238
186
  $ phlo postgres query "SELECT * FROM users"
239
187
  $ phlo postgres query --file query.sql --timeout 60
240
-
241
188
  """
242
189
  _require_container_backend()
243
190
  enforce_surface_mutation_authorization("postgres.query", get_postgres_cli_adapter)
@@ -287,22 +234,12 @@ def postgres_dump(
287
234
  ) -> None:
288
235
  """Create a PostgreSQL logical backup (pg_dump) to a local file.
289
236
 
290
- Dumps the entire database using pg_dump, with optional gzip compression
291
- if the output file has a .gz extension.
292
-
293
- Args:
294
- output_file: Path to write the dump. Use .gz extension for compression.
295
- user: Database user (default from settings).
296
- database: Database name (default from settings).
297
- timeout_seconds: Maximum time to wait for dump completion.
298
-
299
- Raises:
300
- click.ClickException: If the dump fails or times out.
237
+ Dumps the entire database, gzip-compressing when the output file ends in
238
+ ``.gz``. Raise ClickException when the dump fails or times out.
301
239
 
302
240
  Example:
303
241
  $ phlo postgres dump --file backup.sql
304
242
  $ phlo postgres dump --file backup.sql.gz --timeout 300
305
-
306
243
  """
307
244
  _require_container_backend()
308
245
  enforce_surface_mutation_authorization("postgres.dump", get_postgres_cli_adapter)
@@ -360,25 +297,16 @@ def postgres_restore(
360
297
  ) -> None:
361
298
  """Restore a PostgreSQL database from a logical backup file.
362
299
 
363
- Restores the database from a SQL dump file (plain or gzip-compressed).
364
- Uses psql internally to execute the dump SQL.
300
+ Uses psql to execute a plain or gzip-compressed SQL dump.
365
301
 
366
302
  Warning:
367
303
  This may overwrite existing data. Use with caution on production databases.
368
304
 
369
- Args:
370
- input_file: Path to the dump file (.sql or .sql.gz).
371
- user: Database user (default from settings).
372
- database: Database name (default from settings).
373
- timeout_seconds: Maximum time to wait for restore completion.
374
-
375
- Raises:
376
- click.ClickException: If the restore fails or times out.
305
+ Raise ClickException when the restore fails or times out.
377
306
 
378
307
  Example:
379
308
  $ phlo postgres restore --file backup.sql
380
309
  $ phlo postgres restore --file backup.sql.gz --db mydb --timeout 600
381
-
382
310
  """
383
311
  _require_container_backend()
384
312
  enforce_surface_mutation_authorization("postgres.restore", get_postgres_cli_adapter)
@@ -427,23 +355,13 @@ def postgres_vacuum(
427
355
  ) -> None:
428
356
  """Run vacuumdb for PostgreSQL maintenance inside the container.
429
357
 
430
- Executes vacuumdb to reclaim storage and optionally update statistics.
431
- This is useful for routine database maintenance after large operations.
432
-
433
- Args:
434
- user: Database user (default from settings).
435
- database: Database name (default from settings).
436
- analyze: Whether to run ANALYZE after vacuum (updates statistics).
437
- timeout_seconds: Maximum time to wait for vacuum completion.
438
-
439
- Raises:
440
- click.ClickException: If vacuum fails or times out.
358
+ Reclaims storage and optionally ANALYZEs afterwards to refresh statistics.
359
+ Raise ClickException when vacuum fails or times out.
441
360
 
442
361
  Example:
443
362
  $ phlo postgres vacuum
444
363
  $ phlo postgres vacuum --no-analyze
445
364
  $ phlo postgres vacuum --db analytics --timeout 300
446
-
447
365
  """
448
366
  _require_container_backend()
449
367
  enforce_surface_mutation_authorization("postgres.vacuum", get_postgres_cli_adapter)
@@ -0,0 +1,24 @@
1
+ """Postgres CLI plugin registration.
2
+
3
+ Exposes the postgres command group as a cli-command plugin so the
4
+ phlo-postgres package contributes its commands through plugin discovery
5
+ rather than a core-CLI import.
6
+ Loaded through the phlo plugin entry-point mechanism at startup rather than
7
+ imported directly; exposes the command group from phlo_postgres.cli.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ from phlo.plugins.base import cli_command_plugin_class
14
+
15
+ from phlo_postgres.cli import postgres_group
16
+
17
+
18
+ PostgresCliPlugin = cli_command_plugin_class(
19
+ "PostgresCliPlugin",
20
+ name="postgres",
21
+ version="0.1.0",
22
+ description="CLI commands for PostgreSQL service access",
23
+ commands=[postgres_group],
24
+ )