dbx-tools-postgres 0.6.211__tar.gz → 0.6.213__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.
- dbx_tools_postgres-0.6.213/LICENSE +202 -0
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/PKG-INFO +6 -4
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/pyproject.toml +5 -3
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/pyproject.toml.orig +4 -2
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/__init__.py +2 -0
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/address.py +7 -2
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/advisory_lock.py +59 -0
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/engine.py +66 -2
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/topic_bus.py +71 -0
- {dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/README.md +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: dbx-tools-postgres
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.213
|
|
4
4
|
Summary: WorkspaceClient-backed Lakebase Postgres resolution, SQLAlchemy engines, advisory locks, and LISTEN/NOTIFY topic bus
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
5
7
|
Requires-Dist: asyncpg>=0.30
|
|
6
8
|
Requires-Dist: databricks-sdk>=0.63.0
|
|
7
9
|
Requires-Dist: greenlet>=3.2
|
|
8
10
|
Requires-Dist: psycopg[binary]>=3.2.9
|
|
9
11
|
Requires-Dist: sqlalchemy>=2.0.41
|
|
10
|
-
Requires-Dist: dbx-tools-core==0.6.
|
|
11
|
-
Requires-Dist: dbx-tools-core-rs==0.6.
|
|
12
|
+
Requires-Dist: dbx-tools-core==0.6.213
|
|
13
|
+
Requires-Dist: dbx-tools-core-rs==0.6.213
|
|
12
14
|
Requires-Python: >=3.11, <4
|
|
13
15
|
Project-URL: Source, https://github.com/reggie-db/dbx-tools/tree/main/packages/py/postgres
|
|
14
16
|
Description-Content-Type: text/markdown
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dbx-tools-postgres"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.213"
|
|
4
4
|
description = "WorkspaceClient-backed Lakebase Postgres resolution, SQLAlchemy engines, advisory locks, and LISTEN/NOTIFY topic bus"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11,<4"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
7
9
|
dependencies = [
|
|
8
10
|
"asyncpg>=0.30",
|
|
9
11
|
"databricks-sdk>=0.63.0",
|
|
10
12
|
"greenlet>=3.2",
|
|
11
13
|
"psycopg[binary]>=3.2.9",
|
|
12
14
|
"sqlalchemy>=2.0.41",
|
|
13
|
-
"dbx-tools-core==0.6.
|
|
14
|
-
"dbx-tools-core-rs==0.6.
|
|
15
|
+
"dbx-tools-core==0.6.213",
|
|
16
|
+
"dbx-tools-core-rs==0.6.213",
|
|
15
17
|
]
|
|
16
18
|
|
|
17
19
|
[project.urls]
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[project]
|
|
4
4
|
name = "dbx-tools-postgres"
|
|
5
|
-
version = "0.6.
|
|
5
|
+
version = "0.6.213"
|
|
6
6
|
description = "WorkspaceClient-backed Lakebase Postgres resolution, SQLAlchemy engines, advisory locks, and LISTEN/NOTIFY topic bus"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.11,<4"
|
|
9
|
-
|
|
9
|
+
license = "Apache-2.0"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
dependencies = ["asyncpg>=0.30", "databricks-sdk>=0.63.0", "greenlet>=3.2", "psycopg[binary]>=3.2.9", "sqlalchemy>=2.0.41", "dbx-tools-core==0.6.213", "dbx-tools-core-rs==0.6.213"]
|
|
10
12
|
|
|
11
13
|
[project.urls]
|
|
12
14
|
Source = "https://github.com/reggie-db/dbx-tools/tree/main/packages/py/postgres"
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Lakebase address types and parsers backed by the Databricks Rust bindings."""
|
|
2
|
+
|
|
1
3
|
from typing import Literal, TypeAlias
|
|
2
4
|
|
|
3
5
|
from dbx_tools.core_rs.bindings import (
|
|
@@ -9,11 +11,14 @@ from dbx_tools.core_rs.bindings import (
|
|
|
9
11
|
SslMode as NativeSslMode,
|
|
10
12
|
)
|
|
11
13
|
|
|
12
|
-
"""Lakebase address types and parsers backed by the Databricks Rust bindings."""
|
|
13
|
-
|
|
14
14
|
SslMode = Literal["require", "disable", "prefer"]
|
|
15
|
+
"""Accepted PostgreSQL SSL modes."""
|
|
16
|
+
|
|
15
17
|
SSL_MODES: tuple[SslMode, ...] = ("require", "disable", "prefer")
|
|
18
|
+
"""All accepted values for :class:`SslMode`."""
|
|
19
|
+
|
|
16
20
|
LakebaseConnectionInputs: TypeAlias = ParsedAddress
|
|
21
|
+
"""Resolved address inputs returned by the native parser."""
|
|
17
22
|
|
|
18
23
|
parseAddress = parse_address
|
|
19
24
|
parseResourcePath = parse_resource_path
|
{dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/advisory_lock.py
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Stable Postgres advisory-lock ids and connection-owning context managers."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import hashlib
|
|
@@ -14,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
|
|
|
14
16
|
|
|
15
17
|
@dataclass(frozen=True, slots=True)
|
|
16
18
|
class ExplicitAdvisoryLockId:
|
|
19
|
+
"""A caller-supplied 64-bit lock id that bypasses stable-key hashing."""
|
|
20
|
+
|
|
17
21
|
value: int
|
|
18
22
|
|
|
19
23
|
|
|
@@ -28,6 +32,14 @@ class AsyncQueryable(Protocol):
|
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
def advisory_lock_id(key: object) -> int:
|
|
35
|
+
"""Return the deterministic signed 64-bit Postgres lock id for ``key``.
|
|
36
|
+
|
|
37
|
+
Lists and tuples are treated as ordered composite keys. Other values are
|
|
38
|
+
canonicalized with :func:`dbx_tools.core.to_stable_key`, joined with a null
|
|
39
|
+
separator, and hashed with SHA-256. ``ExplicitAdvisoryLockId`` values are
|
|
40
|
+
normalized directly into Postgres's signed 64-bit range.
|
|
41
|
+
"""
|
|
42
|
+
|
|
31
43
|
if isinstance(key, ExplicitAdvisoryLockId):
|
|
32
44
|
return _signed_64(key.value)
|
|
33
45
|
parts = key if isinstance(key, (list, tuple)) else [key]
|
|
@@ -36,6 +48,8 @@ def advisory_lock_id(key: object) -> int:
|
|
|
36
48
|
|
|
37
49
|
|
|
38
50
|
def explicit_advisory_lock_id(value: int) -> ExplicitAdvisoryLockId:
|
|
51
|
+
"""Mark ``value`` as an explicit Postgres lock id instead of a value to hash."""
|
|
52
|
+
|
|
39
53
|
return ExplicitAdvisoryLockId(value)
|
|
40
54
|
|
|
41
55
|
|
|
@@ -46,6 +60,14 @@ def acquire_advisory_lock(
|
|
|
46
60
|
transaction: bool = False,
|
|
47
61
|
wait: bool = True,
|
|
48
62
|
) -> bool:
|
|
63
|
+
"""Acquire a session or transaction advisory lock on ``connection``.
|
|
64
|
+
|
|
65
|
+
The supplied connection owns the lock. With ``wait=True`` this blocks and
|
|
66
|
+
returns ``True`` after acquisition. With ``wait=False`` it returns whether
|
|
67
|
+
the lock was acquired. Transaction locks are released by transaction end;
|
|
68
|
+
session locks must be released on the same connection.
|
|
69
|
+
"""
|
|
70
|
+
|
|
49
71
|
function = _function_name(transaction=transaction, wait=wait)
|
|
50
72
|
result = connection.execute(
|
|
51
73
|
text(f"SELECT {function}(:lock_id)"), {"lock_id": advisory_lock_id(key)}
|
|
@@ -60,6 +82,11 @@ async def acquire_advisory_lock_async(
|
|
|
60
82
|
transaction: bool = False,
|
|
61
83
|
wait: bool = True,
|
|
62
84
|
) -> bool:
|
|
85
|
+
"""Asynchronously acquire a session or transaction lock on ``connection``.
|
|
86
|
+
|
|
87
|
+
Ownership and ``wait`` behavior match :func:`acquire_advisory_lock`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
63
90
|
function = _function_name(transaction=transaction, wait=wait)
|
|
64
91
|
result = await connection.execute(
|
|
65
92
|
text(f"SELECT {function}(:lock_id)"), {"lock_id": advisory_lock_id(key)}
|
|
@@ -68,6 +95,12 @@ async def acquire_advisory_lock_async(
|
|
|
68
95
|
|
|
69
96
|
|
|
70
97
|
def release_advisory_lock(connection: SyncQueryable, key: object) -> None:
|
|
98
|
+
"""Release a session lock on its owning connection.
|
|
99
|
+
|
|
100
|
+
Raises ``RuntimeError`` when the connection does not hold the derived lock.
|
|
101
|
+
Transaction-scoped locks release automatically and should not use this API.
|
|
102
|
+
"""
|
|
103
|
+
|
|
71
104
|
lock_id = advisory_lock_id(key)
|
|
72
105
|
result = connection.execute(text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id})
|
|
73
106
|
if result.scalar_one() is not True:
|
|
@@ -75,6 +108,8 @@ def release_advisory_lock(connection: SyncQueryable, key: object) -> None:
|
|
|
75
108
|
|
|
76
109
|
|
|
77
110
|
async def release_advisory_lock_async(connection: AsyncQueryable, key: object) -> None:
|
|
111
|
+
"""Asynchronously release a session lock on its owning connection."""
|
|
112
|
+
|
|
78
113
|
lock_id = advisory_lock_id(key)
|
|
79
114
|
result = await connection.execute(
|
|
80
115
|
text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id}
|
|
@@ -85,6 +120,13 @@ async def release_advisory_lock_async(connection: AsyncQueryable, key: object) -
|
|
|
85
120
|
|
|
86
121
|
@contextmanager
|
|
87
122
|
def advisory_lock(engine: Engine, key: object) -> Iterator[Connection]:
|
|
123
|
+
"""Hold a blocking session lock on one owned connection for the context.
|
|
124
|
+
|
|
125
|
+
The checked-out connection is yielded to the caller and retained until the
|
|
126
|
+
lock is released. If body and unlock both fail, the unlock failure is added
|
|
127
|
+
as a note to the original exception.
|
|
128
|
+
"""
|
|
129
|
+
|
|
88
130
|
with engine.connect() as connection:
|
|
89
131
|
acquire_advisory_lock(connection, key)
|
|
90
132
|
failure: Exception | None = None
|
|
@@ -103,6 +145,8 @@ def advisory_lock(engine: Engine, key: object) -> Iterator[Connection]:
|
|
|
103
145
|
|
|
104
146
|
@contextmanager
|
|
105
147
|
def try_advisory_lock(engine: Engine, key: object) -> Iterator[Connection | None]:
|
|
148
|
+
"""Try to hold a session lock, yielding ``None`` without waiting on contention."""
|
|
149
|
+
|
|
106
150
|
with engine.connect() as connection:
|
|
107
151
|
if not acquire_advisory_lock(connection, key, wait=False):
|
|
108
152
|
yield None
|
|
@@ -123,6 +167,11 @@ def try_advisory_lock(engine: Engine, key: object) -> Iterator[Connection | None
|
|
|
123
167
|
|
|
124
168
|
@contextmanager
|
|
125
169
|
def advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Connection]:
|
|
170
|
+
"""Hold a blocking transaction lock for an owned transaction context.
|
|
171
|
+
|
|
172
|
+
The lock is released automatically when the context commits or rolls back.
|
|
173
|
+
"""
|
|
174
|
+
|
|
126
175
|
with engine.begin() as connection:
|
|
127
176
|
acquire_advisory_lock(connection, key, transaction=True)
|
|
128
177
|
yield connection
|
|
@@ -130,6 +179,8 @@ def advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Connectio
|
|
|
130
179
|
|
|
131
180
|
@contextmanager
|
|
132
181
|
def try_advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Connection | None]:
|
|
182
|
+
"""Try to hold a transaction lock, yielding ``None`` without waiting."""
|
|
183
|
+
|
|
133
184
|
with engine.begin() as connection:
|
|
134
185
|
if not acquire_advisory_lock(connection, key, transaction=True, wait=False):
|
|
135
186
|
yield None
|
|
@@ -139,6 +190,8 @@ def try_advisory_transaction_lock(engine: Engine, key: object) -> Iterator[Conne
|
|
|
139
190
|
|
|
140
191
|
@asynccontextmanager
|
|
141
192
|
async def advisory_lock_async(engine: AsyncEngine, key: object) -> AsyncIterator[AsyncConnection]:
|
|
193
|
+
"""Asynchronously hold a session lock on one owned connection for the context."""
|
|
194
|
+
|
|
142
195
|
async with engine.connect() as connection:
|
|
143
196
|
await acquire_advisory_lock_async(connection, key)
|
|
144
197
|
failure: Exception | None = None
|
|
@@ -159,6 +212,8 @@ async def advisory_lock_async(engine: AsyncEngine, key: object) -> AsyncIterator
|
|
|
159
212
|
async def try_advisory_lock_async(
|
|
160
213
|
engine: AsyncEngine, key: object
|
|
161
214
|
) -> AsyncIterator[AsyncConnection | None]:
|
|
215
|
+
"""Asynchronously try a session lock, yielding ``None`` without waiting."""
|
|
216
|
+
|
|
162
217
|
async with engine.connect() as connection:
|
|
163
218
|
if not await acquire_advisory_lock_async(connection, key, wait=False):
|
|
164
219
|
yield None
|
|
@@ -181,6 +236,8 @@ async def try_advisory_lock_async(
|
|
|
181
236
|
async def advisory_transaction_lock_async(
|
|
182
237
|
engine: AsyncEngine, key: object
|
|
183
238
|
) -> AsyncIterator[AsyncConnection]:
|
|
239
|
+
"""Asynchronously hold a transaction lock until commit or rollback."""
|
|
240
|
+
|
|
184
241
|
async with engine.begin() as connection:
|
|
185
242
|
await acquire_advisory_lock_async(connection, key, transaction=True)
|
|
186
243
|
yield connection
|
|
@@ -190,6 +247,8 @@ async def advisory_transaction_lock_async(
|
|
|
190
247
|
async def try_advisory_transaction_lock_async(
|
|
191
248
|
engine: AsyncEngine, key: object
|
|
192
249
|
) -> AsyncIterator[AsyncConnection | None]:
|
|
250
|
+
"""Asynchronously try a transaction lock, yielding ``None`` without waiting."""
|
|
251
|
+
|
|
193
252
|
async with engine.begin() as connection:
|
|
194
253
|
if not await acquire_advisory_lock_async(connection, key, transaction=True, wait=False):
|
|
195
254
|
yield None
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Lakebase connection resolution and connect-time credential injection."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import datetime as dt
|
|
@@ -22,10 +24,11 @@ from .address import (
|
|
|
22
24
|
parse_resource_path,
|
|
23
25
|
)
|
|
24
26
|
|
|
25
|
-
"""Lakebase connection resolution and connect-time credential injection."""
|
|
26
|
-
|
|
27
27
|
CredentialProvider = Callable[[], str]
|
|
28
|
+
"""Return a current database credential for a new physical connection."""
|
|
29
|
+
|
|
28
30
|
CredentialLoader = Callable[[], tuple[str, dt.datetime | None]]
|
|
31
|
+
"""Load a database credential and its optional absolute expiration time."""
|
|
29
32
|
|
|
30
33
|
_API_BASE = "/api/2.0/postgres"
|
|
31
34
|
_CREDENTIAL_REFRESH_LEAD = dt.timedelta(minutes=5)
|
|
@@ -42,6 +45,8 @@ class WorkspaceApiClient(Protocol):
|
|
|
42
45
|
|
|
43
46
|
|
|
44
47
|
class WorkspaceClientLike(Protocol):
|
|
48
|
+
"""WorkspaceClient surface required for Lakebase discovery and credentials."""
|
|
49
|
+
|
|
45
50
|
api_client: WorkspaceApiClient
|
|
46
51
|
config: Any
|
|
47
52
|
current_user: Any
|
|
@@ -50,6 +55,14 @@ class WorkspaceClientLike(Protocol):
|
|
|
50
55
|
|
|
51
56
|
@dataclass(frozen=True, slots=True)
|
|
52
57
|
class PostgresEngineConfig:
|
|
58
|
+
"""Explicit Lakebase/Postgres connection inputs.
|
|
59
|
+
|
|
60
|
+
Explicit fields take precedence over parsed address values and environment
|
|
61
|
+
variables. Missing autoscaling project, branch, endpoint, host, database,
|
|
62
|
+
and user values may be discovered through the supplied ``WorkspaceClient``.
|
|
63
|
+
``instance_name`` selects provisioned Lakebase discovery and credentials.
|
|
64
|
+
"""
|
|
65
|
+
|
|
53
66
|
address: str | None = None
|
|
54
67
|
instance_name: str | None = None
|
|
55
68
|
project: str | None = None
|
|
@@ -64,6 +77,12 @@ class PostgresEngineConfig:
|
|
|
64
77
|
|
|
65
78
|
@dataclass(frozen=True, slots=True)
|
|
66
79
|
class ResolvedPostgresConnection:
|
|
80
|
+
"""Concrete connection coordinates with no embedded password.
|
|
81
|
+
|
|
82
|
+
``project``, ``branch``, ``endpoint``, and ``instance_name`` retain the
|
|
83
|
+
resolved Lakebase resource identity used to choose a credential provider.
|
|
84
|
+
"""
|
|
85
|
+
|
|
67
86
|
host: str
|
|
68
87
|
database: str
|
|
69
88
|
user: str
|
|
@@ -75,6 +94,8 @@ class ResolvedPostgresConnection:
|
|
|
75
94
|
instance_name: str | None = None
|
|
76
95
|
|
|
77
96
|
def url(self, drivername: str) -> URL:
|
|
97
|
+
"""Build a SQLAlchemy URL using the driver's expected SSL query key."""
|
|
98
|
+
|
|
78
99
|
ssl_parameter = "ssl" if drivername.endswith("+asyncpg") else "sslmode"
|
|
79
100
|
return URL.create(
|
|
80
101
|
drivername,
|
|
@@ -113,6 +134,13 @@ def workspace_credential_provider(
|
|
|
113
134
|
workspace_client: WorkspaceClientLike,
|
|
114
135
|
instance_name: str,
|
|
115
136
|
) -> CredentialProvider:
|
|
137
|
+
"""Create a cached provisioned-Lakebase credential provider.
|
|
138
|
+
|
|
139
|
+
The provider mints through ``WorkspaceClient.database`` and caches the token
|
|
140
|
+
until five minutes before its reported expiry. Refresh uses process-local
|
|
141
|
+
check-lock-check serialization so concurrent pool connects share one mint.
|
|
142
|
+
"""
|
|
143
|
+
|
|
116
144
|
if not instance_name.strip():
|
|
117
145
|
raise ValueError("instance_name must not be empty")
|
|
118
146
|
|
|
@@ -133,6 +161,12 @@ def autoscaling_credential_provider(
|
|
|
133
161
|
workspace_client: WorkspaceClientLike,
|
|
134
162
|
endpoint: str,
|
|
135
163
|
) -> CredentialProvider:
|
|
164
|
+
"""Create a cached Autoscaling-Lakebase endpoint credential provider.
|
|
165
|
+
|
|
166
|
+
Tokens come from ``POST /api/2.0/postgres/credentials`` and use the same
|
|
167
|
+
process-local early-refresh and refresh-lock policy as provisioned tokens.
|
|
168
|
+
"""
|
|
169
|
+
|
|
136
170
|
if not endpoint.strip():
|
|
137
171
|
raise ValueError("endpoint must not be empty")
|
|
138
172
|
|
|
@@ -152,6 +186,13 @@ def autoscaling_credential_provider(
|
|
|
152
186
|
|
|
153
187
|
|
|
154
188
|
def install_credential_injection(engine: Engine, provider: CredentialProvider) -> None:
|
|
189
|
+
"""Install ``provider`` on SQLAlchemy's physical ``do_connect`` boundary.
|
|
190
|
+
|
|
191
|
+
The password is requested for every new DBAPI connection and never stored in
|
|
192
|
+
the engine URL. The caller owns caching and refresh when supplying a custom
|
|
193
|
+
provider.
|
|
194
|
+
"""
|
|
195
|
+
|
|
155
196
|
@event.listens_for(engine, "do_connect")
|
|
156
197
|
def provide_token(
|
|
157
198
|
dialect: Any,
|
|
@@ -171,6 +212,14 @@ def create_engine(
|
|
|
171
212
|
drivername: str = "postgresql+psycopg",
|
|
172
213
|
**engine_options: Any,
|
|
173
214
|
) -> Engine:
|
|
215
|
+
"""Create a synchronous SQLAlchemy engine with connect-time credentials.
|
|
216
|
+
|
|
217
|
+
Connection coordinates are resolved once. The built-in provider is selected
|
|
218
|
+
from the resolved provisioned instance or Autoscaling endpoint and caches
|
|
219
|
+
credentials; a caller-supplied provider owns its complete lifecycle.
|
|
220
|
+
Additional keyword arguments pass directly to SQLAlchemy.
|
|
221
|
+
"""
|
|
222
|
+
|
|
174
223
|
resolved = resolve_postgres_connection(workspace_client, config)
|
|
175
224
|
engine = sqlalchemy_create_engine(resolved.url(drivername), **engine_options)
|
|
176
225
|
provider = credential_provider or _default_provider(workspace_client, resolved)
|
|
@@ -186,6 +235,13 @@ def create_async_engine(
|
|
|
186
235
|
drivername: str = "postgresql+asyncpg",
|
|
187
236
|
**engine_options: Any,
|
|
188
237
|
) -> AsyncEngine:
|
|
238
|
+
"""Create an async SQLAlchemy engine with connect-time credentials.
|
|
239
|
+
|
|
240
|
+
Credential injection is installed on the async engine's underlying sync
|
|
241
|
+
engine. Resolution, provider ownership, and keyword forwarding match
|
|
242
|
+
:func:`create_engine`.
|
|
243
|
+
"""
|
|
244
|
+
|
|
189
245
|
resolved = resolve_postgres_connection(workspace_client, config)
|
|
190
246
|
engine = sqlalchemy_create_async_engine(resolved.url(drivername), **engine_options)
|
|
191
247
|
provider = credential_provider or _default_provider(workspace_client, resolved)
|
|
@@ -199,6 +255,14 @@ def resolve_postgres_connection(
|
|
|
199
255
|
*,
|
|
200
256
|
environ: Mapping[str, str] | None = None,
|
|
201
257
|
) -> ResolvedPostgresConnection:
|
|
258
|
+
"""Resolve complete Postgres coordinates without minting credentials.
|
|
259
|
+
|
|
260
|
+
Values are layered from explicit config, parsed address/resource inputs,
|
|
261
|
+
``LAKEBASE_*`` and ``PG*`` environment variables, then Workspace APIs for
|
|
262
|
+
missing Lakebase resources. Ambiguous discovery and unresolved host or user
|
|
263
|
+
values raise ``ValueError``.
|
|
264
|
+
"""
|
|
265
|
+
|
|
202
266
|
config = config or PostgresEngineConfig()
|
|
203
267
|
env = os.environ if environ is None else environ
|
|
204
268
|
raw_address = _first(config.address, config.endpoint, env.get("LAKEBASE_ENDPOINT"))
|
{dbx_tools_postgres-0.6.211 → dbx_tools_postgres-0.6.213}/src/dbx_tools/postgres/topic_bus.py
RENAMED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Live Postgres LISTEN/NOTIFY topic fan-out with Node-compatible envelopes."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import asyncio
|
|
@@ -21,9 +23,16 @@ from sqlalchemy.ext.asyncio import AsyncEngine
|
|
|
21
23
|
SerializableValue: TypeAlias = (
|
|
22
24
|
str | int | float | bool | None | list["SerializableValue"] | dict[str, "SerializableValue"]
|
|
23
25
|
)
|
|
26
|
+
"""A JSON value accepted without coercion by the topic-bus wire format."""
|
|
27
|
+
|
|
24
28
|
TopicMetadata: TypeAlias = dict[str, SerializableValue]
|
|
29
|
+
"""JSON metadata merged into a published topic message."""
|
|
30
|
+
|
|
25
31
|
TopicListener: TypeAlias = Callable[["TopicMessage"], Awaitable[None] | None]
|
|
32
|
+
"""Synchronous or asynchronous callback for one topic message."""
|
|
33
|
+
|
|
26
34
|
TopicMetadataProvider: TypeAlias = Callable[[], Awaitable[TopicMetadata] | TopicMetadata]
|
|
35
|
+
"""Synchronous or asynchronous provider evaluated for each broadcast."""
|
|
27
36
|
|
|
28
37
|
_DEFAULT_CHANNEL = "dbx_tools_topic_bus"
|
|
29
38
|
_MAX_CHANNEL_LENGTH = 63
|
|
@@ -42,6 +51,12 @@ class AsyncEngineLike(Protocol):
|
|
|
42
51
|
|
|
43
52
|
@dataclass(frozen=True, slots=True)
|
|
44
53
|
class TopicPublishInput:
|
|
54
|
+
"""Caller-supplied message type, body, and per-message metadata.
|
|
55
|
+
|
|
56
|
+
Per-message metadata overrides configured and machine metadata with the same
|
|
57
|
+
key. The complete payload must remain JSON serializable without coercion.
|
|
58
|
+
"""
|
|
59
|
+
|
|
45
60
|
type: str
|
|
46
61
|
body: SerializableValue
|
|
47
62
|
metadata: TopicMetadata = field(default_factory=dict)
|
|
@@ -49,6 +64,8 @@ class TopicPublishInput:
|
|
|
49
64
|
|
|
50
65
|
@dataclass(frozen=True, slots=True)
|
|
51
66
|
class TopicMessage:
|
|
67
|
+
"""Immutable Node-compatible topic message returned and delivered by the bus."""
|
|
68
|
+
|
|
52
69
|
id: str
|
|
53
70
|
topic: str
|
|
54
71
|
type: str
|
|
@@ -58,9 +75,13 @@ class TopicMessage:
|
|
|
58
75
|
|
|
59
76
|
@property
|
|
60
77
|
def publishedAt(self) -> str:
|
|
78
|
+
"""Return the wire-compatible camelCase publication timestamp."""
|
|
79
|
+
|
|
61
80
|
return self.published_at
|
|
62
81
|
|
|
63
82
|
def as_dict(self) -> dict[str, SerializableValue]:
|
|
83
|
+
"""Return the JSON wire envelope with a ``publishedAt`` field."""
|
|
84
|
+
|
|
64
85
|
return {
|
|
65
86
|
"id": self.id,
|
|
66
87
|
"topic": self.topic,
|
|
@@ -73,12 +94,28 @@ class TopicMessage:
|
|
|
73
94
|
|
|
74
95
|
@dataclass(frozen=True, slots=True)
|
|
75
96
|
class PostgresTopicBusOptions:
|
|
97
|
+
"""Channel identity, automatic metadata, and asynchronous error handling.
|
|
98
|
+
|
|
99
|
+
``metadata`` may be a static mapping or a provider evaluated for each
|
|
100
|
+
broadcast. ``on_error`` receives listener, reconnect, and cleanup failures
|
|
101
|
+
that cannot be raised back through the originating callback.
|
|
102
|
+
"""
|
|
103
|
+
|
|
76
104
|
channel: object = _DEFAULT_CHANNEL
|
|
77
105
|
metadata: TopicMetadata | TopicMetadataProvider | None = None
|
|
78
106
|
on_error: Callable[[BaseException], None] | None = None
|
|
79
107
|
|
|
80
108
|
|
|
81
109
|
class PostgresTopicBus:
|
|
110
|
+
"""Publish and receive live topic messages over Postgres LISTEN/NOTIFY.
|
|
111
|
+
|
|
112
|
+
The caller owns the SQLAlchemy engine. Listening lazily checks out one
|
|
113
|
+
dedicated raw connection that remains bound to the event loop until
|
|
114
|
+
:meth:`close`; broadcasts use independent transaction-scoped engine
|
|
115
|
+
connections. A terminated listener connection reconnects with bounded
|
|
116
|
+
exponential delay while listeners remain. Delivery is live and unstored.
|
|
117
|
+
"""
|
|
118
|
+
|
|
82
119
|
def __init__(
|
|
83
120
|
self,
|
|
84
121
|
engine: AsyncEngine | AsyncEngineLike,
|
|
@@ -103,9 +140,17 @@ class PostgresTopicBus:
|
|
|
103
140
|
|
|
104
141
|
@property
|
|
105
142
|
def channelName(self) -> str:
|
|
143
|
+
"""Return the Node-compatible camelCase channel name property."""
|
|
144
|
+
|
|
106
145
|
return self.channel_name
|
|
107
146
|
|
|
108
147
|
async def start(self) -> None:
|
|
148
|
+
"""Start the dedicated LISTEN connection once.
|
|
149
|
+
|
|
150
|
+
Calls are idempotent while active and serialized across concurrent
|
|
151
|
+
callers. A bus cannot be restarted after :meth:`close`.
|
|
152
|
+
"""
|
|
153
|
+
|
|
109
154
|
if self._driver_connection is not None:
|
|
110
155
|
return
|
|
111
156
|
if self._closed:
|
|
@@ -140,6 +185,13 @@ class PostgresTopicBus:
|
|
|
140
185
|
topic: str,
|
|
141
186
|
message_input: TopicPublishInput | Mapping[str, Any],
|
|
142
187
|
) -> TopicMessage:
|
|
188
|
+
"""Publish one validated message and return its complete wire envelope.
|
|
189
|
+
|
|
190
|
+
Broadcasting does not require or create the dedicated LISTEN connection.
|
|
191
|
+
The encoded notification must fit within the package's conservative
|
|
192
|
+
7,900-byte Postgres payload limit.
|
|
193
|
+
"""
|
|
194
|
+
|
|
143
195
|
if not topic.strip():
|
|
144
196
|
raise TypeError("Topic must not be empty")
|
|
145
197
|
if self._closed:
|
|
@@ -169,6 +221,13 @@ class PostgresTopicBus:
|
|
|
169
221
|
return message
|
|
170
222
|
|
|
171
223
|
async def listen(self, topic: str, listener: TopicListener) -> Callable[[], Awaitable[None]]:
|
|
224
|
+
"""Register ``listener`` and return an asynchronous unsubscribe callback.
|
|
225
|
+
|
|
226
|
+
The first listener starts the dedicated connection. Unsubscribing removes
|
|
227
|
+
only this callback; call :meth:`close` to release the LISTEN connection.
|
|
228
|
+
Listener failures are reported through ``on_error``.
|
|
229
|
+
"""
|
|
230
|
+
|
|
172
231
|
if not topic.strip():
|
|
173
232
|
raise TypeError("Topic must not be empty")
|
|
174
233
|
await self.start()
|
|
@@ -183,6 +242,11 @@ class PostgresTopicBus:
|
|
|
183
242
|
return unsubscribe
|
|
184
243
|
|
|
185
244
|
async def close(self) -> None:
|
|
245
|
+
"""Permanently stop reconnecting, clear listeners, and close LISTEN state.
|
|
246
|
+
|
|
247
|
+
The operation is idempotent and does not dispose the caller-owned engine.
|
|
248
|
+
"""
|
|
249
|
+
|
|
186
250
|
if self._closed:
|
|
187
251
|
return
|
|
188
252
|
self._closed = True
|
|
@@ -280,6 +344,13 @@ class PostgresTopicBus:
|
|
|
280
344
|
|
|
281
345
|
|
|
282
346
|
def channel_name(channel: object = _DEFAULT_CHANNEL) -> str:
|
|
347
|
+
"""Derive a stable PostgreSQL channel identifier from one or more values.
|
|
348
|
+
|
|
349
|
+
The readable prefix is normalized and truncated, then suffixed with the same
|
|
350
|
+
stable-key FNV hash used by the Node package. The result fits PostgreSQL's
|
|
351
|
+
63-byte identifier limit and remains deterministic across runtimes.
|
|
352
|
+
"""
|
|
353
|
+
|
|
283
354
|
parts = list(channel) if isinstance(channel, (list, tuple)) else [channel]
|
|
284
355
|
stable = "\0".join(to_stable_key(part) for part in parts)
|
|
285
356
|
suffix = fnv_hash(stable, length=_CHANNEL_HASH_LENGTH)
|
|
File without changes
|