runcloud-sdk 0.7.0__tar.gz → 0.7.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runcloud-sdk
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
5
  Author: Newly
6
6
  License-Expression: Apache-2.0
@@ -21,7 +21,7 @@ Python SDK for [run.cloud](https://run.cloud).
21
21
 
22
22
  ```bash
23
23
  pip install runcloud-sdk
24
- export RUN_CLOUD_API_KEY="rc_live_..."
24
+ runcloud login
25
25
  ```
26
26
 
27
27
  ```python
@@ -36,6 +36,10 @@ finally:
36
36
  box.destroy()
37
37
  ```
38
38
 
39
+ `Client()` automatically uses the credential saved by `runcloud login`. For
40
+ automation, set `RUN_CLOUD_API_KEY` and optionally `RUN_CLOUD_API_URL`, or pass
41
+ `api_key` and `api_url` directly.
42
+
39
43
  ## Compatibility adapters
40
44
 
41
45
  The same distribution includes migration adapters:
@@ -4,7 +4,7 @@ Python SDK for [run.cloud](https://run.cloud).
4
4
 
5
5
  ```bash
6
6
  pip install runcloud-sdk
7
- export RUN_CLOUD_API_KEY="rc_live_..."
7
+ runcloud login
8
8
  ```
9
9
 
10
10
  ```python
@@ -19,6 +19,10 @@ finally:
19
19
  box.destroy()
20
20
  ```
21
21
 
22
+ `Client()` automatically uses the credential saved by `runcloud login`. For
23
+ automation, set `RUN_CLOUD_API_KEY` and optionally `RUN_CLOUD_API_URL`, or pass
24
+ `api_key` and `api_url` directly.
25
+
22
26
  ## Compatibility adapters
23
27
 
24
28
  The same distribution includes migration adapters:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runcloud-sdk"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Python SDK for run.cloud, including sandbox-provider compatibility adapters"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -5,13 +5,17 @@ from __future__ import annotations
5
5
  import base64
6
6
  import json
7
7
  import os
8
+ import time
8
9
  import urllib.error
9
10
  import urllib.parse
10
11
  import urllib.request
11
12
  from dataclasses import dataclass
13
+ from pathlib import Path
12
14
  from typing import Any, Callable, Optional, Union
13
15
 
14
16
  Transport = Callable[[str, str, Any], Any]
17
+ DEFAULT_API_URL = "https://api.run.cloud"
18
+ USER_AGENT = "runcloud-python/0.7.2"
15
19
 
16
20
 
17
21
  class RunCloudError(Exception):
@@ -38,6 +42,47 @@ class ExecResult:
38
42
  stderr: str
39
43
 
40
44
 
45
+ @dataclass(frozen=True)
46
+ class _SavedCredentials:
47
+ api_key: str
48
+ api_url: str
49
+
50
+
51
+ def _nonempty_string(value: Any) -> Optional[str]:
52
+ if not isinstance(value, str):
53
+ return None
54
+ normalized = value.strip()
55
+ return normalized or None
56
+
57
+
58
+ def _load_saved_credentials() -> Optional[_SavedCredentials]:
59
+ configured_home = _nonempty_string(os.environ.get("RUN_CLOUD_HOME"))
60
+ credentials_home = (
61
+ Path(configured_home).expanduser()
62
+ if configured_home
63
+ else Path.home() / ".run-cloud"
64
+ )
65
+ try:
66
+ payload = json.loads((credentials_home / "credentials").read_text())
67
+ except (OSError, TypeError, ValueError):
68
+ return None
69
+
70
+ if not isinstance(payload, dict):
71
+ return None
72
+ api_key = _nonempty_string(payload.get("token"))
73
+ api_url = _nonempty_string(payload.get("apiUrl"))
74
+ expires_at = payload.get("expiresAt")
75
+ if (
76
+ not api_key
77
+ or not api_url
78
+ or isinstance(expires_at, bool)
79
+ or not isinstance(expires_at, (int, float))
80
+ or expires_at < time.time()
81
+ ):
82
+ return None
83
+ return _SavedCredentials(api_key=api_key, api_url=api_url)
84
+
85
+
41
86
  class Client:
42
87
  def __init__(
43
88
  self,
@@ -46,17 +91,32 @@ class Client:
46
91
  timeout: float = 60.0,
47
92
  transport: Optional[Transport] = None,
48
93
  ):
94
+ explicit_api_key = _nonempty_string(api_key)
95
+ environment_api_key = _nonempty_string(
96
+ os.environ.get("RUN_CLOUD_API_KEY")
97
+ ) or _nonempty_string(os.environ.get("RUN_CLOUD_API_TOKEN"))
98
+ saved_credentials = (
99
+ None
100
+ if explicit_api_key or environment_api_key
101
+ else _load_saved_credentials()
102
+ )
49
103
  self.api_key = (
50
- api_key
51
- or os.environ.get("RUN_CLOUD_API_KEY")
52
- or os.environ.get("RUN_CLOUD_API_TOKEN")
104
+ explicit_api_key
105
+ or environment_api_key
106
+ or (saved_credentials.api_key if saved_credentials else None)
53
107
  )
54
108
  if not self.api_key:
55
109
  raise ValueError(
56
- "Run Cloud API key required. Set RUN_CLOUD_API_KEY or pass Client(api_key=...)."
110
+ "Run Cloud API key required. Run `runcloud login`, set "
111
+ "RUN_CLOUD_API_KEY, or pass Client(api_key=...)."
57
112
  )
113
+ explicit_api_url = _nonempty_string(api_url)
114
+ environment_api_url = _nonempty_string(os.environ.get("RUN_CLOUD_API_URL"))
58
115
  self.api_url = (
59
- api_url or os.environ.get("RUN_CLOUD_API_URL") or "https://api.run.cloud"
116
+ explicit_api_url
117
+ or environment_api_url
118
+ or (saved_credentials.api_url if saved_credentials else None)
119
+ or DEFAULT_API_URL
60
120
  ).rstrip("/")
61
121
  self.timeout = timeout
62
122
  self._transport = transport
@@ -72,6 +132,7 @@ class Client:
72
132
  headers={
73
133
  "Authorization": "Bearer " + self.api_key,
74
134
  "Content-Type": "application/json",
135
+ "User-Agent": USER_AGENT,
75
136
  },
76
137
  )
77
138
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runcloud-sdk
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Python SDK for run.cloud, including sandbox-provider compatibility adapters
5
5
  Author: Newly
6
6
  License-Expression: Apache-2.0
@@ -21,7 +21,7 @@ Python SDK for [run.cloud](https://run.cloud).
21
21
 
22
22
  ```bash
23
23
  pip install runcloud-sdk
24
- export RUN_CLOUD_API_KEY="rc_live_..."
24
+ runcloud login
25
25
  ```
26
26
 
27
27
  ```python
@@ -36,6 +36,10 @@ finally:
36
36
  box.destroy()
37
37
  ```
38
38
 
39
+ `Client()` automatically uses the credential saved by `runcloud login`. For
40
+ automation, set `RUN_CLOUD_API_KEY` and optionally `RUN_CLOUD_API_URL`, or pass
41
+ `api_key` and `api_url` directly.
42
+
39
43
  ## Compatibility adapters
40
44
 
41
45
  The same distribution includes migration adapters:
@@ -0,0 +1,198 @@
1
+ import asyncio
2
+ import base64
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import time
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ from runcloud import Client, UnsupportedCompatibilityFeatureError
12
+ from runcloud.compat import modal
13
+ from runcloud.compat.blaxel import SandboxInstance, SyncSandboxInstance
14
+ from runcloud.compat.daytona import Daytona
15
+ from runcloud.compat.e2b import Sandbox as E2BSandbox
16
+ from runcloud.compat.sprites import SpritesClient
17
+ from runcloud.compat.vercel import Sandbox as VercelSandbox
18
+
19
+ SANDBOX = {"id": "sb_python", "name": "compat", "state": "running"}
20
+
21
+
22
+ class Recorder:
23
+ def __init__(self):
24
+ self.calls = []
25
+
26
+ def __call__(self, method, path, body):
27
+ self.calls.append((method, path, body))
28
+ if path.endswith("/exec"):
29
+ return {"exit_code": 0, "stdout": "ok\n", "stderr": ""}
30
+ if path.endswith("/fs"):
31
+ return {"content": base64.b64encode(b"artifact").decode()}
32
+ if path == "/run-cloud/sandboxes" and method == "GET":
33
+ return {"items": [SANDBOX]}
34
+ return SANDBOX
35
+
36
+
37
+ class Response:
38
+ def __enter__(self):
39
+ return self
40
+
41
+ def __exit__(self, *_):
42
+ return None
43
+
44
+ def read(self):
45
+ return b"{}"
46
+
47
+
48
+ class SDKTests(unittest.TestCase):
49
+ def setUp(self):
50
+ self.recorder = Recorder()
51
+ self.client = Client(api_key="rc_live_test", transport=self.recorder)
52
+
53
+ def test_native_routes(self):
54
+ box = self.client.create(cpu=1.5, memory=2048)
55
+ self.assertEqual(box.exec("echo ok").stdout, "ok\n")
56
+ self.assertEqual(box.read_file("/tmp/a"), b"artifact")
57
+ box.pause()
58
+ box.resume()
59
+ box.destroy()
60
+ self.assertIn(
61
+ (
62
+ "POST",
63
+ "/run-cloud/sandboxes",
64
+ {
65
+ "image": "runcloud/agent-base",
66
+ "cpu": 1.5,
67
+ "memory": 2048,
68
+ },
69
+ ),
70
+ self.recorder.calls,
71
+ )
72
+
73
+ def test_compatibility_adapters_share_the_public_client(self):
74
+ app = modal.App.lookup("compat", client=self.client)
75
+ self.assertEqual(modal.Sandbox.create(app=app).exec("echo ok").wait(), 0)
76
+ self.assertEqual(
77
+ E2BSandbox.create(client=self.client).commands.run("echo ok").exit_code, 0
78
+ )
79
+ self.assertEqual(
80
+ Daytona(client=self.client).create().process.exec("echo ok").exit_code, 0
81
+ )
82
+ self.assertEqual(
83
+ VercelSandbox.create(client=self.client).run_command("echo").exit_code, 0
84
+ )
85
+ self.assertTrue(SyncSandboxInstance.create(client=self.client))
86
+ async_blaxel = asyncio.run(SandboxInstance.create(client=self.client))
87
+ asyncio.run(async_blaxel.delete())
88
+ self.assertTrue(SpritesClient(client=self.client).create_sprite("compat"))
89
+
90
+ def test_unsupported_features_fail_loudly(self):
91
+ with self.assertRaises(UnsupportedCompatibilityFeatureError):
92
+ modal.Image.debian_slim().pip_install("requests")
93
+
94
+ def test_requests_identify_the_python_sdk(self):
95
+ with patch(
96
+ "runcloud.client.urllib.request.urlopen",
97
+ return_value=Response(),
98
+ ) as urlopen:
99
+ Client(api_key="rc_live_test").request("GET", "/run-cloud/account")
100
+
101
+ request = urlopen.call_args.args[0]
102
+ self.assertEqual(request.get_header("User-agent"), "runcloud-python/0.7.2")
103
+
104
+
105
+ class CredentialTests(unittest.TestCase):
106
+ def setUp(self):
107
+ self.temporary_directory = tempfile.TemporaryDirectory()
108
+ self.run_cloud_home = Path(self.temporary_directory.name)
109
+ self.environment = patch.dict(
110
+ os.environ,
111
+ {"RUN_CLOUD_HOME": str(self.run_cloud_home)},
112
+ clear=False,
113
+ )
114
+ self.environment.start()
115
+ for name in ("RUN_CLOUD_API_KEY", "RUN_CLOUD_API_TOKEN", "RUN_CLOUD_API_URL"):
116
+ os.environ.pop(name, None)
117
+
118
+ def tearDown(self):
119
+ self.environment.stop()
120
+ self.temporary_directory.cleanup()
121
+
122
+ def save_credentials(
123
+ self,
124
+ *,
125
+ token="saved-key",
126
+ api_url="https://saved.example/api/",
127
+ expires_at=None,
128
+ ):
129
+ payload = {
130
+ "token": token,
131
+ "apiUrl": api_url,
132
+ "appUrl": "https://run.cloud",
133
+ "expiresAt": expires_at or time.time() + 3600,
134
+ }
135
+ (self.run_cloud_home / "credentials").write_text(json.dumps(payload))
136
+
137
+ def test_uses_saved_cli_credentials(self):
138
+ self.save_credentials()
139
+
140
+ client = Client()
141
+
142
+ self.assertEqual(client.api_key, "saved-key")
143
+ self.assertEqual(client.api_url, "https://saved.example/api")
144
+
145
+ def test_modal_app_lookup_uses_saved_cli_credentials(self):
146
+ self.save_credentials()
147
+
148
+ app = modal.App.lookup("my-app", create_if_missing=True)
149
+
150
+ self.assertEqual(app.client.api_key, "saved-key")
151
+ self.assertEqual(app.client.api_url, "https://saved.example/api")
152
+
153
+ def test_explicit_credentials_override_environment_and_saved_login(self):
154
+ self.save_credentials()
155
+ os.environ["RUN_CLOUD_API_KEY"] = "environment-key"
156
+ os.environ["RUN_CLOUD_API_URL"] = "https://environment.example"
157
+
158
+ client = Client(
159
+ api_key="explicit-key",
160
+ api_url="https://explicit.example/",
161
+ )
162
+
163
+ self.assertEqual(client.api_key, "explicit-key")
164
+ self.assertEqual(client.api_url, "https://explicit.example")
165
+
166
+ def test_environment_credentials_override_saved_login(self):
167
+ self.save_credentials()
168
+ os.environ["RUN_CLOUD_API_KEY"] = "environment-key"
169
+
170
+ client = Client()
171
+
172
+ self.assertEqual(client.api_key, "environment-key")
173
+ self.assertEqual(client.api_url, "https://api.run.cloud")
174
+
175
+ def test_api_url_override_applies_to_saved_login(self):
176
+ self.save_credentials()
177
+ os.environ["RUN_CLOUD_API_URL"] = "https://override.example/"
178
+
179
+ client = Client()
180
+
181
+ self.assertEqual(client.api_key, "saved-key")
182
+ self.assertEqual(client.api_url, "https://override.example")
183
+
184
+ def test_ignores_expired_saved_credentials(self):
185
+ self.save_credentials(expires_at=time.time() - 1)
186
+
187
+ with self.assertRaisesRegex(ValueError, "runcloud login"):
188
+ Client()
189
+
190
+ def test_ignores_malformed_saved_credentials(self):
191
+ (self.run_cloud_home / "credentials").write_text("{not json")
192
+
193
+ with self.assertRaisesRegex(ValueError, "RUN_CLOUD_API_KEY"):
194
+ Client()
195
+
196
+
197
+ if __name__ == "__main__":
198
+ unittest.main()
@@ -1,79 +0,0 @@
1
- import asyncio
2
- import base64
3
- import unittest
4
-
5
- from runcloud import Client, UnsupportedCompatibilityFeatureError
6
- from runcloud.compat import modal
7
- from runcloud.compat.blaxel import SandboxInstance, SyncSandboxInstance
8
- from runcloud.compat.daytona import Daytona
9
- from runcloud.compat.e2b import Sandbox as E2BSandbox
10
- from runcloud.compat.sprites import SpritesClient
11
- from runcloud.compat.vercel import Sandbox as VercelSandbox
12
-
13
- SANDBOX = {"id": "sb_python", "name": "compat", "state": "running"}
14
-
15
-
16
- class Recorder:
17
- def __init__(self):
18
- self.calls = []
19
-
20
- def __call__(self, method, path, body):
21
- self.calls.append((method, path, body))
22
- if path.endswith("/exec"):
23
- return {"exit_code": 0, "stdout": "ok\n", "stderr": ""}
24
- if path.endswith("/fs"):
25
- return {"content": base64.b64encode(b"artifact").decode()}
26
- if path == "/run-cloud/sandboxes" and method == "GET":
27
- return {"items": [SANDBOX]}
28
- return SANDBOX
29
-
30
-
31
- class SDKTests(unittest.TestCase):
32
- def setUp(self):
33
- self.recorder = Recorder()
34
- self.client = Client(api_key="rc_live_test", transport=self.recorder)
35
-
36
- def test_native_routes(self):
37
- box = self.client.create(cpu=1.5, memory=2048)
38
- self.assertEqual(box.exec("echo ok").stdout, "ok\n")
39
- self.assertEqual(box.read_file("/tmp/a"), b"artifact")
40
- box.pause()
41
- box.resume()
42
- box.destroy()
43
- self.assertIn(
44
- (
45
- "POST",
46
- "/run-cloud/sandboxes",
47
- {
48
- "image": "runcloud/agent-base",
49
- "cpu": 1.5,
50
- "memory": 2048,
51
- },
52
- ),
53
- self.recorder.calls,
54
- )
55
-
56
- def test_compatibility_adapters_share_the_public_client(self):
57
- app = modal.App.lookup("compat", client=self.client)
58
- self.assertEqual(modal.Sandbox.create(app=app).exec("echo ok").wait(), 0)
59
- self.assertEqual(
60
- E2BSandbox.create(client=self.client).commands.run("echo ok").exit_code, 0
61
- )
62
- self.assertEqual(
63
- Daytona(client=self.client).create().process.exec("echo ok").exit_code, 0
64
- )
65
- self.assertEqual(
66
- VercelSandbox.create(client=self.client).run_command("echo").exit_code, 0
67
- )
68
- self.assertTrue(SyncSandboxInstance.create(client=self.client))
69
- async_blaxel = asyncio.run(SandboxInstance.create(client=self.client))
70
- asyncio.run(async_blaxel.delete())
71
- self.assertTrue(SpritesClient(client=self.client).create_sprite("compat"))
72
-
73
- def test_unsupported_features_fail_loudly(self):
74
- with self.assertRaises(UnsupportedCompatibilityFeatureError):
75
- modal.Image.debian_slim().pip_install("requests")
76
-
77
-
78
- if __name__ == "__main__":
79
- unittest.main()
File without changes
File without changes