method-ai 0.14.3__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,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: method-ai
3
+ Version: 0.14.3
4
+ Summary: Run agentic methods in discrete verifiable steps.
5
+ Author: Method
6
+ License: MIT
7
+ Keywords: workflow,agent,verification,sdk
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # method-ai
19
+
20
+ MIT-licensed Python binding to the same installed Node Method runtime. Requires Python 3.9+ and the JavaScript SDK 0.14.3.
21
+
22
+ ```sh
23
+ npm install -g https://github.com/method-ai-hq/method-sdk/releases/download/v0.14.3/method-ai-hq-sdk-0.14.3.tgz
24
+ pip install https://github.com/method-ai-hq/method-sdk/releases/download/v0.14.3/method_ai-0.14.3-py3-none-any.whl
25
+ withmethod authoring
26
+ ```
27
+
28
+ ```python
29
+ from pathlib import Path
30
+ from withmethod import load_method, run_method
31
+ method = load_method(Path("task.method"))
32
+ result = run_method("task.method", {"allow_local_processes": True}, inputs={}, directory="runs/first")
33
+ print(result["status"])
34
+ ```
35
+
36
+ Use method/3.4 for new Methods. To run a published Method from an app, use `Method().run("wf_…", inputs)`; see https://docs.withmethod.ai/guides/production. Python calls the executor through the method-bridge command. Source and releases: https://github.com/method-ai-hq/method-sdk
@@ -0,0 +1,11 @@
1
+ method_ai-0.14.3.dist-info/licenses/LICENSE,sha256=kXK7yFv9k5aNTd3rGldSPZsXrLH6FCubjN1i1Q0KDWs,1063
2
+ withmethod/__init__.py,sha256=5_jDj1TQM7HkQP2sUZuhafJcPMeLut3U5pZO0Oi95zo,482
3
+ withmethod/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
4
+ withmethod/cli.py,sha256=d-tAwLaVkSAHtStF5vseVcPI1_sChpsBxpPn0di0Cng,346
5
+ withmethod/client.py,sha256=Mvptkck1KMY5KA4sn1P8_Gkd56Bx52W5liZj4nu-aLU,3675
6
+ withmethod/production.py,sha256=BQq28kiOM-M8KX8-uwphB0UpyId9wJEIxMgQeYJGYb0,20002
7
+ method_ai-0.14.3.dist-info/METADATA,sha256=3jZOt_DnAOMLK25ahxvvat6iOzBqEEfGpU9Po5FZhfQ,1441
8
+ method_ai-0.14.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ method_ai-0.14.3.dist-info/entry_points.txt,sha256=M9MUiNjy1y32cJaeG5gYmUJDM01ZqO6wTe5OD8ONqME,51
10
+ method_ai-0.14.3.dist-info/top_level.txt,sha256=iQ-NATP7l9uSzJM5sSgs7hX6VGEuzDqF379Y5PWgvso,11
11
+ method_ai-0.14.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ withmethod = withmethod.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Method
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 @@
1
+ withmethod
withmethod/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Python API for Method. Requires Node.js 22 and @method-ai-hq/sdk."""
2
+ from .client import WorkflowError, load_workflow, render_prompt, run_method
3
+ from .production import Method, MethodApiError, Question, Worker, verify_webhook
4
+
5
+ __version__ = "0.5.0"
6
+ MethodError = WorkflowError
7
+ load_method = load_workflow
8
+ __all__ = ["Method", "MethodApiError", "Question", "Worker", "verify_webhook", "MethodError", "load_method", "run_method", "render_prompt", "WorkflowError", "load_workflow"]
withmethod/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
withmethod/cli.py ADDED
@@ -0,0 +1,12 @@
1
+ """The Python CLI uses the product CLI and its exact options."""
2
+ import subprocess
3
+ import sys
4
+ from .client import runtime_command, WorkflowError
5
+
6
+
7
+ def main() -> int:
8
+ try:
9
+ return subprocess.call([*runtime_command("method"), *sys.argv[1:]])
10
+ except WorkflowError as error:
11
+ print(str(error), file=sys.stderr)
12
+ return 1
withmethod/client.py ADDED
@@ -0,0 +1,81 @@
1
+ """Python binding to the public Method runtime."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ from typing import Any, Callable
10
+
11
+ Json = Any
12
+ Callback = Callable[[dict[str, Json]], Json]
13
+
14
+
15
+ class WorkflowError(RuntimeError):
16
+ """A workflow validation, runtime, or host connection error."""
17
+
18
+
19
+ def runtime_command(entry: str = "bridge") -> list[str]:
20
+ override = os.environ.get("METHOD_BRIDGE" if entry == "bridge" else "METHOD_CLI")
21
+ if override:
22
+ return [override]
23
+ # Development checkout uses the source runtime, so tests cannot pass against an old build.
24
+ project = Path(__file__).resolve().parents[4]
25
+ # The CLI entry is method.ts; the bridge is bridge.ts.
26
+ source = project / "packages" / "sdk" / "src" / ("method.ts" if entry == "cli" else f"{entry}.ts")
27
+ loader = project / "node_modules" / "tsx" / "dist" / "loader.mjs"
28
+ if source.is_file() and loader.is_file():
29
+ return ["node", "--import", str(loader), str(source)]
30
+ installed = shutil.which("method-bridge" if entry == "bridge" else "method")
31
+ if installed:
32
+ return [installed]
33
+ raise WorkflowError("Install Node.js 22+ and @method-ai-hq/sdk, or set METHOD_BRIDGE to the installed method-bridge executable.")
34
+
35
+
36
+ def _call(payload: dict[str, Json], on_event: Callback | None = None) -> Json:
37
+ process = subprocess.Popen(runtime_command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
38
+ stderr=None, text=True, encoding="utf-8", bufsize=1)
39
+ assert process.stdin is not None and process.stdout is not None
40
+ try:
41
+ process.stdin.write(json.dumps(payload, allow_nan=False) + "\n")
42
+ process.stdin.flush()
43
+ for line in process.stdout:
44
+ response = json.loads(line)
45
+ if response["type"] == "result":
46
+ return response["result"]
47
+ if response["type"] == "error":
48
+ raise WorkflowError(response["error"])
49
+ if response["type"] == "event" and on_event:
50
+ on_event(response["event"])
51
+ raise WorkflowError(f"The runtime exited without a result (status {process.wait()}).")
52
+ finally:
53
+ process.stdin.close()
54
+ process.stdout.close()
55
+ try:
56
+ process.wait(timeout=5)
57
+ except subprocess.TimeoutExpired:
58
+ process.terminate()
59
+ process.wait(timeout=5)
60
+
61
+
62
+ def load_workflow(value: dict[str, Json] | str | Path) -> dict[str, Json]:
63
+ """Load and validate Method YAML, data shapes, and named references."""
64
+ if isinstance(value, Path):
65
+ value = value.read_text(encoding="utf-8")
66
+ return _call({"op": "load", "workflow": value})
67
+
68
+
69
+ def render_prompt(workflow: dict[str, Json]) -> str:
70
+ return _call({"op": "prompt", "workflow": workflow})
71
+
72
+
73
+ def run_method(file: str | Path, config: dict[str, Json], *, inputs: dict[str, Json] | None = None,
74
+ state: dict[str, Json] | None = None, directory: str | Path | None = None,
75
+ resume: bool = False, retry: list[str] | None = None,
76
+ human: dict[str, Json] | None = None, on_event: Callback | None = None) -> dict[str, Json]:
77
+ """Run scripts, model calls, and bounded agents with the shared Method runtime."""
78
+ options = {"inputs": inputs, "state": state, "runDir": str(Path(directory).resolve()) if directory else None,
79
+ "resume": resume, "retry": retry or [], "human": human}
80
+ return _call({"op": "run_method", "file": str(Path(file).resolve()), "config": config,
81
+ "options": {key: value for key, value in options.items() if value is not None}}, on_event=on_event)
@@ -0,0 +1,414 @@
1
+ """Production runs from Python: run a published Method from an app, answer its questions, and verify webhooks.
2
+
3
+ `method.run(...)` starts a worker by itself on first use: the bundled CLI (`method worker`) in a child process. Each
4
+ finished run arrives from it as one JSON line, so a device run's result reaches this process only.
5
+
6
+ The credential: `api_key`, else METHOD_API_KEY (the environment, then .env in the current folder), else the CLI sign-in
7
+ of this computer (`method login`). A device run's inputs, questions, and answers are sealed with a key derived from
8
+ that credential (HKDF-SHA256, AES-256-GCM), so the server stores only ciphertext. The CLI seals them for this library.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import os
16
+ import re
17
+ import signal
18
+ import subprocess
19
+ import threading
20
+ import time
21
+ import urllib.error
22
+ import urllib.request
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Iterable
25
+
26
+ from .client import WorkflowError, runtime_command
27
+
28
+ Json = Any
29
+ DEFAULT_SERVER = "https://app.withmethod.ai"
30
+ TERMINAL = {"succeeded", "failed", "cancelled"}
31
+ CREDENTIAL = re.compile(r"^(mk_live_|method_)[A-Za-z0-9_-]{43}$")
32
+
33
+
34
+
35
+ def _user_agent() -> str:
36
+ try:
37
+ from importlib.metadata import version
38
+ return f"withmethod-python/{version('method-ai')}"
39
+ except Exception:
40
+ return "withmethod-python"
41
+
42
+
43
+ USER_AGENT = _user_agent()
44
+
45
+ class MethodApiError(WorkflowError):
46
+ def __init__(self, status: int, code: str, message: str):
47
+ super().__init__(message)
48
+ self.status = status
49
+ self.code = code
50
+
51
+
52
+ def verify_webhook(body: bytes | str, signature: str | None, secret: str, tolerance_seconds: int = 300,
53
+ now: float | None = None) -> bool:
54
+ """Method-Signature is t=TIMESTAMP,v1=HEX(HMAC-SHA256(secret, f"{t}.{body}")). Pass the raw request body."""
55
+ if not signature:
56
+ return False
57
+ parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part)
58
+ timestamp, sent = parts.get("t", ""), parts.get("v1", "")
59
+ if not timestamp.isdigit() or len(sent) != 64:
60
+ return False
61
+ if abs((now if now is not None else time.time()) - int(timestamp)) > tolerance_seconds:
62
+ return False
63
+ text = body if isinstance(body, bytes) else body.encode("utf-8")
64
+ expected = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + text, hashlib.sha256).hexdigest()
65
+ return hmac.compare_digest(expected, sent)
66
+
67
+
68
+ def dotenv_values(folder: str | Path | None = None) -> dict[str, str]:
69
+ """METHOD_API_KEY and METHOD_SERVER from .env in a folder. Other lines are not read."""
70
+ path = Path(folder or os.getcwd()) / ".env"
71
+ found: dict[str, str] = {}
72
+ if not path.is_file():
73
+ return found
74
+ for line in path.read_text(encoding="utf-8").splitlines():
75
+ match = re.match(r"^\s*(?:export\s+)?(METHOD_API_KEY|METHOD_SERVER)\s*=\s*(.*?)\s*$", line)
76
+ if match:
77
+ value = match.group(2)
78
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
79
+ value = value[1:-1]
80
+ found[match.group(1)] = value
81
+ return found
82
+
83
+
84
+ def _signed_in_token(server: str) -> str | None:
85
+ """The CLI sign-in of this computer for a server (the same file `method login` writes)."""
86
+ root = Path(os.environ.get("METHOD_CONFIG_DIR") or Path.home() / ".config" / "method")
87
+ path = root / (hashlib.sha256(server.encode("utf-8")).hexdigest()[:24] + ".json")
88
+ try:
89
+ saved = json.loads(path.read_text(encoding="utf-8"))
90
+ except (OSError, ValueError):
91
+ return None
92
+ token = saved.get("token")
93
+ return token if saved.get("server") == server and isinstance(token, str) and CREDENTIAL.match(token) else None
94
+
95
+
96
+ def resolve_credential(api_key: str | None = None, server: str | None = None) -> tuple[str, str]:
97
+ dotenv = dotenv_values()
98
+ origin = (server or os.environ.get("METHOD_SERVER") or dotenv.get("METHOD_SERVER") or DEFAULT_SERVER).rstrip("/")
99
+ key = api_key or os.environ.get("METHOD_API_KEY") or dotenv.get("METHOD_API_KEY") or _signed_in_token(origin)
100
+ if not key:
101
+ raise WorkflowError("Set METHOD_API_KEY (run method connect APP_FOLDER to write it to the app's .env), or sign in on this computer with method login.")
102
+ if not CREDENTIAL.match(key):
103
+ raise WorkflowError("Use a Method service key (mk_live_...). Run method connect APP_FOLDER to make one, or method login on this computer.")
104
+ return key, origin
105
+
106
+
107
+ class Question:
108
+ """A question of an ask step. Return the answer from on_ask, or call answer() later."""
109
+
110
+ def __init__(self, method: "Method", run_id: str, step: str, question: str, form: dict[str, Json]):
111
+ self._method = method
112
+ self.run_id, self.step, self.question, self.form = run_id, step, question, form
113
+
114
+ def answer(self, value: dict[str, Json]) -> dict[str, Json]:
115
+ return self._method.runs.answer(self.run_id, value)
116
+
117
+ def __getitem__(self, name: str) -> Json:
118
+ return {"run_id": self.run_id, "step": self.step, "question": self.question, "form": self.form}[name]
119
+
120
+ def __repr__(self) -> str:
121
+ return f"Question(run_id={self.run_id!r}, step={self.step!r}, question={self.question!r})"
122
+
123
+
124
+ AskHandler = Callable[[Question], "dict[str, Json] | None"]
125
+
126
+
127
+ class _Webhooks:
128
+ verify = staticmethod(verify_webhook)
129
+
130
+
131
+ def _check_answer(form: dict[str, Json], value: Json) -> None:
132
+ """The fields that the form requires, with their JSON types. The server checks plain answers fully."""
133
+ if not isinstance(value, dict):
134
+ raise MethodApiError(422, "invalid_answer", "The answer must be an object.")
135
+ kinds = {"string": str, "number": (int, float), "boolean": bool, "array": list, "object": dict}
136
+ for name in form.get("required", []):
137
+ if name not in value:
138
+ raise MethodApiError(422, "invalid_answer", f"answer.{name} is required.")
139
+ for name, item in value.items():
140
+ schema = (form.get("properties") or {}).get(name)
141
+ if schema is None:
142
+ if form.get("additionalProperties") is False:
143
+ raise MethodApiError(422, "invalid_answer", f"answer.{name} is not a field of this answer.")
144
+ continue
145
+ kind = kinds.get(schema.get("type"))
146
+ if kind and (not isinstance(item, kind) or (schema.get("type") == "number" and isinstance(item, bool))):
147
+ raise MethodApiError(422, "invalid_answer", f"answer.{name} must be {schema.get('type')}.")
148
+
149
+
150
+ class _Runs:
151
+ def __init__(self, method: "Method"):
152
+ self._method = method
153
+
154
+ def start(self, method: str, inputs: dict[str, Json] | None = None, *, version: str | None = None,
155
+ idempotency_key: str | None = None, webhook_url: str | None = None,
156
+ on_ask: AskHandler | None = None) -> dict[str, Json]:
157
+ """Queue a run of the published version (or `version`). Returns {run_id, version_id, status}."""
158
+ owner = self._method
159
+ values = inputs or {}
160
+ handler = on_ask or owner._on_ask
161
+
162
+ def send(sealed: bool) -> dict[str, Json]:
163
+ body: dict[str, Json] = {"method": method, "inputs": {} if sealed else values}
164
+ if sealed:
165
+ body["sealed_inputs"] = owner._run_data("seal", "inputs", method, values)
166
+ body["inputs_digest"] = owner._run_data("digest", "inputs", method, values)
167
+ if version:
168
+ body["version"] = version
169
+ if idempotency_key:
170
+ body["idempotency_key"] = idempotency_key
171
+ if webhook_url:
172
+ body["webhook_url"] = webhook_url
173
+ if handler:
174
+ body["answered_by"] = "app"
175
+ return owner._request("POST", "/v1/runs", body)
176
+
177
+ try:
178
+ started = send(bool(values) and owner._keeps_on_device(method))
179
+ except MethodApiError as error:
180
+ if error.code != "seal_inputs":
181
+ raise
182
+ owner._device[method] = (True, time.monotonic())
183
+ started = send(True)
184
+ if handler:
185
+ owner._handlers[started["run_id"]] = handler
186
+ return started
187
+
188
+ def get(self, run_id: str) -> dict[str, Json]:
189
+ return self._method._with_local(self._method._request("GET", f"/v1/runs/{run_id}"))
190
+
191
+ def cancel(self, run_id: str) -> dict[str, Json]:
192
+ return self._method._with_local(self._method._request("POST", f"/v1/runs/{run_id}/cancel", {}))
193
+
194
+ def answer(self, run_id: str, answer: dict[str, Json]) -> dict[str, Json]:
195
+ """Answer the waiting question of a run. The answer must match the question's form."""
196
+ run = self.get(run_id)
197
+ question = run.get("question")
198
+ if run["status"] != "waiting" or not question:
199
+ raise MethodApiError(409, "not_waiting", f"The run is {run['status']}; it has no question to answer.")
200
+ opened = self._method._open_question(run)
201
+ _check_answer(opened["form"], answer)
202
+ body: dict[str, Json] = {"step": question["step"]}
203
+ if question.get("sealed"):
204
+ body["sealed_answer"] = self._method._run_data("seal", "answer", run["method_id"], answer)
205
+ else:
206
+ body["answer"] = answer
207
+ return self._method._request("POST", f"/v1/runs/{run_id}/answer", body)
208
+
209
+ def wait(self, run_id: str, timeout: float = 1800, interval: float = 2, on_ask: AskHandler | None = None) -> dict[str, Json]:
210
+ """Wait until the run ends. A question of a run with an ask handler goes to the handler."""
211
+ deadline = time.monotonic() + timeout
212
+ while True:
213
+ run = self.get(run_id)
214
+ if run["status"] in TERMINAL:
215
+ self._method._handlers.pop(run_id, None)
216
+ return run
217
+ if run["status"] == "waiting" and run.get("question"):
218
+ self._method._ask(run, on_ask)
219
+ if time.monotonic() > deadline:
220
+ raise MethodApiError(408, "wait_timeout", f"The run is still {run['status']}.")
221
+ with self._method._finished:
222
+ self._method._finished.wait_for(lambda: run_id in self._method._local, timeout=min(interval, max(0, deadline - time.monotonic())))
223
+
224
+ def run(self, method: str, inputs: dict[str, Json] | None = None, *, timeout: float = 1800, interval: float = 2,
225
+ **options: Any) -> dict[str, Json]:
226
+ """Start a run and wait for its result. Needs a worker: method.run() starts one in this process."""
227
+ return self.wait(self.start(method, inputs, **options)["run_id"], timeout=timeout, interval=interval)
228
+
229
+
230
+ class Worker:
231
+ """`method worker` in a child process. stop() lets the current runs finish; stop(cancel=True) stops them too."""
232
+
233
+ def __init__(self, method: "Method", methods: Iterable[str] | None, concurrency: int,
234
+ on_result: Callable[[dict[str, Json]], Any] | None, grow: bool = False):
235
+ command = runtime_command("cli") + ["worker", "--server", method.server, "--concurrency", str(concurrency)]
236
+ for item in methods or []:
237
+ command += ["--method", item]
238
+ if grow:
239
+ command.append("--methods-from-stdin")
240
+ env = dict(os.environ)
241
+ # A service key goes to the child. The CLI sign-in reaches it through its saved file.
242
+ if method.api_key.startswith("mk_live_"):
243
+ env["METHOD_API_KEY"] = method.api_key
244
+ self._process = subprocess.Popen(command, stdout=subprocess.PIPE, stdin=subprocess.PIPE if grow else subprocess.DEVNULL,
245
+ stderr=None, text=True, encoding="utf-8", env=env, start_new_session=True)
246
+ self._method, self._on_result = method, on_result
247
+ self._thread = threading.Thread(target=self._read, name="method-worker", daemon=True)
248
+ self._thread.start()
249
+
250
+ def add_method(self, method_id: str) -> None:
251
+ """Claim runs of one more Method (a worker that method.run() started)."""
252
+ if self._process.stdin and self.running:
253
+ self._process.stdin.write(method_id + "\n")
254
+ self._process.stdin.flush()
255
+
256
+ def _read(self) -> None:
257
+ assert self._process.stdout is not None
258
+ for line in self._process.stdout:
259
+ try:
260
+ result = json.loads(line)
261
+ except json.JSONDecodeError:
262
+ continue
263
+ if not isinstance(result, dict) or "run_id" not in result:
264
+ continue
265
+ if result.get("status") == "waiting":
266
+ with self._method._finished:
267
+ self._method._finished.notify_all()
268
+ continue
269
+ with self._method._finished:
270
+ self._method._local[result["run_id"]] = result
271
+ self._method._finished.notify_all()
272
+ if self._on_result:
273
+ try:
274
+ self._on_result(result)
275
+ except Exception as error: # A callback error must not stop the worker.
276
+ print(f"Method worker on_result failed: {error}", flush=True)
277
+
278
+ @property
279
+ def running(self) -> bool:
280
+ return self._process.poll() is None
281
+
282
+ def stop(self, cancel: bool = False, timeout: float | None = None) -> int:
283
+ if self.running:
284
+ self._process.send_signal(signal.SIGINT)
285
+ if cancel:
286
+ time.sleep(0.2)
287
+ self._process.send_signal(signal.SIGINT)
288
+ code = self._process.wait(timeout=timeout)
289
+ self._thread.join(timeout=5)
290
+ return code
291
+
292
+
293
+ class _WorkerFactory:
294
+ def __init__(self, method: "Method"):
295
+ self._method = method
296
+
297
+ def start(self, methods: Iterable[str] | None = None, concurrency: int = 1,
298
+ on_result: Callable[[dict[str, Json]], Any] | None = None) -> Worker:
299
+ """A separate worker in a child process. method.run() starts one by itself; use this for more control."""
300
+ return Worker(self._method, methods, concurrency, on_result)
301
+
302
+
303
+ class Method:
304
+ """The production runs client.
305
+
306
+ `api_key` defaults to METHOD_API_KEY (the environment, then .env), then the CLI sign-in; `server` to METHOD_SERVER.
307
+ `on_ask` answers the questions of ask steps in runs that this process starts. `worker=False`: method.run() does not
308
+ start a worker in this process.
309
+ """
310
+ webhooks = _Webhooks()
311
+
312
+ def __init__(self, api_key: str | None = None, server: str | None = None, *, on_ask: AskHandler | None = None,
313
+ worker: bool = True):
314
+ self.api_key, self.server = resolve_credential(api_key, server)
315
+ self._on_ask, self._lazy = on_ask, worker
316
+ self._local: dict[str, dict[str, Json]] = {}
317
+ self._finished = threading.Condition()
318
+ self._handlers: dict[str, AskHandler] = {}
319
+ self._asked: set[str] = set()
320
+ self._device: dict[str, tuple[bool, float]] = {}
321
+ self._worker: Worker | None = None
322
+ self._worker_methods: set[str] = set()
323
+ self._lock = threading.Lock()
324
+ self.runs = _Runs(self)
325
+ self.worker = _WorkerFactory(self)
326
+
327
+ def run(self, method: str, inputs: dict[str, Json] | None = None, *, on_ask: AskHandler | None = None,
328
+ idempotency_key: str | None = None, webhook_url: str | None = None, version: str | None = None,
329
+ timeout: float = 1800, interval: float = 2) -> dict[str, Json]:
330
+ """Run a published Method and wait for its result. The first call starts a worker in a child process."""
331
+ self._ensure_worker(method)
332
+ return self.runs.run(method, inputs, timeout=timeout, interval=interval, on_ask=on_ask,
333
+ idempotency_key=idempotency_key, webhook_url=webhook_url, version=version)
334
+
335
+ def close(self, cancel: bool = False) -> None:
336
+ """Stop the worker that method.run() started. Runs in progress finish first."""
337
+ with self._lock:
338
+ worker, self._worker = self._worker, None
339
+ if worker:
340
+ worker.stop(cancel=cancel, timeout=60)
341
+
342
+ def _ensure_worker(self, method: str) -> None:
343
+ if not self._lazy:
344
+ return
345
+ with self._lock:
346
+ if self._worker and self._worker.running:
347
+ if method not in self._worker_methods:
348
+ self._worker_methods.add(method)
349
+ self._worker.add_method(method)
350
+ return
351
+ self._worker_methods.add(method)
352
+ self._worker = Worker(self, sorted(self._worker_methods), 1, None, grow=True)
353
+
354
+ def _keeps_on_device(self, method: str) -> bool:
355
+ known = self._device.get(method)
356
+ if known and time.monotonic() - known[1] < 300:
357
+ return known[0]
358
+ try:
359
+ found = self._request("GET", f"/v1/methods/{method}")
360
+ except MethodApiError:
361
+ return False # The server refuses plain inputs of a device run (seal_inputs); start seals them then.
362
+ device = found.get("run_data") == "device"
363
+ self._device[method] = (device, time.monotonic())
364
+ return device
365
+
366
+ def _run_data(self, action: str, kind: str, method_id: str, value: Json) -> Json:
367
+ """Seal, open, or digest run content with the bundled CLI (AES-256-GCM is not in the Python standard library)."""
368
+ result = subprocess.run(runtime_command("cli") + ["__run-data", action, kind, method_id],
369
+ input=value if action == "open" else json.dumps(value), capture_output=True, text=True,
370
+ encoding="utf-8", env={**os.environ, "METHOD_API_KEY": self.api_key}, timeout=60)
371
+ if result.returncode != 0:
372
+ message = (result.stderr or "").strip().splitlines()[-1:] or ["The run content could not be sealed."]
373
+ raise MethodApiError(0, "wrong_key" if "another key" in message[0] else "run_data_failed", message[0])
374
+ return json.loads(result.stdout)
375
+
376
+ def _open_question(self, run: dict[str, Json]) -> dict[str, Json]:
377
+ question = run["question"]
378
+ if question.get("sealed"):
379
+ return self._run_data("open", "question", run["method_id"], question["sealed"])
380
+ return {"question": question.get("question", ""), "form": question.get("form") or {}}
381
+
382
+ def _ask(self, run: dict[str, Json], override: AskHandler | None) -> None:
383
+ handler = override or self._handlers.get(run["run_id"])
384
+ question = run["question"]
385
+ asked = f"{question['id']}:{question['asked_at']}"
386
+ if not handler or asked in self._asked:
387
+ return
388
+ self._asked.add(asked)
389
+ opened = self._open_question(run)
390
+ value = handler(Question(self, run["run_id"], question["step"], opened["question"], opened["form"]))
391
+ if isinstance(value, dict):
392
+ self.runs.answer(run["run_id"], value)
393
+
394
+ def _request(self, method: str, path: str, body: Json = None) -> dict[str, Json]:
395
+ data = None if body is None else json.dumps(body).encode("utf-8")
396
+ # Cloudflare refuses the default Python-urllib user agent, so the library names itself.
397
+ request = urllib.request.Request(self.server + path, data=data, method=method, headers={
398
+ "user-agent": USER_AGENT, "authorization": f"Bearer {self.api_key}", **({"content-type": "application/json"} if data is not None else {})})
399
+ try:
400
+ with urllib.request.urlopen(request, timeout=30) as response:
401
+ return json.loads(response.read() or b"null")
402
+ except urllib.error.HTTPError as error:
403
+ try:
404
+ parsed = json.loads(error.read())
405
+ except ValueError:
406
+ parsed = {}
407
+ raise MethodApiError(error.code, str(parsed.get("code", "request_failed")),
408
+ f"{error.code}: {parsed.get('message', 'Method request failed')}") from None
409
+
410
+ def _with_local(self, run: dict[str, Json]) -> dict[str, Json]:
411
+ local = self._local.get(run.get("run_id", ""))
412
+ if local and "result" in local and "result" not in run:
413
+ return {**run, "result": local["result"]}
414
+ return run