jmfts-web 0.6.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.
Files changed (45) hide show
  1. jmfts_web-0.6.0/LICENSE +21 -0
  2. jmfts_web-0.6.0/PKG-INFO +88 -0
  3. jmfts_web-0.6.0/README.md +64 -0
  4. jmfts_web-0.6.0/jmfts_web/__init__.py +73 -0
  5. jmfts_web-0.6.0/jmfts_web/static/client/copyout.d.ts +23 -0
  6. jmfts_web-0.6.0/jmfts_web/static/client/copyout.js +226 -0
  7. jmfts_web-0.6.0/jmfts_web/static/client/index.d.ts +18 -0
  8. jmfts_web-0.6.0/jmfts_web/static/client/index.js +26 -0
  9. jmfts_web-0.6.0/jmfts_web/static/client/operations.d.ts +159 -0
  10. jmfts_web-0.6.0/jmfts_web/static/client/operations.js +3004 -0
  11. jmfts_web-0.6.0/jmfts_web/static/client/transport.d.ts +72 -0
  12. jmfts_web-0.6.0/jmfts_web/static/client/transport.js +365 -0
  13. jmfts_web-0.6.0/jmfts_web/static/client/verbs.d.ts +4304 -0
  14. jmfts_web-0.6.0/jmfts_web/static/client/verbs.js +1122 -0
  15. jmfts_web-0.6.0/jmfts_web/static/index.html +38 -0
  16. jmfts_web-0.6.0/jmfts_web/static/render/markdown.js +481 -0
  17. jmfts_web-0.6.0/jmfts_web/static/render/overlay.js +384 -0
  18. jmfts_web-0.6.0/jmfts_web/static/render/renderers.js +292 -0
  19. jmfts_web-0.6.0/jmfts_web/static/render/sheet.js +276 -0
  20. jmfts_web-0.6.0/jmfts_web/static/render/surfaces.js +573 -0
  21. jmfts_web-0.6.0/jmfts_web/static/render/transcript.js +123 -0
  22. jmfts_web-0.6.0/jmfts_web/static/shell/citation.js +191 -0
  23. jmfts_web-0.6.0/jmfts_web/static/shell/manifest.js +32 -0
  24. jmfts_web-0.6.0/jmfts_web/static/shell/registry.js +171 -0
  25. jmfts_web-0.6.0/jmfts_web/static/shell/shell.css +200 -0
  26. jmfts_web-0.6.0/jmfts_web/static/shell/shell.js +336 -0
  27. jmfts_web-0.6.0/jmfts_web/static/views/capabilities.js +300 -0
  28. jmfts_web-0.6.0/jmfts_web/static/views/document.css +87 -0
  29. jmfts_web-0.6.0/jmfts_web/static/views/document.js +1158 -0
  30. jmfts_web-0.6.0/jmfts_web/static/views/log.css +86 -0
  31. jmfts_web-0.6.0/jmfts_web/static/views/log.js +331 -0
  32. jmfts_web-0.6.0/jmfts_web/static/views/presets.css +68 -0
  33. jmfts_web-0.6.0/jmfts_web/static/views/presets.js +1127 -0
  34. jmfts_web-0.6.0/jmfts_web/static/views/search.css +77 -0
  35. jmfts_web-0.6.0/jmfts_web/static/views/search.js +1461 -0
  36. jmfts_web-0.6.0/jmfts_web/static/views/tree.css +55 -0
  37. jmfts_web-0.6.0/jmfts_web/static/views/tree.js +351 -0
  38. jmfts_web-0.6.0/jmfts_web/static/views/upload.css +202 -0
  39. jmfts_web-0.6.0/jmfts_web/static/views/upload.js +1293 -0
  40. jmfts_web-0.6.0/jmfts_web.egg-info/PKG-INFO +88 -0
  41. jmfts_web-0.6.0/jmfts_web.egg-info/SOURCES.txt +43 -0
  42. jmfts_web-0.6.0/jmfts_web.egg-info/dependency_links.txt +1 -0
  43. jmfts_web-0.6.0/jmfts_web.egg-info/top_level.txt +1 -0
  44. jmfts_web-0.6.0/pyproject.toml +71 -0
  45. jmfts_web-0.6.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fight Fire with Fire Robotics, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: jmfts-web
