erupt-sdk 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.
- erupt_sdk-0.1.0/PKG-INFO +238 -0
- erupt_sdk-0.1.0/README.md +230 -0
- erupt_sdk-0.1.0/pyproject.toml +15 -0
- erupt_sdk-0.1.0/setup.cfg +4 -0
- erupt_sdk-0.1.0/src/erupt/__init__.py +7 -0
- erupt_sdk-0.1.0/src/erupt/_defaults.json +1577 -0
- erupt_sdk-0.1.0/src/erupt/_schema.json +2137 -0
- erupt_sdk-0.1.0/src/erupt/_schema.py +67 -0
- erupt_sdk-0.1.0/src/erupt/_signature.py +31 -0
- erupt_sdk-0.1.0/src/erupt/_types.py +458 -0
- erupt_sdk-0.1.0/src/erupt/model.py +469 -0
- erupt_sdk-0.1.0/src/erupt/node.py +560 -0
- erupt_sdk-0.1.0/src/erupt/repo.py +347 -0
- erupt_sdk-0.1.0/src/erupt_sdk.egg-info/PKG-INFO +238 -0
- erupt_sdk-0.1.0/src/erupt_sdk.egg-info/SOURCES.txt +19 -0
- erupt_sdk-0.1.0/src/erupt_sdk.egg-info/dependency_links.txt +1 -0
- erupt_sdk-0.1.0/src/erupt_sdk.egg-info/top_level.txt +1 -0
- erupt_sdk-0.1.0/tests/test_databases.py +98 -0
- erupt_sdk-0.1.0/tests/test_node.py +584 -0
- erupt_sdk-0.1.0/tests/test_signature.py +25 -0
- erupt_sdk-0.1.0/tests/test_sql.py +165 -0
erupt_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: erupt-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Serve Python models as an erupt admin UI through an erupt cloud server
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# erupt-sdk (Python)
|
|
10
|
+
|
|
11
|
+
Serve Python models as an erupt admin UI. Zero dependencies, Python 3.9+.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install erupt-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import datetime, os
|
|
19
|
+
from erupt import Field, Node, SqliteRepository, erupt
|
|
20
|
+
|
|
21
|
+
@erupt("Article", order_by="id desc")
|
|
22
|
+
class Article:
|
|
23
|
+
id: int = Field(pk=True)
|
|
24
|
+
title: str = Field("Title", search=True, not_null=True, sortable=True)
|
|
25
|
+
status: str = Field("Status", choices={"draft": "Draft", "published": "Published"}, search=True)
|
|
26
|
+
publish_date: datetime.date = Field("Publish Date", search=True)
|
|
27
|
+
body: str = Field("Body", type="TEXTAREA", table=False)
|
|
28
|
+
|
|
29
|
+
node = Node("blog", os.environ["ERUPT_ACCESS_TOKEN"], "http://erupt-server:8080")
|
|
30
|
+
node.register(Article, SqliteRepository("blog.db", "article"))
|
|
31
|
+
node.serve(port=9090)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
1. On the erupt server create node `blog` (Node Config) and copy its access token.
|
|
35
|
+
2. Add a menu of type `table` with value `blog.Article`.
|
|
36
|
+
3. Run the node. The server must be able to reach it on `http://<node ip>:9090`; set `address=` otherwise.
|
|
37
|
+
|
|
38
|
+
The edit type follows the Python annotation (`str` INPUT, `int`/`float` NUMBER, `bool` BOOLEAN,
|
|
39
|
+
`date` DATE, `datetime` DATE_TIME, `choices=` CHOICE) unless `type=` is given. `view={...}` / `edit={...}`
|
|
40
|
+
on a field and keyword arguments on `@erupt` pass raw annotation attributes through.
|
|
41
|
+
|
|
42
|
+
## References
|
|
43
|
+
|
|
44
|
+
Annotate a field with another `@erupt` class (declared earlier, registered on the same node). The repository
|
|
45
|
+
stores the referenced primary key; the UI shows `label` (default `name`).
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
@erupt("Category")
|
|
49
|
+
class Category:
|
|
50
|
+
id: int = Field(pk=True)
|
|
51
|
+
name: str = Field("Name", search=True)
|
|
52
|
+
parent: int = Field("Parent Id")
|
|
53
|
+
|
|
54
|
+
class Article:
|
|
55
|
+
category: Category = Field("Category", search=True) # table picker
|
|
56
|
+
topic: Category = Field("Topic", tree="parent") # tree picker over Category.parent
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Row operations
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
@erupt("Publish Options")
|
|
63
|
+
class PublishForm: # a form model needs no primary key
|
|
64
|
+
publish_date: datetime.date = Field("Publish Date", not_null=True)
|
|
65
|
+
|
|
66
|
+
class Article:
|
|
67
|
+
@operation("Publish", form=PublishForm, if_expr="item.status !== 'published'")
|
|
68
|
+
def publish(op): # op.rows, op.param, op.repo, op.user
|
|
69
|
+
for row in op.rows:
|
|
70
|
+
op.repo.update(row["id"], {"status": "published", "publish_date": op.param["publish_date"]})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`mode`: `SINGLE` / `MULTI` (default) / `MULTI_ONLY` / `BUTTON`. `confirm=False` skips the dialog, a string
|
|
74
|
+
replaces its text. `show=lambda user: ...` hides the operation and refuses to run it. A returned string is
|
|
75
|
+
executed as JavaScript in the browser, so return only code you wrote.
|
|
76
|
+
|
|
77
|
+
## DataProxy
|
|
78
|
+
|
|
79
|
+
Hooks around a model's reads and writes, as erupt's Java `DataProxy`. Override what you need; raise `Fail` to
|
|
80
|
+
refuse with a message the user sees.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from erupt import DataProxy, Fail, current_user
|
|
84
|
+
|
|
85
|
+
class ArticleProxy(DataProxy):
|
|
86
|
+
def add_behavior(self, record): # the new form's values
|
|
87
|
+
record["status"] = "draft"
|
|
88
|
+
|
|
89
|
+
def validate(self, record): # before every add and update
|
|
90
|
+
if "spam" in (record.get("title") or ""):
|
|
91
|
+
raise Fail("no spam")
|
|
92
|
+
|
|
93
|
+
def before_fetch(self, conditions): # SQL ANDed to the table's WHERE, values bound
|
|
94
|
+
return "owner = ?", [current_user()["uid"]]
|
|
95
|
+
|
|
96
|
+
def after_fetch(self, rows): # a table page, before it is sent
|
|
97
|
+
for row in rows:
|
|
98
|
+
row["title"] = row["title"].strip()
|
|
99
|
+
|
|
100
|
+
@erupt("Article", data_proxy=ArticleProxy) # one proxy or a list, classes or instances
|
|
101
|
+
class Article: ...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Hook | Runs |
|
|
105
|
+
|------|------|
|
|
106
|
+
| `search_condition(condition)` | building the model: default values of the search bar |
|
|
107
|
+
| `before_fetch(conditions)` | before a table page is read: SQL ANDed to its WHERE as written, `"sql"` or `("sql with ?", [values])` (with values, each `?` is one of them); SQL repositories only, `MemoryRepository` refuses it |
|
|
108
|
+
| `after_fetch(rows)` | after a table page is read |
|
|
109
|
+
| `add_behavior(record)` | for the new record form (`init-value`) |
|
|
110
|
+
| `edit_behavior(record)` | after a record is read by its key (edit form, detail view) |
|
|
111
|
+
| `validate(record)` | before an add or an update |
|
|
112
|
+
| `before_add` / `after_add(record)` | around `repository.add` |
|
|
113
|
+
| `before_update` / `after_update(record)` | around `repository.update`, with the submitted values |
|
|
114
|
+
| `before_delete` / `after_delete(record)` | around `repository.delete`, once per record as stored |
|
|
115
|
+
|
|
116
|
+
## Views
|
|
117
|
+
|
|
118
|
+
`vis` declares the extra views of `@Erupt(vis = ...)`. A view's `filter` (one SQL string or a list, ANDed as
|
|
119
|
+
written) and `orderBy` (as `order_by`) are applied by the node when the table is read for that view, and kept
|
|
120
|
+
out of the build, as Java keeps them `@Transient`. A view without a `code` is addressed by its title. Like
|
|
121
|
+
`before_fetch`, a filter needs a SQL repository.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@erupt("Task", vis=[
|
|
125
|
+
{"title": "Board", "type": "BOARD", "boardView": {"groupField": "status"}, "filter": "archived = 0"},
|
|
126
|
+
{"title": "Recent", "type": "TABLE", "orderBy": "created desc"},
|
|
127
|
+
])
|
|
128
|
+
class Task: ...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Dragging on a board, calendar or gantt view is saved by the erupt server through the node's `get` and `update`.
|
|
132
|
+
|
|
133
|
+
## Trees
|
|
134
|
+
|
|
135
|
+
`tree={"id": "id", "label": "name", "pid": "parent"}` shows the model as a tree, and
|
|
136
|
+
`linkTree={"field": "category"}` puts the tree of a reference field beside the table: the erupt server builds
|
|
137
|
+
both trees from the node's table, and the node keeps the rows under the picked tree node (`dependNode: True`
|
|
138
|
+
shows none until one is picked). `linkTree.field` must be a reference field.
|
|
139
|
+
|
|
140
|
+
## Field types and field code
|
|
141
|
+
|
|
142
|
+
Besides the inferred types, `type=` takes `TAGS`, `AUTO_COMPLETE`, `ATTACHMENT` (the erupt server stores the
|
|
143
|
+
upload, the node keeps its path), `HTML_EDITOR`, `MARKDOWN`, `CODE_EDITOR` (all text), `BUTTON` (form only),
|
|
144
|
+
and `CHECKBOX` / `TAB_TREE` with `ref=`: the picked records, stored as the JSON list of their keys. A
|
|
145
|
+
`list`-annotated field with `choices` is a `MULTI_CHOICE`.
|
|
146
|
+
|
|
147
|
+
A field can run its own code; the erupt server calls it through the node's `invoke` endpoint with the form's
|
|
148
|
+
values:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
@erupt("Order")
|
|
152
|
+
class Order:
|
|
153
|
+
id: int = Field(pk=True)
|
|
154
|
+
status: str = Field("Status", fetch=lambda form: {"new": "New", "paid": "Paid"}) # options, also TAGS: [...]
|
|
155
|
+
qty: int = Field("Qty", onchange=lambda form: {"formData": {"total": form["qty"] * 9}, "editExpr": {}})
|
|
156
|
+
city: str = Field("City", type="AUTO_COMPLETE", complete=lambda form, typed: lookup(typed))
|
|
157
|
+
total: float = Field("Total")
|
|
158
|
+
recalc = Field("Recalc", type="BUTTON", click=lambda form: {"eval": "alert('done')", "formData": {}})
|
|
159
|
+
tags: list = Field("Tags", type="CHECKBOX", ref=Tag)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`fetch` belongs to `CHOICE` / `MULTI_CHOICE` (`{value: label}` or pairs) and `TAGS` (a list), `complete` to
|
|
163
|
+
`AUTO_COMPLETE`, `click` to `BUTTON`; `onchange` to any field. Raise `Fail` to refuse with a message.
|
|
164
|
+
|
|
165
|
+
Also: `COLOR`, `ICON`, `SIGNATURE`, `MAP`, `HIDDEN` (text), `RATE`, `SLIDER` (numbers) and `TRANSFER` (like
|
|
166
|
+
`CHECKBOX`). A reference table depending on another field takes
|
|
167
|
+
`edit={"referenceTableType": {"dependField": "province", "dependColumn": "province"}}`: the erupt server keeps the
|
|
168
|
+
picked model's rows whose `dependColumn` holds the depend field's value.
|
|
169
|
+
|
|
170
|
+
## Drills
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
@erupt("Author", drills=[{"title": "Books", "link": {"linkErupt": "Book", "joinColumn": "author"}}])
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
opens `Book` filtered to the rows whose `author` holds the author's `id` (`link.column`, `id` by default);
|
|
177
|
+
`link.linkCondition` adds SQL as written. Both models must be on the node.
|
|
178
|
+
|
|
179
|
+
## Server config and user info
|
|
180
|
+
|
|
181
|
+
`node.config()` and `node.group_config()` return the config text set for the node and its group on the erupt
|
|
182
|
+
server; `node.user_info()` the acting user's full info (roles, org, post), while a request is served.
|
|
183
|
+
|
|
184
|
+
## Storage
|
|
185
|
+
|
|
186
|
+
Storage is any `Repository` (`page`, `get`, `add`, `update`, `delete`). Shipped:
|
|
187
|
+
|
|
188
|
+
- `MemoryRepository` for tests and demos;
|
|
189
|
+
- `SqliteRepository(path, table)`, which creates the table;
|
|
190
|
+
- `SqlRepository(connect, table, dialect=..., release=..., paramstyle=...)` over any DB-API 2.0 driver.
|
|
191
|
+
`dialect` (`sqlite`, `postgres`, `mysql`, `mssql`, `oracle`) decides identifier quotes and paging; the
|
|
192
|
+
driver's paramstyle is detected. Create the tables yourself, with columns named like the fields.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
import psycopg # PostgreSQL
|
|
196
|
+
repo = SqlRepository(lambda: psycopg.connect(DSN), "article", dialect="postgres")
|
|
197
|
+
|
|
198
|
+
from psycopg_pool import ConnectionPool
|
|
199
|
+
pool = ConnectionPool(DSN) # pooled: hand connections back instead of closing them
|
|
200
|
+
repo = SqlRepository(pool.getconn, "article", dialect="postgres", release=pool.putconn)
|
|
201
|
+
|
|
202
|
+
import pymysql # MySQL
|
|
203
|
+
repo = SqlRepository(lambda: pymysql.connect(**MYSQL), "article", dialect="mysql")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Without a sort, every repository returns rows by primary key ascending. `erupt.current_user()` returns the
|
|
207
|
+
acting user inside a repository call.
|
|
208
|
+
|
|
209
|
+
Not yet: sub-tables (TAB_TABLE_*), TPL operations, i18n of messages. Trees, file
|
|
210
|
+
upload and Excel are done by the erupt server.
|
|
211
|
+
|
|
212
|
+
## Without a registry
|
|
213
|
+
|
|
214
|
+
From the repository, without PyPI:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install "erupt-sdk @ git+https://github.com/erupt-io/erupt-sdk@python/v0.1.0#subdirectory=python"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Or copy `python/src/erupt/` into your project as the package `erupt`; it has no dependencies, keep the two
|
|
221
|
+
`.json` files next to the code. A copy does not update itself: when erupt changes the protocol or the annotation schema, replace the whole directory with the next release instead of merging by hand, and leave `__version__` in `node.py` untouched so the server shows which release the node runs.
|
|
222
|
+
|
|
223
|
+
## Generated code
|
|
224
|
+
|
|
225
|
+
`src/erupt/_types.py` (TypedDicts and Literals for every annotation attribute: `Edit`, `View`, `Erupt`,
|
|
226
|
+
`RowOperation`, `EditType`, …) is generated from the erupt annotation JSON Schema. Raw `view=` / `edit=` /
|
|
227
|
+
`@erupt(...)` / `@operation(...)` attributes are checked against the same schema when a model is declared,
|
|
228
|
+
so a misspelt key or enum value fails at import instead of silently doing nothing in the browser.
|
|
229
|
+
|
|
230
|
+
After an annotation change in erupt (regenerate there with `-Derupt.node-schema.write=true` /
|
|
231
|
+
`-Derupt.node-defaults.write=true`):
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
python tools/gen_types.py <erupt repo>/schema # sync schemas, regenerate _types.py
|
|
235
|
+
python tools/gen_types.py <erupt repo>/schema --check # CI: fail when stale
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Tests: `PYTHONPATH=src python -m unittest discover -s tests`
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# erupt-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Serve Python models as an erupt admin UI. Zero dependencies, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install erupt-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import datetime, os
|
|
11
|
+
from erupt import Field, Node, SqliteRepository, erupt
|
|
12
|
+
|
|
13
|
+
@erupt("Article", order_by="id desc")
|
|
14
|
+
class Article:
|
|
15
|
+
id: int = Field(pk=True)
|
|
16
|
+
title: str = Field("Title", search=True, not_null=True, sortable=True)
|
|
17
|
+
status: str = Field("Status", choices={"draft": "Draft", "published": "Published"}, search=True)
|
|
18
|
+
publish_date: datetime.date = Field("Publish Date", search=True)
|
|
19
|
+
body: str = Field("Body", type="TEXTAREA", table=False)
|
|
20
|
+
|
|
21
|
+
node = Node("blog", os.environ["ERUPT_ACCESS_TOKEN"], "http://erupt-server:8080")
|
|
22
|
+
node.register(Article, SqliteRepository("blog.db", "article"))
|
|
23
|
+
node.serve(port=9090)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
1. On the erupt server create node `blog` (Node Config) and copy its access token.
|
|
27
|
+
2. Add a menu of type `table` with value `blog.Article`.
|
|
28
|
+
3. Run the node. The server must be able to reach it on `http://<node ip>:9090`; set `address=` otherwise.
|
|
29
|
+
|
|
30
|
+
The edit type follows the Python annotation (`str` INPUT, `int`/`float` NUMBER, `bool` BOOLEAN,
|
|
31
|
+
`date` DATE, `datetime` DATE_TIME, `choices=` CHOICE) unless `type=` is given. `view={...}` / `edit={...}`
|
|
32
|
+
on a field and keyword arguments on `@erupt` pass raw annotation attributes through.
|
|
33
|
+
|
|
34
|
+
## References
|
|
35
|
+
|
|
36
|
+
Annotate a field with another `@erupt` class (declared earlier, registered on the same node). The repository
|
|
37
|
+
stores the referenced primary key; the UI shows `label` (default `name`).
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
@erupt("Category")
|
|
41
|
+
class Category:
|
|
42
|
+
id: int = Field(pk=True)
|
|
43
|
+
name: str = Field("Name", search=True)
|
|
44
|
+
parent: int = Field("Parent Id")
|
|
45
|
+
|
|
46
|
+
class Article:
|
|
47
|
+
category: Category = Field("Category", search=True) # table picker
|
|
48
|
+
topic: Category = Field("Topic", tree="parent") # tree picker over Category.parent
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Row operations
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
@erupt("Publish Options")
|
|
55
|
+
class PublishForm: # a form model needs no primary key
|
|
56
|
+
publish_date: datetime.date = Field("Publish Date", not_null=True)
|
|
57
|
+
|
|
58
|
+
class Article:
|
|
59
|
+
@operation("Publish", form=PublishForm, if_expr="item.status !== 'published'")
|
|
60
|
+
def publish(op): # op.rows, op.param, op.repo, op.user
|
|
61
|
+
for row in op.rows:
|
|
62
|
+
op.repo.update(row["id"], {"status": "published", "publish_date": op.param["publish_date"]})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`mode`: `SINGLE` / `MULTI` (default) / `MULTI_ONLY` / `BUTTON`. `confirm=False` skips the dialog, a string
|
|
66
|
+
replaces its text. `show=lambda user: ...` hides the operation and refuses to run it. A returned string is
|
|
67
|
+
executed as JavaScript in the browser, so return only code you wrote.
|
|
68
|
+
|
|
69
|
+
## DataProxy
|
|
70
|
+
|
|
71
|
+
Hooks around a model's reads and writes, as erupt's Java `DataProxy`. Override what you need; raise `Fail` to
|
|
72
|
+
refuse with a message the user sees.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from erupt import DataProxy, Fail, current_user
|
|
76
|
+
|
|
77
|
+
class ArticleProxy(DataProxy):
|
|
78
|
+
def add_behavior(self, record): # the new form's values
|
|
79
|
+
record["status"] = "draft"
|
|
80
|
+
|
|
81
|
+
def validate(self, record): # before every add and update
|
|
82
|
+
if "spam" in (record.get("title") or ""):
|
|
83
|
+
raise Fail("no spam")
|
|
84
|
+
|
|
85
|
+
def before_fetch(self, conditions): # SQL ANDed to the table's WHERE, values bound
|
|
86
|
+
return "owner = ?", [current_user()["uid"]]
|
|
87
|
+
|
|
88
|
+
def after_fetch(self, rows): # a table page, before it is sent
|
|
89
|
+
for row in rows:
|
|
90
|
+
row["title"] = row["title"].strip()
|
|
91
|
+
|
|
92
|
+
@erupt("Article", data_proxy=ArticleProxy) # one proxy or a list, classes or instances
|
|
93
|
+
class Article: ...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Hook | Runs |
|
|
97
|
+
|------|------|
|
|
98
|
+
| `search_condition(condition)` | building the model: default values of the search bar |
|
|
99
|
+
| `before_fetch(conditions)` | before a table page is read: SQL ANDed to its WHERE as written, `"sql"` or `("sql with ?", [values])` (with values, each `?` is one of them); SQL repositories only, `MemoryRepository` refuses it |
|
|
100
|
+
| `after_fetch(rows)` | after a table page is read |
|
|
101
|
+
| `add_behavior(record)` | for the new record form (`init-value`) |
|
|
102
|
+
| `edit_behavior(record)` | after a record is read by its key (edit form, detail view) |
|
|
103
|
+
| `validate(record)` | before an add or an update |
|
|
104
|
+
| `before_add` / `after_add(record)` | around `repository.add` |
|
|
105
|
+
| `before_update` / `after_update(record)` | around `repository.update`, with the submitted values |
|
|
106
|
+
| `before_delete` / `after_delete(record)` | around `repository.delete`, once per record as stored |
|
|
107
|
+
|
|
108
|
+
## Views
|
|
109
|
+
|
|
110
|
+
`vis` declares the extra views of `@Erupt(vis = ...)`. A view's `filter` (one SQL string or a list, ANDed as
|
|
111
|
+
written) and `orderBy` (as `order_by`) are applied by the node when the table is read for that view, and kept
|
|
112
|
+
out of the build, as Java keeps them `@Transient`. A view without a `code` is addressed by its title. Like
|
|
113
|
+
`before_fetch`, a filter needs a SQL repository.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@erupt("Task", vis=[
|
|
117
|
+
{"title": "Board", "type": "BOARD", "boardView": {"groupField": "status"}, "filter": "archived = 0"},
|
|
118
|
+
{"title": "Recent", "type": "TABLE", "orderBy": "created desc"},
|
|
119
|
+
])
|
|
120
|
+
class Task: ...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Dragging on a board, calendar or gantt view is saved by the erupt server through the node's `get` and `update`.
|
|
124
|
+
|
|
125
|
+
## Trees
|
|
126
|
+
|
|
127
|
+
`tree={"id": "id", "label": "name", "pid": "parent"}` shows the model as a tree, and
|
|
128
|
+
`linkTree={"field": "category"}` puts the tree of a reference field beside the table: the erupt server builds
|
|
129
|
+
both trees from the node's table, and the node keeps the rows under the picked tree node (`dependNode: True`
|
|
130
|
+
shows none until one is picked). `linkTree.field` must be a reference field.
|
|
131
|
+
|
|
132
|
+
## Field types and field code
|
|
133
|
+
|
|
134
|
+
Besides the inferred types, `type=` takes `TAGS`, `AUTO_COMPLETE`, `ATTACHMENT` (the erupt server stores the
|
|
135
|
+
upload, the node keeps its path), `HTML_EDITOR`, `MARKDOWN`, `CODE_EDITOR` (all text), `BUTTON` (form only),
|
|
136
|
+
and `CHECKBOX` / `TAB_TREE` with `ref=`: the picked records, stored as the JSON list of their keys. A
|
|
137
|
+
`list`-annotated field with `choices` is a `MULTI_CHOICE`.
|
|
138
|
+
|
|
139
|
+
A field can run its own code; the erupt server calls it through the node's `invoke` endpoint with the form's
|
|
140
|
+
values:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
@erupt("Order")
|
|
144
|
+
class Order:
|
|
145
|
+
id: int = Field(pk=True)
|
|
146
|
+
status: str = Field("Status", fetch=lambda form: {"new": "New", "paid": "Paid"}) # options, also TAGS: [...]
|
|
147
|
+
qty: int = Field("Qty", onchange=lambda form: {"formData": {"total": form["qty"] * 9}, "editExpr": {}})
|
|
148
|
+
city: str = Field("City", type="AUTO_COMPLETE", complete=lambda form, typed: lookup(typed))
|
|
149
|
+
total: float = Field("Total")
|
|
150
|
+
recalc = Field("Recalc", type="BUTTON", click=lambda form: {"eval": "alert('done')", "formData": {}})
|
|
151
|
+
tags: list = Field("Tags", type="CHECKBOX", ref=Tag)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`fetch` belongs to `CHOICE` / `MULTI_CHOICE` (`{value: label}` or pairs) and `TAGS` (a list), `complete` to
|
|
155
|
+
`AUTO_COMPLETE`, `click` to `BUTTON`; `onchange` to any field. Raise `Fail` to refuse with a message.
|
|
156
|
+
|
|
157
|
+
Also: `COLOR`, `ICON`, `SIGNATURE`, `MAP`, `HIDDEN` (text), `RATE`, `SLIDER` (numbers) and `TRANSFER` (like
|
|
158
|
+
`CHECKBOX`). A reference table depending on another field takes
|
|
159
|
+
`edit={"referenceTableType": {"dependField": "province", "dependColumn": "province"}}`: the erupt server keeps the
|
|
160
|
+
picked model's rows whose `dependColumn` holds the depend field's value.
|
|
161
|
+
|
|
162
|
+
## Drills
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
@erupt("Author", drills=[{"title": "Books", "link": {"linkErupt": "Book", "joinColumn": "author"}}])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
opens `Book` filtered to the rows whose `author` holds the author's `id` (`link.column`, `id` by default);
|
|
169
|
+
`link.linkCondition` adds SQL as written. Both models must be on the node.
|
|
170
|
+
|
|
171
|
+
## Server config and user info
|
|
172
|
+
|
|
173
|
+
`node.config()` and `node.group_config()` return the config text set for the node and its group on the erupt
|
|
174
|
+
server; `node.user_info()` the acting user's full info (roles, org, post), while a request is served.
|
|
175
|
+
|
|
176
|
+
## Storage
|
|
177
|
+
|
|
178
|
+
Storage is any `Repository` (`page`, `get`, `add`, `update`, `delete`). Shipped:
|
|
179
|
+
|
|
180
|
+
- `MemoryRepository` for tests and demos;
|
|
181
|
+
- `SqliteRepository(path, table)`, which creates the table;
|
|
182
|
+
- `SqlRepository(connect, table, dialect=..., release=..., paramstyle=...)` over any DB-API 2.0 driver.
|
|
183
|
+
`dialect` (`sqlite`, `postgres`, `mysql`, `mssql`, `oracle`) decides identifier quotes and paging; the
|
|
184
|
+
driver's paramstyle is detected. Create the tables yourself, with columns named like the fields.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
import psycopg # PostgreSQL
|
|
188
|
+
repo = SqlRepository(lambda: psycopg.connect(DSN), "article", dialect="postgres")
|
|
189
|
+
|
|
190
|
+
from psycopg_pool import ConnectionPool
|
|
191
|
+
pool = ConnectionPool(DSN) # pooled: hand connections back instead of closing them
|
|
192
|
+
repo = SqlRepository(pool.getconn, "article", dialect="postgres", release=pool.putconn)
|
|
193
|
+
|
|
194
|
+
import pymysql # MySQL
|
|
195
|
+
repo = SqlRepository(lambda: pymysql.connect(**MYSQL), "article", dialect="mysql")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Without a sort, every repository returns rows by primary key ascending. `erupt.current_user()` returns the
|
|
199
|
+
acting user inside a repository call.
|
|
200
|
+
|
|
201
|
+
Not yet: sub-tables (TAB_TABLE_*), TPL operations, i18n of messages. Trees, file
|
|
202
|
+
upload and Excel are done by the erupt server.
|
|
203
|
+
|
|
204
|
+
## Without a registry
|
|
205
|
+
|
|
206
|
+
From the repository, without PyPI:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pip install "erupt-sdk @ git+https://github.com/erupt-io/erupt-sdk@python/v0.1.0#subdirectory=python"
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Or copy `python/src/erupt/` into your project as the package `erupt`; it has no dependencies, keep the two
|
|
213
|
+
`.json` files next to the code. A copy does not update itself: when erupt changes the protocol or the annotation schema, replace the whole directory with the next release instead of merging by hand, and leave `__version__` in `node.py` untouched so the server shows which release the node runs.
|
|
214
|
+
|
|
215
|
+
## Generated code
|
|
216
|
+
|
|
217
|
+
`src/erupt/_types.py` (TypedDicts and Literals for every annotation attribute: `Edit`, `View`, `Erupt`,
|
|
218
|
+
`RowOperation`, `EditType`, …) is generated from the erupt annotation JSON Schema. Raw `view=` / `edit=` /
|
|
219
|
+
`@erupt(...)` / `@operation(...)` attributes are checked against the same schema when a model is declared,
|
|
220
|
+
so a misspelt key or enum value fails at import instead of silently doing nothing in the browser.
|
|
221
|
+
|
|
222
|
+
After an annotation change in erupt (regenerate there with `-Derupt.node-schema.write=true` /
|
|
223
|
+
`-Derupt.node-defaults.write=true`):
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
python tools/gen_types.py <erupt repo>/schema # sync schemas, regenerate _types.py
|
|
227
|
+
python tools/gen_types.py <erupt repo>/schema --check # CI: fail when stale
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Tests: `PYTHONPATH=src python -m unittest discover -s tests`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "erupt-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Serve Python models as an erupt admin UI through an erupt cloud server"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.package-data]
|
|
15
|
+
erupt = ["*.json"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Erupt node SDK: give any Python service an erupt admin UI through an erupt cloud server."""
|
|
2
|
+
from .model import DataProxy, Field, erupt, operation
|
|
3
|
+
from .node import Fail, Node, OperationContext, __version__, current_user
|
|
4
|
+
from .repo import Cond, MemoryRepository, Query, Repository, SqliteRepository, SqlRepository
|
|
5
|
+
|
|
6
|
+
__all__ = ["erupt", "Field", "operation", "OperationContext", "DataProxy", "Fail", "Node", "current_user", "Repository",
|
|
7
|
+
"MemoryRepository", "SqliteRepository", "SqlRepository", "Query", "Cond", "__version__"]
|