flowbase-cli 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,3 @@
1
+ """FlowBase OpenAPI CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,37 @@
1
+ """CLI endpoint catalog, contract-tested against the server OpenAPI schema."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Endpoint:
9
+ key: str
10
+ operation_id: str
11
+ method: str
12
+ path: str
13
+ scope: str
14
+ summary: str
15
+ example: str
16
+ requires_confirmation: bool = False
17
+
18
+
19
+ ENDPOINTS = (
20
+ Endpoint("objects.list", "flowbase_objects_list", "GET", "/objects", "metadata:read", "List object metadata.", "flowbase objects list"),
21
+ Endpoint("objects.get", "flowbase_objects_get", "GET", "/objects/{object_id}", "metadata:read", "Get object and field metadata.", "flowbase objects get obj_xxx"),
22
+ Endpoint("objects.create", "flowbase_objects_create", "POST", "/objects", "metadata:write", "Create an object draft.", "flowbase objects create --json-file object.json"),
23
+ Endpoint("objects.update", "flowbase_objects_update", "PUT", "/objects/{object_id}", "metadata:write", "Update an object draft.", "flowbase objects update obj_xxx --label Customer --description 'Customer records'"),
24
+ Endpoint("objects.delete", "flowbase_objects_delete", "DELETE", "/objects/{object_id}", "metadata:write", "Logically delete an object.", "flowbase objects delete obj_xxx --yes", True),
25
+ Endpoint("objects.restore", "flowbase_objects_restore", "POST", "/objects/{object_id}/restore", "metadata:write", "Restore an object.", "flowbase objects restore obj_xxx --yes", True),
26
+ Endpoint("fields.create", "flowbase_fields_create", "POST", "/objects/{object_id}/fields", "metadata:write", "Create a field draft.", "flowbase fields create obj_xxx --json-file field.json"),
27
+ Endpoint("fields.update", "flowbase_fields_update", "PUT", "/objects/{object_id}/fields/{field_id}", "metadata:write", "Update a field draft.", "flowbase fields update obj_xxx fld_xxx --label Name --description 'Customer name'"),
28
+ Endpoint("fields.delete", "flowbase_fields_delete", "DELETE", "/objects/{object_id}/fields/{field_id}", "metadata:write", "Logically delete a field.", "flowbase fields delete obj_xxx fld_xxx --yes", True),
29
+ Endpoint("fields.restore", "flowbase_fields_restore", "POST", "/objects/{object_id}/fields/{field_id}/restore", "metadata:write", "Restore a field.", "flowbase fields restore obj_xxx fld_xxx --yes", True),
30
+ Endpoint("records.list", "flowbase_records_list", "GET", "/objects/{object_id}/records", "records:read", "List records.", "flowbase records list obj_xxx --page 1"),
31
+ Endpoint("records.get", "flowbase_records_get", "GET", "/objects/{object_id}/records/{record_id}", "records:read", "Get a record.", "flowbase records get obj_xxx rec_xxx"),
32
+ Endpoint("records.create", "flowbase_records_create", "POST", "/objects/{object_id}/records", "records:write", "Create a record.", "flowbase records create obj_xxx --json-file record.json"),
33
+ Endpoint("records.update", "flowbase_records_update", "PUT", "/objects/{object_id}/records/{record_id}", "records:write", "Replace active field values on a record.", "flowbase records update obj_xxx rec_xxx --json-file record.json"),
34
+ Endpoint("records.delete", "flowbase_records_delete", "DELETE", "/objects/{object_id}/records/{record_id}", "records:write", "Delete a record.", "flowbase records delete obj_xxx rec_xxx --yes", True),
35
+ )
36
+
37
+ ENDPOINT_BY_KEY = {endpoint.key: endpoint for endpoint in ENDPOINTS}
flowbase_cli/main.py ADDED
@@ -0,0 +1,362 @@
1
+ """Command line client for the FlowBase OpenAPI."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import quote, urlencode
12
+ from urllib.request import Request, urlopen
13
+
14
+ from flowbase_cli.endpoints import ENDPOINTS, ENDPOINT_BY_KEY, Endpoint
15
+
16
+ DEFAULT_HOST = "http://flowbase.localhost:5001"
17
+ API_PREFIX = "/api/openapi/v1"
18
+ ENV_HOST = "FLOWBASE_HOST"
19
+ ENV_API_KEY = "FLOWBASE_API_KEY"
20
+
21
+
22
+ class CliError(Exception):
23
+ """Expected user-facing CLI failure."""
24
+
25
+
26
+ def api_root(host: str) -> str:
27
+ normalized = host.strip().rstrip("/")
28
+ if not normalized:
29
+ raise CliError("Host cannot be empty.")
30
+ if normalized.endswith(API_PREFIX):
31
+ return normalized
32
+ return f"{normalized}{API_PREFIX}"
33
+
34
+
35
+ def get_endpoint(key: str) -> Endpoint:
36
+ try:
37
+ return ENDPOINT_BY_KEY[key]
38
+ except KeyError as exc:
39
+ raise CliError(f"Unknown endpoint: {key}") from exc
40
+
41
+
42
+ def load_json_payload(json_text: str | None, json_file: str | None) -> dict[str, Any]:
43
+ if json_text and json_file:
44
+ raise CliError("--json and --json-file cannot be used together.")
45
+ if not json_text and not json_file:
46
+ return {}
47
+ try:
48
+ source = Path(json_file).read_text(encoding="utf-8") if json_file else (json_text or "{}")
49
+ except OSError as exc:
50
+ raise CliError(f"Cannot read JSON file: {exc}") from exc
51
+ try:
52
+ payload = json.loads(source)
53
+ except json.JSONDecodeError as exc:
54
+ raise CliError(f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
55
+ if not isinstance(payload, dict):
56
+ raise CliError("JSON payload must be an object.")
57
+ return payload
58
+
59
+
60
+ def require_fields(payload: dict[str, Any], *names: str) -> None:
61
+ missing = [name for name in names if name not in payload or payload[name] is None]
62
+ if missing:
63
+ raise CliError(f"Missing request field(s): {', '.join(missing)}")
64
+
65
+
66
+ class OpenApiClient:
67
+ def __init__(self, host: str, api_key: str | None) -> None:
68
+ self.root = api_root(host)
69
+ self.api_key = api_key
70
+
71
+ def build_url(
72
+ self,
73
+ endpoint: Endpoint,
74
+ path_params: dict[str, Any] | None = None,
75
+ query: dict[str, Any] | None = None,
76
+ ) -> str:
77
+ path = endpoint.path
78
+ values = path_params or {}
79
+ for name in _path_parameter_names(endpoint.path):
80
+ if name not in values:
81
+ raise CliError(f"Missing path parameter: {name}")
82
+ path = path.replace("{" + name + "}", quote(str(values[name]), safe=""))
83
+ clean_query = {key: value for key, value in (query or {}).items() if value is not None}
84
+ suffix = f"?{urlencode(clean_query, doseq=True)}" if clean_query else ""
85
+ return f"{self.root}{path}{suffix}"
86
+
87
+ def request(
88
+ self,
89
+ endpoint_key: str,
90
+ *,
91
+ path_params: dict[str, Any] | None = None,
92
+ query: dict[str, Any] | None = None,
93
+ body: dict[str, Any] | None = None,
94
+ ) -> bytes:
95
+ endpoint = get_endpoint(endpoint_key)
96
+ if not self.api_key:
97
+ raise CliError(f"Missing API key. Set {ENV_API_KEY} or pass --api-key.")
98
+ url = self.build_url(endpoint, path_params, query)
99
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"}
100
+ data = None
101
+ if body is not None:
102
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
103
+ headers["Content-Type"] = "application/json"
104
+ request = Request(url, data=data, headers=headers, method=endpoint.method)
105
+ try:
106
+ with urlopen(request) as response: # noqa: S310 - the user selects the FlowBase host.
107
+ return response.read()
108
+ except HTTPError as exc:
109
+ payload = exc.read().decode("utf-8", errors="replace")
110
+ raise CliError(f"HTTP {exc.code} for {endpoint.key}: {_error_detail(payload)}") from exc
111
+ except URLError as exc:
112
+ raise CliError(f"Failed to connect to {url}: {exc.reason}") from exc
113
+
114
+
115
+ def _path_parameter_names(path: str) -> list[str]:
116
+ return [part[1:-1] for part in path.split("/") if part.startswith("{") and part.endswith("}")]
117
+
118
+
119
+ def _error_detail(payload: str) -> str:
120
+ try:
121
+ parsed = json.loads(payload)
122
+ except json.JSONDecodeError:
123
+ return payload or "empty response"
124
+ if isinstance(parsed, dict) and "detail" in parsed:
125
+ detail = parsed["detail"]
126
+ return detail if isinstance(detail, str) else json.dumps(detail, ensure_ascii=False)
127
+ return json.dumps(parsed, ensure_ascii=False)
128
+
129
+
130
+ def print_output(data: bytes, *, pretty: bool, raw: bool) -> None:
131
+ text = data.decode("utf-8", errors="replace")
132
+ if raw:
133
+ print(text, end="" if text.endswith("\n") else "\n")
134
+ return
135
+ try:
136
+ parsed = json.loads(text)
137
+ except json.JSONDecodeError:
138
+ print(text, end="" if text.endswith("\n") else "\n")
139
+ return
140
+ indent = 2 if pretty else None
141
+ separators = None if pretty else (",", ":")
142
+ print(json.dumps(parsed, ensure_ascii=False, indent=indent, separators=separators))
143
+
144
+
145
+ def confirm_change(endpoint: Endpoint, yes: bool) -> None:
146
+ if not endpoint.requires_confirmation or yes:
147
+ return
148
+ if not sys.stdin.isatty():
149
+ raise CliError("Refusing a state-changing command in non-interactive mode without --yes.")
150
+ answer = input(f"{endpoint.summary} Continue? [y/N] ").strip().lower()
151
+ if answer not in {"y", "yes"}:
152
+ raise CliError("Cancelled.")
153
+
154
+
155
+ def client_from_args(args: argparse.Namespace) -> OpenApiClient:
156
+ host = args.host or os.getenv(ENV_HOST) or DEFAULT_HOST
157
+ api_key = args.api_key or os.getenv(ENV_API_KEY)
158
+ return OpenApiClient(host, api_key)
159
+
160
+
161
+ def run_request(
162
+ args: argparse.Namespace,
163
+ endpoint_key: str,
164
+ *,
165
+ path_params: dict[str, Any] | None = None,
166
+ query: dict[str, Any] | None = None,
167
+ body: dict[str, Any] | None = None,
168
+ ) -> int:
169
+ endpoint = get_endpoint(endpoint_key)
170
+ confirm_change(endpoint, getattr(args, "yes", False))
171
+ data = client_from_args(args).request(endpoint_key, path_params=path_params, query=query, body=body)
172
+ print_output(data, pretty=args.pretty, raw=args.raw)
173
+ return 0
174
+
175
+
176
+ def handle_docs(args: argparse.Namespace) -> int:
177
+ if args.docs_action == "list":
178
+ payload = [
179
+ {"key": e.key, "operationId": e.operation_id, "method": e.method, "path": e.path, "scope": e.scope, "summary": e.summary}
180
+ for e in ENDPOINTS
181
+ ]
182
+ print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
183
+ return 0
184
+ endpoint = get_endpoint(args.endpoint)
185
+ payload = {
186
+ "key": endpoint.key,
187
+ "operationId": endpoint.operation_id,
188
+ "method": endpoint.method,
189
+ "path": endpoint.path,
190
+ "scope": endpoint.scope,
191
+ "summary": endpoint.summary,
192
+ "requiresConfirmation": endpoint.requires_confirmation,
193
+ "example": endpoint.example,
194
+ }
195
+ print(json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None))
196
+ return 0
197
+
198
+
199
+ def _payload_from_args(args: argparse.Namespace, direct: dict[str, Any], required: tuple[str, ...]) -> dict[str, Any]:
200
+ payload = load_json_payload(args.json, args.json_file)
201
+ payload.update({key: value for key, value in direct.items() if value is not None})
202
+ require_fields(payload, *required)
203
+ return payload
204
+
205
+
206
+ def handle_objects(args: argparse.Namespace) -> int:
207
+ action = args.objects_action
208
+ if action == "list":
209
+ return run_request(args, "objects.list", query={"status": args.status})
210
+ if action == "get":
211
+ return run_request(args, "objects.get", path_params={"object_id": args.object_id}, query={"status": args.status})
212
+ if action == "create":
213
+ body = _payload_from_args(
214
+ args,
215
+ {"label": args.label, "key": args.key, "description": args.description},
216
+ ("label", "key", "description"),
217
+ )
218
+ return run_request(args, "objects.create", body=body)
219
+ if action == "update":
220
+ body = _payload_from_args(args, {"label": args.label, "description": args.description}, ("label", "description"))
221
+ return run_request(args, "objects.update", path_params={"object_id": args.object_id}, body=body)
222
+ return run_request(args, f"objects.{action}", path_params={"object_id": args.object_id})
223
+
224
+
225
+ def handle_fields(args: argparse.Namespace) -> int:
226
+ action = args.fields_action
227
+ path = {"object_id": args.object_id}
228
+ if hasattr(args, "field_id"):
229
+ path["field_id"] = args.field_id
230
+ if action == "create":
231
+ body = _payload_from_args(
232
+ args,
233
+ {"label": args.label, "key": args.key, "description": args.description},
234
+ ("label", "key", "description"),
235
+ )
236
+ return run_request(args, "fields.create", path_params=path, body=body)
237
+ if action == "update":
238
+ body = _payload_from_args(args, {"label": args.label, "description": args.description}, ("label", "description"))
239
+ return run_request(args, "fields.update", path_params=path, body=body)
240
+ return run_request(args, f"fields.{action}", path_params=path)
241
+
242
+
243
+ def handle_records(args: argparse.Namespace) -> int:
244
+ action = args.records_action
245
+ path = {"object_id": args.object_id}
246
+ if hasattr(args, "record_id"):
247
+ path["record_id"] = args.record_id
248
+ if action == "list":
249
+ return run_request(args, "records.list", path_params=path, query={"page": args.page})
250
+ if action == "get" or action == "delete":
251
+ return run_request(args, f"records.{action}", path_params=path)
252
+ body = load_json_payload(args.json, args.json_file)
253
+ require_fields(body, "values")
254
+ if not isinstance(body["values"], dict):
255
+ raise CliError("Request field 'values' must be an object.")
256
+ return run_request(args, f"records.{action}", path_params=path, body=body)
257
+
258
+
259
+ def add_json_args(parser: argparse.ArgumentParser) -> None:
260
+ group = parser.add_mutually_exclusive_group()
261
+ group.add_argument("--json", help="Request body as a JSON object.")
262
+ group.add_argument("--json-file", help="Read the request body from a UTF-8 JSON file.")
263
+
264
+
265
+ def add_object_fields(parser: argparse.ArgumentParser, *, include_key: bool) -> None:
266
+ parser.add_argument("--label")
267
+ if include_key:
268
+ parser.add_argument("--key")
269
+ parser.add_argument("--description")
270
+ add_json_args(parser)
271
+
272
+
273
+ def add_yes(parser: argparse.ArgumentParser) -> None:
274
+ parser.add_argument("--yes", action="store_true", help="Skip the interactive confirmation.")
275
+
276
+
277
+ def build_parser() -> argparse.ArgumentParser:
278
+ parser = argparse.ArgumentParser(prog="flowbase", description="FlowBase OpenAPI CLI")
279
+ parser.add_argument("--host", help=f"Environment Base URL. Defaults to ${ENV_HOST} or {DEFAULT_HOST}.")
280
+ parser.add_argument("--api-key", help=f"API key. Defaults to ${ENV_API_KEY}.")
281
+ parser.add_argument("--pretty", action=argparse.BooleanOptionalAction, default=True, help="Pretty-print JSON output.")
282
+ parser.add_argument("--raw", action="store_true", help="Print the response body without JSON formatting.")
283
+ groups = parser.add_subparsers(dest="group", required=True)
284
+
285
+ docs = groups.add_parser("docs", help="Inspect the offline endpoint catalog.")
286
+ docs_actions = docs.add_subparsers(dest="docs_action", required=True)
287
+ docs_actions.add_parser("list")
288
+ docs_show = docs_actions.add_parser("show")
289
+ docs_show.add_argument("endpoint", choices=sorted(ENDPOINT_BY_KEY))
290
+ docs.set_defaults(handler=handle_docs)
291
+
292
+ objects = groups.add_parser("objects", help="Manage object metadata.")
293
+ object_actions = objects.add_subparsers(dest="objects_action", required=True)
294
+ object_list = object_actions.add_parser("list")
295
+ object_list.add_argument("--status", choices=("active", "deleted"), default="active")
296
+ object_get = object_actions.add_parser("get")
297
+ object_get.add_argument("object_id")
298
+ object_get.add_argument("--status", choices=("active", "deleted"), default="active")
299
+ add_object_fields(object_actions.add_parser("create"), include_key=True)
300
+ object_update = object_actions.add_parser("update")
301
+ object_update.add_argument("object_id")
302
+ add_object_fields(object_update, include_key=False)
303
+ for action in ("delete", "restore"):
304
+ item = object_actions.add_parser(action)
305
+ item.add_argument("object_id")
306
+ add_yes(item)
307
+ objects.set_defaults(handler=handle_objects)
308
+
309
+ fields = groups.add_parser("fields", help="Manage field metadata.")
310
+ field_actions = fields.add_subparsers(dest="fields_action", required=True)
311
+ field_create = field_actions.add_parser("create")
312
+ field_create.add_argument("object_id")
313
+ add_object_fields(field_create, include_key=True)
314
+ field_update = field_actions.add_parser("update")
315
+ field_update.add_argument("object_id")
316
+ field_update.add_argument("field_id")
317
+ add_object_fields(field_update, include_key=False)
318
+ for action in ("delete", "restore"):
319
+ item = field_actions.add_parser(action)
320
+ item.add_argument("object_id")
321
+ item.add_argument("field_id")
322
+ add_yes(item)
323
+ fields.set_defaults(handler=handle_fields)
324
+
325
+ records = groups.add_parser("records", help="Manage business records.")
326
+ record_actions = records.add_subparsers(dest="records_action", required=True)
327
+ record_list = record_actions.add_parser("list")
328
+ record_list.add_argument("object_id")
329
+ record_list.add_argument("--page", type=int, default=1)
330
+ record_get = record_actions.add_parser("get")
331
+ record_get.add_argument("object_id")
332
+ record_get.add_argument("record_id")
333
+ record_create = record_actions.add_parser("create")
334
+ record_create.add_argument("object_id")
335
+ add_json_args(record_create)
336
+ record_update = record_actions.add_parser("update")
337
+ record_update.add_argument("object_id")
338
+ record_update.add_argument("record_id")
339
+ add_json_args(record_update)
340
+ record_delete = record_actions.add_parser("delete")
341
+ record_delete.add_argument("object_id")
342
+ record_delete.add_argument("record_id")
343
+ add_yes(record_delete)
344
+ records.set_defaults(handler=handle_records)
345
+ return parser
346
+
347
+
348
+ def main(argv: list[str] | None = None) -> int:
349
+ args = build_parser().parse_args(argv)
350
+ try:
351
+ return args.handler(args)
352
+ except CliError as exc:
353
+ print(f"flowbase: error: {exc}", file=sys.stderr)
354
+ return 1
355
+
356
+
357
+ def entrypoint() -> None:
358
+ raise SystemExit(main())
359
+
360
+
361
+ if __name__ == "__main__":
362
+ entrypoint()
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: flowbase-cli
3
+ Version: 0.1.0
4
+ Summary: Command line client for the FlowBase OpenAPI
5
+ Author: DeepFlowAI
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+
10
+ # FlowBase CLI
11
+
12
+ Install from this checkout:
13
+
14
+ ```bash
15
+ python -m pip install ./packages/flowbase-cli
16
+ ```
17
+
18
+ Configure an environment Base URL and an API key:
19
+
20
+ ```bash
21
+ export FLOWBASE_HOST=https://flowbase-test-a.deepflowagent.com
22
+ export FLOWBASE_API_KEY=fbk_xxx
23
+
24
+ flowbase objects list
25
+ flowbase records create obj_xxx --json '{"values":{"name":"Example"}}'
26
+ flowbase docs list
27
+ ```
28
+
29
+ `--host` selects the environment through its Host. The CLI never accepts a separate environment override and never stores API keys on disk. See `flowbase --help` and `flowbase docs list` for the complete command set.
@@ -0,0 +1,8 @@
1
+ flowbase_cli/__init__.py,sha256=IXcJbeEA1I_Xo2f3BH-e0nw3DSIcwavbeipyUiYFUnI,51
2
+ flowbase_cli/endpoints.py,sha256=kDP3VJlyz-JPJs29mfwAvsg1WngCfDyNWUUAfaZ2z74,3348
3
+ flowbase_cli/main.py,sha256=lVYeXB4ZxbN-GwvLjhaVFLqwa3l6lD6-wwa_kfvz-B8,14662
4
+ flowbase_cli-0.1.0.dist-info/METADATA,sha256=JnaWIYMmW8wp1Gha2tTRr1rTkRqV-Mjx8p95TypmlwY,807
5
+ flowbase_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ flowbase_cli-0.1.0.dist-info/entry_points.txt,sha256=kb3ZwOcvIIJ-2W3yyWgn7luZVbPsj57YusOyoIOv2YA,58
7
+ flowbase_cli-0.1.0.dist-info/top_level.txt,sha256=As7iuQHHfVT5d2WJ0cCN22Mmp0gTiOcoPpM0OC3XJxQ,13
8
+ flowbase_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flowbase = flowbase_cli.main:entrypoint
@@ -0,0 +1 @@
1
+ flowbase_cli