macss-modular-api-postgres 0.4.7__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,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: macss-modular-api-postgres
3
+ Version: 0.4.7
4
+ Summary: Official MACSS Postgres integration package for Python.
5
+ Author: ccisne.dev
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/macss-dev/modular_api
8
+ Project-URL: Repository, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres
9
+ Project-URL: Issues, https://github.com/macss-dev/modular_api/issues
10
+ Project-URL: Documentation, https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres#readme
11
+ Keywords: macss,postgres,database
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+
25
+ # macss-modular-api-postgres
26
+
27
+ Official MACSS Postgres integration package for Python.
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ from modular_api_postgres import DbClient, DbCommand, DbCommandKind, DbConnectionSettings
33
+
34
+ settings = DbConnectionSettings.from_environment()
35
+
36
+ client = DbClient(
37
+ settings=settings,
38
+ session_provider=my_session_provider,
39
+ command_executor=my_command_executor,
40
+ transaction_runner=my_transaction_runner,
41
+ )
42
+
43
+ result = client.scalar(
44
+ DbCommand(
45
+ kind=DbCommandKind.SCALAR,
46
+ text="select count(*) from users",
47
+ label="users.count",
48
+ )
49
+ )
50
+
51
+ if result.is_success:
52
+ print(result.value.value)
53
+ else:
54
+ print(result.failure.message)
55
+ ```
56
+
57
+ See [example/example.py](example/example.py) for a complete in-memory wiring sample.
58
+
59
+ ## Current slice
60
+
61
+ - normalized Postgres connection defaults and redacted summaries
62
+ - engine-agnostic `DbClient`, `DbRepository`, and transaction contracts
63
+ - explicit lease ownership semantics for package-owned and application-owned sessions
64
+ - health contributor and GraphQL support bundle for higher-level integrations
65
+ - real driver bindings intentionally remain outside this first slice
@@ -0,0 +1,6 @@
1
+ modular_api_postgres/__init__.py,sha256=URrLgEvTQBn48LkyY7aJsz5vzwC38lEo_AMgF1JY0a4,1089
2
+ modular_api_postgres/db_client.py,sha256=ixskWFwjikbIV_OsHCncYTjbKZTR4ZQdtbLD1ErmN0A,12959
3
+ macss_modular_api_postgres-0.4.7.dist-info/METADATA,sha256=j4IyD8lHfFMi_0Gynd5fU_soWWWfxyV_mNN7ylC7DZU,2196
4
+ macss_modular_api_postgres-0.4.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ macss_modular_api_postgres-0.4.7.dist-info/top_level.txt,sha256=256SRpi7HI1fM37vDEnZVT6An9ivmo9YcA7TJN1Dmuk,21
6
+ macss_modular_api_postgres-0.4.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ modular_api_postgres
@@ -0,0 +1,53 @@
1
+ """Public package exports for modular_api_postgres."""
2
+
3
+ from .db_client import (
4
+ DbClient,
5
+ DbCommand,
6
+ DbCommandExecutor,
7
+ DbCommandKind,
8
+ DbConnectionSettings,
9
+ DbExecutionMetadata,
10
+ DbExecutionSummary,
11
+ DbFailure,
12
+ DbFailureKind,
13
+ DbGraphqlSupport,
14
+ DbHealthContributor,
15
+ DbHealthReport,
16
+ DbHealthStatus,
17
+ DbProviderDescription,
18
+ DbRepository,
19
+ DbRepositoryContext,
20
+ DbResult,
21
+ DbRowSet,
22
+ DbScalar,
23
+ DbSessionLease,
24
+ DbSessionProvider,
25
+ DbTransactionContext,
26
+ DbTransactionRunner,
27
+ )
28
+
29
+ __all__ = [
30
+ "DbClient",
31
+ "DbCommand",
32
+ "DbCommandExecutor",
33
+ "DbCommandKind",
34
+ "DbConnectionSettings",
35
+ "DbExecutionMetadata",
36
+ "DbExecutionSummary",
37
+ "DbFailure",
38
+ "DbFailureKind",
39
+ "DbGraphqlSupport",
40
+ "DbHealthContributor",
41
+ "DbHealthReport",
42
+ "DbHealthStatus",
43
+ "DbProviderDescription",
44
+ "DbRepository",
45
+ "DbRepositoryContext",
46
+ "DbResult",
47
+ "DbRowSet",
48
+ "DbScalar",
49
+ "DbSessionLease",
50
+ "DbSessionProvider",
51
+ "DbTransactionContext",
52
+ "DbTransactionRunner",
53
+ ]
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Callable, Generic, Mapping, Protocol, TypeVar, cast
8
+
9
+ S = TypeVar("S")
10
+ T = TypeVar("T")
11
+ R = TypeVar("R")
12
+ _MISSING = object()
13
+
14
+
15
+ class DbCommandKind(str, Enum):
16
+ QUERY = "query"
17
+ EXECUTE = "execute"
18
+ BATCH = "batch"
19
+ SCALAR = "scalar"
20
+
21
+
22
+ class DbFailureKind(str, Enum):
23
+ CONNECTIVITY = "connectivity"
24
+ TIMEOUT = "timeout"
25
+ AUTHENTICATION = "authentication"
26
+ AUTHORIZATION = "authorization"
27
+ CONSTRAINT = "constraint"
28
+ CONFLICT = "conflict"
29
+ NOT_FOUND = "not_found"
30
+ SERIALIZATION = "serialization"
31
+ CANCELLED = "cancelled"
32
+ UNKNOWN = "unknown"
33
+
34
+
35
+ class DbHealthStatus(str, Enum):
36
+ HEALTHY = "healthy"
37
+ UNHEALTHY = "unhealthy"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class DbConnectionSettings:
42
+ host: str
43
+ port: int
44
+ database: str
45
+ username: str
46
+ password: str
47
+ ssl_mode: str
48
+ options: Mapping[str, object] = field(default_factory=dict)
49
+
50
+ @classmethod
51
+ def from_environment(
52
+ cls,
53
+ environment: Mapping[str, str] | None = None,
54
+ ) -> DbConnectionSettings:
55
+ values = os.environ if environment is None else environment
56
+ raw_port = values.get("MODULAR_API_POSTGRES_PORT", "")
57
+
58
+ try:
59
+ port = int(raw_port)
60
+ except ValueError:
61
+ port = 5432
62
+
63
+ return cls(
64
+ host=values.get("MODULAR_API_POSTGRES_HOST", "127.0.0.1"),
65
+ port=port,
66
+ database=values.get("MODULAR_API_POSTGRES_DATABASE", "modular_api_graphql_v1"),
67
+ username=values.get("MODULAR_API_POSTGRES_USERNAME", "postgres"),
68
+ password=values.get("MODULAR_API_POSTGRES_PASSWORD", "postgres"),
69
+ ssl_mode=values.get("MODULAR_API_POSTGRES_SSLMODE", "disable"),
70
+ )
71
+
72
+ @property
73
+ def engine_id(self) -> str:
74
+ return "postgres"
75
+
76
+ @property
77
+ def redacted_summary(self) -> str:
78
+ return (
79
+ f"{self.engine_id}://{self.username}@{self.host}:{self.port}/"
80
+ f"{self.database}?sslmode={self.ssl_mode}"
81
+ )
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class DbCommand:
86
+ kind: DbCommandKind
87
+ text: str
88
+ parameters: tuple[object, ...] = field(default_factory=tuple)
89
+ label: str | None = None
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class DbExecutionMetadata:
94
+ duration: int
95
+ command_label: str | None = None
96
+ row_count: int | None = None
97
+ affected_count: int | None = None
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class DbRowSet:
102
+ rows: list[Mapping[str, object]]
103
+ metadata: DbExecutionMetadata
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class DbExecutionSummary:
108
+ affected_count: int
109
+ metadata: DbExecutionMetadata
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class DbScalar(Generic[T]):
114
+ value: T
115
+ metadata: DbExecutionMetadata
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class DbFailure:
120
+ kind: DbFailureKind
121
+ code: str
122
+ message: str
123
+ retryable: bool
124
+ transient: bool
125
+ details: object | None = None
126
+ cause_summary: str | None = None
127
+
128
+
129
+ class DbResult(Generic[T]):
130
+ def __init__(self, value: object = _MISSING, failure: DbFailure | None = None) -> None:
131
+ self._value = value
132
+ self._failure = failure
133
+
134
+ @classmethod
135
+ def success(cls, value: T) -> DbResult[T]:
136
+ return cls(value=value)
137
+
138
+ @classmethod
139
+ def from_failure(cls, failure: DbFailure) -> DbResult[T]:
140
+ return cls(failure=failure)
141
+
142
+ @property
143
+ def is_success(self) -> bool:
144
+ return self._failure is None
145
+
146
+ @property
147
+ def is_failure(self) -> bool:
148
+ return self._failure is not None
149
+
150
+ @property
151
+ def value(self) -> T:
152
+ if self._failure is not None or self._value is _MISSING:
153
+ raise RuntimeError("DbResult does not contain a success value.")
154
+ return cast(T, self._value)
155
+
156
+ @property
157
+ def failure(self) -> DbFailure:
158
+ if self._failure is None:
159
+ raise RuntimeError("DbResult does not contain a failure value.")
160
+ return self._failure
161
+
162
+ def map(self, transform: Callable[[T], R]) -> DbResult[R]:
163
+ if self.is_failure:
164
+ return DbResult.from_failure(self.failure)
165
+ return DbResult.success(transform(self.value))
166
+
167
+ def flat_map(self, transform: Callable[[T], DbResult[R]]) -> DbResult[R]:
168
+ if self.is_failure:
169
+ return DbResult.from_failure(self.failure)
170
+ return transform(self.value)
171
+
172
+ def map_failure(self, transform: Callable[[DbFailure], DbFailure]) -> DbResult[T]:
173
+ if self.is_success:
174
+ return DbResult.success(self.value)
175
+ return DbResult.from_failure(transform(self.failure))
176
+
177
+ def get_or_throw(self, message: str | None = None) -> T:
178
+ if self.is_failure:
179
+ raise RuntimeError(message or self.failure.message)
180
+ return self.value
181
+
182
+
183
+ @dataclass(frozen=True, slots=True)
184
+ class DbProviderDescription:
185
+ engine_id: str
186
+ database: str
187
+ redacted_summary: str
188
+ owns_resources: bool
189
+
190
+
191
+ class DbSessionLease(Generic[S]):
192
+ def __init__(
193
+ self,
194
+ *,
195
+ session: S,
196
+ owned_by_package: bool,
197
+ releaser: Callable[[], DbResult[None]],
198
+ ) -> None:
199
+ self.session = session
200
+ self.owned_by_package = owned_by_package
201
+ self._releaser = releaser
202
+
203
+ def release(self) -> DbResult[None]:
204
+ if not self.owned_by_package:
205
+ return DbResult.success(None)
206
+ return self._releaser()
207
+
208
+
209
+ class DbSessionProvider(Protocol[S]):
210
+ def acquire(self) -> DbResult[DbSessionLease[S]]: ...
211
+
212
+ def close(self) -> DbResult[None]: ...
213
+
214
+ def describe(self) -> DbProviderDescription: ...
215
+
216
+
217
+ class DbCommandExecutor(Protocol[S]):
218
+ def query(self, session: S, command: DbCommand) -> DbResult[DbRowSet]: ...
219
+
220
+ def execute(self, session: S, command: DbCommand) -> DbResult[DbExecutionSummary]: ...
221
+
222
+ def scalar(self, session: S, command: DbCommand) -> DbResult[DbScalar[object]]: ...
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class DbTransactionContext(Generic[S]):
227
+ settings: DbConnectionSettings
228
+ session: S
229
+ command_executor: DbCommandExecutor[S]
230
+
231
+ def query(self, command: DbCommand) -> DbResult[DbRowSet]:
232
+ return self.command_executor.query(self.session, command)
233
+
234
+ def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
235
+ return self.command_executor.execute(self.session, command)
236
+
237
+ def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
238
+ return cast(DbResult[DbScalar[T]], self.command_executor.scalar(self.session, command))
239
+
240
+
241
+ class DbTransactionRunner(Protocol[S]):
242
+ def run(
243
+ self,
244
+ context: DbTransactionContext[S],
245
+ body: Callable[[DbTransactionContext[S]], DbResult[T]],
246
+ ) -> DbResult[T]: ...
247
+
248
+
249
+ @dataclass(frozen=True, slots=True)
250
+ class DbRepositoryContext(Generic[S]):
251
+ settings: DbConnectionSettings
252
+ session_provider: DbSessionProvider[S]
253
+ command_executor: DbCommandExecutor[S]
254
+ transaction_runner: DbTransactionRunner[S]
255
+
256
+
257
+ class DbRepository(Generic[S]):
258
+ def __init__(self, context: DbRepositoryContext[S]) -> None:
259
+ self.context = context
260
+
261
+ def query(self, command: DbCommand) -> DbResult[DbRowSet]:
262
+ return _with_lease(
263
+ self.context.session_provider,
264
+ lambda lease: self.context.command_executor.query(lease.session, command),
265
+ )
266
+
267
+ def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
268
+ return _with_lease(
269
+ self.context.session_provider,
270
+ lambda lease: self.context.command_executor.execute(lease.session, command),
271
+ )
272
+
273
+ def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
274
+ return cast(
275
+ DbResult[DbScalar[T]],
276
+ _with_lease(
277
+ self.context.session_provider,
278
+ lambda lease: self.context.command_executor.scalar(lease.session, command),
279
+ ),
280
+ )
281
+
282
+ def transaction(self, body: Callable[[DbTransactionContext[S]], DbResult[T]]) -> DbResult[T]:
283
+ client = DbClient(
284
+ settings=self.context.settings,
285
+ session_provider=self.context.session_provider,
286
+ command_executor=self.context.command_executor,
287
+ transaction_runner=self.context.transaction_runner,
288
+ )
289
+ return client.transaction(body)
290
+
291
+
292
+ class DbClient(Generic[S]):
293
+ def __init__(
294
+ self,
295
+ *,
296
+ settings: DbConnectionSettings,
297
+ session_provider: DbSessionProvider[S],
298
+ command_executor: DbCommandExecutor[S],
299
+ transaction_runner: DbTransactionRunner[S],
300
+ ) -> None:
301
+ self.settings = settings
302
+ self.session_provider = session_provider
303
+ self.command_executor = command_executor
304
+ self.transaction_runner = transaction_runner
305
+
306
+ def query(self, command: DbCommand) -> DbResult[DbRowSet]:
307
+ return _with_lease(
308
+ self.session_provider,
309
+ lambda lease: self.command_executor.query(lease.session, command),
310
+ )
311
+
312
+ def execute(self, command: DbCommand) -> DbResult[DbExecutionSummary]:
313
+ return _with_lease(
314
+ self.session_provider,
315
+ lambda lease: self.command_executor.execute(lease.session, command),
316
+ )
317
+
318
+ def scalar(self, command: DbCommand) -> DbResult[DbScalar[T]]:
319
+ return cast(
320
+ DbResult[DbScalar[T]],
321
+ _with_lease(
322
+ self.session_provider,
323
+ lambda lease: self.command_executor.scalar(lease.session, command),
324
+ ),
325
+ )
326
+
327
+ def transaction(self, body: Callable[[DbTransactionContext[S]], DbResult[T]]) -> DbResult[T]:
328
+ lease_result = self.session_provider.acquire()
329
+ if lease_result.is_failure:
330
+ return DbResult.from_failure(lease_result.failure)
331
+
332
+ lease = lease_result.value
333
+ context = DbTransactionContext(
334
+ settings=self.settings,
335
+ session=lease.session,
336
+ command_executor=self.command_executor,
337
+ )
338
+ result = self.transaction_runner.run(context, body)
339
+ release_result = lease.release()
340
+
341
+ if result.is_failure:
342
+ return DbResult.from_failure(result.failure)
343
+ if release_result.is_failure:
344
+ return DbResult.from_failure(release_result.failure)
345
+ return result
346
+
347
+ def repository_context(self) -> DbRepositoryContext[S]:
348
+ return DbRepositoryContext(
349
+ settings=self.settings,
350
+ session_provider=self.session_provider,
351
+ command_executor=self.command_executor,
352
+ transaction_runner=self.transaction_runner,
353
+ )
354
+
355
+ def close(self) -> DbResult[None]:
356
+ return self.session_provider.close()
357
+
358
+ def describe(self) -> DbProviderDescription:
359
+ return self.session_provider.describe()
360
+
361
+
362
+ @dataclass(frozen=True, slots=True)
363
+ class DbHealthReport:
364
+ status: DbHealthStatus
365
+ response_time: int
366
+ redacted_summary: str
367
+ details: str | None = None
368
+
369
+
370
+ class DbHealthContributor(Generic[S]):
371
+ def __init__(self, *, client: DbClient[S], probe_command: DbCommand | None = None) -> None:
372
+ self.client = client
373
+ self.probe_command = probe_command or DbCommand(
374
+ kind=DbCommandKind.SCALAR,
375
+ text="SELECT 1",
376
+ label="db.health",
377
+ )
378
+
379
+ def probe(self) -> DbHealthReport:
380
+ started_at = time.monotonic()
381
+ result = self.client.scalar(self.probe_command)
382
+ response_time = int((time.monotonic() - started_at) * 1000)
383
+
384
+ if result.is_success:
385
+ return DbHealthReport(
386
+ status=DbHealthStatus.HEALTHY,
387
+ response_time=response_time,
388
+ redacted_summary=self.client.describe().redacted_summary,
389
+ )
390
+
391
+ return DbHealthReport(
392
+ status=DbHealthStatus.UNHEALTHY,
393
+ response_time=response_time,
394
+ redacted_summary=self.client.describe().redacted_summary,
395
+ details=result.failure.code,
396
+ )
397
+
398
+
399
+ @dataclass(frozen=True, slots=True)
400
+ class DbGraphqlSupport(Generic[S]):
401
+ catalog_provider: object
402
+ read_executor: object
403
+ health_contributor: DbHealthContributor[S]
404
+ source_digest_factory: object | None = None
405
+ artifact_loader: object | None = None
406
+ capability_registration: object | None = None
407
+
408
+
409
+ def _with_lease(
410
+ session_provider: DbSessionProvider[S],
411
+ operation: Callable[[DbSessionLease[S]], DbResult[T]],
412
+ ) -> DbResult[T]:
413
+ lease_result = session_provider.acquire()
414
+ if lease_result.is_failure:
415
+ return DbResult.from_failure(lease_result.failure)
416
+
417
+ lease = lease_result.value
418
+ operation_result = operation(lease)
419
+ release_result = lease.release()
420
+
421
+ if operation_result.is_failure:
422
+ return DbResult.from_failure(operation_result.failure)
423
+ if release_result.is_failure:
424
+ return DbResult.from_failure(release_result.failure)
425
+ return operation_result