runcloud-sdk 0.7.0__tar.gz → 0.7.1__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.1
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.1"
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,16 @@ 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"
15
18
 
16
19
 
17
20
  class RunCloudError(Exception):
@@ -38,6 +41,47 @@ class ExecResult:
38
41
  stderr: str
39
42
 
40
43
 
44
+ @dataclass(frozen=True)
45
+ class _SavedCredentials:
46
+ api_key: str
47
+ api_url: str
48
+
49
+
50
+ def _nonempty_string(value: Any) -> Optional[str]:
51
+ if not isinstance(value, str):
52
+ return None
53
+ normalized = value.strip()
54
+ return normalized or None
55
+
56
+
57
+ def _load_saved_credentials() -> Optional[_SavedCredentials]:
58
+ configured_home = _nonempty_string(os.environ.get("RUN_CLOUD_HOME"))
59
+ credentials_home = (
60
+ Path(configured_home).expanduser()
61
+ if configured_home
62
+ else Path.home() / ".run-cloud"
63
+ )
64
+ try:
65
+ payload = json.loads((credentials_home / "credentials").read_text())
66
+ except (OSError, TypeError, ValueError):
67
+ return None
68
+
69
+ if not isinstance(payload, dict):
70
+ return None
71
+ api_key = _nonempty_string(payload.get("token"))
72
+ api_url = _nonempty_string(payload.get("apiUrl"))
73
+ expires_at = payload.get("expiresAt")
74
+ if (
75
+ not api_key
76
+ or not api_url
77
+ or isinstance(expires_at, bool)
78
+ or not isinstance(expires_at, (int, float))
79
+ or expires_at < time.time()
80
+ ):
81
+ return None
82
+ return _SavedCredentials(api_key=api_key, api_url=api_url)
83
+
84
+
41
85
  class Client:
42
86
  def __init__(
43
87
  self,
@@ -46,17 +90,32 @@ class Client:
46
90
  timeout: float = 60.0,
47
91
  transport: Optional[Transport] = None,
48
92
  ):
93
+ explicit_api_key = _nonempty_string(api_key)
94
+ environment_api_key = _nonempty_string(
95
+ os.environ.get("RUN_CLOUD_API_KEY")
96
+ ) or _nonempty_string(os.environ.get("RUN_CLOUD_API_TOKEN"))
97
+ saved_credentials = (
98
+ None
99
+ if explicit_api_key or environment_api_key
100
+ else _load_saved_credentials()
101
+ )
49
102
  self.api_key = (
50
- api_key
51
- or os.environ.get("RUN_CLOUD_API_KEY")
52
- or os.environ.get("RUN_CLOUD_API_TOKEN")
103
+ explicit_api_key
104
+ or environment_api_key
105
+ or (saved_credentials.api_key if saved_credentials else None)
53
106
  )
54
107
  if not self.api_key:
55
108
  raise ValueError(
56
- "Run Cloud API key required. Set RUN_CLOUD_API_KEY or pass Client(api_key=...)."
109
+ "Run Cloud API key required. Run `runcloud login`, set "
110
+ "RUN_CLOUD_API_KEY, or pass Client(api_key=...)."
57
111
  )
112
+ explicit_api_url = _nonempty_string(api_url)
113
+ environment_api_url = _nonempty_string(os.environ.get("RUN_CLOUD_API_URL"))
58
114
  self.api_url = (
59
- api_url or os.environ.get("RUN_CLOUD_API_URL") or "https://api.run.cloud"
115
+ explicit_api_url
116
+ or environment_api_url
117
+ or (saved_credentials.api_url if saved_credentials else None)
118
+ or DEFAULT_API_URL
60
119
  ).rstrip("/")
61
120
  self.timeout = timeout