3
+ Version: 0.6.0
4
+ Summary: Browser front end for a JMFTS retrieval appliance
5
+ Author: Fight Fire with Fire Robotics, LLC
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jmccardle/jmfts
8
+ Project-URL: Repository, https://github.com/jmccardle/jmfts
9
+ Keywords: jmfts,retrieval,ui,frontend
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # jmfts-web
26
+
27
+ The browser front end for a [JMFTS](https://github.com/jmccardle/jmfts) retrieval appliance.
28
+
29
+ ```bash
30
+ pip install 'jmfts[web]'
31
+ uvicorn jmfts_core.rest.main:app --host 0.0.0.0 --port 8100
32
+ # then open http://localhost:8100/app/
33
+ ```
34
+
35
+ This wheel carries built static files and one module that says where they are. It has **no
36
+ dependencies**: everything the front end does it does in the browser, over the same REST API
37
+ any other client calls. Installing it adds no Python package to the appliance.
38
+
39
+ It is not useful on its own — there is nothing to import and nothing to run. `jmfts[web]`
40
+ depends on it, and `jmfts_core.rest.main` mounts it at `/app` when it is present. An
41
+ appliance without it starts normally and serves no `/app`.
42
+
43
+ ## One generated client, and one event per call
44
+
45
+ `jmfts_web/static/client/` is the only way this front end talks to an appliance. Four of its
46
+ files are generated from the appliance's own `/openapi.json`, the way `jmfts-client`'s
47
+ `_verbs.py` is generated from the route table:
48
+
49
+ ```bash
50
+ python -m scripts.generate_ts_client # in a jmfts source checkout
51
+ python -m scripts.generate_ts_client --check # exit 1 if the bundle is stale
52
+ ```
53
+
54
+ The other six are the hand-written runtime — the request path, the three copy-out printers,
55
+ and the entry point. `tests/test_ts_client_codegen.py` refuses a stale generated file and
56
+ refuses a file that is in neither set.
57
+
58
+ Every call emits one event: `{op_id, method, path, path_params, query, body, status,
59
+ response, ms}`. Three things follow from that.
60
+
61
+ * **Replay.** An event is a call plus its arguments, so `client.replay(event, {…})` re-sends
62
+ it with any of them edited.
63
+ * **Verifiable thinness.** A view showing something the call log has no event for computed it
64
+ in the browser. That is readable from the log rather than arguable from the source.
65
+ * **Copy-out.** `asCurl`, `asPython` and `asFetch` print any event three ways — and none of
66
+ them prints the credential. Each names the environment variable it lives in instead.
67
+
68
+ There is no build step and no bundler: these are ES modules the browser runs as they stand,
69
+ with `.d.ts` declarations beside them for TypeScript consumers. Nothing loads from a CDN, a
70
+ font host or anywhere but the appliance's own origin, because this appliance is expected to
71
+ run air-gapped.
72
+
73
+ ## Why a separate distribution
74
+
75
+ An extra guards dependencies. Static files inside the server package would ship with the
76
+ appliance whether or not an extra named them, so `jmfts[web]` over a bundle in the main wheel
77
+ would have been a flag that guards nothing. `Dockerfile.worker` builds a worker that drains
78
+ the ingest queue and never serves a page.
79
+
80
+ ## Versioning
81
+
82
+ One number, three wheels, one tag — `jmfts`, `jmfts-client` and `jmfts-web` release in
83
+ lockstep. The front end is written against the appliance's own route table, so a UI one
84
+ release behind its server renders controls for operations that have moved.
85
+
86
+ ## Licence
87
+
88
+ MIT. See `LICENSE`.
@@ -0,0 +1,64 @@
1
+ # jmfts-web
2
+
3
+ The browser front end for a [JMFTS](https://github.com/jmccardle/jmfts) retrieval appliance.
4
+
5
+ ```bash
6
+ pip install 'jmfts[web]'
7
+ uvicorn jmfts_core.rest.main:app --host 0.0.0.0 --port 8100
8
+ # then open http://localhost:8100/app/
9
+ ```
10
+
11
+ This wheel carries built static files and one module that says where they are. It has **no
12
+ dependencies**: everything the front end does it does in the browser, over the same REST API
13
+ any other client calls. Installing it adds no Python package to the appliance.
14
+
15
+ It is not useful on its own — there is nothing to import and nothing to run. `jmfts[web]`
16
+ depends on it, and `jmfts_core.rest.main` mounts it at `/app` when it is present. An
17
+ appliance without it starts normally and serves no `/app`.
18
+
19
+ ## One generated client, and one event per call
20
+
21
+ `jmfts_web/static/client/` is the only way this front end talks to an appliance. Four of its
22
+ files are generated from the appliance's own `/openapi.json`, the way `jmfts-client`'s
23
+ `_verbs.py` is generated from the route table:
24
+
25
+ ```bash
26
+ python -m scripts.generate_ts_client # in a jmfts source checkout
27
+ python -m scripts.generate_ts_client --check # exit 1 if the bundle is stale
28
+ ```
29
+
30
+ The other six are the hand-written runtime — the request path, the three copy-out printers,
31
+ and the entry point. `tests/test_ts_client_codegen.py` refuses a stale generated file and
32
+ refuses a file that is in neither set.
33
+
34
+ Every call emits one event: `{op_id, method, path, path_params, query, body, status,
35
+ response, ms}`. Three things follow from that.
36
+
37
+ * **Replay.** An event is a call plus its arguments, so `client.replay(event, {…})` re-sends
38
+ it with any of them edited.
39
+ * **Verifiable thinness.** A view showing something the call log has no event for computed it
40
+ in the browser. That is readable from the log rather than arguable from the source.
41
+ * **Copy-out.** `asCurl`, `asPython` and `asFetch` print any event three ways — and none of
42
+ them prints the credential. Each names the environment variable it lives in instead.
43
+
44
+ There is no build step and no bundler: these are ES modules the browser runs as they stand,
45
+ with `.d.ts` declarations beside them for TypeScript consumers. Nothing loads from a CDN, a
46
+ font host or anywhere but the appliance's own origin, because this appliance is expected to
47
+ run air-gapped.
48
+
49
+ ## Why a separate distribution
50
+
51
+ An extra guards dependencies. Static files inside the server package would ship with the
52
+ appliance whether or not an extra named them, so `jmfts[web]` over a bundle in the main wheel
53
+ would have been a flag that guards nothing. `Dockerfile.worker` builds a worker that drains
54
+ the ingest queue and never serves a page.
55
+
56
+ ## Versioning
57
+
58
+ One number, three wheels, one tag — `jmfts`, `jmfts-client` and `jmfts-web` release in
59
+ lockstep. The front end is written against the appliance's own route table, so a UI one
60
+ release behind its server renders controls for operations that have moved.
61
+
62
+ ## Licence
63
+
64
+ MIT. See `LICENSE`.
@@ -0,0 +1,73 @@
1
+ """jmfts-web — the browser front end for a JMFTS appliance, as its own distribution.
2
+
3
+ IC-5, ``docs/SPRINT_0_6_0.md`` Block F step 19. This package holds one thing: a directory of
4
+ built static files, and the function that says where it is. There is no Python logic here and
5
+ there is not meant to be — the appliance mounts :func:`static_dir` and serves it, and
6
+ everything the front end does it does by calling the same REST API any other client calls.
7
+
8
+ **Why this is a third distribution rather than a directory inside ``jmfts_core``.** An extra
9
+ guards dependencies. Static files inside the server package ship with the appliance whether or
10
+ not an extra names them, so ``jmfts[web]`` over a bundle in the main wheel would have been a
11
+ flag that guards nothing. ``Dockerfile.worker`` builds a worker that drains the ingest queue
12
+ and never serves a page; it should not carry a UI it cannot use. Question 4.8 of that sprint
13
+ plan records the cost that was accepted for this: a third version in ``bump-version.sh``, a
14
+ step in ``docs/RELEASING.md``, and a third case in ``.github/workflows/publish.yml``.
15
+
16
+ **The version tracks the other two exactly.** One number, three wheels, one tag. The front end
17
+ is generated against the appliance's own route table, so a client one release behind its server
18
+ is a client that renders controls for operations that have moved.
19
+
20
+ ``jmfts_core.rest.main`` imports this package inside a ``try``: a base install has no
21
+ ``jmfts_web`` and must start anyway, with no ``/app`` route rather than a broken one.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ __version__ = "0.6.0"
29
+
30
+ __all__ = ["static_dir", "index_path", "__version__"]
31
+
32
+ #: The bundle, relative to this file. ``tests/test_web_distribution.py`` refuses a
33
+ #: distribution whose bundle is missing.
34
+ #:
35
+ #: **There is no build step and that is not an omission.** Part of ``static/client/`` IS
36
+ #: generated — ``scripts.generate_ts_client`` renders it from the appliance's
37
+ #: ``/openapi.json`` (Block F step 23) — but it is generated as ES modules the browser runs
38
+ #: as they stand, so nothing has to be compiled between here and a page. What a build step
39
+ #: would add is a bundler and a TypeScript compiler, and Block F step 24 is where that
40
+ #: decision belongs; until there is a source tree to compile, it would be a file that runs
41
+ #: and does nothing.
42
+ _STATIC = Path(__file__).resolve().parent / "static"
43
+
44
+
45
+ def static_dir() -> Path:
46
+ """The directory to mount, as an absolute path.
47
+
48
+ Raises ``FileNotFoundError`` when the bundle is absent rather than returning a path that
49
+ does not exist. A ``StaticFiles`` mount over a missing directory raises at mount time in
50
+ some Starlette versions and answers 404 for every asset in others; neither is a state the
51
+ appliance should boot into quietly, because both look exactly like "the front end is
52
+ broken" to whoever opens it.
53
+ """
54
+ if not _STATIC.is_dir():
55
+ raise FileNotFoundError(
56
+ f"jmfts-web is installed but its bundle is missing at {_STATIC}. "
57
+ "This is a packaging fault, not a configuration one: reinstall the wheel, or in "
58
+ "a source checkout run python -m scripts.generate_ts_client."
59
+ )
60
+ return _STATIC
61
+
62
+
63
+ def index_path() -> Path:
64
+ """The single-page entry document.
65
+
66
+ Named separately from :func:`static_dir` because the mount serves assets by path and the
67
+ application needs one document served for every unmatched route — a front end with client
68
+ -side routing answers ``/app/search`` from the same file as ``/app/``.
69
+ """
70
+ index = static_dir() / "index.html"
71
+ if not index.is_file():
72
+ raise FileNotFoundError(f"jmfts-web's bundle has no index.html at {index}.")
73
+ return index
@@ -0,0 +1,23 @@
1
+ // Types for the three copy-out renderers. Hand-written; `copyout.js` is the code.
2
+
3
+ import type { CallEvent } from "./transport.js";
4
+
5
+ export interface CopyOutOptions {
6
+ /**
7
+ * The appliance's origin to print. Empty keeps the URL relative, which is right for
8
+ * `fetch` from the same page and wrong for a `curl` somebody will paste into a terminal.
9
+ */
10
+ baseUrl?: string;
11
+ }
12
+
13
+ /** The event as a `curl` invocation, with the credential named and never printed. */
14
+ export declare function asCurl(event: CallEvent, options?: CopyOutOptions): string;
15
+
16
+ /**
17
+ * The event as a `jmfts_client.RemoteJmftsClient` call, or as the `httpx` call that is its
18
+ * honest equivalent for a route with no generated verb.
19
+ */
20
+ export declare function asPython(event: CallEvent, options?: CopyOutOptions): string;
21
+
22
+ /** The event as the browser `fetch` this client itself issued. */
23
+ export declare function asFetch(event: CallEvent, options?: CopyOutOptions): string;
@@ -0,0 +1,226 @@
1
+ // @ts-self-types="./copyout.d.ts"
2
+ //
3
+ // A call event, printed three ways. Hand-written.
4
+ //
5
+ // docs/SPRINT_0_6_0.md Block F step 23, third property: "an operation id maps to a
6
+ // `_verbs.py` method name, so an event prints as curl with the token elided, as a
7
+ // RemoteJmftsClient call, and as fetch. Worked examples stop being a thing anybody writes
8
+ // by hand."
9
+ //
10
+ // That is the point. Every example in this appliance's documentation of how to call it is a
11
+ // thing that can go stale; a printed event cannot, because it is a transcript of a request
12
+ // that was actually made a moment ago, rendered from the same table the request was built
13
+ // from.
14
+ //
15
+ // THE CREDENTIAL IS NEVER PRINTED. Each renderer names the environment variable or the
16
+ // variable the reader is expected to supply. A copy button that put the appliance's token on
17
+ // the clipboard, and from there into a chat window or a bug report, would be a credential
18
+ // leak with a convenience feature around it.
19
+
20
+ import { OPERATIONS } from "./operations.js";
21
+ import { queryString } from "./transport.js";
22
+
23
+ /** The shell/environment variable each credential is named by, never its value. */
24
+ const CREDENTIAL_ENV = Object.freeze({
25
+ JMFTSToken: "JMFTS_API_TOKEN",
26
+ JMFTSRunnerKey: "JMFTS_RUNNER_KEY",
27
+ });
28
+
29
+ function operationFor(event) {
30
+ const op = OPERATIONS[event.op_id];
31
+ if (!op) throw new Error(`no such operation: ${event.op_id}`);
32
+ return op;
33
+ }
34
+
35
+ /** `'...'` for POSIX sh, with embedded single quotes closed and reopened. */
36
+ function shellQuote(text) {
37
+ return `'${String(text).replace(/'/g, `'\\''`)}'`;
38
+ }
39
+
40
+ /** Continue a multi-line literal from the column it was written at. */
41
+ function reindent(text, spaces) {
42
+ const pad = " ".repeat(spaces);
43
+ return text.split("\n").join(`\n${pad}`);
44
+ }
45
+
46
+ /** What a file part is, without its bytes: curl and fetch both need the name, not the data. */
47
+ function partName(value) {
48
+ if (value && typeof value === "object" && typeof value.name === "string") return value.name;
49
+ return "FILE";
50
+ }
51
+
52
+ /**
53
+ * The event as a `curl` invocation.
54
+ *
55
+ * @param {object} event a call event
56
+ * @param {{baseUrl?: string}} [options] the appliance's origin; "" keeps the path relative
57
+ */
58
+ export function asCurl(event, options = {}) {
59
+ const op = operationFor(event);
60
+ const base = (options.baseUrl ?? "").replace(/\/$/, "");
61
+ const url = base + event.path + queryString(event.query);
62
+ const lines = [`curl -sS -X ${op.method}`];
63
+
64
+ if (op.security_scheme) {
65
+ // Double quotes so the shell expands the variable; the value itself is never here.
66
+ lines.push(` -H "Authorization: Bearer $${CREDENTIAL_ENV[op.security_scheme]}"`);
67
+ }
68
+ if (op.body_media_type === "multipart/form-data") {
69
+ for (const name of op.file_params) {
70
+ if (event.body?.[name] !== undefined) {
71
+ lines.push(` -F ${shellQuote(`${name}=@${partName(event.body[name])}`)}`);
72
+ }
73
+ }
74
+ for (const name of op.form_params) {
75
+ if (event.body?.[name] !== undefined && event.body[name] !== null) {
76
+ lines.push(` -F ${shellQuote(`${name}=${JSON.stringify(event.body[name])}`)}`);
77
+ }
78
+ }
79
+ } else if (op.body_param && event.body !== undefined) {
80
+ if (op.body_media_type === "application/json") {
81
+ lines.push(` -H 'Content-Type: application/json'`);
82
+ lines.push(` -d ${shellQuote(JSON.stringify(event.body))}`);
83
+ } else {
84
+ lines.push(` -H ${shellQuote(`Content-Type: ${op.body_media_type}; charset=utf-8`)}`);
85
+ lines.push(` -d ${shellQuote(event.body)}`);
86
+ }
87
+ }
88
+ if (op.binary) {
89
+ lines.push(` -o out.${(op.response_media_type || "bin").split("/").pop()}`);
90
+ }
91
+ lines.push(` ${shellQuote(url)}`);
92
+ return lines.join(" \\\n");
93
+ }
94
+
95
+ /** A JSON value as Python source. */
96
+ function asPythonLiteral(value, indent = "") {
97
+ if (value === null || value === undefined) return "None";
98
+ if (value === true) return "True";
99
+ if (value === false) return "False";
100
+ if (typeof value === "number") return String(value);
101
+ if (typeof value === "string") return JSON.stringify(value);
102
+ if (Array.isArray(value)) {
103
+ if (value.length === 0) return "[]";
104
+ const inner = value.map((v) => `${indent} ${asPythonLiteral(v, `${indent} `)}`);
105
+ return `[\n${inner.join(",\n")},\n${indent}]`;
106
+ }
107
+ if (value instanceof Blob) return `open(${JSON.stringify(partName(value))}, "rb").read()`;
108
+ const entries = Object.entries(value);
109
+ if (entries.length === 0) return "{}";
110
+ const inner = entries.map(
111
+ ([k, v]) => `${indent} ${JSON.stringify(k)}: ${asPythonLiteral(v, `${indent} `)}`
112
+ );
113
+ return `{\n${inner.join(",\n")},\n${indent}}`;
114
+ }
115
+
116
+ /**
117
+ * The event as a `jmfts_client.RemoteJmftsClient` call.
118
+ *
119
+ * Seven of the appliance's routes are hand-written FastAPI routes rather than `@expose`'d
120
+ * service methods, so they have no generated verb. For those this prints the `httpx` call
121
+ * that is the honest equivalent, rather than a client method that does not exist.
122
+ */
123
+ export function asPython(event, options = {}) {
124
+ const op = operationFor(event);
125
+ const base = options.baseUrl || "http://localhost:8100";
126
+ const env = op.security_scheme ? CREDENTIAL_ENV[op.security_scheme] : null;
127
+
128
+ if (!op.python_verb) {
129
+ const header = env ? `, headers={"Authorization": f"Bearer {os.environ['${env}']}"}` : "";
130
+ const body =
131
+ op.body_param && event.body !== undefined ? `, json=${asPythonLiteral(event.body)}` : "";
132
+ const url = `${base}${event.path}${queryString(event.query)}`;
133
+ return [
134
+ ...(env ? ["import os", ""] : []),
135
+ "import httpx",
136
+ "",
137
+ `# ${op.op_id} is a hand-written route: jmfts-client has no verb for it.`,
138
+ `httpx.request(${JSON.stringify(op.method)}, ${JSON.stringify(url)}${header}${body})`,
139
+ ].join("\n");
140
+ }
141
+
142
+ // Arguments are printed one per line at eight spaces, so nested literals continue from
143
+ // there: a pasted call has to be valid Python, not merely recognisable as one.
144
+ const inner = " ";
145
+ const args = [];
146
+ for (const name of op.path_params) {
147
+ args.push(`${name}=${asPythonLiteral(event.path_params[name], inner)}`);
148
+ }
149
+ if (op.body_param && event.body !== undefined) {
150
+ args.push(`${op.body_param}=${asPythonLiteral(event.body, inner)}`);
151
+ }
152
+ for (const name of [...op.file_params, ...op.form_params]) {
153
+ if (event.body?.[name] !== undefined) {
154
+ args.push(`${name}=${asPythonLiteral(event.body[name], inner)}`);
155
+ }
156
+ }
157
+ for (const [name, value] of Object.entries(event.query)) {
158
+ args.push(`${name}=${asPythonLiteral(value, inner)}`);
159
+ }
160
+
161
+ const token = env ? `, token=os.environ[${JSON.stringify(env)}]` : "";
162
+ const imports = env ? ["import os", ""] : [];
163
+ return [
164
+ ...imports,
165
+ "from jmfts_client import RemoteJmftsClient",
166
+ "",
167
+ `with RemoteJmftsClient(${JSON.stringify(base)}${token}) as client:`,
168
+ ` result = client.${op.python_verb}(`,
169
+ ...args.map((arg) => ` ${arg},`),
170
+ " )",
171
+ ].join("\n");
172
+ }
173
+
174
+ /** The event as a browser `fetch`, which is what this client itself did. */
175
+ export function asFetch(event, options = {}) {
176
+ const op = operationFor(event);
177
+ const base = (options.baseUrl ?? "").replace(/\/$/, "");
178
+ const url = base + event.path + queryString(event.query);
179
+ const headers = [];
180
+ if (op.security_scheme) headers.push(` "Authorization": \`Bearer \${token}\`,`);
181
+
182
+ const init = [` method: ${JSON.stringify(op.method)},`];
183
+ let prelude = [];
184
+ if (op.body_media_type === "multipart/form-data") {
185
+ prelude = ["const form = new FormData();"];
186
+ for (const name of op.file_params) {
187
+ if (event.body?.[name] !== undefined) {
188
+ // The bytes are not printable, so this names where they come from and says which
189
+ // file was actually sent. Nothing here pretends to reproduce the upload.
190
+ prelude.push(
191
+ `form.append(${JSON.stringify(name)}, fileInput.files[0]); ` +
192
+ `// was: ${partName(event.body[name])}`
193
+ );
194
+ }
195
+ }
196
+ for (const name of op.form_params) {
197
+ if (event.body?.[name] !== undefined && event.body[name] !== null) {
198
+ prelude.push(
199
+ `form.append(${JSON.stringify(name)}, ${JSON.stringify(
200
+ JSON.stringify(event.body[name])
201
+ )});`
202
+ );
203
+ }
204
+ }
205
+ init.push(" body: form,");
206
+ } else if (op.body_param && event.body !== undefined) {
207
+ if (op.body_media_type === "application/json") {
208
+ headers.push(` "Content-Type": "application/json",`);
209
+ init.push(` body: JSON.stringify(${reindent(JSON.stringify(event.body, null, 2), 4)}),`);
210
+ } else {
211
+ const declared = JSON.stringify(`${op.body_media_type}; charset=utf-8`);
212
+ headers.push(` "Content-Type": ${declared},`);
213
+ init.push(` body: ${JSON.stringify(event.body)},`);
214
+ }
215
+ }
216
+ if (headers.length) init.splice(1, 0, " headers: {", ...headers, " },");
217
+
218
+ const read = op.binary ? "await response.blob()" : "await response.json()";
219
+ return [
220
+ ...prelude,
221
+ `const response = await fetch(${JSON.stringify(url)}, {`,
222
+ ...init,
223
+ "});",
224
+ `const result = ${read};`,
225
+ ].join("\n");
226
+ }
@@ -0,0 +1,18 @@
1
+ // Types for the entry point. Hand-written; `index.js` is the code.
2
+
3
+ export { JmftsClient } from "./verbs.js";
4
+ export { OPERATIONS, OP_IDS } from "./operations.js";
5
+ export type { Operation, OpId } from "./operations.js";
6
+ export {
7
+ CALL_EVENT_FIELDS,
8
+ CREDENTIAL_KEYS,
9
+ TOKEN_KEY,
10
+ JmftsHttpError,
11
+ JmftsTransport,
12
+ JmftsTransportError,
13
+ credentialStorage,
14
+ detailOf,
15
+ } from "./transport.js";
16
+ export type { CallEvent, JmftsTransportOptions } from "./transport.js";
17
+ export { asCurl, asFetch, asPython } from "./copyout.js";
18
+ export type { CopyOutOptions } from "./copyout.js";
@@ -0,0 +1,26 @@
1
+ // @ts-self-types="./index.d.ts"
2
+ //
3
+ // The one import a view needs. Hand-written.
4
+ //
5
+ // docs/SPRINT_0_6_0.md Block F step 23. Every view calls ONE client, and this is the door to
6
+ // it — `import { JmftsClient } from "./client/index.js"`. Nothing here has behaviour; the
7
+ // request path is transport.js, the operation table and the verbs are generated, and the
8
+ // three printers are copyout.js.
9
+ //
10
+ // No build step and no bundler: this is an ES module the browser executes as it stands, and
11
+ // it loads nothing from anywhere but this origin. The appliance is expected to run
12
+ // air-gapped, and a page that renders blank without internet is worse than no page.
13
+
14
+ export { JmftsClient } from "./verbs.js";
15
+ export { OPERATIONS, OP_IDS } from "./operations.js";
16
+ export {
17
+ CALL_EVENT_FIELDS,
18
+ CREDENTIAL_KEYS,
19
+ TOKEN_KEY,
20
+ JmftsHttpError,
21
+ JmftsTransport,
22
+ JmftsTransportError,
23
+ credentialStorage,
24
+ detailOf,
25
+ } from "./transport.js";
26
+ export { asCurl, asFetch, asPython } from "./copyout.js";