storepy 2.5.0__tar.gz → 2.7.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.
- {storepy-2.5.0 → storepy-2.7.0}/PKG-INFO +83 -1
- {storepy-2.5.0 → storepy-2.7.0}/README.md +82 -0
- {storepy-2.5.0 → storepy-2.7.0}/pyproject.toml +5 -1
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/__init__.py +54 -16
- storepy-2.7.0/src/py_store/ask.py +322 -0
- storepy-2.7.0/src/py_store/ask_knowledge.md +250 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/write.py +62 -5
- storepy-2.7.0/src/py_store/ddl.py +390 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/__init__.py +13 -1
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/mongo.py +14 -2
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/mysql.py +6 -1
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/postgres.py +5 -3
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/sqlite.py +5 -1
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/feedback.py +5 -0
- storepy-2.7.0/src/py_store/llm.py +141 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/permission.py +48 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/schema.py +36 -1
- storepy-2.7.0/src/py_store/workflow.py +690 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/storepy.egg-info/PKG-INFO +83 -1
- {storepy-2.5.0 → storepy-2.7.0}/src/storepy.egg-info/SOURCES.txt +10 -1
- storepy-2.7.0/tests/test_ask.py +454 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_host_paths.py +48 -45
- storepy-2.7.0/tests/test_migration.py +130 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_profile.py +7 -4
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_py_store.py +24 -24
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_raw_sql_and_ddl.py +2 -1
- storepy-2.7.0/tests/test_rbac_host.py +105 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_require_context.py +7 -4
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_scenario_course_platform.py +7 -6
- storepy-2.7.0/tests/test_scenario_manager_transaction.py +151 -0
- storepy-2.7.0/tests/test_workflow.py +299 -0
- storepy-2.5.0/src/py_store/ddl.py +0 -154
- {storepy-2.5.0 → storepy-2.7.0}/LICENSE +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/setup.cfg +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/core.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/__init__.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/exec.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/id.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/mutation.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/crud/query.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/datasource.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/executors/_values.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/introspect/__init__.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/introspect/mysql.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/introspect/postgres.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/py_store/sync.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_coverage_margin.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_federation_e2e.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_host_contract.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_mongo_executor.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_mongo_session.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_multi_datasource.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_real_backends_e2e.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_session.py +0 -0
- {storepy-2.5.0 → storepy-2.7.0}/tests/test_sql_commit_and_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storepy
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
5
|
Author-email: leo <coen_ddt@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -85,6 +85,7 @@ from py_store import init, store
|
|
|
85
85
|
- [Feedback events](#feedback-events)
|
|
86
86
|
- [Schema reference](#schema-reference)
|
|
87
87
|
- [Transactions](#transaction-boundary)
|
|
88
|
+
- [Transactional capabilities](#transactional-capabilities)
|
|
88
89
|
- [FAQ](#faq)
|
|
89
90
|
- [Related projects](#related-projects)
|
|
90
91
|
|
|
@@ -265,6 +266,9 @@ GQL tree queries compile to a single native query per backend — never hand-wri
|
|
|
265
266
|
- **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
266
267
|
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
267
268
|
- **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
|
|
269
|
+
- **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
|
|
270
|
+
- **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
|
|
271
|
+
- **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
|
|
268
272
|
|
|
269
273
|
## GQL syntax
|
|
270
274
|
|
|
@@ -548,6 +552,84 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
548
552
|
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
549
553
|
- **Cross-source writes (no session)**: a single write call touching ≥2 datasources **cannot be atomic**; it runs sequentially and emits one `non_atomic_write` feedback event (`code: nonAtomic`, with the source list) — degradation is allowed, silence is not. Converge writes onto a single source, or wrap them in `store.session()` (which fails closed on cross-source writes).
|
|
550
554
|
|
|
555
|
+
## Transactional capabilities
|
|
556
|
+
|
|
557
|
+
Capabilities aimed at transactional workloads (orders, inventory — write contention plus
|
|
558
|
+
complex reads). Full details, semantics and the explicit-error list:
|
|
559
|
+
**[doc/transaction-capabilities.md](doc/transaction-capabilities.md)** ·
|
|
560
|
+
[中文](doc/transaction-capabilities.zh-CN.md).
|
|
561
|
+
|
|
562
|
+
- **Relation predicates in mutations** — `update_many('Inventory', {'product': {'category': 'meat'}}, {'$inc': {'stock': 10}})`: condition keys matching a declared relation become a semi/anti-join, normalized into a preCommand (aggregate fetching `_id`s) plus `_id $in`.
|
|
563
|
+
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
564
|
+
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
565
|
+
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
566
|
+
- **Declarative migration** — `ddl.diff_defs(old, new)` + `ddl.generate_migration(backend, old, new)`: whitelist-only (add table/column/index, type widening), per-dialect SQL, pure functions; destructive changes fail with `MIGRATION_UNSUPPORTED`.
|
|
567
|
+
|
|
568
|
+
## Workflow orchestration (first batch)
|
|
569
|
+
|
|
570
|
+
Express "orchestration of multi-step data operations" as data: a workflow definition (defn) is pure
|
|
571
|
+
JSON isomorphic to a schema defn, and each run is persisted to the built-in schema `__workflowRun`
|
|
572
|
+
(queryable with plain GQL — zero new observability endpoints). Execution generalizes the existing
|
|
573
|
+
mutation step-sequence mechanism: linear steps + per-step `when` guards + fail-fast. The engine
|
|
574
|
+
lives in the host layer (`py_store/workflow.py`), core unchanged; aligned with
|
|
575
|
+
`nodejs-store/src/workflow.js` (byte-identical outputs guarded by parity anchor tests).
|
|
576
|
+
|
|
577
|
+
```python
|
|
578
|
+
from py_store import workflow
|
|
579
|
+
|
|
580
|
+
workflow.register({
|
|
581
|
+
'name': 'placeOrder',
|
|
582
|
+
'run': ['admin', 'ops'], # three-tier whitelists read/write/run (run falls back to write)
|
|
583
|
+
'steps': [
|
|
584
|
+
{'op': 'query', 'as': 'inv',
|
|
585
|
+
'gql': 'Inventory($condition:@c0){_id, stock}',
|
|
586
|
+
'params': {'c0': {'productId': '{{input.productId}}', 'warehouse': '{{input.warehouse}}'}}},
|
|
587
|
+
{'op': 'fail', 'when': {'exists': '{{inv._id}}', 'is': None}, 'message': '库存记录不存在'},
|
|
588
|
+
{'op': 'fail', 'when': {'lt': '{{inv.stock}}', 'than': '{{input.qty}}'}, 'message': '库存不足'},
|
|
589
|
+
{'op': 'mutation', 'model': 'Inventory',
|
|
590
|
+
'data': {'_id': '{{inv._id}}', 'stock': '{{dec:{{inv.stock}},{{input.qty}}}}'}},
|
|
591
|
+
],
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
run = await workflow.run('placeOrder', {'productId': 'p1', 'warehouse': 'w1', 'qty': 30})
|
|
595
|
+
# run['status'] ∈ succeeded | failed | rejected | drySucceeded | dryFailed
|
|
596
|
+
# Uniform contract: business failures never raise; the error lives in run['error']
|
|
597
|
+
# (set only on failure; always null on success — never `||`-masked downstream)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
- **Step whitelist** (three kinds; anything else fails registration with `WORKFLOW_UNSUPPORTED`):
|
|
601
|
+
`query` (result must be unique — >1 row is an explicit error), `mutation` (store.mutation /
|
|
602
|
+
upsert), `fail` (explicit business assertion). Optional `when` guards (exists / is / eq / ne /
|
|
603
|
+
lt / lte / gt / gte) record `skipped` explicitly — never silently skipped.
|
|
604
|
+
- **Placeholders**: `{{input.<path>}}`, `{{<as>.<path>}}` (forward references only),
|
|
605
|
+
`{{dec:<a>,<b>}}`; full-string replacement keeps the value type. No placeholders inside gql
|
|
606
|
+
(bind via params — injection safety); no array-index path segments.
|
|
607
|
+
- **Permissions**: three-tier role whitelists embedded in the defn (same RBAC semantics: admin /
|
|
608
|
+
super_admin bypass, guest denied, internal bypass); runs inherit the caller's Context and every
|
|
609
|
+
step goes through core permission checks — no superuser. `require_context(true)` rejects
|
|
610
|
+
context-less runs (fail-secure wins over dry-run); rejected runs are persisted for audit.
|
|
611
|
+
- **Atomicity**: a single-source run is atomic across steps (outer `run_atomic` wraps the whole
|
|
612
|
+
loop, inner mutations nest into it); multi-source / prescan-failed runs execute sequentially and
|
|
613
|
+
emit feedback events (`workflow_non_atomic` / `workflow_prescan_failed`) — never silent. The run
|
|
614
|
+
record (running → terminal) is committed outside the business transaction so failed runs stay
|
|
615
|
+
queryable after rollback.
|
|
616
|
+
- **dry-run**: `workflow.run(name, input, dry_run=True)` — query steps execute for real (read-only
|
|
617
|
+
safe); mutation / fail are recorded as `wouldRun` (`drySucceeded | dryFailed`).
|
|
618
|
+
- **Run persistence**: `__workflowRun` is bootstrapped on import (idempotent); SQL backends need a
|
|
619
|
+
one-time `ddl.generate(backend, ['__workflowRun'])` (Mongo creates the collection on first write).
|
|
620
|
+
Its `write` whitelist is explicitly empty (GQL tampering with run audit is rejected by R2).
|
|
621
|
+
|
|
622
|
+
### Explicitly not in the first batch (detected → error; boundaries shipped with the same weight as features)
|
|
623
|
+
|
|
624
|
+
| Not supported | Why | Escape hatch |
|
|
625
|
+
|---|---|---|
|
|
626
|
+
| Loops / parallel / sub-workflows / human approval | DAG & wait semantics explode; linear + `when` covers the first batch | orchestrate in host code via the store API |
|
|
627
|
+
| Auto compensation (Saga) / auto retry | Inverse-operation burden; steps have no automatic idempotency | inspect run records and handle explicitly |
|
|
628
|
+
| Per-step host callbacks | Arbitrary code breaks whitelist governance | schema computes (read) / host code (write) |
|
|
629
|
+
| Workflow defn persistence / hot reload | Depends on schema versioning (next on the roadmap) | defn stays code-side JSON + register, like schemas today |
|
|
630
|
+
| Timers / event triggers | Scheduling is a resident-IO concern, orthogonal to pure orchestration | call `run` from the application layer |
|
|
631
|
+
| Placeholders inside gql / array-index paths | Injection surface / per-row iteration semantics | params binding / host-code orchestration |
|
|
632
|
+
|
|
551
633
|
## FAQ
|
|
552
634
|
|
|
553
635
|
**How do I use one schema for both MongoDB and PostgreSQL in Python?**
|
|
@@ -45,6 +45,7 @@ from py_store import init, store
|
|
|
45
45
|
- [Feedback events](#feedback-events)
|
|
46
46
|
- [Schema reference](#schema-reference)
|
|
47
47
|
- [Transactions](#transaction-boundary)
|
|
48
|
+
- [Transactional capabilities](#transactional-capabilities)
|
|
48
49
|
- [FAQ](#faq)
|
|
49
50
|
- [Related projects](#related-projects)
|
|
50
51
|
|
|
@@ -225,6 +226,9 @@ GQL tree queries compile to a single native query per backend — never hand-wri
|
|
|
225
226
|
- **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
226
227
|
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
227
228
|
- **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
|
|
229
|
+
- **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
|
|
230
|
+
- **Autoincrement primary keys** — declare `_id` as `{"type": "int", "strategy": "autoincrement"}` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
|
|
231
|
+
- **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
|
|
228
232
|
|
|
229
233
|
## GQL syntax
|
|
230
234
|
|
|
@@ -508,6 +512,84 @@ Boundary rules worth knowing up front (all **fail explicitly**, never silently d
|
|
|
508
512
|
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
509
513
|
- **Cross-source writes (no session)**: a single write call touching ≥2 datasources **cannot be atomic**; it runs sequentially and emits one `non_atomic_write` feedback event (`code: nonAtomic`, with the source list) — degradation is allowed, silence is not. Converge writes onto a single source, or wrap them in `store.session()` (which fails closed on cross-source writes).
|
|
510
514
|
|
|
515
|
+
## Transactional capabilities
|
|
516
|
+
|
|
517
|
+
Capabilities aimed at transactional workloads (orders, inventory — write contention plus
|
|
518
|
+
complex reads). Full details, semantics and the explicit-error list:
|
|
519
|
+
**[doc/transaction-capabilities.md](doc/transaction-capabilities.md)** ·
|
|
520
|
+
[中文](doc/transaction-capabilities.zh-CN.md).
|
|
521
|
+
|
|
522
|
+
- **Relation predicates in mutations** — `update_many('Inventory', {'product': {'category': 'meat'}}, {'$inc': {'stock': 10}})`: condition keys matching a declared relation become a semi/anti-join, normalized into a preCommand (aggregate fetching `_id`s) plus `_id $in`.
|
|
523
|
+
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
524
|
+
- **Autoincrement PKs** — `_id: {'type': 'int', 'strategy': 'autoincrement'}`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via last-insert-id; MongoDB and `insert_many` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
525
|
+
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
526
|
+
- **Declarative migration** — `ddl.diff_defs(old, new)` + `ddl.generate_migration(backend, old, new)`: whitelist-only (add table/column/index, type widening), per-dialect SQL, pure functions; destructive changes fail with `MIGRATION_UNSUPPORTED`.
|
|
527
|
+
|
|
528
|
+
## Workflow orchestration (first batch)
|
|
529
|
+
|
|
530
|
+
Express "orchestration of multi-step data operations" as data: a workflow definition (defn) is pure
|
|
531
|
+
JSON isomorphic to a schema defn, and each run is persisted to the built-in schema `__workflowRun`
|
|
532
|
+
(queryable with plain GQL — zero new observability endpoints). Execution generalizes the existing
|
|
533
|
+
mutation step-sequence mechanism: linear steps + per-step `when` guards + fail-fast. The engine
|
|
534
|
+
lives in the host layer (`py_store/workflow.py`), core unchanged; aligned with
|
|
535
|
+
`nodejs-store/src/workflow.js` (byte-identical outputs guarded by parity anchor tests).
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
from py_store import workflow
|
|
539
|
+
|
|
540
|
+
workflow.register({
|
|
541
|
+
'name': 'placeOrder',
|
|
542
|
+
'run': ['admin', 'ops'], # three-tier whitelists read/write/run (run falls back to write)
|
|
543
|
+
'steps': [
|
|
544
|
+
{'op': 'query', 'as': 'inv',
|
|
545
|
+
'gql': 'Inventory($condition:@c0){_id, stock}',
|
|
546
|
+
'params': {'c0': {'productId': '{{input.productId}}', 'warehouse': '{{input.warehouse}}'}}},
|
|
547
|
+
{'op': 'fail', 'when': {'exists': '{{inv._id}}', 'is': None}, 'message': '库存记录不存在'},
|
|
548
|
+
{'op': 'fail', 'when': {'lt': '{{inv.stock}}', 'than': '{{input.qty}}'}, 'message': '库存不足'},
|
|
549
|
+
{'op': 'mutation', 'model': 'Inventory',
|
|
550
|
+
'data': {'_id': '{{inv._id}}', 'stock': '{{dec:{{inv.stock}},{{input.qty}}}}'}},
|
|
551
|
+
],
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
run = await workflow.run('placeOrder', {'productId': 'p1', 'warehouse': 'w1', 'qty': 30})
|
|
555
|
+
# run['status'] ∈ succeeded | failed | rejected | drySucceeded | dryFailed
|
|
556
|
+
# Uniform contract: business failures never raise; the error lives in run['error']
|
|
557
|
+
# (set only on failure; always null on success — never `||`-masked downstream)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
- **Step whitelist** (three kinds; anything else fails registration with `WORKFLOW_UNSUPPORTED`):
|
|
561
|
+
`query` (result must be unique — >1 row is an explicit error), `mutation` (store.mutation /
|
|
562
|
+
upsert), `fail` (explicit business assertion). Optional `when` guards (exists / is / eq / ne /
|
|
563
|
+
lt / lte / gt / gte) record `skipped` explicitly — never silently skipped.
|
|
564
|
+
- **Placeholders**: `{{input.<path>}}`, `{{<as>.<path>}}` (forward references only),
|
|
565
|
+
`{{dec:<a>,<b>}}`; full-string replacement keeps the value type. No placeholders inside gql
|
|
566
|
+
(bind via params — injection safety); no array-index path segments.
|
|
567
|
+
- **Permissions**: three-tier role whitelists embedded in the defn (same RBAC semantics: admin /
|
|
568
|
+
super_admin bypass, guest denied, internal bypass); runs inherit the caller's Context and every
|
|
569
|
+
step goes through core permission checks — no superuser. `require_context(true)` rejects
|
|
570
|
+
context-less runs (fail-secure wins over dry-run); rejected runs are persisted for audit.
|
|
571
|
+
- **Atomicity**: a single-source run is atomic across steps (outer `run_atomic` wraps the whole
|
|
572
|
+
loop, inner mutations nest into it); multi-source / prescan-failed runs execute sequentially and
|
|
573
|
+
emit feedback events (`workflow_non_atomic` / `workflow_prescan_failed`) — never silent. The run
|
|
574
|
+
record (running → terminal) is committed outside the business transaction so failed runs stay
|
|
575
|
+
queryable after rollback.
|
|
576
|
+
- **dry-run**: `workflow.run(name, input, dry_run=True)` — query steps execute for real (read-only
|
|
577
|
+
safe); mutation / fail are recorded as `wouldRun` (`drySucceeded | dryFailed`).
|
|
578
|
+
- **Run persistence**: `__workflowRun` is bootstrapped on import (idempotent); SQL backends need a
|
|
579
|
+
one-time `ddl.generate(backend, ['__workflowRun'])` (Mongo creates the collection on first write).
|
|
580
|
+
Its `write` whitelist is explicitly empty (GQL tampering with run audit is rejected by R2).
|
|
581
|
+
|
|
582
|
+
### Explicitly not in the first batch (detected → error; boundaries shipped with the same weight as features)
|
|
583
|
+
|
|
584
|
+
| Not supported | Why | Escape hatch |
|
|
585
|
+
|---|---|---|
|
|
586
|
+
| Loops / parallel / sub-workflows / human approval | DAG & wait semantics explode; linear + `when` covers the first batch | orchestrate in host code via the store API |
|
|
587
|
+
| Auto compensation (Saga) / auto retry | Inverse-operation burden; steps have no automatic idempotency | inspect run records and handle explicitly |
|
|
588
|
+
| Per-step host callbacks | Arbitrary code breaks whitelist governance | schema computes (read) / host code (write) |
|
|
589
|
+
| Workflow defn persistence / hot reload | Depends on schema versioning (next on the roadmap) | defn stays code-side JSON + register, like schemas today |
|
|
590
|
+
| Timers / event triggers | Scheduling is a resident-IO concern, orthogonal to pure orchestration | call `run` from the application layer |
|
|
591
|
+
| Placeholders inside gql / array-index paths | Injection surface / per-row iteration semantics | params binding / host-code orchestration |
|
|
592
|
+
|
|
511
593
|
## FAQ
|
|
512
594
|
|
|
513
595
|
**How do I use one schema for both MongoDB and PostgreSQL in Python?**
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storepy"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.7.0"
|
|
8
8
|
description = "Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -56,6 +56,10 @@ Issues = "https://github.com/coenddt/py-store/issues"
|
|
|
56
56
|
[tool.setuptools.packages.find]
|
|
57
57
|
where = ["src"]
|
|
58
58
|
|
|
59
|
+
# ask() 的 system prompt 知识源(text-to-query 通用契约裁剪版)随包分发
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
py_store = ["ask_knowledge.md"]
|
|
62
|
+
|
|
59
63
|
[tool.pytest.ini_options]
|
|
60
64
|
testpaths = ["tests"]
|
|
61
65
|
pythonpath = ["src"]
|
|
@@ -20,18 +20,19 @@ py-store — 轻量多后端数据层(Python 版,Rust 单核心架构;支
|
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from collections.abc import Mapping
|
|
23
|
-
from contextlib import contextmanager
|
|
24
23
|
from typing import Any
|
|
25
24
|
|
|
26
25
|
from pymongo.errors import PyMongoError
|
|
27
26
|
|
|
28
27
|
from . import (
|
|
28
|
+
ask,
|
|
29
29
|
crud,
|
|
30
30
|
datasource,
|
|
31
31
|
ddl,
|
|
32
32
|
feedback,
|
|
33
33
|
permission,
|
|
34
34
|
schema,
|
|
35
|
+
workflow,
|
|
35
36
|
)
|
|
36
37
|
from . import (
|
|
37
38
|
executors as executors,
|
|
@@ -39,10 +40,16 @@ from . import (
|
|
|
39
40
|
from . import (
|
|
40
41
|
introspect as introspect,
|
|
41
42
|
)
|
|
43
|
+
from .ask import AskExhausted as AskExhausted
|
|
44
|
+
from .ask import AskResult as AskResult
|
|
42
45
|
from .datasource import NativeCommandError as NativeCommandError
|
|
43
46
|
from .datasource import NonAtomicWriteError as NonAtomicWriteError
|
|
44
47
|
from .datasource import RawSqlError as RawSqlError
|
|
45
48
|
from .datasource import Session as Session
|
|
49
|
+
from .llm import get_llm as get_llm
|
|
50
|
+
from .llm import make_openai_compat as make_openai_compat
|
|
51
|
+
from .llm import register_llm as register_llm
|
|
52
|
+
from .schema import text2query
|
|
46
53
|
from .sync import sync_schema
|
|
47
54
|
|
|
48
55
|
|
|
@@ -52,21 +59,6 @@ def _build_pipeline(gql, params=None):
|
|
|
52
59
|
gql, params if params is not None else {}, permission.get_context())
|
|
53
60
|
|
|
54
61
|
|
|
55
|
-
@contextmanager
|
|
56
|
-
def text2query():
|
|
57
|
-
"""以 text2query 档执行(功能收缩 + 硬限制),退出恢复原档位。
|
|
58
|
-
|
|
59
|
-
AI 问数链路入口;与 ``permission.scoped_roles`` 同构(token-set/reset,嵌套安全)。
|
|
60
|
-
进入档位即等效强制携带用户上下文(core `ensure_profile_ctx`,见执行文档 §4.2)。
|
|
61
|
-
"""
|
|
62
|
-
prev = schema.get_profile()
|
|
63
|
-
schema.set_profile('text2query')
|
|
64
|
-
try:
|
|
65
|
-
yield
|
|
66
|
-
finally:
|
|
67
|
-
schema.set_profile(prev)
|
|
68
|
-
|
|
69
|
-
|
|
70
62
|
class Store:
|
|
71
63
|
"""以属性方式访问各 API(**全显式类方法,无动态查找**)
|
|
72
64
|
|
|
@@ -203,6 +195,35 @@ class Store:
|
|
|
203
195
|
"""从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6)"""
|
|
204
196
|
return ddl.generate(backend, names)
|
|
205
197
|
|
|
198
|
+
# ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.py 与设计文档)──
|
|
199
|
+
def registerWorkflow(self, defn: dict) -> dict:
|
|
200
|
+
"""注册工作流定义(注册即静态校验,白名单外显式 Err 含 WORKFLOW_UNSUPPORTED)"""
|
|
201
|
+
return workflow.register(defn)
|
|
202
|
+
|
|
203
|
+
def workflows(self, ctx: dict | None = None) -> list[str]:
|
|
204
|
+
"""全部可见工作流名(read 白名单过滤)"""
|
|
205
|
+
return workflow.list(ctx)
|
|
206
|
+
|
|
207
|
+
def getWorkflow(self, name: str, ctx: dict | None = None) -> dict:
|
|
208
|
+
"""按名取工作流定义(read 白名单过滤;不可见与不存在同形——防枚举)"""
|
|
209
|
+
return workflow.get(name, ctx)
|
|
210
|
+
|
|
211
|
+
async def runWorkflow(self, name: str, input: dict | None = None, *,
|
|
212
|
+
dry_run: bool = False,
|
|
213
|
+
route_override: dict | None = None) -> dict[str, Any]:
|
|
214
|
+
"""触发工作流 → 完整 run 文档(终态 failed/rejected 不抛错,以 run.status + error 表达)"""
|
|
215
|
+
return await workflow.run(name, input, dry_run=dry_run, route_override=route_override)
|
|
216
|
+
|
|
217
|
+
# ── AI 问数(L1,只读):自然语言 → LLM 翻译 → text2query 沙箱执行 → 结构化回喂 ──
|
|
218
|
+
# 护栏(档位/ctx/route_override)全部服务端硬编码于 ask.py,零暴露进 LLM 消息面(D5)
|
|
219
|
+
# (先于 ask 赋值取 describe_for_ai:赋值后类体命名空间的 ask 不再是模块)
|
|
220
|
+
describeForAi = staticmethod(ask.describe_for_ai)
|
|
221
|
+
describe_for_ai = describeForAi
|
|
222
|
+
ask = staticmethod(ask.ask)
|
|
223
|
+
# 问数结果/耗尽错误(实例可被 store.AskExhausted 捕获;对齐 PermissionError 先例)
|
|
224
|
+
AskResult = AskResult
|
|
225
|
+
AskExhausted = AskExhausted
|
|
226
|
+
|
|
206
227
|
# ── 驼峰别名(与上方同名蛇形方法为**同一实现**,仅命名差异)──
|
|
207
228
|
queryOne = query_one
|
|
208
229
|
queryWithCount = query_with_count
|
|
@@ -214,6 +235,10 @@ class Store:
|
|
|
214
235
|
executeRaw = execute_raw
|
|
215
236
|
executeNative = execute_native
|
|
216
237
|
generateDdl = generate_ddl
|
|
238
|
+
# 工作流编排(蛇形别名与上方驼峰同实现)
|
|
239
|
+
register_workflow = registerWorkflow
|
|
240
|
+
run_workflow = runWorkflow
|
|
241
|
+
get_workflow = getWorkflow
|
|
217
242
|
|
|
218
243
|
# ── 其余 API 显式绑定(staticmethod:避免实例化后 self 注入)──
|
|
219
244
|
# Schema 管理
|
|
@@ -243,6 +268,19 @@ class Store:
|
|
|
243
268
|
set_require_context = staticmethod(schema.set_require_context)
|
|
244
269
|
requireContext = staticmethod(schema.require_context)
|
|
245
270
|
require_context = staticmethod(schema.require_context)
|
|
271
|
+
# RBAC 动态策略(判决唯一在 core;本层仅透传配置与查询面)
|
|
272
|
+
setRbac = staticmethod(permission.set_rbac)
|
|
273
|
+
set_rbac = staticmethod(permission.set_rbac)
|
|
274
|
+
rbacEnabled = staticmethod(permission.rbac_enabled)
|
|
275
|
+
rbac_enabled = staticmethod(permission.rbac_enabled)
|
|
276
|
+
rbacCan = staticmethod(permission.rbac_can)
|
|
277
|
+
rbac_can = staticmethod(permission.rbac_can)
|
|
278
|
+
rbacReadableFields = staticmethod(permission.rbac_readable_fields)
|
|
279
|
+
rbac_readable_fields = staticmethod(permission.rbac_readable_fields)
|
|
280
|
+
rbacWritableFields = staticmethod(permission.rbac_writable_fields)
|
|
281
|
+
rbac_writable_fields = staticmethod(permission.rbac_writable_fields)
|
|
282
|
+
rbacRowCondition = staticmethod(permission.rbac_row_condition)
|
|
283
|
+
rbac_row_condition = staticmethod(permission.rbac_row_condition)
|
|
246
284
|
# 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩
|
|
247
285
|
# 进入档即等效强制 ctx;未知档由 core 抛 ValueError 上抛(禁静默回落默认档)
|
|
248
286
|
setProfile = staticmethod(schema.set_profile)
|