62
121
  self._transport = transport
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runcloud-sdk
3
- Version: 0.7.0
3
+ Version: 0.7.1
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,177 @@
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 SDKTests(unittest.TestCase):
38
+ def setUp(self):
39
+ self.recorder = Recorder()
40
+ self.client = Client(api_key="rc_live_test", transport=self.recorder)
41
+
42
+ def test_native_routes(self):
43
+ box = self.client.create(cpu=1.5, memory=2048)
44
+ self.assertEqual(box.exec("echo ok").stdout, "ok\n")
45
+ self.assertEqual(box.read_file("/tmp/a"), b"artifact")
46
+ box.pause()
47
+ box.resume()
48
+ box.destroy()
49
+ self.assertIn(
50
+ (
51
+ "POST",
52
+ "/run-cloud/sandboxes",
53
+ {
54
+ "image": "runcloud/agent-base",
55
+ "cpu": 1.5,
56
+ "memory": 2048,
57
+ },
58
+ ),
59
+ self.recorder.calls,
60
+ )
61
+
62
+ def test_compatibility_adapters_share_the_public_client(self):
63
+ app = modal.App.lookup("compat", client=self.client)
64
+ self.assertEqual(modal.Sandbox.create(app=app).exec("echo ok").wait(), 0)
65
+ self.assertEqual(
66
+ E2BSandbox.create(client=self.client).commands.run("echo ok").exit_code, 0
67
+ )
68
+ self.assertEqual(
69
+ Daytona(client=self.client).create().process.exec("echo ok").exit_code, 0
70
+ )
71
+ self.assertEqual(
72
+ VercelSandbox.create(client=self.client).run_command("echo").exit_code, 0
73
+ )
74
+ self.assertTrue(SyncSandboxInstance.create(client=self.client))
75
+ async_blaxel = asyncio.run(SandboxInstance.create(client=self.client))
76
+ asyncio.run(async_blaxel.delete())
77
+ self.assertTrue(SpritesClient(client=self.client).create_sprite("compat"))
78
+
79
+ def test_unsupported_features_fail_loudly(self):
80
+ with self.assertRaises(UnsupportedCompatibilityFeatureError):
81
+ modal.Image.debian_slim().pip_install("requests")
82
+
83
+
84
+ class CredentialTests(unittest.TestCase):
85
+ def setUp(self):
86
+ self.temporary_directory = tempfile.TemporaryDirectory()
87
+ self.run_cloud_home = Path(self.temporary_directory.name)
88
+ self.environment = patch.dict(
89
+ os.environ,
90
+ {"RUN_CLOUD_HOME": str(self.run_cloud_home)},
91
+ clear=False,
92
+ )
93
+ self.environment.start()
94
+ for name in ("RUN_CLOUD_API_KEY", "RUN_CLOUD_API_TOKEN", "RUN_CLOUD_API_URL"):
95
+ os.environ.pop(name, None)
96
+
97
+ def tearDown(self):
98
+ self.environment.stop()
99
+ self.temporary_directory.cleanup()
100
+
101
+ def save_credentials(
102
+ self,
103
+ *,
104
+ token="saved-key",
105
+ api_url="https://saved.example/api/",
106
+ expires_at=None,
107
+ ):
108
+ payload = {
109
+ "token": token,
110
+ "apiUrl": api_url,
111
+ "appUrl": "https://run.cloud",
112
+ "expiresAt": expires_at or time.time() + 3600,
113
+ }
114
+ (self.run_cloud_home / "credentials").write_text(json.dumps(payload))
115
+
116
+ def test_uses_saved_cli_credentials(self):
117
+ self.save_credentials()
118
+
119
+ client = Client()
120
+
121
+ self.assertEqual(client.api_key, "saved-key")
122
+ self.assertEqual(client.api_url, "https://saved.example/api")
123
+
124
+ def test_modal_app_lookup_uses_saved_cli_credentials(self):
125
+ self.save_credentials()
126
+
127
+ app = modal.App.lookup("my-app", create_if_missing=True)
128
+
129
+ self.assertEqual(app.client.api_key, "saved-key")
130
+ self.assertEqual(app.client.api_url, "https://saved.example/api")
131
+
132
+ def test_explicit_credentials_override_environment_and_saved_login(self):
133
+ self.save_credentials()
134
+ os.environ["RUN_CLOUD_API_KEY"] = "environment-key"
135
+ os.environ["RUN_CLOUD_API_URL"] = "https://environment.example"
136
+
137
+ client = Client(
138
+ api_key="explicit-key",
139
+ api_url="https://explicit.example/",
140
+ )
141
+
142
+ self.assertEqual(client.api_key, "explicit-key")
143
+ self.assertEqual(client.api_url, "https://explicit.example")
144
+
145
+ def test_environment_credentials_override_saved_login(self):
146
+ self.save_credentials()
147
+ os.environ["RUN_CLOUD_API_KEY"] = "environment-key"
148
+
149
+ client = Client()
150
+
151
+ self.assertEqual(client.api_key, "environment-key")
152
+ self.assertEqual(client.api_url, "https://api.run.cloud")
153
+
154
+ def test_api_url_override_applies_to_saved_login(self):
155
+ self.save_credentials()
156
+ os.environ["RUN_CLOUD_API_URL"] = "https://override.example/"
157
+
158
+ client = Client()
159
+
160
+ self.assertEqual(client.api_key, "saved-key")
161
+ self.assertEqual(client.api_url, "https://override.example")
162
+
163
+ def test_ignores_expired_saved_credentials(self):
164
+ self.save_credentials(expires_at=time.time() - 1)
165
+
166
+ with self.assertRaisesRegex(ValueError, "runcloud login"):
167
+ Client()
168
+
169
+ def test_ignores_malformed_saved_credentials(self):
170
+ (self.run_cloud_home / "credentials").write_text("{not json")
171
+
172
+ with self.assertRaisesRegex(ValueError, "RUN_CLOUD_API_KEY"):
173
+ Client()
174
+
175
+
176
+ if __name__ == "__main__":
177
+ 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