swytchcode-runtime 1.1.5__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {swytchcode_runtime-1.1.5/swytchcode_runtime.egg-info → swytchcode_runtime-1.2.0}/PKG-INFO +21 -1
  2. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/README.md +20 -0
  3. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/pyproject.toml +1 -1
  4. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/__init__.py +5 -0
  5. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/cli.py +5 -2
  6. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/client.py +33 -2
  7. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/exec.py +22 -0
  8. swytchcode_runtime-1.2.0/swytchcode_runtime/tenants.py +143 -0
  9. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0/swytchcode_runtime.egg-info}/PKG-INFO +21 -1
  10. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/SOURCES.txt +3 -1
  11. swytchcode_runtime-1.2.0/tests/test_tenants.py +175 -0
  12. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/LICENSE.md +0 -0
  13. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/setup.cfg +0 -0
  14. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/discover.py +0 -0
  15. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/errors.py +0 -0
  16. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/manage.py +0 -0
  17. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/prompts.py +0 -0
  18. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/__init__.py +0 -0
  19. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/anthropic.py +0 -0
  20. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/base.py +0 -0
  21. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/crewai.py +0 -0
  22. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/langgraph.py +0 -0
  23. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/openai_agents.py +0 -0
  24. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/providers/vercel.py +0 -0
  25. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime/schema.py +0 -0
  26. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/dependency_links.txt +0 -0
  27. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/swytchcode_runtime.egg-info/top_level.txt +0 -0
  28. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_alias.py +0 -0
  29. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_exec_errors.py +0 -0
  30. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_nested_body.py +0 -0
  31. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_prompts.py +0 -0
  32. {swytchcode_runtime-1.1.5 → swytchcode_runtime-1.2.0}/tests/test_schema_routing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swytchcode-runtime
3
- Version: 1.1.5
3
+ Version: 1.2.0
4
4
  Summary: Thin runtime wrapper around the Swytchcode CLI
5
5
  Author: Swytchcode
6
6
  License: MIT
@@ -86,6 +86,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
86
86
  - **raw** - If `True`, use `--raw` and return stdout as a string.
87
87
  - **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
88
88
  - **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
89
+ - **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
90
+ - **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
89
91
 
90
92
  This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
91
93
 
@@ -121,6 +123,24 @@ except SwytchcodeError as e:
121
123
  print(e.details.get("suggested_action"))
