iron-sql 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iron-sql
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: iron_sql generates typed async PostgreSQL clients and runtime helpers from schemas and SQL queries
5
5
  Keywords: postgresql,sql,sqlc,psycopg,codegen,async
6
6
  Author: Ilia Ablamonov
@@ -16,7 +16,7 @@ Requires-Dist: psycopg>=3.3.2
16
16
  Requires-Dist: psycopg-pool>=3.3.0
17
17
  Requires-Dist: pydantic>=2.12.4
18
18
  Requires-Dist: inflection>=0.5.1 ; extra == 'codegen'
19
- Requires-Dist: sqlc>=1.30.0 ; extra == 'codegen'
19
+ Requires-Dist: sqlc>=1.30.0.post18 ; extra == 'codegen'
20
20
  Requires-Python: >=3.13
21
21
  Project-URL: Homepage, https://github.com/Flamefork/iron_sql
22
22
  Project-URL: Repository, https://github.com/Flamefork/iron_sql.git
@@ -81,6 +81,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
81
81
  This writes `myapp/db/mydb.py` containing:
82
82
  - a connection pool singleton,
83
83
  - `*_connection()` and `*_transaction()` context managers,
84
+ - `*_listen_session(channel)` and `*_notify(channel, payload="")` helpers,
84
85
  - dataclasses for multi-column results (deduplicated by table),
85
86
  - `StrEnum` classes for PostgreSQL enums,
86
87
  - a query class per statement with typed methods,
@@ -95,6 +96,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
95
96
 
96
97
  ## Runtime Highlights
97
98
  - `ConnectionPool` opens lazily and reopens after `close()`, with `ContextVar`-based connection reuse for nested contexts.
99
+ - `*_listen_session()` uses a dedicated pooled connection and doesn't reuse `ContextVar` transaction connections.
98
100
  - `query_single_row()` raises `NoRowsError`; `query_optional_row()` returns `None`. Both raise `TooManyRowsError` on 2+ rows.
99
101
  - JSONB params are sent with `pgjson.Jsonb`; JSON with `pgjson.Json`. Scalar row factories validate types at runtime.
100
102
  - `json_validated` decorator applies Pydantic model validation to dataclass fields on construction.
@@ -55,6 +55,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
55
55
  This writes `myapp/db/mydb.py` containing:
56
56
  - a connection pool singleton,
57
57
  - `*_connection()` and `*_transaction()` context managers,
58
+ - `*_listen_session(channel)` and `*_notify(channel, payload="")` helpers,
58
59
  - dataclasses for multi-column results (deduplicated by table),
59
60
  - `StrEnum` classes for PostgreSQL enums,
60
61
  - a query class per statement with typed methods,
@@ -69,6 +70,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
69
70
 
70
71
  ## Runtime Highlights
71
72
  - `ConnectionPool` opens lazily and reopens after `close()`, with `ContextVar`-based connection reuse for nested contexts.
73
+ - `*_listen_session()` uses a dedicated pooled connection and doesn't reuse `ContextVar` transaction connections.
72
74
  - `query_single_row()` raises `NoRowsError`; `query_optional_row()` returns `None`. Both raise `TooManyRowsError` on 2+ rows.
73
75
  - JSONB params are sent with `pgjson.Jsonb`; JSON with `pgjson.Json`. Scalar row factories validate types at runtime.
74
76
  - `json_validated` decorator applies Pydantic model validation to dataclass fields on construction.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iron-sql"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
 
5
5
  description = "iron_sql generates typed async PostgreSQL clients and runtime helpers from schemas and SQL queries"
6
6
  readme = "README.md"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  [project.optional-dependencies]
27
27
  codegen = [
28
28
  "inflection>=0.5.1",
29
- "sqlc>=1.30.0",
29
+ "sqlc>=1.30.0.post18",
30
30
  ]
31
31
 
32
32
  [project.urls]
@@ -52,7 +52,6 @@ dev = [
52
52
  ]
53
53
 
54
54
  [tool.pyright]
55
- ignore = ["example"]
56
55
  typeCheckingMode = "strict"
57
56
  reportUnknownArgumentType = "none"
58
57
  reportUnknownLambdaType = "none"
@@ -71,7 +70,6 @@ reportImplicitRelativeImport = true
71
70
 
72
71
  [tool.ruff]
73
72
  target-version = "py313"
74
- exclude = ["example"]
75
73
 
76
74
  [tool.ruff.format]
77
75
  preview = true
