diff --git a/Makefile b/Makefile index c1de38b6..6129455b 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,13 @@ clean: ## Removes all generated code (except _patch.py files) @printf "=== Cleaning src directory\n" @rm -rf src/pydo/resources @rm -rf src/pydo/types - @find src/pydo -type f ! -name "_patch.py" ! -name "custom_*.py" ! -name "exceptions.py" -exec rm -rf {} + + @find src/pydo -type f \ + ! -name "_patch.py" ! -name "custom_*.py" ! -name "exceptions.py" \ + ! -path "*/agents/__init__.py" ! -path "*/aio/agents/__init__.py" \ + ! -path "*/agents/session.py" ! -path "*/aio/agents/session.py" \ + ! -path "*/gateway/*" \ + ! -path "*/action_gateway/*" \ + -exec rm -rf {} + .PHONY: download-spec download-spec: ## Download Latest DO Spec diff --git a/examples/agents/async_stream_session.py b/examples/agents/async_stream_session.py new file mode 100644 index 00000000..37b4684e --- /dev/null +++ b/examples/agents/async_stream_session.py @@ -0,0 +1,24 @@ +"""Async stream session events. Set DIGITALOCEAN_TOKEN and SESSION_ID.""" + +import asyncio +import os + +from pydo.aio import Client + +SESSION_ID = os.environ["SESSION_ID"] + + +async def main() -> None: + async with Client(token=os.environ["DIGITALOCEAN_TOKEN"]) as client: + stream = await client.agents.sessions.stream(SESSION_ID) + async with stream as events: + async for event in events: + if getattr(event, "type", None) == "run.token_delta" and event.get("data"): + print(event.data.text, end="", flush=True) + elif "token_chunk" in event: + print(event.token_chunk.text, end="", flush=True) + else: + print(event) + + +asyncio.run(main()) diff --git a/examples/agents/attach.py b/examples/agents/attach.py new file mode 100644 index 00000000..af74da85 --- /dev/null +++ b/examples/agents/attach.py @@ -0,0 +1,53 @@ +"""Attach to an EXISTING agent session and stream one turn. + +Like ``doctl agents attach``: connect to a session that already exists, send a +prompt, and consume the SSE event feed through the high-level API — no thread, +no raw event-string matching. attach() never destroys the session, so it stays +alive for further turns. + +Get a SESSION_ID first by creating one (the session stays up): + AGENT_SPEC=... python examples/agents/create_session.py +then copy the "session_id" it prints. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + SESSION_ID the session to attach to + +Optional env: + PROMPT message to send (default below) +""" + +import os +import sys + +from pydo import Client +from pydo.agents import AgentEventType + +client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), +) + +agent = client.agents.attach(os.environ["SESSION_ID"]) +prompt = os.environ.get("PROMPT", "In one short sentence, what is DigitalOcean?") + +print(f"[attached {agent.session_id}]\n>>> {prompt}\n", file=sys.stderr) + +stream = agent.run_streamed(prompt) # opens the stream, sends input, yields events +for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) # live reply -> stdout + elif event.type == AgentEventType.TOOL_CALL: + print(f"\n[tool] {event.tool_name}", file=sys.stderr) + elif event.type == AgentEventType.HITL_REQUESTED: + print(f"\n[hitl auto-approved] {event.request_id}", file=sys.stderr) + +result = stream.result +print( + f"\n\n[{result.status}] captured {len(result.final_output)} chars " + f"(tokens out={result.usage.get('tokens_out')})", + file=sys.stderr, +) +# attach() leaves the session running; destroy it when done: +# SESSION_ID=... python examples/agents/destroy_session.py diff --git a/examples/agents/attach_by_name.py b/examples/agents/attach_by_name.py new file mode 100644 index 00000000..b68b18e9 --- /dev/null +++ b/examples/agents/attach_by_name.py @@ -0,0 +1,52 @@ +"""Look up a hosted-agent session by name (instead of by id). + +Sessions can be filtered server-side by name (``GET /v2/agents/sessions?name=``). +This script lists the matches and resolves the name to a session handle via +``client.agents.attach_by_name(...)``. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + SESSION_NAME the session name to look up +""" + +import os +import sys + +from pydo import Client + + +def main() -> int: + name = os.environ["SESSION_NAME"] + + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + resp = client.agents.sessions.list(name=name) + sessions = resp.get("sessions", []) if hasattr(resp, "get") else [] + print(f"[list name={name!r}] {len(sessions)} match(es):", file=sys.stderr) + for s in sessions: + print(f" {s['session_id']} {s['name']} {s['status']}", file=sys.stderr) + + if not sessions: + # Help diagnose a 0-match result: show what names actually exist now. + all_resp = client.agents.sessions.list() + all_sessions = all_resp.get("sessions", []) if hasattr(all_resp, "get") else [] + print( + f"[attach_by_name] no session named {name!r}. " + f"{len(all_sessions)} session(s) currently exist:", + file=sys.stderr, + ) + for s in all_sessions: + print(f" {s['name']} ({s['status']})", file=sys.stderr) + return 1 + + agent = client.agents.attach_by_name(name) + print(f"[attach_by_name] resolved session_id: {agent.session_id}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/create_session.py b/examples/agents/create_session.py new file mode 100644 index 00000000..e41e339d --- /dev/null +++ b/examples/agents/create_session.py @@ -0,0 +1,26 @@ +"""Create a session from an agent manifest (``agents.yaml``). + +A session is created entirely from the agent spec — the runtime adapter, +sandbox template, env vars, etc. are all defined in the manifest. The client +uploads it verbatim (``Content-Type: application/x-yaml``); the server parses +and validates it. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT (stage2: https://api.s2r1.internal.digitalocean.com) + AGENT_SPEC path to the agent spec YAML (default: agent-spec.yaml) +""" + +import json +import os + +from pydo import Client + +spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") +with open(spec_path, "r", encoding="utf-8") as fh: + manifest = fh.read() + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +resp = client.agents.sessions.create_from_manifest(manifest) + +print(json.dumps(resp, indent=2, default=str)) diff --git a/examples/agents/destroy_session.py b/examples/agents/destroy_session.py new file mode 100644 index 00000000..ca6b2379 --- /dev/null +++ b/examples/agents/destroy_session.py @@ -0,0 +1,12 @@ +"""Destroy a session. Set DIGITALOCEAN_TOKEN and SESSION_ID.""" + +import os + +from pydo import Client + +SESSION_ID = os.environ["SESSION_ID"] + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +client.agents.sessions.destroy(SESSION_ID) + +print("ok", SESSION_ID) diff --git a/examples/agents/generate_transfer_fixtures.py b/examples/agents/generate_transfer_fixtures.py new file mode 100644 index 00000000..cde4133d --- /dev/null +++ b/examples/agents/generate_transfer_fixtures.py @@ -0,0 +1,127 @@ +"""Generate local fixture files for workspace transfer testing. + +Writes into examples/agents/testdata/ (gitignored large binaries). + +Usage: + python examples/agents/generate_transfer_fixtures.py + python examples/agents/generate_transfer_fixtures.py --sizes 1KiB,1MiB,40MiB +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import tarfile +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +OUT_DIR = HERE / "testdata" + +# Default sizes: tiny (single part), medium, and large enough to force +# multiple ~16 MiB parts on typical CreateTransfer responses. +DEFAULT_SIZES = ("1KiB", "1MiB", "40MiB") + +_UNITS = { + "b": 1, + "kb": 1000, + "kib": 1024, + "mb": 1000 * 1000, + "mib": 1024 * 1024, + "gb": 1000 * 1000 * 1000, + "gib": 1024 * 1024 * 1024, +} + + +def _parse_size(text: str) -> int: + raw = text.strip().lower().replace(" ", "") + for suffix, mult in sorted(_UNITS.items(), key=lambda kv: -len(kv[0])): + if raw.endswith(suffix): + return int(float(raw[: -len(suffix)]) * mult) + return int(raw) + + +def _write_patterned(path: Path, size: int) -> str: + """Write *size* bytes of a repeating pattern; return lowercase sha256 hex.""" + path.parent.mkdir(parents=True, exist_ok=True) + hasher = hashlib.sha256() + # 256-byte pattern so multi-part boundaries are easy to eyeball in hexdumps. + pattern = bytes((i % 256) for i in range(256)) + remaining = size + with path.open("wb") as fh: + while remaining > 0: + chunk = pattern[: min(len(pattern), remaining)] + fh.write(chunk) + hasher.update(chunk) + remaining -= len(chunk) + return hasher.hexdigest() + + +def _write_tar(path: Path) -> str: + """Create a small tar with two text members; return sha256 of the tar bytes.""" + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "hello.txt").write_text("hello from tarball\n", encoding="utf-8") + nested = root / "nested" + nested.mkdir() + (nested / "world.txt").write_text("world\n", encoding="utf-8") + with tarfile.open(path, "w") as tar: + tar.add(root / "hello.txt", arcname="hello.txt") + tar.add(nested / "world.txt", arcname="nested/world.txt") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return digest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--sizes", + default=",".join(DEFAULT_SIZES), + help="Comma-separated sizes (e.g. 1KiB,1MiB,40MiB). Default: %(default)s", + ) + parser.add_argument( + "--out", + type=Path, + default=OUT_DIR, + help=f"Output directory (default: {OUT_DIR})", + ) + args = parser.parse_args() + out: Path = args.out + out.mkdir(parents=True, exist_ok=True) + + manifest_lines = ["# generated by generate_transfer_fixtures.py", ""] + + for label in [s.strip() for s in args.sizes.split(",") if s.strip()]: + size = _parse_size(label) + safe = label.replace(" ", "") + path = out / f"blob_{safe}.bin" + digest = _write_patterned(path, size) + print(f"wrote {path} size={size} sha256={digest}") + manifest_lines.append(f"{path.name}\t{size}\t{digest}") + + tar_path = out / "sample.tar" + tar_digest = _write_tar(tar_path) + print(f"wrote {tar_path} size={tar_path.stat().st_size} sha256={tar_digest}") + manifest_lines.append( + f"{tar_path.name}\t{tar_path.stat().st_size}\t{tar_digest}\tis_archive=true" + ) + + # Tiny committed-friendly text fixture (always regenerated). + text_path = out / "hello.txt" + text_path.write_text("hello from pydo staged transfer\n", encoding="utf-8") + text_digest = hashlib.sha256(text_path.read_bytes()).hexdigest() + print(f"wrote {text_path} size={text_path.stat().st_size} sha256={text_digest}") + manifest_lines.append( + f"{text_path.name}\t{text_path.stat().st_size}\t{text_digest}" + ) + + (out / "MANIFEST.tsv").write_text("\n".join(manifest_lines) + "\n", encoding="utf-8") + print(f"\nmanifest: {out / 'MANIFEST.tsv'}") + print(f"fixtures dir: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/get_session.py b/examples/agents/get_session.py new file mode 100644 index 00000000..bd135240 --- /dev/null +++ b/examples/agents/get_session.py @@ -0,0 +1,13 @@ +"""Get a session. Set DIGITALOCEAN_TOKEN and SESSION_ID.""" + +import json +import os + +from pydo import Client + +SESSION_ID = os.environ["SESSION_ID"] + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +resp = client.agents.sessions.get(SESSION_ID) + +print(json.dumps(resp, indent=2, default=str)) diff --git a/examples/agents/list_sessions.py b/examples/agents/list_sessions.py new file mode 100644 index 00000000..1ec10a6a --- /dev/null +++ b/examples/agents/list_sessions.py @@ -0,0 +1,11 @@ +"""List sessions. Set DIGITALOCEAN_TOKEN (and PYDO_AGENTS_ENDPOINT for stage2).""" + +import os + +from pydo import Client + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) + +resp = client.agents.sessions.list(page_size=50) +for session in resp.get("sessions", []): + print(session.session_id, session.status, session.agent_kind) diff --git a/examples/agents/page_history.py b/examples/agents/page_history.py new file mode 100644 index 00000000..b5f63a4f --- /dev/null +++ b/examples/agents/page_history.py @@ -0,0 +1,60 @@ +"""Page backwards through a session's older event history. + +A plain attach replays only the newest events the server keeps within its +replay budget, so long-lived sessions have history that never arrives on the +live feed. This walks further back a page at a time using ``before``, the same +way a UI would build scrollback. + +Required env: + DIGITALOCEAN_TOKEN + SESSION_ID + PYDO_AGENTS_ENDPOINT (stage2: https://api.s2r1.internal.digitalocean.com) + +Optional env: + PAGE_SIZE events per page (server default is 200) + MAX_PAGES stop after this many pages (default 5) +""" + +import os +import sys + +from pydo import Client + +SESSION_ID = os.environ["SESSION_ID"] +PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "50")) +MAX_PAGES = int(os.environ.get("MAX_PAGES", "5")) + +client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), +) +sessions = client.agents.sessions + +# Start from the oldest event of a bounded replay: that is the cursor for the +# page before it. +with sessions.stream(SESSION_ID, replay_only=True) as replay: + recent = list(replay) + +if not recent: + print("session has no events to page back from", file=sys.stderr) + raise SystemExit(0) + +cursor = replay.oldest_event_id +print(f"replay returned {len(recent)} events, oldest={cursor}", file=sys.stderr) + +older = [] +for page_number in range(1, MAX_PAGES + 1): + page = sessions.history_page(SESSION_ID, before=cursor, limit=PAGE_SIZE) + older = page.events + older + print( + f"page {page_number}: {len(page.events)} events " + f"(has_more={page.has_more}, next before={page.next_before})", + file=sys.stderr, + ) + if not page.has_more or not page.next_before: + break + cursor = page.next_before + +print(f"\nfetched {len(older)} older events, oldest first:", file=sys.stderr) +for event in older: + print(f" {event.get('event_id')} {event.get('type')}") diff --git a/examples/agents/pause_resume.py b/examples/agents/pause_resume.py new file mode 100644 index 00000000..4453dc92 --- /dev/null +++ b/examples/agents/pause_resume.py @@ -0,0 +1,76 @@ +"""Pause and resume a hosted-agent session. + +Attaches to an existing session (by id or by name), pauses it, waits for it to +report ``SESSION_STATUS_PAUSED``, then resumes it and waits for ``READY``. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + +Pick a session with one of (SESSION_ID takes precedence): + SESSION_ID the session id to pause/resume + SESSION_NAME resolve the session by name instead +""" + +import os +import sys +import time + +from pydo import Client +from pydo.agents.custom_models import SessionStatus + + +def _wait_for_status(agent, target, *, timeout=120.0, poll_interval=2.0): + """Poll until the session reaches ``target`` (or a terminal/failed state).""" + deadline = time.monotonic() + timeout + while True: + agent.refresh() + status = agent.status + print(f" status: {status}", file=sys.stderr) + if status == target: + return + if status in (SessionStatus.FAILED, SessionStatus.DESTROYED): + raise RuntimeError(f"session {agent.session_id} is {status}") + if time.monotonic() > deadline: + raise TimeoutError( + f"session {agent.session_id} did not reach {target} in {timeout}s " + f"(last status: {status})" + ) + time.sleep(poll_interval) + + +def main() -> int: + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + session_id = os.environ.get("SESSION_ID") + session_name = os.environ.get("SESSION_NAME") + if session_id: + agent = client.agents.attach(session_id) + elif session_name: + agent = client.agents.attach_by_name(session_name) + else: + print("set SESSION_ID or SESSION_NAME", file=sys.stderr) + return 2 + + print(f"[attach] session_id={agent.session_id}", file=sys.stderr) + agent.refresh() + print(f"[attach] current status: {agent.status}", file=sys.stderr) + + print("[pause] pausing session...", file=sys.stderr) + agent.pause() + _wait_for_status(agent, SessionStatus.PAUSED) + print("[pause] session is PAUSED", file=sys.stderr) + + print("[resume] resuming session...", file=sys.stderr) + agent.resume() + _wait_for_status(agent, SessionStatus.READY) + print("[resume] session is READY", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/policy_auto_allow.py b/examples/agents/policy_auto_allow.py new file mode 100644 index 00000000..3d9260b5 --- /dev/null +++ b/examples/agents/policy_auto_allow.py @@ -0,0 +1,99 @@ +"""Policy engine test: auto-allow rule. + +Creates a session with ``defaultAction: ask`` and a single ``touch * → allow`` +rule, then asks the agent to run ``touch /workspace/allow_test.txt``. + +Expected: the Bash tool call matches the allow rule and executes WITHOUT any +HITL prompt being raised. The test FAILS if a ``hitl_requested`` event fires. + +Required env: + DIGITALOCEAN_TOKEN + AGENT_SPEC path to the base agents.yaml (default: agent-spec.yaml) + +Optional env: + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com +""" + +import os +import sys + +import yaml + +from pydo import Client +from pydo.agents import AgentEventType + + +_PERMISSIONS = { + "defaultAction": "ask", + "rules": [ + {"tool": "bash", "match": {"command": "touch *"}, "action": "allow"}, + {"tool": "bash", "match": {"command": "git status"}, "action": "allow"}, + ], +} + +_PROMPT = ( + "Use bash to run exactly this command and nothing else: " + "touch /workspace/allow_test.txt" +) + + +def _load_manifest(name: str, permissions: dict) -> str: + spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") + with open(spec_path, "r", encoding="utf-8") as fh: + spec = yaml.safe_load(fh) + spec["metadata"]["name"] = name + spec["spec"]["permissions"] = permissions + return yaml.dump(spec, default_flow_style=False) + + +def main() -> int: + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + manifest = _load_manifest("policy-auto-allow-test", _PERMISSIONS) + hitl_events = [] + + def _track_and_approve(event): + hitl_events.append(event) + return "approve" # resolve so the run doesn't hang if HITL fires unexpectedly + + with client.agents.start(manifest) as agent: + print(f"[session {agent.session_id}]", file=sys.stderr) + print(f"[policy] defaultAction=ask touch * → allow", file=sys.stderr) + print(f"[prompt] {_PROMPT}\n", file=sys.stderr) + + stream = agent.run_streamed(_PROMPT, hitl=_track_and_approve, timeout=180) + for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) + elif event.type == AgentEventType.TOOL_CALL: + print(f"\n[tool_call] {event.tool_name}", file=sys.stderr) + elif event.type == AgentEventType.HITL_REQUESTED: + print(f"\n[hitl_fired] request_id={event.request_id}", file=sys.stderr) + + result = stream.result + print(f"\n[{result.status}]", file=sys.stderr) + + if hitl_events: + print( + f"\nFAIL HITL fired {len(hitl_events)} time(s) — " + "touch should have been auto-allowed without prompting", + file=sys.stderr, + ) + return 1 + + if result.status != "completed": + print( + f"\nFAIL run ended with status={result.status!r}", + file=sys.stderr, + ) + return 1 + + print("\nPASS touch ran without HITL prompt (auto-allow confirmed)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/policy_auto_deny.py b/examples/agents/policy_auto_deny.py new file mode 100644 index 00000000..3ac4c5d6 --- /dev/null +++ b/examples/agents/policy_auto_deny.py @@ -0,0 +1,100 @@ +"""Policy engine test: auto-deny rule. + +Creates a session with ``ls * → deny`` and ``rm -rf * → deny`` rules and asks +the agent to run ``ls /workspace``. + +Expected: the deny rule blocks the Bash call automatically — the agent receives +a "forbidden" response and continues WITHOUT raising a HITL prompt. The test +FAILS if a ``hitl_requested`` event fires. + +Required env: + DIGITALOCEAN_TOKEN + AGENT_SPEC path to the base agents.yaml (default: agent-spec.yaml) + +Optional env: + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com +""" + +import os +import sys + +import yaml + +from pydo import Client +from pydo.agents import AgentEventType + + +_PERMISSIONS = { + "defaultAction": "ask", + "rules": [ + {"tool": "bash", "match": {"command": "ls *"}, "action": "deny"}, + {"tool": "bash", "match": {"command": "rm -rf *"}, "action": "deny"}, + {"tool": "bash", "match": {"command": "touch *"}, "action": "allow"}, + ], +} + +_PROMPT = ( + "Use bash to run exactly this command and nothing else: ls /workspace" +) + + +def _load_manifest(name: str, permissions: dict) -> str: + spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") + with open(spec_path, "r", encoding="utf-8") as fh: + spec = yaml.safe_load(fh) + spec["metadata"]["name"] = name + spec["spec"]["permissions"] = permissions + return yaml.dump(spec, default_flow_style=False) + + +def main() -> int: + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + manifest = _load_manifest("policy-auto-deny-test", _PERMISSIONS) + hitl_events = [] + + def _track_and_approve(event): + hitl_events.append(event) + return "approve" # resolve to not hang; we assert this never fires + + with client.agents.start(manifest) as agent: + print(f"[session {agent.session_id}]", file=sys.stderr) + print(f"[policy] ls * → deny rm -rf * → deny defaultAction=ask", file=sys.stderr) + print(f"[prompt] {_PROMPT}\n", file=sys.stderr) + + stream = agent.run_streamed(_PROMPT, hitl=_track_and_approve, timeout=180) + for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) + elif event.type == AgentEventType.TOOL_CALL: + print(f"\n[tool_call] {event.tool_name}", file=sys.stderr) + elif event.type == AgentEventType.HITL_REQUESTED: + print(f"\n[hitl_fired] request_id={event.request_id}", file=sys.stderr) + + result = stream.result + print(f"\n[{result.status}]", file=sys.stderr) + + if hitl_events: + print( + f"\nFAIL HITL fired {len(hitl_events)} time(s) — " + "ls should have been auto-denied without prompting", + file=sys.stderr, + ) + return 1 + + if result.status != "completed": + print( + f"\nFAIL run ended with status={result.status!r}", + file=sys.stderr, + ) + return 1 + + print("\nPASS ls was auto-denied without HITL prompt") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/policy_hitl_ask.py b/examples/agents/policy_hitl_ask.py new file mode 100644 index 00000000..268af67d --- /dev/null +++ b/examples/agents/policy_hitl_ask.py @@ -0,0 +1,129 @@ +"""Policy engine test: defaultAction: ask → HITL. + +Runs two sub-tests against a session with ``defaultAction: ask`` and no rule +matching ``mkdir``: + + Sub-test approve: + Agent tries ``mkdir /workspace/hitl_dir``. + Expected: HITL prompt fires → we approve → run completes. + + Sub-test reject: + Same prompt on a fresh session. + Expected: HITL prompt fires → we reject → agent acknowledges denial, run + completes (agent continues the conversation after the rejection). + +Both sub-tests FAIL if no ``hitl_requested`` event fires, or if the run ends in +a non-completed state. + +Required env: + DIGITALOCEAN_TOKEN + AGENT_SPEC path to the base agents.yaml (default: agent-spec.yaml) + +Optional env: + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com +""" + +import os +import sys + +import yaml + +from pydo import Client +from pydo.agents import AgentEventType + + +_PERMISSIONS = { + "defaultAction": "ask", + "rules": [ + # touch is explicitly allowed so the agent can set up; mkdir is unmatched → ask + {"tool": "bash", "match": {"command": "touch *"}, "action": "allow"}, + {"tool": "bash", "match": {"command": "git status"}, "action": "allow"}, + ], +} + +_PROMPT = ( + "Use bash to run exactly this command and nothing else: " + "mkdir /workspace/hitl_test_dir" +) + + +def _load_manifest(name: str, permissions: dict) -> str: + spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") + with open(spec_path, "r", encoding="utf-8") as fh: + spec = yaml.safe_load(fh) + spec["metadata"]["name"] = name + spec["spec"]["permissions"] = permissions + return yaml.dump(spec, default_flow_style=False) + + +def _run_hitl_subtest(client, manifest_name: str, decision: str, label: str) -> int: + """Run one HITL sub-test, resolving HITL with ``decision`` (approve/reject).""" + manifest = _load_manifest(manifest_name, _PERMISSIONS) + hitl_events = [] + + def _track_and_decide(event): + hitl_events.append(event) + print(f"\n[hitl_{decision}] request_id={event.request_id}", file=sys.stderr) + return decision + + with client.agents.start(manifest) as agent: + print(f"\n[{label}] session={agent.session_id}", file=sys.stderr) + print(f"[{label}] prompt: {_PROMPT}", file=sys.stderr) + + stream = agent.run_streamed(_PROMPT, hitl=_track_and_decide, timeout=180) + for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) + elif event.type == AgentEventType.TOOL_CALL: + print(f"\n[tool_call] {event.tool_name}", file=sys.stderr) + + result = stream.result + print(f"\n[{result.status}]", file=sys.stderr) + + if not hitl_events: + print( + f"\nFAIL [{label}] no HITL event fired — " + "mkdir should have been gated by defaultAction: ask", + file=sys.stderr, + ) + return 1 + + if result.status != "completed": + print( + f"\nFAIL [{label}] run ended with status={result.status!r} " + f"(expected 'completed')", + file=sys.stderr, + ) + return 1 + + print(f"\nPASS [{label}] HITL fired and was {decision}d, run completed") + return 0 + + +def main() -> int: + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + print("[policy] defaultAction=ask touch * → allow mkdir → unmatched", file=sys.stderr) + + rc_a = _run_hitl_subtest( + client, + manifest_name="policy-hitl-approve-test", + decision="approve", + label="approve", + ) + + rc_b = _run_hitl_subtest( + client, + manifest_name="policy-hitl-reject-test", + decision="reject", + label="reject", + ) + + return max(rc_a, rc_b) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/policy_write_interception.py b/examples/agents/policy_write_interception.py new file mode 100644 index 00000000..d85cb5d3 --- /dev/null +++ b/examples/agents/policy_write_interception.py @@ -0,0 +1,104 @@ +"""Policy engine test: non-Bash tool interception (Write/Edit). + +Creates a session with ``defaultAction: ask`` (no explicit bash rules) and asks +the agent to write a file. Codex uses its Write tool (not bash) for this, so +the interception exercises the non-Bash tool path of the policy engine. + +Expected: a ``hitl_requested`` event fires for the Write/Edit tool call. +The test FAILS if no HITL prompt is raised. + +Required env: + DIGITALOCEAN_TOKEN + AGENT_SPEC path to the base agents.yaml (default: agent-spec.yaml) + +Optional env: + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com +""" + +import os +import sys + +import yaml + +from pydo import Client +from pydo.agents import AgentEventType + + +_PERMISSIONS = { + "defaultAction": "ask", + # No rules → every tool call (Bash, Write, Edit, …) requires approval +} + +_PROMPT = ( + "Write the text 'policy engine write interception test' into the file " + "/workspace/write_intercept_test.txt using your file-write capability " + "(not bash). Do not use bash." +) + + +def _load_manifest(name: str, permissions: dict) -> str: + spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") + with open(spec_path, "r", encoding="utf-8") as fh: + spec = yaml.safe_load(fh) + spec["metadata"]["name"] = name + spec["spec"]["permissions"] = permissions + return yaml.dump(spec, default_flow_style=False) + + +def main() -> int: + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + manifest = _load_manifest("policy-write-intercept-test", _PERMISSIONS) + hitl_events = [] + tool_calls = [] + + def _track_and_approve(event): + hitl_events.append(event) + print(f"\n[hitl_fired] request_id={event.request_id}", file=sys.stderr) + return "approve" + + with client.agents.start(manifest) as agent: + print(f"[session {agent.session_id}]", file=sys.stderr) + print(f"[policy] defaultAction=ask (no rules — all tools intercepted)", file=sys.stderr) + print(f"[prompt] {_PROMPT}\n", file=sys.stderr) + + stream = agent.run_streamed(_PROMPT, hitl=_track_and_approve, timeout=180) + for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) + elif event.type == AgentEventType.TOOL_CALL: + tool_calls.append(event.tool_name) + print(f"\n[tool_call] {event.tool_name}", file=sys.stderr) + elif event.type == AgentEventType.HITL_REQUESTED: + pass # already logged in the callback + + result = stream.result + print(f"\n[{result.status}] tool_calls={tool_calls}", file=sys.stderr) + + if not hitl_events: + print( + "\nFAIL no HITL event fired — Write/Edit tool should have been " + "intercepted by defaultAction: ask", + file=sys.stderr, + ) + return 1 + + if result.status != "completed": + print( + f"\nFAIL run ended with status={result.status!r}", + file=sys.stderr, + ) + return 1 + + print( + f"\nPASS Write/Edit tool intercepted by policy engine " + f"({len(hitl_events)} HITL event(s) fired, tool_calls={tool_calls})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/resolve_hitl.py b/examples/agents/resolve_hitl.py new file mode 100644 index 00000000..73b17359 --- /dev/null +++ b/examples/agents/resolve_hitl.py @@ -0,0 +1,19 @@ +"""Resolve HITL. Set DIGITALOCEAN_TOKEN, SESSION_ID, REQUEST_ID, and PYDO_AGENTS_ENDPOINT (stage2).""" + +import os + +from pydo import Client +from pydo.agents import HITLOutcome, ResolutionSource + +SESSION_ID = os.environ["SESSION_ID"] +REQUEST_ID = os.environ["REQUEST_ID"] + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +client.agents.sessions.resolve_hitl( + SESSION_ID, + REQUEST_ID, + outcome=HITLOutcome.APPROVE, + source=ResolutionSource.OUT_OF_BAND, +) + +print("ok", REQUEST_ID) diff --git a/examples/agents/run_blocking.py b/examples/agents/run_blocking.py new file mode 100644 index 00000000..e950a130 --- /dev/null +++ b/examples/agents/run_blocking.py @@ -0,0 +1,31 @@ +"""One-liner blocking run: create from spec -> run -> print -> destroy. + +The high-level API collapses the whole flow into ``agent.run(prompt)``, which +blocks until the run finishes and returns the assembled output. The ``with`` +block auto-destroys the session on exit. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + AGENT_SPEC path to the agent spec YAML + +Optional env: + PROMPT +""" + +import os + +from pydo import Client + +client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), +) + +with open(os.environ.get("AGENT_SPEC", "agent-spec.yaml"), encoding="utf-8") as fh: + manifest = fh.read() + +prompt = os.environ.get("PROMPT", "In one short sentence, what is DigitalOcean?") + +with client.agents.start(manifest) as agent: # auto-destroys on exit + print(agent.run(prompt).final_output) diff --git a/examples/agents/send_input.py b/examples/agents/send_input.py new file mode 100644 index 00000000..60386bc4 --- /dev/null +++ b/examples/agents/send_input.py @@ -0,0 +1,14 @@ +"""Send input to a session. Set DIGITALOCEAN_TOKEN and SESSION_ID.""" + +import json +import os + +from pydo import Client + +SESSION_ID = os.environ["SESSION_ID"] +TEXT = "Summarise the README in two sentences." + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +resp = client.agents.sessions.send_input(SESSION_ID, text=TEXT) + +print(json.dumps(resp, indent=2, default=str)) diff --git a/examples/agents/session_e2e.py b/examples/agents/session_e2e.py new file mode 100644 index 00000000..10457af3 --- /dev/null +++ b/examples/agents/session_e2e.py @@ -0,0 +1,70 @@ +"""End-to-end hosted-agents demo: create -> attach -> destroy. + +Uses pydo's high-level agent interface, so consuming the SSE feed needs no +manual wiring — no background thread, no completion event, no dispatching on +raw ``run.*`` event strings, and no explicit teardown: + + * ``client.agents.start(manifest)`` creates the session and returns a handle + that auto-destroys when the ``with`` block exits. + * ``agent.run_streamed(prompt)`` opens the stream, sends the prompt, and + yields normalized, typed events (auto-approving HITL prompts by default). + * ``agent.run(prompt)`` is the blocking one-liner equivalent. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + AGENT_SPEC path to the agent spec YAML (agents.yaml manifest) + +Optional env: + PROMPT message to send (default: a short demo prompt) +""" + +import os +import sys + +from pydo import Client +from pydo.agents import AgentEventType + + +def main(): + spec_path = os.environ.get("AGENT_SPEC", "agent-spec.yaml") + with open(spec_path, "r", encoding="utf-8") as fh: + manifest = fh.read() + + prompt = os.environ.get("PROMPT", "In one short sentence, what is DigitalOcean?") + + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + + # create + auto-destroy via the context manager. + with client.agents.start(manifest) as agent: + print(f"[session {agent.session_id} | {agent.status}]", file=sys.stderr) + print(f">>> {prompt}\n", file=sys.stderr) + + # attach: send the prompt and consume typed events as they stream in. + stream = agent.run_streamed(prompt) + for event in stream: + if event.type == AgentEventType.TOKEN: + print(event.text, end="", flush=True) # live reply -> stdout + elif event.type == AgentEventType.TOOL_CALL: + print(f"\n[tool] {event.tool_name}", file=sys.stderr) + elif event.type == AgentEventType.HITL_REQUESTED: + print(f"\n[hitl auto-approved] {event.request_id}", file=sys.stderr) + + result = stream.result + print( + f"\n\n[{result.status}] " + f"tokens in={result.usage.get('tokens_in')} " + f"out={result.usage.get('tokens_out')} " + f"| captured {len(result.final_output)} chars", + file=sys.stderr, + ) + + # session is destroyed here. + return 0 if stream.status == "completed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/start_oauth_flow.py b/examples/agents/start_oauth_flow.py new file mode 100644 index 00000000..5b01824c --- /dev/null +++ b/examples/agents/start_oauth_flow.py @@ -0,0 +1,18 @@ +"""Start GitHub OAuth. Set DIGITALOCEAN_TOKEN and SESSION_ID.""" + +import json +import os + +from pydo import Client +from pydo.agents import OAuthProvider + +SESSION_ID = os.environ["SESSION_ID"] + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +resp = client.agents.sessions.start_oauth_flow( + SESSION_ID, + OAuthProvider.GITHUB, + requested_scopes=["repo"], +) + +print(json.dumps(resp, indent=2, default=str)) diff --git a/examples/agents/stream_session.py b/examples/agents/stream_session.py new file mode 100644 index 00000000..3b6454be --- /dev/null +++ b/examples/agents/stream_session.py @@ -0,0 +1,31 @@ +"""Stream session events. + +Required env: + DIGITALOCEAN_TOKEN + SESSION_ID + PYDO_AGENTS_ENDPOINT (stage2: https://api.s2r1.internal.digitalocean.com) + +Tip: open this stream *before* send_input.py so you catch live events. +If the run already finished, the stream may sit idle until the next input. +""" + +import os +import sys + +from pydo import Client + +SESSION_ID = os.environ["SESSION_ID"] + +client = Client(token=os.environ["DIGITALOCEAN_TOKEN"]) +print(f"agents endpoint: {client.agents.base_url}", file=sys.stderr) + +with client.agents.sessions.stream(SESSION_ID) as events: + for event in events: + # SPI wire (harness-api HTTP handler): type + data.text + if getattr(event, "type", None) == "run.token_delta" and event.get("data"): + print(event.data.text, end="", flush=True) + # grpc-gateway proto envelope (legacy) + elif "token_chunk" in event: + print(event.token_chunk.text, end="", flush=True) + else: + print(event) diff --git a/examples/agents/test_workspace_transfers.py b/examples/agents/test_workspace_transfers.py new file mode 100644 index 00000000..7471cb54 --- /dev/null +++ b/examples/agents/test_workspace_transfers.py @@ -0,0 +1,326 @@ +"""Live E2E tests for staged workspace transfers. + +Requires a READY session. Generates fixtures if missing, then round-trips +each file through upload → download → SHA-256 compare. + +Required env: + DIGITALOCEAN_TOKEN + SESSION_ID + PYDO_AGENTS_ENDPOINT (stage2 / harness OHS base URL) + +Optional env: + FIXTURE_DIR default: examples/agents/testdata + SIZES comma sizes for auto-generate (default: 1KiB,1MiB,40MiB) + SKIP_GENERATE if "1", do not auto-generate fixtures + SKIP_ARCHIVE if "1", skip the tar/is_archive case + SKIP_LARGE if "1", skip fixtures larger than 5 MiB + POLL_INTERVAL seconds (default: 1) + GUEST_PREFIX workspace path prefix (default: uploads/pydo-transfer-test) + +Usage: + export DIGITALOCEAN_TOKEN=... + export SESSION_ID=... + export PYDO_AGENTS_ENDPOINT=https://api.s2r1.internal.digitalocean.com + python examples/agents/generate_transfer_fixtures.py + python examples/agents/test_workspace_transfers.py +""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from pydo import Client +from pydo.agents import WorkspaceTransferError + +HERE = Path(__file__).resolve().parent +DEFAULT_FIXTURE_DIR = HERE / "testdata" + + +def _sha256_file(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def _ensure_fixtures(fixture_dir: Path) -> None: + if os.environ.get("SKIP_GENERATE") == "1": + return + needed = ["hello.txt", "sample.tar", "MANIFEST.tsv"] + if all((fixture_dir / name).exists() for name in needed): + return + print(f"[fixtures] generating under {fixture_dir}", file=sys.stderr) + cmd = [ + sys.executable, + str(HERE / "generate_transfer_fixtures.py"), + "--out", + str(fixture_dir), + ] + sizes = os.environ.get("SIZES") + if sizes: + cmd.extend(["--sizes", sizes]) + subprocess.check_call(cmd) + + +def _iter_cases(fixture_dir: Path): + skip_large = os.environ.get("SKIP_LARGE") == "1" + skip_archive = os.environ.get("SKIP_ARCHIVE") == "1" + limit = 5 * 1024 * 1024 + + # Prefer MANIFEST.tsv when present. + manifest = fixture_dir / "MANIFEST.tsv" + if manifest.exists(): + for line in manifest.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split("\t") + name, size_s, digest = parts[0], parts[1], parts[2] + is_archive = any(p == "is_archive=true" for p in parts[3:]) + path = fixture_dir / name + if not path.exists(): + print(f"[skip] missing {path}", file=sys.stderr) + continue + size = int(size_s) + if skip_large and size > limit: + print(f"[skip] large {path.name} ({size} bytes)", file=sys.stderr) + continue + if is_archive and skip_archive: + print(f"[skip] archive {path.name}", file=sys.stderr) + continue + yield path, digest, is_archive + return + + # Fallback: every file in the directory. + for path in sorted(fixture_dir.iterdir()): + if not path.is_file() or path.name.startswith("."): + continue + if path.name == "MANIFEST.tsv": + continue + size = path.stat().st_size + if skip_large and size > limit: + continue + is_archive = path.suffix == ".tar" + if is_archive and skip_archive: + continue + yield path, _sha256_file(path), is_archive + + +def _round_trip(agent, local: Path, digest: str, *, guest_path: str, is_archive: bool, poll_interval: float) -> None: + size = local.stat().st_size + print( + f"\n=== {local.name} ({size} bytes, archive={is_archive}) -> {guest_path} ===", + file=sys.stderr, + ) + t0 = time.monotonic() + up = agent.upload_file( + path=guest_path, + data=str(local), + is_archive=is_archive, + content_sha256=digest, + poll_interval=poll_interval, + ) + print( + f"[upload] status={getattr(up, 'status', None)} " + f"bytes_written={getattr(up, 'bytes_written', None)} " + f"transfer_id={getattr(up, 'transfer_id', None)} " + f"elapsed={time.monotonic() - t0:.1f}s", + file=sys.stderr, + ) + + # For archive uploads the guest path is an extract root; download as archive + # of that path so we can still verify something came back. + download_path = guest_path + as_archive = is_archive + t1 = time.monotonic() + download = agent.download_file( + path=download_path, + as_archive=as_archive, + poll_interval=poll_interval, + ) + fd, tmp_name = tempfile.mkstemp(prefix="pydo-xfer-", suffix=".bin") + os.close(fd) + tmp = Path(tmp_name) + try: + written = download.save(str(tmp)) + print( + f"[download] written={written} size_hint={download.size_hint} " + f"expected_sha256={download.expected_sha256} " + f"elapsed={time.monotonic() - t1:.1f}s", + file=sys.stderr, + ) + got = _sha256_file(tmp) + if is_archive: + # Extracted tree re-tarred by the server will not match the original + # tar bytes; only check that we got a non-empty payload and (when + # present) that the server digest matches what we downloaded. + if written <= 0: + raise AssertionError("archive download returned 0 bytes") + if download.expected_sha256 and got != download.expected_sha256.lower(): + raise AssertionError( + f"downloaded archive sha mismatch: {got} != {download.expected_sha256}" + ) + print("[ok] archive download non-empty + digest check", file=sys.stderr) + else: + if got != digest.lower(): + raise AssertionError( + f"round-trip sha mismatch: got {got} expected {digest}" + ) + if written != size: + raise AssertionError(f"size mismatch: got {written} expected {size}") + print("[ok] bytes + sha256 match", file=sys.stderr) + except WorkspaceTransferError: + raise + finally: + try: + tmp.unlink() + except OSError: + pass + + +def _low_level_smoke(sessions, session_id: str, local: Path, digest: str, poll_interval: float) -> None: + """Exercise the 5 transfer endpoints explicitly on a small file.""" + guest = "uploads/pydo-lowlevel-smoke.bin" + size = local.stat().st_size + print(f"\n=== low-level API smoke ({local.name}) ===", file=sys.stderr) + + created = sessions.create_transfer( + session_id, + direction="upload", + path=guest, + size_bytes=size, + sha256=digest, + ) + transfer_id = created.transfer_id + part_size = int(created.part_size) + print( + f"[create] transfer_id={transfer_id} part_size={part_size} status={created.status}", + file=sys.stderr, + ) + + offset = 0 + part_number = 1 + part_numbers = [] + chunks = [] + with local.open("rb") as fh: + while offset < size: + chunk = fh.read(min(part_size, size - offset)) + chunks.append(chunk) + part_numbers.append(part_number) + offset += len(chunk) + part_number += 1 + + batch = sessions.create_part_upload_urls( + session_id, transfer_id, part_numbers=part_numbers + ) + url_by_n = { + int(entry.part_number): entry.upload_url for entry in batch.part_urls + } + from pydo.agents.custom_sessions import _http_put_bytes + + for n, chunk in zip(part_numbers, chunks): + _http_put_bytes(url_by_n[n], chunk) + print(f"[part {n}] uploaded {len(chunk)} bytes", file=sys.stderr) + + committed = sessions.commit_upload(session_id, transfer_id, sha256=digest) + print(f"[commit] status={committed.status}", file=sys.stderr) + done = sessions.wait_transfer(session_id, transfer_id, poll_interval=poll_interval) + print( + f"[wait] status={done.status} bytes_written={getattr(done, 'bytes_written', None)}", + file=sys.stderr, + ) + + # Cancel on a finished transfer: idempotent when supported; stage2 may still + # return 501 until sandboxsvc exposes cancel. + try: + cancelled = sessions.cancel_transfer( + session_id, transfer_id, reason="smoke_done" + ) + print( + f"[cancel] aborted={cancelled.aborted} status={cancelled.status}", + file=sys.stderr, + ) + except Exception as exc: # noqa: BLE001 + message = str(exc) + if "501" in message or "not yet exposed" in message.lower(): + print(f"[cancel] skipped (not supported yet): {message}", file=sys.stderr) + else: + raise + print("[ok] low-level smoke", file=sys.stderr) + + +def main() -> int: + session_id = os.environ["SESSION_ID"] + fixture_dir = Path(os.environ.get("FIXTURE_DIR", DEFAULT_FIXTURE_DIR)) + poll_interval = float(os.environ.get("POLL_INTERVAL", "1")) + guest_prefix = os.environ.get("GUEST_PREFIX", "uploads/pydo-transfer-test") + + _ensure_fixtures(fixture_dir) + if not fixture_dir.exists(): + print(f"no fixtures at {fixture_dir}; run generate_transfer_fixtures.py", file=sys.stderr) + return 2 + + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + agent = client.agents.attach(session_id) + print(f"[session] {session_id}", file=sys.stderr) + print(f"[endpoint] {client.agents.base_url}", file=sys.stderr) + print(f"[fixtures] {fixture_dir}", file=sys.stderr) + + failures = [] + cases = list(_iter_cases(fixture_dir)) + if not cases: + print("no test cases found", file=sys.stderr) + return 2 + + # Low-level smoke on the smallest non-archive fixture. + small = next((c for c in cases if not c[2]), None) + if small is not None: + try: + _low_level_smoke( + client.agents.sessions, + session_id, + small[0], + small[1], + poll_interval, + ) + except Exception as exc: # noqa: BLE001 — report and continue high-level cases + print(f"[FAIL] low-level smoke: {exc}", file=sys.stderr) + failures.append("low-level-smoke") + + for local, digest, is_archive in cases: + guest = f"{guest_prefix}/{local.name}" + if is_archive: + guest = f"{guest_prefix}/extracted-{local.stem}" + try: + _round_trip( + agent, + local, + digest, + guest_path=guest, + is_archive=is_archive, + poll_interval=poll_interval, + ) + except Exception as exc: # noqa: BLE001 — collect failures across cases + print(f"[FAIL] {local.name}: {exc}", file=sys.stderr) + failures.append(local.name) + + print(file=sys.stderr) + if failures: + print(f"FAILED ({len(failures)}): {', '.join(failures)}", file=sys.stderr) + return 1 + print(f"PASSED ({len(cases)} high-level cases)", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agents/workspace_transfer.py b/examples/agents/workspace_transfer.py new file mode 100644 index 00000000..8030d428 --- /dev/null +++ b/examples/agents/workspace_transfer.py @@ -0,0 +1,78 @@ +"""Upload a file to a session's sandbox workspace and download it back. + +Uses the staged transfer APIs (``.../workspace/transfers``) for all sizes. + +Required env: + DIGITALOCEAN_TOKEN + PYDO_AGENTS_ENDPOINT stage2: https://api.s2r1.internal.digitalocean.com + SESSION_ID an existing, READY session id + +Optional env: + LOCAL_FILE file to upload (default: a small generated text file) + GUEST_PATH destination inside /workspace (default: uploads/example.txt) + DOWNLOAD_TO local path to write the round-tripped copy (default: a temp file) +""" + +import hashlib +import os +import sys +import tempfile + +from pydo import Client +from pydo.agents import WorkspaceTransferError + + +def _sample_file() -> str: + fd, path = tempfile.mkstemp(prefix="pydo-ws-", suffix=".txt") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write("hello from pydo workspace transfer\n" * 4) + return path + + +def main() -> int: + session_id = os.environ["SESSION_ID"] + local_file = os.environ.get("LOCAL_FILE") or _sample_file() + guest_path = os.environ.get("GUEST_PATH", "uploads/example.txt") + download_to = os.environ.get("DOWNLOAD_TO") or tempfile.mktemp(prefix="pydo-dl-") + + client = Client( + token=os.environ["DIGITALOCEAN_TOKEN"], + agents_endpoint=os.environ.get("PYDO_AGENTS_ENDPOINT"), + ) + agent = client.agents.attach(session_id) + + with open(local_file, "rb") as fh: + original = fh.read() + sha256 = hashlib.sha256(original).hexdigest() + print( + f"[session {session_id}] uploading {len(original)} bytes " + f"({local_file}) -> /workspace/{guest_path}", + file=sys.stderr, + ) + + up = agent.upload_file(path=guest_path, data=local_file, content_sha256=sha256) + print(f"[uploaded] {dict(up)}", file=sys.stderr) + + download = agent.download_file(path=guest_path) + try: + written = download.save(download_to) + except WorkspaceTransferError as exc: + print(f"[integrity check FAILED] {exc}", file=sys.stderr) + return 1 + + print( + f"[downloaded] {written} bytes -> {download_to} " + f"(is_archive={download.is_archive}, size_hint={download.size_hint}, " + f"sha256={download.expected_sha256})", + file=sys.stderr, + ) + + with open(download_to, "rb") as fh: + roundtripped = fh.read() + ok = roundtripped == original + print(f"[round-trip] bytes match: {ok}", file=sys.stderr) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/gateway/approval_flow.py b/examples/gateway/approval_flow.py new file mode 100644 index 00000000..51ea2583 --- /dev/null +++ b/examples/gateway/approval_flow.py @@ -0,0 +1,94 @@ +"""Approve Chat Completions tool calls through an Action Gateway session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + MODEL + PROMPT +""" + +import json +import os + +from pydo.action_gateway import ActionGatewayClient + + +def find_approval_ids(value): + """Find approval IDs in gateway tool-result messages.""" + approval_ids = [] + if isinstance(value, dict): + for key in ("approval_id", "approvalId"): + if value.get(key): + approval_ids.append(value[key]) + for nested in value.values(): + approval_ids.extend(find_approval_ids(nested)) + elif isinstance(value, list): + for nested in value: + approval_ids.extend(find_approval_ids(nested)) + elif isinstance(value, str): + try: + approval_ids.extend(find_approval_ids(json.loads(value))) + except json.JSONDecodeError: + pass + return approval_ids + + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"], timeout=30) +print("Creating Action Gateway session...") +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [{"tool": "action_search", "action": "allow"}], + }, +) + +model = os.environ.get("MODEL", "openai-gpt-4o") +messages = [ + { + "role": "user", + "content": os.environ.get( + "PROMPT", + "Search for the latest DigitalOcean news and summarize it.", + ), + } +] +tools = session.tools() +tool_choice = "required" + +while True: + print(f"Requesting next tool call from {model}...") + response = client.chat.completions.create( + model=model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + parallel_tool_calls=False, + ) + message = response.choices[0].message + if not message.get("tool_calls"): + break + + print("Executing requested gateway tool...") + tool_messages = session.handle_tool_calls(response) + approval_ids = list(dict.fromkeys(find_approval_ids(tool_messages))) + for approval_id in approval_ids: + input(f"Approve {approval_id}? Press Enter to continue...") + session.approve(approval_id) + + if approval_ids: + print("Retrying approved gateway tool...") + tool_messages = session.handle_tool_calls(response) + + messages.append(dict(message)) + messages.extend(tool_messages) + if any( + tool_call["function"]["name"] != "action_search" + for tool_call in message["tool_calls"] + ): + tool_choice = "auto" + +print("\nFinal answer:\n") +print(message.get("content")) diff --git a/examples/gateway/async_invoke_tools.py b/examples/gateway/async_invoke_tools.py new file mode 100644 index 00000000..bdc2aff5 --- /dev/null +++ b/examples/gateway/async_invoke_tools.py @@ -0,0 +1,45 @@ +"""Async Action Gateway session: list, invoke, and execute code. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID +""" + +import asyncio +import os + +from pydo.action_gateway.aio import ActionGatewayClient + + +async def main() -> None: + client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = await client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "execute_code", "action": "allow"}, + ], + }, + ) + + tools = await session.tools.list(include_all=True) + print("session tools:", [tool.name for tool in tools]) + print("MCP URL:", session.url) + + output = await session.tools.invoke_one( + "exa_web_search", {"query": "DigitalOcean Gradient", "max_results": 2} + ) + print("web_search output:", str(output)[:200]) + + result = await session.code.execute("print('hello from async')") + print("code stdout:", result.get("stdout")) + + await client.close() + + +asyncio.run(main()) diff --git a/examples/gateway/create_session.py b/examples/gateway/create_session.py new file mode 100644 index 00000000..1481917d --- /dev/null +++ b/examples/gateway/create_session.py @@ -0,0 +1,25 @@ +"""Create an Action Gateway session and print its MCP URL. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + tools=["exa_web_search@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={ + "default_action": "ask", + "rules": [{"tool": "exa_web_search", "action": "allow"}], + }, +) + +print(session.url) diff --git a/examples/gateway/create_toolbelt.py b/examples/gateway/create_toolbelt.py new file mode 100644 index 00000000..f9b08ca1 --- /dev/null +++ b/examples/gateway/create_toolbelt.py @@ -0,0 +1,36 @@ +"""Create a versioned Action Gateway toolbelt. + +Required env: + DIGITALOCEAN_TOKEN +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + +toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=[ + "exa_web_search", + "exa_web_fetch", + ], +) + +print(toolbelt.ref) + +# Public Tool Registry APIs are generated from DigitalOcean's OpenAPI spec. +print(client.toolbelts.list(status="active")) +print(client.toolbelts.get("search-toolbelt", version="1")) +client.toolbelts.add_tools( + "search-toolbelt", + body={"tools": ["jira_create_issue"]}, +) +client.toolbelts.delete_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) + +# Delete the toolbelt when it is no longer needed. +# client.toolbelts.delete("search-toolbelt") diff --git a/examples/gateway/execute_code.py b/examples/gateway/execute_code.py new file mode 100644 index 00000000..886bb8a9 --- /dev/null +++ b/examples/gateway/execute_code.py @@ -0,0 +1,34 @@ +"""Run Python code in the Action Gateway sandbox (action_code). + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [{"tool": "execute_code", "action": "allow"}], + }, +) + +result = session.code.execute( + "import sys\n" "print('hello from the sandbox')\n" "print(sys.version)\n", + thought="verify the sandbox works", +) + +print("exit_code:", result.get("exit_code")) +print("stdout:") +print(result.get("stdout")) +if result.get("stderr"): + print("stderr:") + print(result.get("stderr")) diff --git a/examples/gateway/function_calling_loop.py b/examples/gateway/function_calling_loop.py new file mode 100644 index 00000000..d4770909 --- /dev/null +++ b/examples/gateway/function_calling_loop.py @@ -0,0 +1,57 @@ +"""Agentic function-calling loop: chat completions + Action Gateway session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID + MODEL + PROMPT +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, +) + +model = os.environ.get("MODEL", "openai-gpt-5.4") +prompt = os.environ.get("PROMPT", "Search the web for information on DigitalOcean.") + +tools = session.tools() +messages = [{"role": "user", "content": prompt}] +tool_choice = "required" + +while True: + response = client.chat.completions.create( + model=model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + ) + message = response.choices[0].message + if not message.get("tool_calls"): + break + + messages.append(dict(message)) + tool_messages = session.handle_tool_calls(response) + if any( + tool_call["function"]["name"] != "action_search" + for tool_call in message["tool_calls"] + ): + tool_choice = "auto" + messages.extend(tool_messages) + +print("\nFinal answer:\n") +print(message.get("content")) diff --git a/examples/gateway/invoke_tools.py b/examples/gateway/invoke_tools.py new file mode 100644 index 00000000..8415bf5a --- /dev/null +++ b/examples/gateway/invoke_tools.py @@ -0,0 +1,54 @@ +"""Invoke Action Gateway tools in parallel (action_invoke). + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, +) + +envelope = session.tools.invoke( + [ + { + "tool": "exa_web_search", + "arguments": {"query": "DigitalOcean Gradient", "max_results": 3}, + }, + { + "tool": "exa_web_fetch", + "arguments": {"url": "https://www.digitalocean.com"}, + }, + ], + rationale="demonstrate parallel tool invocation", +) + +print(f"{envelope.success_count}/{envelope.total_count} succeeded\n") +for item in envelope.results: + result = item.result + print(f"[{item.index}] {item.tool}: {result.status}") + if result.status == "succeeded": + print(f" output: {str(result.get('output'))[:200]}") + else: + error = result.get("error", {}) + print(f" error ({error.get('class')}): {error.get('message')}") + +output = session.tools.invoke_one( + "exa_web_search", {"query": "MCP protocol", "max_results": 1} +) +print("\ninvoke_one output:", str(output)[:200]) diff --git a/examples/gateway/list_tools.py b/examples/gateway/list_tools.py new file mode 100644 index 00000000..0ac1b288 --- /dev/null +++ b/examples/gateway/list_tools.py @@ -0,0 +1,31 @@ +"""List Action Gateway tools for a session. + +By default the session exposes three meta-tools (action_search, +action_invoke, action_code). Pass include_all=True to include tools configured +through config.preloadTools. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), +) + +print("MCP URL:", session.url) +print("\nMeta-tools (default):") +for tool in session.tools.list(): + print(f" {tool.name}: {tool.get('description', '')[:80]}") + +print("\nAll tools exposed by this session MCP endpoint:") +for tool in session.tools.list(include_all=True): + print(f" {tool.name}: {tool.get('description', '')[:80]}") diff --git a/examples/gateway/messages_tool_use.py b/examples/gateway/messages_tool_use.py new file mode 100644 index 00000000..2dec6211 --- /dev/null +++ b/examples/gateway/messages_tool_use.py @@ -0,0 +1,80 @@ +"""Tool use via the Messages API (Anthropic format) + Action Gateway session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID + MODEL + PROMPT +""" + +import os + +from pydo.action_gateway import ActionGatewayClient, MessagesProvider + + +def _assistant_content(response) -> list: + """Return assistant content blocks, tolerating missing keys.""" + content = response.get("content") + return list(content) if content else [] + + +def _print_final_message(response) -> None: + """Print assistant text from a Messages API response.""" + if response.get("type") == "error": + error = response.get("error") or {} + raise RuntimeError(f"Messages API error: {error}") + + blocks = _assistant_content(response) + printed = False + for block in blocks: + if block.get("type") == "text" and block.get("text"): + print(block["text"]) + printed = True + + if not printed: + stop_reason = response.get("stop_reason") + if stop_reason: + print(f"(no text blocks; stop_reason={stop_reason!r})") + print(response) + + +client = ActionGatewayClient( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=MessagesProvider(), +) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "allow"}, + ], + }, +) + +model = os.environ.get("MODEL", "claude-opus-4-6") +prompt = os.environ.get( + "PROMPT", "Find the latest news about DigitalOcean and summarize it." +) + +tools = session.tools() +messages = [{"role": "user", "content": prompt}] + +while True: + response = client.messages.create( + model=model, + max_tokens=1024, + tools=tools, + messages=messages, + ) + if response.get("stop_reason") != "tool_use": + break + + messages.append({"role": "assistant", "content": _assistant_content(response)}) + messages.extend(session.handle_tool_calls(response)) + +_print_final_message(response) diff --git a/examples/gateway/public_api.py b/examples/gateway/public_api.py new file mode 100644 index 00000000..46b2bdea --- /dev/null +++ b/examples/gateway/public_api.py @@ -0,0 +1,80 @@ +"""Use every OpenAPI-generated Action Gateway resource. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + CONNECTION_ID enables get, update, and delete connection examples + SESSION_URN enables session deletion example +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +actor_id = os.environ.get("ACTOR_ID", "example-user") + +# Tools are read-only catalog resources. +print("Tools:", client.tools.list(toolkit_id="exa")) +print("Toolkits:", client.tools.list_toolkits()) +print("Providers:", client.tools.list_providers()) +print( + "Definition:", + client.tools.get_definition("exa_web_search", version="v1"), +) + +# Toolbelts support create, list, get, membership changes, and delete. +toolbelt = client.toolbelts.create( + body={"name": "search-toolbelt", "tools": ["exa_web_search"]} +) +print("Created toolbelt:", toolbelt) +print("Toolbelts:", client.toolbelts.list(status="active")) +print("Toolbelt:", client.toolbelts.get("search-toolbelt")) +client.toolbelts.add_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) +client.toolbelts.delete_tools( + "search-toolbelt", + body={"tools": ["exa_web_fetch"]}, +) + +# Connections support create, list, get, parameter updates, and delete. +connection = client.connections.create( + body={"provider": "github", "user_id": actor_id, "scopes": ["repo"]} +) +print("Created connection:", connection) +print("Connections:", client.connections.list(user_id=actor_id)) + +connection_id = os.environ.get("CONNECTION_ID") +if connection_id: + print("Connection:", client.connections.get(connection_id)) + client.connections.update( + connection_id, + body={"connection_parameters": {"site_url": "https://github.com"}}, + ) + client.connections.delete(connection_id) + +# Users are derived from their sessions and connections. +print("Users:", client.users.list()) +print("User:", client.users.get(actor_id)) + +# Sessions are generated too. The convenience session API delegates creation +# to this same generated resource and returns a session bound to response.mcpUrl. +print("Sessions:", client.sessions_api.list(end_user_id=actor_id)) +session = client.session.create( + actor_id=actor_id, + tools=["exa_web_search@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={"default_action": "ask"}, +) +print("Session MCP URL:", session.url) + +session_urn = os.environ.get("SESSION_URN") +if session_urn: + client.sessions_api.delete(session_urn) + +# Uncomment when the example toolbelt is no longer needed. +# client.toolbelts.delete("search-toolbelt") diff --git a/examples/gateway/responses_tool_use.py b/examples/gateway/responses_tool_use.py new file mode 100644 index 00000000..1f7e084b --- /dev/null +++ b/examples/gateway/responses_tool_use.py @@ -0,0 +1,38 @@ +"""Tool use via the Responses API + Action Gateway session. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID + MODEL + PROMPT +""" + +import os + +from pydo.action_gateway import ActionGatewayClient, ResponsesProvider + +client = ActionGatewayClient( + token=os.environ["DIGITALOCEAN_TOKEN"], + gateway_provider=ResponsesProvider(), +) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [{"tool": "exa_web_search", "action": "allow"}], + }, +) + +response = client.responses.create( + model=os.environ.get("MODEL", "openai-gpt-4o"), + input=os.environ.get( + "PROMPT", + "Find the latest DigitalOcean news and summarize it.", + ), + tools=session.tools(), +) + +for tool_output in session.handle_tool_calls(response): + print(tool_output) diff --git a/examples/gateway/search_tools.py b/examples/gateway/search_tools.py new file mode 100644 index 00000000..715866c6 --- /dev/null +++ b/examples/gateway/search_tools.py @@ -0,0 +1,31 @@ +"""Search the Action Gateway tool catalog by use case (action_search). + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + PYDO_GATEWAY_ENDPOINT preview: https://actions.do-ai-test.run + ACTOR_ID + USE_CASE +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), +) + +use_case = os.environ.get("USE_CASE", "search the public web for a topic") + +payload = session.tools.search(use_case, limit=3) + +for group in payload.get("results", []): + print(f"use case: {group.get('use_case')}") + for match in group.get("results", []): + print(f" {match.get('name')} (score {match.get('score')})") + print(f" {match.get('description', '')[:100]}") + if group.get("guidance"): + print(f" guidance: {group['guidance']}") diff --git a/examples/gateway/session_controls.py b/examples/gateway/session_controls.py new file mode 100644 index 00000000..ea110b6d --- /dev/null +++ b/examples/gateway/session_controls.py @@ -0,0 +1,41 @@ +"""Control Action Gateway discovery, direct tools, and invocation policy. + +The three session controls have separate roles: + tools catalog available to action_search/action_invoke + config.preloadTools concrete tools also exposed directly over MCP + permissions allow, ask, or deny each invocation + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + tools=["exa_web_search@v1", "exa_web_fetch@v1"], + config={"preloadTools": ["exa_web_search@v1"]}, + permissions={ + "default_action": "deny", + "rules": [ + {"tool": "exa_web_search", "action": "allow"}, + {"tool": "exa_web_fetch", "action": "ask"}, + ], + }, +) + +print("MCP URL:", session.url) +print("Selected for search/invoke:", session.selected_tools) +print( + "Exposed directly:", + [tool.name for tool in session.tools.list(include_all=True)], +) + +results = session.tools.search("search or fetch a public web page") +print("Search results:", results) diff --git a/examples/gateway/toolbelt_policy.py b/examples/gateway/toolbelt_policy.py new file mode 100644 index 00000000..b1a2dc77 --- /dev/null +++ b/examples/gateway/toolbelt_policy.py @@ -0,0 +1,28 @@ +"""Create a session whose policy allows one pinned toolbelt. + +Required env: + DIGITALOCEAN_TOKEN + +Optional env: + ACTOR_ID +""" + +import os + +from pydo.action_gateway import ActionGatewayClient + +client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + +session = client.session.create( + actor_id=os.environ.get("ACTOR_ID", "example-user"), + permissions={ + "default_action": "ask", + "rules": [ + {"tool": "toolbelt:search-toolbelt@1", "action": "allow"}, + {"tool": "exa_web_search", "action": "allow"}, + ], + }, +) + +print("MCP URL:", session.url) +print("tools:", [tool["function"]["name"] for tool in session.tools()]) diff --git a/openapi/README.md b/openapi/README.md new file mode 100644 index 00000000..a1366e7e --- /dev/null +++ b/openapi/README.md @@ -0,0 +1,19 @@ +# Action Gateway OpenAPI patch + +`action-gateway-toolbelts.patch` adds the public Toolbelts API used to generate +the synchronous and asynchronous `client.toolbelts` operations. + +The patch is based on the OpenAPI revision recorded in +`DO_OPENAPI_COMMIT_SHA.txt`. To regenerate the SDK: + +```shell +git -C /path/to/openapi checkout "$(cat DO_OPENAPI_COMMIT_SHA.txt)" +git -C /path/to/openapi apply "$PWD/openapi/action-gateway-toolbelts.patch" +make -C /path/to/openapi bundle \ + BUNDLE_PATH="$PWD/DigitalOcean-public.v2.yaml" +SPEC_FILE="$PWD/DigitalOcean-public.v2.yaml" make generate +``` + +Submit the same source changes to the DigitalOcean OpenAPI repository. Once +they are published and `DO_OPENAPI_COMMIT_SHA.txt` advances to include them, +remove this transitional patch. diff --git a/openapi/action-gateway-toolbelts.patch b/openapi/action-gateway-toolbelts.patch new file mode 100644 index 00000000..c89cfd96 --- /dev/null +++ b/openapi/action-gateway-toolbelts.patch @@ -0,0 +1,518 @@ +diff --git a/specification/DigitalOcean-public.v2.yaml b/specification/DigitalOcean-public.v2.yaml +index b8c29b4..6bbe488 100644 +--- a/specification/DigitalOcean-public.v2.yaml ++++ b/specification/DigitalOcean-public.v2.yaml +@@ -50,6 +50,9 @@ tags: + + - `Accept: application/vnd.digitalocean.reserveip+json` + ++ - name: Action Gateway ++ description: Manage versioned tool collections used by Action Gateway sessions. ++ + - name: Add-Ons + description: |- + Add-ons are third-party applications that can be added to your DigitalOcean account. +@@ -718,6 +721,26 @@ x-tagGroups: + - Serverless Inference + + paths: ++ /v2/toolbelts: ++ get: ++ $ref: "resources/action_gateway/toolbelts_list.yml" ++ post: ++ $ref: "resources/action_gateway/toolbelts_create.yml" ++ ++ /v2/toolbelts/{name}: ++ get: ++ $ref: "resources/action_gateway/toolbelts_get.yml" ++ delete: ++ $ref: "resources/action_gateway/toolbelts_delete.yml" ++ ++ /v2/toolbelts/{name}/tools/add: ++ post: ++ $ref: "resources/action_gateway/toolbelts_add_tools.yml" ++ ++ /v2/toolbelts/{name}/tools/remove: ++ post: ++ $ref: "resources/action_gateway/toolbelts_remove_tools.yml" ++ + /v2/1-clicks: + get: + $ref: "resources/1-clicks/oneClicks_list.yml" +diff --git a/specification/resources/action_gateway/models.yml b/specification/resources/action_gateway/models.yml +new file mode 100644 +index 0000000..c709cc0 +--- /dev/null ++++ b/specification/resources/action_gateway/models.yml +@@ -0,0 +1,193 @@ ++toolbelt: ++ type: object ++ required: ++ - name ++ - version ++ - tools ++ - status ++ - reference ++ - reference_latest ++ - tool_count ++ - created_at ++ - updated_at ++ properties: ++ name: ++ type: string ++ example: search-toolbelt ++ version: ++ type: string ++ pattern: '^[0-9]+$' ++ example: '1' ++ display_name: ++ type: string ++ maxLength: 128 ++ example: Search Tools ++ description: ++ type: string ++ maxLength: 255 ++ example: Tools for searching and fetching public web pages. ++ tools: ++ type: array ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ - exa_web_fetch ++ status: ++ type: string ++ enum: ++ - active ++ - deprecated ++ example: active ++ reference: ++ type: string ++ description: A reference pinned to this immutable toolbelt version. ++ example: search-toolbelt@1 ++ reference_latest: ++ type: string ++ description: An unversioned reference to the latest active version. ++ example: search-toolbelt ++ tool_count: ++ type: integer ++ format: int32 ++ example: 2 ++ created_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ updated_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ ++toolbelt_summary: ++ type: object ++ required: ++ - name ++ - latest_version ++ - version_count ++ - tool_count ++ - status ++ - reference_latest ++ - updated_at ++ properties: ++ name: ++ type: string ++ example: search-toolbelt ++ display_name: ++ type: string ++ example: Search Tools ++ description: ++ type: string ++ example: Tools for searching and fetching public web pages. ++ latest_version: ++ type: string ++ example: '1' ++ version_count: ++ type: integer ++ format: int32 ++ example: 1 ++ tool_count: ++ type: integer ++ format: int32 ++ example: 2 ++ status: ++ type: string ++ enum: ++ - active ++ - deprecated ++ example: active ++ reference_latest: ++ type: string ++ example: search-toolbelt ++ updated_at: ++ type: string ++ format: date-time ++ example: '2026-06-11T12:00:00Z' ++ ++toolbelt_create: ++ type: object ++ required: ++ - name ++ - tools ++ properties: ++ name: ++ type: string ++ pattern: '^[a-z][a-z0-9_-]{0,63}$' ++ example: search-toolbelt ++ version: ++ type: string ++ pattern: '^[0-9]+$' ++ default: '1' ++ display_name: ++ type: string ++ maxLength: 128 ++ example: Search Tools ++ description: ++ type: string ++ maxLength: 255 ++ example: Tools for searching and fetching public web pages. ++ tools: ++ type: array ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ - exa_web_fetch ++ ++toolbelt_tools: ++ type: object ++ required: ++ - tools ++ properties: ++ tools: ++ type: array ++ minItems: 1 ++ maxItems: 500 ++ items: ++ type: string ++ example: ++ - exa_web_search ++ ++toolbelt_response: ++ type: object ++ required: ++ - toolbelt ++ properties: ++ toolbelt: ++ $ref: '#/toolbelt' ++ ++toolbelts_response: ++ type: object ++ required: ++ - toolbelts ++ - pagination ++ properties: ++ toolbelts: ++ type: array ++ items: ++ $ref: '#/toolbelt_summary' ++ pagination: ++ $ref: '#/pagination' ++ ++pagination: ++ type: object ++ required: ++ - page ++ - per_page ++ - total ++ properties: ++ page: ++ type: integer ++ format: int32 ++ example: 1 ++ per_page: ++ type: integer ++ format: int32 ++ example: 20 ++ total: ++ type: integer ++ format: int32 ++ example: 1 +diff --git a/specification/resources/action_gateway/parameters.yml b/specification/resources/action_gateway/parameters.yml +new file mode 100644 +index 0000000..8f40fbf +--- /dev/null ++++ b/specification/resources/action_gateway/parameters.yml +@@ -0,0 +1,33 @@ ++toolbelt_name: ++ name: name ++ in: path ++ required: true ++ description: The natural key identifying the toolbelt. ++ schema: ++ type: string ++ pattern: '^[a-z][a-z0-9_-]{0,63}$' ++ example: search-toolbelt ++ ++toolbelt_version: ++ name: version ++ in: query ++ required: false ++ description: An immutable numeric toolbelt version. Omit to retrieve the latest active version. ++ schema: ++ type: string ++ pattern: '^[0-9]+$' ++ example: '1' ++ ++toolbelt_status: ++ name: status ++ in: query ++ required: false ++ description: Filter toolbelts by status. ++ schema: ++ type: string ++ enum: ++ - active ++ - deprecated ++ - all ++ default: active ++ example: active +diff --git a/specification/resources/action_gateway/response_headers.yml b/specification/resources/action_gateway/response_headers.yml +new file mode 100644 +index 0000000..c8ac026 +--- /dev/null ++++ b/specification/resources/action_gateway/response_headers.yml +@@ -0,0 +1,6 @@ ++ratelimit-limit: ++ $ref: '../../shared/headers.yml#/ratelimit-limit' ++ratelimit-remaining: ++ $ref: '../../shared/headers.yml#/ratelimit-remaining' ++ratelimit-reset: ++ $ref: '../../shared/headers.yml#/ratelimit-reset' +diff --git a/specification/resources/action_gateway/toolbelts_add_tools.yml b/specification/resources/action_gateway/toolbelts_add_tools.yml +new file mode 100644 +index 0000000..8253309 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_add_tools.yml +@@ -0,0 +1,36 @@ ++operationId: toolbelts_add_tools ++summary: Add Tools to a Toolbelt ++description: Adds provider-qualified tool names and creates a new immutable toolbelt version. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_tools' ++responses: ++ '200': ++ description: The resulting toolbelt version. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_create.yml b/specification/resources/action_gateway/toolbelts_create.yml +new file mode 100644 +index 0000000..f496b03 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_create.yml +@@ -0,0 +1,34 @@ ++operationId: toolbelts_create ++summary: Create a Toolbelt ++description: Creates a versioned collection of provider-qualified Action Gateway tool names. ++tags: ++ - Action Gateway ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_create' ++responses: ++ '200': ++ description: A toolbelt was created successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '409': ++ $ref: '../../shared/responses/conflict.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_delete.yml b/specification/resources/action_gateway/toolbelts_delete.yml +new file mode 100644 +index 0000000..ea6a984 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_delete.yml +@@ -0,0 +1,28 @@ ++operationId: toolbelts_delete ++summary: Delete a Toolbelt ++description: Deprecates the latest active version of a toolbelt. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++responses: ++ '200': ++ description: The toolbelt was deprecated successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_get.yml b/specification/resources/action_gateway/toolbelts_get.yml +new file mode 100644 +index 0000000..b1e6992 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_get.yml +@@ -0,0 +1,29 @@ ++operationId: toolbelts_get ++summary: Retrieve a Toolbelt ++description: Retrieves the latest active version or a specified immutable version of a toolbelt. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++ - $ref: 'parameters.yml#/toolbelt_version' ++responses: ++ '200': ++ description: The toolbelt was retrieved successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_list.yml b/specification/resources/action_gateway/toolbelts_list.yml +new file mode 100644 +index 0000000..f5c4776 +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_list.yml +@@ -0,0 +1,28 @@ ++operationId: toolbelts_list ++summary: List Toolbelts ++description: Lists the latest version of each toolbelt owned by the authenticated team. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_status' ++ - $ref: '../../shared/parameters.yml#/page' ++ - $ref: '../../shared/parameters.yml#/per_page' ++responses: ++ '200': ++ description: Toolbelts were retrieved successfully. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelts_response' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] +diff --git a/specification/resources/action_gateway/toolbelts_remove_tools.yml b/specification/resources/action_gateway/toolbelts_remove_tools.yml +new file mode 100644 +index 0000000..f28dc3f +--- /dev/null ++++ b/specification/resources/action_gateway/toolbelts_remove_tools.yml +@@ -0,0 +1,36 @@ ++operationId: toolbelts_delete_tools ++summary: Remove Tools from a Toolbelt ++description: Removes tool names and creates a new immutable toolbelt version. ++tags: ++ - Action Gateway ++parameters: ++ - $ref: 'parameters.yml#/toolbelt_name' ++requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_tools' ++responses: ++ '200': ++ description: The resulting toolbelt version. ++ headers: ++ $ref: 'response_headers.yml' ++ content: ++ application/json: ++ schema: ++ $ref: 'models.yml#/toolbelt_response' ++ '400': ++ $ref: '../../shared/responses/bad_request.yml' ++ '401': ++ $ref: '../../shared/responses/unauthorized.yml' ++ '404': ++ $ref: '../../shared/responses/not_found.yml' ++ '429': ++ $ref: '../../shared/responses/too_many_requests.yml' ++ '500': ++ $ref: '../../shared/responses/server_error.yml' ++ default: ++ $ref: '../../shared/responses/unexpected_error.yml' ++security: ++ - bearer_auth: [] diff --git a/src/pydo/_client.py b/src/pydo/_client.py index b190e6ab..da85c809 100644 --- a/src/pydo/_client.py +++ b/src/pydo/_client.py @@ -26,6 +26,7 @@ ByoipPrefixesOperations, CdnOperations, CertificatesOperations, + ConnectionsOperations, DatabasesOperations, DedicatedInferencesOperations, DomainsOperations, @@ -54,12 +55,16 @@ ReservedIPv6ActionsOperations, ReservedIPv6Operations, SecurityOperations, + SessionsOperations, SizesOperations, SnapshotsOperations, SpacesKeyOperations, SshKeysOperations, TagsOperations, + ToolbeltsOperations, + ToolsOperations, UptimeOperations, + UsersOperations, VectorDatabasesOperations, VolumeActionsOperations, VolumeSnapshotsOperations, @@ -77,6 +82,16 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar tools: ToolsOperations operations + :vartype tools: pydo.operations.ToolsOperations + :ivar toolbelts: ToolbeltsOperations operations + :vartype toolbelts: pydo.operations.ToolbeltsOperations + :ivar connections: ConnectionsOperations operations + :vartype connections: pydo.operations.ConnectionsOperations + :ivar users: UsersOperations operations + :vartype users: pydo.operations.UsersOperations + :ivar sessions: SessionsOperations operations + :vartype sessions: pydo.operations.SessionsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -211,11 +226,9 @@ def __init__( self._config.custom_hook_policy, self._config.logging_policy, policies.DistributedTracingPolicy(**kwargs), - ( - policies.SensitiveHeaderCleanupPolicy(**kwargs) - if self._config.redirect_policy - else None - ), + policies.SensitiveHeaderCleanupPolicy(**kwargs) + if self._config.redirect_policy + else None, self._config.http_logging_policy, ] self._client: PipelineClient = PipelineClient( @@ -225,6 +238,21 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.tools = ToolsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.toolbelts = ToolbeltsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.connections = ConnectionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.users = UsersOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.sessions = SessionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/_patch.py b/src/pydo/_patch.py index d979f13d..9a2cd5b1 100644 --- a/src/pydo/_patch.py +++ b/src/pydo/_patch.py @@ -6,6 +6,7 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ + from typing import Optional from azure.core.credentials import AccessToken @@ -58,6 +59,15 @@ class Client( # type: ignore subdomain (e.g. ``"https://.agents.do-ai.run"``). Required only when using agent inference endpoints. :paramtype agent_endpoint: str + :keyword agents_endpoint: Hosted Agents API base URL (default + ``api.digitalocean.com``; override via ``PYDO_AGENTS_ENDPOINT``). + :keyword gateway_endpoint: Action Gateway base URL (default + ``https://actions.do-ai.run``; preview is + ``https://actions.do-ai-test.run``; override via + ``PYDO_GATEWAY_ENDPOINT``). + :keyword gateway_provider: Provider that formats gateway tools for an + inference surface (default :class:`ChatCompletionsProvider`; also + ``MessagesProvider`` and ``ResponsesProvider`` in ``pydo.gateway``). """ def __init__( @@ -68,6 +78,9 @@ def __init__( timeout: int = 120, inference_endpoint: str = INFERENCE_BASE_URL, agent_endpoint: str = "", + agents_endpoint: Optional[str] = None, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, **kwargs, ): if token is not None and api_key is not None: @@ -111,6 +124,24 @@ def __init__( self.images.generate = inference_images.generate self.images.generations = inference_images.generations + try: + from pydo.agents import AgentsResources + except ImportError: + self.agents = None + else: + self.agents = AgentsResources(self, agents_endpoint=agents_endpoint) + + try: + from pydo.gateway import GatewayResources + except ImportError: + self.gateway = None + else: + self.gateway = GatewayResources( + self, + gateway_endpoint=gateway_endpoint, + provider=gateway_provider, + ) + def _setup_inference_routing( self, inference_endpoint: str, diff --git a/src/pydo/action_gateway/__init__.py b/src/pydo/action_gateway/__init__.py new file mode 100644 index 00000000..6821fc0b --- /dev/null +++ b/src/pydo/action_gateway/__init__.py @@ -0,0 +1,165 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway entry point: ``from pydo.action_gateway import ActionGatewayClient``. + +Purpose-built client for the Action Gateway. Create a session first, then +use ``session.tools`` / ``session.code`` / ``session.handle_tool_calls``. +Inference surfaces inherited from :class:`pydo.Client` (``chat``, +``messages``, ``responses``, …) remain available for agentic loops. + +Example:: + + import os + from pydo.action_gateway import ActionGatewayClient + + client = ActionGatewayClient(token=os.environ["DIGITALOCEAN_TOKEN"]) + session = client.session.create(actor_id="user-123") + + tools = session.tools() + response = client.chat.completions.create( + model="openai-gpt-4o", + messages=[{"role": "user", "content": "Search for DigitalOcean news"}], + tools=tools, + ) + messages = session.handle_tool_calls(response) +""" + +from __future__ import annotations + +from typing import List, Optional + +from pydo import Client as _DigitalOceanClient +from pydo._patch import TokenCredentials +from pydo.gateway import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + DEFAULT_GATEWAY_BASE_URL, + ChatCompletionsProvider, + GatewayProtocolError, + GatewayToolError, + MessagesProvider, + ResponsesProvider, + Session, + SessionsOperations, + Toolbelt, + ToolCall, + normalize_permissions, + resolve_gateway_base_url, + session_mcp_url, +) + +_GATEWAY_SURFACE: tuple = ( + "base_url", + "chat", + "create_toolbelt", + "messages", + "provider", + "responses", + "session", + "sessions", + "sessions_api", + "connections", + "tools", + "toolbelts", + "users", +) + + +class Client(_DigitalOceanClient): + """Action Gateway–focused DigitalOcean Python client. + + Primary surface: + + * ``client.session.create(actor_id=...)`` → :class:`Session` + * ``session.tools`` / ``session.tools()`` — discover and wrap tools + * ``session.code`` — sandboxed Python execution + * ``session.handle_tool_calls(response)`` — run model tool calls + * ``session.url`` — MCP URL for external clients + + Inherits the full :class:`pydo.Client` machinery (auth, transport, + inference routing), so agentic loops can call ``client.chat`` / + ``client.messages`` on the same instance. + """ + + def __init__( + self, + token: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: int = 120, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, + **kwargs, + ) -> None: + super().__init__( + token=token, + api_key=api_key, + timeout=timeout, + gateway_endpoint=gateway_endpoint, + gateway_provider=gateway_provider, + **kwargs, + ) + gateway = self.gateway + if gateway is None: + raise RuntimeError( + "Action Gateway package is unavailable; " + "ensure pydo.gateway is installed" + ) + self.sessions_api = self.sessions + self.sessions = gateway.sessions + self.session = self.sessions + self.provider = gateway.provider + + def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: + """Create a versioned collection of Action Gateway tools.""" + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be an iterable of tool names") + body = {"name": name, "tools": list(tools), **kwargs} + response = self.toolbelts.create( + body=body, + cls=Toolbelt.validate_create_response, + ) + return Toolbelt.from_response(response) + + @property + def base_url(self) -> Optional[str]: + """Resolved Action Gateway base URL.""" + gateway = self.gateway + return gateway.base_url if gateway is not None else None + + def __dir__(self) -> List[str]: + return sorted(set(_GATEWAY_SURFACE)) + + def __repr__(self) -> str: + return "" + + +ActionGatewayClient = Client + + +__all__ = [ + "Client", + "ActionGatewayClient", + "TokenCredentials", + "Session", + "SessionsOperations", + "Toolbelt", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", + "normalize_permissions", + "session_mcp_url", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/action_gateway/aio/__init__.py b/src/pydo/action_gateway/aio/__init__.py new file mode 100644 index 00000000..bc657090 --- /dev/null +++ b/src/pydo/action_gateway/aio/__init__.py @@ -0,0 +1,143 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway entry point: ``ActionGatewayClient``. + +Asynchronous twin of :class:`pydo.action_gateway.Client`. Same surface, +``await``-friendly. See :mod:`pydo.action_gateway` for usage details. +""" + +from __future__ import annotations + +from typing import List, Optional + +from pydo.aio import Client as _DigitalOceanClient +from pydo.aio._patch import TokenCredentials +from pydo.aio.gateway import ( + AsyncSession, + AsyncSessionsOperations, +) +from pydo.gateway import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + DEFAULT_GATEWAY_BASE_URL, + ChatCompletionsProvider, + GatewayProtocolError, + GatewayToolError, + MessagesProvider, + ResponsesProvider, + Toolbelt, + ToolCall, + normalize_permissions, + resolve_gateway_base_url, + session_mcp_url, +) + +_GATEWAY_SURFACE: tuple = ( + "base_url", + "chat", + "create_toolbelt", + "messages", + "provider", + "responses", + "session", + "sessions", + "sessions_api", + "connections", + "tools", + "toolbelts", + "users", +) + + +class Client(_DigitalOceanClient): + """Action Gateway–focused DigitalOcean async client. + + Asynchronous counterpart to :class:`pydo.action_gateway.Client`. + Create a session with ``await client.session.create(actor_id=...)``, + then use ``session.tools`` / ``session.code`` / + ``await session.handle_tool_calls(...)``. + """ + + def __init__( + self, + token: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: int = 120, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, + **kwargs, + ) -> None: + super().__init__( + token=token, + api_key=api_key, + timeout=timeout, + gateway_endpoint=gateway_endpoint, + gateway_provider=gateway_provider, + **kwargs, + ) + gateway = self.gateway + if gateway is None: + raise RuntimeError( + "Action Gateway package is unavailable; " + "ensure pydo.aio.gateway is installed" + ) + self.sessions_api = self.sessions + self.sessions = gateway.sessions + self.session = self.sessions + self.provider = gateway.provider + + async def create_toolbelt(self, name: str, tools, **kwargs) -> Toolbelt: + """Create a versioned collection of Action Gateway tools.""" + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be an iterable of tool names") + body = {"name": name, "tools": list(tools), **kwargs} + response = await self.toolbelts.create( + body=body, + cls=Toolbelt.validate_create_response, + ) + return Toolbelt.from_response(response) + + @property + def base_url(self) -> Optional[str]: + """Resolved Action Gateway base URL.""" + gateway = self.gateway + return gateway.base_url if gateway is not None else None + + def __dir__(self) -> List[str]: + return sorted(set(_GATEWAY_SURFACE)) + + def __repr__(self) -> str: + return "" + + +ActionGatewayClient = Client + + +__all__ = [ + "Client", + "ActionGatewayClient", + "TokenCredentials", + "AsyncSession", + "AsyncSessionsOperations", + "Toolbelt", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", + "normalize_permissions", + "session_mcp_url", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/agents/__init__.py b/src/pydo/agents/__init__.py new file mode 100644 index 00000000..c661a4fb --- /dev/null +++ b/src/pydo/agents/__init__.py @@ -0,0 +1,155 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Hosted Agents (Harness) API — hand-written; preserved across ``make generate``.""" +from __future__ import annotations + +import os +from typing import Optional + +from pydo.custom_extensions import _BaseURLProxy + +from .custom_models import ( + AgentKind, + HITLActionKind, + HITLOutcome, + OAuthFlowKind, + OAuthProvider, + ProviderAuthState, + ResolutionSource, + RunFailureCode, + RunState, + SessionStatus, + SignatureScheme, + TriggerExecutionStatus, + TriggerKind, + TriggerOutputMode, + TriggerSessionMode, + TriggerStatus, + WebhookProviderKey, +) +from .custom_sessions import ( + HarnessEventStream, + HarnessStreamError, + HistoryPage, + SessionsOperations, + WorkspaceDownload, + WorkspaceTransferError, +) +from .custom_triggers import TriggersOperations +from .session import ( + AgentEvent, + AgentEventType, + AgentSession, + HITLPolicy, + RunResult, + RunStream, +) + +DEFAULT_AGENTS_BASE_URL = "https://api.digitalocean.com" +_ENV_VAR = "PYDO_AGENTS_ENDPOINT" + + +def resolve_agents_base_url(explicit: Optional[str] = None) -> str: + url = explicit or os.environ.get(_ENV_VAR) or DEFAULT_AGENTS_BASE_URL + url = url.rstrip("/") + if "://" not in url: + url = f"https://{url}" + return url + + +def _select_session_by_name(list_response, name: str): + """Pick the most recently created session from a name-filtered list. + + Raises :class:`LookupError` when the response contains no sessions. + """ + get = getattr(list_response, "get", None) + sessions = (get("sessions") if get else None) or [] + if not sessions: + raise LookupError(f"no session found with name {name!r}") + return max( + sessions, + key=lambda s: (getattr(s, "get", lambda *_: "")("created_at") or ""), + ) + + +class AgentsResources: + def __init__(self, parent_client, *, agents_endpoint: Optional[str] = None): + self._proxy = _BaseURLProxy( + parent_client._client, + resolve_agents_base_url(agents_endpoint), + ) + self.sessions = SessionsOperations(self._proxy) + self.triggers = TriggersOperations(self._proxy) + + @property + def base_url(self) -> str: + return self._proxy._base_url + + def start(self, manifest: "str | bytes") -> AgentSession: + """Create a session from an ``agents.yaml`` manifest and return a handle. + + Use as a context manager to auto-destroy on exit:: + + with client.agents.start(manifest) as agent: + print(agent.run("hello").final_output) + """ + resp = self.sessions.create_from_manifest(manifest) + get = getattr(resp, "get", None) + info = get("session") if get else None + session_id = (getattr(info or resp, "get", lambda *_: None))("session_id") + return AgentSession(self.sessions, session_id, raw=resp) + + def attach(self, session_id: str) -> AgentSession: + """Return an :class:`AgentSession` handle for an existing session.""" + return AgentSession(self.sessions, session_id) + + def attach_by_name(self, name: str) -> AgentSession: + """Resolve a session by ``name`` and return an :class:`AgentSession`. + + Looks up ``GET /v2/agents/sessions?name=``. If several sessions + share the name (e.g. reused over time), the most recently created one + is chosen. Raises :class:`LookupError` when there is no match. + """ + resp = self.sessions.list(name=name) + session = _select_session_by_name(resp, name) + session_id = (getattr(session, "get", lambda *_: None))("session_id") + return AgentSession(self.sessions, session_id, raw=session) + + +__all__ = [ + "AgentsResources", + "AgentSession", + "AgentEvent", + "AgentEventType", + "RunResult", + "RunStream", + "HITLPolicy", + "SessionsOperations", + "TriggersOperations", + "HarnessEventStream", + "HarnessStreamError", + "HistoryPage", + "WorkspaceDownload", + "WorkspaceTransferError", + "DEFAULT_AGENTS_BASE_URL", + "resolve_agents_base_url", + "AgentKind", + "SessionStatus", + "RunState", + "RunFailureCode", + "HITLOutcome", + "HITLActionKind", + "ResolutionSource", + "OAuthProvider", + "OAuthFlowKind", + "ProviderAuthState", + "TriggerKind", + "TriggerStatus", + "TriggerSessionMode", + "TriggerOutputMode", + "WebhookProviderKey", + "TriggerExecutionStatus", + "SignatureScheme", +] diff --git a/src/pydo/agents/custom_models.py b/src/pydo/agents/custom_models.py new file mode 100644 index 00000000..9a512578 --- /dev/null +++ b/src/pydo/agents/custom_models.py @@ -0,0 +1,189 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Hosted Agents API enum string constants.""" +from __future__ import annotations + + +class AgentKind: + """Discriminator for what runs inside a session's sandbox.""" + + UNSPECIFIED = "AGENT_KIND_UNSPECIFIED" + CLAUDE_CODE = "AGENT_KIND_CLAUDE_CODE" + OPENCODE = "AGENT_KIND_OPENCODE" + CODEX_CLI = "AGENT_KIND_CODEX_CLI" + CURSOR_CLI = "AGENT_KIND_CURSOR_CLI" + NONE = "AGENT_KIND_NONE" + CUSTOM = "AGENT_KIND_CUSTOM" + + +class SessionStatus: + """Lifecycle states for a session.""" + + UNSPECIFIED = "SESSION_STATUS_UNSPECIFIED" + PROVISIONING = "SESSION_STATUS_PROVISIONING" + READY = "SESSION_STATUS_READY" + DETACHED = "SESSION_STATUS_DETACHED" + PAUSED = "SESSION_STATUS_PAUSED" + DESTROYING = "SESSION_STATUS_DESTROYING" + DESTROYED = "SESSION_STATUS_DESTROYED" + FAILED = "SESSION_STATUS_FAILED" + + +class RunState: + """Finite-state machine for an individual agent run inside a session.""" + + UNSPECIFIED = "RUN_STATE_UNSPECIFIED" + QUEUED = "RUN_STATE_QUEUED" + RUNNING = "RUN_STATE_RUNNING" + AWAITING_HITL = "RUN_STATE_AWAITING_HITL" + PAUSED = "RUN_STATE_PAUSED" + COMPLETED = "RUN_STATE_COMPLETED" + FAILED = "RUN_STATE_FAILED" + + +class RunFailureCode: + """Canonical reasons a run can fail. Closed enum; routable by clients.""" + + UNSPECIFIED = "RUN_FAILURE_CODE_UNSPECIFIED" + MODEL_ERROR = "RUN_FAILURE_CODE_MODEL_ERROR" + MODEL_TIMEOUT = "RUN_FAILURE_CODE_MODEL_TIMEOUT" + TOOL_ERROR = "RUN_FAILURE_CODE_TOOL_ERROR" + SANDBOX_LOST = "RUN_FAILURE_CODE_SANDBOX_LOST" + HITL_REJECTED = "RUN_FAILURE_CODE_HITL_REJECTED" + BUDGET_EXCEEDED = "RUN_FAILURE_CODE_BUDGET_EXCEEDED" + INTERNAL = "RUN_FAILURE_CODE_INTERNAL" + + +class HITLOutcome: + """User decision on a pending HITL request.""" + + UNSPECIFIED = "HITL_OUTCOME_UNSPECIFIED" + APPROVE = "HITL_OUTCOME_APPROVE" + REJECT = "HITL_OUTCOME_REJECT" + DEFER = "HITL_OUTCOME_DEFER" + + +class HITLActionKind: + """Classifies the action the agent is asking permission for.""" + + UNSPECIFIED = "HITL_ACTION_KIND_UNSPECIFIED" + BASH = "HITL_ACTION_BASH" + FILE_WRITE_OUTSIDE_WORKSPACE = "HITL_ACTION_FILE_WRITE_OUTSIDE_WORKSPACE" + GITHUB_COMMIT_PUSH = "HITL_ACTION_GITHUB_COMMIT_PUSH" + GITHUB_CREATE_PR = "HITL_ACTION_GITHUB_CREATE_PR" + GITHUB_BRANCH_DELETE = "HITL_ACTION_GITHUB_BRANCH_DELETE" + GITHUB_FORCE_PUSH = "HITL_ACTION_GITHUB_FORCE_PUSH" + + +class ResolutionSource: + """How a HITL resolution was triggered. Captured for audit / UX analytics.""" + + UNSPECIFIED = "RESOLUTION_SOURCE_UNSPECIFIED" + INLINE_KEYSTROKE = "RESOLUTION_SOURCE_INLINE_KEYSTROKE" + OUT_OF_BAND = "RESOLUTION_SOURCE_OUT_OF_BAND" + + +class OAuthProvider: + """External identity provider a session is linking to.""" + + UNSPECIFIED = "OAUTH_PROVIDER_UNSPECIFIED" + GITHUB = "OAUTH_PROVIDER_GITHUB" + + +class OAuthFlowKind: + """Interaction model the client should drive the developer through.""" + + UNSPECIFIED = "OAUTH_FLOW_KIND_UNSPECIFIED" + WEB_CALLBACK = "OAUTH_FLOW_KIND_WEB_CALLBACK" + DEVICE = "OAUTH_FLOW_KIND_DEVICE" + + +class ProviderAuthState: + """OAuth state for a single provider in ``Session.provider_auth``.""" + + UNSPECIFIED = "PROVIDER_AUTH_STATE_UNSPECIFIED" + NONE = "PROVIDER_AUTH_STATE_NONE" + PENDING = "PROVIDER_AUTH_STATE_PENDING" + AUTHORIZED = "PROVIDER_AUTH_STATE_AUTHORIZED" + EXPIRED = "PROVIDER_AUTH_STATE_EXPIRED" + + +# --------------------------------------------------------------------------- +# Triggers (harness-trigger / OHS) +# --------------------------------------------------------------------------- + + +class TriggerKind: + """How a trigger fires: on an event (webhook) or on a schedule (cron).""" + + WEBHOOK = "webhook" + CRON = "cron" + + +class TriggerStatus: + """Trigger lifecycle state. Soft-deleted triggers are never returned.""" + + ACTIVE = "active" + PAUSED = "paused" + + +class TriggerSessionMode: + """Whether each firing creates a new session or reuses a paused one.""" + + FRESH = "fresh" + REUSE = "reuse" + + +class TriggerOutputMode: + """Where collected run output is delivered after a firing.""" + + NONE = "none" + EMAIL = "email" + SLACK = "slack" + + +class WebhookProviderKey: + """Signature-verification scheme for webhook deliveries.""" + + GITHUB = "github" + GITLAB = "gitlab" + CUSTOM = "custom" + + +class TriggerExecutionStatus: + """Per-firing outcome for a trigger execution.""" + + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class SignatureScheme: + """How an external system authenticates a webhook delivery.""" + + HMAC_SHA256 = "hmac-sha256" + PLAINTEXT = "plaintext" + + +__all__ = [ + "AgentKind", + "SessionStatus", + "RunState", + "RunFailureCode", + "HITLOutcome", + "HITLActionKind", + "ResolutionSource", + "OAuthProvider", + "OAuthFlowKind", + "ProviderAuthState", + "TriggerKind", + "TriggerStatus", + "TriggerSessionMode", + "TriggerOutputMode", + "WebhookProviderKey", + "TriggerExecutionStatus", + "SignatureScheme", +] diff --git a/src/pydo/agents/custom_sessions.py b/src/pydo/agents/custom_sessions.py new file mode 100644 index 00000000..3b51b8b7 --- /dev/null +++ b/src/pydo/agents/custom_sessions.py @@ -0,0 +1,1020 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Sync Hosted Agents session operations (``/v2/agents/sessions/...``).""" + +from __future__ import annotations + +import hashlib +import json as _json +import os +import time +import warnings +from typing import Any, BinaryIO, Dict, Iterator, List, NamedTuple, Optional, Union +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceExistsError, + ResourceNotFoundError, + ResourceNotModifiedError, + map_error, +) +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import SSEStream, _wrap + +_ERROR_MAP = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, +} + +_BASE_PATH = "/v2/agents/sessions" +_OK_STATUS = (200, 201, 202, 204) + +# Private Beta contract: a session is created from an ``agents.yaml`` manifest +# uploaded verbatim. The server routes on this media type (handlers.go: +# isYAMLContentType) and parses the body as the agent spec. +_YAML_MEDIA_TYPE = "application/x-yaml" + + +def _manifest_bytes(manifest: Union[str, bytes]) -> bytes: + """Normalize an agents.yaml manifest to non-empty UTF-8 bytes.""" + if isinstance(manifest, str): + data = manifest.encode("utf-8") + elif isinstance(manifest, (bytes, bytearray)): + data = bytes(manifest) + else: + raise TypeError("manifest must be a str or bytes YAML document") + if not data.strip(): + raise ValueError("manifest is empty") + return data + + +# Staged workspace transfers (``.../workspace/transfers``). Prefer these over +# the older streaming upload/download routes for all payload sizes. +_OCTET_STREAM = "application/octet-stream" +_TRANSFERS_SUFFIX = "workspace/transfers" +_MAX_TRANSFER_BYTES = 50 * 1024 * 1024 * 1024 # 50 GiB +_DEFAULT_POLL_INTERVAL = 1.0 +_DEFAULT_POLL_TIMEOUT = 600.0 +_DOWNLOAD_CHUNK = 1024 * 1024 + +UploadData = Union[bytes, bytearray, str, "os.PathLike[str]", BinaryIO] + + +class WorkspaceTransferError(RuntimeError): + """A workspace transfer failed (integrity check, timeout, or server error).""" + + +def _field(obj: Any, key: str, default: Any = None) -> Any: + if obj is None: + return default + getter = getattr(obj, "get", None) + if getter is not None: + return getter(key, default) + return getattr(obj, key, default) + + +def _coerce_upload_content(data: UploadData) -> "tuple[Any, int, Any]": + """Normalize an upload payload to ``(content, size, handle_to_close)``. + + Size is required by the staged transfer API. + """ + if isinstance(data, (bytes, bytearray)): + payload = bytes(data) + return payload, len(payload), None + if isinstance(data, (str, os.PathLike)): + path = os.fspath(data) + size = os.path.getsize(path) + handle = open(path, "rb") # pylint: disable=consider-using-with + return handle, size, handle + if hasattr(data, "read"): + try: + current = data.tell() + data.seek(0, os.SEEK_END) + size = data.tell() - current + data.seek(current) + except (OSError, AttributeError, ValueError) as exc: + raise ValueError( + "upload streams must support seek/tell so size_bytes can be " + "determined; pass bytes or a filesystem path instead" + ) from exc + return data, int(size), None + raise TypeError( + "data must be bytes, a filesystem path, or a readable binary stream" + ) + + +def _read_exact(source: Any, size: int) -> bytes: + if isinstance(source, (bytes, bytearray)): + raise TypeError("use slicing for bytes payloads") + chunks: List[bytes] = [] + remaining = size + while remaining > 0: + chunk = source.read(remaining) + if not chunk: + raise WorkspaceTransferError( + f"unexpected EOF while reading upload part " + f"({size - remaining} of {size} bytes read)" + ) + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _http_put_bytes(url: str, data: bytes) -> None: + """PUT part bytes to a presigned Spaces URL (not via OHS).""" + request = Request( + url, + data=data, + method="PUT", + headers={"Content-Type": _OCTET_STREAM}, + ) + try: + with urlopen(request) as resp: # noqa: S310 — caller-supplied Spaces URL + body = resp.read() + status = getattr(resp, "status", None) or resp.getcode() + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") if exc.fp else "" + raise WorkspaceTransferError( + f"part upload failed: HTTP {exc.code}" + + (f": {detail.strip()}" if detail.strip() else "") + ) from exc + except URLError as exc: + raise WorkspaceTransferError(f"part upload failed: {exc.reason}") from exc + if status not in (200, 201, 204): + raise WorkspaceTransferError( + f"part upload failed: HTTP {status}" + + (f": {body[:200]!r}" if body else "") + ) + + +def _http_get_iter(url: str) -> Iterator[bytes]: + """Stream bytes from a presigned download URL (not via OHS).""" + request = Request(url, method="GET") + try: + resp = urlopen(request) # noqa: S310 — caller-supplied Spaces URL + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") if exc.fp else "" + raise WorkspaceTransferError( + f"download failed: HTTP {exc.code}" + + (f": {detail.strip()}" if detail.strip() else "") + ) from exc + except URLError as exc: + raise WorkspaceTransferError(f"download failed: {exc.reason}") from exc + try: + status = getattr(resp, "status", None) or resp.getcode() + if status != 200: + raise WorkspaceTransferError(f"download failed: HTTP {status}") + while True: + chunk = resp.read(_DOWNLOAD_CHUNK) + if not chunk: + break + yield chunk + finally: + try: + resp.close() + except Exception: # noqa: BLE001 + pass + + +def _verify_transfer_sha256( + computed_hex: str, + expected: Optional[str], + *, + total_bytes: int, + size_hint: Optional[int], + require_checksum: bool, +) -> None: + if expected: + if expected.strip().lower() != computed_hex.lower(): + raise WorkspaceTransferError( + "workspace download integrity check failed: SHA-256 mismatch " + f"(expected {expected.strip()!r} != computed {computed_hex!r}) — " + "discard the output." + ) + return + if size_hint is not None and size_hint != total_bytes: + raise WorkspaceTransferError( + "workspace download is truncated: received " + f"{total_bytes} bytes but the server reported {size_hint} — " + "discard the output." + ) + if require_checksum: + raise WorkspaceTransferError( + "workspace download integrity check failed: sha256 was missing " + "from the completed transfer response." + ) + warnings.warn( + "workspace download completed without a sha256 digest; integrity was " + + ( + "confirmed via bytes_written." + if size_hint is not None + else "NOT independently verified." + ), + stacklevel=2, + ) + + +def _unwrap_harness_sse_chunk(chunk: Dict[str, Any]) -> Optional[Any]: + """Normalize SSE JSON to a harness Event. + + harness-api's HTTP handler emits SPI canonical events + (``event_id``, ``type``, ``data``). grpc-gateway streaming uses a + ``{result, error}`` envelope — accept both. + """ + if chunk.get("result") is not None: + return chunk["result"] + if chunk.get("event_id") and chunk.get("type"): + return chunk + return None + + +def _quote(value: str) -> str: + return quote(str(value), safe="") + + +def _response_body_text(response) -> str: + try: + if hasattr(response, "read"): + try: + response.read() + except Exception: # noqa: BLE001 + pass + body = response.text() if hasattr(response, "text") else response.body() + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + return body or "" + except Exception: # noqa: BLE001 — best-effort error detail for callers + return "" + + +def _raise_agents_http_error(response) -> None: + body = _response_body_text(response) + map_error( + status_code=response.status_code, + response=response, + error_map=_ERROR_MAP, + ) + message = body.strip() or getattr(response, "reason", None) or "request failed" + raise HttpResponseError(message=message, response=response) + + +class HistoryPage(NamedTuple): + """One backward page of session history. + + ``next_before`` is the cursor to pass as ``before`` for the page before + this one; it is ``None`` when the page came back empty. + """ + + events: List[Any] + has_more: Optional[bool] + next_before: Optional[str] + + +class HarnessEventStream: + """Unwraps grpc-gateway SSE envelopes ``{result, error}`` into harness Events.""" + + def __init__(self, sse_stream: SSEStream): + self._sse = sse_stream + self.oldest_event_id: Optional[str] = None + + @property + def has_more(self) -> Optional[bool]: + """Whether older history remains, per the server's trailing comment. + + Only history pages (``before=``) carry this; ``None`` until the + ``: has_more=...`` frame arrives, so read it after iterating. + """ + return getattr(self._sse, "has_more", None) + + def __iter__(self) -> Iterator[Any]: + for chunk in self._sse: + if not isinstance(chunk, dict): + continue + if chunk.get("error"): + err = chunk["error"] + raise HarnessStreamError( + grpc_code=err.get("grpc_code"), + http_code=err.get("http_code"), + message=err.get("message") or "stream error", + http_status=err.get("http_status"), + details=err.get("details") or [], + ) + event = _unwrap_harness_sse_chunk(chunk) + if event is not None: + if self.oldest_event_id is None: + event_id = _field(event, "event_id") + if event_id: + self.oldest_event_id = str(event_id) + yield event + + def close(self) -> None: + self._sse.close() + + def __enter__(self) -> "HarnessEventStream": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + +class HarnessStreamError(RuntimeError): + """SSE stream error frame from harness-api.""" + + def __init__( + self, + *, + grpc_code: Optional[int], + http_code: Optional[int], + message: str, + http_status: Optional[str] = None, + details: Optional[List[Any]] = None, + ): + self.grpc_code = grpc_code + self.http_code = http_code + self.http_status = http_status + self.details = details or [] + super().__init__(message) + + +class SessionsOperations: + """Hosted Agents session REST operations.""" + + def __init__(self, base_url_proxy): + self._client = base_url_proxy + + def _send( + self, + method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + content: Optional[Any] = None, + content_type: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + stream: bool = False, + ): + headers = {"Accept": "application/json", **(headers or {})} + kwargs: Dict[str, Any] = {"headers": headers} + if params: + kwargs["params"] = { + k: v for k, v in params.items() if v is not None and v != "" + } + if body is not None: + headers["Content-Type"] = "application/json" + kwargs["json"] = body + elif content is not None: + if content_type: + headers["Content-Type"] = content_type + kwargs["content"] = content + + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request, stream=stream) + response = pipeline_response.http_response + + if response.status_code not in _OK_STATUS: + _raise_agents_http_error(response) + return pipeline_response + + @staticmethod + def _parse_json(pipeline_response) -> Any: + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if not body: + return None + if isinstance(body, bytes): + body = body.decode("utf-8") + return _wrap(_json.loads(body)) + + def list( + self, + *, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + status: Optional[str] = None, + name: Optional[str] = None, + ) -> Any: + """List sessions, optionally filtered by ``status`` and/or ``name``. + + ``name`` filters server-side (``GET /v2/agents/sessions?name=...``) and + may match more than one session (e.g. a name reused over time). + """ + return self._parse_json( + self._send( + "GET", + _BASE_PATH, + params={ + "page_token": page_token, + "page_size": page_size, + "status": status, + "name": name, + }, + ), + ) + + def create_from_manifest(self, manifest: Union[str, bytes]) -> Any: + """Create a session from an ``agents.yaml`` manifest. + + This is the supported creation path: the manifest defines everything + about the session (runtime adapter, sandbox, env vars, egress). It is + uploaded verbatim as ``application/x-yaml`` and the server owns parsing + and validation. There are no ``agent_kind``/``repo_hint`` arguments. + + :param manifest: The agent spec as a YAML ``str`` or ``bytes`` document. + """ + data = _manifest_bytes(manifest) + return self._parse_json( + self._send( + "POST", + _BASE_PATH, + content=data, + content_type=_YAML_MEDIA_TYPE, + ), + ) + + def get(self, session_id: str) -> Any: + return self._parse_json( + self._send("GET", f"{_BASE_PATH}/{_quote(session_id)}"), + ) + + def destroy(self, session_id: str) -> None: + self._send("DELETE", f"{_BASE_PATH}/{_quote(session_id)}") + + def pause(self, session_id: str) -> Any: + """Pause a running session (``POST .../{session_id}/pause``).""" + return self._parse_json( + self._send("POST", f"{_BASE_PATH}/{_quote(session_id)}/pause"), + ) + + def resume(self, session_id: str) -> Any: + """Resume a paused session (``POST .../{session_id}/resume``).""" + return self._parse_json( + self._send("POST", f"{_BASE_PATH}/{_quote(session_id)}/resume"), + ) + + def send_input(self, session_id: str, *, text: str) -> Any: + return self._parse_json( + self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/input", + body={"text": text}, + ), + ) + + def resolve_hitl( + self, + session_id: str, + request_id: str, + *, + outcome: str, + reason: Optional[str] = None, + source: Optional[str] = None, + ) -> None: + body: Dict[str, Any] = {"outcome": outcome} + if reason is not None: + body["reason"] = reason + if source is not None: + body["source"] = source + self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/hitl/{_quote(request_id)}", + body=body, + ) + + def start_oauth_flow( + self, + session_id: str, + provider: str, + *, + requested_scopes: Optional[List[str]] = None, + ) -> Any: + body: Dict[str, Any] = {} + if requested_scopes is not None: + body["requested_scopes"] = list(requested_scopes) + return self._parse_json( + self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/oauth/{_quote(provider)}", + body=body, + ), + ) + + def stream( + self, + session_id: str, + *, + replay_from: Optional[str] = None, + replay_only: bool = False, + before: Optional[str] = None, + limit: Optional[int] = None, + ) -> HarnessEventStream: + """Attach to a session's SSE event feed. + + A cursorless attach replays only the newest events the server keeps + within its replay budget, then goes live — it is not the session's + full history. Older history is read a page at a time with ``before``, + an ``event_id`` to page backwards from (exclusive): the server sends + up to ``limit`` older events, oldest-first, then closes without going + live. ``before`` implies ``replay_only``, which the server requires. + + Prefer :meth:`history_page` for scrollback; it drains one page and + hands back the next cursor. + """ + if limit is not None: + if before is None: + raise ValueError("limit is only meaningful together with before") + if int(limit) < 1: + raise ValueError("limit must be a positive integer") + + params: Dict[str, Any] = {} + if replay_from: + params["replay_from"] = replay_from + if before: + params["before"] = before + replay_only = True + if limit is not None: + params["limit"] = int(limit) + if replay_only: + params["replay_only"] = "true" + + request = HttpRequest( + "GET", + f"{_BASE_PATH}/{_quote(session_id)}/stream", + headers={"Accept": "text/event-stream, application/json"}, + params=params, + ) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request, stream=True) + response = pipeline_response.http_response + if response.status_code != 200: + _raise_agents_http_error(response) + return HarnessEventStream(SSEStream(response)) + + def history_page( + self, + session_id: str, + *, + before: str, + limit: Optional[int] = None, + ) -> HistoryPage: + """Read one page of history older than ``before``, oldest-first. + + Walk backwards by feeding ``next_before`` into the next call:: + + cursor = oldest_event_id_you_hold + while cursor: + page = sessions.history_page(session_id, before=cursor) + older = page.events + older + cursor = page.next_before if page.has_more else None + """ + if not before: + raise ValueError("before is required") + stream = self.stream(session_id, before=before, limit=limit) + with stream: + events = list(stream) + return HistoryPage( + events=events, + has_more=stream.has_more, + next_before=stream.oldest_event_id, + ) + + def _transfers_path(self, session_id: str, *parts: str) -> str: + path = f"{_BASE_PATH}/{_quote(session_id)}/{_TRANSFERS_SUFFIX}" + for part in parts: + path = f"{path}/{_quote(part)}" + return path + + def create_transfer( + self, + session_id: str, + *, + direction: str, + path: str, + is_archive: bool = False, + as_archive: bool = False, + size_bytes: Optional[int] = None, + sha256: Optional[str] = None, + ) -> Any: + """Start a staged workspace transfer (``POST .../workspace/transfers``). + + ``direction`` is ``"upload"`` or ``"download"``. Upload responses are + ``201`` and include ``part_size``; download responses are ``202``. + """ + if not path: + raise ValueError("path is required") + if direction not in ("upload", "download"): + raise ValueError('direction must be "upload" or "download"') + body: Dict[str, Any] = {"direction": direction, "path": path} + if direction == "upload": + body["is_archive"] = bool(is_archive) + if size_bytes is not None: + body["size_bytes"] = int(size_bytes) + if sha256 is not None: + body["sha256"] = sha256 + else: + body["as_archive"] = bool(as_archive) + return self._parse_json( + self._send("POST", self._transfers_path(session_id), body=body), + ) + + def create_part_upload_urls( + self, + session_id: str, + transfer_id: str, + *, + part_numbers: List[int], + ) -> Any: + """Get presigned URLs for one or more upload parts (upload only). + + Request body is ``{"part_numbers": [1, 2, ...]}``. The response includes + ``part_urls``: ``[{"part_number": N, "upload_url": "..."}, ...]``. + """ + numbers = [int(n) for n in part_numbers] + if not numbers or any(n < 1 for n in numbers): + raise ValueError("part_numbers must be a non-empty list of integers >= 1") + return self._parse_json( + self._send( + "POST", + self._transfers_path(session_id, transfer_id, "part-upload-urls"), + body={"part_numbers": numbers}, + ), + ) + + def create_part_upload_url( + self, + session_id: str, + transfer_id: str, + *, + part_number: int, + ) -> Any: + """Convenience wrapper: request a single part URL via the batch endpoint. + + Returns the matching ``part_urls`` entry (``part_number`` + ``upload_url``). + """ + resp = self.create_part_upload_urls( + session_id, transfer_id, part_numbers=[part_number] + ) + urls = _field(resp, "part_urls") or [] + for entry in urls: + if int(_field(entry, "part_number") or 0) == int(part_number): + return entry + if len(urls) == 1: + return urls[0] + raise WorkspaceTransferError( + f"CreatePartUploadURL response missing part_number={part_number}" + ) + + def commit_upload( + self, + session_id: str, + transfer_id: str, + *, + sha256: Optional[str] = None, + ) -> Any: + """Finalize uploaded parts and start applying them into the workspace.""" + body: Dict[str, Any] = {} + if sha256 is not None: + body["sha256"] = sha256 + return self._parse_json( + self._send( + "POST", + self._transfers_path(session_id, transfer_id, "commit"), + body=body, + ), + ) + + def get_transfer(self, session_id: str, transfer_id: str) -> Any: + """Poll transfer status; downloads expose ``download_url`` + ``sha256``.""" + return self._parse_json( + self._send("GET", self._transfers_path(session_id, transfer_id)), + ) + + def cancel_transfer( + self, + session_id: str, + transfer_id: str, + *, + reason: Optional[str] = None, + ) -> Any: + """Abort an in-flight transfer (idempotent).""" + body: Dict[str, Any] = {} + if reason is not None: + body["reason"] = reason + return self._parse_json( + self._send( + "POST", + self._transfers_path(session_id, transfer_id, "cancel"), + body=body, + ), + ) + + def wait_transfer( + self, + session_id: str, + transfer_id: str, + *, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> Any: + """Poll :meth:`get_transfer` until ``completed`` or ``failed``.""" + deadline = time.monotonic() + timeout + while True: + info = self.get_transfer(session_id, transfer_id) + status = _field(info, "status") + if status in ("completed", "failed"): + if status == "failed": + message = _field(info, "error_message") or "transfer failed" + raise WorkspaceTransferError(str(message)) + return info + if time.monotonic() >= deadline: + raise WorkspaceTransferError( + f"transfer {transfer_id!r} timed out after {timeout:g}s " + f"(last status={status!r})" + ) + time.sleep(max(poll_interval, 0.05)) + + def workspace_upload( + self, + session_id: str, + *, + path: str, + data: UploadData, + is_archive: bool = False, + content_sha256: Optional[str] = None, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> Any: + """Upload a file/tar into the workspace via staged transfers. + + Uses ``CreateTransfer`` → part PUTs to Spaces → ``CommitUpload`` → + poll ``GetTransfer`` for all sizes (up to 50 GiB). ``data`` is bytes, a + filesystem path, or a seekable binary stream. Returns the completed + transfer record (includes ``bytes_written`` / ``sha256`` when present). + """ + if not path: + raise ValueError("path is required") + content, size, handle = _coerce_upload_content(data) + try: + if size > _MAX_TRANSFER_BYTES: + raise ValueError( + f"upload of {size} bytes exceeds the 50 GiB transfer limit" + ) + created = self.create_transfer( + session_id, + direction="upload", + path=path, + is_archive=is_archive, + size_bytes=size, + sha256=content_sha256, + ) + transfer_id = _field(created, "transfer_id") + part_size = int(_field(created, "part_size") or 0) + if not transfer_id: + raise WorkspaceTransferError("CreateTransfer response missing transfer_id") + if part_size < 1: + raise WorkspaceTransferError( + "CreateTransfer response missing a positive part_size" + ) + + hasher = hashlib.sha256() + if size == 0: + # Still need at least one empty part URL? Skip parts; commit only. + part_url_by_number: Dict[int, str] = {} + else: + num_parts = (size + part_size - 1) // part_size + part_numbers = list(range(1, num_parts + 1)) + batch = self.create_part_upload_urls( + session_id, transfer_id, part_numbers=part_numbers + ) + part_url_by_number = {} + for entry in _field(batch, "part_urls") or []: + n = int(_field(entry, "part_number") or 0) + url = _field(entry, "upload_url") + if n and url: + part_url_by_number[n] = str(url) + missing = [n for n in part_numbers if n not in part_url_by_number] + if missing: + raise WorkspaceTransferError( + f"CreatePartUploadURL missing upload_url for parts {missing}" + ) + + offset = 0 + part_number = 1 + while offset < size: + length = min(part_size, size - offset) + if isinstance(content, (bytes, bytearray)): + chunk = bytes(content[offset : offset + length]) + else: + chunk = _read_exact(content, length) + hasher.update(chunk) + upload_url = part_url_by_number[part_number] + _http_put_bytes(upload_url, chunk) + offset += length + part_number += 1 + + digest = content_sha256 or hasher.hexdigest() + self.commit_upload(session_id, transfer_id, sha256=digest) + completed = self.wait_transfer( + session_id, + transfer_id, + poll_interval=poll_interval, + timeout=timeout, + ) + if _field(completed, "path") is None and hasattr(completed, "__setitem__"): + completed["path"] = path + if _field(completed, "bytes_written") is None and hasattr( + completed, "__setitem__" + ): + completed["bytes_written"] = size + return completed + except Exception: + # Best-effort cancel if we already created a transfer. + transfer_id = locals().get("transfer_id") + if transfer_id: + try: + self.cancel_transfer(session_id, transfer_id, reason="client_error") + except Exception: # noqa: BLE001 + pass + raise + finally: + if handle is not None: + handle.close() + + def workspace_download( + self, + session_id: str, + *, + path: str, + as_archive: bool = False, + require_checksum: bool = False, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> "WorkspaceDownload": + """Download a workspace file/tar via staged transfers. + + Uses ``CreateTransfer`` (download) → poll ``GetTransfer`` → GET the + presigned ``download_url``, verifying ``sha256`` from the JSON status. + """ + if not path: + raise ValueError("path is required") + created = self.create_transfer( + session_id, + direction="download", + path=path, + as_archive=as_archive, + ) + transfer_id = _field(created, "transfer_id") + if not transfer_id: + raise WorkspaceTransferError("CreateTransfer response missing transfer_id") + try: + completed = self.wait_transfer( + session_id, + transfer_id, + poll_interval=poll_interval, + timeout=timeout, + ) + except Exception: + try: + self.cancel_transfer(session_id, transfer_id, reason="client_error") + except Exception: # noqa: BLE001 + pass + raise + download_url = _field(completed, "download_url") + if not download_url: + raise WorkspaceTransferError( + "completed download transfer is missing download_url" + ) + size_hint = _field(completed, "bytes_written") + try: + size_hint = int(size_hint) if size_hint is not None else None + except (TypeError, ValueError): + size_hint = None + return WorkspaceDownload( + download_url=str(download_url), + expected_sha256=_field(completed, "sha256"), + size_hint=size_hint, + is_archive=as_archive, + require_checksum=require_checksum, + transfer_id=str(transfer_id), + ) + + +class WorkspaceDownload: + """Streaming download from a completed staged transfer's ``download_url``. + + Iterating yields body chunks while computing SHA-256; integrity is verified + against the digest from :meth:`SessionsOperations.get_transfer` once the + body is fully consumed. Consume fully (iteration, :meth:`read`, or + :meth:`save`) before trusting the output. + """ + + def __init__( + self, + *, + download_url: str, + expected_sha256: Optional[str] = None, + size_hint: Optional[int] = None, + is_archive: bool = False, + require_checksum: bool = False, + transfer_id: Optional[str] = None, + ): + self._download_url = download_url + self._expected_sha256 = expected_sha256 + self._size_hint = size_hint + self._is_archive = bool(is_archive) + self._require_checksum = require_checksum + self.transfer_id = transfer_id + self.bytes_read = 0 + + @property + def is_archive(self) -> bool: + """Whether the caller requested a tar archive download.""" + return self._is_archive + + @property + def size_hint(self) -> Optional[int]: + """``bytes_written`` from the completed transfer, when known.""" + return self._size_hint + + @property + def expected_sha256(self) -> Optional[str]: + """SHA-256 from the completed transfer response (verify after download).""" + return self._expected_sha256 + + def __iter__(self) -> Iterator[bytes]: + hasher = hashlib.sha256() + total = 0 + for chunk in _http_get_iter(self._download_url): + hasher.update(chunk) + total += len(chunk) + yield chunk + self.bytes_read = total + _verify_transfer_sha256( + hasher.hexdigest(), + self._expected_sha256, + total_bytes=total, + size_hint=self._size_hint, + require_checksum=self._require_checksum, + ) + + def read(self) -> bytes: + """Consume the whole body and return the (verified) bytes.""" + return b"".join(self) + + def save(self, dest: Union[str, "os.PathLike[str]", BinaryIO]) -> int: + """Stream the body to *dest* (a path or writable binary file). + + Returns the number of bytes written. If verification fails, a + file opened from a path is removed before re-raising. + """ + own = isinstance(dest, (str, os.PathLike)) + handle = open(os.fspath(dest), "wb") if own else dest # type: ignore[arg-type] + total = 0 + try: + for chunk in self: + handle.write(chunk) + total += len(chunk) + except WorkspaceTransferError: + if own: + handle.close() + try: + os.remove(os.fspath(dest)) # type: ignore[arg-type] + except OSError: + pass + raise + finally: + if own and not handle.closed: + handle.close() + return total + + def close(self) -> None: + return None + + def __enter__(self) -> "WorkspaceDownload": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + +__all__ = [ + "SessionsOperations", + "HarnessEventStream", + "HarnessStreamError", + "HistoryPage", + "WorkspaceDownload", + "WorkspaceTransferError", + "UploadData", +] diff --git a/src/pydo/agents/custom_triggers.py b/src/pydo/agents/custom_triggers.py new file mode 100644 index 00000000..16168d22 --- /dev/null +++ b/src/pydo/agents/custom_triggers.py @@ -0,0 +1,251 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Sync Hosted Agents trigger operations (``/v2/agents/triggers/...``). + +Hand-written against harness-trigger's public contract +(``contracts/trigger.swagger.json``). Preserved across ``make generate``. +""" + +from __future__ import annotations + +import json as _json +from typing import Any, Dict, Optional +from urllib.parse import quote + +from azure.core.rest import HttpRequest + +from pydo.agents.custom_sessions import _OK_STATUS, _raise_agents_http_error +from pydo.custom_extensions import _wrap + +_TRIGGERS_PATH = "/v2/agents/triggers" +_WEBHOOK_PROVIDERS_PATH = "/v2/agents/webhook-providers" + + +def _quote(value: str) -> str: + return quote(str(value), safe="") + + +class TriggersOperations: + """Hosted Agents trigger REST operations (team-scoped Config API). + + Covers webhook & cron trigger CRUD, secret rotation, execution history, + reusable-session listing, and the webhook-provider registry. + + The public webhook ingress + (``POST /v2/agents/triggers/{id}/webhook``) is intentionally omitted — + it is authenticated by the per-trigger HMAC secret, not a DO bearer token, + and is meant for external systems rather than SDK callers. + """ + + def __init__(self, base_url_proxy): + self._client = base_url_proxy + + def _send( + self, + method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ): + headers = {"Accept": "application/json", **(headers or {})} + kwargs: Dict[str, Any] = {"headers": headers} + if params: + kwargs["params"] = { + k: v for k, v in params.items() if v is not None and v != "" + } + if body is not None: + headers["Content-Type"] = "application/json" + kwargs["json"] = body + + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request, stream=False) + response = pipeline_response.http_response + + if response.status_code not in _OK_STATUS: + _raise_agents_http_error(response) + return pipeline_response + + @staticmethod + def _parse_json(pipeline_response) -> Any: + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if not body: + return None + if isinstance(body, bytes): + body = body.decode("utf-8") + return _wrap(_json.loads(body)) + + # ------------------------------------------------------------------ + # Triggers CRUD + # ------------------------------------------------------------------ + + def list( + self, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + kind: Optional[str] = None, + status: Optional[str] = None, + ) -> Any: + """List the calling team's triggers (``GET /v2/agents/triggers``). + + Keyset-paginated. Soft-deleted triggers are excluded. Optional + ``kind`` (``webhook`` / ``cron``) and ``status`` (``active`` / + ``paused``) filters are applied server-side. + """ + return self._parse_json( + self._send( + "GET", + _TRIGGERS_PATH, + params={ + "page_size": page_size, + "page_token": page_token, + "kind": kind, + "status": status, + }, + ), + ) + + def create(self, body: Dict[str, Any]) -> Any: + """Create a webhook or cron trigger (``POST /v2/agents/triggers``). + + For webhook triggers the response includes ``webhook_secret`` exactly + once — it is never returned again. Cron triggers return no secret. + + See the CreateTrigger contract for the conditional-requirement matrix + (kind/block match, session_mode, output mode, etc.). + """ + if not isinstance(body, dict) or not body: + raise ValueError("body must be a non-empty dict") + return self._parse_json( + self._send("POST", _TRIGGERS_PATH, body=body), + ) + + def get(self, trigger_id: str) -> Any: + """Get a single trigger by id (``GET /v2/agents/triggers/{id}``).""" + return self._parse_json( + self._send("GET", f"{_TRIGGERS_PATH}/{_quote(trigger_id)}"), + ) + + def update(self, trigger_id: str, body: Dict[str, Any]) -> Any: + """Partial-update a trigger (``PATCH /v2/agents/triggers/{id}``). + + Only supplied fields change. Pause / re-enable by sending + ``{"status": "paused"}`` or ``{"status": "active"}``. + ``kind`` and ``webhook.provider`` are immutable. + """ + if not isinstance(body, dict) or not body: + raise ValueError("body must be a non-empty dict") + return self._parse_json( + self._send( + "PATCH", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}", + body=body, + ), + ) + + def delete(self, trigger_id: str) -> None: + """Soft-delete a trigger (``DELETE /v2/agents/triggers/{id}``). + + Returns ``204`` with no body. A reuse trigger only unbinds — it never + destroys the customer's session. + """ + self._send("DELETE", f"{_TRIGGERS_PATH}/{_quote(trigger_id)}") + + def rotate_secret(self, trigger_id: str) -> Any: + """Issue a new webhook secret (``POST .../{id}/rotate-secret``). + + Webhook triggers only (``409`` for cron). The new secret is shown + once; the previous value stays valid briefly for in-flight deliveries. + """ + return self._parse_json( + self._send( + "POST", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}/rotate-secret", + ), + ) + + # ------------------------------------------------------------------ + # Executions + # ------------------------------------------------------------------ + + def list_executions( + self, + trigger_id: str, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + status: Optional[str] = None, + ) -> Any: + """List a trigger's execution history (keyset-paginated). + + Large ``payload`` / ``output_text`` fields are omitted from list items; + use :meth:`get_execution` to read them. + """ + return self._parse_json( + self._send( + "GET", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}/executions", + params={ + "page_size": page_size, + "page_token": page_token, + "status": status, + }, + ), + ) + + def get_execution(self, trigger_id: str, execution_id: str) -> Any: + """Get a single execution, including payload and output text.""" + return self._parse_json( + self._send( + "GET", + ( + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}" + f"/executions/{_quote(execution_id)}" + ), + ), + ) + + # ------------------------------------------------------------------ + # Lookups & helpers + # ------------------------------------------------------------------ + + def get_by_session(self, session_id: str) -> Any: + """Reverse-look-up the trigger that produced or binds a session.""" + return self._parse_json( + self._send( + "GET", + f"{_TRIGGERS_PATH}/by-session/{_quote(session_id)}", + ), + ) + + def list_reusable_sessions( + self, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + ) -> Any: + """List the team's PAUSED sessions for the reuse-mode picker.""" + return self._parse_json( + self._send( + "GET", + f"{_TRIGGERS_PATH}/reusable-sessions", + params={ + "page_size": page_size, + "page_token": page_token, + }, + ), + ) + + def list_webhook_providers(self) -> Any: + """List supported webhook providers for the create-trigger UI. + + Static registry (not a database table). Cron triggers have no + provider and never call this. + """ + return self._parse_json(self._send("GET", _WEBHOOK_PROVIDERS_PATH)) diff --git a/src/pydo/agents/session.py b/src/pydo/agents/session.py new file mode 100644 index 00000000..de9760c7 --- /dev/null +++ b/src/pydo/agents/session.py @@ -0,0 +1,467 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""High-level, self-contained agent interface over the Hosted Agents session API. + +The low-level :class:`~pydo.agents.custom_sessions.SessionsOperations` mirrors the +REST endpoints one-to-one, which means consuming a run requires hand-wiring the +SSE feed: spawn a reader, dispatch on raw ``run.*`` event strings, accumulate +token deltas, resolve HITL prompts, and coordinate completion. + +This module wraps that into an ergonomic surface inspired by +``openai-agents-python``:: + + with client.agents.start(manifest) as agent: # create + auto-destroy + result = agent.run("Summarize the repo") # blocking + print(result.final_output) + + for event in agent.run_streamed("Now add tests"): # streamed, typed + if event.type == AgentEventType.TOKEN: + print(event.text, end="") + +No threads, no raw event-string matching, no manual teardown. +""" +from __future__ import annotations + +import time +from typing import Any, Callable, Dict, List, Optional, Union + +from .custom_models import HITLOutcome, ResolutionSource, SessionStatus + +# Normalized event type -> raw SPI ``type`` it maps from. +_RAW_TO_TYPE = { + "run.started": "run_started", + "run.token_delta": "token", + "run.tool_call_started": "tool_call", + "run.tool_call_completed": "tool_result", + "run.human_input_requested": "hitl_requested", + "run.human_input_received": "hitl_resolved", + "run.completed": "completed", + "run.failed": "failed", +} + +_HITL_OUTCOMES = { + "approve": HITLOutcome.APPROVE, + "reject": HITLOutcome.REJECT, + "defer": HITLOutcome.DEFER, +} + +# A HITL policy is either a fixed decision ("approve"/"reject"/"defer"), an +# explicit HITLOutcome constant, a callable mapping an event -> decision, or +# None to leave prompts unresolved for the caller to handle. +HITLPolicy = Union[str, Callable[["AgentEvent"], Optional[str]], None] + + +class AgentEventType: + """Normalized, friendly event kinds yielded by a run stream.""" + + RUN_STARTED = "run_started" + TOKEN = "token" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + HITL_REQUESTED = "hitl_requested" + HITL_RESOLVED = "hitl_resolved" + COMPLETED = "completed" + FAILED = "failed" + OTHER = "other" + + +class AgentEvent: + """A normalized view over a raw harness SSE event. + + Exposes a stable :attr:`type` (see :class:`AgentEventType`) plus typed + accessors, while keeping the underlying event available as :attr:`raw`. + """ + + def __init__(self, raw: Any): + self.raw = raw + get = getattr(raw, "get", None) + self.raw_type: Optional[str] = get("type") if get else None + self.type = _RAW_TO_TYPE.get(self.raw_type or "", AgentEventType.OTHER) + data = get("data") if get else None + self.data = data if data is not None else {} + self.run_id: str = (get("run_id") if get else None) or "" + + def _d(self, key: str, default: Any = None) -> Any: + get = getattr(self.data, "get", None) + return get(key, default) if get else default + + @property + def text(self) -> str: + """Token text for ``TOKEN`` events ("" otherwise).""" + return self._d("text", "") or "" + + @property + def tool_name(self) -> Optional[str]: + """Tool name for ``TOOL_CALL`` events.""" + return self._d("name") + + @property + def request_id(self) -> Optional[str]: + """HITL request id for ``HITL_REQUESTED`` / ``HITL_RESOLVED`` events.""" + return self._d("hitl_id") or self._d("request_id") + + @property + def usage(self) -> Dict[str, Any]: + """Token/cost totals for ``COMPLETED`` events.""" + return { + "tokens_in": self._d("total_tokens_in"), + "tokens_out": self._d("total_tokens_out"), + "cost_micros": self._d("run_cost_micros"), + } + + @property + def error(self) -> Optional[Dict[str, Any]]: + """Failure ``{code, message}`` for ``FAILED`` events.""" + if self.type != AgentEventType.FAILED: + return None + return {"code": self._d("code"), "message": self._d("message")} + + def __repr__(self) -> str: + return f"AgentEvent(type={self.type!r}, run_id={self.run_id!r})" + + +class RunResult: + """The outcome of a single run: assembled output, usage, and raw events.""" + + def __init__( + self, + *, + run_id: Optional[str], + final_output: str, + events: List[AgentEvent], + usage: Dict[str, Any], + status: str, + error: Optional[Dict[str, Any]] = None, + ): + self.run_id = run_id + self.final_output = final_output + self.events = events + self.usage = usage + self.status = status # "completed" | "failed" | "timeout" + self.error = error + + @property + def ok(self) -> bool: + return self.status == "completed" + + def __str__(self) -> str: + return self.final_output + + def __repr__(self) -> str: + return ( + f"RunResult(status={self.status!r}, run_id={self.run_id!r}, " + f"chars={len(self.final_output)})" + ) + + +def _decide_hitl(policy: HITLPolicy, event: AgentEvent) -> Optional[str]: + """Resolve a HITL policy to a concrete outcome constant (or None).""" + decision: Any = policy(event) if callable(policy) else policy + if not decision: + return None + if isinstance(decision, str): + return _HITL_OUTCOMES.get(decision.lower(), decision) + return decision + + +class RunStream: + """Iterable of :class:`AgentEvent` for one run. + + Iterating yields normalized events, auto-resolves HITL prompts per the + configured policy, and accumulates the assembled output. After iteration, + :attr:`final_output`, :attr:`usage`, :attr:`status`, and :attr:`result` + are populated. + """ + + def __init__( + self, + *, + raw_stream: Any, + run_id: Optional[str], + session: "AgentSession", + hitl: HITLPolicy = "approve", + timeout: Optional[float] = None, + ): + self._raw = raw_stream + self.run_id = run_id + self._session = session + self._hitl = hitl + self._timeout = timeout + self._chunks: List[str] = [] + self.events: List[AgentEvent] = [] + self.usage: Dict[str, Any] = {} + self.status = "running" + self.error: Optional[Dict[str, Any]] = None + + def __iter__(self): + deadline = time.monotonic() + self._timeout if self._timeout else None + try: + for raw in self._raw: + event = AgentEvent(raw) + self.events.append(event) + + if event.type == AgentEventType.TOKEN: + self._chunks.append(event.text) + elif event.type == AgentEventType.HITL_REQUESTED: + self._auto_resolve(event) + + yield event + + if self._is_terminal(event): + break + if deadline and time.monotonic() > deadline: + self.status = "timeout" + break + finally: + self.close() + + def _auto_resolve(self, event: AgentEvent) -> None: + outcome = _decide_hitl(self._hitl, event) + if not outcome or not event.request_id: + return + try: + self._session.resolve_hitl( + event.request_id, + outcome=outcome, + source=ResolutionSource.OUT_OF_BAND, + ) + except Exception: # noqa: BLE001 - best-effort; surfaced via the feed + pass + + def _is_terminal(self, event: AgentEvent) -> bool: + if self.run_id and event.run_id and event.run_id != self.run_id: + return False + if event.type == AgentEventType.COMPLETED: + self.usage = event.usage + self.status = "completed" + return True + if event.type == AgentEventType.FAILED: + self.error = event.error + self.status = "failed" + return True + return False + + @property + def final_output(self) -> str: + return "".join(self._chunks).strip() + + @property + def result(self) -> RunResult: + return RunResult( + run_id=self.run_id, + final_output=self.final_output, + events=self.events, + usage=self.usage, + status=self.status, + error=self.error, + ) + + def close(self) -> None: + closer = getattr(self._raw, "close", None) + if closer: + try: + closer() + except Exception: # noqa: BLE001 + pass + + def __enter__(self) -> "RunStream": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + +class AgentSession: + """A self-managing handle to one hosted-agent session. + + Wraps :class:`~pydo.agents.custom_sessions.SessionsOperations`, binding the + ``session_id`` so callers never re-pass it, and adds :meth:`run` / + :meth:`run_streamed`. Use as a context manager to auto-destroy on exit. + """ + + def __init__(self, sessions: Any, session_id: str, *, raw: Any = None): + self._sessions = sessions + self.session_id = session_id + self._raw = raw + + @property + def sessions(self) -> Any: + """The underlying low-level operations object.""" + return self._sessions + + @property + def info(self) -> Any: + """The most recent session object (``{...}``), if known.""" + raw = self._raw + if raw is None: + return None + get = getattr(raw, "get", None) + inner = get("session") if get else None + return inner if inner is not None else raw + + @property + def status(self) -> Optional[str]: + info = self.info + get = getattr(info, "get", None) + return get("status") if get else None + + def refresh(self) -> Any: + """Fetch and cache the latest session state.""" + self._raw = self._sessions.get(self.session_id) + return self.info + + def wait_until_ready( + self, *, timeout: float = 120.0, poll_interval: float = 2.0 + ) -> "AgentSession": + """Block until the session reports ``READY`` (or raise).""" + deadline = time.monotonic() + timeout + while True: + status = (getattr(self.refresh(), "get", lambda *_: None))("status") + if status == SessionStatus.READY: + return self + if status in (SessionStatus.FAILED, SessionStatus.DESTROYED): + raise RuntimeError(f"session {self.session_id} is {status}") + if time.monotonic() > deadline: + raise TimeoutError( + f"session {self.session_id} not ready after {timeout}s" + ) + time.sleep(poll_interval) + + def run_streamed( + self, + prompt: str, + *, + hitl: HITLPolicy = "approve", + timeout: Optional[float] = 300.0, + ) -> RunStream: + """Send ``prompt`` and return a :class:`RunStream` of typed events. + + The SSE subscription is opened *before* the input is submitted, so no + early events are missed — no caller-managed thread required. + """ + raw_stream = self._sessions.stream(self.session_id) + run = self._sessions.send_input(self.session_id, text=prompt) + run_id = (getattr(run, "get", lambda *_: None))("run_id") + return RunStream( + raw_stream=raw_stream, + run_id=run_id, + session=self, + hitl=hitl, + timeout=timeout, + ) + + def run( + self, + prompt: str, + *, + hitl: HITLPolicy = "approve", + timeout: Optional[float] = 300.0, + ) -> RunResult: + """Send ``prompt`` and block until the run finishes, returning a result.""" + stream = self.run_streamed(prompt, hitl=hitl, timeout=timeout) + for _ in stream: + pass + return stream.result + + # --- thin passthroughs (session id bound) --------------------------- + def send_input(self, text: str) -> Any: + return self._sessions.send_input(self.session_id, text=text) + + def pause(self) -> Any: + return self._sessions.pause(self.session_id) + + def resume(self) -> Any: + return self._sessions.resume(self.session_id) + + def stream(self, **kwargs: Any) -> Any: + return self._sessions.stream(self.session_id, **kwargs) + + def history(self, *, before: str, limit: Optional[int] = None) -> Any: + """Read one page of history older than ``before``. + + See :meth:`SessionsOperations.history_page`. + """ + return self._sessions.history_page(self.session_id, before=before, limit=limit) + + def resolve_hitl( + self, + request_id: str, + *, + outcome: str, + reason: Optional[str] = None, + source: Optional[str] = None, + ) -> None: + self._sessions.resolve_hitl( + self.session_id, + request_id, + outcome=outcome, + reason=reason, + source=source, + ) + + def upload_file( + self, + *, + path: str, + data: Any, + is_archive: bool = False, + content_sha256: Optional[str] = None, + poll_interval: float = 1.0, + timeout: float = 600.0, + ) -> Any: + """Upload bytes/a tar into the session workspace via staged transfers.""" + return self._sessions.workspace_upload( + self.session_id, + path=path, + data=data, + is_archive=is_archive, + content_sha256=content_sha256, + poll_interval=poll_interval, + timeout=timeout, + ) + + def download_file( + self, + *, + path: str, + as_archive: bool = False, + require_checksum: bool = False, + poll_interval: float = 1.0, + timeout: float = 600.0, + ) -> Any: + """Download a workspace file/tar via staged transfers.""" + return self._sessions.workspace_download( + self.session_id, + path=path, + as_archive=as_archive, + require_checksum=require_checksum, + poll_interval=poll_interval, + timeout=timeout, + ) + + def destroy(self) -> None: + self._sessions.destroy(self.session_id) + + def __enter__(self) -> "AgentSession": + return self + + def __exit__(self, *args: Any) -> None: + try: + self.destroy() + except Exception: # noqa: BLE001 - teardown best-effort + pass + + def __repr__(self) -> str: + return f"AgentSession(session_id={self.session_id!r})" + + +__all__ = [ + "AgentEvent", + "AgentEventType", + "AgentSession", + "RunResult", + "RunStream", + "HITLPolicy", +] diff --git a/src/pydo/aio/_client.py b/src/pydo/aio/_client.py index 6a8b62f4..04057e72 100644 --- a/src/pydo/aio/_client.py +++ b/src/pydo/aio/_client.py @@ -26,6 +26,7 @@ ByoipPrefixesOperations, CdnOperations, CertificatesOperations, + ConnectionsOperations, DatabasesOperations, DedicatedInferencesOperations, DomainsOperations, @@ -54,12 +55,16 @@ ReservedIPv6ActionsOperations, ReservedIPv6Operations, SecurityOperations, + SessionsOperations, SizesOperations, SnapshotsOperations, SpacesKeyOperations, SshKeysOperations, TagsOperations, + ToolbeltsOperations, + ToolsOperations, UptimeOperations, + UsersOperations, VectorDatabasesOperations, VolumeActionsOperations, VolumeSnapshotsOperations, @@ -77,6 +82,16 @@ class GeneratedClient: # pylint: disable=client-accepts-api-version-keyword,too-many-instance-attributes """GeneratedClient. + :ivar tools: ToolsOperations operations + :vartype tools: pydo.aio.operations.ToolsOperations + :ivar toolbelts: ToolbeltsOperations operations + :vartype toolbelts: pydo.aio.operations.ToolbeltsOperations + :ivar connections: ConnectionsOperations operations + :vartype connections: pydo.aio.operations.ConnectionsOperations + :ivar users: UsersOperations operations + :vartype users: pydo.aio.operations.UsersOperations + :ivar sessions: SessionsOperations operations + :vartype sessions: pydo.aio.operations.SessionsOperations :ivar one_clicks: OneClicksOperations operations :vartype one_clicks: pydo.aio.operations.OneClicksOperations :ivar account: AccountOperations operations @@ -211,11 +226,9 @@ def __init__( self._config.custom_hook_policy, self._config.logging_policy, policies.DistributedTracingPolicy(**kwargs), - ( - policies.SensitiveHeaderCleanupPolicy(**kwargs) - if self._config.redirect_policy - else None - ), + policies.SensitiveHeaderCleanupPolicy(**kwargs) + if self._config.redirect_policy + else None, self._config.http_logging_policy, ] self._client: AsyncPipelineClient = AsyncPipelineClient( @@ -225,6 +238,21 @@ def __init__( self._serialize = Serializer() self._deserialize = Deserializer() self._serialize.client_side_validation = False + self.tools = ToolsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.toolbelts = ToolbeltsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.connections = ConnectionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.users = UsersOperations( + self._client, self._config, self._serialize, self._deserialize + ) + self.sessions = SessionsOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.one_clicks = OneClicksOperations( self._client, self._config, self._serialize, self._deserialize ) diff --git a/src/pydo/aio/_patch.py b/src/pydo/aio/_patch.py index 1d317f97..291de8b3 100644 --- a/src/pydo/aio/_patch.py +++ b/src/pydo/aio/_patch.py @@ -6,6 +6,7 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ + from typing import TYPE_CHECKING, Optional from azure.core.credentials import AccessToken @@ -64,6 +65,15 @@ class Client( # type: ignore subdomain (e.g. ``"https://.agents.do-ai.run"``). Required only when using agent inference endpoints. :paramtype agent_endpoint: str + :keyword agents_endpoint: Hosted Agents API base URL (default + ``api.digitalocean.com``; override via ``PYDO_AGENTS_ENDPOINT``). + :keyword gateway_endpoint: Action Gateway base URL (default + ``https://actions.do-ai.run``; preview is + ``https://actions.do-ai-test.run``; override via + ``PYDO_GATEWAY_ENDPOINT``). + :keyword gateway_provider: Provider that formats gateway tools for an + inference surface (default :class:`ChatCompletionsProvider`; also + ``MessagesProvider`` and ``ResponsesProvider`` in ``pydo.gateway``). """ def __init__( @@ -74,6 +84,9 @@ def __init__( timeout: int = 120, inference_endpoint: str = INFERENCE_BASE_URL, agent_endpoint: str = "", + agents_endpoint: Optional[str] = None, + gateway_endpoint: Optional[str] = None, + gateway_provider=None, **kwargs, ): if token is not None and api_key is not None: @@ -117,6 +130,24 @@ def __init__( self.images.generate = inference_images.generate self.images.generations = inference_images.generations + try: + from pydo.aio.agents import AsyncAgentsResources + except ImportError: + self.agents = None + else: + self.agents = AsyncAgentsResources(self, agents_endpoint=agents_endpoint) + + try: + from pydo.aio.gateway import AsyncGatewayResources + except ImportError: + self.gateway = None + else: + self.gateway = AsyncGatewayResources( + self, + gateway_endpoint=gateway_endpoint, + provider=gateway_provider, + ) + def _setup_inference_routing( self, inference_endpoint: str, diff --git a/src/pydo/aio/agents/__init__.py b/src/pydo/aio/agents/__init__.py new file mode 100644 index 00000000..b2738baa --- /dev/null +++ b/src/pydo/aio/agents/__init__.py @@ -0,0 +1,70 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async Hosted Agents API — hand-written; preserved across ``make generate``.""" +from __future__ import annotations + +from typing import Optional + +from pydo.agents import _select_session_by_name, resolve_agents_base_url +from pydo.custom_extensions import _BaseURLProxy + +from .custom_sessions import ( + AsyncHarnessEventStream, + AsyncSessionsOperations, + AsyncWorkspaceDownload, +) +from .custom_triggers import AsyncTriggersOperations +from .session import AsyncAgentSession, AsyncRunStream + + +class AsyncAgentsResources: + def __init__(self, parent_client, *, agents_endpoint: Optional[str] = None): + self._proxy = _BaseURLProxy( + parent_client._client, + resolve_agents_base_url(agents_endpoint), + ) + self.sessions = AsyncSessionsOperations(self._proxy) + self.triggers = AsyncTriggersOperations(self._proxy) + + @property + def base_url(self) -> str: + return self._proxy._base_url + + async def start(self, manifest: "str | bytes") -> AsyncAgentSession: + """Create a session from an ``agents.yaml`` manifest and return a handle. + + Use as ``async with await client.agents.start(manifest) as agent:`` to + auto-destroy on exit. + """ + resp = await self.sessions.create_from_manifest(manifest) + get = getattr(resp, "get", None) + info = get("session") if get else None + session_id = (getattr(info or resp, "get", lambda *_: None))("session_id") + return AsyncAgentSession(self.sessions, session_id, raw=resp) + + def attach(self, session_id: str) -> AsyncAgentSession: + """Return an :class:`AsyncAgentSession` handle for an existing session.""" + return AsyncAgentSession(self.sessions, session_id) + + async def attach_by_name(self, name: str) -> AsyncAgentSession: + """Resolve a session by ``name`` and return an :class:`AsyncAgentSession`. + + See :meth:`pydo.agents.AgentsResources.attach_by_name`. + """ + resp = await self.sessions.list(name=name) + session = _select_session_by_name(resp, name) + session_id = (getattr(session, "get", lambda *_: None))("session_id") + return AsyncAgentSession(self.sessions, session_id, raw=session) + + +__all__ = [ + "AsyncAgentsResources", + "AsyncAgentSession", + "AsyncRunStream", + "AsyncSessionsOperations", + "AsyncTriggersOperations", + "AsyncHarnessEventStream", + "AsyncWorkspaceDownload", +] diff --git a/src/pydo/aio/agents/custom_sessions.py b/src/pydo/aio/agents/custom_sessions.py new file mode 100644 index 00000000..a12d6039 --- /dev/null +++ b/src/pydo/aio/agents/custom_sessions.py @@ -0,0 +1,789 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async Hosted Agents session operations.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json as _json +import os +import time +from typing import Any, AsyncIterator, BinaryIO, Dict, List, Optional, Union +from urllib.parse import quote + +from azure.core.rest import HttpRequest + +from pydo.agents.custom_sessions import ( + _DEFAULT_POLL_INTERVAL, + _DEFAULT_POLL_TIMEOUT, + _DOWNLOAD_CHUNK, + _MAX_TRANSFER_BYTES, + _OCTET_STREAM, + _OK_STATUS, + _TRANSFERS_SUFFIX, + _YAML_MEDIA_TYPE, + HarnessStreamError, + HistoryPage, + UploadData, + WorkspaceTransferError, + _coerce_upload_content, + _field, + _http_get_iter, + _http_put_bytes, + _manifest_bytes, + _raise_agents_http_error, + _unwrap_harness_sse_chunk, + _verify_transfer_sha256, +) +from pydo.custom_extensions import AsyncSSEStream, _wrap + +_BASE_PATH = "/v2/agents/sessions" + + +def _quote(value: str) -> str: + return quote(str(value), safe="") + + +async def _run_sync(func, *args): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: func(*args)) + + +async def _aio_http_put_bytes(url: str, data: bytes) -> None: + try: + import aiohttp + except ImportError: + # Fallback for environments without the aio extra. + await _run_sync(_http_put_bytes, url, data) + return + async with aiohttp.ClientSession() as session: + async with session.put( + url, data=data, headers={"Content-Type": _OCTET_STREAM} + ) as resp: + body = await resp.read() + if resp.status not in (200, 201, 204): + detail = body.decode("utf-8", errors="replace").strip() + raise WorkspaceTransferError( + f"part upload failed: HTTP {resp.status}" + + (f": {detail}" if detail else "") + ) + + +async def _aio_http_get_iter(url: str) -> AsyncIterator[bytes]: + try: + import aiohttp + except ImportError: + chunks = await _run_sync(lambda: list(_http_get_iter(url))) + for chunk in chunks: + yield chunk + return + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status != 200: + detail = (await resp.text()).strip() + raise WorkspaceTransferError( + f"download failed: HTTP {resp.status}" + + (f": {detail}" if detail else "") + ) + async for chunk in resp.content.iter_chunked(_DOWNLOAD_CHUNK): + if chunk: + yield chunk + + +class AsyncHarnessEventStream: + def __init__(self, sse_stream: AsyncSSEStream): + self._sse = sse_stream + self.oldest_event_id: Optional[str] = None + + @property + def has_more(self) -> Optional[bool]: + """Whether older history remains, per the server's trailing comment. + + Only history pages (``before=``) carry this; ``None`` until the + ``: has_more=...`` frame arrives, so read it after iterating. + """ + return getattr(self._sse, "has_more", None) + + def __aiter__(self) -> AsyncIterator[Any]: + return self._iter() + + async def _iter(self) -> AsyncIterator[Any]: + async for chunk in self._sse: + if not isinstance(chunk, dict): + continue + if chunk.get("error"): + err = chunk["error"] + raise HarnessStreamError( + grpc_code=err.get("grpc_code"), + http_code=err.get("http_code"), + message=err.get("message") or "stream error", + http_status=err.get("http_status"), + details=err.get("details") or [], + ) + event = _unwrap_harness_sse_chunk(chunk) + if event is not None: + if self.oldest_event_id is None: + event_id = _field(event, "event_id") + if event_id: + self.oldest_event_id = str(event_id) + yield event + + async def close(self) -> None: + await self._sse.close() + + async def __aenter__(self) -> "AsyncHarnessEventStream": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + +class AsyncSessionsOperations: + def __init__(self, base_url_proxy): + self._client = base_url_proxy + + async def _send( + self, + method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + content: Optional[Any] = None, + content_type: Optional[str] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + stream: bool = False, + ): + headers = {"Accept": "application/json", **(headers or {})} + kwargs: Dict[str, Any] = {"headers": headers} + if params: + kwargs["params"] = { + k: v for k, v in params.items() if v is not None and v != "" + } + if body is not None: + headers["Content-Type"] = "application/json" + kwargs["json"] = body + elif content is not None: + if content_type: + headers["Content-Type"] = content_type + kwargs["content"] = content + + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request, stream=stream) + response = pipeline_response.http_response + + if response.status_code not in _OK_STATUS: + await response.read() + _raise_agents_http_error(response) + return pipeline_response + + @staticmethod + async def _parse_json(pipeline_response) -> Any: + body = await pipeline_response.http_response.read() + if not body: + return None + if isinstance(body, bytes): + body = body.decode("utf-8") + return _wrap(_json.loads(body)) + + async def list( + self, + *, + page_token: Optional[str] = None, + page_size: Optional[int] = None, + status: Optional[str] = None, + name: Optional[str] = None, + ) -> Any: + """List sessions, optionally filtered by ``status`` and/or ``name``. + + ``name`` filters server-side (``GET /v2/agents/sessions?name=...``) and + may match more than one session (e.g. a name reused over time). + """ + return await self._parse_json( + await self._send( + "GET", + _BASE_PATH, + params={ + "page_token": page_token, + "page_size": page_size, + "status": status, + "name": name, + }, + ), + ) + + async def create_from_manifest(self, manifest: Union[str, bytes]) -> Any: + """Create a session from an ``agents.yaml`` manifest. + + This is the supported creation path: the manifest defines everything + about the session (runtime adapter, sandbox, env vars, egress). It is + uploaded verbatim as ``application/x-yaml`` and the server owns parsing + and validation. There are no ``agent_kind``/``repo_hint`` arguments. + + :param manifest: The agent spec as a YAML ``str`` or ``bytes`` document. + """ + data = _manifest_bytes(manifest) + return await self._parse_json( + await self._send( + "POST", + _BASE_PATH, + content=data, + content_type=_YAML_MEDIA_TYPE, + ), + ) + + async def get(self, session_id: str) -> Any: + return await self._parse_json( + await self._send("GET", f"{_BASE_PATH}/{_quote(session_id)}"), + ) + + async def destroy(self, session_id: str) -> None: + await self._send("DELETE", f"{_BASE_PATH}/{_quote(session_id)}") + + async def pause(self, session_id: str) -> Any: + """Pause a running session (``POST .../{session_id}/pause``).""" + return await self._parse_json( + await self._send("POST", f"{_BASE_PATH}/{_quote(session_id)}/pause"), + ) + + async def resume(self, session_id: str) -> Any: + """Resume a paused session (``POST .../{session_id}/resume``).""" + return await self._parse_json( + await self._send("POST", f"{_BASE_PATH}/{_quote(session_id)}/resume"), + ) + + async def send_input(self, session_id: str, *, text: str) -> Any: + return await self._parse_json( + await self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/input", + body={"text": text}, + ), + ) + + async def resolve_hitl( + self, + session_id: str, + request_id: str, + *, + outcome: str, + reason: Optional[str] = None, + source: Optional[str] = None, + ) -> None: + body: Dict[str, Any] = {"outcome": outcome} + if reason is not None: + body["reason"] = reason + if source is not None: + body["source"] = source + await self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/hitl/{_quote(request_id)}", + body=body, + ) + + async def start_oauth_flow( + self, + session_id: str, + provider: str, + *, + requested_scopes: Optional[List[str]] = None, + ) -> Any: + body: Dict[str, Any] = {} + if requested_scopes is not None: + body["requested_scopes"] = list(requested_scopes) + return await self._parse_json( + await self._send( + "POST", + f"{_BASE_PATH}/{_quote(session_id)}/oauth/{_quote(provider)}", + body=body, + ), + ) + + async def stream( + self, + session_id: str, + *, + replay_from: Optional[str] = None, + replay_only: bool = False, + before: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncHarnessEventStream: + """Attach to a session's SSE event feed. + + A cursorless attach replays only the newest events the server keeps + within its replay budget, then goes live — it is not the session's + full history. Older history is read a page at a time with ``before``, + an ``event_id`` to page backwards from (exclusive): the server sends + up to ``limit`` older events, oldest-first, then closes without going + live. ``before`` implies ``replay_only``, which the server requires. + + Prefer :meth:`history_page` for scrollback; it drains one page and + hands back the next cursor. + """ + if limit is not None: + if before is None: + raise ValueError("limit is only meaningful together with before") + if int(limit) < 1: + raise ValueError("limit must be a positive integer") + + params: Dict[str, Any] = {} + if replay_from: + params["replay_from"] = replay_from + if before: + params["before"] = before + replay_only = True + if limit is not None: + params["limit"] = int(limit) + if replay_only: + params["replay_only"] = "true" + + request = HttpRequest( + "GET", + f"{_BASE_PATH}/{_quote(session_id)}/stream", + headers={"Accept": "text/event-stream, application/json"}, + params=params, + ) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request, stream=True) + response = pipeline_response.http_response + if response.status_code != 200: + await response.read() + _raise_agents_http_error(response) + return AsyncHarnessEventStream(AsyncSSEStream(response)) + + async def history_page( + self, + session_id: str, + *, + before: str, + limit: Optional[int] = None, + ) -> HistoryPage: + """Read one page of history older than ``before``, oldest-first. + + Walk backwards by feeding ``next_before`` into the next call:: + + cursor = oldest_event_id_you_hold + while cursor: + page = await sessions.history_page(session_id, before=cursor) + cursor = page.next_before if page.has_more else None + """ + if not before: + raise ValueError("before is required") + stream = await self.stream(session_id, before=before, limit=limit) + async with stream: + events = [event async for event in stream] + return HistoryPage( + events=events, + has_more=stream.has_more, + next_before=stream.oldest_event_id, + ) + + def _transfers_path(self, session_id: str, *parts: str) -> str: + path = f"{_BASE_PATH}/{_quote(session_id)}/{_TRANSFERS_SUFFIX}" + for part in parts: + path = f"{path}/{_quote(part)}" + return path + + async def create_transfer( + self, + session_id: str, + *, + direction: str, + path: str, + is_archive: bool = False, + as_archive: bool = False, + size_bytes: Optional[int] = None, + sha256: Optional[str] = None, + ) -> Any: + """Start a staged workspace transfer (``POST .../workspace/transfers``).""" + if not path: + raise ValueError("path is required") + if direction not in ("upload", "download"): + raise ValueError('direction must be "upload" or "download"') + body: Dict[str, Any] = {"direction": direction, "path": path} + if direction == "upload": + body["is_archive"] = bool(is_archive) + if size_bytes is not None: + body["size_bytes"] = int(size_bytes) + if sha256 is not None: + body["sha256"] = sha256 + else: + body["as_archive"] = bool(as_archive) + return await self._parse_json( + await self._send("POST", self._transfers_path(session_id), body=body), + ) + + async def create_part_upload_urls( + self, + session_id: str, + transfer_id: str, + *, + part_numbers: List[int], + ) -> Any: + """Get presigned URLs for one or more upload parts (upload only).""" + numbers = [int(n) for n in part_numbers] + if not numbers or any(n < 1 for n in numbers): + raise ValueError("part_numbers must be a non-empty list of integers >= 1") + return await self._parse_json( + await self._send( + "POST", + self._transfers_path(session_id, transfer_id, "part-upload-urls"), + body={"part_numbers": numbers}, + ), + ) + + async def create_part_upload_url( + self, + session_id: str, + transfer_id: str, + *, + part_number: int, + ) -> Any: + """Convenience wrapper for a single part URL via the batch endpoint.""" + resp = await self.create_part_upload_urls( + session_id, transfer_id, part_numbers=[part_number] + ) + urls = _field(resp, "part_urls") or [] + for entry in urls: + if int(_field(entry, "part_number") or 0) == int(part_number): + return entry + if len(urls) == 1: + return urls[0] + raise WorkspaceTransferError( + f"CreatePartUploadURL response missing part_number={part_number}" + ) + + async def commit_upload( + self, + session_id: str, + transfer_id: str, + *, + sha256: Optional[str] = None, + ) -> Any: + """Finalize uploaded parts and start applying them into the workspace.""" + body: Dict[str, Any] = {} + if sha256 is not None: + body["sha256"] = sha256 + return await self._parse_json( + await self._send( + "POST", + self._transfers_path(session_id, transfer_id, "commit"), + body=body, + ), + ) + + async def get_transfer(self, session_id: str, transfer_id: str) -> Any: + """Poll transfer status; downloads expose ``download_url`` + ``sha256``.""" + return await self._parse_json( + await self._send("GET", self._transfers_path(session_id, transfer_id)), + ) + + async def cancel_transfer( + self, + session_id: str, + transfer_id: str, + *, + reason: Optional[str] = None, + ) -> Any: + """Abort an in-flight transfer (idempotent).""" + body: Dict[str, Any] = {} + if reason is not None: + body["reason"] = reason + return await self._parse_json( + await self._send( + "POST", + self._transfers_path(session_id, transfer_id, "cancel"), + body=body, + ), + ) + + async def wait_transfer( + self, + session_id: str, + transfer_id: str, + *, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> Any: + """Poll :meth:`get_transfer` until ``completed`` or ``failed``.""" + deadline = time.monotonic() + timeout + while True: + info = await self.get_transfer(session_id, transfer_id) + status = _field(info, "status") + if status in ("completed", "failed"): + if status == "failed": + message = _field(info, "error_message") or "transfer failed" + raise WorkspaceTransferError(str(message)) + return info + if time.monotonic() >= deadline: + raise WorkspaceTransferError( + f"transfer {transfer_id!r} timed out after {timeout:g}s " + f"(last status={status!r})" + ) + await asyncio.sleep(max(poll_interval, 0.05)) + + async def workspace_upload( + self, + session_id: str, + *, + path: str, + data: UploadData, + is_archive: bool = False, + content_sha256: Optional[str] = None, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> Any: + """Upload a file/tar via staged transfers (async). + + Async counterpart of + :meth:`pydo.agents.custom_sessions.SessionsOperations.workspace_upload`. + """ + if not path: + raise ValueError("path is required") + content, size, handle = _coerce_upload_content(data) + try: + # Materialize non-bytes payloads; aiohttp/part PUTs need concrete bytes. + if hasattr(content, "read"): + content = content.read() + if isinstance(content, str): + content = content.encode("utf-8") + size = len(content) + finally: + if handle is not None: + handle.close() + + if size > _MAX_TRANSFER_BYTES: + raise ValueError( + f"upload of {size} bytes exceeds the 50 GiB transfer limit" + ) + + transfer_id = None + try: + created = await self.create_transfer( + session_id, + direction="upload", + path=path, + is_archive=is_archive, + size_bytes=size, + sha256=content_sha256, + ) + transfer_id = _field(created, "transfer_id") + part_size = int(_field(created, "part_size") or 0) + if not transfer_id: + raise WorkspaceTransferError( + "CreateTransfer response missing transfer_id" + ) + if part_size < 1: + raise WorkspaceTransferError( + "CreateTransfer response missing a positive part_size" + ) + + hasher = hashlib.sha256() + if size == 0: + part_url_by_number: Dict[int, str] = {} + else: + num_parts = (size + part_size - 1) // part_size + part_numbers = list(range(1, num_parts + 1)) + batch = await self.create_part_upload_urls( + session_id, transfer_id, part_numbers=part_numbers + ) + part_url_by_number = {} + for entry in _field(batch, "part_urls") or []: + n = int(_field(entry, "part_number") or 0) + url = _field(entry, "upload_url") + if n and url: + part_url_by_number[n] = str(url) + missing = [n for n in part_numbers if n not in part_url_by_number] + if missing: + raise WorkspaceTransferError( + f"CreatePartUploadURL missing upload_url for parts {missing}" + ) + + offset = 0 + part_number = 1 + while offset < size: + length = min(part_size, size - offset) + chunk = bytes(content[offset : offset + length]) + hasher.update(chunk) + await _aio_http_put_bytes(part_url_by_number[part_number], chunk) + offset += length + part_number += 1 + + digest = content_sha256 or hasher.hexdigest() + await self.commit_upload(session_id, transfer_id, sha256=digest) + completed = await self.wait_transfer( + session_id, + transfer_id, + poll_interval=poll_interval, + timeout=timeout, + ) + if _field(completed, "path") is None and hasattr(completed, "__setitem__"): + completed["path"] = path + if _field(completed, "bytes_written") is None and hasattr( + completed, "__setitem__" + ): + completed["bytes_written"] = size + return completed + except Exception: + if transfer_id: + try: + await self.cancel_transfer( + session_id, transfer_id, reason="client_error" + ) + except Exception: # noqa: BLE001 + pass + raise + + async def workspace_download( + self, + session_id: str, + *, + path: str, + as_archive: bool = False, + require_checksum: bool = False, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_POLL_TIMEOUT, + ) -> "AsyncWorkspaceDownload": + """Download a workspace file/tar via staged transfers (async).""" + if not path: + raise ValueError("path is required") + created = await self.create_transfer( + session_id, + direction="download", + path=path, + as_archive=as_archive, + ) + transfer_id = _field(created, "transfer_id") + if not transfer_id: + raise WorkspaceTransferError("CreateTransfer response missing transfer_id") + try: + completed = await self.wait_transfer( + session_id, + transfer_id, + poll_interval=poll_interval, + timeout=timeout, + ) + except Exception: + try: + await self.cancel_transfer( + session_id, transfer_id, reason="client_error" + ) + except Exception: # noqa: BLE001 + pass + raise + download_url = _field(completed, "download_url") + if not download_url: + raise WorkspaceTransferError( + "completed download transfer is missing download_url" + ) + size_hint = _field(completed, "bytes_written") + try: + size_hint = int(size_hint) if size_hint is not None else None + except (TypeError, ValueError): + size_hint = None + return AsyncWorkspaceDownload( + download_url=str(download_url), + expected_sha256=_field(completed, "sha256"), + size_hint=size_hint, + is_archive=as_archive, + require_checksum=require_checksum, + transfer_id=str(transfer_id), + ) + + +class AsyncWorkspaceDownload: + """Async streaming download from a completed staged transfer.""" + + def __init__( + self, + *, + download_url: str, + expected_sha256: Optional[str] = None, + size_hint: Optional[int] = None, + is_archive: bool = False, + require_checksum: bool = False, + transfer_id: Optional[str] = None, + ): + self._download_url = download_url + self._expected_sha256 = expected_sha256 + self._size_hint = size_hint + self._is_archive = bool(is_archive) + self._require_checksum = require_checksum + self.transfer_id = transfer_id + self.bytes_read = 0 + + @property + def is_archive(self) -> bool: + return self._is_archive + + @property + def size_hint(self) -> Optional[int]: + return self._size_hint + + @property + def expected_sha256(self) -> Optional[str]: + return self._expected_sha256 + + def __aiter__(self) -> AsyncIterator[bytes]: + return self._iter() + + async def _iter(self) -> AsyncIterator[bytes]: + hasher = hashlib.sha256() + total = 0 + async for chunk in _aio_http_get_iter(self._download_url): + hasher.update(chunk) + total += len(chunk) + yield bytes(chunk) + self.bytes_read = total + _verify_transfer_sha256( + hasher.hexdigest(), + self._expected_sha256, + total_bytes=total, + size_hint=self._size_hint, + require_checksum=self._require_checksum, + ) + + async def read(self) -> bytes: + chunks = [chunk async for chunk in self] + return b"".join(chunks) + + async def save(self, dest: Union[str, "os.PathLike[str]", BinaryIO]) -> int: + own = isinstance(dest, (str, os.PathLike)) + handle = open(os.fspath(dest), "wb") if own else dest # type: ignore[arg-type] + total = 0 + try: + async for chunk in self: + handle.write(chunk) + total += len(chunk) + except WorkspaceTransferError: + if own: + handle.close() + try: + os.remove(os.fspath(dest)) # type: ignore[arg-type] + except OSError: + pass + raise + finally: + if own and not handle.closed: + handle.close() + return total + + async def close(self) -> None: + return None + + async def __aenter__(self) -> "AsyncWorkspaceDownload": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + +__all__ = [ + "AsyncSessionsOperations", + "AsyncHarnessEventStream", + "AsyncWorkspaceDownload", +] diff --git a/src/pydo/aio/agents/custom_triggers.py b/src/pydo/aio/agents/custom_triggers.py new file mode 100644 index 00000000..dc1bb5ea --- /dev/null +++ b/src/pydo/aio/agents/custom_triggers.py @@ -0,0 +1,196 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async Hosted Agents trigger operations.""" + +from __future__ import annotations + +import json as _json +from typing import Any, Dict, Optional +from urllib.parse import quote + +from azure.core.rest import HttpRequest + +from pydo.agents.custom_sessions import _OK_STATUS, _raise_agents_http_error +from pydo.agents.custom_triggers import ( + _TRIGGERS_PATH, + _WEBHOOK_PROVIDERS_PATH, +) +from pydo.custom_extensions import _wrap + + +def _quote(value: str) -> str: + return quote(str(value), safe="") + + +class AsyncTriggersOperations: + """Async twin of :class:`~pydo.agents.custom_triggers.TriggersOperations`.""" + + def __init__(self, base_url_proxy): + self._client = base_url_proxy + + async def _send( + self, + method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ): + headers = {"Accept": "application/json", **(headers or {})} + kwargs: Dict[str, Any] = {"headers": headers} + if params: + kwargs["params"] = { + k: v for k, v in params.items() if v is not None and v != "" + } + if body is not None: + headers["Content-Type"] = "application/json" + kwargs["json"] = body + + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request, stream=False) + response = pipeline_response.http_response + + if response.status_code not in _OK_STATUS: + await response.read() + _raise_agents_http_error(response) + return pipeline_response + + @staticmethod + async def _parse_json(pipeline_response) -> Any: + body = await pipeline_response.http_response.read() + if not body: + return None + if isinstance(body, bytes): + body = body.decode("utf-8") + return _wrap(_json.loads(body)) + + async def list( + self, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + kind: Optional[str] = None, + status: Optional[str] = None, + ) -> Any: + """List the calling team's triggers (``GET /v2/agents/triggers``).""" + return await self._parse_json( + await self._send( + "GET", + _TRIGGERS_PATH, + params={ + "page_size": page_size, + "page_token": page_token, + "kind": kind, + "status": status, + }, + ), + ) + + async def create(self, body: Dict[str, Any]) -> Any: + """Create a webhook or cron trigger (``POST /v2/agents/triggers``).""" + if not isinstance(body, dict) or not body: + raise ValueError("body must be a non-empty dict") + return await self._parse_json( + await self._send("POST", _TRIGGERS_PATH, body=body), + ) + + async def get(self, trigger_id: str) -> Any: + """Get a single trigger by id.""" + return await self._parse_json( + await self._send("GET", f"{_TRIGGERS_PATH}/{_quote(trigger_id)}"), + ) + + async def update(self, trigger_id: str, body: Dict[str, Any]) -> Any: + """Partial-update a trigger (``PATCH /v2/agents/triggers/{id}``).""" + if not isinstance(body, dict) or not body: + raise ValueError("body must be a non-empty dict") + return await self._parse_json( + await self._send( + "PATCH", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}", + body=body, + ), + ) + + async def delete(self, trigger_id: str) -> None: + """Soft-delete a trigger (``DELETE /v2/agents/triggers/{id}``).""" + await self._send("DELETE", f"{_TRIGGERS_PATH}/{_quote(trigger_id)}") + + async def rotate_secret(self, trigger_id: str) -> Any: + """Issue a new webhook secret (shown once).""" + return await self._parse_json( + await self._send( + "POST", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}/rotate-secret", + ), + ) + + async def list_executions( + self, + trigger_id: str, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + status: Optional[str] = None, + ) -> Any: + """List a trigger's execution history.""" + return await self._parse_json( + await self._send( + "GET", + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}/executions", + params={ + "page_size": page_size, + "page_token": page_token, + "status": status, + }, + ), + ) + + async def get_execution(self, trigger_id: str, execution_id: str) -> Any: + """Get a single execution, including payload and output text.""" + return await self._parse_json( + await self._send( + "GET", + ( + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}" + f"/executions/{_quote(execution_id)}" + ), + ), + ) + + async def get_by_session(self, session_id: str) -> Any: + """Reverse-look-up the trigger that produced or binds a session.""" + return await self._parse_json( + await self._send( + "GET", + f"{_TRIGGERS_PATH}/by-session/{_quote(session_id)}", + ), + ) + + async def list_reusable_sessions( + self, + *, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + ) -> Any: + """List the team's PAUSED sessions for the reuse-mode picker.""" + return await self._parse_json( + await self._send( + "GET", + f"{_TRIGGERS_PATH}/reusable-sessions", + params={ + "page_size": page_size, + "page_token": page_token, + }, + ), + ) + + async def list_webhook_providers(self) -> Any: + """List supported webhook providers for the create-trigger UI.""" + return await self._parse_json( + await self._send("GET", _WEBHOOK_PROVIDERS_PATH), + ) diff --git a/src/pydo/aio/agents/session.py b/src/pydo/aio/agents/session.py new file mode 100644 index 00000000..87f821de --- /dev/null +++ b/src/pydo/aio/agents/session.py @@ -0,0 +1,300 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async high-level agent interface (see :mod:`pydo.agents.session`).""" +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional + +from pydo.agents.custom_models import ResolutionSource, SessionStatus +from pydo.agents.session import ( + AgentEvent, + AgentEventType, + HITLPolicy, + RunResult, + _decide_hitl, +) + + +class AsyncRunStream: + """Async iterable of :class:`~pydo.agents.session.AgentEvent` for one run.""" + + def __init__( + self, + *, + raw_stream: Any, + run_id: Optional[str], + session: "AsyncAgentSession", + hitl: HITLPolicy = "approve", + timeout: Optional[float] = None, + ): + self._raw = raw_stream + self.run_id = run_id + self._session = session + self._hitl = hitl + self._timeout = timeout + self._chunks: List[str] = [] + self.events: List[AgentEvent] = [] + self.usage: Dict[str, Any] = {} + self.status = "running" + self.error: Optional[Dict[str, Any]] = None + + def __aiter__(self): + return self._iter() + + async def _iter(self): + deadline = ( + (asyncio.get_event_loop().time() + self._timeout) if self._timeout else None + ) + try: + async for raw in self._raw: + event = AgentEvent(raw) + self.events.append(event) + + if event.type == AgentEventType.TOKEN: + self._chunks.append(event.text) + elif event.type == AgentEventType.HITL_REQUESTED: + await self._auto_resolve(event) + + yield event + + if self._is_terminal(event): + break + if deadline and asyncio.get_event_loop().time() > deadline: + self.status = "timeout" + break + finally: + await self.close() + + async def _auto_resolve(self, event: AgentEvent) -> None: + outcome = _decide_hitl(self._hitl, event) + if not outcome or not event.request_id: + return + try: + await self._session.resolve_hitl( + event.request_id, + outcome=outcome, + source=ResolutionSource.OUT_OF_BAND, + ) + except Exception: # noqa: BLE001 - best-effort; surfaced via the feed + pass + + def _is_terminal(self, event: AgentEvent) -> bool: + if self.run_id and event.run_id and event.run_id != self.run_id: + return False + if event.type == AgentEventType.COMPLETED: + self.usage = event.usage + self.status = "completed" + return True + if event.type == AgentEventType.FAILED: + self.error = event.error + self.status = "failed" + return True + return False + + @property + def final_output(self) -> str: + return "".join(self._chunks).strip() + + @property + def result(self) -> RunResult: + return RunResult( + run_id=self.run_id, + final_output=self.final_output, + events=self.events, + usage=self.usage, + status=self.status, + error=self.error, + ) + + async def close(self) -> None: + closer = getattr(self._raw, "close", None) + if closer: + try: + await closer() + except Exception: # noqa: BLE001 + pass + + async def __aenter__(self) -> "AsyncRunStream": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + +class AsyncAgentSession: + """Async self-managing handle to one hosted-agent session.""" + + def __init__(self, sessions: Any, session_id: str, *, raw: Any = None): + self._sessions = sessions + self.session_id = session_id + self._raw = raw + + @property + def sessions(self) -> Any: + return self._sessions + + @property + def info(self) -> Any: + raw = self._raw + if raw is None: + return None + get = getattr(raw, "get", None) + inner = get("session") if get else None + return inner if inner is not None else raw + + @property + def status(self) -> Optional[str]: + info = self.info + get = getattr(info, "get", None) + return get("status") if get else None + + async def refresh(self) -> Any: + self._raw = await self._sessions.get(self.session_id) + return self.info + + async def wait_until_ready( + self, *, timeout: float = 120.0, poll_interval: float = 2.0 + ) -> "AsyncAgentSession": + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout + while True: + info = await self.refresh() + status = (getattr(info, "get", lambda *_: None))("status") + if status == SessionStatus.READY: + return self + if status in (SessionStatus.FAILED, SessionStatus.DESTROYED): + raise RuntimeError(f"session {self.session_id} is {status}") + if loop.time() > deadline: + raise TimeoutError( + f"session {self.session_id} not ready after {timeout}s" + ) + await asyncio.sleep(poll_interval) + + async def run_streamed( + self, + prompt: str, + *, + hitl: HITLPolicy = "approve", + timeout: Optional[float] = 300.0, + ) -> AsyncRunStream: + raw_stream = await self._sessions.stream(self.session_id) + run = await self._sessions.send_input(self.session_id, text=prompt) + run_id = (getattr(run, "get", lambda *_: None))("run_id") + return AsyncRunStream( + raw_stream=raw_stream, + run_id=run_id, + session=self, + hitl=hitl, + timeout=timeout, + ) + + async def run( + self, + prompt: str, + *, + hitl: HITLPolicy = "approve", + timeout: Optional[float] = 300.0, + ) -> RunResult: + stream = await self.run_streamed(prompt, hitl=hitl, timeout=timeout) + async for _ in stream: + pass + return stream.result + + # --- thin passthroughs (session id bound) --------------------------- + async def send_input(self, text: str) -> Any: + return await self._sessions.send_input(self.session_id, text=text) + + async def pause(self) -> Any: + return await self._sessions.pause(self.session_id) + + async def resume(self) -> Any: + return await self._sessions.resume(self.session_id) + + async def stream(self, **kwargs: Any) -> Any: + return await self._sessions.stream(self.session_id, **kwargs) + + async def history(self, *, before: str, limit: Optional[int] = None) -> Any: + """Read one page of history older than ``before``. + + See :meth:`AsyncSessionsOperations.history_page`. + """ + return await self._sessions.history_page( + self.session_id, before=before, limit=limit + ) + + async def resolve_hitl( + self, + request_id: str, + *, + outcome: str, + reason: Optional[str] = None, + source: Optional[str] = None, + ) -> None: + await self._sessions.resolve_hitl( + self.session_id, + request_id, + outcome=outcome, + reason=reason, + source=source, + ) + + async def upload_file( + self, + *, + path: str, + data: Any, + is_archive: bool = False, + content_sha256: Optional[str] = None, + poll_interval: float = 1.0, + timeout: float = 600.0, + ) -> Any: + """Upload bytes/a tar into the session workspace via staged transfers.""" + return await self._sessions.workspace_upload( + self.session_id, + path=path, + data=data, + is_archive=is_archive, + content_sha256=content_sha256, + poll_interval=poll_interval, + timeout=timeout, + ) + + async def download_file( + self, + *, + path: str, + as_archive: bool = False, + require_checksum: bool = False, + poll_interval: float = 1.0, + timeout: float = 600.0, + ) -> Any: + """Download a workspace file/tar via staged transfers.""" + return await self._sessions.workspace_download( + self.session_id, + path=path, + as_archive=as_archive, + require_checksum=require_checksum, + poll_interval=poll_interval, + timeout=timeout, + ) + + async def destroy(self) -> None: + await self._sessions.destroy(self.session_id) + + async def __aenter__(self) -> "AsyncAgentSession": + return self + + async def __aexit__(self, *args: Any) -> None: + try: + await self.destroy() + except Exception: # noqa: BLE001 - teardown best-effort + pass + + def __repr__(self) -> str: + return f"AsyncAgentSession(session_id={self.session_id!r})" + + +__all__ = ["AsyncAgentSession", "AsyncRunStream"] diff --git a/src/pydo/aio/gateway/__init__.py b/src/pydo/aio/gateway/__init__.py new file mode 100644 index 00000000..1bc0b53b --- /dev/null +++ b/src/pydo/aio/gateway/__init__.py @@ -0,0 +1,99 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway API — hand-written; preserved across ``make generate``.""" + +from __future__ import annotations + +from typing import Any, List, Optional, Sequence + +from pydo.gateway.custom_models import ToolCall +from pydo.gateway.providers import BaseProvider, default_provider +from pydo.gateway import resolve_gateway_base_url + +from .custom_operations import ( + AsyncCodeOperations, + AsyncGatewayTransport, + AsyncMCPTransport, + AsyncRESTTransport, + AsyncToolsOperations, + async_execute_tool_calls, +) +from .session import AsyncSession, AsyncSessionsOperations + + +class AsyncGatewayResources: + """Async Action Gateway surface attached at ``client.gateway``.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + transport: Optional[AsyncGatewayTransport] = None, + ): + self._parent = parent_client + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self.provider = provider or default_provider() + self.sessions = AsyncSessionsOperations( + parent_client, + gateway_endpoint=gateway_endpoint, + provider=self.provider, + ) + self._transport = transport + if transport is not None: + self.tools = AsyncToolsOperations(transport, self.provider) + self.code = AsyncCodeOperations(transport) + else: + self.tools = None + self.code = None + + @property + def base_url(self) -> str: + return self._gateway_base_url + + async def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + if self.tools is None: + raise RuntimeError( + "create a session first: session = await client.sessions.create(" + "actor_id=...); then await session.handle_tool_calls(response)" + ) + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + async def execute_tool_calls( + self, + calls: Sequence[ToolCall], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + if self.tools is None: + raise RuntimeError( + "create a session first via await client.sessions.create(" + "actor_id=...)" + ) + return await async_execute_tool_calls(calls, self.tools, rationale=rationale) + + +__all__ = [ + "AsyncGatewayResources", + "AsyncSession", + "AsyncSessionsOperations", + "AsyncGatewayTransport", + "AsyncMCPTransport", + "AsyncRESTTransport", + "AsyncToolsOperations", + "AsyncCodeOperations", + "async_execute_tool_calls", +] diff --git a/src/pydo/aio/gateway/custom_operations.py b/src/pydo/aio/gateway/custom_operations.py new file mode 100644 index 00000000..aa7634a0 --- /dev/null +++ b/src/pydo/aio/gateway/custom_operations.py @@ -0,0 +1,430 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway operations (mirror of :mod:`pydo.gateway`).""" + +from __future__ import annotations + +import itertools +from typing import Any, Dict, List, Optional, Sequence, Union +from urllib.parse import quote, urlsplit + +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _wrap +from pydo.gateway.custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + GatewayToolError, +) +from pydo.gateway.custom_operations import ( + QueryInput, + ToolSpecInput, + _normalize_invoke_entry, + _normalize_queries, + _normalize_tool_specs, + _result_output_or_raise, + _flatten_search_results, + _tool_name, + normalize_invoke_arguments, +) +from pydo.gateway.providers import _error_payload, _get +from pydo.gateway.transport import ( + ACTOR_ID_HEADER, + SESSION_ID_HEADER, + _MCP_HEADERS, + _MCP_META_PATH, + _MCP_PATH, + _META_TOOL_DEFINITIONS, + _REST_CODE_PATH, + _REST_HEADERS, + _REST_INVOKE_PATH, + _REST_SEARCH_PATH, + _REST_TOOLS_PATH, + _external_session_id, + _parse_json_body, + _parse_jsonrpc, + _raise_gateway_http_error, + _unwrap_call_result, + _unwrap_tool_result, +) + + +class AsyncGatewayTransport: + """Async counterpart of :class:`pydo.gateway.transport.GatewayTransport`.""" + + async def list_tools(self, *, meta: bool) -> List[Any]: + raise NotImplementedError + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + raise NotImplementedError + + async def decide_approval(self, approval_id: str, decision: str) -> Any: + raise NotImplementedError + + async def approve(self, approval_id: str) -> Any: + return await self.decide_approval(approval_id, "approve") + + +class AsyncMCPTransport(AsyncGatewayTransport): + """Async JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" + + def __init__( + self, + base_url_proxy: Any, + *, + session_id: Optional[str] = None, + actor_id: str, + endpoint_url: Optional[str] = None, + ): + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for AsyncMCPTransport") + self._client = base_url_proxy + self._ids = itertools.count(1) + self.session_id = _external_session_id(session_id) if session_id else None + self.actor_id = str(actor_id).strip() + self.endpoint_url = endpoint_url + + def _headers(self) -> Dict[str, str]: + headers = dict(_MCP_HEADERS) + if self.session_id: + headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id + return headers + + async def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if self.endpoint_url: + path = self.endpoint_url + request = HttpRequest( + "POST", + path, + headers=self._headers(), + json=payload, + ) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + body = await response.read() + if response.status_code != 200: + _raise_gateway_http_error(response) + return _parse_jsonrpc(body) + + async def _rpc( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + meta: bool, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": next(self._ids), + "method": method, + } + if params is not None: + payload["params"] = params + return await self._post(_MCP_META_PATH if meta else _MCP_PATH, payload) + + async def list_tools(self, *, meta: bool) -> List[Any]: + result = await self._rpc("tools/list", meta=meta) + return _wrap(result.get("tools") or []) + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + result = await self._rpc( + "tools/call", + {"name": name, "arguments": arguments or {}}, + meta=meta, + ) + return _unwrap_call_result(result) + + async def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + endpoint = urlsplit(self.endpoint_url or self._client._base_url) + approval_id = quote(str(approval_id).strip(), safe="") + url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" + request = HttpRequest( + "POST", + url, + headers={**self._headers(), "Accept": "application/json"}, + json={"decision": decision}, + ) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + body = await response.read() + if response.status_code not in (200, 201, 202, 204): + _raise_gateway_http_error(response) + return _wrap(_parse_json_body(body)) if body else None + + +class AsyncRESTTransport(AsyncGatewayTransport): + """Async REST transport; requires ``session_id`` via ``X-Session-Id``.""" + + def __init__(self, base_url_proxy: Any, *, session_id: str, actor_id: str): + if not session_id: + raise ValueError("session_id is required for AsyncRESTTransport") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for AsyncRESTTransport") + self._client = base_url_proxy + self.session_id = _external_session_id(session_id) + self.actor_id = str(actor_id).strip() + + def _headers(self) -> Dict[str, str]: + headers = dict(_REST_HEADERS) + headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id + return headers + + async def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + kwargs: Dict[str, Any] = {"headers": self._headers()} + if payload is not None: + kwargs["json"] = payload + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = await self._client._pipeline.run(request) + response = pipeline_response.http_response + body = await response.read() + if response.status_code != 200: + _raise_gateway_http_error(response) + return _parse_json_body(body) + + async def list_tools(self, *, meta: bool) -> List[Any]: + if meta: + return _wrap([dict(tool) for tool in _META_TOOL_DEFINITIONS]) + catalog = await self._request("GET", _REST_TOOLS_PATH) + if isinstance(catalog, dict): + return _wrap(catalog.get("tools") or []) + return _wrap(catalog or []) + + async def call_tool( + self, name: str, arguments: Dict[str, Any], *, meta: bool + ) -> Any: + arguments = arguments or {} + if name == META_SEARCH: + return _unwrap_tool_result( + await self._request("POST", _REST_SEARCH_PATH, arguments) + ) + if name == META_INVOKE: + return _wrap(await self._request("POST", _REST_INVOKE_PATH, arguments)) + if name == META_CODE: + return _unwrap_tool_result( + await self._request("POST", _REST_CODE_PATH, arguments) + ) + envelope = await self._request( + "POST", + _REST_INVOKE_PATH, + {"tools": [{"tool": name, "arguments": arguments}]}, + ) + results = (envelope or {}).get("results") or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + item = results[0] + item_result = item.get("result") if isinstance(item, dict) else item + return _unwrap_tool_result(item_result) + + async def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + approval_id = quote(str(approval_id).strip(), safe="") + return await self._request( + "POST", + f"/approvals/{approval_id}", + {"decision": decision}, + ) + + +class AsyncToolsOperations: + """Async Action Gateway tool discovery and invocation.""" + + def __init__(self, transport: AsyncGatewayTransport, provider: Any = None): + self._transport = transport + self._provider = provider + + async def list(self, *, include_all: bool = False) -> Any: + return await self._transport.list_tools(meta=not include_all) + + async def search( + self, + queries: Union[QueryInput, Sequence[QueryInput]], + *, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> Any: + arguments: Dict[str, Any] = {"queries": _normalize_queries(queries)} + if providers: + arguments["providers"] = list(providers) + if tags: + arguments["tags"] = list(tags) + if limit is not None: + arguments["limit"] = limit + return await self._transport.call_tool(META_SEARCH, arguments, meta=True) + + async def invoke( + self, + tools: Sequence[ToolSpecInput], + *, + rationale: Optional[str] = None, + ) -> Any: + arguments: Dict[str, Any] = {"tools": _normalize_tool_specs(tools)} + if rationale: + arguments["rationale"] = rationale + return await self._transport.call_tool(META_INVOKE, arguments, meta=True) + + async def invoke_one( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + rationale: Optional[str] = None, + ) -> Any: + envelope = await self.invoke( + [{"tool": name, "arguments": arguments or {}}], + rationale=rationale, + ) + get = getattr(envelope, "get", None) + results = (get("results") if get else None) or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + first = results[0] + item_result = (getattr(first, "get", lambda *_: first)("result")) or first + return _result_output_or_raise(item_result, name) + + async def call(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Any: + return await self._transport.call_tool(name, arguments or {}, meta=False) + + async def __call__( + self, + *, + include_all: bool = False, + names: Optional[Sequence[str]] = None, + search: Optional[Union[QueryInput, Sequence[QueryInput]]] = None, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> List[Any]: + if self._provider is None: + raise RuntimeError( + "no gateway provider configured; pass gateway_provider= to " + "Client() or use tools.list()/tools.invoke() directly" + ) + if search is not None: + payload = await self.search( + search, providers=providers, tags=tags, limit=limit + ) + catalog: List[Any] = _flatten_search_results(payload) + else: + wants_concrete = include_all or bool(names) + tools = await self.list(include_all=wants_concrete) + if names: + wanted = set(names) + tools = [t for t in tools if _tool_name(t) in wanted] + missing = wanted - {_tool_name(t) for t in tools} + if missing: + raise LookupError(f"tools not found in catalog: {sorted(missing)}") + catalog = list(tools) + return self._provider.wrap_tools(catalog) + + +class AsyncCodeOperations: + """Async ephemeral Python sandbox execution (``action.code``).""" + + def __init__(self, transport: AsyncGatewayTransport): + self._transport = transport + + async def execute(self, code: str, *, thought: Optional[str] = None) -> Any: + if not code or not code.strip(): + raise ValueError("code is empty") + arguments: Dict[str, Any] = {"code": code} + if thought: + arguments["thought"] = thought + return await self._transport.call_tool(META_CODE, arguments, meta=True) + + +async def async_execute_tool_calls( + calls: Sequence[Any], + tools_operations: AsyncToolsOperations, + *, + rationale: Optional[str] = None, +) -> List[Any]: + """Async twin of :func:`pydo.gateway.providers.execute_tool_calls`.""" + results: List[Any] = [None] * len(calls) + concrete: List[int] = [] + + for index, call in enumerate(calls): + if call.name in META_TOOL_NAMES: + try: + arguments = call.arguments + if call.name == META_INVOKE: + arguments = normalize_invoke_arguments(arguments) + results[index] = await tools_operations._transport.call_tool( + call.name, arguments, meta=True + ) + except (GatewayToolError, TypeError, ValueError) as exc: + results[index] = _error_payload(exc) + else: + concrete.append(index) + + if concrete: + try: + batch = [ + _normalize_invoke_entry( + {"tool": calls[i].name, "arguments": calls[i].arguments} + ) + for i in concrete + ] + except (TypeError, ValueError) as exc: + error = _error_payload(exc) + for index in concrete: + results[index] = error + return results + envelope = await tools_operations.invoke(batch, rationale=rationale) + items = (_get(envelope, "results") or []) if envelope is not None else [] + for position, index in enumerate(concrete): + if position < len(items): + item = items[position] + item_result = _get(item, "result") or item + status = _get(item_result, "status") + if status and status != "succeeded": + error_result = { + "error": _get(item_result, "error") + or {"message": f"tool {calls[index].name!r} failed"} + } + meta = _get(item_result, "_meta") + if meta: + error_result["_meta"] = meta + results[index] = error_result + else: + results[index] = _get(item_result, "output") + else: + results[index] = { + "error": {"message": "no result returned for this tool call"} + } + return results + + +__all__ = [ + "AsyncGatewayTransport", + "AsyncMCPTransport", + "AsyncRESTTransport", + "AsyncToolsOperations", + "AsyncCodeOperations", + "async_execute_tool_calls", +] diff --git a/src/pydo/aio/gateway/session.py b/src/pydo/aio/gateway/session.py new file mode 100644 index 00000000..dbaee9fa --- /dev/null +++ b/src/pydo/aio/gateway/session.py @@ -0,0 +1,199 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Async Action Gateway sessions.""" + +from __future__ import annotations + +import uuid +from typing import Any, Dict, List, Optional, Sequence + +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway.custom_models import GatewayProtocolError +from pydo.gateway.providers import BaseProvider, default_provider +from pydo.gateway.session import normalize_permissions +from pydo.gateway.transport import ( + resolve_gateway_base_url, +) + +from .custom_operations import ( + AsyncCodeOperations, + AsyncMCPTransport, + AsyncToolsOperations, + async_execute_tool_calls, +) + + +def _pick(data: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +class AsyncSession: + """Async twin of :class:`pydo.gateway.session.Session`.""" + + def __init__( + self, + *, + session_urn: str, + actor_id: str, + name: str, + policy: Dict[str, Any], + mcp_url: str, + tools: AsyncToolsOperations, + code: AsyncCodeOperations, + provider: BaseProvider, + selected_tools: Optional[Sequence[str]] = None, + raw: Optional[Dict[str, Any]] = None, + ): + self.session_urn = session_urn + self.id = session_urn + self.actor_id = actor_id + self.name = name + self.policy = policy + self._mcp_url = mcp_url + self.tools = tools + self.code = code + self._transport = tools._transport + self.provider = provider + self.selected_tools = list(selected_tools or []) + self.raw = raw or {} + + @property + def url(self) -> str: + return self._mcp_url + + async def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = await async_execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + async def execute_tool_calls( + self, + calls: Sequence[Any], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + return await async_execute_tool_calls(calls, self.tools, rationale=rationale) + + async def approve(self, approval_id: str) -> Any: + """Approve a pending tool invocation for this session.""" + return await self._transport.decide_approval(approval_id, "approve") + + async def deny(self, approval_id: str) -> Any: + """Deny a pending tool invocation for this session.""" + return await self._transport.decide_approval(approval_id, "deny") + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class AsyncSessionsOperations: + """Create sessions through the generated async Action Gateway operation.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + ): + self._parent = parent_client + self._sessions_api = parent_client.sessions + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self._provider = provider or default_provider() + + async def create( + self, + actor_id: str, + *, + name: Optional[str] = None, + permissions: Optional[Dict[str, Any]] = None, + tools: Optional[Sequence[str]] = None, + config: Optional[Dict[str, Any]] = None, + ) -> AsyncSession: + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required") + + session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" + policy = normalize_permissions(permissions) + body = { + "name": session_name, + "policy": policy, + "actor_id": str(actor_id).strip(), + } + if tools is not None: + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be a sequence of tool references") + body["tools"] = list(tools) + if config is not None: + if not isinstance(config, dict): + raise TypeError("config must be a dict") + body["config"] = config + + raw_session = await self._post_create(body) + session_urn = _pick(raw_session, "sessionUrn", "session_urn") + if not session_urn: + raise GatewayProtocolError( + f"session create response missing sessionUrn: {raw_session!r}" + ) + + mcp_url = _pick(raw_session, "mcpUrl", "mcp_url") + if not mcp_url: + raise GatewayProtocolError( + f"session create response missing mcpUrl: {raw_session!r}" + ) + + transport = AsyncMCPTransport( + _BaseURLProxy(self._parent._client, self._gateway_base_url), + session_id=session_urn, + actor_id=actor_id, + endpoint_url=mcp_url, + ) + tools = AsyncToolsOperations(transport, self._provider) + code = AsyncCodeOperations(transport) + return AsyncSession( + session_urn=session_urn, + actor_id=str(actor_id).strip(), + name=_pick(raw_session, "name") or session_name, + policy=policy, + mcp_url=mcp_url, + tools=tools, + code=code, + provider=self._provider, + selected_tools=_pick(raw_session, "selectedTools") or [], + raw=raw_session, + ) + + async def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: + payload = await self._sessions_api.create(body=body) + if not isinstance(payload, dict): + raise GatewayProtocolError( + f"unexpected session create response: {payload!r}" + ) + session = payload.get("session") + if not isinstance(session, dict): + raise GatewayProtocolError( + f"session create response missing session object: {payload!r}" + ) + result = dict(session) + mcp_url = _pick(payload, "mcpUrl", "mcp_url") + if mcp_url: + result["mcpUrl"] = mcp_url + if "tools" in payload: + result["selectedTools"] = payload["tools"] + return result + + +__all__ = ["AsyncSession", "AsyncSessionsOperations"] diff --git a/src/pydo/aio/operations/__init__.py b/src/pydo/aio/operations/__init__.py index 2de68fec..3960eadb 100644 --- a/src/pydo/aio/operations/__init__.py +++ b/src/pydo/aio/operations/__init__.py @@ -4,6 +4,11 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolsOperations +from ._operations import ToolbeltsOperations +from ._operations import ConnectionsOperations +from ._operations import UsersOperations +from ._operations import SessionsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -63,6 +68,11 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolsOperations", + "ToolbeltsOperations", + "ConnectionsOperations", + "UsersOperations", + "SessionsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/aio/operations/_operations.py b/src/pydo/aio/operations/_operations.py index 120e722d..c9c03aff 100644 --- a/src/pydo/aio/operations/_operations.py +++ b/src/pydo/aio/operations/_operations.py @@ -113,6 +113,11 @@ build_certificates_delete_request, build_certificates_get_request, build_certificates_list_request, + build_connections_create_request, + build_connections_delete_request, + build_connections_get_request, + build_connections_list_request, + build_connections_update_request, build_databases_add_connection_pool_request, build_databases_add_request, build_databases_add_user_request, @@ -617,6 +622,9 @@ build_security_post_restore_secret_request, build_security_update_secret_request, build_security_update_settings_plan_request, + build_sessions_create_request, + build_sessions_delete_request, + build_sessions_list_request, build_sizes_list_request, build_snapshots_delete_request, build_snapshots_get_request, @@ -638,6 +646,16 @@ build_tags_get_request, build_tags_list_request, build_tags_unassign_resources_request, + build_toolbelts_add_tools_request, + build_toolbelts_create_request, + build_toolbelts_delete_request, + build_toolbelts_delete_tools_request, + build_toolbelts_get_request, + build_toolbelts_list_request, + build_tools_get_definition_request, + build_tools_list_providers_request, + build_tools_list_request, + build_tools_list_toolkits_request, build_uptime_create_alert_request, build_uptime_create_check_request, build_uptime_delete_alert_request, @@ -649,6 +667,8 @@ build_uptime_list_checks_request, build_uptime_update_alert_request, build_uptime_update_check_request, + build_users_get_request, + build_users_list_request, build_vector_databases_create_request, build_vector_databases_delete_request, build_vector_databases_get_credentials_request, @@ -706,6 +726,4615 @@ ] +class ToolsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`tools` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + toolkit_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """List Tools. + + Lists active Action Gateway tools visible to the authenticated team. + + :keyword toolkit_id: Filter tools by toolkit identifier. Default value is None. + :paramtype toolkit_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "definitions": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "caseInsensitive": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "extractField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchValue": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "match_value_parameter": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "method": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "requiredScopes": [ + "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access + token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When + both are empty, exactly one entry whose own "scopes" + array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + ], + "trimTrailingSlash": bool, # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "url": "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote + MCP server (as opposed to a plain HTTP endpoint). endpoint is + the remote MCP server's URL, tool_name is the name the remote + server expects on tools/call (may differ from this tool's + registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server + for logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage + metadata is present, false prevents billing. Omitting usage + metadata leaves consumers' legacy billing classification + unchanged. + "meters": [ + { + "quantitySource": "str", # + Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "tools": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "description": "str", # Optional. + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "streamingSafe": bool, # Optional. + "title": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque + rather than reconstructing it from toolkit_id and name. + "toolkitId": "str", # Optional. + "version": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_request( + toolkit_id=toolkit_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def list_toolkits(self, **kwargs: Any) -> JSON: + """List Toolkits. + + Lists the toolkits that group Action Gateway tools. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolkits": [ + { + "description": "str", # Optional. + "id": "str", # Optional. + "name": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_toolkits_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def list_providers(self, **kwargs: Any) -> JSON: + """List Tool Providers. + + Lists Action Gateway providers and their connection requirements. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "providers": [ + { + "auth_type": "str", # Optional. + "connection_parameters": [ + { + "allowed_host_suffixes": [ + "str" # Optional. + ], + "allowed_values": [ + "str" # Optional. + ], + "description": "str", # Optional. + "input_kind": "str", # Optional. + "key": "str", # Optional. + "label": "str", # Optional. + "max_length": 0, # Optional. + "normalization": "str", # Optional. + "required": bool # Optional. + } + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "name": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_providers_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get_definition( + self, + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Tool Definition. + + Retrieves the executable definition for an active Action Gateway tool. + + :param name: The provider-qualified tool name. Required. + :type name: str + :keyword version: The tool version. Omit to retrieve the current version. Default value is + None. + :paramtype version: str + :keyword toolkit_id: The toolkit identifier used to disambiguate a bare tool name. Default + value is None. + :paramtype toolkit_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "caseInsensitive": bool, # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "extractField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchValue": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "match_value_parameter": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "method": "str", # Optional. HTTPLookupSpec resolves + a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "requiredScopes": [ + "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON + array, extracting extract_field from that entry, and substituting + it for "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both are + empty, exactly one entry whose own "scopes" array contains + required_scopes must exist. Configuring only one match field is + invalid. Resolution fails fast on zero or multiple compatible + entries. + ], + "trimTrailingSlash": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "url": "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the just-exchanged + access token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for "{value}" in + base_url_template. When match_field and match_value are both set, + they select the entry. When both are empty, exactly one entry whose + own "scopes" array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, + tool_name is the name the remote server expects on tools/call (may + differ from this tool's registry name), transport selects the wire + protocol ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server for + logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution describes how + to invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage metadata is + present, false prevents billing. Omitting usage metadata leaves + consumers' legacy billing classification unchanged. + "meters": [ + { + "quantitySource": "str", # Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the provider-qualified, stable + tool identifier ":code:``_:code:``". Pass this value back + verbatim to the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_get_definition_request( + name=name, + version=version, + toolkit_id=toolkit_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ToolbeltsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`toolbelts` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + status: str = "active", + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + """List Toolbelts. + + Lists the latest version of each toolbelt owned by the authenticated team. + + :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and + "all". Default value is "active". + :paramtype status: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "toolbelts": [ + { + "latest_version": "str", # Required. + "name": "str", # Required. + "reference_latest": "str", # Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "updated_at": "2020-02-20 00:00:00", # Required. + "version_count": 0, # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_list_request( + status=status, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get( + self, name: str, *, version: Optional[str] = None, **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Toolbelt. + + Retrieves the latest active version or a specified immutable version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :keyword version: An immutable numeric toolbelt version. Omit to retrieve the latest active + version. Default value is None. + :paramtype version: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_get_request( + name=name, + version=version, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def delete(self, name: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Toolbelt. + + Deprecates the latest active version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_delete_request( + name=name, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def add_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def add_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def add_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_add_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ConnectionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`connections` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + """List Connections. + + Lists OAuth connections owned by the authenticated team. + + :keyword provider: Filter by provider name. Default value is None. + :paramtype provider: str + :keyword user_id: Filter by end-user identifier. Default value is None. + :paramtype user_id: str + :keyword status: Filter by connection status. Default value is None. + :paramtype status: str + :keyword sort: Field used to sort results. Default value is None. + :paramtype sort: str + :keyword sort_direction: Sort direction. Default value is None. + :paramtype sort_direction: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + "granted_at": "2020-02-20 00:00:00", # Optional. + "id": "str", # Optional. + "provider": "str", # Optional. + "provider_display_name": "str", # Optional. + "revoked_at": "2020-02-20 00:00:00", # Optional. + "scopes": [ + "str" # Optional. + ], + "status": "str", # Optional. + "updated_at": "2020-02-20 00:00:00", # Optional. + "user_id": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + } + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_list_request( + provider=provider, + user_id=user_id, + status=status, + sort=sort, + sort_direction=sort_direction, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Connection. + + Retrieves an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_get_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def update( + self, + id: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def update( + self, + id: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def update( + self, id: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_update_request( + id=id, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def delete(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Connection. + + Revokes and deletes an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_delete_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class UsersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`users` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list(self, *, page: int = 1, per_page: int = 20, **kwargs: Any) -> JSON: + """List Action Gateway Users. + + Lists end-user identifiers derived from sessions and OAuth connections for the authenticated + team. + + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "user_ids": [ + "str" # Optional. + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_list_request( + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def get(self, user_id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve an Action Gateway User. + + Retrieves a derived end-user view containing its sessions and OAuth connections. + + :param user_id: The end-user identifier. Required. + :type user_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "user": { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "granted_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "id": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider_display_name": "str", # Optional. User is + a derived, team-scoped view across sessions and OAuth connections. + "revoked_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "scopes": [ + "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + ], + "status": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "user_id": "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + } + ], + "sessions": [ + { + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "name": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "session_urn": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00" # Optional. User + is a derived, team-scoped view across sessions and OAuth connections. + } + ], + "user_id": "str" # Optional. User is a derived, team-scoped view + across sessions and OAuth connections. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_get_request( + user_id=user_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class SessionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.aio.GeneratedClient`'s + :attr:`sessions` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace_async + async def list( + self, + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """List Action Gateway Sessions. + + Lists Action Gateway sessions owned by the authenticated team. + + :keyword end_user_id: Filter sessions by actor identifier. Default value is None. + :paramtype end_user_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "sessions": [ + { + "actorId": "str", # Optional. actor_id is empty when the + session is not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. + Gateway currently interprets config.preloadTools to add selected direct + tools to the session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. + "name": "str", # Optional. name is the required + human-readable session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is + "ask". SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default + value is "ask". SessionPolicyAction is the disposition + applied to a tool call. Lowercase values are canonical so + ProtoJSON matches the public REST vocabulary; the prefixed + aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. + Dictionary of :code:``. + }, + "tool": "str" # Optional. + SessionPolicySpec is the Gateway-relevant subset of a + session's permission policy. Filesystem and network policy + remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known + values are: "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + "version": "str" # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_list_request( + end_user_id=end_user_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + async def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + async def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace_async + async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_sessions_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace_async + async def delete(self, session_urn: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete an Action Gateway Session. + + Deletes an Action Gateway session owned by the authenticated team. + + :param session_urn: The URL-encoded managed agents session URN. Required. + :type session_urn: str + :return: JSON or JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_delete_request( + session_urn=session_urn, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + await response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + class OneClicksOperations: """ .. warning:: @@ -13446,7 +18075,7 @@ async def create( }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -30169,7 +34798,7 @@ async def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -111450,8 +116079,10 @@ async def list_clusters( } ], "pg_allow_replication": bool # - Optional. For Postgres clusters, set to ``true`` for a user - with replication rights. This option is not currently + Optional. For PostgreSQL clusters, set to ``true`` to grant + the user replication privileges. When omitted on create or + update, the value defaults to ``false`` and replication + privileges are not granted. This option is not currently supported for other database engines. } } @@ -111727,7 +116358,7 @@ async def create_cluster( "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -111904,9 +116535,10 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -112208,9 +116840,11 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -112581,9 +117215,11 @@ async def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -112775,7 +117411,7 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -112952,9 +117588,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -113256,9 +117893,11 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -113692,9 +118331,11 @@ async def get_cluster(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -115506,10 +120147,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -115578,10 +120220,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -115622,10 +120265,11 @@ async def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -118038,6 +122682,11 @@ async def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: For MySQL clusters, additional options will be contained in the mysql_settings object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + + For Kafka clusters, additional options will be contained in the ``settings`` object. + For MongoDB clusters, additional information will be contained in the mongo_user_settings object. @@ -118128,9 +122777,10 @@ async def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ] @@ -118245,10 +122895,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -118340,9 +122994,11 @@ async def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -118417,9 +123073,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -118456,10 +123114,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -118551,9 +123213,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -118585,10 +123249,14 @@ async def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -118677,9 +123345,11 @@ async def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -118754,9 +123424,11 @@ async def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -118883,6 +123555,9 @@ async def get_user( For MySQL clusters, additional options will be contained in the ``mysql_settings`` object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + For Kafka clusters, additional options will be contained in the ``settings`` object. For MongoDB clusters, additional information will be contained in the mongo_user_settings @@ -118970,9 +123645,11 @@ async def get_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119201,8 +123878,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -119271,9 +123954,11 @@ async def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -119348,9 +124033,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119388,8 +124075,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -119480,9 +124173,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119518,8 +124213,14 @@ async def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -119585,9 +124286,11 @@ async def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -119662,9 +124365,11 @@ async def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -119895,9 +124600,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120024,9 +124731,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -120160,9 +124869,11 @@ async def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -133724,9 +138435,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -133771,9 +138483,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -134397,9 +139110,10 @@ async def get(self, droplet_id: int, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -134442,9 +139156,9 @@ async def get(self, droplet_id: int, **kwargs: Any) -> JSON: measure for the disk size. }, "type": "str" # Optional. The type of disk. All - Droplets contain a ``local`` disk. Additionally, GPU Droplets can - also have a ``scratch`` disk for non-persistent data. Known values - are: "local" and "scratch". + Droplets contain a ``local`` or ``remote`` disk. Additionally, GPU + Droplets can also have a ``scratch`` disk for non-persistent data. + Known values are: "local", "remote", and "scratch". } ], "gpu_info": { @@ -135970,9 +140684,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -136017,9 +140732,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -150188,6 +154904,10 @@ async def list_clusters( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -150500,6 +155220,10 @@ async def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -150719,257 +155443,265 @@ async def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, - "rdma_shared_dev_plugin": { - "enabled": bool # Optional. Indicates whether the RDMA - shared device plugin is enabled. - }, - "registry_enabled": bool, # Optional. A read-only boolean value - indicating if a container registry is integrated with the cluster. - "routing_agent": { + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, + "rdma_shared_dev_plugin": { + "enabled": bool # Optional. Indicates whether the RDMA + shared device plugin is enabled. + }, + "registry_enabled": bool, # Optional. A read-only boolean value + indicating if a container registry is integrated with the cluster. + "routing_agent": { + "enabled": bool # Optional. Indicates whether the + routing-agent component is enabled. + }, + "service_subnet": "str", # Optional. The range of assignable IP + addresses for services running in the Kubernetes cluster in CIDR notation. + "sso": { + "client_id": "str", # Optional. The OIDC client ID + registered with the identity provider. Required when ``enabled`` is + ``true``. + "enabled": False, # Optional. Default value is False. + Indicates whether SSO authentication is enabled for the cluster. + "issuer_url": "str", # Optional. The OIDC issuer URL for the + identity provider. Required when ``enabled`` is ``true``. + "required": False # Optional. Default value is False. + Indicates whether any non-SSO forms of authentication are disallowed. Can + only be set to ``true`` when ``enabled`` is ``true``. + }, + "status": { + "message": "str", # Optional. An optional message providing + additional information about the current cluster state. + "state": "str" # Optional. A string indicating the current + status of the cluster. Known values are: "running", "provisioning", + "degraded", "error", "deleted", "upgrading", and "deleting". + }, + "surge_upgrade": False, # Optional. Default value is False. A + boolean value indicating whether surge upgrade is enabled/disabled for the + cluster. Surge upgrade makes cluster upgrades fast and reliable by bringing + up new nodes before destroying the outdated nodes. + "tags": [ + "str" # Optional. An array of tags to apply to the + Kubernetes cluster. All clusters are automatically tagged ``k8s`` and + ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires ``tag:read`` + and ``tag:create`` scope, as well as ``tag:delete`` if existing tags are + getting removed. + ], + "updated_at": "2020-02-20 00:00:00", # Optional. A time value given + in ISO8601 combined date and time format that represents when the Kubernetes + cluster was last updated. + "vpc_uuid": "str", # Optional. A string specifying the UUID of the + VPC to which the Kubernetes cluster is + assigned.:code:`
`:code:`
`Requires ``vpc:read`` scope. + "worker_subnet_uuid": "str" # Optional. The UUID of the VPC subnet + to attach worker nodes to. When omitted on create, the default subnet for the + VPC is used. This value cannot be changed after the cluster is created. + ``vpc_uuid`` must also be set. :code:`
`:code:`
`Requires ``vpc:read`` + scope. + } + } + """ + + @overload + async def create_cluster( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a New Kubernetes Cluster. + + To create a new Kubernetes cluster, send a POST request to + ``/v2/kubernetes/clusters``. The request must contain at least one node pool + with at least one worker. + + The request may contain a maintenance window policy describing a time period + when disruptive maintenance tasks may be carried out. Omitting the policy + implies that a window will be chosen automatically. See + `here `_ + for details. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 201 + response == { + "kubernetes_cluster": { + "name": "str", # A human-readable name for a Kubernetes cluster. + Required. + "node_pools": [ + { + "auto_scale": bool, # Optional. A boolean value + indicating whether auto-scaling is enabled for this node pool. + "count": 0, # Optional. The number of Droplet + instances in the node pool. + "id": "str", # Optional. A unique ID that can be + used to identify and reference a specific node pool. + "labels": {}, # Optional. An object of key/value + mappings specifying labels to apply to all nodes in a pool. Labels + will automatically be applied to all existing nodes and any + subsequent nodes added to the pool. Note that when a label is + removed, it is not deleted from the nodes in the pool. + "max_nodes": 0, # Optional. The maximum number of + nodes that this node pool can be auto-scaled to. The value will be + ``0`` if ``auto_scale`` is set to ``false``. + "min_nodes": 0, # Optional. The minimum number of + nodes that this node pool can be auto-scaled to. The value will be + ``0`` if ``auto_scale`` is set to ``false``. + "name": "str", # Optional. A human-readable name for + the node pool. + "nodes": [ + { + "created_at": "2020-02-20 00:00:00", + # Optional. A time value given in ISO8601 combined date and + time format that represents when the node was created. + "droplet_id": "str", # Optional. The + ID of the Droplet used for the worker node. + "id": "str", # Optional. A unique ID + that can be used to identify and reference the node. + "name": "str", # Optional. An + automatically generated, human-readable name for the node. + "status": { + "state": "str" # Optional. A + string indicating the current status of the node. Known + values are: "provisioning", "running", "draining", and + "deleting". + }, + "updated_at": "2020-02-20 00:00:00" + # Optional. A time value given in ISO8601 combined date and + time format that represents when the node was last updated. + } + ], + "size": "str", # Optional. The slug identifier for + the type of Droplet used as workers in the node pool. + "tags": [ + "str" # Optional. An array containing the + tags applied to the node pool. All node pools are automatically + tagged ``k8s``"" , ``k8s-worker``"" , and + ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires + ``tag:read`` scope. + ], + "taints": [ + { + "effect": "str", # Optional. How the + node reacts to pods that it won't tolerate. Available effect + values are ``NoSchedule``"" , ``PreferNoSchedule``"" , and + ``NoExecute``. Known values are: "NoSchedule", + "PreferNoSchedule", and "NoExecute". + "key": "str", # Optional. An + arbitrary string. The ``key`` and ``value`` fields of the + ``taint`` object form a key-value pair. For example, if the + value of the ``key`` field is "special" and the value of the + ``value`` field is "gpu", the key value pair would be + ``special=gpu``. + "value": "str" # Optional. An + arbitrary string. The ``key`` and ``value`` fields of the + ``taint`` object form a key-value pair. For example, if the + value of the ``key`` field is "special" and the value of the + ``value`` field is "gpu", the key value pair would be + ``special=gpu``. + } + ] + } + ], + "region": "str", # The slug identifier for the region where the + Kubernetes cluster is located. Required. + "version": "str", # The slug identifier for the version of + Kubernetes used for the cluster. If set to a minor version (e.g. "1.14"), the + latest version within it will be used (e.g. "1.14.6-do.1"); if set to + "latest", the latest published version will be used. See the + ``/v2/kubernetes/options`` endpoint to find all currently available versions. + Required. + "amd_gpu_device_metrics_exporter_plugin": { + "enabled": bool # Optional. Indicates whether the AMD Device + Metrics Exporter is enabled. + }, + "amd_gpu_device_plugin": { + "enabled": bool # Optional. Indicates whether the AMD GPU + Device Plugin is enabled. + }, + "auto_upgrade": False, # Optional. Default value is False. A boolean + value indicating whether the cluster will be automatically upgraded to new + patch releases during its maintenance window. + "cluster_autoscaler_configuration": { + "expanders": [ + "str" # Optional. Customizes expanders used by + cluster-autoscaler. The autoscaler will apply each expander from the + provided list to narrow down the selection of node types created to + scale up, until either a single node type is left, or the list of + expanders is exhausted. If this flag is unset, autoscaler will use + its default expander ``random``. Passing an empty list ("" *not* + ``null``"" ) will unset any previous expander customizations. + Available expanders: * ``random``"" : Randomly selects a node group + to scale. * `priority`: Selects the node group with the highest + priority as per [user-provided + configuration](https://docs.digitalocean.com/products/kubernetes/how-to/autoscale/#configuring-priority-expander) + * ``least_waste``"" : Selects the node group that will result in the + least amount of idle resources. + ], + "scale_down_unneeded_time": "str", # Optional. Used to + customize how long a node is unneeded before being scaled down. + "scale_down_utilization_threshold": 0.0 # Optional. Used to + customize when cluster autoscaler scales down non-empty nodes by setting + the node utilization threshold. + }, + "cluster_subnet": "str", # Optional. The range of IP addresses for + the overlay network of the Kubernetes cluster in CIDR notation. + "control_plane_firewall": { + "allowed_addresses": [ + "str" # Optional. An array of public addresses (IPv4 + or CIDR) allowed to access the control plane. + ], + "enabled": bool # Optional. Indicates whether the control + plane firewall is enabled. + }, + "coredns_autoscaler": { + "enabled": bool # Optional. Indicates whether the CoreDNS + Cluster Proportional Autoscaler add-on is enabled. + }, + "created_at": "2020-02-20 00:00:00", # Optional. A time value given + in ISO8601 combined date and time format that represents when the Kubernetes + cluster was created. + "endpoint": "str", # Optional. The base URL of the API server on the + Kubernetes master node. + "ha": bool, # Optional. A boolean value indicating whether the + control plane is run in a highly available configuration in the cluster. + Highly available control planes incur less downtime. The property cannot be + disabled. When omitted on create, the default is version-dependent; for DOKS + 1.36.0 and later, the default is true; for earlier versions, the default is + false. + "id": "str", # Optional. A unique ID that can be used to identify + and reference a Kubernetes cluster. + "ipv4": "str", # Optional. The public IPv4 address of the Kubernetes + master node. This will not be set if high availability is configured on the + cluster (v1.21+). + "maintenance_policy": { + "day": "str", # Optional. The day of the maintenance window + policy. May be one of ``monday`` through ``sunday``"" , or ``any`` to + indicate an arbitrary week day. Known values are: "any", "monday", + "tuesday", "wednesday", "thursday", "friday", "saturday", and "sunday". + "duration": "str", # Optional. The duration of the + maintenance window policy in human-readable format. + "start_time": "str" # Optional. The start time in UTC of the + maintenance window policy in 24-hour clock format / HH:MM notation (e.g., + ``15:00``"" ). + }, + "nvidia_gpu_device_plugin": { + "enabled": bool # Optional. Indicates whether the Nvidia GPU + Device Plugin is enabled. + }, + "p2p_oci_registry_plugin": { "enabled": bool # Optional. Indicates whether the - routing-agent component is enabled. - }, - "service_subnet": "str", # Optional. The range of assignable IP - addresses for services running in the Kubernetes cluster in CIDR notation. - "sso": { - "client_id": "str", # Optional. The OIDC client ID - registered with the identity provider. Required when ``enabled`` is - ``true``. - "enabled": False, # Optional. Default value is False. - Indicates whether SSO authentication is enabled for the cluster. - "issuer_url": "str", # Optional. The OIDC issuer URL for the - identity provider. Required when ``enabled`` is ``true``. - "required": False # Optional. Default value is False. - Indicates whether any non-SSO forms of authentication are disallowed. Can - only be set to ``true`` when ``enabled`` is ``true``. - }, - "status": { - "message": "str", # Optional. An optional message providing - additional information about the current cluster state. - "state": "str" # Optional. A string indicating the current - status of the cluster. Known values are: "running", "provisioning", - "degraded", "error", "deleted", "upgrading", and "deleting". - }, - "surge_upgrade": False, # Optional. Default value is False. A - boolean value indicating whether surge upgrade is enabled/disabled for the - cluster. Surge upgrade makes cluster upgrades fast and reliable by bringing - up new nodes before destroying the outdated nodes. - "tags": [ - "str" # Optional. An array of tags to apply to the - Kubernetes cluster. All clusters are automatically tagged ``k8s`` and - ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires ``tag:read`` - and ``tag:create`` scope, as well as ``tag:delete`` if existing tags are - getting removed. - ], - "updated_at": "2020-02-20 00:00:00", # Optional. A time value given - in ISO8601 combined date and time format that represents when the Kubernetes - cluster was last updated. - "vpc_uuid": "str", # Optional. A string specifying the UUID of the - VPC to which the Kubernetes cluster is - assigned.:code:`
`:code:`
`Requires ``vpc:read`` scope. - "worker_subnet_uuid": "str" # Optional. The UUID of the VPC subnet - to attach worker nodes to. When omitted on create, the default subnet for the - VPC is used. This value cannot be changed after the cluster is created. - ``vpc_uuid`` must also be set. :code:`
`:code:`
`Requires ``vpc:read`` - scope. - } - } - """ - - @overload - async def create_cluster( - self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any - ) -> JSON: - # pylint: disable=line-too-long - """Create a New Kubernetes Cluster. - - To create a new Kubernetes cluster, send a POST request to - ``/v2/kubernetes/clusters``. The request must contain at least one node pool - with at least one worker. - - The request may contain a maintenance window policy describing a time period - when disruptive maintenance tasks may be carried out. Omitting the policy - implies that a window will be chosen automatically. See - `here `_ - for details. - - :param body: Required. - :type body: IO[bytes] - :keyword content_type: Body Parameter content-type. Content type parameter for binary body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # response body for status code(s): 201 - response == { - "kubernetes_cluster": { - "name": "str", # A human-readable name for a Kubernetes cluster. - Required. - "node_pools": [ - { - "auto_scale": bool, # Optional. A boolean value - indicating whether auto-scaling is enabled for this node pool. - "count": 0, # Optional. The number of Droplet - instances in the node pool. - "id": "str", # Optional. A unique ID that can be - used to identify and reference a specific node pool. - "labels": {}, # Optional. An object of key/value - mappings specifying labels to apply to all nodes in a pool. Labels - will automatically be applied to all existing nodes and any - subsequent nodes added to the pool. Note that when a label is - removed, it is not deleted from the nodes in the pool. - "max_nodes": 0, # Optional. The maximum number of - nodes that this node pool can be auto-scaled to. The value will be - ``0`` if ``auto_scale`` is set to ``false``. - "min_nodes": 0, # Optional. The minimum number of - nodes that this node pool can be auto-scaled to. The value will be - ``0`` if ``auto_scale`` is set to ``false``. - "name": "str", # Optional. A human-readable name for - the node pool. - "nodes": [ - { - "created_at": "2020-02-20 00:00:00", - # Optional. A time value given in ISO8601 combined date and - time format that represents when the node was created. - "droplet_id": "str", # Optional. The - ID of the Droplet used for the worker node. - "id": "str", # Optional. A unique ID - that can be used to identify and reference the node. - "name": "str", # Optional. An - automatically generated, human-readable name for the node. - "status": { - "state": "str" # Optional. A - string indicating the current status of the node. Known - values are: "provisioning", "running", "draining", and - "deleting". - }, - "updated_at": "2020-02-20 00:00:00" - # Optional. A time value given in ISO8601 combined date and - time format that represents when the node was last updated. - } - ], - "size": "str", # Optional. The slug identifier for - the type of Droplet used as workers in the node pool. - "tags": [ - "str" # Optional. An array containing the - tags applied to the node pool. All node pools are automatically - tagged ``k8s``"" , ``k8s-worker``"" , and - ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires - ``tag:read`` scope. - ], - "taints": [ - { - "effect": "str", # Optional. How the - node reacts to pods that it won't tolerate. Available effect - values are ``NoSchedule``"" , ``PreferNoSchedule``"" , and - ``NoExecute``. Known values are: "NoSchedule", - "PreferNoSchedule", and "NoExecute". - "key": "str", # Optional. An - arbitrary string. The ``key`` and ``value`` fields of the - ``taint`` object form a key-value pair. For example, if the - value of the ``key`` field is "special" and the value of the - ``value`` field is "gpu", the key value pair would be - ``special=gpu``. - "value": "str" # Optional. An - arbitrary string. The ``key`` and ``value`` fields of the - ``taint`` object form a key-value pair. For example, if the - value of the ``key`` field is "special" and the value of the - ``value`` field is "gpu", the key value pair would be - ``special=gpu``. - } - ] - } - ], - "region": "str", # The slug identifier for the region where the - Kubernetes cluster is located. Required. - "version": "str", # The slug identifier for the version of - Kubernetes used for the cluster. If set to a minor version (e.g. "1.14"), the - latest version within it will be used (e.g. "1.14.6-do.1"); if set to - "latest", the latest published version will be used. See the - ``/v2/kubernetes/options`` endpoint to find all currently available versions. - Required. - "amd_gpu_device_metrics_exporter_plugin": { - "enabled": bool # Optional. Indicates whether the AMD Device - Metrics Exporter is enabled. - }, - "amd_gpu_device_plugin": { - "enabled": bool # Optional. Indicates whether the AMD GPU - Device Plugin is enabled. - }, - "auto_upgrade": False, # Optional. Default value is False. A boolean - value indicating whether the cluster will be automatically upgraded to new - patch releases during its maintenance window. - "cluster_autoscaler_configuration": { - "expanders": [ - "str" # Optional. Customizes expanders used by - cluster-autoscaler. The autoscaler will apply each expander from the - provided list to narrow down the selection of node types created to - scale up, until either a single node type is left, or the list of - expanders is exhausted. If this flag is unset, autoscaler will use - its default expander ``random``. Passing an empty list ("" *not* - ``null``"" ) will unset any previous expander customizations. - Available expanders: * ``random``"" : Randomly selects a node group - to scale. * `priority`: Selects the node group with the highest - priority as per [user-provided - configuration](https://docs.digitalocean.com/products/kubernetes/how-to/autoscale/#configuring-priority-expander) - * ``least_waste``"" : Selects the node group that will result in the - least amount of idle resources. - ], - "scale_down_unneeded_time": "str", # Optional. Used to - customize how long a node is unneeded before being scaled down. - "scale_down_utilization_threshold": 0.0 # Optional. Used to - customize when cluster autoscaler scales down non-empty nodes by setting - the node utilization threshold. - }, - "cluster_subnet": "str", # Optional. The range of IP addresses for - the overlay network of the Kubernetes cluster in CIDR notation. - "control_plane_firewall": { - "allowed_addresses": [ - "str" # Optional. An array of public addresses (IPv4 - or CIDR) allowed to access the control plane. - ], - "enabled": bool # Optional. Indicates whether the control - plane firewall is enabled. - }, - "coredns_autoscaler": { - "enabled": bool # Optional. Indicates whether the CoreDNS - Cluster Proportional Autoscaler add-on is enabled. - }, - "created_at": "2020-02-20 00:00:00", # Optional. A time value given - in ISO8601 combined date and time format that represents when the Kubernetes - cluster was created. - "endpoint": "str", # Optional. The base URL of the API server on the - Kubernetes master node. - "ha": bool, # Optional. A boolean value indicating whether the - control plane is run in a highly available configuration in the cluster. - Highly available control planes incur less downtime. The property cannot be - disabled. When omitted on create, the default is version-dependent; for DOKS - 1.36.0 and later, the default is true; for earlier versions, the default is - false. - "id": "str", # Optional. A unique ID that can be used to identify - and reference a Kubernetes cluster. - "ipv4": "str", # Optional. The public IPv4 address of the Kubernetes - master node. This will not be set if high availability is configured on the - cluster (v1.21+). - "maintenance_policy": { - "day": "str", # Optional. The day of the maintenance window - policy. May be one of ``monday`` through ``sunday``"" , or ``any`` to - indicate an arbitrary week day. Known values are: "any", "monday", - "tuesday", "wednesday", "thursday", "friday", "saturday", and "sunday". - "duration": "str", # Optional. The duration of the - maintenance window policy in human-readable format. - "start_time": "str" # Optional. The start time in UTC of the - maintenance window policy in 24-hour clock format / HH:MM notation (e.g., - ``15:00``"" ). - }, - "nvidia_gpu_device_plugin": { - "enabled": bool # Optional. Indicates whether the Nvidia GPU - Device Plugin is enabled. + Peer-to-peer OCI registry component is enabled. }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA @@ -151209,6 +155941,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -151428,6 +156164,10 @@ async def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> J "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -151741,6 +156481,10 @@ async def get_cluster(self, cluster_id: str, **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -151983,6 +156727,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152179,6 +156927,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152443,6 +157195,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152600,6 +157356,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -152796,6 +157556,10 @@ async def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -178398,9 +183162,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -178472,9 +183237,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -178532,9 +183298,10 @@ async def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -178821,9 +183588,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: JSON @@ -178888,9 +183656,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: IO[bytes] @@ -178946,9 +183715,10 @@ async def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Is either a JSON type or a IO[bytes] type. Required. :type body: JSON or IO[bytes] @@ -192237,9 +197007,10 @@ async def list(self, *, per_page: int = 20, page: int = 1, **kwargs: Any) -> JSO of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { diff --git a/src/pydo/custom_extensions.py b/src/pydo/custom_extensions.py index dc52efe0..987eca14 100644 --- a/src/pydo/custom_extensions.py +++ b/src/pydo/custom_extensions.py @@ -462,6 +462,20 @@ async def _auto_streaming_call( # SSE stream iterators # --------------------------------------------------------------------------- +_HAS_MORE_RE = re.compile(r"^has_more\s*=\s*(true|false)$", re.IGNORECASE) + + +def _parse_has_more_comment(line: str) -> Optional[bool]: + """Read ``has_more`` out of an SSE comment line (``: has_more=true``). + + Returns ``None`` for any other comment, such as harness-api's + ``: connected to `` banner. + """ + match = _HAS_MORE_RE.match(line.lstrip(":").strip()) + if match is None: + return None + return match.group(1).lower() == "true" + class SSEStream: """Synchronous iterator over Server-Sent Events. @@ -485,10 +499,14 @@ class SSEStream: For automatic retries on **transient** transport errors **before any chunk is yielded**, see :func:`iter_sse_with_retry`. + + :ivar has_more: ``True``/``False`` once the server has sent a + ``: has_more=...`` comment (harness history pages), else ``None``. """ def __init__(self, response: Any): self._response = response + self.has_more: Optional[bool] = None def __iter__(self) -> Iterator[dict]: return self._iter_events() @@ -505,6 +523,11 @@ def _iter_events(self) -> Iterator[dict]: line = line.strip() if not line: continue + if line.startswith(":"): + has_more = _parse_has_more_comment(line) + if has_more is not None: + self.has_more = has_more + continue if line.startswith("data:"): data = line[5:].strip() if data == "[DONE]": @@ -546,10 +569,14 @@ class AsyncSSEStream: Transport and decode errors match :class:`SSEStream`. See :func:`async_iter_sse_with_retry` for retries before the first chunk. + + :ivar has_more: ``True``/``False`` once the server has sent a + ``: has_more=...`` comment (harness history pages), else ``None``. """ def __init__(self, response: Any): self._response = response + self.has_more: Optional[bool] = None def __aiter__(self) -> AsyncIterator[dict]: return self._iter_events() @@ -566,6 +593,11 @@ async def _iter_events(self) -> AsyncIterator[dict]: line = line.strip() if not line: continue + if line.startswith(":"): + has_more = _parse_has_more_comment(line) + if has_more is not None: + self.has_more = has_more + continue if line.startswith("data:"): data = line[5:].strip() if data == "[DONE]": diff --git a/src/pydo/gateway/__init__.py b/src/pydo/gateway/__init__.py new file mode 100644 index 00000000..e137bc2b --- /dev/null +++ b/src/pydo/gateway/__init__.py @@ -0,0 +1,170 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway API — hand-written; preserved across ``make generate``. + +Session-first surface: create a session on the DigitalOcean API +(generated ``POST /v2/action-gateway/sessions``), then discover/invoke tools and run code +through the API-returned MCP endpoint. Composio-style providers make session +tools plug into pydo inference surfaces (chat completions, messages, responses). +""" + +from __future__ import annotations + +from typing import Any, List, Optional, Sequence + +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + META_TOOL_NAMES, + GatewayProtocolError, + GatewayToolError, + RecoveryHint, + ToolCall, + Toolbelt, + ToolErrorClass, + ToolResultStatus, +) +from .custom_operations import ( + CodeOperations, + ToolsOperations, + normalize_invoke_arguments, +) +from .providers import ( + BaseProvider, + ChatCompletionsProvider, + MessagesProvider, + ResponsesProvider, + default_provider, + execute_tool_calls, + simplify_inference_tool_schema, + simplify_messages_input_schema, +) +from .session import ( + Session, + SessionsOperations, + normalize_permissions, +) +from .transport import ( + ACTOR_ID_HEADER, + MCP_PROTOCOL_VERSION, + SESSION_ID_HEADER, + DEFAULT_GATEWAY_BASE_URL, + GatewayTransport, + MCPTransport, + RESTTransport, + resolve_gateway_base_url, + session_mcp_url, +) + +_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" # kept for docs / discoverability + + +class GatewayResources: + """Action Gateway surface attached at ``client.gateway``. + + Primary entry point is :attr:`sessions` — create a :class:`Session` before + invoking tools. Legacy ``tools`` / ``code`` attributes require an explicit + session-bound transport and are not usable until a session exists. + """ + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + transport: Optional[GatewayTransport] = None, + ): + self._parent = parent_client + self._gateway_endpoint = gateway_endpoint + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self.provider = provider or default_provider() + self.sessions = SessionsOperations( + parent_client, + gateway_endpoint=gateway_endpoint, + provider=self.provider, + ) + # Optional pre-bound transport (tests). Production callers use sessions. + self._transport = transport + if transport is not None: + self.tools = ToolsOperations(transport, self.provider) + self.code = CodeOperations(transport) + else: + self.tools = None + self.code = None + + @property + def base_url(self) -> str: + return self._gateway_base_url + + def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Deprecated path — prefer ``session.handle_tool_calls(response)``.""" + if self.tools is None: + raise RuntimeError( + "create a session first: session = client.sessions.create(" + "actor_id=...); then session.handle_tool_calls(response)" + ) + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + def execute_tool_calls( + self, + calls: Sequence[ToolCall], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + if self.tools is None: + raise RuntimeError( + "create a session first via client.sessions.create(actor_id=...)" + ) + return execute_tool_calls(calls, self.tools, rationale=rationale) + + +__all__ = [ + "GatewayResources", + "Session", + "SessionsOperations", + "normalize_permissions", + "ToolsOperations", + "CodeOperations", + "normalize_invoke_arguments", + "GatewayTransport", + "RESTTransport", + "MCPTransport", + "MCP_PROTOCOL_VERSION", + "ACTOR_ID_HEADER", + "SESSION_ID_HEADER", + "session_mcp_url", + "BaseProvider", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "default_provider", + "execute_tool_calls", + "simplify_inference_tool_schema", + "simplify_messages_input_schema", + "ToolCall", + "Toolbelt", + "GatewayToolError", + "GatewayProtocolError", + "ToolErrorClass", + "ToolResultStatus", + "RecoveryHint", + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/gateway/custom_models.py b/src/pydo/gateway/custom_models.py new file mode 100644 index 00000000..65b731f6 --- /dev/null +++ b/src/pydo/gateway/custom_models.py @@ -0,0 +1,188 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway constants, errors, and shared value types.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from azure.core.exceptions import HttpResponseError, ResourceExistsError + +# Meta-tool names exposed on the gateway's ``/mcp/meta`` endpoint. +META_SEARCH = "action_search" +META_INVOKE = "action_invoke" +META_CODE = "action_code" +META_TOOL_NAMES = frozenset({META_SEARCH, META_INVOKE, META_CODE}) + + +class ToolResultStatus: + """Status of a single tool invocation envelope.""" + + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class ToolErrorClass: + """Closed error taxonomy for tool invocation failures.""" + + INVALID_ARGUMENT = "invalid_argument" + UNAUTHORIZED = "unauthorized" + FORBIDDEN = "forbidden" + RATE_LIMITED = "rate_limited" + NOT_FOUND = "not_found" + TIMEOUT = "timeout" + UPSTREAM_ERROR = "upstream_error" + OUTPUT_TOO_LARGE = "output_too_large" + EXECUTION_FAILED = "execution_failed" + UNAVAILABLE = "unavailable" + CANCELED = "canceled" + + +class RecoveryHint: + """Machine-routable hint on how a caller should recover from a failure.""" + + FIX_ARGS = "fix_args" + REFRESH_AUTH = "refresh_auth" + RETRY_LATER = "retry_later" + NARROW_OUTPUT = "narrow_output" + CONTACT_SUPPORT = "contact_support" + + +class GatewayToolError(RuntimeError): + """A tool invocation failed (gateway ``ToolResult`` error envelope). + + Raised when a single-tool operation (``invoke_one``, ``code.execute``, + ``tools.call``) fails, or when the MCP result reports ``isError``. + Batch ``invoke`` calls do NOT raise per-item failures; inspect the + envelope instead. + """ + + def __init__( + self, + message: str, + *, + error_class: Optional[str] = None, + retriable: Optional[bool] = None, + recovery_hint: Optional[str] = None, + invocation_id: Optional[str] = None, + details: Optional[Any] = None, + meta: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.message = message + self.error_class = error_class + self.retriable = retriable + self.recovery_hint = recovery_hint + self.invocation_id = invocation_id + self.details = details + self.meta = meta + + @classmethod + def from_error_payload( + cls, + error: Dict[str, Any], + *, + invocation_id: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, + ) -> "GatewayToolError": + return cls( + error.get("message") or "tool invocation failed", + error_class=error.get("class"), + retriable=error.get("retriable"), + recovery_hint=error.get("recovery_hint"), + invocation_id=invocation_id, + details=error, + meta=meta, + ) + + +class GatewayProtocolError(RuntimeError): + """A JSON-RPC protocol-level error from the gateway MCP endpoint.""" + + def __init__( + self, + message: str, + *, + code: Optional[int] = None, + data: Optional[Any] = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.data = data + + +class ToolCall: + """A normalized tool call extracted from an inference response. + + ``arguments`` is always a decoded ``dict`` (providers JSON-decode the + vendor's string encoding when needed). + """ + + __slots__ = ("call_id", "name", "arguments") + + def __init__(self, call_id: str, name: str, arguments: Dict[str, Any]): + self.call_id = call_id + self.name = name + self.arguments = arguments + + def __repr__(self) -> str: # pragma: no cover - debug aid + return ( + f"ToolCall(call_id={self.call_id!r}, name={self.name!r}, " + f"arguments={self.arguments!r})" + ) + + +class Toolbelt(dict): + """A generated toolbelt response with a concise ``ref`` alias.""" + + @classmethod + def from_response(cls, response: Any) -> "Toolbelt": + """Accept the documented envelope and legacy flat API response.""" + if not isinstance(response, dict): + raise GatewayProtocolError( + f"unexpected toolbelt create response: {response!r}" + ) + data = response.get("toolbelt", response) + if not isinstance(data, dict) or not data.get("reference"): + raise GatewayProtocolError( + f"toolbelt create response missing toolbelt reference: {response!r}" + ) + return cls(data) + + @staticmethod + def validate_create_response( + pipeline_response: Any, response: Any, _headers: Any + ) -> Any: + """Raise for generated error responses before returning the body.""" + http_response = pipeline_response.http_response + if http_response.status_code == 409: + raise ResourceExistsError(response=http_response) + if http_response.status_code != 200: + raise HttpResponseError(response=http_response) + return response + + def __getattr__(self, name: str) -> Any: + if name == "ref": + return self.get("reference") + try: + return self[name] + except KeyError: + raise AttributeError(name) from None + + +__all__: List[str] = [ + "META_SEARCH", + "META_INVOKE", + "META_CODE", + "META_TOOL_NAMES", + "ToolResultStatus", + "ToolErrorClass", + "RecoveryHint", + "GatewayToolError", + "GatewayProtocolError", + "ToolCall", + "Toolbelt", +] diff --git a/src/pydo/gateway/custom_operations.py b/src/pydo/gateway/custom_operations.py new file mode 100644 index 00000000..511eed4e --- /dev/null +++ b/src/pydo/gateway/custom_operations.py @@ -0,0 +1,356 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway operations (tools + sandboxed code execution). + +Every method delegates to a :class:`~pydo.gateway.transport.GatewayTransport`, +so the public return shapes hold regardless of the underlying wire protocol +(MCP JSON-RPC today, REST later). +""" + +from __future__ import annotations + +import json as _json +from typing import Any, Dict, List, Optional, Sequence, Union + +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + GatewayToolError, + ToolResultStatus, +) +from .transport import GatewayTransport + +_MAX_SEARCH_QUERIES = 5 +_MAX_INVOKE_TOOLS = 10 + +QueryInput = Union[str, Dict[str, Any]] +ToolSpecInput = Dict[str, Any] + + +def _normalize_queries( + queries: Union[QueryInput, Sequence[QueryInput]], +) -> List[Dict[str, Any]]: + """Accept a single use-case str, a list of strs, or dicts with ``use_case``.""" + if isinstance(queries, (str, dict)): + queries = [queries] + normalized: List[Dict[str, Any]] = [] + for query in queries: + if isinstance(query, str): + entry: Dict[str, Any] = {"use_case": query} + elif isinstance(query, dict): + if not query.get("use_case"): + raise ValueError("each search query dict requires a 'use_case'") + entry = {"use_case": query["use_case"]} + if query.get("known_fields"): + entry["known_fields"] = query["known_fields"] + else: + raise TypeError("queries must be str or dict entries") + normalized.append(entry) + if not 1 <= len(normalized) <= _MAX_SEARCH_QUERIES: + raise ValueError(f"search accepts between 1 and {_MAX_SEARCH_QUERIES} queries") + return normalized + + +def _decode_json_object(value: Any) -> Dict[str, Any]: + if isinstance(value, str): + if not value.strip(): + return {} + return _json.loads(value) + if isinstance(value, dict): + return dict(value) + return {} + + +_INVOKE_ENTRY_RESERVED_KEYS = frozenset( + {"tool", "tool_slug", "name", "function", "type", "id"} +) + + +def _normalize_invoke_entry(spec: Any) -> Dict[str, Any]: + """Normalize one ``action.invoke`` tool entry to ``{tool, arguments}``. + + Models often emit chat-style ``{"function": {"name", "arguments"}}`` blobs + inside ``action.invoke`` even though the gateway expects ``tool`` / + ``tool_slug``. This helper accepts both shapes (plus a flat ``name`` key + and hoisted argument fields). + """ + if not isinstance(spec, dict): + raise TypeError( + "each invoke entry must be a dict like " + "{'tool': name, 'arguments': {...}}" + ) + + function = spec.get("function") + if isinstance(function, dict): + name = ( + function.get("name") + or spec.get("tool") + or spec.get("tool_slug") + or spec.get("name") + ) + if not name: + raise ValueError("each invoke entry requires a tool name") + if function.get("arguments") is not None: + arguments = _decode_json_object(function.get("arguments")) + else: + arguments = _decode_json_object(spec.get("arguments")) + return {"tool": name, "arguments": arguments} + + name = spec.get("tool") or spec.get("tool_slug") or spec.get("name") + if not name: + raise ValueError("each invoke entry requires a 'tool' name") + + arguments = spec.get("arguments") + if arguments is None: + hoisted = { + k: v for k, v in spec.items() if k not in _INVOKE_ENTRY_RESERVED_KEYS + } + arguments = hoisted if hoisted else {} + else: + arguments = _decode_json_object(arguments) + return {"tool": name, "arguments": arguments} + + +def normalize_invoke_arguments(arguments: Any) -> Dict[str, Any]: + """Normalize an ``action.invoke`` arguments object before calling the gateway.""" + if not isinstance(arguments, dict): + return {"tools": []} + normalized = dict(arguments) + tools = normalized.get("tools") + if tools is None: + return normalized + if isinstance(tools, dict): + tools = [tools] + elif not isinstance(tools, list): + tools = [tools] + normalized["tools"] = [_normalize_invoke_entry(entry) for entry in tools] + return normalized + + +def _normalize_tool_specs(tools: Sequence[ToolSpecInput]) -> List[Dict[str, Any]]: + """Normalize invoke entries; ``tool_slug`` is accepted as alias for ``tool``.""" + normalized = [_normalize_invoke_entry(spec) for spec in tools] + if not 1 <= len(normalized) <= _MAX_INVOKE_TOOLS: + raise ValueError(f"invoke accepts between 1 and {_MAX_INVOKE_TOOLS} tools") + return normalized + + +def _result_output_or_raise(item_result: Any, tool_name: str) -> Any: + """Unwrap one invoke ``ToolResult`` envelope; raise on failure.""" + get = getattr(item_result, "get", None) + if get is None: + return item_result + status = get("status") + if status and status != ToolResultStatus.SUCCEEDED: + error = get("error") or {} + raise GatewayToolError.from_error_payload( + dict(error) if error else {"message": f"tool {tool_name!r} failed"}, + invocation_id=get("invocation_id"), + ) + return get("output") + + +class ToolsOperations: + """Action Gateway tool discovery and invocation. + + Calling the instance itself (``client.gateway.tools()``) returns + provider-formatted tool definitions ready for an inference ``tools=`` + parameter — see :mod:`pydo.gateway.providers`. + """ + + def __init__(self, transport: GatewayTransport, provider: Any = None): + self._transport = transport + self._provider = provider + + # -- discovery --------------------------------------------------------- + + def list(self, *, include_all: bool = False) -> Any: + """List available tools. + + By default returns the three meta-tools (``action.search``, + ``action.invoke``, ``action.code``) — the intended agent workflow. + Pass ``include_all=True`` for every tool exposed on the session MCP + endpoint, including configured ``preloadTools``. + """ + return self._transport.list_tools(meta=not include_all) + + def search( + self, + queries: Union[QueryInput, Sequence[QueryInput]], + *, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> Any: + """Search the tool catalog by use case (``action.search``). + + :param queries: A use-case string, a list of strings, or dicts with + ``use_case`` (and optional ``known_fields``). 1–5 queries. + :param providers: Optional provider filters (e.g. ``["exa"]``). + :param tags: Optional tag filters. + :param limit: Per-query result cap. + """ + arguments: Dict[str, Any] = {"queries": _normalize_queries(queries)} + if providers: + arguments["providers"] = list(providers) + if tags: + arguments["tags"] = list(tags) + if limit is not None: + arguments["limit"] = limit + return self._transport.call_tool(META_SEARCH, arguments, meta=True) + + # -- execution --------------------------------------------------------- + + def invoke( + self, + tools: Sequence[ToolSpecInput], + *, + rationale: Optional[str] = None, + ) -> Any: + """Invoke 1–10 tools in parallel (``action.invoke``). + + Returns the full envelope (``total_count`` / ``success_count`` / + ``error_count`` / ``results[]``). Per-tool failures are reported + inside the envelope and do NOT raise. + """ + arguments: Dict[str, Any] = {"tools": _normalize_tool_specs(tools)} + if rationale: + arguments["rationale"] = rationale + return self._transport.call_tool(META_INVOKE, arguments, meta=True) + + def invoke_one( + self, + name: str, + arguments: Optional[Dict[str, Any]] = None, + *, + rationale: Optional[str] = None, + ) -> Any: + """Invoke a single tool and return its output directly. + + Raises :class:`GatewayToolError` if the tool failed. + """ + envelope = self.invoke( + [{"tool": name, "arguments": arguments or {}}], + rationale=rationale, + ) + get = getattr(envelope, "get", None) + results = (get("results") if get else None) or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + first = results[0] + item_result = (getattr(first, "get", lambda *_: first)("result")) or first + return _result_output_or_raise(item_result, name) + + def call(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Any: + """Call one concrete catalog tool directly (``tools/call`` on ``/mcp``). + + Unlike :meth:`invoke`, the result is the tool's output payload with + no invoke envelope; failures raise :class:`GatewayToolError`. + """ + return self._transport.call_tool(name, arguments or {}, meta=False) + + # -- inference integration (Composio-style) ----------------------------- + + def __call__( + self, + *, + include_all: bool = False, + names: Optional[Sequence[str]] = None, + search: Optional[Union[QueryInput, Sequence[QueryInput]]] = None, + providers: Optional[Sequence[str]] = None, + tags: Optional[Sequence[str]] = None, + limit: Optional[int] = None, + ) -> List[Any]: + """Return provider-formatted tool definitions for ``tools=``. + + By default wraps the three meta-tools so the model drives the + search → invoke → code workflow itself. Pass ``include_all=True``, + ``names=``, or ``search=`` to wrap selected tools instead. + """ + if self._provider is None: + raise RuntimeError( + "no gateway provider configured; pass gateway_provider= to " + "Client() or use tools.list()/tools.invoke() directly" + ) + catalog = self._fetch_catalog( + include_all=include_all, + names=names, + search=search, + providers=providers, + tags=tags, + limit=limit, + ) + return self._provider.wrap_tools(catalog) + + def _fetch_catalog( + self, + *, + include_all: bool, + names: Optional[Sequence[str]], + search: Optional[Union[QueryInput, Sequence[QueryInput]]], + providers: Optional[Sequence[str]], + tags: Optional[Sequence[str]], + limit: Optional[int], + ) -> List[Any]: + if search is not None: + payload = self.search(search, providers=providers, tags=tags, limit=limit) + return _flatten_search_results(payload) + wants_concrete = include_all or bool(names) + tools = self.list(include_all=wants_concrete) + if names: + wanted = set(names) + tools = [t for t in tools if _tool_name(t) in wanted] + missing = wanted - {_tool_name(t) for t in tools} + if missing: + raise LookupError(f"tools not found in catalog: {sorted(missing)}") + return list(tools) + + +def _tool_name(tool: Any) -> Optional[str]: + get = getattr(tool, "get", None) + return get("name") if get else getattr(tool, "name", None) + + +def _flatten_search_results(payload: Any) -> List[Any]: + """Flatten an ``action.search`` payload into a deduplicated tool list.""" + get = getattr(payload, "get", None) + groups = (get("results") if get else None) or [] + seen: Dict[str, Any] = {} + for group in groups: + group_get = getattr(group, "get", None) + matches = (group_get("results") if group_get else None) or [] + for match in matches: + name = _tool_name(match) + if name and name not in seen: + seen[name] = match + return list(seen.values()) + + +class CodeOperations: + """Ephemeral Python sandbox execution (``action.code``).""" + + def __init__(self, transport: GatewayTransport): + self._transport = transport + + def execute(self, code: str, *, thought: Optional[str] = None) -> Any: + """Run Python code in the gateway sandbox. + + Returns the execution output (``stdout`` / ``stderr`` / + ``exit_code``). Raises :class:`GatewayToolError` on sandbox failure. + """ + if not code or not code.strip(): + raise ValueError("code is empty") + arguments: Dict[str, Any] = {"code": code} + if thought: + arguments["thought"] = thought + return self._transport.call_tool(META_CODE, arguments, meta=True) + + +__all__ = [ + "ToolsOperations", + "CodeOperations", + "normalize_invoke_arguments", +] diff --git a/src/pydo/gateway/providers.py b/src/pydo/gateway/providers.py new file mode 100644 index 00000000..4a0f1edf --- /dev/null +++ b/src/pydo/gateway/providers.py @@ -0,0 +1,363 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Inference providers — translate gateway tools to/from vendor formats. + +pydo exposes three inference surfaces with three different tool wire +formats. A provider owns both directions of translation for one of them +(the Composio pattern): + +* :class:`ChatCompletionsProvider` — ``client.chat.completions.create`` + (OpenAI chat completions format). The default. +* :class:`MessagesProvider` — ``client.messages.create`` (Anthropic + Messages format). +* :class:`ResponsesProvider` — ``client.responses.create`` (OpenAI + Responses format). + +Usage:: + + client = Client(token=..., gateway_provider=MessagesProvider()) + tools = client.gateway.tools() # vendor tools= format + resp = client.messages.create(..., tools=tools, messages=messages) + messages += client.gateway.handle_tool_calls(resp) +""" + +from __future__ import annotations + +import copy +import json as _json +from typing import Any, Dict, List, Optional, Sequence + +from .custom_models import ToolCall +from .custom_operations import _normalize_invoke_entry, normalize_invoke_arguments + + +def simplify_inference_tool_schema(schema: Any) -> Dict[str, Any]: + """Normalize a gateway tool JSON Schema for inference ``tools=`` parameters. + + DO inference endpoints (chat completions, messages, responses) require a + plain top-level ``object`` schema. Gateway meta-tools such as + ``action.code`` use top-level combinators (``anyOf`` for Composio alias + args). We drop those keywords and keep ``properties``; the gateway still + validates aliases server-side. + """ + if not isinstance(schema, dict): + return {"type": "object", "properties": {}} + simplified = copy.deepcopy(schema) + for key in ( + "oneOf", + "allOf", + "anyOf", + "enum", + "const", + "not", + ): + simplified.pop(key, None) + if "type" not in simplified: + simplified["type"] = "object" + if simplified.get("type") == "object" and "properties" not in simplified: + simplified["properties"] = {} + return simplified + + +# Backward-compatible alias. +simplify_messages_input_schema = simplify_inference_tool_schema + + +def _get(obj: Any, key: str, default: Any = None) -> Any: + """Uniform field access over dicts/DotDicts and attribute objects.""" + getter = getattr(obj, "get", None) + if getter is not None: + return getter(key, default) + return getattr(obj, key, default) + + +def _decode_arguments(arguments: Any) -> Dict[str, Any]: + if isinstance(arguments, str): + if not arguments.strip(): + return {} + return _json.loads(arguments) + if isinstance(arguments, dict): + return dict(arguments) + return {} + + +def _tool_fields(tool: Any) -> Dict[str, Any]: + """Extract canonical fields from a gateway catalog/meta tool definition.""" + return { + "name": _get(tool, "name"), + "description": _get(tool, "description") or _get(tool, "title") or "", + "parameters": simplify_inference_tool_schema( + _get(tool, "inputSchema") or {"type": "object"} + ), + } + + +def _result_to_content(result: Any) -> str: + """Serialize one tool result (output or error payload) for the model.""" + if isinstance(result, str): + return result + try: + return _json.dumps(result, default=str) + except (TypeError, ValueError): + return str(result) + + +class BaseProvider: + """Translation contract between gateway tools and one inference surface.""" + + name = "base" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + """Convert canonical gateway tool defs to the vendor ``tools=`` format.""" + raise NotImplementedError + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + """Extract normalized tool calls from a vendor response object.""" + raise NotImplementedError + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + """Convert invocation outputs into vendor-shaped result messages/items.""" + raise NotImplementedError + + +class ChatCompletionsProvider(BaseProvider): + """OpenAI chat-completions format (``client.chat.completions.create``).""" + + name = "chat.completions" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + return [ + {"type": "function", "function": _tool_fields(tool)} + for tool in catalog_tools + ] + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + choices = _get(response, "choices") or [] + if not choices: + return [] + message = _get(choices[0], "message") or {} + calls = [] + for tool_call in _get(message, "tool_calls") or []: + function = _get(tool_call, "function") or {} + calls.append( + ToolCall( + call_id=_get(tool_call, "id") or "", + name=_get(function, "name") or "", + arguments=_decode_arguments(_get(function, "arguments")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + return [ + { + "role": "tool", + "tool_call_id": call.call_id, + "content": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + + +class MessagesProvider(BaseProvider): + """Anthropic Messages format (``client.messages.create``).""" + + name = "messages" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + wrapped = [] + for tool in catalog_tools: + fields = _tool_fields(tool) + wrapped.append( + { + "name": fields["name"], + "description": fields["description"], + "input_schema": fields["parameters"], + } + ) + return wrapped + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + calls = [] + for block in _get(response, "content") or []: + if _get(block, "type") != "tool_use": + continue + calls.append( + ToolCall( + call_id=_get(block, "id") or "", + name=_get(block, "name") or "", + arguments=_decode_arguments(_get(block, "input")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + if not calls: + return [] + content = [ + { + "type": "tool_result", + "tool_use_id": call.call_id, + "content": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + # Anthropic expects all tool results in a single user turn. + return [{"role": "user", "content": content}] + + +class ResponsesProvider(BaseProvider): + """OpenAI Responses format (``client.responses.create``).""" + + name = "responses" + + def wrap_tools(self, catalog_tools: Sequence[Any]) -> List[Dict[str, Any]]: + return [{"type": "function", **_tool_fields(tool)} for tool in catalog_tools] + + def extract_tool_calls(self, response: Any) -> List[ToolCall]: + calls = [] + for item in _get(response, "output") or []: + if _get(item, "type") != "function_call": + continue + calls.append( + ToolCall( + call_id=_get(item, "call_id") or _get(item, "id") or "", + name=_get(item, "name") or "", + arguments=_decode_arguments(_get(item, "arguments")), + ) + ) + return calls + + def format_tool_results( + self, + calls: Sequence[ToolCall], + results: Sequence[Any], + ) -> List[Dict[str, Any]]: + return [ + { + "type": "function_call_output", + "call_id": call.call_id, + "output": _result_to_content(result), + } + for call, result in zip(calls, results) + ] + + +def default_provider() -> BaseProvider: + return ChatCompletionsProvider() + + +def execute_tool_calls( + calls: Sequence[ToolCall], + tools_operations: Any, + *, + rationale: Optional[str] = None, +) -> List[Any]: + """Execute normalized tool calls against the gateway. + + Meta-tool calls (``action.search`` / ``action.invoke`` / ``action.code``) + go straight through; concrete tool names are batched through one + ``action.invoke``. Per-tool failures become structured error payloads + rather than raising, so the model can observe and recover. + """ + from .custom_models import META_INVOKE, META_TOOL_NAMES, GatewayToolError + + results: List[Any] = [None] * len(calls) + concrete: List[int] = [] + + for index, call in enumerate(calls): + if call.name in META_TOOL_NAMES: + try: + arguments = call.arguments + if call.name == META_INVOKE: + arguments = normalize_invoke_arguments(arguments) + results[index] = tools_operations._transport.call_tool( + call.name, arguments, meta=True + ) + except ( + GatewayToolError, + TypeError, + ValueError, + _json.JSONDecodeError, + ) as exc: + results[index] = _error_payload(exc) + else: + concrete.append(index) + + if concrete: + try: + batch = [ + _normalize_invoke_entry( + {"tool": calls[i].name, "arguments": calls[i].arguments} + ) + for i in concrete + ] + except (TypeError, ValueError, _json.JSONDecodeError) as exc: + error = _error_payload(exc) + for index in concrete: + results[index] = error + return results + envelope = tools_operations.invoke(batch, rationale=rationale) + items = (_get(envelope, "results") or []) if envelope is not None else [] + for position, index in enumerate(concrete): + if position < len(items): + item = items[position] + item_result = _get(item, "result") or item + status = _get(item_result, "status") + if status and status != "succeeded": + error_result = { + "error": _get(item_result, "error") + or {"message": f"tool {calls[index].name!r} failed"} + } + meta = _get(item_result, "_meta") + if meta: + error_result["_meta"] = meta + results[index] = error_result + else: + results[index] = _get(item_result, "output") + else: + results[index] = { + "error": {"message": "no result returned for this tool call"} + } + return results + + +def _error_payload(exc: Any) -> Dict[str, Any]: + payload = { + "error": { + "message": str(exc), + "class": getattr(exc, "error_class", None), + "retriable": getattr(exc, "retriable", None), + "recovery_hint": getattr(exc, "recovery_hint", None), + } + } + meta = getattr(exc, "meta", None) + if isinstance(meta, dict) and meta: + payload["_meta"] = meta + return payload + + +__all__ = [ + "BaseProvider", + "ChatCompletionsProvider", + "MessagesProvider", + "ResponsesProvider", + "default_provider", + "execute_tool_calls", + "simplify_inference_tool_schema", + "simplify_messages_input_schema", +] diff --git a/src/pydo/gateway/session.py b/src/pydo/gateway/session.py new file mode 100644 index 00000000..103e361e --- /dev/null +++ b/src/pydo/gateway/session.py @@ -0,0 +1,254 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +# pylint: disable=duplicate-code +"""Action Gateway sessions — create on the DO API, execute on the gateway.""" + +from __future__ import annotations + +import uuid +from typing import Any, Dict, List, Optional, Sequence + +from pydo.custom_extensions import _BaseURLProxy + +from .custom_models import GatewayProtocolError +from .custom_operations import CodeOperations, ToolsOperations +from .providers import BaseProvider, default_provider, execute_tool_calls +from .transport import ( + MCPTransport, + resolve_gateway_base_url, +) + +_DEFAULT_POLICY: Dict[str, Any] = {"defaultAction": "ask"} + + +def _pick(data: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +def normalize_permissions(permissions: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Normalize SDK permissions into the wire policy object. + + Accepts snake_case ``default_action`` or wire ``defaultAction``. When + omitted, returns ``{"defaultAction": "ask"}``. + """ + if permissions is None: + return dict(_DEFAULT_POLICY) + + default_action = ( + permissions.get("default_action") + if "default_action" in permissions + else permissions.get("defaultAction", "ask") + ) + rules_in = permissions.get("rules") or [] + rules: List[Dict[str, Any]] = [] + for rule in rules_in: + if not isinstance(rule, dict): + raise TypeError("each permissions rule must be a dict") + if "toolbelt" in rule: + raise ValueError( + "toolbelt permissions are no longer supported; " + "use a tool value such as 'toolbelt:my-belt@1'" + ) + entry: Dict[str, Any] = {"action": rule.get("action") or "allow"} + if rule.get("tool"): + entry["tool"] = rule["tool"] + if rule.get("match"): + entry["match"] = rule["match"] + if "tool" not in entry: + raise ValueError("each permissions rule requires tool") + rules.append(entry) + return {"defaultAction": default_action, "rules": rules} + + +class Session: + """A gateway session bound to an ``actor_id`` and tool policy. + + Create via :meth:`SessionsOperations.create`. Use ``url`` for external + MCP clients, ``tools()`` for inference ``tools=``, and + ``handle_tool_calls`` to execute model tool calls over MCP. + """ + + def __init__( + self, + *, + session_urn: str, + actor_id: str, + name: str, + policy: Dict[str, Any], + mcp_url: str, + tools: ToolsOperations, + code: CodeOperations, + provider: BaseProvider, + selected_tools: Optional[Sequence[str]] = None, + raw: Optional[Dict[str, Any]] = None, + ): + self.session_urn = session_urn + self.id = session_urn + self.actor_id = actor_id + self.name = name + self.policy = policy + self._mcp_url = mcp_url + self.tools = tools + self.code = code + self._transport = tools._transport + self.provider = provider + self.selected_tools = list(selected_tools or []) + self.raw = raw or {} + + @property + def url(self) -> str: + """Session-pinned MCP URL for external MCP clients.""" + return self._mcp_url + + def handle_tool_calls( + self, + response: Any, + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute tool calls from an inference response against this session.""" + calls = self.provider.extract_tool_calls(response) + if not calls: + return [] + results = execute_tool_calls(calls, self.tools, rationale=rationale) + return self.provider.format_tool_results(calls, results) + + def execute_tool_calls( + self, + calls: Sequence[Any], + *, + rationale: Optional[str] = None, + ) -> List[Any]: + """Execute pre-extracted tool calls; return raw outputs.""" + return execute_tool_calls(calls, self.tools, rationale=rationale) + + def approve(self, approval_id: str) -> Any: + """Approve a pending tool invocation for this session.""" + return self._transport.decide_approval(approval_id, "approve") + + def deny(self, approval_id: str) -> Any: + """Deny a pending tool invocation for this session.""" + return self._transport.decide_approval(approval_id, "deny") + + def __repr__(self) -> str: # pragma: no cover - debug aid + return f"" + + +class SessionsOperations: + """Create sessions through the generated Action Gateway operation.""" + + def __init__( + self, + parent_client: Any, + *, + gateway_endpoint: Optional[str] = None, + provider: Optional[BaseProvider] = None, + ): + self._parent = parent_client + self._sessions_api = parent_client.sessions + self._gateway_base_url = resolve_gateway_base_url(gateway_endpoint) + self._provider = provider or default_provider() + + def create( + self, + actor_id: str, + *, + name: Optional[str] = None, + permissions: Optional[Dict[str, Any]] = None, + tools: Optional[Sequence[str]] = None, + config: Optional[Dict[str, Any]] = None, + ) -> Session: + """Create a session. + + :param actor_id: Required actor identifier used to evaluate the policy. + :param name: Optional display name (auto-generated when omitted). + :param permissions: Optional policy. When omitted, defaults to + ``{"defaultAction": "ask"}``. + :param tools: Optional tool or version-pinned toolbelt references. + Omit for all tools; pass an empty sequence for no tools. + :param config: Optional session configuration, including + ``preloadTools``. + """ + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required") + + session_name = name or f"pydo-session-{uuid.uuid4().hex[:8]}" + policy = normalize_permissions(permissions) + body = { + "name": session_name, + "policy": policy, + "actor_id": str(actor_id).strip(), + } + if tools is not None: + if isinstance(tools, (str, bytes)): + raise TypeError("tools must be a sequence of tool references") + body["tools"] = list(tools) + if config is not None: + if not isinstance(config, dict): + raise TypeError("config must be a dict") + body["config"] = config + + raw_session = self._post_create(body) + session_urn = _pick(raw_session, "sessionUrn", "session_urn") + if not session_urn: + raise GatewayProtocolError( + f"session create response missing sessionUrn: {raw_session!r}" + ) + + mcp_url = _pick(raw_session, "mcpUrl", "mcp_url") + if not mcp_url: + raise GatewayProtocolError( + f"session create response missing mcpUrl: {raw_session!r}" + ) + + transport = MCPTransport( + _BaseURLProxy(self._parent._client, self._gateway_base_url), + session_id=session_urn, + actor_id=actor_id, + endpoint_url=mcp_url, + ) + tools = ToolsOperations(transport, self._provider) + code = CodeOperations(transport) + return Session( + session_urn=session_urn, + actor_id=str(actor_id).strip(), + name=_pick(raw_session, "name") or session_name, + policy=policy, + mcp_url=mcp_url, + tools=tools, + code=code, + provider=self._provider, + selected_tools=_pick(raw_session, "selectedTools") or [], + raw=raw_session, + ) + + def _post_create(self, body: Dict[str, Any]) -> Dict[str, Any]: + payload = self._sessions_api.create(body=body) + if not isinstance(payload, dict): + raise GatewayProtocolError( + f"unexpected session create response: {payload!r}" + ) + session = payload.get("session") + if not isinstance(session, dict): + raise GatewayProtocolError( + f"session create response missing session object: {payload!r}" + ) + result = dict(session) + mcp_url = _pick(payload, "mcpUrl", "mcp_url") + if mcp_url: + result["mcpUrl"] = mcp_url + if "tools" in payload: + result["selectedTools"] = payload["tools"] + return result + + +__all__ = [ + "Session", + "SessionsOperations", + "normalize_permissions", +] diff --git a/src/pydo/gateway/transport.py b/src/pydo/gateway/transport.py new file mode 100644 index 00000000..30851c31 --- /dev/null +++ b/src/pydo/gateway/transport.py @@ -0,0 +1,543 @@ +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Action Gateway wire layer. + +The public SDK surface (``ToolsOperations`` / ``CodeOperations``) only talks +to the small :class:`GatewayTransport` interface. Sessions use MCP JSON-RPC at +the endpoint returned by the create API. REST transports remain available for +the compatibility routes and focused testing. +""" + +from __future__ import annotations + +import itertools +import json as _json +import os +from typing import Any, Dict, List, Optional +from urllib.parse import quote, urlsplit + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceExistsError, + ResourceNotFoundError, + ResourceNotModifiedError, +) +from azure.core.rest import HttpRequest + +from pydo.custom_extensions import _wrap + +from .custom_models import ( + META_CODE, + META_INVOKE, + META_SEARCH, + GatewayProtocolError, + GatewayToolError, + ToolResultStatus, +) + +DEFAULT_GATEWAY_BASE_URL = "https://actions.do-ai.run" +_ENV_VAR = "PYDO_GATEWAY_ENDPOINT" + + +def resolve_gateway_base_url(explicit: Optional[str] = None) -> str: + url = explicit or os.environ.get(_ENV_VAR) or DEFAULT_GATEWAY_BASE_URL + url = url.rstrip("/") + if "://" not in url: + url = f"https://{url}" + return url + + +_ERROR_MAP = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, +} + +MCP_PROTOCOL_VERSION = "2025-06-18" +SESSION_ID_HEADER = "X-Session-Id" +ACTOR_ID_HEADER = "X-Actor-Id" + +_MCP_PATH = "/mcp" +_MCP_META_PATH = "/mcp/meta" +_REST_TOOLS_PATH = "/tools" +_REST_SEARCH_PATH = "/tools/search" +_REST_INVOKE_PATH = "/tools/invoke" +_REST_CODE_PATH = "/code/execute" + +_MCP_HEADERS = { + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + "Accept": "application/json, text/event-stream", +} + +_REST_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + +# Static meta-tool catalog for REST list(meta=True). Mirrors /mcp/meta. +_META_TOOL_DEFINITIONS: List[Dict[str, Any]] = [ + { + "name": META_SEARCH, + "title": "Action Search", + "description": ( + "Discover the catalog tools needed to satisfy one or more user " + "use cases. Call this before action_invoke whenever you need a " + "catalog tool you do not already have." + ), + "inputSchema": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "use_case": {"type": "string"}, + "known_fields": {"type": "string"}, + }, + "required": ["use_case"], + }, + }, + "providers": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer"}, + }, + "required": ["queries"], + }, + }, + { + "name": META_INVOKE, + "title": "Action Invoke", + "description": "Invoke 1–10 catalog tools in parallel.", + "inputSchema": { + "type": "object", + "properties": { + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "properties": { + "tool": {"type": "string"}, + "tool_slug": {"type": "string"}, + "arguments": {"type": "object"}, + }, + "anyOf": [ + {"required": ["tool"]}, + {"required": ["tool_slug"]}, + ], + }, + }, + "rationale": {"type": "string", "maxLength": 512}, + }, + "required": ["tools"], + }, + }, + { + "name": META_CODE, + "title": "Action Code", + "description": ( + "Run Python in an ephemeral sandbox. Use for computation, " + "parsing, or data processing — no prior action_search needed." + ), + "inputSchema": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "code_to_execute": {"type": "string"}, + "thought": {"type": "string"}, + }, + "anyOf": [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ], + }, + }, +] + + +def _response_body_text(response: Any) -> str: + try: + body = response.text() if hasattr(response, "text") else response.body() + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + return body or "" + except Exception: # noqa: BLE001 — best-effort error detail for callers + return "" + + +def _raise_gateway_http_error(response: Any) -> None: + body = _response_body_text(response) + message = body.strip() or getattr(response, "reason", None) or "request failed" + if response.status_code == 412: + message = ( + "team is not enabled for the Action Infra release " + f"(412 Precondition Failed): {message}" + ) + error_type = _ERROR_MAP.get(response.status_code) + if error_type: + raise error_type( + message=message, + response=response, + error_format=lambda _body: None, + ) + raise HttpResponseError(message=message, response=response) + + +def _content_text(content: Optional[List[Dict[str, Any]]]) -> str: + parts = [] + for block in content or []: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text") or "") + return "\n".join(p for p in parts if p) + + +def _unwrap_call_result(result: Dict[str, Any]) -> Any: + """Normalize an MCP ``tools/call`` result to its useful payload.""" + if result.get("isError"): + structured = result.get("structuredContent") + meta = result.get("_meta") + error = None + if isinstance(structured, dict): + error = structured.get("error") or ( + structured if "message" in structured else None + ) + if error: + raise GatewayToolError.from_error_payload( + error, + invocation_id=( + structured.get("invocation_id") + if isinstance(structured, dict) + else None + ), + meta=meta if isinstance(meta, dict) else None, + ) + raise GatewayToolError( + _content_text(result.get("content")) or "tool call failed", + meta=meta if isinstance(meta, dict) else None, + ) + + structured = result.get("structuredContent") + if structured is not None: + return _wrap(structured) + + text = _content_text(result.get("content")) + try: + return _wrap(_json.loads(text)) + except (TypeError, ValueError): + return text + + +def _parse_json_body(body: Any) -> Any: + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + if isinstance(body, (dict, list)): + return body + try: + return _json.loads(body) + except (TypeError, ValueError) as exc: + raise GatewayProtocolError( + f"gateway returned a non-JSON response: {body!r}" + ) from exc + + +def _parse_jsonrpc(body: Any) -> Dict[str, Any]: + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + if isinstance(body, str) and any( + line.startswith("data:") for line in body.splitlines() + ): + events = [] + data_lines = [] + for line in body.splitlines(): + if not line: + if data_lines: + events.append("\n".join(data_lines)) + data_lines = [] + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if data_lines: + events.append("\n".join(data_lines)) + for event in events: + try: + candidate = _parse_json_body(event) + except GatewayProtocolError: + continue + if isinstance(candidate, dict) and ( + "result" in candidate or "error" in candidate + ): + body = candidate + break + envelope = _parse_json_body(body) + if not isinstance(envelope, dict): + raise GatewayProtocolError( + f"gateway returned an unexpected JSON-RPC envelope: {envelope!r}" + ) + error = envelope.get("error") + if error: + raise GatewayProtocolError( + error.get("message") or "JSON-RPC error", + code=error.get("code"), + data=error.get("data"), + ) + result = envelope.get("result") + if not isinstance(result, dict): + raise GatewayProtocolError( + f"gateway JSON-RPC response is missing a result: {envelope!r}" + ) + return result + + +def _decode_output(value: Any) -> Any: + if isinstance(value, (bytes, bytearray)): + value = value.decode("utf-8", errors="replace") + if isinstance(value, str): + try: + return _json.loads(value) + except (TypeError, ValueError): + return value + return value + + +def _unwrap_tool_result(payload: Any) -> Any: + """Unwrap a REST ``ToolResult`` envelope; raise on failure.""" + if not isinstance(payload, dict): + return _wrap(payload) + status = payload.get("status") + if status and status != ToolResultStatus.SUCCEEDED: + error = payload.get("error") or {} + raise GatewayToolError.from_error_payload( + dict(error) if isinstance(error, dict) else {"message": str(error)}, + invocation_id=payload.get("invocation_id") or payload.get("call_id"), + ) + if "output" in payload: + return _wrap(_decode_output(payload.get("output"))) + return _wrap(payload) + + +def session_mcp_url(gateway_base_url: str, session_urn: str) -> str: + """Build the session-pinned MCP URL for external MCP clients.""" + base = gateway_base_url.rstrip("/") + session_id = _external_session_id(session_urn) + return f"{base}/mcp/session/{session_id}" + + +def _external_session_id(session_urn: str) -> str: + """Return the bare session ID accepted by Action Gateway ingress.""" + return session_urn.rsplit(":", 1)[-1] + + +class GatewayTransport: + """Swappable wire layer; MCP semantics are the lowest common denominator.""" + + def list_tools(self, *, meta: bool) -> List[Any]: + raise NotImplementedError + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + raise NotImplementedError + + def decide_approval(self, approval_id: str, decision: str) -> Any: + raise NotImplementedError + + def approve(self, approval_id: str) -> Any: + return self.decide_approval(approval_id, "approve") + + +class RESTTransport(GatewayTransport): + """REST over ``/tools``, ``/tools/search``, ``/tools/invoke``, ``/code/execute``. + + Requires a session URN or ID and actor ID on every request. + """ + + def __init__(self, base_url_proxy: Any, *, session_id: str, actor_id: str): + if not session_id: + raise ValueError("session_id is required for RESTTransport") + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for RESTTransport") + self._client = base_url_proxy + self.session_id = _external_session_id(session_id) + self.actor_id = str(actor_id).strip() + + def _headers(self) -> Dict[str, str]: + headers = dict(_REST_HEADERS) + headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id + return headers + + def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Any: + kwargs: Dict[str, Any] = {"headers": self._headers()} + if payload is not None: + kwargs["json"] = payload + request = HttpRequest(method, path, **kwargs) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if response.status_code != 200: + _raise_gateway_http_error(response) + return _parse_json_body(body) + + def list_tools(self, *, meta: bool) -> List[Any]: + if meta: + return _wrap([dict(tool) for tool in _META_TOOL_DEFINITIONS]) + catalog = self._request("GET", _REST_TOOLS_PATH) + if isinstance(catalog, dict): + return _wrap(catalog.get("tools") or []) + return _wrap(catalog or []) + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + arguments = arguments or {} + if name == META_SEARCH or (meta and name == META_SEARCH): + return _unwrap_tool_result( + self._request("POST", _REST_SEARCH_PATH, arguments) + ) + if name == META_INVOKE or (meta and name == META_INVOKE): + # Invoke returns the batch envelope directly (not ToolResult). + return _wrap(self._request("POST", _REST_INVOKE_PATH, arguments)) + if name == META_CODE or (meta and name == META_CODE): + return _unwrap_tool_result( + self._request("POST", _REST_CODE_PATH, arguments) + ) + # Concrete catalog tool → single-item invoke. + envelope = self._request( + "POST", + _REST_INVOKE_PATH, + {"tools": [{"tool": name, "arguments": arguments}]}, + ) + results = (envelope or {}).get("results") or [] + if not results: + raise GatewayToolError(f"invoke of {name!r} returned no results") + item = results[0] + item_result = item.get("result") if isinstance(item, dict) else item + return _unwrap_tool_result(item_result) + + def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + approval_id = quote(str(approval_id).strip(), safe="") + return self._request( + "POST", + f"/approvals/{approval_id}", + {"decision": decision}, + ) + + +class MCPTransport(GatewayTransport): + """JSON-RPC 2.0 over plain HTTP POST to ``/mcp`` and ``/mcp/meta``.""" + + def __init__( + self, + base_url_proxy: Any, + *, + session_id: Optional[str] = None, + actor_id: str, + endpoint_url: Optional[str] = None, + ): + if not actor_id or not str(actor_id).strip(): + raise ValueError("actor_id is required for MCPTransport") + self._client = base_url_proxy + self._ids = itertools.count(1) + self.session_id = _external_session_id(session_id) if session_id else None + self.actor_id = str(actor_id).strip() + self.endpoint_url = endpoint_url + + def _headers(self) -> Dict[str, str]: + headers = dict(_MCP_HEADERS) + if self.session_id: + headers[SESSION_ID_HEADER] = self.session_id + headers[ACTOR_ID_HEADER] = self.actor_id + return headers + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if self.endpoint_url: + path = self.endpoint_url + request = HttpRequest( + "POST", + path, + headers=self._headers(), + json=payload, + ) + request.url = self._client.format_url(request.url) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if response.status_code != 200: + _raise_gateway_http_error(response) + return _parse_jsonrpc(body) + + def _rpc( + self, + method: str, + params: Optional[Dict[str, Any]] = None, + *, + meta: bool, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": next(self._ids), + "method": method, + } + if params is not None: + payload["params"] = params + return self._post(_MCP_META_PATH if meta else _MCP_PATH, payload) + + def list_tools(self, *, meta: bool) -> List[Any]: + result = self._rpc("tools/list", meta=meta) + return _wrap(result.get("tools") or []) + + def call_tool(self, name: str, arguments: Dict[str, Any], *, meta: bool) -> Any: + result = self._rpc( + "tools/call", + {"name": name, "arguments": arguments or {}}, + meta=meta, + ) + return _unwrap_call_result(result) + + def decide_approval(self, approval_id: str, decision: str) -> Any: + if not approval_id or not str(approval_id).strip(): + raise ValueError("approval_id is required") + if decision not in ("approve", "deny"): + raise ValueError("decision must be 'approve' or 'deny'") + endpoint = urlsplit(self.endpoint_url or self._client._base_url) + approval_id = quote(str(approval_id).strip(), safe="") + url = f"{endpoint.scheme}://{endpoint.netloc}/approvals/{approval_id}" + request = HttpRequest( + "POST", + url, + headers={**self._headers(), "Accept": "application/json"}, + json={"decision": decision}, + ) + pipeline_response = self._client._pipeline.run(request) + response = pipeline_response.http_response + body = response.text() if hasattr(response, "text") else response.body() + if response.status_code not in (200, 201, 202, 204): + _raise_gateway_http_error(response) + return _wrap(_parse_json_body(body)) if body else None + + +__all__ = [ + "GatewayTransport", + "RESTTransport", + "MCPTransport", + "MCP_PROTOCOL_VERSION", + "SESSION_ID_HEADER", + "session_mcp_url", + "DEFAULT_GATEWAY_BASE_URL", + "resolve_gateway_base_url", +] diff --git a/src/pydo/operations/__init__.py b/src/pydo/operations/__init__.py index 2de68fec..3960eadb 100644 --- a/src/pydo/operations/__init__.py +++ b/src/pydo/operations/__init__.py @@ -4,6 +4,11 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- +from ._operations import ToolsOperations +from ._operations import ToolbeltsOperations +from ._operations import ConnectionsOperations +from ._operations import UsersOperations +from ._operations import SessionsOperations from ._operations import OneClicksOperations from ._operations import AccountOperations from ._operations import SshKeysOperations @@ -63,6 +68,11 @@ from ._patch import patch_sdk as _patch_sdk __all__ = [ + "ToolsOperations", + "ToolbeltsOperations", + "ConnectionsOperations", + "UsersOperations", + "SessionsOperations", "OneClicksOperations", "AccountOperations", "SshKeysOperations", diff --git a/src/pydo/operations/_operations.py b/src/pydo/operations/_operations.py index d587a331..d7451f57 100644 --- a/src/pydo/operations/_operations.py +++ b/src/pydo/operations/_operations.py @@ -51,6 +51,511 @@ _SERIALIZER.client_side_validation = False +def build_tools_list_request( + *, + toolkit_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/tools" + + # Construct parameters + if toolkit_id is not None: + _params["toolkit_id"] = _SERIALIZER.query("toolkit_id", toolkit_id, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_tools_list_toolkits_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/tools/toolkits" + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_tools_list_providers_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/tools/providers" + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_tools_get_definition_request( + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/tools/{name}/definition" + path_format_arguments = { + "name": _SERIALIZER.url("name", name, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + if version is not None: + _params["version"] = _SERIALIZER.query("version", version, "str") + if toolkit_id is not None: + _params["toolkit_id"] = _SERIALIZER.query("toolkit_id", toolkit_id, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_toolbelts_list_request( + *, status: str = "active", page: int = 1, per_page: int = 20, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts" + + # Construct parameters + if status is not None: + _params["status"] = _SERIALIZER.query("status", status, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_toolbelts_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_get_request( + name: str, *, version: Optional[str] = None, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + if version is not None: + _params["version"] = _SERIALIZER.query( + "version", version, "str", pattern=r"^[0-9]+$" + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_toolbelts_delete_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_add_tools_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}/tools/add" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_toolbelts_delete_tools_request(name: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/toolbelts/{name}/tools/remove" + path_format_arguments = { + "name": _SERIALIZER.url( + "name", name, "str", pattern=r"^[a-z][a-z0-9_-]{0,63}$" + ), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_connections_list_request( + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/connections" + + # Construct parameters + if provider is not None: + _params["provider"] = _SERIALIZER.query("provider", provider, "str") + if user_id is not None: + _params["user_id"] = _SERIALIZER.query("user_id", user_id, "str") + if status is not None: + _params["status"] = _SERIALIZER.query("status", status, "str") + if sort is not None: + _params["sort"] = _SERIALIZER.query("sort", sort, "str") + if sort_direction is not None: + _params["sort_direction"] = _SERIALIZER.query( + "sort_direction", sort_direction, "str" + ) + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_connections_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/connections" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_connections_get_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_connections_update_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="PATCH", url=_url, headers=_headers, **kwargs) + + +def build_connections_delete_request(id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/connections/{id}" + path_format_arguments = { + "id": _SERIALIZER.url("id", id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + +def build_users_list_request( + *, page: int = 1, per_page: int = 20, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/users" + + # Construct parameters + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_users_get_request(user_id: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/users/{user_id}" + path_format_arguments = { + "user_id": _SERIALIZER.url("user_id", user_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) + + +def build_sessions_list_request( + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/sessions" + + # Construct parameters + if end_user_id is not None: + _params["end_user_id"] = _SERIALIZER.query("end_user_id", end_user_id, "str") + if page is not None: + _params["page"] = _SERIALIZER.query("page", page, "int", minimum=1) + if per_page is not None: + _params["per_page"] = _SERIALIZER.query( + "per_page", per_page, "int", maximum=200, minimum=1 + ) + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest( + method="GET", url=_url, params=_params, headers=_headers, **kwargs + ) + + +def build_sessions_create_request(**kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/sessions" + + # Construct headers + if content_type is not None: + _headers["Content-Type"] = _SERIALIZER.header( + "content_type", content_type, "str" + ) + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) + + +def build_sessions_delete_request(session_urn: str, **kwargs: Any) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/v2/action-gateway/sessions/{session_urn}" + path_format_arguments = { + "session_urn": _SERIALIZER.url("session_urn", session_urn, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) + + def build_one_clicks_list_request( *, type: Optional[str] = None, **kwargs: Any ) -> HttpRequest: @@ -74,9 +579,9 @@ def build_one_clicks_list_request( ) -def build_one_clicks_install_kubernetes_request( +def build_one_clicks_install_kubernetes_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -1481,9 +1986,9 @@ def build_apps_get_metrics_bandwidth_daily_request( # pylint: disable=name-too- ) -def build_apps_list_metrics_bandwidth_daily_request( +def build_apps_list_metrics_bandwidth_daily_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -3862,9 +4367,9 @@ def build_dedicated_inferences_list_request( ) -def build_dedicated_inferences_create_request( +def build_dedicated_inferences_create_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -4061,9 +4566,9 @@ def build_dedicated_inferences_delete_tokens_request( # pylint: disable=name-to return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_dedicated_inferences_list_sizes_request( +def build_dedicated_inferences_list_sizes_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -4803,9 +5308,9 @@ def build_droplets_destroy_retry_with_associated_resources_request( # pylint: d return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_droplets_list_neighbors_ids_request( +def build_droplets_list_neighbors_ids_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -6673,9 +7178,9 @@ def build_kubernetes_add_registries_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_kubernetes_remove_registries_request( +def build_kubernetes_remove_registries_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -6996,9 +7501,9 @@ def build_monitoring_list_alert_policy_request( # pylint: disable=name-too-long ) -def build_monitoring_create_alert_policy_request( +def build_monitoring_create_alert_policy_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -8532,9 +9037,9 @@ def build_monitoring_get_database_mysql_schema_latency_request( # pylint: disab ) -def build_monitoring_create_destination_request( +def build_monitoring_create_destination_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -8555,9 +9060,9 @@ def build_monitoring_create_destination_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_monitoring_list_destinations_request( +def build_monitoring_list_destinations_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9498,9 +10003,9 @@ def build_projects_assign_resources_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_projects_list_resources_default_request( +def build_projects_list_resources_default_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9514,9 +10019,9 @@ def build_projects_list_resources_default_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_projects_assign_resources_default_request( +def build_projects_assign_resources_default_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -9658,9 +10163,9 @@ def build_registries_get_docker_credentials_request( # pylint: disable=name-too return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registries_get_subscription_request( +def build_registries_get_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -9674,9 +10179,9 @@ def build_registries_get_subscription_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registries_update_subscription_request( +def build_registries_update_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -10083,9 +10588,9 @@ def build_registry_get_subscription_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_registry_update_subscription_request( +def build_registry_update_subscription_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11104,9 +11609,9 @@ def build_security_list_settings_request( ) -def build_security_update_settings_plan_request( +def build_security_update_settings_plan_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11127,9 +11632,9 @@ def build_security_update_settings_plan_request( return HttpRequest(method="PUT", url=_url, headers=_headers, **kwargs) -def build_security_create_suppression_request( +def build_security_create_suppression_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11873,9 +12378,9 @@ def build_vector_databases_delete_request(id: str, **kwargs: Any) -> HttpRequest return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_vector_databases_list_backups_request( +def build_vector_databases_list_backups_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -11966,9 +12471,9 @@ def build_vector_databases_get_credentials_request( # pylint: disable=name-too- return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_vector_databases_post_resize_request( +def build_vector_databases_post_resize_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -11994,9 +12499,9 @@ def build_vector_databases_post_resize_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_vector_databases_update_tags_request( +def build_vector_databases_update_tags_request( # pylint: disable=name-too-long id: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -13858,9 +14363,9 @@ def build_genai_list_anthropic_api_keys_request( # pylint: disable=name-too-lon ) -def build_genai_create_anthropic_api_key_request( +def build_genai_create_anthropic_api_key_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14126,9 +14631,9 @@ def build_genai_list_evaluation_datasets_request( # pylint: disable=name-too-lo ) -def build_genai_create_evaluation_dataset_request( +def build_genai_create_evaluation_dataset_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14214,9 +14719,9 @@ def build_genai_get_evaluation_dataset_download_url_request( # pylint: disable= return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_evaluation_metrics_request( +def build_genai_list_evaluation_metrics_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -14230,9 +14735,9 @@ def build_genai_list_evaluation_metrics_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_create_custom_evaluation_metric_request( +def build_genai_create_custom_evaluation_metric_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14302,9 +14807,9 @@ def build_genai_delete_custom_evaluation_metric_request( # pylint: disable=name return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_genai_run_evaluation_test_case_request( +def build_genai_run_evaluation_test_case_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14408,9 +14913,9 @@ def build_genai_get_evaluation_run_prompt_results_request( # pylint: disable=na return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_evaluation_test_cases_request( +def build_genai_list_evaluation_test_cases_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -14424,9 +14929,9 @@ def build_genai_list_evaluation_test_cases_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_create_evaluation_test_case_request( +def build_genai_create_evaluation_test_case_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14707,9 +15212,9 @@ def build_genai_list_knowledge_bases_request( ) -def build_genai_create_knowledge_base_request( +def build_genai_create_knowledge_base_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14920,9 +15425,9 @@ def build_genai_get_knowledge_base_request(uuid: str, **kwargs: Any) -> HttpRequ return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_update_knowledge_base_request( +def build_genai_update_knowledge_base_request( # pylint: disable=name-too-long uuid: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -14948,9 +15453,9 @@ def build_genai_update_knowledge_base_request( return HttpRequest(method="PUT", url=_url, headers=_headers, **kwargs) -def build_genai_delete_knowledge_base_request( +def build_genai_delete_knowledge_base_request( # pylint: disable=name-too-long uuid: str, **kwargs: Any -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -14992,9 +15497,9 @@ def build_genai_create_model_eval_dataset_upload_presigned_urls_request( # pyli return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_genai_list_model_evaluation_metrics_request( +def build_genai_list_model_evaluation_metrics_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -15008,9 +15513,9 @@ def build_genai_list_model_evaluation_metrics_request( return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_genai_list_model_evaluation_presets_request( +def build_genai_list_model_evaluation_presets_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) accept = _headers.pop("Accept", "application/json") @@ -15125,9 +15630,9 @@ def build_genai_list_model_evaluation_runs_request( # pylint: disable=name-too- ) -def build_genai_create_model_evaluation_run_request( +def build_genai_create_model_evaluation_run_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15643,9 +16148,9 @@ def build_genai_delete_model_router_request(uuid: str, **kwargs: Any) -> HttpReq return HttpRequest(method="DELETE", url=_url, headers=_headers, **kwargs) -def build_genai_create_oauth2_dropbox_tokens_request( +def build_genai_create_oauth2_dropbox_tokens_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15716,9 +16221,9 @@ def build_genai_list_openai_api_keys_request( ) -def build_genai_create_openai_api_key_request( +def build_genai_create_openai_api_key_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -15875,9 +16380,9 @@ def build_genai_list_datacenter_regions_request( # pylint: disable=name-too-lon ) -def build_genai_create_scheduled_indexing_request( +def build_genai_create_scheduled_indexing_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16135,9 +16640,9 @@ def build_genai_list_evaluation_test_cases_by_workspace_request( # pylint: disa return HttpRequest(method="GET", url=_url, headers=_headers, **kwargs) -def build_inference_create_chat_completion_request( +def build_inference_create_chat_completion_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16256,9 +16761,9 @@ def build_inference_create_response_request(**kwargs: Any) -> HttpRequest: return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_inference_create_async_invoke_request( +def build_inference_create_async_invoke_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16279,9 +16784,9 @@ def build_inference_create_async_invoke_request( return HttpRequest(method="POST", url=_url, headers=_headers, **kwargs) -def build_inference_create_batch_file_request( +def build_inference_create_batch_file_request( # pylint: disable=name-too-long **kwargs: Any, -) -> HttpRequest: # pylint: disable=name-too-long +) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) content_type: Optional[str] = kwargs.pop( @@ -16469,14 +16974,14 @@ def build_agent_inference_create_chat_completion_request( # pylint: disable=nam ) -class OneClicksOperations: +class ToolsOperations: """ .. warning:: **DO NOT** instantiate this class directly. Instead, you should access the following operations through :class:`~pydo.GeneratedClient`'s - :attr:`one_clicks` attribute. + :attr:`tools` attribute. """ def __init__(self, *args, **kwargs): @@ -16489,22 +16994,406 @@ def __init__(self, *args, **kwargs): ) @distributed_trace - def list(self, *, type: Optional[str] = None, **kwargs: Any) -> JSON: - """List 1-Click Applications. + def list( + self, + *, + toolkit_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """List Tools. - To list all available 1-Click applications, send a GET request to ``/v2/1-clicks``. The - ``type`` may - be provided as query paramater in order to restrict results to a certain type of 1-Click, for - example: ``/v2/1-clicks?type=droplet``. Current supported types are ``kubernetes`` and - ``droplet``. + Lists active Action Gateway tools visible to the authenticated team. - The response will be a JSON object with a key called ``1_clicks``. This will be set to an array - of - 1-Click application data, each of which will contain the the slug and type for the 1-Click. + :keyword toolkit_id: Filter tools by toolkit identifier. Default value is None. + :paramtype toolkit_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "definitions": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "caseInsensitive": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "extractField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchField": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "matchValue": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "match_value_parameter": "str", # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "method": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "requiredScopes": [ + "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access + token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When + both are empty, exactly one entry whose own "scopes" + array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + ], + "trimTrailingSlash": bool, # + Optional. HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + "url": "str" # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both + are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one + match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote + MCP server (as opposed to a plain HTTP endpoint). endpoint is + the remote MCP server's URL, tool_name is the name the remote + server expects on tools/call (may differ from this tool's + registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server + for logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution + describes how to invoke a tool that is fronted by a remote MCP + server (as opposed to a plain HTTP endpoint). endpoint is the + remote MCP server's URL, tool_name is the name the remote server + expects on tools/call (may differ from this tool's registry + name), transport selects the wire protocol ("streamable_http" is + the only kind implemented today), and server_ref is an opaque + label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage + metadata is present, false prevents billing. Omitting usage + metadata leaves consumers' legacy billing classification + unchanged. + "meters": [ + { + "quantitySource": "str", # + Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "tools": [ + { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "description": "str", # Optional. + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "streamingSafe": bool, # Optional. + "title": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the + provider-qualified, stable tool identifier + ":code:``_:code:``". Pass this value back verbatim to + the toolbelt add/remove endpoints; clients should treat it as opaque + rather than reconstructing it from toolkit_id and name. + "toolkitId": "str", # Optional. + "version": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_request( + toolkit_id=toolkit_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def list_toolkits(self, **kwargs: Any) -> JSON: + """List Toolkits. + + Lists the toolkits that group Action Gateway tools. - :keyword type: Restrict results to a certain type of 1-Click. Known values are: "droplet" and - "kubernetes". Default value is None. - :paramtype type: str :return: JSON object :rtype: JSON :raises ~azure.core.exceptions.HttpResponseError: @@ -16514,12 +17403,4231 @@ def list(self, *, type: Optional[str] = None, **kwargs: Any) -> JSON: # response body for status code(s): 200 response == { - "1_clicks": [ + "toolkits": [ { - "slug": "str", # The slug identifier for the 1-Click - application. Required. - "type": "str" # The type of the 1-Click application. - Required. + "description": "str", # Optional. + "id": "str", # Optional. + "name": "str" # Optional. + } + ], + "version": "str" # Optional. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_toolkits_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def list_providers(self, **kwargs: Any) -> JSON: + """List Tool Providers. + + Lists Action Gateway providers and their connection requirements. + + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "providers": [ + { + "auth_type": "str", # Optional. + "connection_parameters": [ + { + "allowed_host_suffixes": [ + "str" # Optional. + ], + "allowed_values": [ + "str" # Optional. + ], + "description": "str", # Optional. + "input_kind": "str", # Optional. + "key": "str", # Optional. + "label": "str", # Optional. + "max_length": 0, # Optional. + "normalization": "str", # Optional. + "required": bool # Optional. + } + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "name": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_list_providers_request( + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get_definition( + self, + name: str, + *, + version: Optional[str] = None, + toolkit_id: Optional[str] = None, + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Tool Definition. + + Retrieves the executable definition for an active Action Gateway tool. + + :param name: The provider-qualified tool name. Required. + :type name: str + :keyword version: The tool version. Omit to retrieve the current version. Default value is + None. + :paramtype version: str + :keyword toolkit_id: The toolkit identifier used to disambiguate a bare tool name. Default + value is None. + :paramtype toolkit_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "annotations": { + "destructiveHint": bool, # Optional. + "idempotentHint": bool, # Optional. + "openWorldHint": bool, # Optional. + "readOnlyHint": bool, # Optional. + "title": "str" # Optional. + }, + "auth": { + "baseUrlResolution": { + "httpLookup": { + "baseUrlTemplate": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "caseInsensitive": bool, # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "extractField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchField": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "matchValue": "str", # Optional. HTTPLookupSpec + resolves a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "match_value_parameter": "str", # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "method": "str", # Optional. HTTPLookupSpec resolves + a base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON array, + extracting extract_field from that entry, and substituting it for + "{value}" in base_url_template. When match_field and match_value are + both set, they select the entry. When both are empty, exactly one + entry whose own "scopes" array contains required_scopes must exist. + Configuring only one match field is invalid. Resolution fails fast on + zero or multiple compatible entries. + "requiredScopes": [ + "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the + just-exchanged access token), selecting an entry in the JSON + array, extracting extract_field from that entry, and substituting + it for "{value}" in base_url_template. When match_field and + match_value are both set, they select the entry. When both are + empty, exactly one entry whose own "scopes" array contains + required_scopes must exist. Configuring only one match field is + invalid. Resolution fails fast on zero or multiple compatible + entries. + ], + "trimTrailingSlash": bool, # Optional. + HTTPLookupSpec resolves a base_url by calling url + (bearer-authenticated with the just-exchanged access token), + selecting an entry in the JSON array, extracting extract_field from + that entry, and substituting it for "{value}" in base_url_template. + When match_field and match_value are both set, they select the entry. + When both are empty, exactly one entry whose own "scopes" array + contains required_scopes must exist. Configuring only one match field + is invalid. Resolution fails fast on zero or multiple compatible + entries. + "url": "str" # Optional. HTTPLookupSpec resolves a + base_url by calling url (bearer-authenticated with the just-exchanged + access token), selecting an entry in the JSON array, extracting + extract_field from that entry, and substituting it for "{value}" in + base_url_template. When match_field and match_value are both set, + they select the entry. When both are empty, exactly one entry whose + own "scopes" array contains required_scopes must exist. Configuring + only one match field is invalid. Resolution fails fast on zero or + multiple compatible entries. + } + }, + "credentialBinding": "str", # Optional. + "credentialRefSource": "str", # Optional. + "doManagedCredentialRef": "str", # Optional. + "injection": { + "location": "str", # Optional. + "name": "str", # Optional. + "scheme": "str" # Optional. + }, + "modes": [ + "str" # Optional. + ], + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ] + }, + "classification": { + "dataClasses": [ + "str" # Optional. + ], + "operation": "str", # Optional. + "risk": "str" # Optional. + }, + "description": "str", # Optional. + "execution": { + "adapterVersion": "str", # Optional. + "configRef": "str", # Optional. + "http": { + "allowedHosts": [ + "str" # Optional. + ], + "baseUrl": "str", # Optional. + "method": "str", # Optional. + "path": "str", # Optional. + "requestEncoding": "str", # Optional. + "responseFormat": "str" # Optional. + }, + "mcp": { + "allowedHosts": [ + "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, + tool_name is the name the remote server expects on tools/call (may + differ from this tool's registry name), transport selects the wire + protocol ("streamable_http" is the only kind implemented today), and + server_ref is an opaque label identifying the remote server for + logging/metrics/allowlisting. + ], + "endpoint": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "serverRef": "str", # Optional. MCPExecution describes how + to invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "toolName": "str", # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + "transport": "str" # Optional. MCPExecution describes how to + invoke a tool that is fronted by a remote MCP server (as opposed to a + plain HTTP endpoint). endpoint is the remote MCP server's URL, tool_name + is the name the remote server expects on tools/call (may differ from this + tool's registry name), transport selects the wire protocol + ("streamable_http" is the only kind implemented today), and server_ref is + an opaque label identifying the remote server for + logging/metrics/allowlisting. + }, + "type": "str" # Optional. + }, + "flipperName": "str", # Optional. + "hooks": { + "usage": { + "billable": bool, # Optional. When usage metadata is + present, false prevents billing. Omitting usage metadata leaves + consumers' legacy billing classification unchanged. + "meters": [ + { + "quantitySource": "str", # Optional. + "sku": "str", # Optional. + "unit": "str" # Optional. + } + ] + } + }, + "inputSchema": {}, # Optional. Any object. + "name": "str", # Optional. + "outputSchema": {}, # Optional. Any object. + "parallelizable": bool, # Optional. + "policy": { + "permission": "str" # Optional. + }, + "reliability": { + "maxOutputBytes": "str", # Optional. + "retry": { + "backoff": "str", # Optional. + "maxAttempts": 0, # Optional. + "retryOn": [ + "str" # Optional. + ] + }, + "timeoutMs": 0 # Optional. + }, + "schemaVersion": "str", # Optional. + "status": "str", # Optional. + "streamingSafe": bool, # Optional. + "tags": [ + "str" # Optional. + ], + "title": "str", # Optional. + "toolId": "str", # Optional. + "toolSlug": "str", # Optional. tool_slug is the provider-qualified, stable + tool identifier ":code:``_:code:``". Pass this value back + verbatim to the toolbelt add/remove endpoints; clients should treat it as opaque. + "toolkitId": "str", # Optional. + "transform": { + "input": {}, # Optional. Any object. + "language": "str", # Optional. + "output": {} # Optional. Any object. + }, + "version": "str" # Optional. + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_tools_get_definition_request( + name=name, + version=version, + toolkit_id=toolkit_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ToolbeltsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`toolbelts` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + status: str = "active", + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + """List Toolbelts. + + Lists the latest version of each toolbelt owned by the authenticated team. + + :keyword status: Filter toolbelts by status. Known values are: "active", "deprecated", and + "all". Default value is "active". + :paramtype status: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "toolbelts": [ + { + "latest_version": "str", # Required. + "name": "str", # Required. + "reference_latest": "str", # Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "updated_at": "2020-02-20 00:00:00", # Required. + "version_count": 0, # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_list_request( + status=status, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Toolbelt. + + Creates a versioned collection of provider-qualified Action Gateway tool names. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "name": "str", # Required. + "tools": [ + "str" # Required. + ], + "description": "str", # Optional. + "display_name": "str", # Optional. + "version": "1" # Optional. Default value is "1". + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, name: str, *, version: Optional[str] = None, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Toolbelt. + + Retrieves the latest active version or a specified immutable version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :keyword version: An immutable numeric toolbelt version. Omit to retrieve the latest active + version. Default value is None. + :paramtype version: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_get_request( + name=name, + version=version, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def delete(self, name: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Toolbelt. + + Deprecates the latest active version of a toolbelt. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_toolbelts_delete_request( + name=name, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def add_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def add_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def add_tools(self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Add Tools to a Toolbelt. + + Adds provider-qualified tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_add_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def delete_tools( + self, + name: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def delete_tools( + self, + name: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def delete_tools( + self, name: str, body: Union[JSON, IO[bytes]], **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Remove Tools from a Toolbelt. + + Removes tool names and creates a new immutable toolbelt version. + + :param name: The natural key identifying the toolbelt. Required. + :type name: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "tools": [ + "str" # Required. + ] + } + + # response body for status code(s): 200 + response == { + "toolbelt": { + "created_at": "2020-02-20 00:00:00", # Required. + "name": "str", # Required. + "reference": "str", # A reference pinned to this immutable toolbelt + version. Required. + "reference_latest": "str", # An unversioned reference to the latest + active version. Required. + "status": "str", # Required. Known values are: "active" and + "deprecated". + "tool_count": 0, # Required. + "tools": [ + "str" # Required. + ], + "updated_at": "2020-02-20 00:00:00", # Required. + "version": "str", # Required. + "description": "str", # Optional. Required. + "display_name": "str" # Optional. Required. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_toolbelts_delete_tools_request( + name=name, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class ConnectionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`connections` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + provider: Optional[str] = None, + user_id: Optional[str] = None, + status: Optional[str] = None, + sort: Optional[str] = None, + sort_direction: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + """List Connections. + + Lists OAuth connections owned by the authenticated team. + + :keyword provider: Filter by provider name. Default value is None. + :paramtype provider: str + :keyword user_id: Filter by end-user identifier. Default value is None. + :paramtype user_id: str + :keyword status: Filter by connection status. Default value is None. + :paramtype status: str + :keyword sort: Field used to sort results. Default value is None. + :paramtype sort: str + :keyword sort_direction: Sort direction. Default value is None. + :paramtype sort_direction: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + "granted_at": "2020-02-20 00:00:00", # Optional. + "id": "str", # Optional. + "provider": "str", # Optional. + "provider_display_name": "str", # Optional. + "revoked_at": "2020-02-20 00:00:00", # Optional. + "scopes": [ + "str" # Optional. + ], + "status": "str", # Optional. + "updated_at": "2020-02-20 00:00:00", # Optional. + "user_id": "str" # Optional. + } + ], + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + } + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_list_request( + provider=provider, + user_id=user_id, + status=status, + sort=sort, + sort_direction=sort_direction, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create a Connection. + + Creates or begins authorization for an OAuth connection to an Action Gateway provider. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "provider": "str", # Optional. + "scopes": [ + "str" # Optional. + ], + "user_id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 409 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 409]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 409: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve a Connection. + + Retrieves an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "authorization": { + "connect_url": "str", # Optional. ConnectionAuthorization is present + only while a connection is pending. The UI sends the user to connect_url and + polls GetConnection until the connection becomes active or expires. The + Secrets Manager poll URL is never exposed. + "expires_at": "2020-02-20 00:00:00", # Optional. + ConnectionAuthorization is present only while a connection is pending. The UI + sends the user to connect_url and polls GetConnection until the connection + becomes active or expires. The Secrets Manager poll URL is never exposed. + "status": "str", # Optional. ConnectionAuthorization is present only + while a connection is pending. The UI sends the user to connect_url and polls + GetConnection until the connection becomes active or expires. The Secrets + Manager poll URL is never exposed. + "verification_code": "str" # Optional. ConnectionAuthorization is + present only while a connection is pending. The UI sends the user to + connect_url and polls GetConnection until the connection becomes active or + expires. The Secrets Manager poll URL is never exposed. + }, + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_get_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def update( + self, + id: str, + body: JSON, + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def update( + self, + id: str, + body: IO[bytes], + *, + content_type: str = "application/json", + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def update(self, id: str, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Update Connection Parameters. + + Updates non-sensitive connection parameters for an OAuth connection. + + :param id: The connection UUID. Required. + :type id: str + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "connection_parameters": {}, # Optional. Any object. + "id": "str" # Optional. + } + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 400, 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_connections_update_request( + id=id, + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def delete(self, id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete a Connection. + + Revokes and deletes an OAuth connection owned by the authenticated team. + + :param id: The connection UUID. Required. + :type id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "connection": { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "granted_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "id": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "provider_display_name": "str", # Optional. ---- OAuth connection + resources -------------------------- OAuthConnection is the public, + team-scoped connection metadata returned to the UI. It deliberately excludes + the team ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + "revoked_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "scopes": [ + "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team + ID, Secrets Manager assignment, actor identifiers, poll URL, and + authorization handle. + ], + "status": "str", # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + "updated_at": "2020-02-20 00:00:00", # Optional. ---- OAuth + connection resources -------------------------- OAuthConnection is the + public, team-scoped connection metadata returned to the UI. It deliberately + excludes the team ID, Secrets Manager assignment, actor identifiers, poll + URL, and authorization handle. + "user_id": "str" # Optional. ---- OAuth connection resources + -------------------------- OAuthConnection is the public, team-scoped + connection metadata returned to the UI. It deliberately excludes the team ID, + Secrets Manager assignment, actor identifiers, poll URL, and authorization + handle. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_connections_delete_request( + id=id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class UsersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`users` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list(self, *, page: int = 1, per_page: int = 20, **kwargs: Any) -> JSON: + """List Action Gateway Users. + + Lists end-user identifiers derived from sessions and OAuth connections for the authenticated + team. + + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "user_ids": [ + "str" # Optional. + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_list_request( + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def get(self, user_id: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Retrieve an Action Gateway User. + + Retrieves a derived end-user view containing its sessions and OAuth connections. + + :param user_id: The end-user identifier. Required. + :type user_id: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "user": { + "connections": [ + { + "connection_parameters": {}, # Optional. Any object. + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "granted_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "id": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "provider_display_name": "str", # Optional. User is + a derived, team-scoped view across sessions and OAuth connections. + "revoked_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "scopes": [ + "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + ], + "status": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "user_id": "str" # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + } + ], + "sessions": [ + { + "created_at": "2020-02-20 00:00:00", # Optional. + User is a derived, team-scoped view across sessions and OAuth + connections. + "name": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "session_urn": "str", # Optional. User is a derived, + team-scoped view across sessions and OAuth connections. + "updated_at": "2020-02-20 00:00:00" # Optional. User + is a derived, team-scoped view across sessions and OAuth connections. + } + ], + "user_id": "str" # Optional. User is a derived, team-scoped view + across sessions and OAuth connections. + } + } + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_users_get_request( + user_id=user_id, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class SessionsOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`sessions` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list( + self, + *, + end_user_id: Optional[str] = None, + page: int = 1, + per_page: int = 20, + **kwargs: Any, + ) -> JSON: + # pylint: disable=line-too-long + """List Action Gateway Sessions. + + Lists Action Gateway sessions owned by the authenticated team. + + :keyword end_user_id: Filter sessions by actor identifier. Default value is None. + :paramtype end_user_id: str + :keyword page: Which 'page' of paginated results to return. Default value is 1. + :paramtype page: int + :keyword per_page: Number of items returned per page. Default value is 20. + :paramtype per_page: int + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "pagination": { + "page": 0, # Required. + "per_page": 0, # Required. + "total": 0 # Required. + }, + "sessions": [ + { + "actorId": "str", # Optional. actor_id is empty when the + session is not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. + Gateway currently interprets config.preloadTools to add selected direct + tools to the session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. + "name": "str", # Optional. name is the required + human-readable session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is + "ask". SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default + value is "ask". SessionPolicyAction is the disposition + applied to a tool call. Lowercase values are canonical so + ProtoJSON matches the public REST vocabulary; the prefixed + aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. + Dictionary of :code:``. + }, + "tool": "str" # Optional. + SessionPolicySpec is the Gateway-relevant subset of a + session's permission policy. Filesystem and network policy + remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known + values are: "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + "version": "str" # Optional. Omitted + when the request omitted tools (all tools). A present + selection with no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. + } + ] + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_list_request( + end_user_id=end_user_id, + page=page, + per_page=per_page, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @overload + def create( + self, body: JSON, *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: JSON + :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @overload + def create( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + + @distributed_trace + def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Create an Action Gateway Session. + + Creates a session with a tool selection, invocation policy, and optional direct-tool preload + configuration. + + :param body: Is either a JSON type or a IO[bytes] type. Required. + :type body: JSON or IO[bytes] + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # JSON input template you can fill out and use as your body input. + body = { + "actor_id": "str", # Required. + "name": "str", # Required. + "config": { + "preloadTools": [ + "str" # Optional. Concrete tools or pinned toolbelts to + expose directly beside the session meta-tools. + ] + }, + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. Known + values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. + Lowercase values are canonical so ProtoJSON matches the public REST + vocabulary; the prefixed aliases preserve compatibility for existing + protobuf clients. Known values are: "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary of + :code:``. + }, + "tool": "str" # Optional. Invocation policy. Omit to + use a default action of ask. + } + ] + }, + "tools": [ + "str" # Optional. Omitted enables every tool. An explicit empty + array enables no tools. Direct tools may be :code:`` or + @:code:``; toolbelt references must be version-pinned as + toolbelt::code:``@:code:``. + ] + } + + # response body for status code(s): 200 + response == { + "mcpUrl": "str", # Public session-pinned MCP URL. Required. + "session": { + "actorId": "str", # Optional. actor_id is empty when the session is + not bound to an actor. + "config": {}, # Optional. Preserved as an opaque object. Gateway + currently interprets config.preloadTools to add selected direct tools to the + session MCP. + "createdAt": "2020-02-20 00:00:00", # Optional. A session and the + tool-permission policy bound to it. Required. + "name": "str", # Optional. name is the required human-readable + session name. + "policy": { + "defaultAction": "ask", # Optional. Default value is "ask". + SessionPolicyAction is the disposition applied to a tool call. Lowercase + values are canonical so ProtoJSON matches the public REST vocabulary; the + prefixed aliases preserve compatibility for existing protobuf clients. + Known values are: "allow", "ask", and "deny". + "rules": [ + { + "action": "ask", # Optional. Default value + is "ask". SessionPolicyAction is the disposition applied to a + tool call. Lowercase values are canonical so ProtoJSON matches + the public REST vocabulary; the prefixed aliases preserve + compatibility for existing protobuf clients. Known values are: + "allow", "ask", and "deny". + "match": { + "str": "str" # Optional. Dictionary + of :code:``. + }, + "tool": "str" # Optional. SessionPolicySpec + is the Gateway-relevant subset of a session's permission policy. + Filesystem and network policy remain enforced by the sandbox. + } + ] + }, + "sessionUrn": "str", # Optional. A session and the tool-permission + policy bound to it. Required. + "tools": { + "references": [ + { + "kind": "str", # Optional. Known values are: + "SESSION_TOOL_REFERENCE_KIND_TOOL" and + "SESSION_TOOL_REFERENCE_KIND_TOOLBELT". + "name": "str", # Optional. Omitted when the + request omitted tools (all tools). A present selection with no + references represents tools: []. + "version": "str" # Optional. Omitted when + the request omitted tools (all tools). A present selection with + no references represents tools: []. + } + ] + }, + "updatedAt": "2020-02-20 00:00:00" # Optional. A session and the + tool-permission policy bound to it. Required. + }, + "tools": [ + "str" # Canonical, version-pinned selected tool references. + Required. + ] + } + # response body for status code(s): 400 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = kwargs.pop("params", {}) or {} + + content_type: Optional[str] = kwargs.pop( + "content_type", _headers.pop("Content-Type", None) + ) + cls: ClsType[JSON] = kwargs.pop("cls", None) + + content_type = content_type or "application/json" + _json = None + _content = None + if isinstance(body, (IOBase, bytes)): + _content = body + else: + _json = body + + _request = build_sessions_create_request( + content_type=content_type, + json=_json, + content=_content, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 400]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 400: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + @distributed_trace + def delete(self, session_urn: str, **kwargs: Any) -> JSON: + # pylint: disable=line-too-long + """Delete an Action Gateway Session. + + Deletes an Action Gateway session owned by the authenticated team. + + :param session_urn: The URL-encoded managed agents session URN. Required. + :type session_urn: str + :return: JSON or JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 404 + response == { + "id": "str", # A short identifier corresponding to the HTTP status code + returned. For example, the ID for a response returning a 404 status code would + be "not_found.". Required. + "message": "str", # A message providing additional information about the + error, including details to help resolve it when possible. Required. + "request_id": "str" # Optional. Optionally, some endpoints may include a + request ID that should be provided when reporting bugs or opening support + tickets to help identify the issue. + } + """ + error_map: MutableMapping[int, Type[HttpResponseError]] = { + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + 401: cast( + Type[HttpResponseError], + lambda response: ClientAuthenticationError(response=response), + ), + 429: HttpResponseError, + 500: HttpResponseError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[JSON] = kwargs.pop("cls", None) + + _request = build_sessions_delete_request( + session_urn=session_urn, + headers=_headers, + params=_params, + ) + _request.url = self._client.format_url(_request.url) + + _stream = False + pipeline_response: PipelineResponse = ( + self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + ) + + response = pipeline_response.http_response + + if response.status_code not in [200, 404]: + if _stream: + response.read() # Load the body in memory and close the socket + map_error(status_code=response.status_code, response=response, error_map=error_map) # type: ignore + raise HttpResponseError(response=response) + + response_headers = {} + if response.status_code == 200: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if response.status_code == 404: + response_headers["ratelimit-limit"] = self._deserialize( + "int", response.headers.get("ratelimit-limit") + ) + response_headers["ratelimit-remaining"] = self._deserialize( + "int", response.headers.get("ratelimit-remaining") + ) + response_headers["ratelimit-reset"] = self._deserialize( + "int", response.headers.get("ratelimit-reset") + ) + + if response.content: + deserialized = response.json() + else: + deserialized = None + + if cls: + return cls(pipeline_response, cast(JSON, deserialized), response_headers) # type: ignore + + return cast(JSON, deserialized) # type: ignore + + +class OneClicksOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~pydo.GeneratedClient`'s + :attr:`one_clicks` attribute. + """ + + def __init__(self, *args, **kwargs): + input_args = list(args) + self._client = input_args.pop(0) if input_args else kwargs.pop("client") + self._config = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize = ( + input_args.pop(0) if input_args else kwargs.pop("deserializer") + ) + + @distributed_trace + def list(self, *, type: Optional[str] = None, **kwargs: Any) -> JSON: + """List 1-Click Applications. + + To list all available 1-Click applications, send a GET request to ``/v2/1-clicks``. The + ``type`` may + be provided as query paramater in order to restrict results to a certain type of 1-Click, for + example: ``/v2/1-clicks?type=droplet``. Current supported types are ``kubernetes`` and + ``droplet``. + + The response will be a JSON object with a key called ``1_clicks``. This will be set to an array + of + 1-Click application data, each of which will contain the the slug and type for the 1-Click. + + :keyword type: Restrict results to a certain type of 1-Click. Known values are: "droplet" and + "kubernetes". Default value is None. + :paramtype type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 200 + response == { + "1_clicks": [ + { + "slug": "str", # The slug identifier for the 1-Click + application. Required. + "type": "str" # The type of the 1-Click application. + Required. } ] } @@ -29207,7 +34315,7 @@ def create( }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -45930,7 +51038,7 @@ def create(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: }, "project_id": "str" # Optional. The ID of the project the app should be assigned to. If omitted, it will be assigned to your default project. - :code:`
`:code:`
`Requires ``project:update`` scope. + :code:`
`:code:`
`Requires ``project:assign_resource`` scope. } # response body for status code(s): 200 @@ -127197,8 +132305,10 @@ def list_clusters(self, *, tag_name: Optional[str] = None, **kwargs: Any) -> JSO } ], "pg_allow_replication": bool # - Optional. For Postgres clusters, set to ``true`` for a user - with replication rights. This option is not currently + Optional. For PostgreSQL clusters, set to ``true`` to grant + the user replication privileges. When omitted on create or + update, the value defaults to ``false`` and replication + privileges are not granted. This option is not currently supported for other database engines. } } @@ -127474,7 +132584,7 @@ def create_cluster( "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -127651,9 +132761,10 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -127955,9 +133066,11 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -128328,9 +133441,11 @@ def create_cluster( } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -128522,7 +133637,7 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "project_id": "str", # Optional. The ID of the project that the database cluster is assigned to. If excluded when creating a new database cluster, it will be assigned to your default project.:code:`
`:code:`
`Requires - ``project:update`` scope. + ``project:assign_resource`` scope. "rules": [ { "type": "str", # The type of resource that the firewall rule @@ -128699,9 +133814,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ], @@ -129003,9 +134119,11 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -129439,9 +134557,11 @@ def get_cluster(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user + replication privileges. When omitted on create or update, the + value defaults to ``false`` and replication privileges are not + granted. This option is not currently supported for other + database engines. } } ], @@ -131249,10 +136369,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -131321,10 +136442,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -131365,10 +136487,11 @@ def update_firewall_rules( resources should be able to open connections to the database. You may limit connections to specific Droplets, Kubernetes clusters, or IP addresses. When a tag is provided, any Droplet or Kubernetes node with that tag applied to it will have access. The firewall is limited to 100 - rules (or trusted sources). When possible, we recommend `placing your databases into a VPC - network `_ to limit access to them - instead of using a firewall. - A successful. + rules (or trusted sources). You cannot add IPv6 addresses as trusted sources. For additional + limits, see your database engine's limits page. When possible, we recommend `placing your + databases into a VPC network `_ to + limit access to them instead of using a firewall. + A successful request returns a 204 status code with no content. :param database_cluster_uuid: A unique identifier for a database cluster. Required. :type database_cluster_uuid: str @@ -133781,6 +138904,11 @@ def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: For MySQL clusters, additional options will be contained in the mysql_settings object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + + For Kafka clusters, additional options will be contained in the ``settings`` object. + For MongoDB clusters, additional information will be contained in the mongo_user_settings object. @@ -133871,9 +138999,10 @@ def list_users(self, database_cluster_uuid: str, **kwargs: Any) -> JSON: } ], "pg_allow_replication": bool # Optional. For - Postgres clusters, set to ``true`` for a user with replication - rights. This option is not currently supported for other database - engines. + PostgreSQL clusters, set to ``true`` to grant the user replication + privileges. When omitted on create or update, the value defaults to + ``false`` and replication privileges are not granted. This option is + not currently supported for other database engines. } } ] @@ -133988,10 +139117,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -134083,9 +139216,11 @@ def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -134160,9 +139295,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -134199,10 +139336,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -134294,9 +139435,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -134328,10 +139471,14 @@ def add_user( When adding a user to a MySQL cluster, additional options can be configured in the ``mysql_settings`` object. + When adding a user to a PostgreSQL cluster, additional options can be configured in + the ``settings`` object (for example, ``pg_allow_replication``\\ ). When + ``pg_allow_replication`` is omitted, it defaults to ``false``. + When adding a user to a Kafka cluster, additional options can be configured in the ``settings`` object. - When adding a user to a MongoDB cluster, additional options can be configured in + When adding a user to a MongoDB cluster, additional options can be configured in the ``settings.mongo_user_settings`` object. The response will be a JSON object with a key called ``user``. The value of this will be an @@ -134420,9 +139567,11 @@ def add_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -134497,9 +139646,11 @@ def add_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -134626,6 +139777,9 @@ def get_user( For MySQL clusters, additional options will be contained in the ``mysql_settings`` object. + For PostgreSQL clusters, additional options will be contained in the ``settings`` + object (for example, ``pg_allow_replication``\\ ). + For Kafka clusters, additional options will be contained in the ``settings`` object. For MongoDB clusters, additional information will be contained in the mongo_user_settings @@ -134713,9 +139867,11 @@ def get_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -134944,8 +140100,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -135014,9 +140176,11 @@ def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -135091,9 +140255,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135131,8 +140297,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -135223,9 +140395,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135261,8 +140435,14 @@ def update_user( the name of a user, you must recreate a new user. + For PostgreSQL clusters, you can update ``settings.pg_allow_replication`` to enable or + disable replication privileges for the user. When omitted, the value defaults to ``false``. + + For Kafka and OpenSearch clusters, additional options can be configured in the + ``settings`` object (for example, topic or index ACLs). + The response will be a JSON object with a key called ``user``. The value of this will be an - object that contains the name of the update database user, along with the ``settings`` object + object that contains the name of the updated database user, along with the ``settings`` object that has been updated. @@ -135328,9 +140508,11 @@ def update_user( values are: "deny", "admin", "read", "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres clusters, set - to ``true`` for a user with replication rights. This option is not currently - supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL clusters, + set to ``true`` to grant the user replication privileges. When omitted on + create or update, the value defaults to ``false`` and replication privileges + are not granted. This option is not currently supported for other database + engines. } } @@ -135405,9 +140587,11 @@ def update_user( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135638,9 +140822,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135767,9 +140953,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -135903,9 +141091,11 @@ def reset_auth( "readwrite", and "write". } ], - "pg_allow_replication": bool # Optional. For Postgres - clusters, set to ``true`` for a user with replication rights. This option - is not currently supported for other database engines. + "pg_allow_replication": bool # Optional. For PostgreSQL + clusters, set to ``true`` to grant the user replication privileges. When + omitted on create or update, the value defaults to ``false`` and + replication privileges are not granted. This option is not currently + supported for other database engines. } } } @@ -149457,9 +154647,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -149504,9 +154695,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -150130,9 +155322,10 @@ def get(self, droplet_id: int, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -150175,9 +155368,9 @@ def get(self, droplet_id: int, **kwargs: Any) -> JSON: measure for the disk size. }, "type": "str" # Optional. The type of disk. All - Droplets contain a ``local`` disk. Additionally, GPU Droplets can - also have a ``scratch`` disk for non-persistent data. Known values - are: "local" and "scratch". + Droplets contain a ``local`` or ``remote`` disk. Additionally, GPU + Droplets can also have a ``scratch`` disk for non-persistent data. + Known values are: "local", "remote", and "scratch". } ], "gpu_info": { @@ -151703,9 +156896,10 @@ class of Droplets created from this size. For example: Basic, General The unit of measure for the disk size. }, "type": "str" # Optional. The type - of disk. All Droplets contain a ``local`` disk. Additionally, - GPU Droplets can also have a ``scratch`` disk for - non-persistent data. Known values are: "local" and "scratch". + of disk. All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk + for non-persistent data. Known values are: "local", "remote", + and "scratch". } ], "gpu_info": { @@ -151750,9 +156944,10 @@ class of Droplets created from this size. For example: Basic, General of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { @@ -165915,6 +171110,10 @@ def list_clusters( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -166227,6 +171426,10 @@ def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -166446,257 +171649,265 @@ def create_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, - "rdma_shared_dev_plugin": { - "enabled": bool # Optional. Indicates whether the RDMA - shared device plugin is enabled. - }, - "registry_enabled": bool, # Optional. A read-only boolean value - indicating if a container registry is integrated with the cluster. - "routing_agent": { + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, + "rdma_shared_dev_plugin": { + "enabled": bool # Optional. Indicates whether the RDMA + shared device plugin is enabled. + }, + "registry_enabled": bool, # Optional. A read-only boolean value + indicating if a container registry is integrated with the cluster. + "routing_agent": { + "enabled": bool # Optional. Indicates whether the + routing-agent component is enabled. + }, + "service_subnet": "str", # Optional. The range of assignable IP + addresses for services running in the Kubernetes cluster in CIDR notation. + "sso": { + "client_id": "str", # Optional. The OIDC client ID + registered with the identity provider. Required when ``enabled`` is + ``true``. + "enabled": False, # Optional. Default value is False. + Indicates whether SSO authentication is enabled for the cluster. + "issuer_url": "str", # Optional. The OIDC issuer URL for the + identity provider. Required when ``enabled`` is ``true``. + "required": False # Optional. Default value is False. + Indicates whether any non-SSO forms of authentication are disallowed. Can + only be set to ``true`` when ``enabled`` is ``true``. + }, + "status": { + "message": "str", # Optional. An optional message providing + additional information about the current cluster state. + "state": "str" # Optional. A string indicating the current + status of the cluster. Known values are: "running", "provisioning", + "degraded", "error", "deleted", "upgrading", and "deleting". + }, + "surge_upgrade": False, # Optional. Default value is False. A + boolean value indicating whether surge upgrade is enabled/disabled for the + cluster. Surge upgrade makes cluster upgrades fast and reliable by bringing + up new nodes before destroying the outdated nodes. + "tags": [ + "str" # Optional. An array of tags to apply to the + Kubernetes cluster. All clusters are automatically tagged ``k8s`` and + ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires ``tag:read`` + and ``tag:create`` scope, as well as ``tag:delete`` if existing tags are + getting removed. + ], + "updated_at": "2020-02-20 00:00:00", # Optional. A time value given + in ISO8601 combined date and time format that represents when the Kubernetes + cluster was last updated. + "vpc_uuid": "str", # Optional. A string specifying the UUID of the + VPC to which the Kubernetes cluster is + assigned.:code:`
`:code:`
`Requires ``vpc:read`` scope. + "worker_subnet_uuid": "str" # Optional. The UUID of the VPC subnet + to attach worker nodes to. When omitted on create, the default subnet for the + VPC is used. This value cannot be changed after the cluster is created. + ``vpc_uuid`` must also be set. :code:`
`:code:`
`Requires ``vpc:read`` + scope. + } + } + """ + + @overload + def create_cluster( + self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any + ) -> JSON: + # pylint: disable=line-too-long + """Create a New Kubernetes Cluster. + + To create a new Kubernetes cluster, send a POST request to + ``/v2/kubernetes/clusters``. The request must contain at least one node pool + with at least one worker. + + The request may contain a maintenance window policy describing a time period + when disruptive maintenance tasks may be carried out. Omitting the policy + implies that a window will be chosen automatically. See + `here `_ + for details. + + :param body: Required. + :type body: IO[bytes] + :keyword content_type: Body Parameter content-type. Content type parameter for binary body. + Default value is "application/json". + :paramtype content_type: str + :return: JSON object + :rtype: JSON + :raises ~azure.core.exceptions.HttpResponseError: + + Example: + .. code-block:: python + + # response body for status code(s): 201 + response == { + "kubernetes_cluster": { + "name": "str", # A human-readable name for a Kubernetes cluster. + Required. + "node_pools": [ + { + "auto_scale": bool, # Optional. A boolean value + indicating whether auto-scaling is enabled for this node pool. + "count": 0, # Optional. The number of Droplet + instances in the node pool. + "id": "str", # Optional. A unique ID that can be + used to identify and reference a specific node pool. + "labels": {}, # Optional. An object of key/value + mappings specifying labels to apply to all nodes in a pool. Labels + will automatically be applied to all existing nodes and any + subsequent nodes added to the pool. Note that when a label is + removed, it is not deleted from the nodes in the pool. + "max_nodes": 0, # Optional. The maximum number of + nodes that this node pool can be auto-scaled to. The value will be + ``0`` if ``auto_scale`` is set to ``false``. + "min_nodes": 0, # Optional. The minimum number of + nodes that this node pool can be auto-scaled to. The value will be + ``0`` if ``auto_scale`` is set to ``false``. + "name": "str", # Optional. A human-readable name for + the node pool. + "nodes": [ + { + "created_at": "2020-02-20 00:00:00", + # Optional. A time value given in ISO8601 combined date and + time format that represents when the node was created. + "droplet_id": "str", # Optional. The + ID of the Droplet used for the worker node. + "id": "str", # Optional. A unique ID + that can be used to identify and reference the node. + "name": "str", # Optional. An + automatically generated, human-readable name for the node. + "status": { + "state": "str" # Optional. A + string indicating the current status of the node. Known + values are: "provisioning", "running", "draining", and + "deleting". + }, + "updated_at": "2020-02-20 00:00:00" + # Optional. A time value given in ISO8601 combined date and + time format that represents when the node was last updated. + } + ], + "size": "str", # Optional. The slug identifier for + the type of Droplet used as workers in the node pool. + "tags": [ + "str" # Optional. An array containing the + tags applied to the node pool. All node pools are automatically + tagged ``k8s``"" , ``k8s-worker``"" , and + ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires + ``tag:read`` scope. + ], + "taints": [ + { + "effect": "str", # Optional. How the + node reacts to pods that it won't tolerate. Available effect + values are ``NoSchedule``"" , ``PreferNoSchedule``"" , and + ``NoExecute``. Known values are: "NoSchedule", + "PreferNoSchedule", and "NoExecute". + "key": "str", # Optional. An + arbitrary string. The ``key`` and ``value`` fields of the + ``taint`` object form a key-value pair. For example, if the + value of the ``key`` field is "special" and the value of the + ``value`` field is "gpu", the key value pair would be + ``special=gpu``. + "value": "str" # Optional. An + arbitrary string. The ``key`` and ``value`` fields of the + ``taint`` object form a key-value pair. For example, if the + value of the ``key`` field is "special" and the value of the + ``value`` field is "gpu", the key value pair would be + ``special=gpu``. + } + ] + } + ], + "region": "str", # The slug identifier for the region where the + Kubernetes cluster is located. Required. + "version": "str", # The slug identifier for the version of + Kubernetes used for the cluster. If set to a minor version (e.g. "1.14"), the + latest version within it will be used (e.g. "1.14.6-do.1"); if set to + "latest", the latest published version will be used. See the + ``/v2/kubernetes/options`` endpoint to find all currently available versions. + Required. + "amd_gpu_device_metrics_exporter_plugin": { + "enabled": bool # Optional. Indicates whether the AMD Device + Metrics Exporter is enabled. + }, + "amd_gpu_device_plugin": { + "enabled": bool # Optional. Indicates whether the AMD GPU + Device Plugin is enabled. + }, + "auto_upgrade": False, # Optional. Default value is False. A boolean + value indicating whether the cluster will be automatically upgraded to new + patch releases during its maintenance window. + "cluster_autoscaler_configuration": { + "expanders": [ + "str" # Optional. Customizes expanders used by + cluster-autoscaler. The autoscaler will apply each expander from the + provided list to narrow down the selection of node types created to + scale up, until either a single node type is left, or the list of + expanders is exhausted. If this flag is unset, autoscaler will use + its default expander ``random``. Passing an empty list ("" *not* + ``null``"" ) will unset any previous expander customizations. + Available expanders: * ``random``"" : Randomly selects a node group + to scale. * `priority`: Selects the node group with the highest + priority as per [user-provided + configuration](https://docs.digitalocean.com/products/kubernetes/how-to/autoscale/#configuring-priority-expander) + * ``least_waste``"" : Selects the node group that will result in the + least amount of idle resources. + ], + "scale_down_unneeded_time": "str", # Optional. Used to + customize how long a node is unneeded before being scaled down. + "scale_down_utilization_threshold": 0.0 # Optional. Used to + customize when cluster autoscaler scales down non-empty nodes by setting + the node utilization threshold. + }, + "cluster_subnet": "str", # Optional. The range of IP addresses for + the overlay network of the Kubernetes cluster in CIDR notation. + "control_plane_firewall": { + "allowed_addresses": [ + "str" # Optional. An array of public addresses (IPv4 + or CIDR) allowed to access the control plane. + ], + "enabled": bool # Optional. Indicates whether the control + plane firewall is enabled. + }, + "coredns_autoscaler": { + "enabled": bool # Optional. Indicates whether the CoreDNS + Cluster Proportional Autoscaler add-on is enabled. + }, + "created_at": "2020-02-20 00:00:00", # Optional. A time value given + in ISO8601 combined date and time format that represents when the Kubernetes + cluster was created. + "endpoint": "str", # Optional. The base URL of the API server on the + Kubernetes master node. + "ha": bool, # Optional. A boolean value indicating whether the + control plane is run in a highly available configuration in the cluster. + Highly available control planes incur less downtime. The property cannot be + disabled. When omitted on create, the default is version-dependent; for DOKS + 1.36.0 and later, the default is true; for earlier versions, the default is + false. + "id": "str", # Optional. A unique ID that can be used to identify + and reference a Kubernetes cluster. + "ipv4": "str", # Optional. The public IPv4 address of the Kubernetes + master node. This will not be set if high availability is configured on the + cluster (v1.21+). + "maintenance_policy": { + "day": "str", # Optional. The day of the maintenance window + policy. May be one of ``monday`` through ``sunday``"" , or ``any`` to + indicate an arbitrary week day. Known values are: "any", "monday", + "tuesday", "wednesday", "thursday", "friday", "saturday", and "sunday". + "duration": "str", # Optional. The duration of the + maintenance window policy in human-readable format. + "start_time": "str" # Optional. The start time in UTC of the + maintenance window policy in 24-hour clock format / HH:MM notation (e.g., + ``15:00``"" ). + }, + "nvidia_gpu_device_plugin": { + "enabled": bool # Optional. Indicates whether the Nvidia GPU + Device Plugin is enabled. + }, + "p2p_oci_registry_plugin": { "enabled": bool # Optional. Indicates whether the - routing-agent component is enabled. - }, - "service_subnet": "str", # Optional. The range of assignable IP - addresses for services running in the Kubernetes cluster in CIDR notation. - "sso": { - "client_id": "str", # Optional. The OIDC client ID - registered with the identity provider. Required when ``enabled`` is - ``true``. - "enabled": False, # Optional. Default value is False. - Indicates whether SSO authentication is enabled for the cluster. - "issuer_url": "str", # Optional. The OIDC issuer URL for the - identity provider. Required when ``enabled`` is ``true``. - "required": False # Optional. Default value is False. - Indicates whether any non-SSO forms of authentication are disallowed. Can - only be set to ``true`` when ``enabled`` is ``true``. - }, - "status": { - "message": "str", # Optional. An optional message providing - additional information about the current cluster state. - "state": "str" # Optional. A string indicating the current - status of the cluster. Known values are: "running", "provisioning", - "degraded", "error", "deleted", "upgrading", and "deleting". - }, - "surge_upgrade": False, # Optional. Default value is False. A - boolean value indicating whether surge upgrade is enabled/disabled for the - cluster. Surge upgrade makes cluster upgrades fast and reliable by bringing - up new nodes before destroying the outdated nodes. - "tags": [ - "str" # Optional. An array of tags to apply to the - Kubernetes cluster. All clusters are automatically tagged ``k8s`` and - ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires ``tag:read`` - and ``tag:create`` scope, as well as ``tag:delete`` if existing tags are - getting removed. - ], - "updated_at": "2020-02-20 00:00:00", # Optional. A time value given - in ISO8601 combined date and time format that represents when the Kubernetes - cluster was last updated. - "vpc_uuid": "str", # Optional. A string specifying the UUID of the - VPC to which the Kubernetes cluster is - assigned.:code:`
`:code:`
`Requires ``vpc:read`` scope. - "worker_subnet_uuid": "str" # Optional. The UUID of the VPC subnet - to attach worker nodes to. When omitted on create, the default subnet for the - VPC is used. This value cannot be changed after the cluster is created. - ``vpc_uuid`` must also be set. :code:`
`:code:`
`Requires ``vpc:read`` - scope. - } - } - """ - - @overload - def create_cluster( - self, body: IO[bytes], *, content_type: str = "application/json", **kwargs: Any - ) -> JSON: - # pylint: disable=line-too-long - """Create a New Kubernetes Cluster. - - To create a new Kubernetes cluster, send a POST request to - ``/v2/kubernetes/clusters``. The request must contain at least one node pool - with at least one worker. - - The request may contain a maintenance window policy describing a time period - when disruptive maintenance tasks may be carried out. Omitting the policy - implies that a window will be chosen automatically. See - `here `_ - for details. - - :param body: Required. - :type body: IO[bytes] - :keyword content_type: Body Parameter content-type. Content type parameter for binary body. - Default value is "application/json". - :paramtype content_type: str - :return: JSON object - :rtype: JSON - :raises ~azure.core.exceptions.HttpResponseError: - - Example: - .. code-block:: python - - # response body for status code(s): 201 - response == { - "kubernetes_cluster": { - "name": "str", # A human-readable name for a Kubernetes cluster. - Required. - "node_pools": [ - { - "auto_scale": bool, # Optional. A boolean value - indicating whether auto-scaling is enabled for this node pool. - "count": 0, # Optional. The number of Droplet - instances in the node pool. - "id": "str", # Optional. A unique ID that can be - used to identify and reference a specific node pool. - "labels": {}, # Optional. An object of key/value - mappings specifying labels to apply to all nodes in a pool. Labels - will automatically be applied to all existing nodes and any - subsequent nodes added to the pool. Note that when a label is - removed, it is not deleted from the nodes in the pool. - "max_nodes": 0, # Optional. The maximum number of - nodes that this node pool can be auto-scaled to. The value will be - ``0`` if ``auto_scale`` is set to ``false``. - "min_nodes": 0, # Optional. The minimum number of - nodes that this node pool can be auto-scaled to. The value will be - ``0`` if ``auto_scale`` is set to ``false``. - "name": "str", # Optional. A human-readable name for - the node pool. - "nodes": [ - { - "created_at": "2020-02-20 00:00:00", - # Optional. A time value given in ISO8601 combined date and - time format that represents when the node was created. - "droplet_id": "str", # Optional. The - ID of the Droplet used for the worker node. - "id": "str", # Optional. A unique ID - that can be used to identify and reference the node. - "name": "str", # Optional. An - automatically generated, human-readable name for the node. - "status": { - "state": "str" # Optional. A - string indicating the current status of the node. Known - values are: "provisioning", "running", "draining", and - "deleting". - }, - "updated_at": "2020-02-20 00:00:00" - # Optional. A time value given in ISO8601 combined date and - time format that represents when the node was last updated. - } - ], - "size": "str", # Optional. The slug identifier for - the type of Droplet used as workers in the node pool. - "tags": [ - "str" # Optional. An array containing the - tags applied to the node pool. All node pools are automatically - tagged ``k8s``"" , ``k8s-worker``"" , and - ``k8s:$K8S_CLUSTER_ID``. :code:`
`:code:`
`Requires - ``tag:read`` scope. - ], - "taints": [ - { - "effect": "str", # Optional. How the - node reacts to pods that it won't tolerate. Available effect - values are ``NoSchedule``"" , ``PreferNoSchedule``"" , and - ``NoExecute``. Known values are: "NoSchedule", - "PreferNoSchedule", and "NoExecute". - "key": "str", # Optional. An - arbitrary string. The ``key`` and ``value`` fields of the - ``taint`` object form a key-value pair. For example, if the - value of the ``key`` field is "special" and the value of the - ``value`` field is "gpu", the key value pair would be - ``special=gpu``. - "value": "str" # Optional. An - arbitrary string. The ``key`` and ``value`` fields of the - ``taint`` object form a key-value pair. For example, if the - value of the ``key`` field is "special" and the value of the - ``value`` field is "gpu", the key value pair would be - ``special=gpu``. - } - ] - } - ], - "region": "str", # The slug identifier for the region where the - Kubernetes cluster is located. Required. - "version": "str", # The slug identifier for the version of - Kubernetes used for the cluster. If set to a minor version (e.g. "1.14"), the - latest version within it will be used (e.g. "1.14.6-do.1"); if set to - "latest", the latest published version will be used. See the - ``/v2/kubernetes/options`` endpoint to find all currently available versions. - Required. - "amd_gpu_device_metrics_exporter_plugin": { - "enabled": bool # Optional. Indicates whether the AMD Device - Metrics Exporter is enabled. - }, - "amd_gpu_device_plugin": { - "enabled": bool # Optional. Indicates whether the AMD GPU - Device Plugin is enabled. - }, - "auto_upgrade": False, # Optional. Default value is False. A boolean - value indicating whether the cluster will be automatically upgraded to new - patch releases during its maintenance window. - "cluster_autoscaler_configuration": { - "expanders": [ - "str" # Optional. Customizes expanders used by - cluster-autoscaler. The autoscaler will apply each expander from the - provided list to narrow down the selection of node types created to - scale up, until either a single node type is left, or the list of - expanders is exhausted. If this flag is unset, autoscaler will use - its default expander ``random``. Passing an empty list ("" *not* - ``null``"" ) will unset any previous expander customizations. - Available expanders: * ``random``"" : Randomly selects a node group - to scale. * `priority`: Selects the node group with the highest - priority as per [user-provided - configuration](https://docs.digitalocean.com/products/kubernetes/how-to/autoscale/#configuring-priority-expander) - * ``least_waste``"" : Selects the node group that will result in the - least amount of idle resources. - ], - "scale_down_unneeded_time": "str", # Optional. Used to - customize how long a node is unneeded before being scaled down. - "scale_down_utilization_threshold": 0.0 # Optional. Used to - customize when cluster autoscaler scales down non-empty nodes by setting - the node utilization threshold. - }, - "cluster_subnet": "str", # Optional. The range of IP addresses for - the overlay network of the Kubernetes cluster in CIDR notation. - "control_plane_firewall": { - "allowed_addresses": [ - "str" # Optional. An array of public addresses (IPv4 - or CIDR) allowed to access the control plane. - ], - "enabled": bool # Optional. Indicates whether the control - plane firewall is enabled. - }, - "coredns_autoscaler": { - "enabled": bool # Optional. Indicates whether the CoreDNS - Cluster Proportional Autoscaler add-on is enabled. - }, - "created_at": "2020-02-20 00:00:00", # Optional. A time value given - in ISO8601 combined date and time format that represents when the Kubernetes - cluster was created. - "endpoint": "str", # Optional. The base URL of the API server on the - Kubernetes master node. - "ha": bool, # Optional. A boolean value indicating whether the - control plane is run in a highly available configuration in the cluster. - Highly available control planes incur less downtime. The property cannot be - disabled. When omitted on create, the default is version-dependent; for DOKS - 1.36.0 and later, the default is true; for earlier versions, the default is - false. - "id": "str", # Optional. A unique ID that can be used to identify - and reference a Kubernetes cluster. - "ipv4": "str", # Optional. The public IPv4 address of the Kubernetes - master node. This will not be set if high availability is configured on the - cluster (v1.21+). - "maintenance_policy": { - "day": "str", # Optional. The day of the maintenance window - policy. May be one of ``monday`` through ``sunday``"" , or ``any`` to - indicate an arbitrary week day. Known values are: "any", "monday", - "tuesday", "wednesday", "thursday", "friday", "saturday", and "sunday". - "duration": "str", # Optional. The duration of the - maintenance window policy in human-readable format. - "start_time": "str" # Optional. The start time in UTC of the - maintenance window policy in 24-hour clock format / HH:MM notation (e.g., - ``15:00``"" ). - }, - "nvidia_gpu_device_plugin": { - "enabled": bool # Optional. Indicates whether the Nvidia GPU - Device Plugin is enabled. + Peer-to-peer OCI registry component is enabled. }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA @@ -166936,6 +172147,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167155,6 +172370,10 @@ def create_cluster(self, body: Union[JSON, IO[bytes]], **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167468,6 +172687,10 @@ def get_cluster(self, cluster_id: str, **kwargs: Any) -> JSON: "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167710,6 +172933,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -167906,6 +173133,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168170,6 +173401,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168327,6 +173562,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the Peer-to-peer OCI + registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -168523,6 +173762,10 @@ def update_cluster( "enabled": bool # Optional. Indicates whether the Nvidia GPU Device Plugin is enabled. }, + "p2p_oci_registry_plugin": { + "enabled": bool # Optional. Indicates whether the + Peer-to-peer OCI registry component is enabled. + }, "rdma_shared_dev_plugin": { "enabled": bool # Optional. Indicates whether the RDMA shared device plugin is enabled. @@ -194111,9 +199354,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -194185,9 +199429,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -194245,9 +199490,10 @@ def assign_resources( To assign resources to a project, send a POST request to ``/v2/projects/$PROJECT_ID/resources``. - You must have both ``project:update`` and ``:read`` scopes to assign new resources. - For example, to assign a Droplet to a project, include both the ``project:update`` and - ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to a project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param project_id: A unique identifier for a project. Required. :type project_id: str @@ -194534,9 +199780,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: JSON @@ -194601,9 +199848,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Required. :type body: IO[bytes] @@ -194659,9 +199907,10 @@ def assign_resources_default( To assign resources to your default project, send a POST request to ``/v2/projects/default/resources``. - You must have both project:update and :code:``:read scopes to assign new resources. - For example, to assign a Droplet to the default project, include both the ``project:update`` - and ``droplet:read`` scopes. + You must have both ``project:assign_resource`` and ``:read`` scopes to assign new + resources. For example, to assign a Droplet to the default project, include both the + ``project:assign_resource`` and ``droplet:read`` scopes. The ``project:update`` scope also + grants ``project:assign_resource``. :param body: Is either a JSON type or a IO[bytes] type. Required. :type body: JSON or IO[bytes] @@ -207942,9 +213191,10 @@ def list(self, *, per_page: int = 20, page: int = 1, **kwargs: Any) -> JSON: of measure for the disk size. }, "type": "str" # Optional. The type of disk. - All Droplets contain a ``local`` disk. Additionally, GPU Droplets - can also have a ``scratch`` disk for non-persistent data. Known - values are: "local" and "scratch". + All Droplets contain a ``local`` or ``remote`` disk. + Additionally, GPU Droplets can also have a ``scratch`` disk for + non-persistent data. Known values are: "local", "remote", and + "scratch". } ], "gpu_info": { diff --git a/tests/agents/test_async_sessions.py b/tests/agents/test_async_sessions.py new file mode 100644 index 00000000..63ff5a63 --- /dev/null +++ b/tests/agents/test_async_sessions.py @@ -0,0 +1,281 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.aio.agents.custom_sessions`.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock + +import pytest + +from pydo.aio.agents import AsyncAgentsResources + + +class _FakeAsyncResponse: + def __init__(self, status_code: int, body: Any = None, *, sse_chunks=None): + self.status_code = status_code + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + self._sse = sse_chunks + + async def read(self) -> bytes: + return self._body_bytes + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + async def iter_bytes(self): + for chunk in self._sse or []: + yield chunk + + def close(self) -> None: + pass + + +class _FakeAsyncPipeline: + def __init__(self, responses: List[_FakeAsyncResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + async def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def _make_async_resources(responses: List[_FakeAsyncResponse]) -> AsyncAgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakeAsyncPipeline(responses) + return AsyncAgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +@pytest.mark.asyncio +async def test_async_create_from_manifest_uploads_yaml_verbatim(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, {"session": {"session_id": "abc"}})] + ) + manifest = "apiVersion: agents.digitalocean.com/v1alpha1\nkind: Agent\n" + + resp = await resources.sessions.create_from_manifest(manifest) + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions") + assert call.request.headers.get("Content-Type") == "application/x-yaml" + content = call.request.content + if isinstance(content, bytes): + content = content.decode("utf-8") + assert content == manifest + assert resp.session.session_id == "abc" + + +@pytest.mark.asyncio +async def test_async_create_from_manifest_rejects_empty(): + resources = _make_async_resources([]) + with pytest.raises(ValueError): + await resources.sessions.create_from_manifest("") + + +@pytest.mark.asyncio +async def test_async_pause_session(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, {"session": {"session_id": "abc-123"}})] + ) + await resources.sessions.pause("abc-123") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/abc-123/pause") + + +@pytest.mark.asyncio +async def test_async_resume_session(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, {"session": {"session_id": "abc-123"}})] + ) + await resources.sessions.resume("abc-123") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/abc-123/resume") + + +@pytest.mark.asyncio +async def test_async_list_filters_by_name(): + resources = _make_async_resources([_FakeAsyncResponse(200, {"sessions": []})]) + await resources.sessions.list(name="my-session") + + call = resources._proxy._original._pipeline.calls[0] + assert "name=my-session" in call.request.url + + +@pytest.mark.asyncio +async def test_async_attach_by_name_picks_most_recent_match(): + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 200, + { + "sessions": [ + { + "session_id": "old", + "name": "dup", + "created_at": "2026-01-01T00:00:00Z", + }, + { + "session_id": "new", + "name": "dup", + "created_at": "2026-07-01T00:00:00Z", + }, + ] + }, + ) + ] + ) + agent = await resources.attach_by_name("dup") + assert agent.session_id == "new" + + +@pytest.mark.asyncio +async def test_async_attach_by_name_raises_when_not_found(): + resources = _make_async_resources([_FakeAsyncResponse(200, {"sessions": []})]) + with pytest.raises(LookupError): + await resources.attach_by_name("missing") + + +# --------------------------------------------------------------------------- +# Backward history paging +# --------------------------------------------------------------------------- + +_HISTORY_PAGE_SSE = ( + b": connected to s1\n\n" + b'data: {"event_id":"e10","type":"run.token_delta","data":{"text":"older "}}\n\n' + b'data: {"event_id":"e11","type":"run.token_delta","data":{"text":"newer"}}\n\n' + b": has_more=true\n\n" +) + + +async def _drain(stream) -> list: + return [event async for event in stream] + + +@pytest.mark.asyncio +async def test_async_stream_before_implies_replay_only_and_sends_limit(): + resources = _make_async_resources([_FakeAsyncResponse(200, sse_chunks=[b""])]) + + await _drain(await resources.sessions.stream("s1", before="evt-99", limit=50)) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=evt-99" in url + assert "limit=50" in url + assert "replay_only=true" in url + + +@pytest.mark.asyncio +async def test_async_stream_omits_paging_params_when_unset(): + resources = _make_async_resources([_FakeAsyncResponse(200, sse_chunks=[b""])]) + + await _drain(await resources.sessions.stream("s1")) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=" not in url + assert "limit=" not in url + assert "replay_only=" not in url + + +@pytest.mark.asyncio +async def test_async_stream_rejects_limit_without_before(): + resources = _make_async_resources([]) + with pytest.raises(ValueError, match="before"): + await resources.sessions.stream("s1", limit=10) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", [0, -1]) +async def test_async_stream_rejects_non_positive_limit(limit): + resources = _make_async_resources([]) + with pytest.raises(ValueError, match="positive"): + await resources.sessions.stream("s1", before="evt-99", limit=limit) + + +@pytest.mark.asyncio +async def test_async_stream_records_has_more_comment(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])] + ) + + stream = await resources.sessions.stream("s1", before="evt-12") + assert stream.has_more is None + + events = await _drain(stream) + assert [e.event_id for e in events] == ["e10", "e11"] + assert stream.has_more is True + assert stream.oldest_event_id == "e10" + + +@pytest.mark.asyncio +async def test_async_history_page_returns_events_cursor_and_has_more(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])] + ) + + page = await resources.sessions.history_page("s1", before="evt-12", limit=2) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=evt-12" in url + assert "limit=2" in url + assert "replay_only=true" in url + + events, has_more, next_before = page + assert [e.event_id for e in events] == ["e10", "e11"] + assert has_more is True + assert next_before == "e10" + + +@pytest.mark.asyncio +async def test_async_history_page_empty_has_no_cursor(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, sse_chunks=[b": has_more=false\n\n"])] + ) + + page = await resources.sessions.history_page("s1", before="e1") + assert page.events == [] + assert page.has_more is False + assert page.next_before is None + + +@pytest.mark.asyncio +async def test_async_history_page_requires_before(): + resources = _make_async_resources([]) + with pytest.raises(ValueError, match="before"): + await resources.sessions.history_page("s1", before="") + + +@pytest.mark.asyncio +async def test_async_agent_session_history_binds_session_id(): + resources = _make_async_resources( + [_FakeAsyncResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])] + ) + + page = await resources.attach("s1").history(before="evt-12") + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "/v2/agents/sessions/s1/stream" in url + assert page.next_before == "e10" diff --git a/tests/agents/test_async_triggers.py b/tests/agents/test_async_triggers.py new file mode 100644 index 00000000..a5e6e927 --- /dev/null +++ b/tests/agents/test_async_triggers.py @@ -0,0 +1,154 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.aio.agents.custom_triggers`.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock + +import pytest + +from pydo.aio.agents import AsyncAgentsResources +from pydo.agents import TriggerKind, TriggerStatus + + +class _FakeAsyncResponse: + def __init__(self, status_code: int, body: Any = None): + self.status_code = status_code + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + async def read(self) -> bytes: + return self._body_bytes + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + +class _FakeAsyncPipeline: + def __init__(self, responses: List[_FakeAsyncResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + async def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def _make_async_resources(responses: List[_FakeAsyncResponse]) -> AsyncAgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakeAsyncPipeline(responses) + return AsyncAgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +@pytest.mark.asyncio +async def test_async_list_and_create_triggers(): + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 200, {"triggers": [{"trigger_id": "t1"}], "next_page_token": ""} + ), + _FakeAsyncResponse( + 201, + { + "trigger": {"trigger_id": "t2", "kind": TriggerKind.CRON}, + }, + ), + ] + ) + + listed = await resources.triggers.list(kind=TriggerKind.CRON) + assert listed.triggers[0].trigger_id == "t1" + list_call = resources._proxy._original._pipeline.calls[0] + assert list_call.request.method == "GET" + assert list_call.request.url.split("?", 1)[0].endswith("/v2/agents/triggers") + assert "kind=cron" in list_call.request.url + + created = await resources.triggers.create( + { + "kind": TriggerKind.CRON, + "name": "nightly", + "session_mode": "fresh", + "prompt_template": "run nightly", + "output": {"mode": "none"}, + "session_template": "kind: Agent\n", + "cron": {"cron_expr": "0 2 * * *", "timezone": "UTC"}, + } + ) + assert created.trigger.trigger_id == "t2" + create_call = resources._proxy._original._pipeline.calls[1] + assert create_call.request.method == "POST" + assert create_call.request.headers.get("Content-Type") == "application/json" + + +@pytest.mark.asyncio +async def test_async_update_delete_rotate_and_executions(): + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 200, {"trigger": {"trigger_id": "t1", "status": TriggerStatus.PAUSED}} + ), + _FakeAsyncResponse(204), + _FakeAsyncResponse(200, {"webhook_secret": "new"}), + _FakeAsyncResponse( + 200, {"executions": [{"execution_id": "e1"}], "next_page_token": ""} + ), + _FakeAsyncResponse( + 200, + {"execution": {"execution_id": "e1", "payload": "{}"}}, + ), + _FakeAsyncResponse(200, {"trigger": {"trigger_id": "t1"}}), + _FakeAsyncResponse(200, {"sessions": []}), + _FakeAsyncResponse(200, {"providers": []}), + ] + ) + + updated = await resources.triggers.update("t1", {"status": TriggerStatus.PAUSED}) + assert updated.trigger.status == "paused" + assert resources._proxy._original._pipeline.calls[0].request.method == "PATCH" + + await resources.triggers.delete("t1") + assert resources._proxy._original._pipeline.calls[1].request.method == "DELETE" + + rotated = await resources.triggers.rotate_secret("t1") + assert rotated.webhook_secret == "new" + + executions = await resources.triggers.list_executions("t1") + assert executions.executions[0].execution_id == "e1" + + execution = await resources.triggers.get_execution("t1", "e1") + assert execution.execution.payload == "{}" + + by_session = await resources.triggers.get_by_session("s1") + assert by_session.trigger.trigger_id == "t1" + assert resources._proxy._original._pipeline.calls[5].request.url.endswith( + "/v2/agents/triggers/by-session/s1" + ) + + await resources.triggers.list_reusable_sessions() + assert resources._proxy._original._pipeline.calls[6].request.url.endswith( + "/v2/agents/triggers/reusable-sessions" + ) + + await resources.triggers.list_webhook_providers() + assert resources._proxy._original._pipeline.calls[7].request.url.endswith( + "/v2/agents/webhook-providers" + ) diff --git a/tests/agents/test_session_highlevel.py b/tests/agents/test_session_highlevel.py new file mode 100644 index 00000000..6d51b2d5 --- /dev/null +++ b/tests/agents/test_session_highlevel.py @@ -0,0 +1,292 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for the high-level agent interface (:mod:`pydo.agents.session`).""" +from __future__ import annotations + +from typing import Any, List + +import pytest + +from pydo.agents import AgentEvent, AgentEventType, AgentSession, RunResult +from pydo.agents.custom_models import HITLOutcome +from pydo.aio.agents import AsyncAgentSession + + +# --------------------------------------------------------------------------- +# Sync fakes +# --------------------------------------------------------------------------- + + +class _FakeRawStream: + def __init__(self, events: List[dict]): + self._events = events + self.closed = False + + def __iter__(self): + return iter(self._events) + + def close(self): + self.closed = True + + +class _FakeSessions: + """Stands in for SessionsOperations, recording call order.""" + + def __init__(self, events: List[dict]): + self._events = events + self.calls: List[Any] = [] + self.resolved: List[Any] = [] + self.destroyed = False + + def create_from_manifest(self, manifest): + self.calls.append(("create", manifest)) + return {"session": {"session_id": "s1", "status": "SESSION_STATUS_READY"}} + + def stream(self, session_id, **kwargs): + self.calls.append(("stream", session_id)) + return _FakeRawStream(self._events) + + def send_input(self, session_id, *, text): + self.calls.append(("send_input", session_id, text)) + return {"run_id": "r1"} + + def resolve_hitl( + self, session_id, request_id, *, outcome, reason=None, source=None + ): + self.calls.append(("resolve_hitl", session_id, request_id, outcome)) + self.resolved.append((request_id, outcome)) + + def get(self, session_id): + return {"session": {"session_id": session_id, "status": "SESSION_STATUS_READY"}} + + def destroy(self, session_id): + self.calls.append(("destroy", session_id)) + self.destroyed = True + + +# --------------------------------------------------------------------------- +# AgentEvent normalization +# --------------------------------------------------------------------------- + + +def test_agent_event_normalizes_token(): + ev = AgentEvent({"type": "run.token_delta", "data": {"text": "hi"}, "run_id": "r1"}) + assert ev.type == AgentEventType.TOKEN + assert ev.text == "hi" + assert ev.run_id == "r1" + + +def test_agent_event_normalizes_completed_and_failed(): + done = AgentEvent({"type": "run.completed", "data": {"total_tokens_out": 7}}) + assert done.type == AgentEventType.COMPLETED + assert done.usage["tokens_out"] == 7 + + failed = AgentEvent({"type": "run.failed", "data": {"code": 3, "message": "boom"}}) + assert failed.type == AgentEventType.FAILED + assert failed.error == {"code": 3, "message": "boom"} + + +def test_agent_event_unknown_type_is_other(): + assert AgentEvent({"type": "session.updated"}).type == AgentEventType.OTHER + + +# --------------------------------------------------------------------------- +# run / run_streamed +# --------------------------------------------------------------------------- + + +def test_run_collects_final_output_usage_and_order(): + events = [ + {"type": "run.started", "data": {}}, + {"type": "run.token_delta", "data": {"text": "Hello "}}, + {"type": "run.token_delta", "data": {"text": "world"}}, + { + "type": "run.completed", + "data": { + "total_tokens_in": 3, + "total_tokens_out": 5, + "run_cost_micros": 1234, + }, + }, + ] + sessions = _FakeSessions(events) + result = AgentSession(sessions, "s1").run("hi") + + assert isinstance(result, RunResult) + assert result.final_output == "Hello world" + assert result.status == "completed" and result.ok + assert result.usage["tokens_out"] == 5 + assert result.run_id == "r1" + # stream is opened BEFORE input is sent (so no early events are missed) + kinds = [c[0] for c in sessions.calls] + assert kinds.index("stream") < kinds.index("send_input") + + +def test_run_streamed_yields_typed_events(): + events = [ + {"type": "run.token_delta", "data": {"text": "hi"}}, + {"type": "run.completed", "data": {}}, + ] + stream = AgentSession(_FakeSessions(events), "s1").run_streamed("x") + seen = [e.type for e in stream] + assert seen == [AgentEventType.TOKEN, AgentEventType.COMPLETED] + assert stream.final_output == "hi" + assert stream.status == "completed" + + +def test_run_failed_sets_status_and_error(): + events = [ + {"type": "run.token_delta", "data": {"text": "partial"}}, + {"type": "run.failed", "data": {"code": 3, "message": "boom"}}, + ] + result = AgentSession(_FakeSessions(events), "s1").run("x") + assert result.status == "failed" and not result.ok + assert result.error["message"] == "boom" + + +# --------------------------------------------------------------------------- +# HITL policy +# --------------------------------------------------------------------------- + + +def test_run_auto_approves_hitl_by_default(): + events = [ + {"type": "run.human_input_requested", "data": {"hitl_id": "req-1"}}, + {"type": "run.token_delta", "data": {"text": "done"}}, + {"type": "run.completed", "data": {}}, + ] + sessions = _FakeSessions(events) + result = AgentSession(sessions, "s1").run("do it") + assert ("req-1", HITLOutcome.APPROVE) in sessions.resolved + assert result.final_output == "done" + + +def test_hitl_callable_policy_receives_event(): + events = [ + {"type": "run.human_input_requested", "data": {"hitl_id": "req-9"}}, + {"type": "run.completed", "data": {}}, + ] + sessions = _FakeSessions(events) + seen = [] + + def policy(event): + seen.append(event.request_id) + return "reject" + + AgentSession(sessions, "s1").run("x", hitl=policy) + assert seen == ["req-9"] + assert ("req-9", HITLOutcome.REJECT) in sessions.resolved + + +def test_hitl_none_leaves_prompt_unresolved(): + events = [ + {"type": "run.human_input_requested", "data": {"hitl_id": "req-9"}}, + {"type": "run.completed", "data": {}}, + ] + sessions = _FakeSessions(events) + AgentSession(sessions, "s1").run("x", hitl=None) + assert sessions.resolved == [] + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +def test_context_manager_destroys(): + sessions = _FakeSessions([]) + with AgentSession(sessions, "s1") as agent: + assert agent.session_id == "s1" + assert sessions.destroyed + + +def test_run_stream_closes_underlying_stream(): + events = [{"type": "run.completed", "data": {}}] + sessions = _FakeSessions(events) + stream = AgentSession(sessions, "s1").run_streamed("x") + raw = stream._raw + list(stream) + assert raw.closed + + +# --------------------------------------------------------------------------- +# Async parity +# --------------------------------------------------------------------------- + + +class _FakeAsyncRawStream: + def __init__(self, events: List[dict]): + self._events = events + self.closed = False + + def __aiter__(self): + return self._gen() + + async def _gen(self): + for ev in self._events: + yield ev + + async def close(self): + self.closed = True + + +class _FakeAsyncSessions: + def __init__(self, events: List[dict]): + self._events = events + self.calls: List[Any] = [] + self.resolved: List[Any] = [] + self.destroyed = False + + async def stream(self, session_id, **kwargs): + self.calls.append(("stream", session_id)) + return _FakeAsyncRawStream(self._events) + + async def send_input(self, session_id, *, text): + self.calls.append(("send_input", session_id, text)) + return {"run_id": "r1"} + + async def resolve_hitl( + self, session_id, request_id, *, outcome, reason=None, source=None + ): + self.resolved.append((request_id, outcome)) + + async def destroy(self, session_id): + self.destroyed = True + + +@pytest.mark.asyncio +async def test_async_run_collects_output_and_order(): + events = [ + {"type": "run.token_delta", "data": {"text": "Hi "}}, + {"type": "run.token_delta", "data": {"text": "there"}}, + {"type": "run.completed", "data": {"total_tokens_out": 2}}, + ] + sessions = _FakeAsyncSessions(events) + result = await AsyncAgentSession(sessions, "s1").run("x") + assert result.final_output == "Hi there" + assert result.status == "completed" + assert result.usage["tokens_out"] == 2 + kinds = [c[0] for c in sessions.calls] + assert kinds.index("stream") < kinds.index("send_input") + + +@pytest.mark.asyncio +async def test_async_auto_approves_hitl(): + events = [ + {"type": "run.human_input_requested", "data": {"hitl_id": "req-1"}}, + {"type": "run.completed", "data": {}}, + ] + sessions = _FakeAsyncSessions(events) + await AsyncAgentSession(sessions, "s1").run("x") + assert ("req-1", HITLOutcome.APPROVE) in sessions.resolved + + +@pytest.mark.asyncio +async def test_async_context_manager_destroys(): + sessions = _FakeAsyncSessions([]) + async with AsyncAgentSession(sessions, "s1") as agent: + assert agent.session_id == "s1" + assert sessions.destroyed diff --git a/tests/agents/test_sessions.py b/tests/agents/test_sessions.py new file mode 100644 index 00000000..87644c6f --- /dev/null +++ b/tests/agents/test_sessions.py @@ -0,0 +1,464 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.agents.custom_sessions`.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock + +import pytest + +from pydo.agents import ( + AgentsResources, + HITLOutcome, + HarnessStreamError, + OAuthProvider, + ResolutionSource, + SessionStatus, + resolve_agents_base_url, +) + +# --------------------------------------------------------------------------- +# Fake pipeline / response plumbing +# --------------------------------------------------------------------------- + + +class _FakeResponse: + def __init__(self, status_code: int, body: Any = None, *, sse_chunks=None): + self.status_code = status_code + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + self._sse = sse_chunks + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + def read(self) -> bytes: + return self._body_bytes + + def iter_bytes(self): + for chunk in self._sse or []: + yield chunk + + def close(self) -> None: + pass + + +class _FakePipeline: + def __init__(self, responses: List[_FakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + response = self._responses.pop(0) + return SimpleNamespace(http_response=response) + + +def _make_resources(responses: List[_FakeResponse]) -> AgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakePipeline(responses) + return AgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +# --------------------------------------------------------------------------- +# CRUD endpoints +# --------------------------------------------------------------------------- + + +def test_create_from_manifest_uploads_yaml_verbatim(): + body = {"session": {"session_id": "abc", "status": SessionStatus.PROVISIONING}} + resources = _make_resources([_FakeResponse(200, body)]) + + manifest = ( + "apiVersion: agents.digitalocean.com/v1alpha1\n" + "kind: Agent\n" + "metadata:\n" + " name: harness-demo\n" + ) + resp = resources.sessions.create_from_manifest(manifest) + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions") + assert call.request.headers.get("Content-Type") == "application/x-yaml" + content = call.request.content + if isinstance(content, bytes): + content = content.decode("utf-8") + assert content == manifest + assert resp.session.session_id == "abc" + + +def test_create_from_manifest_accepts_bytes(): + resources = _make_resources([_FakeResponse(200, {"session": {"session_id": "z"}})]) + resources.sessions.create_from_manifest(b"kind: Agent\n") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.headers.get("Content-Type") == "application/x-yaml" + + +def test_create_from_manifest_rejects_empty(): + resources = _make_resources([]) + with pytest.raises(ValueError): + resources.sessions.create_from_manifest(" \n ") + + +def test_get_session_url_encodes_id(): + resources = _make_resources( + [_FakeResponse(200, {"session": {"session_id": "x/y"}})] + ) + resources.sessions.get("x/y") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "GET" + assert call.request.url.endswith("/v2/agents/sessions/x%2Fy") + + +def test_destroy_session(): + resources = _make_resources([_FakeResponse(200, "")]) + resources.sessions.destroy("abc-123") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "DELETE" + assert call.request.url.endswith("/v2/agents/sessions/abc-123") + + +def test_pause_session(): + resources = _make_resources( + [_FakeResponse(200, {"session": {"session_id": "abc-123"}})] + ) + resources.sessions.pause("abc-123") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/abc-123/pause") + + +def test_resume_session(): + resources = _make_resources( + [_FakeResponse(200, {"session": {"session_id": "abc-123"}})] + ) + resources.sessions.resume("abc-123") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/abc-123/resume") + + +def test_list_sessions_propagates_query_params(): + resources = _make_resources( + [_FakeResponse(200, {"sessions": [], "next_page_token": ""})] + ) + resources.sessions.list(page_token="tok", page_size=10, status=SessionStatus.READY) + + call = resources._proxy._original._pipeline.calls[0] + raw = call.request.url + assert "page_token=tok" in raw + assert "page_size=10" in raw + assert "status=SESSION_STATUS_READY" in raw + + +def test_list_sessions_filters_by_name(): + resources = _make_resources([_FakeResponse(200, {"sessions": []})]) + resources.sessions.list(name="my-session") + + call = resources._proxy._original._pipeline.calls[0] + assert "name=my-session" in call.request.url + + +def test_attach_by_name_picks_most_recent_match(): + resources = _make_resources( + [ + _FakeResponse( + 200, + { + "sessions": [ + { + "session_id": "old", + "name": "dup", + "created_at": "2026-01-01T00:00:00Z", + }, + { + "session_id": "new", + "name": "dup", + "created_at": "2026-07-01T00:00:00Z", + }, + ] + }, + ) + ] + ) + agent = resources.attach_by_name("dup") + + assert "name=dup" in resources._proxy._original._pipeline.calls[0].request.url + assert agent.session_id == "new" + + +def test_attach_by_name_raises_when_not_found(): + resources = _make_resources([_FakeResponse(200, {"sessions": []})]) + with pytest.raises(LookupError): + resources.attach_by_name("missing") + + +def test_send_input_body_shape(): + resources = _make_resources([_FakeResponse(200, {"run_id": "r1"})]) + resp = resources.sessions.send_input("s1", text="hello world") + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/s1/input") + assert json.loads(call.request.content) == {"text": "hello world"} + assert resp.run_id == "r1" + + +def test_resolve_hitl_url_and_body(): + resources = _make_resources([_FakeResponse(200, "")]) + resources.sessions.resolve_hitl( + "s1", + "req-9", + outcome=HITLOutcome.APPROVE, + reason="looks safe", + source=ResolutionSource.OUT_OF_BAND, + ) + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.url.endswith("/v2/agents/sessions/s1/hitl/req-9") + assert json.loads(call.request.content) == { + "outcome": "HITL_OUTCOME_APPROVE", + "reason": "looks safe", + "source": "RESOLUTION_SOURCE_OUT_OF_BAND", + } + + +def test_start_oauth_flow(): + body = { + "authorize_url": "https://github.com/login/oauth/authorize?...", + "flow_kind": "OAUTH_FLOW_KIND_WEB_CALLBACK", + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.sessions.start_oauth_flow( + "s1", + OAuthProvider.GITHUB, + requested_scopes=["repo"], + ) + + call = resources._proxy._original._pipeline.calls[0] + assert call.request.url.endswith( + "/v2/agents/sessions/s1/oauth/OAUTH_PROVIDER_GITHUB" + ) + assert json.loads(call.request.content) == {"requested_scopes": ["repo"]} + assert resp.flow_kind == "OAUTH_FLOW_KIND_WEB_CALLBACK" + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +def test_stream_unwraps_spi_canonical_envelope(): + sse_payload = ( + b'data: {"event_id":"e1","type":"run.token_delta","data":{"text":"hello "}}\n\n' + b'data: {"event_id":"e2","type":"run.token_delta","data":{"text":"world"}}\n\n' + b'data: {"event_id":"e3","type":"run.completed","data":{"run_cost_micros":1234}}\n\n' + ) + resources = _make_resources([_FakeResponse(200, sse_chunks=[sse_payload])]) + + events = list(resources.sessions.stream("s1")) + assert events[0].type == "run.token_delta" + assert events[0].data.text == "hello " + assert events[1].data.text == "world" + assert events[2].type == "run.completed" + assert events[2].data.run_cost_micros == 1234 + + +def test_stream_unwraps_result_envelope(): + sse_payload = ( + b'data: {"result":{"event_id":"e1","token_chunk":{"text":"hello "}}}\n\n' + b'data: {"result":{"event_id":"e2","token_chunk":{"text":"world"}}}\n\n' + b'data: {"result":{"event_id":"e3","run_completed":{"run_cost_micros":1234}}}\n\n' + ) + resources = _make_resources([_FakeResponse(200, sse_chunks=[sse_payload])]) + + events = list(resources.sessions.stream("s1")) + assert events[0].token_chunk.text == "hello " + assert events[1].token_chunk.text == "world" + assert events[2].run_completed.run_cost_micros == 1234 + + +def test_stream_error_envelope_raises(): + sse_payload = ( + b'data: {"error":{"grpc_code":9,"http_code":412,"message":"not ready"}}\n\n' + ) + resources = _make_resources([_FakeResponse(200, sse_chunks=[sse_payload])]) + + with pytest.raises(HarnessStreamError) as excinfo: + list(resources.sessions.stream("s1")) + + assert excinfo.value.grpc_code == 9 + assert excinfo.value.http_code == 412 + assert "not ready" in str(excinfo.value) + + +def test_stream_passes_replay_query_params(): + sse_payload = b"" + resources = _make_resources([_FakeResponse(200, sse_chunks=[sse_payload])]) + + stream = resources.sessions.stream("s1", replay_from="evt-42", replay_only=True) + list(stream) + + call = resources._proxy._original._pipeline.calls[0] + assert "replay_from=evt-42" in call.request.url + assert "replay_only=true" in call.request.url + + +# --------------------------------------------------------------------------- +# Backward history paging +# --------------------------------------------------------------------------- + +_HISTORY_PAGE_SSE = ( + b": connected to s1\n\n" + b'data: {"event_id":"e10","type":"run.token_delta","data":{"text":"older "}}\n\n' + b'data: {"event_id":"e11","type":"run.token_delta","data":{"text":"newer"}}\n\n' + b": has_more=true\n\n" +) + + +def test_stream_before_implies_replay_only_and_sends_limit(): + resources = _make_resources([_FakeResponse(200, sse_chunks=[b""])]) + + list(resources.sessions.stream("s1", before="evt-99", limit=50)) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=evt-99" in url + assert "limit=50" in url + assert "replay_only=true" in url + + +def test_stream_omits_paging_params_when_unset(): + resources = _make_resources([_FakeResponse(200, sse_chunks=[b""])]) + + list(resources.sessions.stream("s1")) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=" not in url + assert "limit=" not in url + assert "replay_only=" not in url + + +def test_stream_rejects_limit_without_before(): + resources = _make_resources([]) + with pytest.raises(ValueError, match="before"): + resources.sessions.stream("s1", limit=10) + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_stream_rejects_non_positive_limit(limit): + resources = _make_resources([]) + with pytest.raises(ValueError, match="positive"): + resources.sessions.stream("s1", before="evt-99", limit=limit) + + +def test_stream_records_has_more_comment(): + resources = _make_resources([_FakeResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])]) + + stream = resources.sessions.stream("s1", before="evt-12") + assert stream.has_more is None # not known until the trailing comment arrives + + events = list(stream) + assert [e.event_id for e in events] == ["e10", "e11"] + assert stream.has_more is True + assert stream.oldest_event_id == "e10" + + +def test_stream_ignores_non_has_more_comments(): + payload = ( + b": connected to s1\n\n" + b'data: {"event_id":"e1","type":"run.started","data":{}}\n\n' + ) + resources = _make_resources([_FakeResponse(200, sse_chunks=[payload])]) + + stream = resources.sessions.stream("s1") + assert len(list(stream)) == 1 + assert stream.has_more is None + + +def test_history_page_returns_events_cursor_and_has_more(): + resources = _make_resources([_FakeResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])]) + + page = resources.sessions.history_page("s1", before="evt-12", limit=2) + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "before=evt-12" in url + assert "limit=2" in url + assert "replay_only=true" in url + + events, has_more, next_before = page + assert [e.event_id for e in events] == ["e10", "e11"] + assert has_more is True + assert next_before == "e10" + + +def test_history_page_at_oldest_event_reports_no_more(): + payload = ( + b'data: {"event_id":"e1","type":"run.started","data":{}}\n\n' + b": has_more=false\n\n" + ) + resources = _make_resources([_FakeResponse(200, sse_chunks=[payload])]) + + page = resources.sessions.history_page("s1", before="e2") + assert page.has_more is False + assert page.next_before == "e1" + + +def test_history_page_empty_has_no_cursor(): + payload = b": has_more=false\n\n" + resources = _make_resources([_FakeResponse(200, sse_chunks=[payload])]) + + page = resources.sessions.history_page("s1", before="e1") + assert page.events == [] + assert page.has_more is False + assert page.next_before is None + + +def test_history_page_requires_before(): + resources = _make_resources([]) + with pytest.raises(ValueError, match="before"): + resources.sessions.history_page("s1", before="") + + +def test_agent_session_history_binds_session_id(): + resources = _make_resources([_FakeResponse(200, sse_chunks=[_HISTORY_PAGE_SSE])]) + + page = resources.attach("s1").history(before="evt-12") + + url = resources._proxy._original._pipeline.calls[0].request.url + assert "/v2/agents/sessions/s1/stream" in url + assert "before=evt-12" in url + assert page.next_before == "e10" + + +def test_resolve_agents_base_url_adds_https_scheme(): + assert ( + resolve_agents_base_url("api.digitalocean.com") + == "https://api.digitalocean.com" + ) + assert resolve_agents_base_url("http://127.0.0.1:8080") == "http://127.0.0.1:8080" diff --git a/tests/agents/test_triggers.py b/tests/agents/test_triggers.py new file mode 100644 index 00000000..182658f9 --- /dev/null +++ b/tests/agents/test_triggers.py @@ -0,0 +1,324 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.agents.custom_triggers`.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock + +import pytest +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError + +from pydo.agents import ( + AgentsResources, + TriggerKind, + TriggerOutputMode, + TriggerSessionMode, + TriggerStatus, + WebhookProviderKey, +) + +# --------------------------------------------------------------------------- +# Fake pipeline / response plumbing (mirrors test_sessions.py) +# --------------------------------------------------------------------------- + + +class _FakeResponse: + def __init__(self, status_code: int, body: Any = None): + self.status_code = status_code + self.reason = None + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + def read(self) -> bytes: + return self._body_bytes + + def close(self) -> None: + pass + + +def _path(url: str) -> str: + return url.split("?", 1)[0] + + +class _FakePipeline: + def __init__(self, responses: List[_FakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + response = self._responses.pop(0) + return SimpleNamespace(http_response=response) + + +def _make_resources(responses: List[_FakeResponse]) -> AgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakePipeline(responses) + return AgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +def _last_call(resources: AgentsResources): + return resources._proxy._original._pipeline.calls[-1] + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +def test_list_triggers_with_filters(): + body = { + "triggers": [{"trigger_id": "t1", "kind": TriggerKind.WEBHOOK}], + "next_page_token": "next", + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.list( + page_size=10, + page_token="tok", + kind=TriggerKind.WEBHOOK, + status=TriggerStatus.ACTIVE, + ) + + call = _last_call(resources) + assert call.request.method == "GET" + assert _path(call.request.url).endswith("/v2/agents/triggers") + assert "page_size=10" in call.request.url + assert "page_token=tok" in call.request.url + assert "kind=webhook" in call.request.url + assert "status=active" in call.request.url + assert resp.triggers[0].trigger_id == "t1" + assert resp.next_page_token == "next" + + +def test_create_webhook_trigger_returns_secret_once(): + body = { + "trigger": { + "trigger_id": "t-new", + "kind": TriggerKind.WEBHOOK, + "name": "gh-prs", + "status": TriggerStatus.ACTIVE, + "session_mode": TriggerSessionMode.FRESH, + "webhook": { + "provider": WebhookProviderKey.GITHUB, + "webhook_url": "https://api.digitalocean.com/v2/agents/triggers/t-new/webhook", + }, + }, + "webhook_secret": "whsec_shown_once", + } + resources = _make_resources([_FakeResponse(201, body)]) + + create_body = { + "kind": TriggerKind.WEBHOOK, + "name": "gh-prs", + "session_mode": TriggerSessionMode.FRESH, + "prompt_template": "Review PR {{payload.pull_request.number}}", + "output": {"mode": TriggerOutputMode.NONE}, + "session_template": "kind: Agent\n", + "webhook": {"provider": WebhookProviderKey.GITHUB}, + } + resp = resources.triggers.create(create_body) + + call = _last_call(resources) + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/triggers") + assert call.request.headers.get("Content-Type") == "application/json" + assert json.loads(call.request.content) == create_body + assert resp.trigger.trigger_id == "t-new" + assert resp.webhook_secret == "whsec_shown_once" + + +def test_create_rejects_empty_body(): + resources = _make_resources([]) + with pytest.raises(ValueError, match="non-empty"): + resources.triggers.create({}) + + +def test_get_trigger(): + body = {"trigger": {"trigger_id": "t1", "name": "nightly"}} + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.get("t1") + + call = _last_call(resources) + assert call.request.method == "GET" + assert call.request.url.endswith("/v2/agents/triggers/t1") + assert resp.trigger.name == "nightly" + + +def test_update_pause_trigger(): + body = { + "trigger": { + "trigger_id": "t1", + "status": TriggerStatus.PAUSED, + } + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.update("t1", {"status": TriggerStatus.PAUSED}) + + call = _last_call(resources) + assert call.request.method == "PATCH" + assert call.request.url.endswith("/v2/agents/triggers/t1") + assert json.loads(call.request.content) == {"status": "paused"} + assert resp.trigger.status == "paused" + + +def test_delete_trigger_returns_none_on_204(): + resources = _make_resources([_FakeResponse(204)]) + + assert resources.triggers.delete("t1") is None + + call = _last_call(resources) + assert call.request.method == "DELETE" + assert call.request.url.endswith("/v2/agents/triggers/t1") + + +def test_rotate_secret(): + body = {"webhook_secret": "whsec_rotated"} + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.rotate_secret("t1") + + call = _last_call(resources) + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/triggers/t1/rotate-secret") + assert resp.webhook_secret == "whsec_rotated" + + +# --------------------------------------------------------------------------- +# Executions & lookups +# --------------------------------------------------------------------------- + + +def test_list_executions(): + body = { + "executions": [{"execution_id": "e1", "status": "succeeded"}], + "next_page_token": "", + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.list_executions("t1", page_size=5, status="succeeded") + + call = _last_call(resources) + assert call.request.method == "GET" + assert _path(call.request.url).endswith("/v2/agents/triggers/t1/executions") + assert "page_size=5" in call.request.url + assert "status=succeeded" in call.request.url + assert resp.executions[0].execution_id == "e1" + + +def test_get_execution_includes_payload(): + body = { + "execution": { + "execution_id": "e1", + "payload": '{"action":"opened"}', + "output_text": "done", + "output_truncated": False, + } + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.get_execution("t1", "e1") + + call = _last_call(resources) + assert call.request.url.endswith("/v2/agents/triggers/t1/executions/e1") + assert resp.execution.payload == '{"action":"opened"}' + assert resp.execution.output_text == "done" + + +def test_get_by_session(): + body = {"trigger": {"trigger_id": "t1", "bound_session_id": "s1"}} + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.get_by_session("s1") + + call = _last_call(resources) + assert call.request.url.endswith("/v2/agents/triggers/by-session/s1") + assert resp.trigger.trigger_id == "t1" + + +def test_list_reusable_sessions(): + body = { + "sessions": [ + { + "session_id": "s1", + "status": "SESSION_STATUS_PAUSED", + } + ] + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.list_reusable_sessions(page_size=20) + + call = _last_call(resources) + assert _path(call.request.url).endswith("/v2/agents/triggers/reusable-sessions") + assert "page_size=20" in call.request.url + assert resp.sessions[0].session_id == "s1" + + +def test_list_webhook_providers(): + body = { + "providers": [ + { + "key": "github", + "display_name": "GitHub", + "signature": { + "header": "X-Hub-Signature-256", + "scheme": "hmac-sha256", + }, + } + ] + } + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.list_webhook_providers() + + call = _last_call(resources) + assert call.request.method == "GET" + assert call.request.url.endswith("/v2/agents/webhook-providers") + assert resp.providers[0].key == "github" + + +def test_path_segments_are_url_encoded(): + resources = _make_resources( + [_FakeResponse(200, {"trigger": {"trigger_id": "a/b"}})] + ) + resources.triggers.get("a/b") + + call = _last_call(resources) + assert call.request.url.endswith("/v2/agents/triggers/a%2Fb") + + +def test_404_maps_to_resource_not_found(): + resources = _make_resources([_FakeResponse(404, {"error": {"message": "gone"}})]) + with pytest.raises(ResourceNotFoundError): + resources.triggers.get("missing") + + +def test_500_raises_http_response_error(): + resources = _make_resources([_FakeResponse(500, {"error": {"message": "boom"}})]) + with pytest.raises(HttpResponseError): + resources.triggers.list() diff --git a/tests/agents/test_workspace.py b/tests/agents/test_workspace.py new file mode 100644 index 00000000..05c2651f --- /dev/null +++ b/tests/agents/test_workspace.py @@ -0,0 +1,868 @@ +# pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for staged workspace transfers (sync + async).""" + +from __future__ import annotations + +import hashlib +import io +import json +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock, patch + +import pytest + +from pydo.agents import AgentsResources, WorkspaceTransferError +from pydo.aio.agents import AsyncAgentsResources + +# --------------------------------------------------------------------------- +# Sync fakes +# --------------------------------------------------------------------------- + + +class _FakeResponse: + def __init__( + self, + status_code: int, + *, + body: Any = None, + ): + self.status_code = status_code + self.headers = {"Content-Type": "application/json"} + self.reason = None + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + def read(self) -> bytes: + return self._body_bytes + + +class _FakePipeline: + def __init__(self, responses: List[_FakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def _make_resources(responses: List[_FakeResponse]) -> AgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakePipeline(responses) + return AgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +def _calls(resources) -> List[Any]: + return resources._proxy._original._pipeline.calls + + +def _request_json(call) -> Any: + # azure HttpRequest stores JSON either in .json or serializes into content + raw = getattr(call.request, "content", None) + if isinstance(raw, (bytes, bytearray)): + return json.loads(raw.decode("utf-8")) + if isinstance(raw, str): + return json.loads(raw) + # Some azure versions keep the dict on the request + data = getattr(call.request, "_json", None) or getattr(call.request, "json", None) + if isinstance(data, dict): + return data + # Fall back: parse from prepared body string in kwargs representation + body = getattr(call.request, "body", None) + if isinstance(body, (bytes, bytearray, str)): + return json.loads(body) + raise AssertionError(f"could not parse JSON body from {call.request!r}") + + +# --------------------------------------------------------------------------- +# Low-level transfer endpoints +# --------------------------------------------------------------------------- + + +def test_create_transfer_upload(): + resources = _make_resources( + [ + _FakeResponse( + 201, + body={ + "transfer_id": "t1", + "direction": "upload", + "status": "pending", + "part_size": 16, + "upload_id": "u1", + }, + ) + ] + ) + resp = resources.sessions.create_transfer( + "s1", + direction="upload", + path="/workspace/a.bin", + size_bytes=32, + sha256="abc", + is_archive=True, + ) + call = _calls(resources)[0] + assert call.request.method == "POST" + assert call.request.url.endswith("/v2/agents/sessions/s1/workspace/transfers") + body = _request_json(call) + assert body == { + "direction": "upload", + "path": "/workspace/a.bin", + "is_archive": True, + "size_bytes": 32, + "sha256": "abc", + } + assert resp.transfer_id == "t1" + assert resp.part_size == 16 + + +def test_create_transfer_download(): + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "t2", + "direction": "download", + "status": "pending", + }, + ) + ] + ) + resp = resources.sessions.create_transfer( + "s1", direction="download", path="dir", as_archive=True + ) + body = _request_json(_calls(resources)[0]) + assert body == { + "direction": "download", + "path": "dir", + "as_archive": True, + } + assert resp.transfer_id == "t2" + + +def test_create_part_upload_url_commit_get_cancel(): + resources = _make_resources( + [ + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "part_urls": [ + { + "part_number": 1, + "upload_url": "https://spaces/part1", + } + ], + }, + ), + _FakeResponse( + 202, + body={"transfer_id": "t1", "status": "in_progress", "size_bytes": 5}, + ), + _FakeResponse( + 200, + body={"transfer_id": "t1", "status": "completed", "bytes_written": 5}, + ), + _FakeResponse( + 200, body={"transfer_id": "t1", "aborted": True, "status": "failed"} + ), + ] + ) + part = resources.sessions.create_part_upload_url("s1", "t1", part_number=1) + assert part.upload_url == "https://spaces/part1" + assert "/t1/part-upload-urls" in _calls(resources)[0].request.url + assert _request_json(_calls(resources)[0]) == {"part_numbers": [1]} + + committed = resources.sessions.commit_upload("s1", "t1", sha256="deadbeef") + assert committed.status == "in_progress" + assert _request_json(_calls(resources)[1]) == {"sha256": "deadbeef"} + + got = resources.sessions.get_transfer("s1", "t1") + assert got.status == "completed" + + cancelled = resources.sessions.cancel_transfer("s1", "t1", reason="stop") + assert cancelled.aborted is True + assert _request_json(_calls(resources)[3]) == {"reason": "stop"} + + +def test_create_part_upload_urls_batch(): + resources = _make_resources( + [ + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "part_urls": [ + {"part_number": 1, "upload_url": "https://spaces/p1"}, + {"part_number": 2, "upload_url": "https://spaces/p2"}, + ], + }, + ) + ] + ) + resp = resources.sessions.create_part_upload_urls("s1", "t1", part_numbers=[1, 2]) + assert _request_json(_calls(resources)[0]) == {"part_numbers": [1, 2]} + assert len(resp.part_urls) == 2 + assert resp.part_urls[0].upload_url == "https://spaces/p1" + + +# --------------------------------------------------------------------------- +# High-level upload (staged) +# --------------------------------------------------------------------------- + + +def test_workspace_upload_multipart_flow(): + payload = b"abcdefghijklmnop" # 16 bytes → 2 parts of part_size=8 + digest = hashlib.sha256(payload).hexdigest() + resources = _make_resources( + [ + _FakeResponse( + 201, + body={ + "transfer_id": "t1", + "direction": "upload", + "status": "pending", + "part_size": 8, + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "part_urls": [ + {"part_number": 1, "upload_url": "https://spaces/p1"}, + {"part_number": 2, "upload_url": "https://spaces/p2"}, + ], + }, + ), + _FakeResponse(202, body={"transfer_id": "t1", "status": "in_progress"}), + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + }, + ), + ] + ) + + puts: List[Any] = [] + + def _fake_put(url, data): + puts.append((url, data)) + + with patch("pydo.agents.custom_sessions._http_put_bytes", side_effect=_fake_put): + resp = resources.sessions.workspace_upload( + "s1", path="a.bin", data=payload, poll_interval=0.01 + ) + + assert resp.status == "completed" + assert resp.bytes_written == len(payload) + assert resp.path == "a.bin" + assert puts == [ + ("https://spaces/p1", b"abcdefgh"), + ("https://spaces/p2", b"ijklmnop"), + ] + + urls = [c.request.url for c in _calls(resources)] + assert urls[0].endswith("/workspace/transfers") + assert urls[1].endswith("/transfers/t1/part-upload-urls") + assert urls[2].endswith("/transfers/t1/commit") + assert urls[3].endswith("/transfers/t1") + assert _request_json(_calls(resources)[1]) == {"part_numbers": [1, 2]} + assert _request_json(_calls(resources)[2])["sha256"] == digest + + +def test_workspace_upload_accepts_filesystem_path(tmp_path): + payload = b"file-on-disk" + src = tmp_path / "input.bin" + src.write_bytes(payload) + resources = _make_resources( + [ + _FakeResponse( + 201, + body={ + "transfer_id": "t1", + "status": "pending", + "part_size": 1024, + "direction": "upload", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "part_urls": [ + {"part_number": 1, "upload_url": "https://s/p"}, + ], + }, + ), + _FakeResponse(202, body={"transfer_id": "t1", "status": "in_progress"}), + _FakeResponse( + 200, + body={ + "transfer_id": "t1", + "status": "completed", + "bytes_written": len(payload), + }, + ), + ] + ) + with patch("pydo.agents.custom_sessions._http_put_bytes") as put: + resources.sessions.workspace_upload( + "s1", path="dest.bin", data=str(src), poll_interval=0.01 + ) + assert put.call_args[0][1] == payload + + +def test_workspace_upload_rejects_over_50_gib(): + class _HugeStream: + def __init__(self): + self._pos = 0 + + def tell(self): + return self._pos + + def seek(self, offset, whence=io.SEEK_SET): + if whence == io.SEEK_END: + self._pos = 50 * 1024 * 1024 * 1024 + 1 + else: + self._pos = offset + return self._pos + + def read(self, *_a, **_k): + return b"" + + resources = _make_resources([]) + with pytest.raises(ValueError, match="50 GiB"): + resources.sessions.workspace_upload("s1", path="big", data=_HugeStream()) + + +def test_workspace_upload_requires_path(): + resources = _make_resources([]) + with pytest.raises(ValueError, match="path is required"): + resources.sessions.workspace_upload("s1", path="", data=b"x") + + +# --------------------------------------------------------------------------- +# High-level download (staged) +# --------------------------------------------------------------------------- + + +def test_workspace_download_polls_and_fetches_url(): + payload = b"hello workspace" + digest = hashlib.sha256(payload).hexdigest() + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse(200, body={"transfer_id": "td", "status": "pending"}), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "direction": "download", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + + with patch( + "pydo.agents.custom_sessions._http_get_iter", + return_value=iter([b"hello ", b"workspace"]), + ): + download = resources.sessions.workspace_download( + "s1", path="out.txt", poll_interval=0.01 + ) + data = download.read() + + assert data == payload + assert download.bytes_read == len(payload) + assert download.size_hint == len(payload) + assert download.expected_sha256 == digest + assert download.is_archive is False + assert download.transfer_id == "td" + + urls = [c.request.url for c in _calls(resources)] + assert urls[0].endswith("/workspace/transfers") + assert _request_json(_calls(resources)[0])["direction"] == "download" + assert urls[-1].endswith("/transfers/td") + + +def test_workspace_download_archive_flag(): + payload = b"tarbytes" + digest = hashlib.sha256(payload).hexdigest() + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + with patch( + "pydo.agents.custom_sessions._http_get_iter", return_value=iter([payload]) + ): + download = resources.sessions.workspace_download( + "s1", path="dir", as_archive=True, poll_interval=0.01 + ) + assert download.read() == payload + assert download.is_archive is True + assert _request_json(_calls(resources)[0])["as_archive"] is True + + +def test_workspace_download_sha_mismatch(): + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": 3, + "sha256": "0" * 64, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + with patch( + "pydo.agents.custom_sessions._http_get_iter", return_value=iter([b"abc"]) + ): + download = resources.sessions.workspace_download( + "s1", path="x", poll_interval=0.01 + ) + with pytest.raises(WorkspaceTransferError, match="mismatch"): + download.read() + + +def test_workspace_download_missing_sha_strict(): + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": 1, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + with patch("pydo.agents.custom_sessions._http_get_iter", return_value=iter([b"x"])): + download = resources.sessions.workspace_download( + "s1", path="x", require_checksum=True, poll_interval=0.01 + ) + with pytest.raises(WorkspaceTransferError, match="sha256"): + download.read() + + +def test_workspace_download_save_discards_on_failure(tmp_path): + good = tmp_path / "good.bin" + payload = b"good-payload" + digest = hashlib.sha256(payload).hexdigest() + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + with patch( + "pydo.agents.custom_sessions._http_get_iter", return_value=iter([payload]) + ): + written = resources.sessions.workspace_download( + "s1", path="g", poll_interval=0.01 + ).save(str(good)) + assert written == len(payload) + assert good.read_bytes() == payload + + bad = tmp_path / "bad.bin" + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": 3, + "sha256": "0" * 64, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + with patch( + "pydo.agents.custom_sessions._http_get_iter", return_value=iter([b"abc"]) + ): + with pytest.raises(WorkspaceTransferError): + resources.sessions.workspace_download( + "s1", path="b", poll_interval=0.01 + ).save(str(bad)) + assert not bad.exists() + + +def test_workspace_download_failed_transfer(): + resources = _make_resources( + [ + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "failed", + "error_message": "not found in workspace", + }, + ), + # cancel best-effort + _FakeResponse( + 200, body={"transfer_id": "td", "aborted": False, "status": "failed"} + ), + ] + ) + with pytest.raises(WorkspaceTransferError, match="not found"): + resources.sessions.workspace_download("s1", path="missing", poll_interval=0.01) + + +def test_agent_session_upload_download_passthrough(): + payload = b"round-trip" + digest = hashlib.sha256(payload).hexdigest() + resources = _make_resources( + [ + _FakeResponse( + 201, + body={ + "transfer_id": "tu", + "status": "pending", + "part_size": 1024, + "direction": "upload", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "tu", + "part_urls": [ + {"part_number": 1, "upload_url": "https://spaces/p"}, + ], + }, + ), + _FakeResponse(202, body={"transfer_id": "tu", "status": "in_progress"}), + _FakeResponse( + 200, + body={ + "transfer_id": "tu", + "status": "completed", + "bytes_written": len(payload), + }, + ), + _FakeResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + agent = resources.attach("s1") + with patch("pydo.agents.custom_sessions._http_put_bytes"), patch( + "pydo.agents.custom_sessions._http_get_iter", return_value=iter([payload]) + ): + up = agent.upload_file(path="f.bin", data=payload, poll_interval=0.01) + assert up.bytes_written == len(payload) + assert agent.download_file(path="f.bin", poll_interval=0.01).read() == payload + + +# --------------------------------------------------------------------------- +# Async +# --------------------------------------------------------------------------- + + +class _FakeAsyncResponse: + def __init__(self, status_code: int, *, body: Any = None): + self.status_code = status_code + self.headers = {"Content-Type": "application/json"} + self.reason = None + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + async def read(self) -> bytes: + return self._body_bytes + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + def body(self) -> bytes: + return self._body_bytes + + +class _FakeAsyncPipeline: + def __init__(self, responses: List[_FakeAsyncResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + async def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def _make_async_resources(responses: List[_FakeAsyncResponse]) -> AsyncAgentsResources: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = _FakeAsyncPipeline(responses) + return AsyncAgentsResources( + parent, agents_endpoint="https://api.stage2.digitalocean.com" + ) + + +@pytest.mark.asyncio +async def test_async_workspace_upload(): + payload = b"abc" + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 201, + body={ + "transfer_id": "t1", + "status": "pending", + "part_size": 1024, + "direction": "upload", + }, + ), + _FakeAsyncResponse( + 200, + body={ + "transfer_id": "t1", + "part_urls": [ + {"part_number": 1, "upload_url": "https://spaces/p"}, + ], + }, + ), + _FakeAsyncResponse( + 202, body={"transfer_id": "t1", "status": "in_progress"} + ), + _FakeAsyncResponse( + 200, + body={ + "transfer_id": "t1", + "status": "completed", + "bytes_written": 3, + }, + ), + ] + ) + with patch( + "pydo.aio.agents.custom_sessions._aio_http_put_bytes", + return_value=None, + ) as put: + resp = await resources.sessions.workspace_upload( + "s1", path="a.txt", data=payload, content_sha256="cafe", poll_interval=0.01 + ) + assert resp.bytes_written == 3 + put.assert_awaited_once() + create_body = _request_json(resources._proxy._original._pipeline.calls[0]) + assert create_body["sha256"] == "cafe" + assert ( + "/workspace/transfers" + in resources._proxy._original._pipeline.calls[0].request.url + ) + assert _request_json(resources._proxy._original._pipeline.calls[1]) == { + "part_numbers": [1] + } + + +@pytest.mark.asyncio +async def test_async_workspace_download(): + payload = b"async-bytes" + digest = hashlib.sha256(payload).hexdigest() + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeAsyncResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": len(payload), + "sha256": digest, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + + async def _fake_get(_url): + for chunk in (b"async-", b"bytes"): + yield chunk + + with patch( + "pydo.aio.agents.custom_sessions._aio_http_get_iter", + side_effect=lambda url: _fake_get(url), + ): + download = await resources.sessions.workspace_download( + "s1", path="o", poll_interval=0.01 + ) + data = await download.read() + assert data == payload + assert download.bytes_read == len(payload) + + +@pytest.mark.asyncio +async def test_async_download_save_discards_on_failure(tmp_path): + bad = tmp_path / "bad.bin" + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 202, + body={ + "transfer_id": "td", + "direction": "download", + "status": "pending", + }, + ), + _FakeAsyncResponse( + 200, + body={ + "transfer_id": "td", + "status": "completed", + "bytes_written": 3, + "sha256": "0" * 64, + "download_url": "https://spaces/dl", + }, + ), + ] + ) + + async def _fake_get(_url): + yield b"abc" + + with patch( + "pydo.aio.agents.custom_sessions._aio_http_get_iter", + side_effect=lambda url: _fake_get(url), + ): + download = await resources.sessions.workspace_download( + "s1", path="b", poll_interval=0.01 + ) + with pytest.raises(WorkspaceTransferError): + await download.save(str(bad)) + assert not bad.exists() diff --git a/tests/gateway/__init__.py b/tests/gateway/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py new file mode 100644 index 00000000..134f8f36 --- /dev/null +++ b/tests/gateway/conftest.py @@ -0,0 +1,291 @@ +# pylint: disable=missing-function-docstring,protected-access,missing-class-docstring,too-few-public-methods +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Shared fakes for gateway tests — no network, fake pipeline plumbing.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, List, Optional +from unittest.mock import MagicMock + +from pydo.aio.gateway import AsyncGatewayResources +from pydo.aio.gateway.custom_operations import AsyncRESTTransport +from pydo.aio.operations import SessionsOperations as AsyncSessionsOperations +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway import GatewayResources, RESTTransport +from pydo.operations import SessionsOperations + +TEST_SESSION_URN = "do:managed_agents_session:test-session" +TEST_GATEWAY_URL = "https://actions.do-ai-test.run" + + +class FakeResponse: + def __init__(self, status_code: int, body: Any = None): + self.status_code = status_code + self.reason = "" + self.headers: dict = {} + if isinstance(body, (dict, list)): + self._body_bytes = json.dumps(body).encode("utf-8") + elif isinstance(body, str): + self._body_bytes = body.encode("utf-8") + elif isinstance(body, bytes): + self._body_bytes = body + else: + self._body_bytes = b"" + + def text(self) -> str: + return self._body_bytes.decode("utf-8") + + @property + def content(self) -> bytes: + return self._body_bytes + + def json(self) -> Any: + return json.loads(self._body_bytes) + + def body(self) -> bytes: + return self._body_bytes + + def read(self) -> bytes: + return self._body_bytes + + def close(self) -> None: + pass + + +class AsyncFakeResponse(FakeResponse): + def __init__(self, status_code: int, body: Any = None): + super().__init__(status_code, body) + self.read_calls = 0 + + async def read(self) -> bytes: # pylint: disable=invalid-overridden-method + self.read_calls += 1 + return self._body_bytes + + +class FakePipeline: + def __init__(self, responses: List[FakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +class AsyncFakePipeline: + def __init__(self, responses: List[AsyncFakeResponse]): + self._responses = list(responses) + self.calls: List[Any] = [] + + async def run(self, request, *, stream=False): + self.calls.append(SimpleNamespace(request=request, stream=stream)) + return SimpleNamespace(http_response=self._responses.pop(0)) + + +def jsonrpc_result(result: Any, *, rpc_id: int = 1) -> dict: + return {"jsonrpc": "2.0", "id": rpc_id, "result": result} + + +def jsonrpc_error(code: int, message: str, *, rpc_id: int = 1) -> dict: + return { + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": code, "message": message}, + } + + +def call_result( + structured: Any = None, + *, + is_error: bool = False, + text: str = "", + meta: Any = None, +) -> dict: + """MCP tools/call result shape (legacy helper for MCP-specific tests).""" + result: dict = {"isError": is_error} + if structured is not None: + result["structuredContent"] = structured + if text: + result["content"] = [{"type": "text", "text": text}] + if meta is not None: + result["_meta"] = meta + return result + + +def tool_result( + output: Any = None, *, error: Any = None, call_id: str = "call_1" +) -> dict: + """REST ToolResult envelope (search / code).""" + if error is not None: + return {"status": "failed", "error": error, "call_id": call_id} + return {"status": "succeeded", "output": output, "call_id": call_id} + + +def invoke_envelope( + results: List[dict] | None = None, + *, + tool: str = "web_search", + output: Any = None, + error: Any = None, + invocation_id: str | None = None, +) -> dict: + """Build a gateway ``action_invoke`` result envelope for tests.""" + if results is None: + if error is not None: + result_body: dict = {"status": "failed", "error": error} + else: + if output is None: + output = {"answer": 1} + result_body = {"status": "succeeded", "output": output} + entry: dict = {"index": 0, "tool": tool, "result": result_body} + if invocation_id is not None: + entry["invocation_id"] = invocation_id + results = [entry] + return { + "total_count": len(results), + "success_count": sum( + 1 for item in results if item["result"].get("status") == "succeeded" + ), + "error_count": sum( + 1 for item in results if item["result"].get("status") != "succeeded" + ), + "results": results, + } + + +def chat_tool_response( + *, + name: str = "web_search", + arguments: str = '{"query": "do"}', + call_id: str = "call_1", +) -> dict: + """Build a chat-completions response containing one tool call.""" + return { + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments, + }, + } + ], + } + } + ] + } + + +def session_create_response( + *, + session_urn: str = TEST_SESSION_URN, + name: str = "test-session", +) -> dict: + return { + "session": { + "sessionUrn": session_urn, + "name": name, + "actorId": "actor-123", + "policy": {"defaultAction": "ask", "rules": []}, + }, + "mcpUrl": f"{TEST_GATEWAY_URL}/mcp/session/test-session", + "tools": [], + } + + +def make_parent(responses: List[FakeResponse]) -> MagicMock: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = FakePipeline(responses) + parent._client.format_url = lambda url, **_kwargs: ( + url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" + ) + parent.sessions = SessionsOperations( + parent._client, + MagicMock(), + MagicMock(), + MagicMock(), + ) + return parent + + +def make_async_parent(responses: List[AsyncFakeResponse]) -> MagicMock: + parent = MagicMock() + parent._client = MagicMock() + parent._client._pipeline = AsyncFakePipeline(responses) + parent._client.format_url = lambda url, **_kwargs: ( + url if str(url).startswith("http") else f"https://api.digitalocean.com{url}" + ) + parent.sessions = AsyncSessionsOperations( + parent._client, + MagicMock(), + MagicMock(), + MagicMock(), + ) + return parent + + +def make_gateway( + responses: List[FakeResponse], + provider=None, + *, + session_id: str = TEST_SESSION_URN, + actor_id: str = "actor-123", +) -> GatewayResources: + parent = make_parent(responses) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = RESTTransport(proxy, session_id=session_id, actor_id=actor_id) + return GatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + provider=provider, + transport=transport, + ) + + +def make_async_gateway( + responses: List[AsyncFakeResponse], + provider=None, + *, + session_id: str = TEST_SESSION_URN, + actor_id: str = "actor-123", +) -> AsyncGatewayResources: + parent = make_async_parent(responses) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = AsyncRESTTransport(proxy, session_id=session_id, actor_id=actor_id) + return AsyncGatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + provider=provider, + transport=transport, + ) + + +def pipeline_of(gateway) -> Any: + return gateway._transport._client._original._pipeline + + +def sent_request(gateway, index: int = 0) -> Any: + return pipeline_of(gateway).calls[index].request + + +def sent_payload(gateway, index: int = 0) -> Optional[dict]: + request = sent_request(gateway, index) + content = request.content + if content is None: + return None + if isinstance(content, bytes): + content = content.decode("utf-8") + if not content: + return None + return json.loads(content) diff --git a/tests/gateway/test_action_gateway_client.py b/tests/gateway/test_action_gateway_client.py new file mode 100644 index 00000000..c32c3ae9 --- /dev/null +++ b/tests/gateway/test_action_gateway_client.py @@ -0,0 +1,275 @@ +# pylint: disable=missing-function-docstring,protected-access,missing-class-docstring,too-few-public-methods,import-outside-toplevel +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Smoke tests for ``pydo.action_gateway.ActionGatewayClient``.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock + +import pytest +from azure.core.exceptions import ResourceExistsError + +import pydo +import pydo.action_gateway +import pydo.aio +from pydo.action_gateway import ActionGatewayClient +from pydo.gateway.transport import _META_TOOL_DEFINITIONS +from pydo.gateway import ( + ChatCompletionsProvider, + GatewayProtocolError, + MessagesProvider, + Toolbelt, +) + +from .conftest import ( + FakeResponse, + call_result, + chat_tool_response, + jsonrpc_result, + session_create_response, +) + +try: + import aiohttp # pylint: disable=unused-import + + _HAS_AIO = True +except ImportError: # pragma: no cover + _HAS_AIO = False + + +def test_namespace_module_exports(): + assert hasattr(pydo.action_gateway, "Client") + assert hasattr(pydo.action_gateway, "ActionGatewayClient") + assert hasattr(pydo.action_gateway, "Session") + assert hasattr(pydo.action_gateway, "TokenCredentials") + assert "Client" in pydo.action_gateway.__all__ + + +def test_namespace_client_is_subclass_of_core_client(): + assert issubclass(ActionGatewayClient, pydo.Client) + + +def test_namespace_client_dir_is_gateway_focused(): + client = ActionGatewayClient(token="dummy") + surface = set(dir(client)) + expected = { + "sessions", + "sessions_api", + "connections", + "provider", + "base_url", + "chat", + "create_toolbelt", + "messages", + "responses", + "session", + "toolbelts", + "tools", + "users", + } + assert expected <= surface + for attr in ("code", "handle_tool_calls", "droplets"): + assert attr not in surface + + +def test_namespace_client_repr_is_distinct(): + client = ActionGatewayClient(token="dummy") + assert repr(client) == "" + + +def test_sessions_delegate_to_gateway(): + client = ActionGatewayClient(token="dummy") + assert client.sessions is client.gateway.sessions + assert client.session is client.sessions + assert client.sessions_api is not client.sessions + assert client.connections is not None + assert client.tools is not None + assert client.toolbelts is not None + assert client.users is not None + assert client.provider is client.gateway.provider + + +def test_gateway_provider_kwarg(): + client = ActionGatewayClient( + token="dummy", + gateway_provider=MessagesProvider(), + ) + assert isinstance(client.provider, MessagesProvider) + assert not isinstance(client.provider, ChatCompletionsProvider) + + +def test_create_toolbelt_convenience_method(monkeypatch): + client = ActionGatewayClient(token="dummy") + response = FakeResponse( + 200, + { + "toolbelt": { + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + } + }, + ) + + class Pipeline: + def __init__(self): + self.calls = [] + + def run(self, request, **_kwargs): + self.calls.append(request) + return type("R", (), {"http_response": response})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + assert toolbelt.ref == "search-toolbelt@1" + request = client._client._pipeline.calls[0] + assert request.url.endswith("/v2/action-gateway/toolbelts") + assert json.loads(request.content) == { + "name": "search-toolbelt", + "tools": ["exa_web_search"], + } + + +def test_create_toolbelt_accepts_flat_api_response(monkeypatch): + client = ActionGatewayClient(token="dummy") + monkeypatch.setattr( + client.toolbelts, + "create", + lambda **_kwargs: { + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + }, + ) + + toolbelt = client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + assert toolbelt.ref == "search-toolbelt@1" + + +def test_create_toolbelt_raises_generated_conflict(monkeypatch): + client = ActionGatewayClient(token="dummy") + response = FakeResponse( + 409, + { + "id": "conflict", + "message": "A toolbelt with this name already exists.", + }, + ) + + class Pipeline: + def run(self, request, **_kwargs): + response.request = request + return type("R", (), {"http_response": response})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + with pytest.raises(ResourceExistsError): + client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + +def test_toolbelt_rejects_unexpected_create_response(): + with pytest.raises(GatewayProtocolError, match="missing toolbelt reference"): + Toolbelt.from_response({"name": "search-toolbelt"}) + + +def test_create_toolbelt_rejects_string_tools(): + client = ActionGatewayClient(token="dummy") + with pytest.raises(TypeError, match="iterable of tool names"): + client.create_toolbelt(name="search-toolbelt", tools="exa_web_search") + + +def test_session_create_and_handle_tool_calls(monkeypatch): + responses = [ + FakeResponse(200, session_create_response()), + FakeResponse(200, jsonrpc_result({"tools": _META_TOOL_DEFINITIONS})), + FakeResponse(200, jsonrpc_result(call_result(structured={"ok": True}))), + ] + client = ActionGatewayClient(token="dummy") + + class Pipeline: + def __init__(self): + self.calls = [] + + def run(self, request, **_kwargs): + self.calls.append(request) + return type("R", (), {"http_response": responses.pop(0)})() + + monkeypatch.setattr(client._client, "_pipeline", Pipeline()) + + session = client.session.create(actor_id="user-123") + tools = session.tools() + assert tools[0]["function"]["name"] == "action_search" + assert "mcp/session/" in session.url + + messages = session.handle_tool_calls(chat_tool_response()) + assert messages[0]["role"] == "tool" + create_body = json.loads( + client._client._pipeline.calls[0].content + if isinstance(client._client._pipeline.calls[0].content, str) + else client._client._pipeline.calls[0].content.decode("utf-8") + ) + assert create_body["actor_id"] == "user-123" + assert "end_user_id" not in create_body + + +@pytest.mark.skipif(not _HAS_AIO, reason="aiohttp extra not installed") +def test_async_namespace_mirrors_sync(): + import pydo.action_gateway.aio as action_gateway_aio + from pydo.action_gateway.aio import ActionGatewayClient as AsyncActionGatewayClient + + assert hasattr(action_gateway_aio, "Client") + assert issubclass(action_gateway_aio.Client, pydo.aio.Client) + client = AsyncActionGatewayClient(token="dummy") + assert repr(client) == "" + assert client.sessions is client.gateway.sessions + assert client.session is client.sessions + assert client.sessions_api is not client.sessions + assert client.connections is not None + assert client.tools is not None + assert client.toolbelts is not None + assert client.users is not None + + +@pytest.mark.skipif(not _HAS_AIO, reason="aiohttp extra not installed") +def test_async_create_toolbelt_accepts_flat_api_response(monkeypatch): + from pydo.action_gateway.aio import ActionGatewayClient as AsyncActionGatewayClient + + client = AsyncActionGatewayClient(token="dummy") + create = AsyncMock( + return_value={ + "name": "search-toolbelt", + "version": "1", + "reference": "search-toolbelt@1", + "tools": ["exa_web_search"], + } + ) + monkeypatch.setattr(client.toolbelts, "create", create) + + async def scenario(): + return await client.create_toolbelt( + name="search-toolbelt", + tools=["exa_web_search"], + ) + + import asyncio + + toolbelt = asyncio.run(scenario()) + assert toolbelt.ref == "search-toolbelt@1" diff --git a/tests/gateway/test_async_gateway.py b/tests/gateway/test_async_gateway.py new file mode 100644 index 00000000..f665f6bc --- /dev/null +++ b/tests/gateway/test_async_gateway.py @@ -0,0 +1,245 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Async smoke tests for :mod:`pydo.aio.gateway`.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from azure.core.exceptions import HttpResponseError + +from pydo.aio.gateway import ( + AsyncGatewayResources, + AsyncMCPTransport, + AsyncSessionsOperations, +) +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway import ( + ACTOR_ID_HEADER, + SESSION_ID_HEADER, + ChatCompletionsProvider, + GatewayToolError, +) + +from .conftest import ( + TEST_GATEWAY_URL, + TEST_SESSION_URN, + AsyncFakeResponse, + jsonrpc_result, + chat_tool_response, + invoke_envelope, + make_async_gateway, + make_async_parent, + session_create_response, + tool_result, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _sent_request(gateway, index=0): + pipeline = gateway._transport._client._original._pipeline + return pipeline.calls[index].request + + +def _sent_payload(gateway, index=0): + content = _sent_request(gateway, index).content + if isinstance(content, bytes): + content = content.decode("utf-8") + return json.loads(content) + + +def test_list_defaults_to_meta(): + gateway = make_async_gateway([]) + tools = _run(gateway.tools.list()) + assert [t.name for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_invoke_and_invoke_one(): + envelope = invoke_envelope(output={"answer": 7}) + gateway = make_async_gateway([AsyncFakeResponse(200, envelope)]) + output = _run(gateway.tools.invoke_one("web_search", {"query": "do"})) + assert output.answer == 7 + request = _sent_request(gateway) + assert request.url.endswith("/tools/invoke") + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + assert _sent_payload(gateway)["tools"][0]["tool"] == "web_search" + + +def test_code_execute_failure_raises(): + gateway = make_async_gateway( + [ + AsyncFakeResponse( + 200, + tool_result(error={"class": "execution_failed", "message": "crash"}), + ) + ] + ) + with pytest.raises(GatewayToolError, match="crash"): + _run(gateway.code.execute("1/0")) + + +def test_http_error_reads_async_response_once(): + response = AsyncFakeResponse(400, "bad request") + gateway = make_async_gateway([response]) + with pytest.raises(HttpResponseError, match="bad request"): + _run(gateway.tools.list(include_all=True)) + assert response.read_calls == 1 + + +def test_mcp_transport_parses_sse_response(): + response = ( + "event: message\n" + 'data: {"jsonrpc":"2.0","id":1,"result":{"tools":' + '[{"name":"action_search"}]}}\n\n' + ) + parent = make_async_parent([AsyncFakeResponse(200, response)]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + gateway = AsyncGatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=AsyncMCPTransport( + proxy, session_id=TEST_SESSION_URN, actor_id="actor-123" + ), + ) + assert _run(gateway.tools.list())[0].name == "action_search" + + +def test_session_create_delegates_to_generated_operation(): + parent = MagicMock() + parent.sessions.create = AsyncMock( + return_value=session_create_response(name="named") + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + session = _run( + operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) + ) + + parent.sessions.create.assert_awaited_once() + body = parent.sessions.create.await_args.kwargs["body"] + assert body["actor_id"] == "actor-123" + assert body["name"] == "named" + assert body["policy"]["defaultAction"] == "ask" + assert body["config"]["preloadTools"] == ["web_search@v1"] + assert session.name == "named" + + +def test_session_create_uses_public_api_and_actor_header(): + parent = make_async_parent( + [ + AsyncFakeResponse(200, session_create_response()), + AsyncFakeResponse(200, jsonrpc_result({"tools": []})), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) + await session.tools.list(include_all=True) + return session + + session = _run(scenario()) + create_request = parent._client._pipeline.calls[0].request + assert create_request.url.endswith("/v2/action-gateway/sessions") + assert json.loads(create_request.content) == { + "actor_id": "actor-123", + "name": "named", + "policy": {"defaultAction": "ask"}, + "tools": ["web_search@v1"], + "config": {"preloadTools": ["web_search@v1"]}, + } + tool_request = parent._client._pipeline.calls[1].request + assert tool_request.url == session.url + assert tool_request.headers[SESSION_ID_HEADER] == "test-session" + assert tool_request.headers[ACTOR_ID_HEADER] == "actor-123" + assert session.actor_id == "actor-123" + assert session.selected_tools == [] + + +def test_session_approve_posts_to_gateway(): + parent = make_async_parent( + [ + AsyncFakeResponse(200, session_create_response()), + AsyncFakeResponse(200, {"status": "approved"}), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create("actor-123") + result = await session.approve("approval-123") + return session, result + + session, result = _run(scenario()) + request = parent._client._pipeline.calls[1].request + assert request.url == f"{TEST_GATEWAY_URL}/approvals/approval-123" + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + assert json.loads(request.content) == {"decision": "approve"} + assert result.status == "approved" + assert session.actor_id == "actor-123" + + +def test_session_deny_posts_to_gateway(): + parent = make_async_parent( + [ + AsyncFakeResponse(200, session_create_response()), + AsyncFakeResponse(200, {"status": "denied"}), + ] + ) + operations = AsyncSessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + async def scenario(): + session = await operations.create("actor-123") + return await session.deny("approval-123") + + result = _run(scenario()) + request = parent._client._pipeline.calls[1].request + assert json.loads(request.content) == {"decision": "deny"} + assert result.status == "denied" + + +def test_tools_callable_and_handle_tool_calls(): + envelope = invoke_envelope(output={"ok": True}) + gateway = make_async_gateway( + [AsyncFakeResponse(200, envelope)], + provider=ChatCompletionsProvider(), + ) + + async def scenario(): + tools = await gateway.tools() + messages = await gateway.handle_tool_calls(chat_tool_response()) + return tools, messages + + tools, messages = _run(scenario()) + assert [t["function"]["name"] for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + assert messages[0]["role"] == "tool" + assert json.loads(messages[0]["content"]) == {"ok": True} diff --git a/tests/gateway/test_code.py b/tests/gateway/test_code.py new file mode 100644 index 00000000..bc79a89c --- /dev/null +++ b/tests/gateway/test_code.py @@ -0,0 +1,70 @@ +# pylint: disable=missing-function-docstring,protected-access,duplicate-code +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :class:`pydo.gateway.custom_operations.CodeOperations`.""" + +from __future__ import annotations + +import pytest + +from pydo.gateway import GatewayToolError + +from .conftest import ( + FakeResponse, + make_gateway, + sent_payload, + sent_request, + tool_result, +) + + +def test_execute_happy_path(): + output = {"stdout": "hello\n", "stderr": "", "exit_code": 0} + gateway = make_gateway([FakeResponse(200, tool_result(output))]) + result = gateway.code.execute("print('hello')", thought="say hello") + + request = sent_request(gateway) + assert request.url.endswith("/code/execute") + payload = sent_payload(gateway) + assert payload == { + "code": "print('hello')", + "thought": "say hello", + } + + assert result.stdout == "hello\n" + assert result.exit_code == 0 + + +def test_execute_omits_empty_thought(): + output = {"stdout": "", "stderr": "", "exit_code": 0} + gateway = make_gateway([FakeResponse(200, tool_result(output))]) + gateway.code.execute("pass") + assert "thought" not in sent_payload(gateway) + + +def test_execute_rejects_empty_code(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="empty"): + gateway.code.execute(" ") + + +def test_execute_sandbox_failure_raises(): + gateway = make_gateway( + [ + FakeResponse( + 200, + tool_result( + error={ + "class": "execution_failed", + "message": "sandbox crashed", + "retriable": False, + } + ), + ) + ] + ) + with pytest.raises(GatewayToolError, match="sandbox crashed") as excinfo: + gateway.code.execute("1/0") + assert excinfo.value.error_class == "execution_failed" diff --git a/tests/gateway/test_providers.py b/tests/gateway/test_providers.py new file mode 100644 index 00000000..7581c228 --- /dev/null +++ b/tests/gateway/test_providers.py @@ -0,0 +1,461 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.gateway.providers` and ``handle_tool_calls``.""" + +from __future__ import annotations + +import json + +import pytest + +from pydo.custom_extensions import _wrap +from pydo.gateway import ( + ChatCompletionsProvider, + MessagesProvider, + ResponsesProvider, + normalize_invoke_arguments, + simplify_messages_input_schema, +) + +from .conftest import ( + FakeResponse, + chat_tool_response, + invoke_envelope, + make_gateway, + sent_payload, + sent_request, + tool_result, +) + +_CATALOG = [ + { + "name": "web_search", + "title": "Web Search", + "description": "Search the public web", + "inputSchema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } +] + +_META_TOOLS = [ + { + "name": "action_search", + "description": "Find tools", + "inputSchema": {"type": "object"}, + }, + { + "name": "action_invoke", + "description": "Run tools", + "inputSchema": {"type": "object"}, + }, + { + "name": "action_code", + "description": "Run code", + "inputSchema": {"type": "object"}, + }, +] + + +# -- wrap_tools --------------------------------------------------------------- + + +def test_chat_completions_wrap_tools(): + tools = ChatCompletionsProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the public web", + "parameters": _CATALOG[0]["inputSchema"], + }, + } + ] + + +def test_messages_wrap_tools(): + tools = MessagesProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "name": "web_search", + "description": "Search the public web", + "input_schema": _CATALOG[0]["inputSchema"], + } + ] + + +def test_messages_wrap_tools_preserves_meta_tool_names(): + tools = MessagesProvider().wrap_tools(_META_TOOLS) + assert [tool["name"] for tool in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_simplify_messages_input_schema_strips_top_level_any_of(): + schema = { + "type": "object", + "properties": { + "code": {"type": "string"}, + "code_to_execute": {"type": "string"}, + }, + "anyOf": [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ], + } + simplified = simplify_messages_input_schema(schema) + assert "anyOf" not in simplified + assert simplified["properties"]["code"]["type"] == "string" + + +def test_chat_completions_wrap_tools_strips_any_of_from_code_meta_tool(): + tools = ChatCompletionsProvider().wrap_tools( + [ + { + "name": "action_code", + "description": "Run Python", + "inputSchema": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "anyOf": [{"required": ["code"]}], + }, + } + ] + ) + assert tools[0]["function"]["name"] == "action_code" + assert "anyOf" not in tools[0]["function"]["parameters"] + + +def test_responses_wrap_tools_preserves_meta_tool_names(): + tools = ResponsesProvider().wrap_tools(_META_TOOLS) + assert [tool["name"] for tool in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_responses_wrap_tools(): + tools = ResponsesProvider().wrap_tools(_CATALOG) + assert tools == [ + { + "type": "function", + "name": "web_search", + "description": "Search the public web", + "parameters": _CATALOG[0]["inputSchema"], + } + ] + + +def test_wrap_tools_falls_back_to_title_and_empty_schema(): + tools = ChatCompletionsProvider().wrap_tools([{"name": "t", "title": "T"}]) + function = tools[0]["function"] + assert function["description"] == "T" + assert function["parameters"] == {"type": "object", "properties": {}} + + +# -- extract_tool_calls ------------------------------------------------------- + + +def _chat_response(arguments='{"query": "do"}'): + return chat_tool_response(arguments=arguments) + + +def _messages_response(): + return { + "content": [ + {"type": "text", "text": "let me check"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "web_search", + "input": {"query": "do"}, + }, + ] + } + + +def _responses_response(): + return { + "output": [ + { + "type": "function_call", + "call_id": "fc_1", + "name": "web_search", + "arguments": '{"query": "do"}', + } + ] + } + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_chat_completions_extract(wrap): + calls = ChatCompletionsProvider().extract_tool_calls(wrap(_chat_response())) + assert len(calls) == 1 + assert calls[0].call_id == "call_1" + assert calls[0].name == "web_search" + assert calls[0].arguments == {"query": "do"} + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_messages_extract(wrap): + calls = MessagesProvider().extract_tool_calls(wrap(_messages_response())) + assert len(calls) == 1 + assert calls[0].call_id == "toolu_1" + assert calls[0].arguments == {"query": "do"} + + +@pytest.mark.parametrize("wrap", [lambda x: x, _wrap], ids=["dict", "DotDict"]) +def test_responses_extract(wrap): + calls = ResponsesProvider().extract_tool_calls(wrap(_responses_response())) + assert len(calls) == 1 + assert calls[0].call_id == "fc_1" + assert calls[0].arguments == {"query": "do"} + + +def test_extract_returns_empty_without_tool_calls(): + assert not ChatCompletionsProvider().extract_tool_calls( + {"choices": [{"message": {"content": "hi"}}]} + ) + assert not MessagesProvider().extract_tool_calls({"content": []}) + assert not ResponsesProvider().extract_tool_calls({"output": []}) + + +# -- format_tool_results ------------------------------------------------------ + + +def test_format_results_per_provider(): + provider = ChatCompletionsProvider() + calls = provider.extract_tool_calls(_chat_response()) + messages = provider.format_tool_results(calls, [{"answer": 1}]) + assert messages == [ + {"role": "tool", "tool_call_id": "call_1", "content": '{"answer": 1}'} + ] + + provider = MessagesProvider() + calls = provider.extract_tool_calls(_messages_response()) + messages = provider.format_tool_results(calls, [{"answer": 1}]) + assert messages[0]["role"] == "user" + assert messages[0]["content"][0]["type"] == "tool_result" + assert messages[0]["content"][0]["tool_use_id"] == "toolu_1" + + provider = ResponsesProvider() + calls = provider.extract_tool_calls(_responses_response()) + items = provider.format_tool_results(calls, [{"answer": 1}]) + assert items == [ + { + "type": "function_call_output", + "call_id": "fc_1", + "output": '{"answer": 1}', + } + ] + + +# -- tools() callable --------------------------------------------------------- + + +def test_tools_callable_wraps_meta_tools_by_default(): + gateway = make_gateway([], provider=ChatCompletionsProvider()) + tools = gateway.tools() + assert [t["function"]["name"] for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_tools_callable_wraps_meta_tools_for_messages(): + gateway = make_gateway([], provider=MessagesProvider()) + tools = gateway.tools() + assert [t["name"] for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + + +def test_tools_callable_include_all_wraps_catalog(): + gateway = make_gateway( + [FakeResponse(200, {"tools": _CATALOG})], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(include_all=True) + assert tools[0]["function"]["name"] == "web_search" + + +def test_tools_callable_names_filter_and_missing(): + gateway = make_gateway( + [ + FakeResponse(200, {"tools": _CATALOG}), + FakeResponse(200, {"tools": _CATALOG}), + ], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(names=["web_search"]) + assert len(tools) == 1 + with pytest.raises(LookupError, match="nope"): + gateway.tools(names=["nope"]) + + +def test_tools_callable_via_search(): + search_payload = { + "results": [ + { + "index": 1, + "use_case": "web", + "results": [_CATALOG[0], _CATALOG[0]], # dupes collapse + } + ] + } + gateway = make_gateway( + [FakeResponse(200, tool_result(search_payload))], + provider=ChatCompletionsProvider(), + ) + tools = gateway.tools(search="search the web", limit=2) + assert len(tools) == 1 + assert tools[0]["function"]["name"] == "web_search" + + +# -- handle_tool_calls -------------------------------------------------------- + + +def test_handle_tool_calls_batches_concrete_tools(): + envelope = invoke_envelope(output={"answer": 42}) + gateway = make_gateway( + [FakeResponse(200, envelope)], + provider=ChatCompletionsProvider(), + ) + messages = gateway.handle_tool_calls(_chat_response(), rationale="why not") + + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["rationale"] == "why not" + assert payload["tools"] == [{"tool": "web_search", "arguments": {"query": "do"}}] + + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"] == "call_1" + assert json.loads(messages[0]["content"]) == {"answer": 42} + + +def test_normalize_invoke_arguments_accepts_chat_function_shape(): + arguments = normalize_invoke_arguments( + { + "tools": [ + { + "type": "function", + "function": { + "name": "web_search", + "arguments": '{"query": "digitalocean news"}', + }, + } + ] + } + ) + assert arguments["tools"] == [ + {"tool": "web_search", "arguments": {"query": "digitalocean news"}} + ] + + +def test_handle_tool_calls_normalizes_action_invoke_payload(): + envelope = invoke_envelope(output={"answer": 1}) + gateway = make_gateway( + [FakeResponse(200, envelope)], + provider=ChatCompletionsProvider(), + ) + response = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_invoke", + "function": { + "name": "action_invoke", + "arguments": json.dumps( + { + "tools": [ + { + "function": { + "name": "web_search", + "arguments": { + "query": "digitalocean" + }, + } + } + ] + } + ), + }, + } + ] + } + } + ] + } + messages = gateway.handle_tool_calls(response) + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["tools"] == [ + {"tool": "web_search", "arguments": {"query": "digitalocean"}} + ] + assert json.loads(messages[0]["content"]) == envelope + + +def test_handle_tool_calls_routes_meta_tools_directly(): + gateway = make_gateway( + [FakeResponse(200, tool_result({"results": []}))], + provider=ChatCompletionsProvider(), + ) + response = { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "call_meta", + "function": { + "name": "action_search", + "arguments": '{"queries": [{"use_case": "x"}]}', + }, + } + ] + } + } + ] + } + messages = gateway.handle_tool_calls(response) + assert sent_request(gateway).url.endswith("/tools/search") + assert json.loads(messages[0]["content"]) == {"results": []} + + +def test_handle_tool_calls_surfaces_failures_as_content(): + envelope = invoke_envelope( + error={"class": "timeout", "message": "too slow"}, + ) + gateway = make_gateway( + [FakeResponse(200, envelope)], + provider=ChatCompletionsProvider(), + ) + messages = gateway.handle_tool_calls(_chat_response()) + content = json.loads(messages[0]["content"]) + assert content["error"]["class"] == "timeout" + + +def test_handle_tool_calls_no_calls_returns_empty(): + gateway = make_gateway([], provider=ChatCompletionsProvider()) + assert gateway.handle_tool_calls({"choices": [{"message": {}}]}) == [] + + +def test_tools_callable_requires_provider(): + gateway = make_gateway([], provider=None) + gateway.tools._provider = None + with pytest.raises(RuntimeError, match="provider"): + gateway.tools() diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py new file mode 100644 index 00000000..21b091a1 --- /dev/null +++ b/tests/gateway/test_session.py @@ -0,0 +1,260 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for Action Gateway sessions.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from azure.core.exceptions import ResourceNotFoundError + +from pydo.gateway import ( + ACTOR_ID_HEADER, + SESSION_ID_HEADER, + SessionsOperations, + normalize_permissions, +) +from pydo.gateway.transport import _META_TOOL_DEFINITIONS + +from .conftest import ( + TEST_GATEWAY_URL, + TEST_SESSION_URN, + FakeResponse, + call_result, + chat_tool_response, + jsonrpc_result, + make_parent, + session_create_response, +) + + +def test_normalize_permissions_defaults_to_ask(): + assert normalize_permissions(None) == {"defaultAction": "ask"} + + +def test_normalize_permissions_accepts_snake_case(): + policy = normalize_permissions( + { + "default_action": "ask", + "rules": [ + {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, + {"tool": "gmail", "action": "deny"}, + ], + } + ) + assert policy == { + "defaultAction": "ask", + "rules": [ + {"tool": "toolbelt:read-only@1.2.3", "action": "allow"}, + {"tool": "gmail", "action": "deny"}, + ], + } + + +def test_normalize_permissions_requires_tool(): + with pytest.raises(ValueError, match="requires tool"): + normalize_permissions({"rules": [{"action": "allow"}]}) + + +def test_normalize_permissions_rejects_legacy_toolbelt_key(): + with pytest.raises(ValueError, match="toolbelt permissions are no longer"): + normalize_permissions({"rules": [{"toolbelt": "read-only@1.2.3"}]}) + + +def test_sessions_create_requires_actor_id(): + ops = SessionsOperations(make_parent([]), gateway_endpoint=TEST_GATEWAY_URL) + with pytest.raises(ValueError, match="actor_id"): + ops.create("") + + +def test_sessions_create_delegates_to_generated_operation(): + parent = MagicMock() + parent.sessions.create.return_value = session_create_response(name="named") + operations = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + + session = operations.create( + "actor-123", + name="named", + tools=["web_search@v1"], + config={"preloadTools": ["web_search@v1"]}, + ) + + parent.sessions.create.assert_called_once() + body = parent.sessions.create.call_args.kwargs["body"] + assert body["actor_id"] == "actor-123" + assert body["name"] == "named" + assert body["tools"] == ["web_search@v1"] + assert body["config"] == {"preloadTools": ["web_search@v1"]} + assert session.name == "named" + + +def test_sessions_create_404_uses_generated_error_mapping(): + response = FakeResponse( + 404, + {"id": "not_found", "message": "Your request could not be routed."}, + ) + response.request = SimpleNamespace( + url="https://api.digitalocean.com/v2/action-gateway/sessions" + ) + ops = SessionsOperations(make_parent([response]), gateway_endpoint=TEST_GATEWAY_URL) + with pytest.raises(ResourceNotFoundError): + ops.create("user-123") + + +def test_sessions_create_posts_to_do_api_and_binds_returned_mcp_url(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, jsonrpc_result({"tools": _META_TOOL_DEFINITIONS})), + FakeResponse(200, jsonrpc_result(call_result(structured={"ok": True}))), + ] + ) + ops = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + session = ops.create("user-123") + + create_req = parent._client._pipeline.calls[0].request + assert create_req.method == "POST" + assert create_req.url.endswith("/v2/action-gateway/sessions") + body = json.loads(create_req.content) + assert body["actor_id"] == "user-123" + assert "end_user_id" not in body + assert body["policy"] == {"defaultAction": "ask"} + assert body["name"].startswith("pydo-session-") + + assert session.session_urn == TEST_SESSION_URN + assert session.actor_id == "user-123" + assert session.url == "https://actions.do-ai-test.run/mcp/session/test-session" + + tools = session.tools() + assert [t["function"]["name"] for t in tools][:1] == ["action_search"] + + messages = session.handle_tool_calls(chat_tool_response()) + invoke_req = parent._client._pipeline.calls[2].request + assert invoke_req.url == session.url + assert invoke_req.headers[SESSION_ID_HEADER] == "test-session" + assert invoke_req.headers[ACTOR_ID_HEADER] == "user-123" + assert json.loads(invoke_req.content)["method"] == "tools/call" + assert messages[0]["role"] == "tool" + + +def test_sessions_create_with_permissions_and_name(): + parent = make_parent( + [ + FakeResponse( + 200, + session_create_response(name="named"), + ) + ] + ) + ops = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL) + session = ops.create( + "u1", + name="named", + permissions={ + "default_action": "deny", + "rules": [{"tool": "web_search", "action": "allow"}], + }, + ) + body = json.loads(parent._client._pipeline.calls[0].request.content) + assert body["name"] == "named" + assert body["policy"] == { + "defaultAction": "deny", + "rules": [{"tool": "web_search", "action": "allow"}], + } + assert session.name == "named" + + +def test_sessions_create_sends_tool_selection_and_config(): + parent = make_parent([FakeResponse(200, session_create_response())]) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "u1", + tools=["web_search@v1", "toolbelt:read-only@2"], + config={"preloadTools": ["web_search@v1"]}, + ) + + body = json.loads(parent._client._pipeline.calls[0].request.content) + assert body["tools"] == ["web_search@v1", "toolbelt:read-only@2"] + assert body["config"] == {"preloadTools": ["web_search@v1"]} + assert not session.selected_tools + + +def test_session_approve_posts_to_gateway(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, {"status": "approved"}), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + result = session.approve("approval-123") + + request = parent._client._pipeline.calls[1].request + assert request.url == f"{TEST_GATEWAY_URL}/approvals/approval-123" + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "user-123" + assert json.loads(request.content) == {"decision": "approve"} + assert result.status == "approved" + + +def test_session_deny_posts_to_gateway(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse(200, {"status": "denied"}), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + result = session.deny("approval-123") + + request = parent._client._pipeline.calls[1].request + assert json.loads(request.content) == {"decision": "deny"} + assert result.status == "denied" + + +def test_handle_tool_calls_preserves_approval_metadata(): + parent = make_parent( + [ + FakeResponse(200, session_create_response()), + FakeResponse( + 200, + jsonrpc_result( + call_result( + structured={ + "results": [ + { + "tool": "exa_web_search", + "result": { + "status": "failed", + "error": {"message": "approval required"}, + "_meta": { + "status": "requires_approval", + "approval_id": "approval-123", + }, + }, + } + ] + }, + ) + ), + ), + ] + ) + session = SessionsOperations(parent, gateway_endpoint=TEST_GATEWAY_URL).create( + "user-123" + ) + + messages = session.handle_tool_calls(chat_tool_response(name="exa_web_search")) + content = json.loads(messages[0]["content"]) + assert content["_meta"]["approval_id"] == "approval-123" diff --git a/tests/gateway/test_tools.py b/tests/gateway/test_tools.py new file mode 100644 index 00000000..c1e17ebe --- /dev/null +++ b/tests/gateway/test_tools.py @@ -0,0 +1,167 @@ +# pylint: disable=missing-function-docstring,protected-access +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :class:`pydo.gateway.custom_operations.ToolsOperations`.""" + +from __future__ import annotations + +import pytest + +from pydo.gateway import GatewayToolError + +from .conftest import ( + FakeResponse, + invoke_envelope, + make_gateway, + sent_payload, + sent_request, + tool_result, +) + +_SEARCH_PAYLOAD = { + "results": [ + { + "index": 1, + "use_case": "search the web", + "results": [ + { + "name": "web_search", + "title": "Web Search", + "description": "Search the public web", + "inputSchema": {"type": "object"}, + "score": 12.3, + } + ], + } + ] +} + + +# -- search ------------------------------------------------------------------ + + +def test_search_accepts_single_string(): + gateway = make_gateway([FakeResponse(200, tool_result(_SEARCH_PAYLOAD))]) + result = gateway.tools.search("search the web") + + assert sent_request(gateway).url.endswith("/tools/search") + assert sent_payload(gateway)["queries"] == [{"use_case": "search the web"}] + assert result.results[0].results[0].name == "web_search" + + +def test_search_accepts_dicts_and_filters(): + gateway = make_gateway([FakeResponse(200, tool_result(_SEARCH_PAYLOAD))]) + gateway.tools.search( + [ + {"use_case": "find stuff", "known_fields": "site:example.com"}, + "another use case", + ], + providers=["exa"], + tags=["web"], + limit=3, + ) + arguments = sent_payload(gateway) + assert arguments["queries"] == [ + {"use_case": "find stuff", "known_fields": "site:example.com"}, + {"use_case": "another use case"}, + ] + assert arguments["providers"] == ["exa"] + assert arguments["tags"] == ["web"] + assert arguments["limit"] == 3 + + +def test_search_rejects_missing_use_case_and_bad_counts(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="use_case"): + gateway.tools.search([{"known_fields": "x"}]) + with pytest.raises(ValueError, match="between 1 and 5"): + gateway.tools.search(["a", "b", "c", "d", "e", "f"]) + with pytest.raises(TypeError): + gateway.tools.search([42]) + + +# -- invoke ------------------------------------------------------------------ + + +def test_invoke_shapes_arguments_and_returns_envelope(): + envelope = invoke_envelope( + [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 1}}, + }, + { + "index": 1, + "tool": "missing_tool", + "result": { + "status": "failed", + "error": {"class": "invalid_argument", "message": "unknown tool"}, + }, + }, + ] + ) + gateway = make_gateway([FakeResponse(200, envelope)]) + result = gateway.tools.invoke( + [ + {"tool": "web_search", "arguments": {"query": "do"}}, + {"tool_slug": "missing_tool"}, + ], + rationale="testing", + ) + + assert sent_request(gateway).url.endswith("/tools/invoke") + payload = sent_payload(gateway) + assert payload["rationale"] == "testing" + assert payload["tools"] == [ + {"tool": "web_search", "arguments": {"query": "do"}}, + {"tool": "missing_tool", "arguments": {}}, + ] + + assert result.error_count == 1 + assert result.results[1].result.status == "failed" + + +def test_invoke_validates_counts_and_entries(): + gateway = make_gateway([]) + with pytest.raises(ValueError, match="between 1 and 10"): + gateway.tools.invoke([]) + with pytest.raises(ValueError, match="between 1 and 10"): + gateway.tools.invoke([{"tool": f"t{i}", "arguments": {}} for i in range(11)]) + with pytest.raises(ValueError, match="'tool' name"): + gateway.tools.invoke([{"arguments": {}}]) + with pytest.raises(TypeError): + gateway.tools.invoke(["web_search"]) + + +def test_invoke_one_returns_output(): + envelope = invoke_envelope( + [ + { + "index": 0, + "tool": "web_search", + "result": {"status": "succeeded", "output": {"answer": 42}}, + } + ] + ) + gateway = make_gateway([FakeResponse(200, envelope)]) + output = gateway.tools.invoke_one("web_search", {"query": "do"}) + assert output.answer == 42 + + +def test_invoke_one_raises_on_failure(): + envelope = invoke_envelope( + error={ + "class": "upstream_error", + "message": "exa is down", + "retriable": True, + }, + invocation_id="inv_9", + ) + gateway = make_gateway([FakeResponse(200, envelope)]) + with pytest.raises(GatewayToolError, match="exa is down") as excinfo: + gateway.tools.invoke_one("web_search", {"query": "do"}) + assert excinfo.value.error_class == "upstream_error" + assert excinfo.value.retriable is True diff --git a/tests/gateway/test_transport.py b/tests/gateway/test_transport.py new file mode 100644 index 00000000..b503ad0f --- /dev/null +++ b/tests/gateway/test_transport.py @@ -0,0 +1,274 @@ +# pylint: disable=missing-function-docstring,protected-access,duplicate-code +# ------------------------------------ +# Copyright (c) DigitalOcean. +# Licensed under the Apache-2.0 License. +# ------------------------------------ +"""Unit tests for :mod:`pydo.gateway.transport` (REST + MCP wire layers).""" + +from __future__ import annotations + +import pytest +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) + +from pydo.custom_extensions import _BaseURLProxy +from pydo.gateway import ( + ACTOR_ID_HEADER, + GatewayProtocolError, + GatewayResources, + GatewayToolError, + MCPTransport, + RecoveryHint, + SESSION_ID_HEADER, + ToolErrorClass, +) +from pydo.gateway.transport import _META_TOOL_DEFINITIONS, session_mcp_url + +from .conftest import ( + TEST_GATEWAY_URL, + TEST_SESSION_URN, + FakeResponse, + call_result, + invoke_envelope, + jsonrpc_error, + jsonrpc_result, + make_gateway, + make_parent, + sent_payload, + sent_request, + tool_result, +) + + +def test_list_meta_tools_is_local_no_network(): + gateway = make_gateway([]) + tools = gateway.tools.list() + assert [t.name for t in tools] == [ + "action_search", + "action_invoke", + "action_code", + ] + assert pipeline_calls(gateway) == 0 + + +def test_gateway_constants_match_server_contract(): + assert ToolErrorClass.NOT_FOUND == "not_found" + assert ToolErrorClass.CANCELED == "canceled" + assert RecoveryHint.REFRESH_AUTH == "refresh_auth" + assert RecoveryHint.RETRY_LATER == "retry_later" + assert RecoveryHint.NARROW_OUTPUT == "narrow_output" + assert RecoveryHint.CONTACT_SUPPORT == "contact_support" + + +def test_meta_schemas_match_server_constraints(): + definitions = {tool["name"]: tool for tool in _META_TOOL_DEFINITIONS} + invoke_schema = definitions["action_invoke"]["inputSchema"] + assert invoke_schema["properties"]["rationale"]["maxLength"] == 512 + assert invoke_schema["properties"]["tools"]["items"]["anyOf"] == [ + {"required": ["tool"]}, + {"required": ["tool_slug"]}, + ] + assert definitions["action_code"]["inputSchema"]["anyOf"] == [ + {"required": ["code"]}, + {"required": ["code_to_execute"]}, + ] + + +def pipeline_calls(gateway) -> int: + return len(gateway._transport._client._original._pipeline.calls) + + +def test_list_tools_include_all_hits_rest_catalog(): + gateway = make_gateway([FakeResponse(200, {"tools": [{"name": "web_search"}]})]) + tools = gateway.tools.list(include_all=True) + request = sent_request(gateway) + assert request.method == "GET" + assert request.url.endswith("/tools") + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + assert tools[0].name == "web_search" + + +def test_search_posts_rest_and_unwraps_tool_result(): + gateway = make_gateway( + [ + FakeResponse( + 200, + tool_result({"results": [{"use_case": "x", "results": []}]}), + ) + ] + ) + result = gateway.tools.search("search the web") + request = sent_request(gateway) + assert request.method == "POST" + assert request.url.endswith("/tools/search") + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + payload = sent_payload(gateway) + assert payload["queries"] == [{"use_case": "search the web"}] + assert result.results[0].use_case == "x" + + +def test_invoke_posts_rest_envelope(): + gateway = make_gateway([FakeResponse(200, invoke_envelope(output={"ok": True}))]) + result = gateway.tools.invoke( + [{"tool": "web_search", "arguments": {"query": "do"}}] + ) + assert sent_request(gateway).url.endswith("/tools/invoke") + assert result.success_count == 1 + + +def test_code_execute_posts_rest(): + gateway = make_gateway( + [FakeResponse(200, tool_result({"stdout": "hi", "exit_code": 0}))] + ) + result = gateway.code.execute("print('hi')") + assert sent_request(gateway).url.endswith("/code/execute") + assert result.stdout == "hi" + assert result.exit_code == 0 + + +def test_concrete_call_routes_through_invoke(): + gateway = make_gateway([FakeResponse(200, invoke_envelope(output={"answer": 42}))]) + result = gateway.tools.call("web_search", {"query": "x"}) + assert sent_request(gateway).url.endswith("/tools/invoke") + assert result.answer == 42 + + +def test_failed_tool_result_raises(): + gateway = make_gateway( + [ + FakeResponse( + 200, + tool_result( + error={ + "class": "rate_limited", + "message": "slow down", + "retriable": True, + "recovery_hint": "retry_later", + } + ), + ) + ] + ) + with pytest.raises(GatewayToolError) as excinfo: + gateway.code.execute("1") + err = excinfo.value + assert err.error_class == "rate_limited" + assert err.retriable is True + assert err.recovery_hint == "retry_later" + + +def test_non_json_body_raises_protocol_error(): + gateway = make_gateway([FakeResponse(200, "nope")]) + with pytest.raises(GatewayProtocolError, match="non-JSON"): + gateway.tools.list(include_all=True) + + +@pytest.mark.parametrize( + "status,exc", + [ + (401, ClientAuthenticationError), + (404, ResourceNotFoundError), + (400, HttpResponseError), + (412, HttpResponseError), + ], +) +def test_http_errors_are_mapped(status, exc): + gateway = make_gateway([FakeResponse(status, {"type": "invalid_request"})]) + with pytest.raises(exc): + gateway.tools.list(include_all=True) + + +def test_412_message_mentions_release_gate(): + gateway = make_gateway([FakeResponse(412, "nope")]) + with pytest.raises(HttpResponseError, match="Action Infra release"): + gateway.tools.list(include_all=True) + + +def test_session_mcp_url_uses_uuid_from_urn(): + session_uuid = "3a12f86f-ef5c-41e3-a951-2b7a933e151d" + url = session_mcp_url( + TEST_GATEWAY_URL, + f"do:managed_agents_session:{session_uuid}", + ) + assert url == f"https://actions.do-ai-test.run/mcp/session/{session_uuid}" + + +def test_mcp_transport_still_works_with_session_header(): + parent = make_parent( + [FakeResponse(200, jsonrpc_result({"tools": [{"name": "action_search"}]}))] + ) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") + gateway = GatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=transport, + ) + tools = gateway.tools.list() + request = sent_request(gateway) + assert request.url.endswith("/mcp/meta") + assert request.headers[SESSION_ID_HEADER] == "test-session" + assert request.headers[ACTOR_ID_HEADER] == "actor-123" + assert tools[0].name == "action_search" + + +def test_mcp_transport_parses_sse_response(): + response = ( + ": heartbeat\n\n" + "event: message\n" + 'data: {"jsonrpc":"2.0","method":"notifications/progress"}\n\n' + "event: message\n" + 'data: {"jsonrpc":"2.0","id":1,"result":{"tools":' + '[{"name":"action_search"}]}}\n\n' + "data: [DONE]\n\n" + ) + parent = make_parent([FakeResponse(200, response)]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + gateway = GatewayResources( + parent, + gateway_endpoint=TEST_GATEWAY_URL, + transport=MCPTransport( + proxy, session_id=TEST_SESSION_URN, actor_id="actor-123" + ), + ) + assert gateway.tools.list()[0].name == "action_search" + + +def test_mcp_jsonrpc_error_raises_protocol_error(): + parent = make_parent([FakeResponse(200, jsonrpc_error(-32601, "method not found"))]) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") + gateway = GatewayResources( + parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport + ) + with pytest.raises(GatewayProtocolError) as excinfo: + gateway.tools.list() + assert excinfo.value.code == -32601 + + +def test_mcp_is_error_raises_gateway_tool_error(): + structured = { + "invocation_id": "inv_1", + "error": { + "class": "rate_limited", + "message": "slow down", + "retriable": True, + "recovery_hint": "retry_later", + }, + } + parent = make_parent( + [FakeResponse(200, jsonrpc_result(call_result(structured, is_error=True)))] + ) + proxy = _BaseURLProxy(parent._client, TEST_GATEWAY_URL) + transport = MCPTransport(proxy, session_id=TEST_SESSION_URN, actor_id="actor-123") + gateway = GatewayResources( + parent, gateway_endpoint=TEST_GATEWAY_URL, transport=transport + ) + with pytest.raises(GatewayToolError) as excinfo: + gateway.tools.call("web_search", {"query": "x"}) + assert excinfo.value.invocation_id == "inv_1" diff --git a/tests/integration/test_droplets.py b/tests/integration/test_droplets.py index 9b490027..d3bab0b6 100644 --- a/tests/integration/test_droplets.py +++ b/tests/integration/test_droplets.py @@ -41,7 +41,6 @@ def test_droplet_attach_volume(integration_client: Client, public_key: bytes): } with shared.with_test_volume(integration_client, **volume_req) as volume: - vol_attach_resp = integration_client.volume_actions.post_by_id( volume["volume"]["id"], {"type": "attach", "droplet_id": droplet["droplet"]["id"]},