122
124
  ```
123
125
 
126
+ ## Multi-tenant apps
127
+
128
+ When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
129
+
130
+ ```python
131
+ from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
132
+
133
+ url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
134
+ save_key("stripe", user.id, key) # API-key providers: stored on your server only
135
+ save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
136
+ {"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
137
+ exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
138
+ client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
139
+ disconnect("gmail", user.id)
140
+ ```
141
+
142
+ An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
143
+
124
144
  ## What this library is
125
145
 
126
146
  - A thin process wrapper over the Swytchcode shell.
@@ -68,6 +68,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
68
68
  - **raw** - If `True`, use `--raw` and return stdout as a string.
69
69
  - **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
70
70
  - **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
71
+ - **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
72
+ - **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
71
73
 
72
74
  This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
73
75
 
@@ -103,6 +105,24 @@ except SwytchcodeError as e:
103
105
  print(e.details.get("suggested_action"))
104
106
  ```
105
107
 
108
+ ## Multi-tenant apps
109
+
110
+ When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
111
+
112
+ ```python
113
+ from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
114
+
115
+ url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
116
+ save_key("stripe", user.id, key) # API-key providers: stored on your server only
117
+ save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
118
+ {"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
119
+ exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
120
+ client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
121
+ disconnect("gmail", user.id)
122
+ ```
123
+
124
+ An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
125
+
106
126
  ## What this library is
107
127
 
108
128
  - A thin process wrapper over the Swytchcode shell.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swytchcode-runtime"
7
- version = "1.1.5"
7
+ version = "1.2.0"
8
8
  description = "Thin runtime wrapper around the Swytchcode CLI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -4,13 +4,18 @@ from .client import Swytchcode
4
4
  from .errors import SwytchcodeError, is_swytchcode_error
5
5
  from .exec import exec_ as exec
6
6
  from .prompts import TOOL_USE_INSTRUCTIONS
7
+ from .tenants import connect, disconnect, save_credentials, save_key
7
8
 
8
9
  __all__ = [
9
10
  "TOOL_USE_INSTRUCTIONS",
10
11
  "Swytchcode",
11
12
  "SwytchcodeError",
13
+ "connect",
14
+ "disconnect",
12
15
  "exec",
13
16
  "is_swytchcode_error",
17
+ "save_credentials",
18
+ "save_key",
14
19
  ]
15
20
 
16
21
 
@@ -17,18 +17,21 @@ def run_cli(
17
17
  cwd: str | None = None,
18
18
  env: dict | None = None,
19
19
  timeout: float | None = 60,
20
+ input: str | None = None,
21
+ json_output: bool = True,
20
22
  ) -> Any:
21
23
  run_env = os.environ.copy()
22
24
  if env:
23
25
  run_env.update(env)
24
26
 
25
27
  cmd = [_resolve_bin(run_env), *args]
26
- if "--json" not in args:
28
+ if json_output and "--json" not in args:
27
29
  cmd.append("--json")
28
30
 
29
31
  try:
30
32
  r = subprocess.run(
31
33
  cmd,
34
+ input=input.encode("utf-8") if input is not None else None,
32
35
  capture_output=True,
33
36
  cwd=cwd or os.getcwd(),
34
37
  env=run_env,
@@ -51,7 +54,7 @@ def run_cli(
51
54
  )
52
55
 
53
56
  out = r.stdout.decode("utf-8", "replace").strip()
54
- if not out:
57
+ if not out or not json_output:
55
58
  return None
56
59
 
57
60
  try:
@@ -9,6 +9,7 @@ from typing import Any
9
9
  from . import discover as _discover
10
10
  from . import manage as _manage
11
11
  from . import schema as _schema
12
+ from .errors import SwytchcodeError
12
13
  from .exec import exec_ as _exec
13
14
  from .providers.base import Provider, Tool
14
15
 
@@ -170,7 +171,14 @@ class _Tools:
170
171
  final_args["body"] = _strip_empty(final_args["body"])
171
172
  if isinstance(final_args.get("params"), (dict, list)):
172
173
  final_args["params"] = _strip_empty(final_args["params"])
173
- # Forward exec options (dry_run, raw, allow_raw, cwd, env) to the CLI.
174
+ # A client bound to one end user runs every tool for that user unless
175
+ # the call names another one itself.
176
+ if self._c.tenant_id is not None and options.get("tenant_id") is None:
177
+ options["tenant_id"] = self._c.tenant_id
178
+ if options.get("tenant_label") is None:
179
+ options["tenant_label"] = self._c.tenant_label
180
+ # Forward exec options (dry_run, raw, allow_raw, cwd, env, tenant_id,
181
+ # tenant_label) to the CLI.
174
182
  return _exec(canonical_id, final_args, **options)
175
183
 
176
184
  def _tool(self, cid: str) -> Tool:
@@ -225,8 +233,31 @@ class _Tools:
225
233
 
226
234
 
227
235
  class Swytchcode:
228
- def __init__(self, provider: Provider | None = None):
236
+ def __init__(
237
+ self,
238
+ provider: Provider | None = None,
239
+ *,
240
+ tenant_id: str | None = None,
241
+ tenant_label: str | None = None,
242
+ ):
243
+ """
244
+ tenant_id binds the client to one end user of your app: every tool it
245
+ runs, including those an AI agent picks, uses that user's connected
246
+ accounts and never yours. Create one client per request, from your
247
+ server's session. tenant_label is how approvers see that end user, for
248
+ example "Alice Smith (alice@acme.com)".
249
+ """
229
250
  self.provider = provider
251
+ if tenant_id is not None:
252
+ tenant_id = tenant_id.strip()
253
+ # An empty id would quietly run the agent's tools on your own account.
254
+ if not tenant_id:
255
+ raise SwytchcodeError("tenant_id must be a non-empty string when set")
256
+ self.tenant_id = tenant_id
257
+ tenant_label = (tenant_label or "").strip() or None
258
+ if tenant_label and not tenant_id:
259
+ raise SwytchcodeError("tenant_label needs tenant_id")
260
+ self.tenant_label = tenant_label
230
261
  self.tools = _Tools(self)
231
262
 
232
263
  def handle_tool_calls(
@@ -80,6 +80,8 @@ def exec_(
80
80
  dry_run: bool = False,
81
81
  allow_raw: bool = False,
82
82
  timeout: float | None = None,
83
+ tenant_id: str | None = None,
84
+ tenant_label: str | None = None,
83
85
  ) -> Any:
84
86
  """
85
87
  Run swytchcode exec <canonical_id> with optional JSON args on stdin.
@@ -102,6 +104,14 @@ def exec_(
102
104
  dry_run: If True, pass --dry-run; request details are output instead of calling the server.
103
105
  allow_raw: If True, pass --allow-raw; required for executing raw methods (kernel default is disabled).
104
106
  timeout: Optional timeout in seconds for the subprocess.
107
+ tenant_id: Run the call for one end user (tenant) of your app, with their own
108
+ connected account (passes --tenant). Use your own id for the logged-in user,
109
+ taken from your server's session. Never falls back to your own account: if
110
+ this user has not connected the provider, it fails with category
111
+ "tenant_not_connected".
112
+ tenant_label: How approvers see this end user when a policy asks for human
113
+ approval, for example "Alice Smith (alice@acme.com)" (passes
114
+ --tenant-label). Needs tenant_id.
105
115
 
106
116
  Returns:
107
117
  Parsed result (any) or raw string when raw is True.
@@ -112,6 +122,14 @@ def exec_(
112
122
  canonical_id = canonical_id.strip()
113
123
  if not canonical_id:
114
124
  raise SwytchcodeError("canonical_id must be a non-empty string")
125
+ # An empty tenant id must never quietly run the call on the developer's own account.
126
+ if tenant_id is not None:
127
+ tenant_id = tenant_id.strip()
128
+ if not tenant_id:
129
+ raise SwytchcodeError("tenant_id must be a non-empty string when set")
130
+ tenant_label = (tenant_label or "").strip()
131
+ if tenant_label and not tenant_id:
132
+ raise SwytchcodeError("tenant_label needs tenant_id")
115
133
 
116
134
  run_env = os.environ.copy()
117
135
  if env:
@@ -124,6 +142,10 @@ def exec_(
124
142
  cmd.append("--dry-run")
125
143
  if allow_raw:
126
144
  cmd.append("--allow-raw")
145
+ if tenant_id:
146
+ cmd.extend(["--tenant", tenant_id])
147
+ if tenant_label:
148
+ cmd.extend(["--tenant-label", tenant_label])
127
149
 
128
150
  stdin_payload: bytes | None = None
129
151
  if input is not None:
@@ -0,0 +1,143 @@
1
+ """Connect, save and remove accounts for the end users (tenants) of your app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .cli import run_cli
8
+ from .errors import SwytchcodeError
9
+
10
+
11
+ def _tenant_args(provider: str, tenant_id: str) -> list[str]:
12
+ provider = (provider or "").strip()
13
+ tenant_id = (tenant_id or "").strip()
14
+ if not provider:
15
+ raise SwytchcodeError("provider must be a non-empty string")
16
+ # An empty tenant id would act on the developer's own account.
17
+ if not tenant_id:
18
+ raise SwytchcodeError("tenant_id must be a non-empty string")
19
+ return [provider, "--tenant", tenant_id]
20
+
21
+
22
+ def connect(
23
+ provider: str,
24
+ tenant_id: str,
25
+ *,
26
+ cwd: str | None = None,
27
+ env: dict[str, str] | None = None,
28
+ ) -> dict:
29
+ """
30
+ Start an OAuth connection for one end user and return the link they open.
31
+
32
+ tenant_id is your own id for the end user, taken from your server's session,
33
+ never from the browser. The account is ready once they finish in the popup;
34
+ the first exec() with this tenant_id picks it up. The provider app used
35
+ (Swytchcode's or your own) is the one you chose with `swytchcode auth connect`.
36
+
37
+ Returns {"url": ..., "connected_account_uuid": ...}.
38
+ """
39
+ out = run_cli(
40
+ ["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
41
+ cwd=cwd,
42
+ env=env,
43
+ )
44
+ if not isinstance(out, dict) or not out.get("authorization_url"):
45
+ raise SwytchcodeError(
46
+ f"{provider} does not connect with OAuth; collect the end user's key "
47
+ "in your app and save it with save_key()",
48
+ out,
49
+ )
50
+ return {
51
+ "url": out["authorization_url"],
52
+ "connected_account_uuid": out.get("connected_account_uuid", ""),
53
+ }
54
+
55
+
56
+ def save_key(
57
+ provider: str,
58
+ tenant_id: str,
59
+ key: str,
60
+ *,
61
+ cwd: str | None = None,
62
+ env: dict[str, str] | None = None,
63
+ ) -> None:
64
+ """
65
+ Save an end user's API key for a provider that uses keys. The key is stored
66
+ encrypted on this machine only and never sent to Swytchcode; the end user is
67
+ recorded (without the key) toward your plan's end-user limit, so this needs a
68
+ login or API key, and at the limit it raises and nothing is saved.
69
+ """
70
+ key = (key or "").strip()
71
+ if not key:
72
+ raise SwytchcodeError("key must be a non-empty string")
73
+ out = run_cli(
74
+ ["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
75
+ cwd=cwd,
76
+ env=env,
77
+ input=key + "\n",
78
+ )
79
+ if not isinstance(out, dict) or out.get("stored") != "local":
80
+ raise SwytchcodeError(
81
+ f"{provider} does not use an API key; use connect() instead", out
82
+ )
83
+
84
+
85
+ def save_credentials(
86
+ provider: str,
87
+ tenant_id: str,
88
+ credentials: dict[str, str],
89
+ *,
90
+ cwd: str | None = None,
91
+ env: dict[str, str] | None = None,
92
+ ) -> None:
93
+ """
94
+ Save an end user's credentials for a provider whose requests are signed
95
+ (crypto exchanges): several fields, such as ``{"api_key": ..., "secret_key":
96
+ ..., "passphrase": ...}``, named as the provider's signing recipe names them
97
+ (``swytchcode info`` shows the auth block). They are stored encrypted on this
98
+ machine only and never sent to Swytchcode; the CLI signs each of this end
99
+ user's requests with them. Like ``save_key()``, this counts the end user
100
+ toward your plan's end-user limit.
101
+ """
102
+ if not isinstance(credentials, dict):
103
+ raise SwytchcodeError("credentials must be a dict of field names to values")
104
+ fields: dict[str, str] = {}
105
+ for name, value in credentials.items():
106
+ if not isinstance(value, str):
107
+ raise SwytchcodeError(f"credential {name} must be a string")
108
+ if value.strip():
109
+ fields[name] = value.strip()
110
+ if not fields:
111
+ raise SwytchcodeError("credentials must not be empty")
112
+ # One JSON line on stdin: the values never appear in the process arguments.
113
+ out = run_cli(
114
+ ["auth", "connect", *_tenant_args(provider, tenant_id), "--json"],
115
+ cwd=cwd,
116
+ env=env,
117
+ input=json.dumps(fields, separators=(",", ":")) + "\n",
118
+ )
119
+ if (
120
+ not isinstance(out, dict)
121
+ or out.get("stored") != "local"
122
+ or out.get("auth_type") != "request_signature"
123
+ ):
124
+ raise SwytchcodeError(
125
+ f"{provider} does not use signed requests; use save_key() or connect() instead",
126
+ out,
127
+ )
128
+
129
+
130
+ def disconnect(
131
+ provider: str,
132
+ tenant_id: str,
133
+ *,
134
+ cwd: str | None = None,
135
+ env: dict[str, str] | None = None,
136
+ ) -> None:
137
+ """Remove one end user's account for a provider, on this machine and on Swytchcode."""
138
+ run_cli(
139
+ ["auth", "disconnect", *_tenant_args(provider, tenant_id)],
140
+ cwd=cwd,
141
+ env=env,
142
+ json_output=False,
143
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swytchcode-runtime
3
- Version: 1.1.5
3
+ Version: 1.2.0
4
4
  Summary: Thin runtime wrapper around the Swytchcode CLI
5
5
  Author: Swytchcode
6
6
  License: MIT
@@ -86,6 +86,8 @@ output = exec("api.report.export", {"id": "123"}, raw=True)
86
86
  - **raw** - If `True`, use `--raw` and return stdout as a string.
87
87
  - **dry_run** - If `True`, pass `--dry-run` to the CLI; request details (method, url, headers, body) are output instead of calling the server.
88
88
  - **allow_raw** - If `True`, pass `--allow-raw` to the CLI; required for executing raw methods (kernel has this disabled by default).
89
+ - **tenant_id** - Run the call for one end user of your app, with their own connected account (passes `--tenant`). Never falls back to your account. See [Multi-tenant apps](#multi-tenant-apps).
90
+ - **tenant_label** - How approvers see that end user, e.g. `"Alice Smith (alice@acme.com)"` (passes `--tenant-label`). Needs `tenant_id`.
89
91
 
90
92
  This runtime invokes `swytchcode exec [canonical_id]` with the flags above. For full exec behavior, see the Swytchcode kernel documentation.
91
93
 
@@ -121,6 +123,24 @@ except SwytchcodeError as e:
121
123
  print(e.details.get("suggested_action"))
122
124
  ```
123
125
 
126
+ ## Multi-tenant apps
127
+
128
+ When your app's own users each connect their accounts, pass your id for the logged-in user (from your server's session, never from the browser) on every call made for them:
129
+
130
+ ```python
131
+ from swytchcode_runtime import connect, save_key, save_credentials, disconnect, exec, Swytchcode
132
+
133
+ url = connect("gmail", user.id)["url"] # one-time link (OAuth), open it from the user's click
134
+ save_key("stripe", user.id, key) # API-key providers: stored on your server only
135
+ save_credentials("coinswitch", user.id, # signed requests (crypto exchanges): every field
136
+ {"api_key": api_key, "secret_key": secret_key}) # the exchange needs, stored on your server only
137
+ exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)
138
+ client = Swytchcode(provider, tenant_id=user.id) # agents: every tool runs for this user
139
+ disconnect("gmail", user.id)
140
+ ```
141
+
142
+ An end user who has not connected raises `SwytchcodeError` with `details["category"] == "tenant_not_connected"`. For exchanges that sign every request, `save_credentials()` takes the fields the provider's signing recipe names (for example `api_key`, `secret_key` and `passphrase`); the CLI signs each of that user's requests with them. Guide: https://docs.swytchcode.com/guides/multi-tenant/
143
+
124
144
  ## What this library is
125
145
 
126
146
  - A thin process wrapper over the Swytchcode shell.
@@ -10,6 +10,7 @@ swytchcode_runtime/exec.py
10
10
  swytchcode_runtime/manage.py
11
11
  swytchcode_runtime/prompts.py
12
12
  swytchcode_runtime/schema.py
13
+ swytchcode_runtime/tenants.py
13
14
  swytchcode_runtime.egg-info/PKG-INFO
14
15
  swytchcode_runtime.egg-info/SOURCES.txt
15
16
  swytchcode_runtime.egg-info/dependency_links.txt
@@ -25,4 +26,5 @@ tests/test_alias.py
25
26
  tests/test_exec_errors.py
26
27
  tests/test_nested_body.py
27
28
  tests/test_prompts.py
28
- tests/test_schema_routing.py
29
+ tests/test_schema_routing.py
30
+ tests/test_tenants.py
@@ -0,0 +1,175 @@
1
+ import json
2
+ import subprocess
3
+ import unittest
4
+ from unittest import mock
5
+
6
+ from swytchcode_runtime import SwytchcodeError, connect, disconnect, exec, save_credentials, save_key
7
+
8
+
9
+ def _done(stdout="", returncode=0, stderr=""):
10
+ return subprocess.CompletedProcess(
11
+ args=[], returncode=returncode, stdout=stdout.encode(), stderr=stderr.encode()
12
+ )
13
+
14
+
15
+ @mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode")
16
+ @mock.patch("swytchcode_runtime.cli._resolve_bin", return_value="swytchcode")
17
+ class TestTenants(unittest.TestCase):
18
+ def test_exec_passes_tenant(self, *_):
19
+ with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
20
+ exec("gmail.send", {"body": {}}, tenant_id=" alice ")
21
+ self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"])
22
+
23
+ def test_exec_without_tenant_passes_no_flag(self, *_):
24
+ with mock.patch("subprocess.run", return_value=_done("{}")) as run:
25
+ exec("gmail.send", {"body": {}})
26
+ self.assertNotIn("--tenant", run.call_args.args[0])
27
+
28
+ def test_exec_passes_tenant_label(self, *_):
29
+ with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
30
+ exec("gmail.send", {}, tenant_id="alice", tenant_label=" Alice Smith ")
31
+ self.assertEqual(run.call_args.args[0][-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"])
32
+
33
+ def test_exec_refuses_label_without_tenant(self, *_):
34
+ with mock.patch("subprocess.run") as run:
35
+ with self.assertRaisesRegex(SwytchcodeError, "tenant_label needs tenant_id"):
36
+ exec("gmail.send", {}, tenant_label="Alice")
37
+ run.assert_not_called()
38
+
39
+ def test_exec_rejects_empty_tenant(self, *_):
40
+ with mock.patch("subprocess.run") as run:
41
+ with self.assertRaises(SwytchcodeError):
42
+ exec("gmail.send", {}, tenant_id=" ")
43
+ run.assert_not_called()
44
+
45
+ def test_exec_reports_tenant_not_connected(self, *_):
46
+ err = json.dumps(
47
+ {
48
+ "error": "alice has not connected google",
49
+ "category": "tenant_not_connected",
50
+ "retryable": False,
51
+ }
52
+ )
53
+ with mock.patch("subprocess.run", return_value=_done("", 3, err)):
54
+ with self.assertRaises(SwytchcodeError) as ctx:
55
+ exec("gmail.send", {}, tenant_id="alice")
56
+ self.assertEqual(ctx.exception.details["category"], "tenant_not_connected")
57
+
58
+ def test_connect_returns_link(self, *_):
59
+ out = json.dumps(
60
+ {
61
+ "authorization_url": "https://auth.example/?state=s",
62
+ "connected_account_uuid": "ca-1",
63
+ "provider_slug": "google",
64
+ "tenant_id": "alice",
65
+ }
66
+ )
67
+ with mock.patch("subprocess.run", return_value=_done(out)) as run:
68
+ res = connect("google", "alice")
69
+ self.assertEqual(
70
+ res, {"url": "https://auth.example/?state=s", "connected_account_uuid": "ca-1"}
71
+ )
72
+ self.assertEqual(
73
+ run.call_args.args[0][1:],
74
+ ["auth", "connect", "google", "--tenant", "alice", "--json"],
75
+ )
76
+
77
+ def test_connect_on_key_provider_points_to_save_key(self, *_):
78
+ with mock.patch("subprocess.run", return_value=_done("")):
79
+ with self.assertRaisesRegex(SwytchcodeError, "save_key"):
80
+ connect("stripe", "alice")
81
+
82
+ def test_save_key_uses_stdin(self, *_):
83
+ out = json.dumps({"provider_slug": "stripe", "tenant_id": "alice", "auth_type": "api_key", "stored": "local"})
84
+ with mock.patch("subprocess.run", return_value=_done(out)) as run:
85
+ save_key("stripe", "alice", "sk_test_123")
86
+ self.assertNotIn("sk_test_123", run.call_args.args[0])
87
+ self.assertEqual(run.call_args.kwargs["input"], b"sk_test_123\n")
88
+
89
+ def test_save_credentials_uses_one_json_line_on_stdin(self, *_):
90
+ out = json.dumps({"provider_slug": "coinswitch", "tenant_id": "alice", "auth_type": "request_signature", "stored": "local"})
91
+ with mock.patch("subprocess.run", return_value=_done(out)) as run:
92
+ save_credentials("coinswitch", "alice", {"api_key": " pub ", "secret_key": "sec"})
93
+ self.assertEqual(run.call_args.args[0][1:], ["auth", "connect", "coinswitch", "--tenant", "alice", "--json"])
94
+ self.assertEqual(run.call_args.kwargs["input"], b'{"api_key":"pub","secret_key":"sec"}\n')
95
+ self.assertNotIn("sec", " ".join(run.call_args.args[0]))
96
+
97
+ def test_save_credentials_on_unsigned_provider_raises(self, *_):
98
+ out = json.dumps({"provider_slug": "stripe", "auth_type": "api_key", "stored": "local"})
99
+ with mock.patch("subprocess.run", return_value=_done(out)):
100
+ with self.assertRaisesRegex(SwytchcodeError, "does not use signed requests"):
101
+ save_credentials("stripe", "alice", {"api_key": "k"})
102
+
103
+ def test_save_credentials_validates_before_running(self, *_):
104
+ with mock.patch("subprocess.run") as run:
105
+ with self.assertRaises(SwytchcodeError):
106
+ save_credentials("x", "alice", {})
107
+ with self.assertRaises(SwytchcodeError):
108
+ save_credentials("x", "alice", {"a": 1})
109
+ run.assert_not_called()
110
+
111
+ def test_disconnect_has_no_json_flag(self, *_):
112
+ with mock.patch("subprocess.run", return_value=_done("Disconnected")) as run:
113
+ disconnect("google", "alice")
114
+ self.assertEqual(
115
+ run.call_args.args[0][1:], ["auth", "disconnect", "google", "--tenant", "alice"]
116
+ )
117
+
118
+ def test_helpers_refuse_empty_tenant(self, *_):
119
+ with mock.patch("subprocess.run") as run:
120
+ with self.assertRaises(SwytchcodeError):
121
+ connect("google", "")
122
+ with self.assertRaises(SwytchcodeError):
123
+ disconnect("google", " ")
124
+ run.assert_not_called()
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()
129
+
130
+
131
+ @mock.patch("swytchcode_runtime.exec._resolve_bin", return_value="swytchcode")
132
+ class TestBoundClient(unittest.TestCase):
133
+ def test_tools_execute_passes_bound_tenant(self, *_):
134
+ from swytchcode_runtime import Swytchcode
135
+
136
+ with mock.patch("subprocess.run", return_value=_done('{"ok": true}')) as run:
137
+ Swytchcode(tenant_id="alice").tools.execute("gmail.send", {"to": "x@y.z"})
138
+ self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "alice"])
139
+
140
+ def test_handle_tool_calls_runs_for_bound_tenant(self, *_):
141
+ from types import SimpleNamespace
142
+
143
+ from swytchcode_runtime import Swytchcode
144
+
145
+ block = SimpleNamespace(type="tool_use", id="t1", name="gmail_send", input={})
146
+ with mock.patch("subprocess.run", return_value=_done("{}")) as run:
147
+ Swytchcode(tenant_id="bob").handle_tool_calls(SimpleNamespace(content=[block]))
148
+ self.assertEqual(run.call_args.args[0][-2:], ["--tenant", "bob"])
149
+
150
+ def test_unbound_client_passes_no_tenant(self, *_):
151
+ from swytchcode_runtime import Swytchcode
152
+
153
+ with mock.patch("subprocess.run", return_value=_done("{}")) as run:
154
+ Swytchcode().tools.execute("gmail.send", {})
155
+ self.assertNotIn("--tenant", run.call_args.args[0])
156
+
157
+ def test_empty_tenant_refused(self, *_):
158
+ from swytchcode_runtime import Swytchcode
159
+
160
+ with self.assertRaises(SwytchcodeError):
161
+ Swytchcode(tenant_id=" ")
162
+
163
+ def test_bound_label_goes_with_bound_tenant_only(self, *_):
164
+ from swytchcode_runtime import Swytchcode
165
+
166
+ client = Swytchcode(tenant_id="alice", tenant_label="Alice Smith")
167
+ with mock.patch("subprocess.run", return_value=_done("{}")) as run:
168
+ client.tools.execute("gmail.send", {})
169
+ alice = run.call_args.args[0]
170
+ client.tools.execute("gmail.send", {}, tenant_id="bob")
171
+ bob = run.call_args.args[0]
172
+ self.assertEqual(alice[-4:], ["--tenant", "alice", "--tenant-label", "Alice Smith"])
173
+ self.assertEqual(bob[-2:], ["--tenant", "bob"])
174
+ with self.assertRaises(SwytchcodeError):
175
+ Swytchcode(tenant_label="Alice")