@@ -100,6 +98,7 @@ ignore = [
100
98
 
101
99
  [tool.ruff.lint.per-file-ignores]
102
100
  "test_*.py" = ["A002", "PLR2004", "S", "FBT"]
101
+ "example/*.py" = ["E501", "T201", "PGH004"]
103
102
 
104
103
  [tool.ruff.lint.isort]
105
104
  force-single-line = true
@@ -382,6 +382,7 @@ import datetime
382
382
  import decimal
383
383
  import ipaddress
384
384
  import uuid
385
+ from collections.abc import AsyncGenerator
385
386
  from collections.abc import AsyncIterator
386
387
  from collections.abc import Sequence
387
388
  from contextlib import asynccontextmanager
@@ -426,6 +427,20 @@ async def {package_name}_transaction() -> AsyncIterator[None]:
426
427
  yield
427
428
 
428
429
 
430
+ @asynccontextmanager
431
+ async def {package_name}_listen_session(
432
+ channel: str,
433
+ ) -> AsyncIterator[AsyncGenerator[str]]:
434
+ async with {package_name.upper()}_POOL.connection() as conn:
435
+ async with runtime.listen(conn, channel) as payloads:
436
+ yield payloads
437
+
438
+
439
+ async def {package_name}_notify(channel: str, payload: str = "") -> None:
440
+ async with {package_name}_connection() as conn:
441
+ await runtime.notify(conn, channel, payload)
442
+
443
+
429
444
  {"\n\n\n".join(enums)}
430
445
 
431
446
 
@@ -1,4 +1,6 @@
1
+ import contextlib
1
2
  import types
3
+ from collections.abc import AsyncGenerator
2
4
  from collections.abc import AsyncIterator
3
5
  from collections.abc import Callable
4
6
  from collections.abc import Sequence
@@ -13,6 +15,7 @@ from typing import overload
13
15
  import psycopg
14
16
  import psycopg.rows
15
17
  import psycopg_pool
18
+ from psycopg import sql
16
19
  from psycopg.types import json as pgjson
17
20
  from pydantic import TypeAdapter
18
21
 
@@ -20,11 +23,9 @@ _adapter_cache: dict[object, TypeAdapter[object]] = {}
20
23
 
21
24
 
22
25
  def get_adapter(typ: object) -> TypeAdapter[object]:
23
- adapter = _adapter_cache.get(typ)
24
- if adapter is None:
25
- adapter = TypeAdapter(typ)
26
- _adapter_cache[typ] = adapter
27
- return adapter
26
+ if typ not in _adapter_cache:
27
+ _adapter_cache[typ] = TypeAdapter(typ)
28
+ return _adapter_cache[typ]
28
29
 
29
30
 
30
31
  class NoRowsError(Exception):
@@ -35,6 +36,66 @@ class TooManyRowsError(Exception):
35
36
  pass
36
37
 
37
38
 
39
+ @asynccontextmanager
40
+ async def listen(
41
+ conn: psycopg.AsyncConnection, channel: str
42
+ ) -> AsyncIterator[AsyncGenerator[str]]:
43
+ _validate_channel(channel)
44
+ if await _has_active_listen_subscriptions(conn):
45
+ msg = "listen() requires a connection without active LISTEN subscriptions"
46
+ raise RuntimeError(msg)
47
+ await execute_listen(conn, channel)
48
+
49
+ async def _payloads() -> AsyncGenerator[str]:
50
+ async for notify_msg in conn.notifies():
51
+ yield notify_msg.payload
52
+
53
+ gen = _payloads()
54
+ try:
55
+ yield gen
56
+ finally:
57
+ with contextlib.suppress(psycopg.OperationalError, psycopg.InterfaceError):
58
+ await gen.aclose()
59
+ with contextlib.suppress(psycopg.OperationalError, psycopg.InterfaceError):
60
+ await execute_unlisten(conn, channel)
61
+
62
+
63
+ async def notify(conn: psycopg.AsyncConnection, channel: str, payload: str) -> None:
64
+ _validate_channel(channel)
65
+ await conn.execute(
66
+ sql.SQL("NOTIFY {}, {}").format(
67
+ sql.Identifier(channel),
68
+ sql.Literal(payload),
69
+ )
70
+ )
71
+
72
+
73
+ async def execute_listen(conn: psycopg.AsyncConnection, channel: str) -> None:
74
+ _validate_channel(channel)
75
+ await conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(channel)))
76
+
77
+
78
+ async def execute_unlisten(conn: psycopg.AsyncConnection, channel: str) -> None:
79
+ _validate_channel(channel)
80
+ await conn.execute(sql.SQL("UNLISTEN {}").format(sql.Identifier(channel)))
81
+
82
+
83
+ async def _has_active_listen_subscriptions(conn: psycopg.AsyncConnection) -> bool:
84
+ async with conn.cursor() as cur:
85
+ await cur.execute("SELECT EXISTS (SELECT FROM pg_listening_channels())")
86
+ row = await cur.fetchone()
87
+ if row is None:
88
+ msg = "Expected a single boolean row from active LISTEN check"
89
+ raise RuntimeError(msg)
90
+ return bool(row[0])
91
+
92
+
93
+ def _validate_channel(name: str) -> None:
94
+ if not name:
95
+ msg = "Channel name must not be empty"
96
+ raise ValueError(msg)
97
+
98
+
38
99
  class ConnectionPool:
39
100
  def __init__(
40
101
  self,
File without changes
File without changes