inflowenger-plugin-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inflow_plugin_sdk/__init__.py +78 -0
- inflow_plugin_sdk/env.py +21 -0
- inflow_plugin_sdk/formkit/__init__.py +84 -0
- inflow_plugin_sdk/formkit/field.py +326 -0
- inflow_plugin_sdk/formkit/form.py +144 -0
- inflow_plugin_sdk/formkit/notification.py +91 -0
- inflow_plugin_sdk/formkit/picker.py +104 -0
- inflow_plugin_sdk/inflow_v1.py +173 -0
- inflow_plugin_sdk/job.py +121 -0
- inflow_plugin_sdk/models.py +229 -0
- inflow_plugin_sdk/nats_box.py +104 -0
- inflow_plugin_sdk/plugin.py +189 -0
- inflow_plugin_sdk/py.typed +0 -0
- inflow_plugin_sdk/req.py +47 -0
- inflow_plugin_sdk/types.py +14 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/METADATA +324 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/RECORD +21 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/WHEEL +5 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/licenses/NOTICE +11 -0
- inflowenger_plugin_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# inflow_plugin_sdk — public API.
|
|
2
|
+
# The Python port of go-plugin-sdk (sdkv1).
|
|
3
|
+
from . import formkit
|
|
4
|
+
from .job import Job
|
|
5
|
+
from .models import (
|
|
6
|
+
Action,
|
|
7
|
+
ActionRequestContent,
|
|
8
|
+
CallSvcBody,
|
|
9
|
+
CommandPayload,
|
|
10
|
+
FormBuilder,
|
|
11
|
+
Frame,
|
|
12
|
+
Icon,
|
|
13
|
+
IPlugin,
|
|
14
|
+
JobBodyContent,
|
|
15
|
+
JobHandler,
|
|
16
|
+
Meta,
|
|
17
|
+
OutboundPort,
|
|
18
|
+
PluginIntro,
|
|
19
|
+
Request,
|
|
20
|
+
RequestBody,
|
|
21
|
+
Response,
|
|
22
|
+
Settings,
|
|
23
|
+
marshal,
|
|
24
|
+
)
|
|
25
|
+
from .nats_box import NatsBox
|
|
26
|
+
from .plugin import (
|
|
27
|
+
DEFAULT_SEND_TIMEOUT,
|
|
28
|
+
REQ_TIMEOUT_ENV,
|
|
29
|
+
Plugin,
|
|
30
|
+
new_plugin,
|
|
31
|
+
with_dot_env,
|
|
32
|
+
with_infra_connection,
|
|
33
|
+
with_plugin_id,
|
|
34
|
+
with_timeout,
|
|
35
|
+
)
|
|
36
|
+
from .req import ActionRequest, cast_request_to, with_job_handler
|
|
37
|
+
from .types import Command
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# plugin
|
|
41
|
+
"Plugin",
|
|
42
|
+
"new_plugin",
|
|
43
|
+
"with_dot_env",
|
|
44
|
+
"with_plugin_id",
|
|
45
|
+
"with_infra_connection",
|
|
46
|
+
"with_timeout",
|
|
47
|
+
"DEFAULT_SEND_TIMEOUT",
|
|
48
|
+
"REQ_TIMEOUT_ENV",
|
|
49
|
+
# job / req
|
|
50
|
+
"Job",
|
|
51
|
+
"ActionRequest",
|
|
52
|
+
"cast_request_to",
|
|
53
|
+
"with_job_handler",
|
|
54
|
+
# types
|
|
55
|
+
"Command",
|
|
56
|
+
"NatsBox",
|
|
57
|
+
# models
|
|
58
|
+
"IPlugin",
|
|
59
|
+
"JobHandler",
|
|
60
|
+
"PluginIntro",
|
|
61
|
+
"Icon",
|
|
62
|
+
"FormBuilder",
|
|
63
|
+
"Action",
|
|
64
|
+
"OutboundPort",
|
|
65
|
+
"Settings",
|
|
66
|
+
"Meta",
|
|
67
|
+
"Frame",
|
|
68
|
+
"CommandPayload",
|
|
69
|
+
"JobBodyContent",
|
|
70
|
+
"Response",
|
|
71
|
+
"Request",
|
|
72
|
+
"RequestBody",
|
|
73
|
+
"ActionRequestContent",
|
|
74
|
+
"CallSvcBody",
|
|
75
|
+
"marshal",
|
|
76
|
+
# formkit (optional form builder)
|
|
77
|
+
"formkit",
|
|
78
|
+
]
|
inflow_plugin_sdk/env.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Dotenv loading. Mirrors sdkv1/dotenv.go.
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_env(path: str = ".env") -> None:
|
|
8
|
+
"""Load an env file into os.environ (defaults to ".env"), like Go's NewEnv.
|
|
9
|
+
Missing files are ignored, matching godotenv.Load's best-effort behavior."""
|
|
10
|
+
if not path:
|
|
11
|
+
path = ".env"
|
|
12
|
+
load_dotenv(path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_env_var(key: str) -> str:
|
|
16
|
+
"""Read an env var, warning (but not failing) when unset — like Go's getEnvVar."""
|
|
17
|
+
v = os.environ.get(key)
|
|
18
|
+
if v is None:
|
|
19
|
+
print(f"Environment variable not set {key}")
|
|
20
|
+
return ""
|
|
21
|
+
return v
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# formkit — builds an Inflowenger form's JSON Schema + JSON Forms UI Schema from
|
|
2
|
+
# a single declaration of each field. The Python port of the Go `formkit` package.
|
|
3
|
+
#
|
|
4
|
+
# The package is additive and optional: nothing in the core SDK imports it, and
|
|
5
|
+
# what it produces is ordinary schema text, so a plugin may build every form with
|
|
6
|
+
# it, build one and hand-write the next, or use only picker / form_data / the
|
|
7
|
+
# Notification helpers against raw schema strings it wrote by hand.
|
|
8
|
+
#
|
|
9
|
+
# from inflow_plugin_sdk import formkit
|
|
10
|
+
#
|
|
11
|
+
# f = formkit.form("Create issue").add(
|
|
12
|
+
# formkit.text("projectKey", "Project key").required()
|
|
13
|
+
# .lookup("jira.meta.project.resolve", "Find").picks("jira.issue.create"),
|
|
14
|
+
# formkit.text("summary", "Summary").required(),
|
|
15
|
+
# formkit.text_area("description", "Description"),
|
|
16
|
+
# ).build()
|
|
17
|
+
from .field import (
|
|
18
|
+
Field,
|
|
19
|
+
boolean,
|
|
20
|
+
choice,
|
|
21
|
+
custom,
|
|
22
|
+
date,
|
|
23
|
+
date_time,
|
|
24
|
+
enum_,
|
|
25
|
+
integer,
|
|
26
|
+
list_,
|
|
27
|
+
list_of,
|
|
28
|
+
number,
|
|
29
|
+
scope_of,
|
|
30
|
+
secret,
|
|
31
|
+
text,
|
|
32
|
+
text_area,
|
|
33
|
+
)
|
|
34
|
+
from .form import Form, form
|
|
35
|
+
from .notification import (
|
|
36
|
+
NotifKey,
|
|
37
|
+
Notification,
|
|
38
|
+
Option,
|
|
39
|
+
failure,
|
|
40
|
+
help,
|
|
41
|
+
info,
|
|
42
|
+
one_of,
|
|
43
|
+
or_default,
|
|
44
|
+
success,
|
|
45
|
+
uiKey,
|
|
46
|
+
warning,
|
|
47
|
+
)
|
|
48
|
+
from .picker import choices, choose, form_data, lines, picker
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Field",
|
|
52
|
+
"Form",
|
|
53
|
+
"form",
|
|
54
|
+
"text",
|
|
55
|
+
"text_area",
|
|
56
|
+
"secret",
|
|
57
|
+
"integer",
|
|
58
|
+
"number",
|
|
59
|
+
"boolean",
|
|
60
|
+
"date",
|
|
61
|
+
"date_time",
|
|
62
|
+
"enum_",
|
|
63
|
+
"choice",
|
|
64
|
+
"list_",
|
|
65
|
+
"list_of",
|
|
66
|
+
"custom",
|
|
67
|
+
"scope_of",
|
|
68
|
+
"Notification",
|
|
69
|
+
"NotifKey",
|
|
70
|
+
"uiKey",
|
|
71
|
+
"Option",
|
|
72
|
+
"info",
|
|
73
|
+
"success",
|
|
74
|
+
"warning",
|
|
75
|
+
"failure",
|
|
76
|
+
"help",
|
|
77
|
+
"one_of",
|
|
78
|
+
"or_default",
|
|
79
|
+
"form_data",
|
|
80
|
+
"choices",
|
|
81
|
+
"picker",
|
|
82
|
+
"choose",
|
|
83
|
+
"lines",
|
|
84
|
+
]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# Field is one property of a form: its JSON Schema entry, and the UI Schema
|
|
2
|
+
# control that renders it. Both are generated from this one declaration, so a
|
|
3
|
+
# control can never point at a property that is not there. Mirrors formkit/field.go
|
|
4
|
+
# (schema/UI) and formkit/lookup.go (buttons/messages).
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .notification import NotifKey, Notification, help as _help, uiKey
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Field:
|
|
13
|
+
def __init__(self, name: str, schema: dict[str, Any]):
|
|
14
|
+
self.name: str = name
|
|
15
|
+
self.schema: dict[str, Any] = schema
|
|
16
|
+
self.options: Optional[dict[str, Any]] = None
|
|
17
|
+
self.inflow_ui: Optional[dict[str, Any]] = None
|
|
18
|
+
self.notifs: list[Notification] = []
|
|
19
|
+
self.rule: Optional[dict[str, Any]] = None
|
|
20
|
+
self.required_flag: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------ constructors --
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _field(name: str, title: str, json_type: str) -> Field:
|
|
27
|
+
schema: dict[str, Any] = {"type": json_type}
|
|
28
|
+
if title != "":
|
|
29
|
+
schema["title"] = title
|
|
30
|
+
return Field(name, schema)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def text(name: str, title: str) -> Field:
|
|
34
|
+
"""A single-line string."""
|
|
35
|
+
return _field(name, title, "string")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def text_area(name: str, title: str) -> Field:
|
|
39
|
+
"""A string rendered as a multi-line box."""
|
|
40
|
+
return text(name, title).option("multi", True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def secret(name: str, title: str) -> Field:
|
|
44
|
+
"""A string rendered with its characters masked. Masking is presentation only;
|
|
45
|
+
the value travels and is stored like any other field, so it belongs on a
|
|
46
|
+
settings profile, not on an action form."""
|
|
47
|
+
return text(name, title).option("format", "password")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def integer(name: str, title: str) -> Field:
|
|
51
|
+
"""A whole number."""
|
|
52
|
+
return _field(name, title, "integer")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def number(name: str, title: str) -> Field:
|
|
56
|
+
"""A decimal number."""
|
|
57
|
+
return _field(name, title, "number")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def boolean(name: str, title: str) -> Field:
|
|
61
|
+
"""A checkbox."""
|
|
62
|
+
return _field(name, title, "boolean")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def date(name: str, title: str) -> Field:
|
|
66
|
+
"""A string holding a calendar date, YYYY-MM-DD."""
|
|
67
|
+
return text(name, title).format("date")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def date_time(name: str, title: str) -> Field:
|
|
71
|
+
"""A string holding an RFC 3339 instant."""
|
|
72
|
+
return text(name, title).format("date-time")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def enum_(name: str, title: str, *values: str) -> Field:
|
|
76
|
+
"""A fixed set of values, rendered as a drop-down. Use it when the value the
|
|
77
|
+
API wants is the one a human should read; when they differ, use choice."""
|
|
78
|
+
return text(name, title).set("enum", list(values))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def choice(name: str, title: str, *options) -> Field:
|
|
82
|
+
"""A drop-down whose entries have two halves: the value the API needs, and the
|
|
83
|
+
label a human recognises. `oneOf` rather than `enum` because an enum can only
|
|
84
|
+
carry one of the two."""
|
|
85
|
+
from .notification import one_of
|
|
86
|
+
|
|
87
|
+
return text(name, title).set("oneOf", one_of(list(options)))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_(name: str, title: str) -> Field:
|
|
91
|
+
"""An array of strings — the renderer draws add/remove rows."""
|
|
92
|
+
return list_of(name, title, "string")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def list_of(name: str, title: str, item_type: str) -> Field:
|
|
96
|
+
"""An array whose items are of the given JSON type."""
|
|
97
|
+
return _field(name, title, "array").set("items", {"type": item_type})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def custom(name: str, title: str, schema: Optional[dict[str, Any]]) -> Field:
|
|
101
|
+
"""A field whose schema this package does not model: the JSON Schema fragment
|
|
102
|
+
is used as-is, while the control, layout, lookup button and messages are still
|
|
103
|
+
generated. The fragment is taken over, not copied."""
|
|
104
|
+
if schema is None:
|
|
105
|
+
schema = {}
|
|
106
|
+
if title != "":
|
|
107
|
+
schema["title"] = title
|
|
108
|
+
return Field(name, schema)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -------------------------------------------------------------- the schema --
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _describe(self: Field, text_: str) -> Field:
|
|
115
|
+
"""Set the property's `description`: a statement of what the field is."""
|
|
116
|
+
return self.set("description", text_)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _required(self: Field) -> Field:
|
|
120
|
+
"""Add the field to the schema's `required` list."""
|
|
121
|
+
self.required_flag = True
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _default(self: Field, value: Any) -> Field:
|
|
126
|
+
"""The value the form starts with, and what the action receives when untouched."""
|
|
127
|
+
return self.set("default", value)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format(self: Field, fmt: str) -> Field:
|
|
131
|
+
"""Set the JSON Schema `format` — date, date-time, uri, email …"""
|
|
132
|
+
return self.set("format", fmt)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _min(self: Field, value: Any) -> Field:
|
|
136
|
+
"""The smallest accepted number."""
|
|
137
|
+
return self.set("minimum", value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _max(self: Field, value: Any) -> Field:
|
|
141
|
+
"""The largest accepted number."""
|
|
142
|
+
return self.set("maximum", value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _between(self: Field, minimum: Any, maximum: Any) -> Field:
|
|
146
|
+
"""Bound a number on both sides."""
|
|
147
|
+
return self.min(minimum).max(maximum)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _set(self: Field, key: str, value: Any) -> Field:
|
|
151
|
+
"""Write a JSON Schema keyword verbatim — pattern, minLength, items, …"""
|
|
152
|
+
self.schema[key] = value
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ the UI --
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _option(self: Field, key: str, value: Any) -> Field:
|
|
160
|
+
"""Set a JSON Forms renderer hint under the control's `options`, e.g. "multi"
|
|
161
|
+
for a text area or "slider" for a bounded number."""
|
|
162
|
+
if self.options is None:
|
|
163
|
+
self.options = {}
|
|
164
|
+
self.options[key] = value
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _when(self: Field, effect: str, other: str, is_: Any) -> Field:
|
|
169
|
+
self.rule = {
|
|
170
|
+
"effect": effect,
|
|
171
|
+
"condition": {"scope": scope_of(other), "schema": {"const": is_}},
|
|
172
|
+
}
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _show_when(self: Field, other: str, is_: Any) -> Field:
|
|
177
|
+
"""Render this field only while another field holds the given value."""
|
|
178
|
+
return self.when("SHOW", other, is_)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hide_when(self: Field, other: str, is_: Any) -> Field:
|
|
182
|
+
"""The field disappears while the other field holds that value."""
|
|
183
|
+
return self.when("HIDE", other, is_)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _enable_when(self: Field, other: str, is_: Any) -> Field:
|
|
187
|
+
"""Leave the field on screen but grey it out until the other holds the value."""
|
|
188
|
+
return self.when("ENABLE", other, is_)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def scope_of(name: str) -> str:
|
|
192
|
+
"""The JSON-pointer-ish reference a UI Schema uses to name a property. A caller
|
|
193
|
+
that already wrote one out in full keeps it."""
|
|
194
|
+
if len(name) > 0 and name[0] == "#":
|
|
195
|
+
return name
|
|
196
|
+
return "#/properties/" + name
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _control(self: Field) -> dict[str, Any]:
|
|
200
|
+
"""Render the field's UI Schema element."""
|
|
201
|
+
element: dict[str, Any] = {"type": "Control", "scope": scope_of(self.name)}
|
|
202
|
+
if self.options:
|
|
203
|
+
element["options"] = self.options
|
|
204
|
+
if self.inflow_ui:
|
|
205
|
+
element[uiKey] = self.inflow_ui
|
|
206
|
+
if self.rule is not None:
|
|
207
|
+
element["rule"] = self.rule
|
|
208
|
+
|
|
209
|
+
# One message is written as an object rather than a one-element array: both
|
|
210
|
+
# are accepted, and the common case should read as the single thing it is.
|
|
211
|
+
if len(self.notifs) == 1:
|
|
212
|
+
element[NotifKey] = self.notifs[0]
|
|
213
|
+
elif len(self.notifs) > 1:
|
|
214
|
+
element[NotifKey] = self.notifs
|
|
215
|
+
return element
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ----------------------------------------------------------- lookup buttons --
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _lookup(self: Field, fn: str, label: str) -> Field:
|
|
222
|
+
"""Hang a button off the field that calls one of the plugin's meta functions
|
|
223
|
+
and patches the answer back into the open form. The host posts the form as it
|
|
224
|
+
stands, plus the settings profile, plus this control's contents as `value`."""
|
|
225
|
+
self.inflow_ui = {
|
|
226
|
+
"action": {
|
|
227
|
+
"name": "pluginFn",
|
|
228
|
+
"fn": fn,
|
|
229
|
+
"body": {"targetField": self.name},
|
|
230
|
+
},
|
|
231
|
+
"button": {"position": "append", "label": label, "icon": "↻"},
|
|
232
|
+
}
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _into(self: Field, target: str) -> Field:
|
|
237
|
+
"""Point the answer at another property, for a button that fills in a field
|
|
238
|
+
other than the one it sits on."""
|
|
239
|
+
self.body()["targetField"] = target
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _picks(self: Field, method: str) -> Field:
|
|
244
|
+
"""Name the action whose form is rebuilt when the lookup finds more than one
|
|
245
|
+
candidate (see picker)."""
|
|
246
|
+
self.body()["form"] = method
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _send(self: Field, key: str, value: Any) -> Field:
|
|
251
|
+
"""Add a static value to the body every press of this button posts."""
|
|
252
|
+
self.body()[key] = value
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _button(self: Field, position: str, icon: str) -> Field:
|
|
257
|
+
"""Override the look of the lookup button: where it sits ("append", "prepend")
|
|
258
|
+
and the icon on it."""
|
|
259
|
+
button = (self.inflow_ui or {}).get("button")
|
|
260
|
+
if button is None:
|
|
261
|
+
return self
|
|
262
|
+
if position != "":
|
|
263
|
+
button["position"] = position
|
|
264
|
+
if icon != "":
|
|
265
|
+
button["icon"] = icon
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _body(self: Field) -> dict[str, Any]:
|
|
270
|
+
"""Reach the static body this field's button posts, creating the button
|
|
271
|
+
scaffolding if lookup has not been called yet so chain order does not matter."""
|
|
272
|
+
if self.inflow_ui is None:
|
|
273
|
+
self.lookup("", "")
|
|
274
|
+
action = self.inflow_ui.get("action", {})
|
|
275
|
+
return action.get("body", {})
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------- messages --
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _field_help(self: Field, fmt: str, *args: Any) -> Field:
|
|
282
|
+
"""Attach a standing hint to the field — shown from the moment it renders."""
|
|
283
|
+
self.notifs.append(_help(fmt, *args))
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _inline(self: Field) -> Field:
|
|
288
|
+
"""Mark the field as the place messages about it are shown. Every lookup needs
|
|
289
|
+
one somewhere; this is for fields a different control fills in."""
|
|
290
|
+
self.notifs.append(Notification(display="inline"))
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _says(self: Field, n: Notification) -> Field:
|
|
295
|
+
"""Attach a message built by hand, for a severity or target the helpers do not
|
|
296
|
+
cover."""
|
|
297
|
+
self.notifs.append(n)
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# Bind the chaining methods onto Field (kept as free functions above so each
|
|
302
|
+
# reads on its own, mirroring the Go method set).
|
|
303
|
+
Field.name_of = lambda self: self.name
|
|
304
|
+
Field.describe = _describe
|
|
305
|
+
Field.required = _required
|
|
306
|
+
Field.default = _default
|
|
307
|
+
Field.format = _format
|
|
308
|
+
Field.min = _min
|
|
309
|
+
Field.max = _max
|
|
310
|
+
Field.between = _between
|
|
311
|
+
Field.set = _set
|
|
312
|
+
Field.option = _option
|
|
313
|
+
Field.when = _when
|
|
314
|
+
Field.show_when = _show_when
|
|
315
|
+
Field.hide_when = _hide_when
|
|
316
|
+
Field.enable_when = _enable_when
|
|
317
|
+
Field.control = _control
|
|
318
|
+
Field.lookup = _lookup
|
|
319
|
+
Field.into = _into
|
|
320
|
+
Field.picks = _picks
|
|
321
|
+
Field.send = _send
|
|
322
|
+
Field.button = _button
|
|
323
|
+
Field.body = _body
|
|
324
|
+
Field.help = _field_help
|
|
325
|
+
Field.inline = _inline
|
|
326
|
+
Field.says = _says
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Form assembly: a single declaration of each field generates both the JSON
|
|
2
|
+
# Schema (data) and the JSON Forms UI Schema (layout). Mirrors formkit/form.go.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field as dfield
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
from ..models import FormBuilder, Settings, to_wire
|
|
10
|
+
from .field import Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class _Section:
|
|
15
|
+
title: str = ""
|
|
16
|
+
fields: list[Field] = dfield(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Form:
|
|
20
|
+
"""A form under construction: the fields it holds, in the order they were
|
|
21
|
+
added, and the sections they are laid out in. Start from `form(title)`."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, title: str):
|
|
24
|
+
self._title = title
|
|
25
|
+
self._description = ""
|
|
26
|
+
self._submit_to = ""
|
|
27
|
+
self._sections: list[_Section] = []
|
|
28
|
+
|
|
29
|
+
def describe(self, text: str) -> "Form":
|
|
30
|
+
"""Set the schema's `description` — a line under the heading."""
|
|
31
|
+
self._description = text
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def submit_to(self, method: str) -> "Form":
|
|
35
|
+
"""Name the meta function the host calls to validate the form on submit."""
|
|
36
|
+
self._submit_to = method
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def add(self, *fields: Field) -> "Form":
|
|
40
|
+
"""Append fields to the form, in the order they will be rendered."""
|
|
41
|
+
if self._sections and self._sections[-1].title == "":
|
|
42
|
+
self._sections[-1].fields.extend(fields)
|
|
43
|
+
return self
|
|
44
|
+
self._sections.append(_Section(fields=list(fields)))
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def group(self, title: str, *fields: Field) -> "Form":
|
|
48
|
+
"""Append a labelled section. Its fields are ordinary properties of the same
|
|
49
|
+
flat schema — the grouping is layout only."""
|
|
50
|
+
self._sections.append(_Section(title=title, fields=list(fields)))
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def fields(self) -> list[Field]:
|
|
54
|
+
"""Every field in declaration order, groups flattened."""
|
|
55
|
+
out: list[Field] = []
|
|
56
|
+
for s in self._sections:
|
|
57
|
+
out.extend(s.fields)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
def validate(self) -> None:
|
|
61
|
+
"""Raise ValueError on what would make the generated documents wrong: a
|
|
62
|
+
field with no name, a name used twice, or a schema that will not marshal."""
|
|
63
|
+
seen: dict[str, bool] = {}
|
|
64
|
+
for field_ in self.fields():
|
|
65
|
+
if field_ is None:
|
|
66
|
+
raise ValueError(f"formkit: form {self._title!r} has a nil field")
|
|
67
|
+
if field_.name.strip() == "":
|
|
68
|
+
raise ValueError(f"formkit: form {self._title!r} has a field with no name")
|
|
69
|
+
if seen.get(field_.name):
|
|
70
|
+
raise ValueError(f"formkit: form {self._title!r} declares {field_.name!r} twice")
|
|
71
|
+
seen[field_.name] = True
|
|
72
|
+
try:
|
|
73
|
+
json.dumps(to_wire(field_.schema))
|
|
74
|
+
except Exception as e:
|
|
75
|
+
raise ValueError(f"formkit: field {field_.name!r} has a schema that will not marshal: {e}")
|
|
76
|
+
|
|
77
|
+
def schema(self) -> str:
|
|
78
|
+
"""The JSON Schema document as text."""
|
|
79
|
+
return _must_encode(self.schema_map())
|
|
80
|
+
|
|
81
|
+
def ui(self) -> str:
|
|
82
|
+
"""The JSON Forms UI Schema document as text."""
|
|
83
|
+
return _must_encode(self.ui_map())
|
|
84
|
+
|
|
85
|
+
def schema_map(self) -> dict[str, Any]:
|
|
86
|
+
"""The JSON Schema as a dict, for callers that go on to edit it."""
|
|
87
|
+
properties: dict[str, Any] = {}
|
|
88
|
+
required: list[Any] = []
|
|
89
|
+
for field_ in self.fields():
|
|
90
|
+
properties[field_.name] = field_.schema
|
|
91
|
+
if field_.required_flag:
|
|
92
|
+
required.append(field_.name)
|
|
93
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
94
|
+
if self._title != "":
|
|
95
|
+
schema["title"] = self._title
|
|
96
|
+
if self._description != "":
|
|
97
|
+
schema["description"] = self._description
|
|
98
|
+
if required:
|
|
99
|
+
schema["required"] = required
|
|
100
|
+
return schema
|
|
101
|
+
|
|
102
|
+
def ui_map(self) -> dict[str, Any]:
|
|
103
|
+
"""The UI Schema as a dict."""
|
|
104
|
+
elements: list[Any] = []
|
|
105
|
+
for s in self._sections:
|
|
106
|
+
controls = [field_.control() for field_ in s.fields]
|
|
107
|
+
if s.title == "":
|
|
108
|
+
elements.extend(controls)
|
|
109
|
+
continue
|
|
110
|
+
elements.append({"type": "Group", "label": s.title, "elements": controls})
|
|
111
|
+
return {"type": "VerticalLayout", "elements": elements}
|
|
112
|
+
|
|
113
|
+
def build(self) -> FormBuilder:
|
|
114
|
+
"""Render the form into the FormBuilder an action or settings profile
|
|
115
|
+
carries. Raises if validate() fails — forms are declared from literals at
|
|
116
|
+
start-up, so a failure here is a programming error."""
|
|
117
|
+
self.validate()
|
|
118
|
+
return FormBuilder(submit_to=self._submit_to, jsonschema=self.schema(), jsonui=self.ui())
|
|
119
|
+
|
|
120
|
+
def settings(self, submit: Callable) -> Settings:
|
|
121
|
+
"""Render the form as a plugin settings profile: the same two documents,
|
|
122
|
+
plus the handler the host calls when the profile is submitted. The handler
|
|
123
|
+
is a validator, not a store — the platform ships the profile back with every
|
|
124
|
+
call as body.settings."""
|
|
125
|
+
fb = self.build()
|
|
126
|
+
return Settings(
|
|
127
|
+
submit_to=fb.submit_to,
|
|
128
|
+
jsonui=fb.jsonui,
|
|
129
|
+
jsonschema=fb.jsonschema,
|
|
130
|
+
submit_handler=submit,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def form(title: str) -> Form:
|
|
135
|
+
"""Start a form. The title is the JSON Schema `title`, shown as the dialog
|
|
136
|
+
heading."""
|
|
137
|
+
return Form(title)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _must_encode(document: dict[str, Any]) -> str:
|
|
141
|
+
try:
|
|
142
|
+
return json.dumps(to_wire(document), separators=(",", ":"), ensure_ascii=False)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
raise RuntimeError("formkit: encode: " + str(e))
|