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.
- synthigy-0.1.0/LICENSE +21 -0
- synthigy-0.1.0/PKG-INFO +310 -0
- synthigy-0.1.0/README.md +283 -0
- synthigy-0.1.0/pyproject.toml +43 -0
- synthigy-0.1.0/setup.cfg +4 -0
- synthigy-0.1.0/synthigy/__init__.py +164 -0
- synthigy-0.1.0/synthigy/async_client.py +2049 -0
- synthigy-0.1.0/synthigy/auth.py +262 -0
- synthigy-0.1.0/synthigy/backfill.py +45 -0
- synthigy-0.1.0/synthigy/codegen.py +819 -0
- synthigy-0.1.0/synthigy/compose.py +88 -0
- synthigy-0.1.0/synthigy/errors.py +122 -0
- synthigy-0.1.0/synthigy/events.py +294 -0
- synthigy-0.1.0/synthigy/facade.py +476 -0
- synthigy-0.1.0/synthigy/filters.py +35 -0
- synthigy-0.1.0/synthigy/ops.py +77 -0
- synthigy-0.1.0/synthigy/py.typed +0 -0
- synthigy-0.1.0/synthigy/selection.py +71 -0
- synthigy-0.1.0/synthigy/util.py +54 -0
- synthigy-0.1.0/synthigy.egg-info/PKG-INFO +310 -0
- synthigy-0.1.0/synthigy.egg-info/SOURCES.txt +34 -0
- synthigy-0.1.0/synthigy.egg-info/dependency_links.txt +1 -0
- synthigy-0.1.0/synthigy.egg-info/entry_points.txt +2 -0
- synthigy-0.1.0/synthigy.egg-info/top_level.txt +1 -0
- synthigy-0.1.0/tests/test_async_client.py +872 -0
- synthigy-0.1.0/tests/test_client.py +558 -0
- synthigy-0.1.0/tests/test_codegen.py +389 -0
- synthigy-0.1.0/tests/test_compose.py +74 -0
- synthigy-0.1.0/tests/test_filters.py +35 -0
- synthigy-0.1.0/tests/test_integration.py +279 -0
- synthigy-0.1.0/tests/test_pool.py +112 -0
- synthigy-0.1.0/tests/test_selection.py +90 -0
- synthigy-0.1.0/tests/test_sse.py +260 -0
- synthigy-0.1.0/tests/test_supervised_auth.py +293 -0
- synthigy-0.1.0/tests/test_watch.py +498 -0
- 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.
|
synthigy-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
synthigy-0.1.0/README.md
ADDED
|
@@ -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"]
|
synthigy-0.1.0/setup.cfg
ADDED