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.
- method_ai-0.14.3.dist-info/METADATA +36 -0
- method_ai-0.14.3.dist-info/RECORD +11 -0
- method_ai-0.14.3.dist-info/WHEEL +5 -0
- method_ai-0.14.3.dist-info/entry_points.txt +2 -0
- method_ai-0.14.3.dist-info/licenses/LICENSE +21 -0
- method_ai-0.14.3.dist-info/top_level.txt +1 -0
- withmethod/__init__.py +8 -0
- withmethod/__main__.py +2 -0
- withmethod/cli.py +12 -0
- withmethod/client.py +81 -0
- withmethod/production.py +414 -0
|
@@ -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,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
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)
|
withmethod/production.py
ADDED
|
@@ -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
|