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.
@@ -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
+ ]
@@ -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))