dbx-tools-postgres 0.6.78__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import math
7
+ import os
8
+ import platform
9
+ import re
10
+ import socket
11
+ import uuid
12
+ from collections.abc import Awaitable, Callable, Mapping
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Protocol, TypeAlias
16
+
17
+ from dbx_tools.core import fnv_hash, to_identifier, to_stable_key
18
+ from sqlalchemy import text
19
+ from sqlalchemy.ext.asyncio import AsyncEngine
20
+
21
+ SerializableValue: TypeAlias = (
22
+ str | int | float | bool | None | list["SerializableValue"] | dict[str, "SerializableValue"]
23
+ )
24
+ TopicMetadata: TypeAlias = dict[str, SerializableValue]
25
+ TopicListener: TypeAlias = Callable[["TopicMessage"], Awaitable[None] | None]
26
+ TopicMetadataProvider: TypeAlias = Callable[[], Awaitable[TopicMetadata] | TopicMetadata]
27
+
28
+ _DEFAULT_CHANNEL = "dbx_tools_topic_bus"
29
+ _MAX_CHANNEL_LENGTH = 63
30
+ _CHANNEL_HASH_LENGTH = 6
31
+ _CHANNEL_FALLBACK = "bus"
32
+ _MAX_NOTIFY_BYTES = 7_900
33
+ _MIN_RECONNECT_DELAY = 0.25
34
+ _MAX_RECONNECT_DELAY = 5.0
35
+
36
+
37
+ class AsyncEngineLike(Protocol):
38
+ def begin(self) -> Any: ...
39
+
40
+ async def raw_connection(self) -> Any: ...
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class TopicPublishInput:
45
+ type: str
46
+ body: SerializableValue
47
+ metadata: TopicMetadata = field(default_factory=dict)
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class TopicMessage:
52
+ id: str
53
+ topic: str
54
+ type: str
55
+ metadata: TopicMetadata
56
+ body: SerializableValue
57
+ published_at: str
58
+
59
+ @property
60
+ def publishedAt(self) -> str:
61
+ return self.published_at
62
+
63
+ def as_dict(self) -> dict[str, SerializableValue]:
64
+ return {
65
+ "id": self.id,
66
+ "topic": self.topic,
67
+ "type": self.type,
68
+ "metadata": self.metadata,
69
+ "body": self.body,
70
+ "publishedAt": self.published_at,
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class PostgresTopicBusOptions:
76
+ channel: object = _DEFAULT_CHANNEL
77
+ metadata: TopicMetadata | TopicMetadataProvider | None = None
78
+ on_error: Callable[[BaseException], None] | None = None
79
+
80
+
81
+ class PostgresTopicBus:
82
+ def __init__(
83
+ self,
84
+ engine: AsyncEngine | AsyncEngineLike,
85
+ options: PostgresTopicBusOptions | None = None,
86
+ *,
87
+ channel: object | None = None,
88
+ metadata: TopicMetadata | TopicMetadataProvider | None = None,
89
+ on_error: Callable[[BaseException], None] | None = None,
90
+ ) -> None:
91
+ configured = options or PostgresTopicBusOptions()
92
+ channel_value = configured.channel if channel is None else channel
93
+ self.engine = engine
94
+ self.channel_name = channel_name(channel_value)
95
+ self.metadata = configured.metadata if metadata is None else metadata
96
+ self.on_error = on_error or configured.on_error or (lambda error: None)
97
+ self._listeners: dict[str, set[TopicListener]] = {}
98
+ self._raw_connection: Any | None = None
99
+ self._driver_connection: Any | None = None
100
+ self._start_lock = asyncio.Lock()
101
+ self._reconnect_task: asyncio.Task[None] | None = None
102
+ self._closed = False
103
+
104
+ @property
105
+ def channelName(self) -> str:
106
+ return self.channel_name
107
+
108
+ async def start(self) -> None:
109
+ if self._driver_connection is not None:
110
+ return
111
+ if self._closed:
112
+ raise RuntimeError("Postgres topic bus is closed")
113
+ async with self._start_lock:
114
+ if self._driver_connection is not None:
115
+ return
116
+ raw_connection = await self.engine.raw_connection()
117
+ driver_connection = raw_connection.driver_connection
118
+ try:
119
+ await driver_connection.add_listener(self.channel_name, self._handle_notification)
120
+ add_termination_listener = getattr(
121
+ driver_connection, "add_termination_listener", None
122
+ )
123
+ if add_termination_listener:
124
+ add_termination_listener(self._handle_termination)
125
+ except BaseException:
126
+ await _maybe_await(raw_connection.close())
127
+ raise
128
+ if self._closed:
129
+ await driver_connection.remove_listener(
130
+ self.channel_name,
131
+ self._handle_notification,
132
+ )
133
+ await _maybe_await(raw_connection.close())
134
+ raise RuntimeError("Postgres topic bus is closed")
135
+ self._raw_connection = raw_connection
136
+ self._driver_connection = driver_connection
137
+
138
+ async def broadcast(
139
+ self,
140
+ topic: str,
141
+ message_input: TopicPublishInput | Mapping[str, Any],
142
+ ) -> TopicMessage:
143
+ if not topic.strip():
144
+ raise TypeError("Topic must not be empty")
145
+ if self._closed:
146
+ raise RuntimeError("Postgres topic bus is closed")
147
+ publish = _publish_input(message_input)
148
+ if not publish.type.strip():
149
+ raise TypeError("Message type must not be empty")
150
+ if not _is_serializable(publish.metadata) or not _is_serializable(publish.body):
151
+ raise TypeError("Message metadata and body must be JSON serializable without coercion")
152
+ automatic = await self._resolve_metadata()
153
+ message = TopicMessage(
154
+ id=str(uuid.uuid4()),
155
+ topic=topic,
156
+ type=publish.type,
157
+ metadata={**automatic, **publish.metadata},
158
+ body=publish.body,
159
+ published_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
160
+ )
161
+ encoded = json.dumps(message.as_dict(), separators=(",", ":"), allow_nan=False)
162
+ if len(encoded.encode("utf-8")) > _MAX_NOTIFY_BYTES:
163
+ raise ValueError(f"Postgres notification exceeds {_MAX_NOTIFY_BYTES} bytes")
164
+ async with self.engine.begin() as connection:
165
+ await connection.execute(
166
+ text("SELECT pg_notify(:channel, :payload)"),
167
+ {"channel": self.channel_name, "payload": encoded},
168
+ )
169
+ return message
170
+
171
+ async def listen(self, topic: str, listener: TopicListener) -> Callable[[], Awaitable[None]]:
172
+ if not topic.strip():
173
+ raise TypeError("Topic must not be empty")
174
+ await self.start()
175
+ listeners = self._listeners.setdefault(topic, set())
176
+ listeners.add(listener)
177
+
178
+ async def unsubscribe() -> None:
179
+ listeners.discard(listener)
180
+ if not listeners:
181
+ self._listeners.pop(topic, None)
182
+
183
+ return unsubscribe
184
+
185
+ async def close(self) -> None:
186
+ if self._closed:
187
+ return
188
+ self._closed = True
189
+ if self._reconnect_task:
190
+ self._reconnect_task.cancel()
191
+ await asyncio.gather(self._reconnect_task, return_exceptions=True)
192
+ self._reconnect_task = None
193
+ raw_connection = self._raw_connection
194
+ driver_connection = self._driver_connection
195
+ self._raw_connection = None
196
+ self._driver_connection = None
197
+ self._listeners.clear()
198
+ if driver_connection is not None:
199
+ remove_termination_listener = getattr(
200
+ driver_connection,
201
+ "remove_termination_listener",
202
+ None,
203
+ )
204
+ if remove_termination_listener:
205
+ remove_termination_listener(self._handle_termination)
206
+ try:
207
+ await driver_connection.remove_listener(
208
+ self.channel_name,
209
+ self._handle_notification,
210
+ )
211
+ except Exception as error:
212
+ self.on_error(error)
213
+ if raw_connection is not None:
214
+ await _maybe_await(raw_connection.close())
215
+
216
+ async def _resolve_metadata(self) -> TopicMetadata:
217
+ configured = self.metadata() if callable(self.metadata) else (self.metadata or {})
218
+ if inspect.isawaitable(configured):
219
+ configured = await configured
220
+ if not _is_serializable(configured) or not isinstance(configured, dict):
221
+ raise TypeError("Bus metadata must be JSON serializable without coercion")
222
+ return {**_machine_metadata(), **configured}
223
+
224
+ def _handle_notification(
225
+ self,
226
+ connection: object,
227
+ process_id: int,
228
+ channel: str,
229
+ payload: str,
230
+ ) -> None:
231
+ del connection, process_id
232
+ if channel != self.channel_name:
233
+ return
234
+ message = _decode(payload)
235
+ if message is None:
236
+ return
237
+ for listener in tuple(self._listeners.get(message.topic, ())):
238
+ asyncio.create_task(self._deliver(listener, message))
239
+
240
+ async def _deliver(self, listener: TopicListener, message: TopicMessage) -> None:
241
+ try:
242
+ result = listener(message)
243
+ if inspect.isawaitable(result):
244
+ await result
245
+ except Exception as error:
246
+ self.on_error(error)
247
+
248
+ def _handle_termination(self, connection: object) -> None:
249
+ del connection
250
+ if self._closed or not self._listeners or self._reconnect_task is not None:
251
+ return
252
+ raw_connection = self._raw_connection
253
+ self._raw_connection = None
254
+ self._driver_connection = None
255
+ self._reconnect_task = asyncio.create_task(self._reconnect(raw_connection))
256
+
257
+ async def _reconnect(self, raw_connection: object | None = None) -> None:
258
+ delay = 0.0
259
+ try:
260
+ if raw_connection is not None:
261
+ try:
262
+ await _maybe_await(raw_connection.close())
263
+ except Exception as error:
264
+ self.on_error(error)
265
+ while not self._closed and self._listeners:
266
+ if delay:
267
+ await asyncio.sleep(delay)
268
+ try:
269
+ await self.start()
270
+ return
271
+ except Exception as error:
272
+ if self._closed:
273
+ return
274
+ self.on_error(error)
275
+ delay = (
276
+ _MIN_RECONNECT_DELAY if delay == 0 else min(delay * 2, _MAX_RECONNECT_DELAY)
277
+ )
278
+ finally:
279
+ self._reconnect_task = None
280
+
281
+
282
+ def channel_name(channel: object = _DEFAULT_CHANNEL) -> str:
283
+ parts = list(channel) if isinstance(channel, (list, tuple)) else [channel]
284
+ stable = "\0".join(to_stable_key(part) for part in parts)
285
+ suffix = fnv_hash(stable, length=_CHANNEL_HASH_LENGTH)
286
+ labels = [part for part in parts if isinstance(part, (str, int, float, bool))]
287
+ body = to_identifier(*labels, delimiter="_")[: _MAX_CHANNEL_LENGTH - len(suffix) - 1].rstrip(
288
+ "_"
289
+ )
290
+ prefix = body if re.match(r"^[A-Za-z_]", body) else f"{_CHANNEL_FALLBACK}_{body}"
291
+ return re.sub(r"_+", "_", f"{prefix}_{suffix}")
292
+
293
+
294
+ def _publish_input(value: TopicPublishInput | Mapping[str, Any]) -> TopicPublishInput:
295
+ if isinstance(value, TopicPublishInput):
296
+ return value
297
+ return TopicPublishInput(
298
+ type=value.get("type", ""),
299
+ metadata=dict(value.get("metadata") or {}),
300
+ body=value.get("body"),
301
+ )
302
+
303
+
304
+ def _decode(payload: str) -> TopicMessage | None:
305
+ try:
306
+ value = json.loads(payload)
307
+ except (TypeError, json.JSONDecodeError):
308
+ return None
309
+ if not isinstance(value, dict) or not _is_serializable(value):
310
+ return None
311
+ required = ("id", "topic", "type", "metadata", "body", "publishedAt")
312
+ if not all(key in value for key in required) or not isinstance(value["metadata"], dict):
313
+ return None
314
+ if not all(isinstance(value[key], str) for key in ("id", "topic", "type", "publishedAt")):
315
+ return None
316
+ return TopicMessage(
317
+ id=value["id"],
318
+ topic=value["topic"],
319
+ type=value["type"],
320
+ metadata=value["metadata"],
321
+ body=value["body"],
322
+ published_at=value["publishedAt"],
323
+ )
324
+
325
+
326
+ def _machine_metadata() -> TopicMetadata:
327
+ values: dict[str, SerializableValue | None] = {
328
+ "project": _first_env(
329
+ "DATABRICKS_APP_NAME",
330
+ "DATABRICKS_BUNDLE_NAME",
331
+ "PROJECT_NAME",
332
+ ),
333
+ "hostname": socket.gethostname(),
334
+ "platform": platform.system().lower(),
335
+ "environment": _env("PYTHON_ENV") or _env("NODE_ENV"),
336
+ "appName": _env("DATABRICKS_APP_NAME"),
337
+ "deploymentId": _env("DATABRICKS_APP_DEPLOYMENT_ID"),
338
+ "databricksHost": _env("DATABRICKS_HOST"),
339
+ }
340
+ return {key: value for key, value in values.items() if value is not None}
341
+
342
+
343
+ def _env(name: str) -> str | None:
344
+ value = os.environ.get(name)
345
+ return value.strip() if value and value.strip() else None
346
+
347
+
348
+ def _first_env(*names: str) -> str | None:
349
+ return next((value for name in names if (value := _env(name))), None)
350
+
351
+
352
+ def _is_serializable(value: object, seen: set[int] | None = None) -> bool:
353
+ if value is None or isinstance(value, (str, bool, int)):
354
+ return True
355
+ if isinstance(value, float):
356
+ return math.isfinite(value)
357
+ seen = seen or set()
358
+ identity = id(value)
359
+ if identity in seen:
360
+ return False
361
+ if isinstance(value, list):
362
+ seen.add(identity)
363
+ try:
364
+ return all(_is_serializable(item, seen) for item in value)
365
+ finally:
366
+ seen.remove(identity)
367
+ if isinstance(value, dict) and all(isinstance(key, str) for key in value):
368
+ seen.add(identity)
369
+ try:
370
+ return all(_is_serializable(item, seen) for item in value.values())
371
+ finally:
372
+ seen.remove(identity)
373
+ return False
374
+
375
+
376
+ async def _maybe_await(value: object) -> object:
377
+ return await value if inspect.isawaitable(value) else value
378
+
379
+
380
+ channelName = channel_name
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.3
2
+ Name: dbx-tools-postgres
3
+ Version: 0.6.78
4
+ Summary: WorkspaceClient-backed Lakebase Postgres resolution, SQLAlchemy engines, advisory locks, and LISTEN/NOTIFY topic bus
5
+ Requires-Dist: asyncpg>=0.30,<1
6
+ Requires-Dist: databricks-sdk>=0.63.0,<1
7
+ Requires-Dist: greenlet>=3.2,<4
8
+ Requires-Dist: dbx-tools-core==0.6.78
9
+ Requires-Dist: psycopg[binary]>=3.2.9,<4
10
+ Requires-Dist: sqlalchemy>=2.0.41,<3
11
+ Requires-Python: >=3.10
12
+ Project-URL: Source, https://github.com/reggie-db/dbx-tools/tree/main/packages/py/postgres
13
+ Description-Content-Type: text/markdown
14
+
15
+ # `dbx-tools-postgres`
16
+
17
+ Python Lakebase/Postgres connection setup, advisory locks, and topic fan-out for
18
+ services that already hold a Databricks `WorkspaceClient`. This package is
19
+ currently an unpublished workspace package, and is the Python counterpart to
20
+ `@dbx-tools/postgres` plus `@dbx-tools/appkit`'s address parsing.
21
+
22
+ Install directly from this monorepo:
23
+
24
+ ```bash
25
+ pip install "dbx-tools-postgres @ git+https://github.com/reggie-db/dbx-tools.git@main#subdirectory=packages/py/postgres"
26
+ ```
27
+
28
+ Key features:
29
+
30
+ - accepts the same Postgres URI, Lakebase resource path, hostname, and project-id
31
+ address shapes as `@dbx-tools/appkit`;
32
+ - resolves missing autoscaling endpoint fields through
33
+ `WorkspaceClient.api_client`;
34
+ - resolves provisioned Lakebase instance DNS through
35
+ `WorkspaceClient.database`;
36
+ - injects a fresh database credential on SQLAlchemy's `do_connect` event rather
37
+ than storing an expiring password in the engine URL, using the SDK's
38
+ provisioned-instance API or the Autoscaling `/postgres/credentials` endpoint;
39
+ - supports sync psycopg and asyncpg SQLAlchemy engines;
40
+ - derives advisory-lock ids from the same stable structured keys as the Node
41
+ package and holds one checked-out connection for the full critical section;
42
+ - provides blocking and try-lock context managers for session and transaction
43
+ locks, with sync and async SQLAlchemy variants;
44
+ - fans messages out to every process on a channel with `PostgresTopicBus`, using
45
+ the same lifecycle and wire envelope as the Node package.
46
+
47
+ ```python
48
+ from databricks.sdk import WorkspaceClient
49
+ from dbx_tools.postgres import PostgresEngineConfig, create_async_engine
50
+
51
+ engine = create_async_engine(
52
+ WorkspaceClient(),
53
+ PostgresEngineConfig(instance_name="my-lakebase", database="databricks_postgres"),
54
+ pool_pre_ping=True,
55
+ pool_recycle=1800,
56
+ )
57
+ ```
58
+
59
+ Pass `credential_provider=` to either engine factory to inject credentials from
60
+ another source while retaining the same connect-time rotation behavior.
61
+
62
+ ```python
63
+ from dbx_tools.postgres import advisory_transaction_lock
64
+
65
+ with advisory_transaction_lock(engine, ["schema-install", "v2"]) as connection:
66
+ connection.exec_driver_sql("CREATE TABLE IF NOT EXISTS ...")
67
+ ```
68
+
69
+ ## Topic bus
70
+
71
+ `PostgresTopicBus` is async Postgres topic fan-out built on `LISTEN`/`NOTIFY`. Its
72
+ public lifecycle and wire shape match `@dbx-tools/postgres`'s `PostgresTopicBus`,
73
+ so Node and Python services can share a channel:
74
+
75
+ - `PostgresTopicBus(engine, options)`;
76
+ - `channelName`;
77
+ - `await start()`;
78
+ - `await broadcast(topic, TopicPublishInput(...))`;
79
+ - `await listen(topic, listener)` returning an async unsubscribe function;
80
+ - `await close()`;
81
+ - envelope fields `id`, `topic`, `type`, `metadata`, `body`, and `publishedAt`.
82
+
83
+ Channel derivation ports the Node stable-key and FNV rules, so equivalent channel
84
+ parts resolve to the same PostgreSQL identifier in Python and Node.
85
+
86
+ ```python
87
+ from dbx_tools.postgres import PostgresTopicBus, TopicPublishInput
88
+
89
+ bus = PostgresTopicBus(engine, channel=["billing", "production"])
90
+
91
+ unsubscribe = await bus.listen("invoice.updated", handle_invoice)
92
+ await bus.broadcast(
93
+ "invoice.updated",
94
+ TopicPublishInput(type="invoice.updated", body={"invoice_id": "inv-7"}),
95
+ )
96
+ ```
97
+
98
+ Delivery is live and unstored, like PostgreSQL `LISTEN`/`NOTIFY` itself. Use a
99
+ table or queue when consumers need replay or acknowledgements.
100
+
101
+ ## Databricks notebooks and Spark
102
+
103
+ Verified end to end against a Lakebase endpoint on serverless notebook compute.
104
+ [`packages/example/notebooks/bus-lakebase.py`](../../example/notebooks/bus-lakebase.py)
105
+ is the runnable version of everything below.
106
+
107
+ Two things about the Databricks Python runtime change how the bus is called, and
108
+ neither is a limitation of the bus itself:
109
+
110
+ - **A notebook kernel already runs an event loop**, so `asyncio.run` in a cell
111
+ raises `RuntimeError: asyncio.run() cannot be called from a running event
112
+ loop`. Drive the coroutine on a short-lived thread with its own loop rather
113
+ than reaching for `nest_asyncio` — the bus holds a dedicated `LISTEN`
114
+ connection bound to whichever loop started it, so one loop per bus lifetime is
115
+ the invariant to preserve.
116
+ - **Install with `%pip install`, not `pip install --target`.** A `--target`
117
+ install leaves the runtime's preloaded `typing_extensions` ahead of the new
118
+ one on `sys.path`, and importing `dbx_tools.postgres` then fails with
119
+ `ImportError: cannot import name 'TypeAliasType'`. `%pip` restarts the Python
120
+ process, which resolves it.
121
+
122
+ ### Publishing from a Spark UDF
123
+
124
+ Publishing from executors works. Listening from them does not, and should not be
125
+ attempted: a UDF invocation is short-lived, while `listen` keeps a connection
126
+ open until `close`.
127
+
128
+ Executors have no Databricks credentials, so they cannot build a
129
+ `WorkspaceClient`. This is where connect-time credential injection pays off — the
130
+ driver mints the Lakebase token once and the UDF closes over it, so the executor
131
+ builds a plain SQLAlchemy engine and installs the token as its provider:
132
+
133
+ ```python
134
+ from sqlalchemy import URL
135
+ from sqlalchemy.ext.asyncio import create_async_engine
136
+ from dbx_tools.postgres import (
137
+ PostgresTopicBus,
138
+ TopicPublishInput,
139
+ install_credential_injection,
140
+ )
141
+
142
+ # driver: resolve once, capture in the closure
143
+ resolved = resolve_postgres_connection(workspace_client, config)
144
+ token = workspace_client.api_client.do(
145
+ "POST",
146
+ "/api/2.0/postgres/credentials",
147
+ body={"endpoint": resolved.endpoint},
148
+ )["token"]
149
+
150
+
151
+ @udf(returnType=StringType())
152
+ def publish(key: str) -> str:
153
+ async def run() -> str:
154
+ engine = create_async_engine(
155
+ URL.create(
156
+ "postgresql+asyncpg",
157
+ username=resolved.user,
158
+ host=resolved.host,
159
+ port=resolved.port,
160
+ database=resolved.database,
161
+ query={"ssl": resolved.ssl_mode},
162
+ )
163
+ )
164
+ install_credential_injection(engine.sync_engine, lambda: token)
165
+ bus = PostgresTopicBus(engine, channel="app-events")
166
+ try:
167
+ message = await bus.broadcast(
168
+ "row.processed", TopicPublishInput(type="row.processed", body={"key": key})
169
+ )
170
+ return message.id
171
+ finally:
172
+ await bus.close()
173
+ await engine.dispose()
174
+
175
+ return asyncio.run(run())
176
+ ```
177
+
178
+ Constraints worth knowing before this reaches production:
179
+
180
+ - A captured token EXPIRES (about an hour). Re-mint per job run; a long-running
181
+ streaming query needs a provider that refreshes instead of a captured string.
182
+ - Each UDF call opens and closes its own connection, so batch the publish at
183
+ partition scope (`mapInPandas`, `foreachPartition`) rather than per row.
184
+ - `spark.sparkContext.broadcast` is unavailable on serverless (Spark Connect).
185
+ A plain closure over driver-side values serializes with the UDF and is enough.
186
+ - Delivery stays live and unstored: if no listener is connected when the UDF
187
+ publishes, the message is gone. Write to a table when executors produce
188
+ results a consumer must not miss.
@@ -0,0 +1,8 @@
1
+ dbx_tools/postgres/__init__.py,sha256=Qb2fH8vxKBpEfuLL2wlgh6_5qS-LMrK2hYByjDczrf4,2780
2
+ dbx_tools/postgres/address.py,sha256=ulOuhMUzYeb5ZslYqe0rTNXqDfovk8QR5s8U6Aej20k,3729
3
+ dbx_tools/postgres/advisory_lock.py,sha256=1Lfp2UOoQ4vJvLOfg47hsKVJpIrfeRByGoAbyw2WMYA,7655
4
+ dbx_tools/postgres/engine.py,sha256=QqR49JVTTfvW-hoxiW08vSKfptClsyD4WsSkM08hcaA,15290
5
+ dbx_tools/postgres/topic_bus.py,sha256=Zr0mtHXr1Gv-cdOW-j7mVwQMZsqjMcVyM-SysjpyNIk,13731
6
+ dbx_tools_postgres-0.6.78.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
7
+ dbx_tools_postgres-0.6.78.dist-info/METADATA,sha256=5IVKuaMhXQas5iHTHvzpktxmRNnlpApXIJqLazALD44,7623
8
+ dbx_tools_postgres-0.6.78.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any