synthigy 0.1.0__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.
Files changed (36) hide show
  1. synthigy-0.1.0/LICENSE +21 -0
  2. synthigy-0.1.0/PKG-INFO +310 -0
  3. synthigy-0.1.0/README.md +283 -0
  4. synthigy-0.1.0/pyproject.toml +43 -0
  5. synthigy-0.1.0/setup.cfg +4 -0
  6. synthigy-0.1.0/synthigy/__init__.py +164 -0
  7. synthigy-0.1.0/synthigy/async_client.py +2049 -0
  8. synthigy-0.1.0/synthigy/auth.py +262 -0
  9. synthigy-0.1.0/synthigy/backfill.py +45 -0
  10. synthigy-0.1.0/synthigy/codegen.py +819 -0
  11. synthigy-0.1.0/synthigy/compose.py +88 -0
  12. synthigy-0.1.0/synthigy/errors.py +122 -0
  13. synthigy-0.1.0/synthigy/events.py +294 -0
  14. synthigy-0.1.0/synthigy/facade.py +476 -0
  15. synthigy-0.1.0/synthigy/filters.py +35 -0
  16. synthigy-0.1.0/synthigy/ops.py +77 -0
  17. synthigy-0.1.0/synthigy/py.typed +0 -0
  18. synthigy-0.1.0/synthigy/selection.py +71 -0
  19. synthigy-0.1.0/synthigy/util.py +54 -0
  20. synthigy-0.1.0/synthigy.egg-info/PKG-INFO +310 -0
  21. synthigy-0.1.0/synthigy.egg-info/SOURCES.txt +34 -0
  22. synthigy-0.1.0/synthigy.egg-info/dependency_links.txt +1 -0
  23. synthigy-0.1.0/synthigy.egg-info/entry_points.txt +2 -0
  24. synthigy-0.1.0/synthigy.egg-info/top_level.txt +1 -0
  25. synthigy-0.1.0/tests/test_async_client.py +872 -0
  26. synthigy-0.1.0/tests/test_client.py +558 -0
  27. synthigy-0.1.0/tests/test_codegen.py +389 -0
  28. synthigy-0.1.0/tests/test_compose.py +74 -0
  29. synthigy-0.1.0/tests/test_filters.py +35 -0
  30. synthigy-0.1.0/tests/test_integration.py +279 -0
  31. synthigy-0.1.0/tests/test_pool.py +112 -0
  32. synthigy-0.1.0/tests/test_selection.py +90 -0
  33. synthigy-0.1.0/tests/test_sse.py +260 -0
  34. synthigy-0.1.0/tests/test_supervised_auth.py +293 -0
  35. synthigy-0.1.0/tests/test_watch.py +498 -0
  36. synthigy-0.1.0/tests/test_watch_query.py +200 -0
