macss-modular-api-postgres 0.4.7__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.
@@ -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,41 @@
1
+ # macss-modular-api-postgres
2
+
3
+ Official MACSS Postgres integration package for Python.
4
+
5
+ ## Quick start
6
+
7
+ ```python
8
+ from modular_api_postgres import DbClient, DbCommand, DbCommandKind, DbConnectionSettings
9
+
10
+ settings = DbConnectionSettings.from_environment()
11
+
12
+ client = DbClient(
13
+ settings=settings,
14
+ session_provider=my_session_provider,
15
+ command_executor=my_command_executor,
16
+ transaction_runner=my_transaction_runner,
17
+ )
18
+
19
+ result = client.scalar(
20
+ DbCommand(
21
+ kind=DbCommandKind.SCALAR,
22
+ text="select count(*) from users",
23
+ label="users.count",
24
+ )
25
+ )
26
+
27
+ if result.is_success:
28
+ print(result.value.value)
29
+ else:
30
+ print(result.failure.message)
31
+ ```
32
+
33
+ See [example/example.py](example/example.py) for a complete in-memory wiring sample.
34
+
35
+ ## Current slice
36
+
37
+ - normalized Postgres connection defaults and redacted summaries
38
+ - engine-agnostic `DbClient`, `DbRepository`, and transaction contracts
39
+ - explicit lease ownership semantics for package-owned and application-owned sessions
40
+ - health contributor and GraphQL support bundle for higher-level integrations
41
+ - real driver bindings intentionally remain outside this first slice
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "macss-modular-api-postgres"
7
+ version = "0.4.7"
8
+ description = "Official MACSS Postgres integration package for Python."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "ccisne.dev" }]
13
+ keywords = ["macss", "postgres", "database"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Database",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/macss-dev/modular_api"
33
+ Repository = "https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres"
34
+ Issues = "https://github.com/macss-dev/modular_api/issues"
35
+ Documentation = "https://github.com/macss-dev/modular_api/tree/main/code/py/modular_api_postgres#readme"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ pythonpath = ["src"]
43
+
44
+ [tool.pyright]
45
+ pythonVersion = "3.11"
46
+ typeCheckingMode = "strict"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/macss_modular_api_postgres.egg-info/PKG-INFO
4
+ src/macss_modular_api_postgres.egg-info/SOURCES.txt
5
+ src/macss_modular_api_postgres.egg-info/dependency_links.txt
6
+ src/macss_modular_api_postgres.egg-info/requires.txt
7
+ src/macss_modular_api_postgres.egg-info/top_level.txt
8
+ src/modular_api_postgres/__init__.py
9
+ src/modular_api_postgres/db_client.py
10
+ tests/test_db_client.py
11
+ tests/test_db_conformance.py
@@ -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
@@ -0,0 +1,478 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pytest
6
+
7
+ from modular_api_postgres import (
8
+ DbClient,
9
+ DbCommand,
10
+ DbCommandKind,
11
+ DbConnectionSettings,
12
+ DbExecutionMetadata,
13
+ DbExecutionSummary,
14
+ DbFailure,
15
+ DbFailureKind,
16
+ DbGraphqlSupport,
17
+ DbHealthContributor,
18
+ DbHealthStatus,
19
+ DbProviderDescription,
20
+ DbRepository,
21
+ DbRepositoryContext,
22
+ DbResult,
23
+ DbRowSet,
24
+ DbScalar,
25
+ DbSessionLease,
26
+ DbTransactionContext,
27
+ )
28
+
29
+
30
+ def test_connection_settings_normalizes_environment_defaults_and_redacts_secrets() -> None:
31
+ settings = DbConnectionSettings.from_environment(
32
+ {
33
+ "MODULAR_API_POSTGRES_HOST": "db.local",
34
+ "MODULAR_API_POSTGRES_PASSWORD": "super-secret",
35
+ }
36
+ )
37
+
38
+ assert settings.engine_id == "postgres"
39
+ assert settings.host == "db.local"
40
+ assert settings.port == 5432
41
+ assert settings.database == "modular_api_graphql_v1"
42
+ assert settings.username == "postgres"
43
+ assert settings.password == "super-secret"
44
+ assert settings.ssl_mode == "disable"
45
+ assert "db.local:5432" in settings.redacted_summary
46
+ assert "postgres@" in settings.redacted_summary
47
+ assert "sslmode=disable" in settings.redacted_summary
48
+ assert "super-secret" not in settings.redacted_summary
49
+
50
+
51
+ def test_db_result_supports_map_flat_map_map_failure_and_get_or_throw() -> None:
52
+ success = DbResult.success(21)
53
+ failure = DbResult.from_failure(
54
+ DbFailure(
55
+ kind=DbFailureKind.TIMEOUT,
56
+ code="timeout",
57
+ message="Timed out",
58
+ retryable=True,
59
+ transient=True,
60
+ )
61
+ )
62
+
63
+ assert success.map(lambda value: value * 2).value == 42
64
+ assert success.flat_map(lambda value: DbResult.success(value + 1)).value == 22
65
+
66
+ mapped_failure = failure.map_failure(
67
+ lambda current: DbFailure(
68
+ kind=current.kind,
69
+ code="wrapped_timeout",
70
+ message=current.message,
71
+ retryable=current.retryable,
72
+ transient=current.transient,
73
+ )
74
+ )
75
+
76
+ assert mapped_failure.failure.code == "wrapped_timeout"
77
+ assert success.get_or_throw() == 21
78
+ with pytest.raises(RuntimeError):
79
+ failure.get_or_throw()
80
+
81
+
82
+ def test_client_delegates_query_calls_and_releases_package_owned_leases() -> None:
83
+ settings = DbConnectionSettings.from_environment()
84
+ provider = _FakeSessionProvider(settings, session="db-session")
85
+ executor = _FakeCommandExecutor(
86
+ row_set=DbRowSet(
87
+ rows=[{"id": 1}],
88
+ metadata=DbExecutionMetadata(
89
+ duration=3,
90
+ command_label="users.list",
91
+ row_count=1,
92
+ ),
93
+ )
94
+ )
95
+ client = DbClient(
96
+ settings=settings,
97
+ session_provider=provider,
98
+ command_executor=executor,
99
+ transaction_runner=_FakeTransactionRunner(),
100
+ )
101
+
102
+ result = client.query(
103
+ DbCommand(
104
+ kind=DbCommandKind.QUERY,
105
+ text="select id from users",
106
+ label="users.list",
107
+ )
108
+ )
109
+
110
+ assert result.is_success is True
111
+ assert result.value.rows == [{"id": 1}]
112
+ assert result.value.metadata.row_count == 1
113
+ assert provider.acquire_count == 1
114
+ assert provider.release_count == 1
115
+ assert executor.last_session == "db-session"
116
+ assert executor.last_command.label == "users.list"
117
+
118
+
119
+ def test_client_returns_a_failure_when_session_acquisition_fails() -> None:
120
+ settings = DbConnectionSettings.from_environment()
121
+ provider = _FakeSessionProvider(
122
+ settings,
123
+ acquire_failure=DbFailure(
124
+ kind=DbFailureKind.CONNECTIVITY,
125
+ code="connect_failed",
126
+ message="Could not connect",
127
+ retryable=True,
128
+ transient=True,
129
+ ),
130
+ )
131
+ client = DbClient(
132
+ settings=settings,
133
+ session_provider=provider,
134
+ command_executor=_FakeCommandExecutor(),
135
+ transaction_runner=_FakeTransactionRunner(),
136
+ )
137
+
138
+ result = client.query(DbCommand(kind=DbCommandKind.QUERY, text="select 1"))
139
+
140
+ assert result.is_failure is True
141
+ assert result.failure.code == "connect_failed"
142
+
143
+
144
+ def test_client_returns_a_failure_when_releasing_a_package_owned_lease_fails() -> None:
145
+ settings = DbConnectionSettings.from_environment()
146
+ provider = _FakeSessionProvider(
147
+ settings,
148
+ release_failure=DbFailure(
149
+ kind=DbFailureKind.UNKNOWN,
150
+ code="release_failed",
151
+ message="Release failed",
152
+ retryable=False,
153
+ transient=False,
154
+ ),
155
+ )
156
+ client = DbClient(
157
+ settings=settings,
158
+ session_provider=provider,
159
+ command_executor=_FakeCommandExecutor(),
160
+ transaction_runner=_FakeTransactionRunner(),
161
+ )
162
+
163
+ result = client.query(DbCommand(kind=DbCommandKind.QUERY, text="select 1"))
164
+
165
+ assert result.is_failure is True
166
+ assert result.failure.code == "release_failed"
167
+
168
+
169
+ def test_client_does_not_release_application_owned_leases() -> None:
170
+ settings = DbConnectionSettings.from_environment()
171
+ provider = _FakeSessionProvider(settings, owned_by_package=False)
172
+ executor = _FakeCommandExecutor(
173
+ execution_summary=DbExecutionSummary(
174
+ affected_count=1,
175
+ metadata=DbExecutionMetadata(
176
+ duration=2,
177
+ command_label="users.touch",
178
+ affected_count=1,
179
+ ),
180
+ )
181
+ )
182
+ client = DbClient(
183
+ settings=settings,
184
+ session_provider=provider,
185
+ command_executor=executor,
186
+ transaction_runner=_FakeTransactionRunner(),
187
+ )
188
+
189
+ result = client.execute(
190
+ DbCommand(
191
+ kind=DbCommandKind.EXECUTE,
192
+ text="update users set touched = true",
193
+ label="users.touch",
194
+ )
195
+ )
196
+
197
+ assert result.is_success is True
198
+ assert result.value.affected_count == 1
199
+ assert provider.release_count == 0
200
+
201
+
202
+ def test_client_commits_successful_transactions_and_rolls_back_failed_ones() -> None:
203
+ settings = DbConnectionSettings.from_environment()
204
+ provider = _FakeSessionProvider(settings)
205
+ executor = _FakeCommandExecutor(scalar_value=7)
206
+ runner = _FakeTransactionRunner()
207
+ client = DbClient(
208
+ settings=settings,
209
+ session_provider=provider,
210
+ command_executor=executor,
211
+ transaction_runner=runner,
212
+ )
213
+
214
+ success = client.transaction(
215
+ lambda transaction: transaction.scalar(
216
+ DbCommand(
217
+ kind=DbCommandKind.SCALAR,
218
+ text="select count(*) from users",
219
+ label="users.count",
220
+ )
221
+ ).map(lambda value: value.value)
222
+ )
223
+ failure = client.transaction(
224
+ lambda _transaction: DbResult.from_failure(
225
+ DbFailure(
226
+ kind=DbFailureKind.CONFLICT,
227
+ code="duplicate_key",
228
+ message="Duplicate key",
229
+ retryable=False,
230
+ transient=False,
231
+ )
232
+ )
233
+ )
234
+
235
+ assert success.is_success is True
236
+ assert success.value == 7
237
+ assert failure.is_failure is True
238
+ assert failure.failure.code == "duplicate_key"
239
+ assert runner.commit_count == 1
240
+ assert runner.rollback_count == 1
241
+ assert provider.release_count == 2
242
+
243
+
244
+ def test_client_describes_its_provider_and_closes_cleanly() -> None:
245
+ settings = DbConnectionSettings.from_environment()
246
+ provider = _FakeSessionProvider(settings)
247
+ client = DbClient(
248
+ settings=settings,
249
+ session_provider=provider,
250
+ command_executor=_FakeCommandExecutor(),
251
+ transaction_runner=_FakeTransactionRunner(),
252
+ )
253
+
254
+ assert client.describe().engine_id == "postgres"
255
+ assert client.describe().database == settings.database
256
+
257
+ closed = client.close()
258
+
259
+ assert closed.is_success is True
260
+ assert provider.close_count == 1
261
+
262
+
263
+ def test_client_propagates_provider_close_failures() -> None:
264
+ settings = DbConnectionSettings.from_environment()
265
+ provider = _FakeSessionProvider(
266
+ settings,
267
+ close_failure=DbFailure(
268
+ kind=DbFailureKind.UNKNOWN,
269
+ code="close_failed",
270
+ message="Close failed",
271
+ retryable=False,
272
+ transient=False,
273
+ ),
274
+ )
275
+ client = DbClient(
276
+ settings=settings,
277
+ session_provider=provider,
278
+ command_executor=_FakeCommandExecutor(),
279
+ transaction_runner=_FakeTransactionRunner(),
280
+ )
281
+
282
+ closed = client.close()
283
+
284
+ assert closed.is_failure is True
285
+ assert closed.failure.code == "close_failed"
286
+
287
+
288
+ def test_repository_helpers_stay_thin_over_the_shared_context() -> None:
289
+ settings = DbConnectionSettings.from_environment()
290
+ provider = _FakeSessionProvider(settings)
291
+ executor = _FakeCommandExecutor(scalar_value=9)
292
+ context = DbRepositoryContext(
293
+ settings=settings,
294
+ session_provider=provider,
295
+ command_executor=executor,
296
+ transaction_runner=_FakeTransactionRunner(),
297
+ )
298
+ repository = _UserStatsRepository(context)
299
+
300
+ result = repository.total_users()
301
+
302
+ assert result.is_success is True
303
+ assert result.value == 9
304
+ assert executor.last_command.label == "users.count"
305
+
306
+
307
+ def test_health_probe_reports_healthy_and_graphql_support_bundles_dependencies() -> None:
308
+ settings = DbConnectionSettings.from_environment()
309
+ provider = _FakeSessionProvider(settings)
310
+ executor = _FakeCommandExecutor(scalar_value=1)
311
+ client = DbClient(
312
+ settings=settings,
313
+ session_provider=provider,
314
+ command_executor=executor,
315
+ transaction_runner=_FakeTransactionRunner(),
316
+ )
317
+ health_contributor = DbHealthContributor(client=client)
318
+ support = DbGraphqlSupport(
319
+ catalog_provider="catalog-provider",
320
+ read_executor="read-executor",
321
+ health_contributor=health_contributor,
322
+ )
323
+
324
+ report = health_contributor.probe()
325
+
326
+ assert report.status is DbHealthStatus.HEALTHY
327
+ assert report.redacted_summary == settings.redacted_summary
328
+ assert report.response_time >= 0
329
+ assert support.catalog_provider == "catalog-provider"
330
+ assert support.read_executor == "read-executor"
331
+ assert support.health_contributor is health_contributor
332
+
333
+
334
+ def test_health_probe_reports_unhealthy_when_scalar_execution_fails() -> None:
335
+ settings = DbConnectionSettings.from_environment()
336
+ provider = _FakeSessionProvider(settings)
337
+ executor = _FakeCommandExecutor(
338
+ failure=DbFailure(
339
+ kind=DbFailureKind.TIMEOUT,
340
+ code="timeout",
341
+ message="Timed out",
342
+ retryable=True,
343
+ transient=True,
344
+ )
345
+ )
346
+ client = DbClient(
347
+ settings=settings,
348
+ session_provider=provider,
349
+ command_executor=executor,
350
+ transaction_runner=_FakeTransactionRunner(),
351
+ )
352
+ health_contributor = DbHealthContributor(client=client)
353
+
354
+ report = health_contributor.probe()
355
+
356
+ assert report.status is DbHealthStatus.UNHEALTHY
357
+ assert report.details == "timeout"
358
+
359
+
360
+ @dataclass(slots=True)
361
+ class _FakeSessionProvider:
362
+ settings: DbConnectionSettings
363
+ session: str = "session-1"
364
+ owned_by_package: bool = True
365
+ acquire_failure: DbFailure | None = None
366
+ release_failure: DbFailure | None = None
367
+ close_failure: DbFailure | None = None
368
+ acquire_count: int = 0
369
+ release_count: int = 0
370
+ close_count: int = 0
371
+
372
+ def acquire(self) -> DbResult[DbSessionLease[str]]:
373
+ self.acquire_count += 1
374
+ if self.acquire_failure is not None:
375
+ return DbResult.from_failure(self.acquire_failure)
376
+
377
+ return DbResult.success(
378
+ DbSessionLease(
379
+ session=self.session,
380
+ owned_by_package=self.owned_by_package,
381
+ releaser=self._release,
382
+ )
383
+ )
384
+
385
+ def close(self) -> DbResult[None]:
386
+ self.close_count += 1
387
+ if self.close_failure is not None:
388
+ return DbResult.from_failure(self.close_failure)
389
+ return DbResult.success(None)
390
+
391
+ def describe(self) -> DbProviderDescription:
392
+ return DbProviderDescription(
393
+ engine_id=self.settings.engine_id,
394
+ database=self.settings.database,
395
+ redacted_summary=self.settings.redacted_summary,
396
+ owns_resources=self.owned_by_package,
397
+ )
398
+
399
+ def _release(self) -> DbResult[None]:
400
+ self.release_count += 1
401
+ if self.release_failure is not None:
402
+ return DbResult.from_failure(self.release_failure)
403
+ return DbResult.success(None)
404
+
405
+
406
+ @dataclass(slots=True)
407
+ class _FakeCommandExecutor:
408
+ row_set: DbRowSet = DbRowSet(
409
+ rows=[],
410
+ metadata=DbExecutionMetadata(duration=0),
411
+ )
412
+ execution_summary: DbExecutionSummary = DbExecutionSummary(
413
+ affected_count=0,
414
+ metadata=DbExecutionMetadata(duration=0),
415
+ )
416
+ scalar_value: object | None = None
417
+ failure: DbFailure | None = None
418
+ last_session: str | None = None
419
+ last_command: DbCommand | None = None
420
+
421
+ def query(self, session: str, command: DbCommand) -> DbResult[DbRowSet]:
422
+ self.last_session = session
423
+ self.last_command = command
424
+ if self.failure is not None:
425
+ return DbResult.from_failure(self.failure)
426
+ return DbResult.success(self.row_set)
427
+
428
+ def execute(self, session: str, command: DbCommand) -> DbResult[DbExecutionSummary]:
429
+ self.last_session = session
430
+ self.last_command = command
431
+ if self.failure is not None:
432
+ return DbResult.from_failure(self.failure)
433
+ return DbResult.success(self.execution_summary)
434
+
435
+ def scalar(self, session: str, command: DbCommand) -> DbResult[DbScalar[object]]:
436
+ self.last_session = session
437
+ self.last_command = command
438
+ if self.failure is not None:
439
+ return DbResult.from_failure(self.failure)
440
+ return DbResult.success(
441
+ DbScalar(
442
+ value=self.scalar_value,
443
+ metadata=DbExecutionMetadata(
444
+ duration=0,
445
+ command_label=command.label,
446
+ ),
447
+ )
448
+ )
449
+
450
+
451
+ @dataclass(slots=True)
452
+ class _FakeTransactionRunner:
453
+ commit_count: int = 0
454
+ rollback_count: int = 0
455
+
456
+ def run(
457
+ self,
458
+ context: DbTransactionContext[str],
459
+ body: callable,
460
+ ) -> DbResult[object]:
461
+ result = body(context)
462
+ if result.is_success:
463
+ self.commit_count += 1
464
+ else:
465
+ self.rollback_count += 1
466
+ return result
467
+
468
+
469
+ class _UserStatsRepository(DbRepository[str]):
470
+ def total_users(self) -> DbResult[int]:
471
+ result = self.scalar(
472
+ DbCommand(
473
+ kind=DbCommandKind.SCALAR,
474
+ text="select count(*) from users",
475
+ label="users.count",
476
+ )
477
+ )
478
+ return result.map(lambda value: int(value.value))
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from modular_api_postgres import DbConnectionSettings, DbFailure, DbFailureKind, DbResult
7
+
8
+
9
+ def _load_fixture() -> dict[str, object]:
10
+ return json.loads(
11
+ (Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "db_client" / "postgres.json").read_text(
12
+ encoding="utf-8"
13
+ )
14
+ )
15
+
16
+
17
+ def test_matches_the_shared_postgres_connection_fixture() -> None:
18
+ fixture = _load_fixture()
19
+ connection = fixture["connection"]
20
+ expected = connection["expected"]
21
+
22
+ settings = DbConnectionSettings.from_environment(connection["environment"])
23
+
24
+ assert settings.engine_id == expected["engineId"]
25
+ assert settings.host == expected["host"]
26
+ assert settings.port == expected["port"]
27
+ assert settings.database == expected["database"]
28
+ assert settings.username == expected["username"]
29
+ assert settings.password == expected["password"]
30
+ assert settings.ssl_mode == expected["sslMode"]
31
+
32
+ for fragment in connection["redactedContains"]:
33
+ assert fragment in settings.redacted_summary
34
+ for fragment in connection["redactedExcludes"]:
35
+ assert fragment not in settings.redacted_summary
36
+
37
+
38
+ def test_matches_the_shared_db_result_fixture() -> None:
39
+ fixture = _load_fixture()
40
+ result_fixture = fixture["result"]
41
+
42
+ success = DbResult.success(result_fixture["successValue"])
43
+ failure = DbResult.from_failure(
44
+ DbFailure(
45
+ kind=DbFailureKind.TIMEOUT,
46
+ code=result_fixture["timeoutCode"],
47
+ message="Timed out",
48
+ retryable=True,
49
+ transient=True,
50
+ )
51
+ )
52
+
53
+ assert success.map(lambda value: value * 2).value == result_fixture["mappedValue"]
54
+ assert success.flat_map(lambda value: DbResult.success(value + 1)).value == result_fixture[
55
+ "flatMappedValue"
56
+ ]
57
+
58
+ mapped_failure = failure.map_failure(
59
+ lambda current: DbFailure(
60
+ kind=current.kind,
61
+ code=result_fixture["wrappedFailureCode"],
62
+ message=current.message,
63
+ retryable=current.retryable,
64
+ transient=current.transient,
65
+ )
66
+ )
67
+
68
+ assert mapped_failure.failure.code == result_fixture["wrappedFailureCode"]