supython 0.5.0__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.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,261 @@
1
+ """Realtime service layer.
2
+
3
+ Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
4
+
5
+ There are three public entry points used by the rest of the realtime
6
+ module:
7
+
8
+ ``enable_table``
9
+ Service-role only. Wraps the SQL helper ``realtime.enable(regclass,
10
+ text)`` defined in :file:`migrations/0006_realtime_schema.sql`. This
11
+ upserts ``realtime.enabled_tables`` and (re-)attaches the
12
+ ``realtime_notify`` trigger.
13
+
14
+ ``list_enabled``
15
+ Reads the registry under the caller's role so RLS still applies.
16
+ Returns one :class:`~.schemas.EnabledTable` per row.
17
+
18
+ ``rls_check``
19
+ Per-event per-subscriber visibility probe. Runs ``select 1 from
20
+ <schema>.<table> where <pk> = $n`` under the subscriber's role +
21
+ claims. Used by the broker to decide whether a particular row change
22
+ should be forwarded to a particular WebSocket.
23
+
24
+ The DELETE-visibility short-circuit (matching ``old_record.<owner_column>``
25
+ against ``auth.uid()``) is implemented in the broker, not here, because
26
+ it does not require a database round-trip — the registry row already
27
+ tells us the owner column.
28
+ """
29
+
30
+ import logging
31
+ from typing import Any
32
+
33
+ import asyncpg
34
+
35
+ from .. import db
36
+ from .schemas import EnabledTable, EnableTableRequest
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class RealtimeError(Exception):
42
+ """Domain error raised by the realtime service layer."""
43
+
44
+ def __init__(self, code: str, message: str, status: int = 400) -> None:
45
+ super().__init__(message)
46
+ self.code = code
47
+ self.message = message
48
+ self.status = status
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Row mappers
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _row_to_enabled_table(row: asyncpg.Record) -> EnabledTable:
57
+ return EnabledTable(
58
+ schema_name=row["schema_name"],
59
+ table_name=row["table_name"],
60
+ pk_columns=list(row["pk_columns"]),
61
+ owner_column=row["owner_column"],
62
+ created_at=row["created_at"],
63
+ )
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # enable_table — service-role only
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ async def enable_table(
72
+ conn: asyncpg.Connection,
73
+ payload: EnableTableRequest,
74
+ ) -> EnabledTable:
75
+ """Opt a table into realtime by attaching the notify trigger.
76
+
77
+ *conn* must be a service-role connection (or one whose effective
78
+ privileges include ``execute`` on ``realtime.enable``). The router
79
+ is responsible for that gating; the service trusts the connection.
80
+
81
+ Returns the freshly written :class:`EnabledTable` row so the caller
82
+ can immediately echo it back to the client.
83
+ """
84
+ qualified = payload.table
85
+
86
+ try:
87
+ await conn.execute(
88
+ "select realtime.enable($1::regclass, $2)",
89
+ qualified,
90
+ payload.owner_column,
91
+ )
92
+ except asyncpg.UndefinedTableError as exc:
93
+ raise RealtimeError(
94
+ "table_not_found",
95
+ f"table {qualified!r} does not exist",
96
+ status=404,
97
+ ) from exc
98
+ except asyncpg.InsufficientPrivilegeError as exc:
99
+ raise RealtimeError(
100
+ "forbidden",
101
+ "service_role required to enable a table for realtime",
102
+ status=403,
103
+ ) from exc
104
+ except asyncpg.RaiseError as exc:
105
+ raise RealtimeError("invalid_table", str(exc), status=400) from exc
106
+
107
+ schema_name, _, table_name = qualified.partition(".")
108
+ row = await conn.fetchrow(
109
+ """
110
+ select schema_name, table_name, pk_columns, owner_column, created_at
111
+ from realtime.enabled_tables
112
+ where schema_name = $1 and table_name = $2
113
+ """,
114
+ schema_name,
115
+ table_name,
116
+ )
117
+ if row is None:
118
+ # The SQL helper returned without raising, but the registry row is
119
+ # missing — should never happen, but treat it as a server fault.
120
+ raise RealtimeError(
121
+ "registry_missing",
122
+ f"realtime.enable({qualified!r}) succeeded but the registry row is missing",
123
+ status=500,
124
+ )
125
+ return _row_to_enabled_table(row)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # list_enabled
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ async def list_enabled(conn: asyncpg.Connection) -> list[EnabledTable]:
134
+ """Return every row from ``realtime.enabled_tables`` visible to *conn*.
135
+
136
+ Visibility is governed by the registry's RLS policies; in the v0.4
137
+ schema both ``anon`` and ``authenticated`` may read every row, so the
138
+ list is effectively the same regardless of the caller's role.
139
+ """
140
+ rows = await conn.fetch(
141
+ """
142
+ select schema_name, table_name, pk_columns, owner_column, created_at
143
+ from realtime.enabled_tables
144
+ order by schema_name, table_name
145
+ """
146
+ )
147
+ return [_row_to_enabled_table(r) for r in rows]
148
+
149
+
150
+ async def get_enabled(
151
+ conn: asyncpg.Connection,
152
+ schema_name: str,
153
+ table_name: str,
154
+ ) -> EnabledTable | None:
155
+ """Return one registry row, or ``None`` if the table is not enabled."""
156
+ row = await conn.fetchrow(
157
+ """
158
+ select schema_name, table_name, pk_columns, owner_column, created_at
159
+ from realtime.enabled_tables
160
+ where schema_name = $1 and table_name = $2
161
+ """,
162
+ schema_name,
163
+ table_name,
164
+ )
165
+ return _row_to_enabled_table(row) if row else None
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # rls_check — per-subscriber visibility probe
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ # Quote an SQL identifier defensively. Schema, table, and PK column names
174
+ # all originate from the catalog (via realtime.enable's regclass /
175
+ # pg_attribute lookup), so they are already safe. We still double-quote
176
+ # them to handle reserved words and embedded uppercase, and we reject any
177
+ # identifier containing a literal double quote since asyncpg / psql will
178
+ # happily mis-interpret one.
179
+ def _quote_ident(name: str) -> str:
180
+ if '"' in name:
181
+ raise ValueError(f"identifier {name!r} contains a double quote")
182
+ return '"' + name + '"'
183
+
184
+
185
+ async def rls_check(
186
+ conn: asyncpg.Connection,
187
+ *,
188
+ schema_name: str,
189
+ table_name: str,
190
+ pk_columns: list[str],
191
+ pk_values: list[Any],
192
+ timeout: float | None = None,
193
+ ) -> bool:
194
+ """Probe whether *conn* (already role-scoped) can ``select`` the row.
195
+
196
+ ``conn`` MUST come from :func:`db.as_role` so that ``set local role``
197
+ and ``request.jwt.claims`` are in effect; this function does not
198
+ re-scope the connection itself.
199
+
200
+ Returns ``True`` if the role's RLS policies expose the row identified
201
+ by ``pk_values`` (in the same order as ``pk_columns``), ``False``
202
+ otherwise. A timeout or transient error returns ``False`` and is
203
+ logged — never propagated — so a misbehaving subscriber connection
204
+ cannot stall the broker fan-out loop.
205
+ """
206
+ if len(pk_columns) != len(pk_values):
207
+ raise ValueError(
208
+ f"pk_columns/pk_values length mismatch: "
209
+ f"{len(pk_columns)} vs {len(pk_values)}"
210
+ )
211
+ if not pk_columns:
212
+ raise ValueError("at least one pk column is required")
213
+
214
+ where_clauses = [
215
+ f"{_quote_ident(col)} = ${i + 1}"
216
+ for i, col in enumerate(pk_columns)
217
+ ]
218
+ sql = (
219
+ f"select 1 from {_quote_ident(schema_name)}.{_quote_ident(table_name)} "
220
+ f"where {' and '.join(where_clauses)} limit 1"
221
+ )
222
+ try:
223
+ row = await conn.fetchrow(sql, *pk_values, timeout=timeout)
224
+ except asyncpg.PostgresError as exc:
225
+ logger.debug(
226
+ "realtime rls_check failed for %s.%s pk=%s: %s",
227
+ schema_name,
228
+ table_name,
229
+ pk_values,
230
+ exc,
231
+ )
232
+ return False
233
+ return row is not None
234
+
235
+
236
+ async def rls_check_with_role(
237
+ role: str,
238
+ claims: dict[str, Any],
239
+ *,
240
+ schema_name: str,
241
+ table_name: str,
242
+ pk_columns: list[str],
243
+ pk_values: list[Any],
244
+ timeout: float | None = None,
245
+ ) -> bool:
246
+ """Convenience wrapper that opens a role-scoped connection per check.
247
+
248
+ The broker reuses an already-acquired connection per subscriber when
249
+ fanning out a single notification across many topics, but for one-off
250
+ checks (tests, REST control plane) this single-shot helper is
251
+ simpler.
252
+ """
253
+ async with db.as_role(role, claims) as conn:
254
+ return await rls_check(
255
+ conn,
256
+ schema_name=schema_name,
257
+ table_name=table_name,
258
+ pk_columns=pk_columns,
259
+ pk_values=pk_values,
260
+ timeout=timeout,
261
+ )
@@ -0,0 +1,175 @@
1
+ """Topic validation and postgres_changes filter parsing for the realtime module.
2
+
3
+ Topic grammar (A2 in the plan):
4
+ topic := "realtime:" name
5
+ name := [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}
6
+
7
+ The phoenix system topic ("phoenix") is also legal and is handled separately
8
+ by the WebSocket layer. This module only deals with "realtime:*" topics.
9
+
10
+ Filter grammar (v0.4 — eq / in only):
11
+ filter := col "=" op "." value
12
+ op := "eq" | "in"
13
+ value := any-string (for eq)
14
+ | "(" v1 "," v2 … ")" (for in)
15
+ """
16
+
17
+ import itertools
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Literal
21
+
22
+ from .schemas import JoinConfig, PostgresChangesFilter, PostgresChangesSubscription
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Topic validation
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _TOPIC_RE = re.compile(r"^realtime:([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})$")
29
+
30
+
31
+ class TopicError(ValueError):
32
+ """Raised when a topic string does not conform to the grammar."""
33
+
34
+
35
+ def validate_topic(topic: str) -> str:
36
+ """Return the *name* portion of a valid ``realtime:<name>`` topic.
37
+
38
+ Raises :class:`TopicError` for any topic that does not match the grammar.
39
+ The reserved ``phoenix`` topic used for connection-level heartbeats is
40
+ intentionally excluded — the WebSocket handler manages it directly.
41
+ """
42
+ m = _TOPIC_RE.fullmatch(topic)
43
+ if m is None:
44
+ raise TopicError(
45
+ f"Invalid topic {topic!r}. "
46
+ "Expected 'realtime:<name>' where <name> matches [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}."
47
+ )
48
+ return m.group(1)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # postgres_changes filter parsing
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _FILTER_RE = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)=(eq|in)\.(.+)$")
56
+ _IN_BODY_RE = re.compile(r"^\((.+)\)$")
57
+
58
+
59
+ class FilterError(ValueError):
60
+ """Raised when a filter string cannot be parsed."""
61
+
62
+
63
+ @dataclass(slots=True, frozen=True)
64
+ class EqFilter:
65
+ column: str
66
+ value: str
67
+ op: Literal["eq"] = field(default="eq", init=False)
68
+
69
+
70
+ @dataclass(slots=True, frozen=True)
71
+ class InFilter:
72
+ column: str
73
+ values: tuple[str, ...]
74
+ op: Literal["in"] = field(default="in", init=False)
75
+
76
+
77
+ ParsedFilter = EqFilter | InFilter
78
+
79
+
80
+ def parse_filter(filter_str: str) -> ParsedFilter:
81
+ """Parse a PostgREST-style filter string into a typed value.
82
+
83
+ Supported operators for v0.4:
84
+ - ``col=eq.<value>``
85
+ - ``col=in.(<v1>,<v2>,…)``
86
+
87
+ Raises :class:`FilterError` for unsupported or malformed inputs.
88
+ """
89
+ m = _FILTER_RE.fullmatch(filter_str)
90
+ if m is None:
91
+ raise FilterError(
92
+ f"Cannot parse filter {filter_str!r}. "
93
+ "Expected 'col=eq.<val>' or 'col=in.(<v1>,<v2>,…)'."
94
+ )
95
+ column, op, raw_value = m.group(1), m.group(2), m.group(3)
96
+
97
+ if op == "eq":
98
+ return EqFilter(column=column, value=raw_value)
99
+
100
+ # op == "in"
101
+ body_m = _IN_BODY_RE.fullmatch(raw_value)
102
+ if body_m is None:
103
+ raise FilterError(
104
+ f"'in' filter value must be wrapped in parentheses, got {raw_value!r}."
105
+ )
106
+ values = tuple(v.strip() for v in body_m.group(1).split(",") if v.strip())
107
+ if not values:
108
+ raise FilterError(f"'in' filter has an empty value list in {filter_str!r}.")
109
+ return InFilter(column=column, values=values)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Subscription-id assignment
114
+ # ---------------------------------------------------------------------------
115
+
116
+ # Module-level monotonic counter. Single-process; each assigned id is unique
117
+ # for the lifetime of the server. The broker uses these as stable handles for
118
+ # the duration of a channel join; a rejoin produces new ids.
119
+ _sub_id_counter: "itertools.count[int]" = itertools.count(1)
120
+
121
+
122
+ def _next_sub_id() -> int:
123
+ return next(_sub_id_counter)
124
+
125
+
126
+ @dataclass(slots=True)
127
+ class ResolvedSubscription:
128
+ """A ``postgres_changes`` filter with its server-assigned id and parsed filter."""
129
+
130
+ id: int
131
+ filter_spec: PostgresChangesFilter
132
+ parsed_filter: ParsedFilter | None
133
+
134
+
135
+ def assign_subscription_ids(
136
+ config: JoinConfig,
137
+ ) -> list[ResolvedSubscription]:
138
+ """Assign a server-local integer id to every entry in *config.postgres_changes*.
139
+
140
+ Also eagerly parses each filter string so callers catch :class:`FilterError`
141
+ before sending the join ack. Entries with ``filter=None`` have
142
+ ``parsed_filter=None``.
143
+
144
+ Returns one :class:`ResolvedSubscription` per entry, **in the same order**
145
+ they appear in *config.postgres_changes* (required by A3).
146
+ """
147
+ result: list[ResolvedSubscription] = []
148
+ for spec in config.postgres_changes:
149
+ parsed: ParsedFilter | None = None
150
+ if spec.filter is not None:
151
+ parsed = parse_filter(spec.filter)
152
+ result.append(
153
+ ResolvedSubscription(
154
+ id=_next_sub_id(),
155
+ filter_spec=spec,
156
+ parsed_filter=parsed,
157
+ )
158
+ )
159
+ return result
160
+
161
+
162
+ def resolved_to_subscription_schema(
163
+ resolved: ResolvedSubscription,
164
+ ) -> PostgresChangesSubscription:
165
+ """Convert a :class:`ResolvedSubscription` to the Pydantic reply schema."""
166
+ spec = resolved.filter_spec
167
+ return PostgresChangesSubscription.model_validate(
168
+ {
169
+ "id": resolved.id,
170
+ "event": spec.event,
171
+ "schema": spec.schema_name,
172
+ "table": spec.table,
173
+ "filter": spec.filter,
174
+ }
175
+ )