synthigy-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Geršak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.4
2
+ Name: synthigy
3
+ Version: 0.1.0
4
+ Summary: Synthigy /data client SDK — zero-dependency, stdlib only
5
+ Author: Robert Geršak
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/synthigy/synthigy
8
+ Project-URL: Repository, https://github.com/synthigy/py
9
+ Project-URL: Issues, https://github.com/synthigy/py/issues
10
+ Keywords: synthigy,sdk,dataset,iam,crud,client,sse,asyncio,xsql
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Framework :: AsyncIO
20
+ Classifier: Topic :: Database :: Front-Ends
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # Synthigy Python SDK
29
+
30
+ Thin, **zero-dependency** (stdlib-only) client for Synthigy's `/data`
31
+ endpoint. **ONE engine, async-native**: everything runs on an asyncio core
32
+ (`AsyncClient`) — hand-rolled keep-alive HTTP/1.1 pool, one multiplexed
33
+ SSE connection, watches as suspended coroutines. The blocking `Client`
34
+ (and every module-level verb) is a thin facade over that engine driving a
35
+ single background event-loop thread — same API scripts and notebooks
36
+ always had, ~1 extra thread total no matter how many watches are open.
37
+ Python ≥ 3.10.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install synthigy # or: uv add synthigy
43
+ ```
44
+
45
+ No runtime dependencies to resolve. The wheel also installs the
46
+ `synthigy-gen` codegen CLI (equivalently `python3 -m synthigy.codegen`).
47
+
48
+ ## Hello world
49
+
50
+ ```python
51
+ import synthigy
52
+ from synthigy import eq, gt, rel
53
+
54
+ synthigy.connect("https://synthigy.example.com",
55
+ client_id="my-service",
56
+ client_secret=os.environ["SYNTHIGY_SECRET"])
57
+
58
+ users = synthigy.search("User",
59
+ {"active": eq(True), "age": gt(18), "_limit": 10,
60
+ "_order_by": [["name", "asc"]]},
61
+ {"name": None, "email": None, "roles": {"name": None}},
62
+ acting_as=user_xid)
63
+ ```
64
+
65
+ ## One process, one client
66
+
67
+ `connect()` installs a module-wide default — any previous default is
68
+ destroyed first (watches close, SSE drops). All module-level verbs operate
69
+ on it. **Identity is multiplexed per-call via `acting_as=`, never a second
70
+ connect.** Constructing `synthigy.Client(...)` directly is the escape hatch
71
+ for tests.
72
+
73
+ ## Async-first (BFFs, FastAPI/uvicorn services)
74
+
75
+ The async surface IS the engine — no thread hops, native cancellation:
76
+
77
+ ```python
78
+ synthigy.aconnect(endpoint, client_id=..., client_secret=SECRET) # once
79
+ c = synthigy.aclient()
80
+
81
+ rows = await c.search("Order", {"status": eq("open")}, {"total": None},
82
+ acting_as=user.xid)
83
+ await c.stack("Order", {"xid": oxid, "note": "expedite"}, acting_as=user.xid)
84
+
85
+ @app.get("/orders/stream")
86
+ async def stream(user=Depends(current_user)):
87
+ async def gen():
88
+ async with c.watch_query("order", acting_as=user.xid) as w:
89
+ yield render(w.list())
90
+ async for ev in w: # derived query/* events
91
+ yield render(w.list())
92
+ return StreamingResponse(gen(), media_type="text/event-stream")
93
+ ```
94
+
95
+ Request teardown is task cancellation in asyncio; watch cleanup is
96
+ cancellation-safe by design (`async with` guarantees close, close never
97
+ awaits). An open watch costs a buffer on the shared SSE connection — not
98
+ a thread, not a second connection.
99
+
100
+ `AsyncClient`/`Client` are themselves context managers too (like
101
+ `httpx.AsyncClient`/`httpx.Client`) — `async with AsyncClient(...) as c:` /
102
+ `with Client(...) as c:` close everything on scope exit, exception or not.
103
+ `aconnect`/`connect` (the module-default singleton) stay the norm for
104
+ long-lived processes; the context-manager form is for scripts and one-off
105
+ scoped clients (tests, short jobs).
106
+
107
+ The blocking `Client` below is for
108
+ scripts/seeds/notebooks; don't call it from a coroutine (it blocks the
109
+ loop, and calling it from its own loop thread raises).
110
+
111
+ ## Reads
112
+
113
+ ```python
114
+ rows = synthigy.search("Movie", {"_limit": 5}, {"title": None})
115
+ movie = synthigy.get("Movie", {"xid": "m-1"}, {"title": None}) # flat unique-key args
116
+ rows = synthigy.sql_template(
117
+ "SELECT COUNT(*) AS n FROM {movie} WHERE {movie.release_year} > ?", [1990])
118
+
119
+ # XSQL — string query surface (server parses/compiles; GraphQL model)
120
+ rows = synthigy.query("""
121
+ movie (release_year > ?y:int, _limit 10)
122
+ title
123
+ ->genres
124
+ name
125
+ """, {"y": 1990})
126
+ ```
127
+
128
+ - Operators: `eq neq gt gte lt lte in_ nin like ilike is_null is_not_null`
129
+ and `and_ / or_ / not_` (Python-keyword renames of the JS `and/or/not/in`).
130
+ - Selections mirror the shape you want back: `None` = scalar, nested dict =
131
+ relation. The server's join default is **LEFT** — a projected relation
132
+ never drops its parent, and relation args filter the related rows. To
133
+ scope parents to those HAVING the relation, be explicit:
134
+ `rel({...}, args={"_join": "inner"})`. The SDK injects nothing.
135
+ - **Empty relations are omitted** by the wire, never `[]` — use
136
+ `row.get("genres", [])`.
137
+ - Counting/aggregation has **no dedicated op**: use `sql_template` or XSQL
138
+ `_count`/`_agg` selections.
139
+
140
+ ## Writes
141
+
142
+ ```python
143
+ synthigy.sync("Movie", {"xid": "m-1", "title": "Dune",
144
+ "genres": [{"xid": "g-scifi"}]}) # upsert, REPLACES link-sets
145
+ synthigy.stack("user_rating", {"value": 5, "movie": {"xid": "m-1"}}) # additive
146
+ synthigy.slice("Movie", {"xid": "m-1"}, {"genres": [{"xid": "g-scifi"}]}) # unlink
147
+ synthigy.delete("Movie", {"xid": "m-1"}) # soft delete
148
+ synthigy.purge("user_rating", {"value": {"_lt": 2}}) # hard delete by filter
149
+ ```
150
+
151
+ **Writes are silent by default** — `sync`/`stack` answer `{"count": n}`, not
152
+ the record. Mint the id up front when you need it; that is cheaper than the
153
+ echo and makes a retried write idempotent rather than duplicating a row:
154
+
155
+ ```python
156
+ from synthigy import new_xid
157
+
158
+ xid = new_xid() # 22-char Base58
159
+ synthigy.sync("Movie", {"xid": xid, "title": "Dune"}) # -> {"count": 1}
160
+ synthigy.sync("Movie", {"xid": xid, "title": "Dune"},
161
+ returning=True) # -> the written record
162
+ ```
163
+
164
+ Batch heterogeneous ops in one round trip:
165
+
166
+ ```python
167
+ from synthigy import ops
168
+ results = synthigy.exec_([
169
+ ops.slice("User", {"xid": u}, {"roles": [{"xid": old}]}),
170
+ ops.stack("User", {"xid": u, "roles": [{"xid": new}]}),
171
+ ], acting_as=user_xid)
172
+ ```
173
+
174
+ ## Live data
175
+
176
+ The blessed pattern is **notify-then-refetch**: events are pokes; the SDK
177
+ re-runs the query through the IAM-filtered read path, so RLS is enforced on
178
+ every refetch.
179
+
180
+ ```python
181
+ w = synthigy.watch_query("Order", {"status": eq("open")}, {"total": None},
182
+ acting_as=user_xid)
183
+ w.ready()
184
+ for ev in w.events(): # blocking iterator; break to stop
185
+ if ev["type"] in ("query/added", "query/changed", "query/removed"):
186
+ render(w.list())
187
+
188
+ w2 = synthigy.watch_sql_template(
189
+ "SELECT COUNT(*) AS n FROM {task}", entities=["task"])
190
+ w2.ready(); print(w2.first())
191
+ ```
192
+
193
+ Lower-level: `watch(interest)` (records/entities/relations, shaped
194
+ record/relation deltas with computed `changed`), `watch_schema()`,
195
+ `listen()` (raw SSE envelopes), `observe(descriptor, backfill=True)`
196
+ (one-call subscribe + reconnect + `/history` gap replay). One SSE
197
+ connection per client — all watches fuse onto it. `keep_alive=True` on
198
+ connect pins the SSE open across watch churn (BFFs).
199
+
200
+ Raw subscription calls (`subscribe`/`set_subscriptions`/...) exist but the
201
+ server set is per-identity **full-replace** — raw calls clobber a live
202
+ watch multiplexer's union. Prefer the watch family.
203
+
204
+ ## History
205
+
206
+ ```python
207
+ h = synthigy.history()
208
+ h.events(record_xid=xid) # recent events up to now
209
+ h.get_at(xid, "2026-01-01T00:00:00Z")
210
+ h.diff(xid, t1, t2)
211
+ ```
212
+
213
+ Raises `HISTORY_UNAVAILABLE` when the server has no audit provider.
214
+
215
+ ## Errors
216
+
217
+ Everything raises `synthigy.SynthigyError` with stable `.code`, derived
218
+ `.category` (`auth | iam | validation | not_found | conflict | rate_limit |
219
+ network | internal`) and `.retryable`. Structured fields when the server
220
+ sends them: `.hint`, `.entity`, `.path`, `.line`/`.col`, `.diagnostics`,
221
+ `.request_id` (matches the `X-Request-Id` the SDK sends — correlate with
222
+ server logs). Discriminate on `.code`, never the message.
223
+
224
+ ## Auth
225
+
226
+ - Client credentials (`client_id`+`client_secret`): tokens minted from
227
+ `/oauth/token`, cached per audience, refreshed 30s before expiry,
228
+ single-flight; one automatic clear-and-retry on 401.
229
+ - `audience=` (or `$SYNTHIGY_AUDIENCE`): binds one audience to every mint this
230
+ client makes. The platform's audience model is **opt-in by design** — a token
231
+ minted naming no audience resolves to an identity-only audience that `/data`
232
+ rejects, so without this every data call 401s. Set it to the server's `/data`
233
+ audience, published at `/.well-known/synthigy` as `auth.oidc.audience`. Left
234
+ unset the SDK names no audience, so an unentitled client keeps a soft 401
235
+ rather than a hard `invalid_target`. `client.token(audience)` still overrides
236
+ per call, for minting tokens aimed at a *different* audience.
237
+ - Static `token="..."` for scripts/tests (`token=""` for authless dev).
238
+ - With none of the above, resolution continues: under
239
+ `SYNTHIGY_SUPERVISED=1` the SDK asks its supervising parent
240
+ (`synthigy exec`/`agent`, or a robotics commander) for a token over the
241
+ process's own stdio, then falls back to the `SYNTHIGY_TOKEN` env var,
242
+ then raises `SynthigyError(code="NO_TOKEN")` with a message that teaches
243
+ the fix. The pipe beats the env var deliberately: `exec` injects the
244
+ cached token *and* supervises, and only the pipe can refresh mid-run.
245
+ A bot written as `synthigy.Client(endpoint)` — nothing else — therefore
246
+ runs unchanged bare, under `exec`, and under a production commander.
247
+ See `docs/plans/PLAN-EXEC-IDENTITY.md`.
248
+ - `acting_as` is server-verified impersonation for **trusted confidential**
249
+ clients (the BFF model) — the SDK never handles end-user OAuth redirects.
250
+
251
+ ## Codegen
252
+
253
+ `python3 -m synthigy.codegen` turns an `.xsql` operations document + the
254
+ server schema into one typed Python module (TypedDict rows/params/inputs +
255
+ functions over the module-level verbs). The server owns the XSQL grammar —
256
+ `op:"describe"` compiles the source and returns a language-neutral IR; the
257
+ emitter renders Python from IR JSON and parses nothing.
258
+
259
+ ```bash
260
+ # 1. Pull the IAM-filtered schema (commit it)
261
+ SYNTHIGY_CLIENT_ID=... SYNTHIGY_CLIENT_SECRET=... \
262
+ python3 -m synthigy.codegen pull http://localhost:7887 schema.json
263
+
264
+ # 2. Describe + generate (saves movies.ir.json beside the .xsql — commit
265
+ # schema.json + .xsql + .ir.json; after that gen runs OFFLINE forever)
266
+ python3 -m synthigy.codegen gen movies.xsql # → movies_gen.py
267
+
268
+ # 3. CI drift gate: offline sourceHash check ("edited .xsql, forgot
269
+ # codegen"); live describe-diff when SYNTHIGY_* creds are present
270
+ python3 -m synthigy.codegen check movies.xsql
271
+ ```
272
+
273
+ ```python
274
+ import synthigy, movies_gen as ops
275
+ synthigy.connect(endpoint, client_id=..., client_secret=...)
276
+
277
+ rows = ops.Movie.list({"since": 2000, "limit": 10}) # list[MovieListRow]
278
+ movie = ops.Movie.detail({"xid": xid}) # MovieDetailRow | None
279
+ w = ops.Dashboard.watch_stats(); w.ready() # @watch variant
280
+ both = ops.overview() # @batch → ONE round trip
281
+ ops.sync_movie({"title": "Dune", "genres": [{"xid": "g-scifi"}]})
282
+ ```
283
+
284
+ Codegen authenticates **as the app** — the same client credentials the app
285
+ uses at runtime. `/schema` and `describe` are IAM-filtered per principal, so
286
+ the generated contract is exactly what the app can do; a personal/dev
287
+ identity would generate a surface the app can't honor. `--no-writes` skips
288
+ the schema-derived `<Entity>Input`/`sync_*`/`stack_*`/`delete_*` tier.
289
+ Empty or unknown op kinds in a saved IR are a hard error (a stale IR is
290
+ never silently emitted from), and data keys stay snake_case verbatim —
291
+ Python native is server native, no casing transform exists.
292
+
293
+ A committed end-to-end example lives in `codegen-example/`
294
+ (`movies.xsql` → `movies.ir.json` + `schema.json` → `movies_gen.py`).
295
+
296
+ ## Tests
297
+
298
+ ```bash
299
+ python3 -m unittest discover -s tests # hermetic (stub server)
300
+ SYNTHIGY_TEST_CLIENT_ID=... SYNTHIGY_TEST_CLIENT_SECRET=... \
301
+ python3 -m unittest tests.test_integration -v # live (default localhost:7887)
302
+ ```
303
+
304
+ Register a dedicated OAuth client for the live suite (trusted confidential,
305
+ `client_credentials`) — never share identity with a live app.
306
+
307
+ ## License
308
+
309
+ MIT — see [LICENSE](LICENSE). The SDKs are permissive client libraries; the
310
+ Synthigy engine is fair-code under the Sustainable Use License.
@@ -0,0 +1,283 @@
1
+ # Synthigy Python SDK
2
+
3
+ Thin, **zero-dependency** (stdlib-only) client for Synthigy's `/data`
4
+ endpoint. **ONE engine, async-native**: everything runs on an asyncio core
5
+ (`AsyncClient`) — hand-rolled keep-alive HTTP/1.1 pool, one multiplexed
6
+ SSE connection, watches as suspended coroutines. The blocking `Client`
7
+ (and every module-level verb) is a thin facade over that engine driving a
8
+ single background event-loop thread — same API scripts and notebooks
9
+ always had, ~1 extra thread total no matter how many watches are open.
10
+ Python ≥ 3.10.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install synthigy # or: uv add synthigy
16
+ ```
17
+
18
+ No runtime dependencies to resolve. The wheel also installs the
19
+ `synthigy-gen` codegen CLI (equivalently `python3 -m synthigy.codegen`).
20
+
21
+ ## Hello world
22
+
23
+ ```python
24
+ import synthigy
25
+ from synthigy import eq, gt, rel
26
+
27
+ synthigy.connect("https://synthigy.example.com",
28
+ client_id="my-service",
29
+ client_secret=os.environ["SYNTHIGY_SECRET"])
30
+
31
+ users = synthigy.search("User",
32
+ {"active": eq(True), "age": gt(18), "_limit": 10,
33
+ "_order_by": [["name", "asc"]]},
34
+ {"name": None, "email": None, "roles": {"name": None}},
35
+ acting_as=user_xid)
36
+ ```
37
+
38
+ ## One process, one client
39
+
40
+ `connect()` installs a module-wide default — any previous default is
41
+ destroyed first (watches close, SSE drops). All module-level verbs operate
42
+ on it. **Identity is multiplexed per-call via `acting_as=`, never a second
43
+ connect.** Constructing `synthigy.Client(...)` directly is the escape hatch
44
+ for tests.
45
+
46
+ ## Async-first (BFFs, FastAPI/uvicorn services)
47
+
48
+ The async surface IS the engine — no thread hops, native cancellation:
49
+
50
+ ```python
51
+ synthigy.aconnect(endpoint, client_id=..., client_secret=SECRET) # once
52
+ c = synthigy.aclient()
53
+
54
+ rows = await c.search("Order", {"status": eq("open")}, {"total": None},
55
+ acting_as=user.xid)
56
+ await c.stack("Order", {"xid": oxid, "note": "expedite"}, acting_as=user.xid)
57
+
58
+ @app.get("/orders/stream")
59
+ async def stream(user=Depends(current_user)):
60
+ async def gen():
61
+ async with c.watch_query("order", acting_as=user.xid) as w:
62
+ yield render(w.list())
63
+ async for ev in w: # derived query/* events
64
+ yield render(w.list())
65
+ return StreamingResponse(gen(), media_type="text/event-stream")
66
+ ```
67
+
68
+ Request teardown is task cancellation in asyncio; watch cleanup is
69
+ cancellation-safe by design (`async with` guarantees close, close never
70
+ awaits). An open watch costs a buffer on the shared SSE connection — not
71
+ a thread, not a second connection.
72
+
73
+ `AsyncClient`/`Client` are themselves context managers too (like
74
+ `httpx.AsyncClient`/`httpx.Client`) — `async with AsyncClient(...) as c:` /
75
+ `with Client(...) as c:` close everything on scope exit, exception or not.
76
+ `aconnect`/`connect` (the module-default singleton) stay the norm for
77
+ long-lived processes; the context-manager form is for scripts and one-off
78
+ scoped clients (tests, short jobs).
79
+
80
+ The blocking `Client` below is for
81
+ scripts/seeds/notebooks; don't call it from a coroutine (it blocks the
82
+ loop, and calling it from its own loop thread raises).
83
+
84
+ ## Reads
85
+
86
+ ```python
87
+ rows = synthigy.search("Movie", {"_limit": 5}, {"title": None})
88
+ movie = synthigy.get("Movie", {"xid": "m-1"}, {"title": None}) # flat unique-key args
89
+ rows = synthigy.sql_template(
90
+ "SELECT COUNT(*) AS n FROM {movie} WHERE {movie.release_year} > ?", [1990])
91
+
92
+ # XSQL — string query surface (server parses/compiles; GraphQL model)
93
+ rows = synthigy.query("""
94
+ movie (release_year > ?y:int, _limit 10)
95
+ title
96
+ ->genres
97
+ name
98
+ """, {"y": 1990})
99
+ ```
100
+
101
+ - Operators: `eq neq gt gte lt lte in_ nin like ilike is_null is_not_null`
102
+ and `and_ / or_ / not_` (Python-keyword renames of the JS `and/or/not/in`).
103
+ - Selections mirror the shape you want back: `None` = scalar, nested dict =
104
+ relation. The server's join default is **LEFT** — a projected relation
105
+ never drops its parent, and relation args filter the related rows. To
106
+ scope parents to those HAVING the relation, be explicit:
107
+ `rel({...}, args={"_join": "inner"})`. The SDK injects nothing.
108
+ - **Empty relations are omitted** by the wire, never `[]` — use
109
+ `row.get("genres", [])`.
110
+ - Counting/aggregation has **no dedicated op**: use `sql_template` or XSQL
111
+ `_count`/`_agg` selections.
112
+
113
+ ## Writes
114
+
115
+ ```python
116
+ synthigy.sync("Movie", {"xid": "m-1", "title": "Dune",
117
+ "genres": [{"xid": "g-scifi"}]}) # upsert, REPLACES link-sets
118
+ synthigy.stack("user_rating", {"value": 5, "movie": {"xid": "m-1"}}) # additive
119
+ synthigy.slice("Movie", {"xid": "m-1"}, {"genres": [{"xid": "g-scifi"}]}) # unlink
120
+ synthigy.delete("Movie", {"xid": "m-1"}) # soft delete
121
+ synthigy.purge("user_rating", {"value": {"_lt": 2}}) # hard delete by filter
122
+ ```
123
+
124
+ **Writes are silent by default** — `sync`/`stack` answer `{"count": n}`, not
125
+ the record. Mint the id up front when you need it; that is cheaper than the
126
+ echo and makes a retried write idempotent rather than duplicating a row:
127
+
128
+ ```python
129
+ from synthigy import new_xid
130
+
131
+ xid = new_xid() # 22-char Base58
132
+ synthigy.sync("Movie", {"xid": xid, "title": "Dune"}) # -> {"count": 1}
133
+ synthigy.sync("Movie", {"xid": xid, "title": "Dune"},
134
+ returning=True) # -> the written record
135
+ ```
136
+
137
+ Batch heterogeneous ops in one round trip:
138
+
139
+ ```python
140
+ from synthigy import ops
141
+ results = synthigy.exec_([
142
+ ops.slice("User", {"xid": u}, {"roles": [{"xid": old}]}),
143
+ ops.stack("User", {"xid": u, "roles": [{"xid": new}]}),
144
+ ], acting_as=user_xid)
145
+ ```
146
+
147
+ ## Live data
148
+
149
+ The blessed pattern is **notify-then-refetch**: events are pokes; the SDK
150
+ re-runs the query through the IAM-filtered read path, so RLS is enforced on
151
+ every refetch.
152
+
153
+ ```python
154
+ w = synthigy.watch_query("Order", {"status": eq("open")}, {"total": None},
155
+ acting_as=user_xid)
156
+ w.ready()
157
+ for ev in w.events(): # blocking iterator; break to stop
158
+ if ev["type"] in ("query/added", "query/changed", "query/removed"):
159
+ render(w.list())
160
+
161
+ w2 = synthigy.watch_sql_template(
162
+ "SELECT COUNT(*) AS n FROM {task}", entities=["task"])
163
+ w2.ready(); print(w2.first())
164
+ ```
165
+
166
+ Lower-level: `watch(interest)` (records/entities/relations, shaped
167
+ record/relation deltas with computed `changed`), `watch_schema()`,
168
+ `listen()` (raw SSE envelopes), `observe(descriptor, backfill=True)`
169
+ (one-call subscribe + reconnect + `/history` gap replay). One SSE
170
+ connection per client — all watches fuse onto it. `keep_alive=True` on
171
+ connect pins the SSE open across watch churn (BFFs).
172
+
173
+ Raw subscription calls (`subscribe`/`set_subscriptions`/...) exist but the
174
+ server set is per-identity **full-replace** — raw calls clobber a live
175
+ watch multiplexer's union. Prefer the watch family.
176
+
177
+ ## History
178
+
179
+ ```python
180
+ h = synthigy.history()
181
+ h.events(record_xid=xid) # recent events up to now
182
+ h.get_at(xid, "2026-01-01T00:00:00Z")
183
+ h.diff(xid, t1, t2)
184
+ ```
185
+
186
+ Raises `HISTORY_UNAVAILABLE` when the server has no audit provider.
187
+
188
+ ## Errors
189
+
190
+ Everything raises `synthigy.SynthigyError` with stable `.code`, derived
191
+ `.category` (`auth | iam | validation | not_found | conflict | rate_limit |
192
+ network | internal`) and `.retryable`. Structured fields when the server
193
+ sends them: `.hint`, `.entity`, `.path`, `.line`/`.col`, `.diagnostics`,
194
+ `.request_id` (matches the `X-Request-Id` the SDK sends — correlate with
195
+ server logs). Discriminate on `.code`, never the message.
196
+
197
+ ## Auth
198
+
199
+ - Client credentials (`client_id`+`client_secret`): tokens minted from
200
+ `/oauth/token`, cached per audience, refreshed 30s before expiry,
201
+ single-flight; one automatic clear-and-retry on 401.
202
+ - `audience=` (or `$SYNTHIGY_AUDIENCE`): binds one audience to every mint this
203
+ client makes. The platform's audience model is **opt-in by design** — a token
204
+ minted naming no audience resolves to an identity-only audience that `/data`
205
+ rejects, so without this every data call 401s. Set it to the server's `/data`
206
+ audience, published at `/.well-known/synthigy` as `auth.oidc.audience`. Left
207
+ unset the SDK names no audience, so an unentitled client keeps a soft 401
208
+ rather than a hard `invalid_target`. `client.token(audience)` still overrides
209
+ per call, for minting tokens aimed at a *different* audience.
210
+ - Static `token="..."` for scripts/tests (`token=""` for authless dev).
211
+ - With none of the above, resolution continues: under
212
+ `SYNTHIGY_SUPERVISED=1` the SDK asks its supervising parent
213
+ (`synthigy exec`/`agent`, or a robotics commander) for a token over the
214
+ process's own stdio, then falls back to the `SYNTHIGY_TOKEN` env var,
215
+ then raises `SynthigyError(code="NO_TOKEN")` with a message that teaches
216
+ the fix. The pipe beats the env var deliberately: `exec` injects the
217
+ cached token *and* supervises, and only the pipe can refresh mid-run.
218
+ A bot written as `synthigy.Client(endpoint)` — nothing else — therefore
219
+ runs unchanged bare, under `exec`, and under a production commander.
220
+ See `docs/plans/PLAN-EXEC-IDENTITY.md`.
221
+ - `acting_as` is server-verified impersonation for **trusted confidential**
222
+ clients (the BFF model) — the SDK never handles end-user OAuth redirects.
223
+
224
+ ## Codegen
225
+
226
+ `python3 -m synthigy.codegen` turns an `.xsql` operations document + the
227
+ server schema into one typed Python module (TypedDict rows/params/inputs +
228
+ functions over the module-level verbs). The server owns the XSQL grammar —
229
+ `op:"describe"` compiles the source and returns a language-neutral IR; the
230
+ emitter renders Python from IR JSON and parses nothing.
231
+
232
+ ```bash
233
+ # 1. Pull the IAM-filtered schema (commit it)
234
+ SYNTHIGY_CLIENT_ID=... SYNTHIGY_CLIENT_SECRET=... \
235
+ python3 -m synthigy.codegen pull http://localhost:7887 schema.json
236
+
237
+ # 2. Describe + generate (saves movies.ir.json beside the .xsql — commit
238
+ # schema.json + .xsql + .ir.json; after that gen runs OFFLINE forever)
239
+ python3 -m synthigy.codegen gen movies.xsql # → movies_gen.py
240
+
241
+ # 3. CI drift gate: offline sourceHash check ("edited .xsql, forgot
242
+ # codegen"); live describe-diff when SYNTHIGY_* creds are present
243
+ python3 -m synthigy.codegen check movies.xsql
244
+ ```
245
+
246
+ ```python
247
+ import synthigy, movies_gen as ops
248
+ synthigy.connect(endpoint, client_id=..., client_secret=...)
249
+
250
+ rows = ops.Movie.list({"since": 2000, "limit": 10}) # list[MovieListRow]
251
+ movie = ops.Movie.detail({"xid": xid}) # MovieDetailRow | None
252
+ w = ops.Dashboard.watch_stats(); w.ready() # @watch variant
253
+ both = ops.overview() # @batch → ONE round trip
254
+ ops.sync_movie({"title": "Dune", "genres": [{"xid": "g-scifi"}]})
255
+ ```
256
+
257
+ Codegen authenticates **as the app** — the same client credentials the app
258
+ uses at runtime. `/schema` and `describe` are IAM-filtered per principal, so
259
+ the generated contract is exactly what the app can do; a personal/dev
260
+ identity would generate a surface the app can't honor. `--no-writes` skips
261
+ the schema-derived `<Entity>Input`/`sync_*`/`stack_*`/`delete_*` tier.
262
+ Empty or unknown op kinds in a saved IR are a hard error (a stale IR is
263
+ never silently emitted from), and data keys stay snake_case verbatim —
264
+ Python native is server native, no casing transform exists.
265
+
266
+ A committed end-to-end example lives in `codegen-example/`
267
+ (`movies.xsql` → `movies.ir.json` + `schema.json` → `movies_gen.py`).
268
+
269
+ ## Tests
270
+
271
+ ```bash
272
+ python3 -m unittest discover -s tests # hermetic (stub server)
273
+ SYNTHIGY_TEST_CLIENT_ID=... SYNTHIGY_TEST_CLIENT_SECRET=... \
274
+ python3 -m unittest tests.test_integration -v # live (default localhost:7887)
275
+ ```
276
+
277
+ Register a dedicated OAuth client for the live suite (trusted confidential,
278
+ `client_credentials`) — never share identity with a live app.
279
+
280
+ ## License
281
+
282
+ MIT — see [LICENSE](LICENSE). The SDKs are permissive client libraries; the
283
+ Synthigy engine is fair-code under the Sustainable Use License.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "synthigy"
7
+ version = "0.1.0"
8
+ description = "Synthigy /data client SDK — zero-dependency, stdlib only"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ authors = [{ name = "Robert Geršak" }]
15
+ keywords = ["synthigy", "sdk", "dataset", "iam", "crud", "client", "sse", "asyncio", "xsql"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Framework :: AsyncIO",
26
+ "Topic :: Database :: Front-Ends",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/synthigy/synthigy"
33
+ Repository = "https://github.com/synthigy/py"
34
+ Issues = "https://github.com/synthigy/py/issues"
35
+
36
+ [project.scripts]
37
+ synthigy-gen = "synthigy.codegen:main"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["synthigy*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ synthigy = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+