scraperack 0.1.0a4__tar.gz → 0.3.0a2__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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: scraperack
3
+ Version: 0.3.0a2
4
+ Summary: Python client for the ScrapeRack core API: microVM pools, images, disks and tasks
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Repository, https://github.com/damadjan/scraperack
7
+ Project-URL: Issues, https://github.com/damadjan/scraperack/issues
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: httpx<1,>=0.27
13
+ Dynamic: license-file
14
+
15
+ # ScrapeRack core SDK
16
+
17
+ Python client for the ScrapeRack core API: images, microVM pools, disks, tasks, and live
18
+ inventory. The core only runs microVMs; what runs inside them is up to plugins.
19
+
20
+ To run Python functions, use [`scraperack-python`](https://github.com/damadjan/scraperack-plugins/tree/main/python/sdk),
21
+ the Python plugin's SDK, which builds on this package.
22
+
23
+ > ScrapeRack is alpha software. Use it on trusted infrastructure and expect breaking changes.
24
+
25
+ ```sh
26
+ pip install scraperack
27
+ ```
28
+
29
+ ```python
30
+ from scraperack import Cluster
31
+
32
+ with Cluster("http://controller:7070") as cluster:
33
+ image = next(i for i in cluster.images() if i["name"] == "headless-chromium")
34
+ cluster.create_pool("browsers", image["id"], cpu=1, memory_mib=1024, warm=4,
35
+ network=True, ports=[{"name": "cdp", "guest_port": 9222,
36
+ "host_port": None, "protocol": "tcp"}])
37
+ for vm in cluster.vms():
38
+ print(vm["pool"], vm["node_address"], vm["ports"])
39
+ ```
40
+
41
+ A **task** sends an opaque JSON payload to the executor of a pool and stores its result,
42
+ stdout, stderr, and metrics for a while. Plugins give payloads their meaning:
43
+
44
+ ```python
45
+ task = cluster.submit("my-pool", {"encoding": "json", "data": {"hello": "world"}})
46
+ print(cluster.result(task["id"]))
47
+ print(cluster.tasks(owner="plugin:python", limit=20))
48
+ ```
49
+
50
+ `AsyncCluster` has the same methods for `asyncio`. `cluster.request(method, path)` calls any
51
+ other endpoint, such as a plugin API under `/plugins/<name>/api/`.
@@ -0,0 +1,37 @@
1
+ # ScrapeRack core SDK
2
+
3
+ Python client for the ScrapeRack core API: images, microVM pools, disks, tasks, and live
4
+ inventory. The core only runs microVMs; what runs inside them is up to plugins.
5
+
6
+ To run Python functions, use [`scraperack-python`](https://github.com/damadjan/scraperack-plugins/tree/main/python/sdk),
7
+ the Python plugin's SDK, which builds on this package.
8
+
9
+ > ScrapeRack is alpha software. Use it on trusted infrastructure and expect breaking changes.
10
+
11
+ ```sh
12
+ pip install scraperack
13
+ ```
14
+
15
+ ```python
16
+ from scraperack import Cluster
17
+
18
+ with Cluster("http://controller:7070") as cluster:
19
+ image = next(i for i in cluster.images() if i["name"] == "headless-chromium")
20
+ cluster.create_pool("browsers", image["id"], cpu=1, memory_mib=1024, warm=4,
21
+ network=True, ports=[{"name": "cdp", "guest_port": 9222,
22
+ "host_port": None, "protocol": "tcp"}])
23
+ for vm in cluster.vms():
24
+ print(vm["pool"], vm["node_address"], vm["ports"])
25
+ ```
26
+
27
+ A **task** sends an opaque JSON payload to the executor of a pool and stores its result,
28
+ stdout, stderr, and metrics for a while. Plugins give payloads their meaning:
29
+
30
+ ```python
31
+ task = cluster.submit("my-pool", {"encoding": "json", "data": {"hello": "world"}})
32
+ print(cluster.result(task["id"]))
33
+ print(cluster.tasks(owner="plugin:python", limit=20))
34
+ ```
35
+
36
+ `AsyncCluster` has the same methods for `asyncio`. `cluster.request(method, path)` calls any
37
+ other endpoint, such as a plugin API under `/plugins/<name>/api/`.
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scraperack"
7
- version = "0.1.0a4"
8
- description = "Python SDK for self-hosted ScrapeRack clusters"
7
+ version = "0.3.0a2"
8
+ description = "Python client for the ScrapeRack core API: microVM pools, images, disks and tasks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- dependencies = ["cloudpickle>=3", "httpx>=0.27"]
11
+ dependencies = ["httpx>=0.27,<1"]
12
12
  license = "Apache-2.0"
13
13
  license-files = ["LICENSE"]
14
14
  classifiers = ["Development Status :: 3 - Alpha"]
@@ -17,5 +17,8 @@ classifiers = ["Development Status :: 3 - Alpha"]
17
17
  Repository = "https://github.com/damadjan/scraperack"
18
18
  Issues = "https://github.com/damadjan/scraperack/issues"
19
19
 
20
+ [tool.setuptools]
21
+ packages = ["scraperack"]
22
+
20
23
  [tool.setuptools.package-data]
21
24
  scraperack = ["py.typed"]
@@ -0,0 +1,54 @@
1
+ """Python client for the ScrapeRack core API.
2
+
3
+ Workload SDKs build on it; Python functions are run with ``scraperack-python``.
4
+ """
5
+ from .client import APIError, AsyncCluster, Cluster, archive_directory
6
+ from .types import (
7
+ Disk,
8
+ DiskView,
9
+ Image,
10
+ ImageView,
11
+ MicroVM,
12
+ Node,
13
+ NodeStatus,
14
+ Plugin,
15
+ Pool,
16
+ PoolDisk,
17
+ PoolView,
18
+ PortProtocol,
19
+ PortSpec,
20
+ PublishedPort,
21
+ StorageSettings,
22
+ Task,
23
+ TaskLogs,
24
+ TaskMetrics,
25
+ TaskResult,
26
+ TaskStatus,
27
+ )
28
+
29
+ __all__ = [
30
+ "APIError",
31
+ "AsyncCluster",
32
+ "Cluster",
33
+ "Disk",
34
+ "DiskView",
35
+ "Image",
36
+ "ImageView",
37
+ "MicroVM",
38
+ "Node",
39
+ "NodeStatus",
40
+ "Plugin",
41
+ "Pool",
42
+ "PoolDisk",
43
+ "PoolView",
44
+ "PortProtocol",
45
+ "PortSpec",
46
+ "PublishedPort",
47
+ "StorageSettings",
48
+ "Task",
49
+ "TaskLogs",
50
+ "TaskMetrics",
51
+ "TaskResult",
52
+ "TaskStatus",
53
+ "archive_directory",
54
+ ]
@@ -0,0 +1,399 @@
1
+ """Clients for the ScrapeRack core API: microVMs, pools, images, disks and tasks.
2
+
3
+ Language plugins ship their own SDKs on top of this one; for Python code see
4
+ ``scraperack-python``.
5
+ """
6
+ import asyncio
7
+ import hashlib
8
+ import io
9
+ from pathlib import Path
10
+ from typing import Any, BinaryIO, Mapping, Sequence
11
+ from urllib.parse import quote
12
+ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
13
+
14
+ import httpx
15
+
16
+ from .types import (
17
+ FINISHED,
18
+ DiskView,
19
+ Disk,
20
+ Image,
21
+ ImageView,
22
+ MicroVM,
23
+ Node,
24
+ Plugin,
25
+ Pool,
26
+ PoolView,
27
+ StorageSettings,
28
+ Task,
29
+ TaskLogs,
30
+ TaskResult,
31
+ )
32
+
33
+ DEFAULT_URL = "http://127.0.0.1:7070"
34
+
35
+
36
+ class APIError(RuntimeError):
37
+ """The controller rejected a request."""
38
+
39
+ def __init__(self, status: int, message: str) -> None:
40
+ super().__init__(f"{status}: {message}")
41
+ self.status = status
42
+ self.message = message
43
+
44
+
45
+ def _check(response: httpx.Response) -> Any:
46
+ if response.status_code >= 400:
47
+ try:
48
+ message = response.json()["error"]
49
+ except Exception:
50
+ message = response.text or response.reason_phrase
51
+ raise APIError(response.status_code, message)
52
+ if not response.content:
53
+ return None
54
+ return response.json()
55
+
56
+
57
+ def _segment(value: str) -> str:
58
+ return quote(str(value), safe="")
59
+
60
+
61
+ def archive_directory(path: str | Path) -> tuple[str, bytes]:
62
+ """Zip a directory reproducibly and return its SHA-256 and bytes."""
63
+ root = Path(path).resolve()
64
+ if not root.is_dir():
65
+ raise ValueError(f"working directory does not exist: {path}")
66
+ output = io.BytesIO()
67
+ with ZipFile(output, "w") as archive:
68
+ for file in sorted(root.rglob("*")):
69
+ if file.is_symlink():
70
+ raise ValueError(f"working directory contains a symlink: {file}")
71
+ if not file.is_file():
72
+ continue
73
+ info = ZipInfo(file.relative_to(root).as_posix(), (1980, 1, 1, 0, 0, 0))
74
+ info.compress_type = ZIP_DEFLATED
75
+ archive.writestr(info, file.read_bytes())
76
+ data = output.getvalue()
77
+ return hashlib.sha256(data).hexdigest(), data
78
+
79
+
80
+ def _pool_body(
81
+ name: str,
82
+ image: str,
83
+ cpu: float,
84
+ memory_mib: int,
85
+ disk_mib: int,
86
+ timeout: int,
87
+ warm: int,
88
+ max_instances: int | None,
89
+ idle_timeout: int,
90
+ reuse: bool,
91
+ network: bool,
92
+ ports: Sequence[Mapping[str, Any]],
93
+ extra: Mapping[str, Any],
94
+ ) -> dict[str, Any]:
95
+ return {
96
+ "name": name,
97
+ "image": image,
98
+ "warm_instances": warm,
99
+ "max_instances": max_instances,
100
+ "cpu_millis": round(cpu * 1000),
101
+ "memory_mib": memory_mib,
102
+ "disk_mib": disk_mib,
103
+ "timeout_secs": timeout,
104
+ "idle_timeout_secs": idle_timeout,
105
+ "reuse": reuse,
106
+ "network": network,
107
+ "ports": list(ports),
108
+ **extra,
109
+ }
110
+
111
+
112
+ def _task_body(
113
+ pool: str,
114
+ payload: Mapping[str, Any],
115
+ queue: bool,
116
+ variables: Mapping[str, str] | None,
117
+ secrets: Mapping[str, str] | None,
118
+ ) -> dict[str, Any]:
119
+ return {
120
+ "pool": pool,
121
+ "payload": dict(payload),
122
+ "queue": queue,
123
+ "variables": dict(variables or {}),
124
+ "secrets": dict(secrets or {}),
125
+ }
126
+
127
+
128
+ def _task_query(limit: int | None, pool: str | Sequence[str] | None, owner: str | None) -> dict:
129
+ params: dict[str, Any] = {}
130
+ if limit is not None:
131
+ params["limit"] = limit
132
+ if pool is not None:
133
+ params["pool"] = pool if isinstance(pool, str) else ",".join(pool)
134
+ if owner is not None:
135
+ params["owner"] = owner
136
+ return params
137
+
138
+
139
+ class Cluster:
140
+ """Synchronous client for a ScrapeRack controller."""
141
+
142
+ def __init__(self, url: str = DEFAULT_URL, *, client: httpx.Client | None = None) -> None:
143
+ self.url = url.rstrip("/")
144
+ self.client = client or httpx.Client(base_url=self.url, timeout=60)
145
+
146
+ def __enter__(self) -> "Cluster":
147
+ return self
148
+
149
+ def __exit__(self, *_: object) -> None:
150
+ self.close()
151
+
152
+ def close(self) -> None:
153
+ self.client.close()
154
+
155
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
156
+ """Call any controller endpoint, including plugin APIs under ``/plugins/<name>/api``."""
157
+ return _check(self.client.request(method, path, **kwargs))
158
+
159
+ # Images
160
+ def images(self) -> list[ImageView]:
161
+ return self.request("GET", "/v1/images")
162
+
163
+ def import_image(self, name: str, reference: str, username: str | None = None,
164
+ password: str | None = None) -> Image:
165
+ """Import a Docker image from a registry; blocks until it is converted."""
166
+ return self.request("POST", "/v1/images/import", timeout=None, json={
167
+ "name": name, "reference": reference, "username": username, "password": password,
168
+ })
169
+
170
+ def rename_image(self, image: str, name: str) -> Image:
171
+ return self.request("PATCH", f"/v1/images/{_segment(image)}", json={"name": name})
172
+
173
+ def delete_image(self, image: str) -> None:
174
+ self.request("DELETE", f"/v1/images/{_segment(image)}")
175
+
176
+ # Pools
177
+ def pools(self) -> list[PoolView]:
178
+ return self.request("GET", "/v1/pools")
179
+
180
+ def pool(self, name: str) -> Pool:
181
+ return self.request("GET", f"/v1/pools/{_segment(name)}")
182
+
183
+ def create_pool(self, name: str, image: str, cpu: float = 1, memory_mib: int = 512,
184
+ disk_mib: int = 1024, timeout: int = 300, warm: int = 0,
185
+ max_instances: int | None = None, idle_timeout: int = 30, reuse: bool = True,
186
+ network: bool = False, ports: Sequence[Mapping[str, Any]] = (),
187
+ **extra: Any) -> Pool:
188
+ """Create a pool. ``extra`` passes other pool fields such as ``disks`` or ``executor``."""
189
+ return self.request("POST", "/v1/pools", json=_pool_body(
190
+ name, image, cpu, memory_mib, disk_mib, timeout, warm, max_instances,
191
+ idle_timeout, reuse, network, ports, extra))
192
+
193
+ def update_pool(self, name: str, **changes: Any) -> Pool:
194
+ """Change pool fields; VM-affecting changes replace idle microVMs."""
195
+ pool = {**self.pool(name), **changes}
196
+ return self.request("PUT", f"/v1/pools/{_segment(name)}", json=pool)
197
+
198
+ def restart_pool(self, name: str) -> Pool:
199
+ return self.request("POST", f"/v1/pools/{_segment(name)}/restart")
200
+
201
+ def delete_pool(self, name: str) -> None:
202
+ self.request("DELETE", f"/v1/pools/{_segment(name)}")
203
+
204
+ # Disks
205
+ def disks(self) -> list[DiskView]:
206
+ return self.request("GET", "/v1/disks")
207
+
208
+ def upload_disk(self, file: str | Path | BinaryIO) -> Disk:
209
+ """Upload a read-only ext4 filesystem; nodes download it on demand."""
210
+ if isinstance(file, (str, Path)):
211
+ with open(file, "rb") as handle:
212
+ return self.request("POST", "/v1/disks", content=handle, timeout=None)
213
+ return self.request("POST", "/v1/disks", content=file, timeout=None)
214
+
215
+ def delete_disk(self, key: str) -> None:
216
+ self.request("DELETE", f"/v1/disks/{_segment(key)}")
217
+
218
+ # Tasks
219
+ def submit(self, pool: str, payload: Mapping[str, Any], queue: bool = True,
220
+ variables: Mapping[str, str] | None = None,
221
+ secrets: Mapping[str, str] | None = None) -> Task:
222
+ """Queue an opaque payload for the pool's executor (pool name or alias)."""
223
+ return self.request("POST", "/v1/tasks",
224
+ json=_task_body(pool, payload, queue, variables, secrets))
225
+
226
+ def tasks(self, limit: int | None = None, pool: str | Sequence[str] | None = None,
227
+ owner: str | None = None) -> list[Task]:
228
+ """Newest tasks first, optionally only on some pools or of one owner."""
229
+ return self.request("GET", "/v1/tasks", params=_task_query(limit, pool, owner))
230
+
231
+ def task(self, task_id: str) -> Task:
232
+ return self.request("GET", f"/v1/tasks/{_segment(task_id)}")
233
+
234
+ def wait(self, task_id: str) -> Task:
235
+ """Block until the task succeeds, fails, or times out."""
236
+ while True:
237
+ task = self.request("GET", f"/v1/tasks/{_segment(task_id)}/wait", timeout=None)
238
+ if task["status"] in FINISHED:
239
+ return task
240
+
241
+ def result(self, task_id: str) -> TaskResult:
242
+ """Wait for the task and return its status, encoded value and error."""
243
+ self.wait(task_id)
244
+ return self.request("GET", f"/v1/tasks/{_segment(task_id)}/result")
245
+
246
+ def logs(self, task_id: str) -> TaskLogs:
247
+ return self.request("GET", f"/v1/tasks/{_segment(task_id)}/logs")
248
+
249
+ def payload(self, task_id: str) -> Any:
250
+ return self.request("GET", f"/v1/tasks/{_segment(task_id)}/payload")
251
+
252
+ def upload_working_dir(self, path: str | Path) -> str:
253
+ """Upload a directory once (content-addressed) and return its digest."""
254
+ digest, archive = archive_directory(path)
255
+ response = self.client.head(f"/v1/working-dirs/{digest}")
256
+ if response.status_code == 404:
257
+ response = self.client.put(f"/v1/working-dirs/{digest}", content=archive,
258
+ headers={"Content-Type": "application/zip"})
259
+ _check(response)
260
+ return digest
261
+
262
+ # Inventory
263
+ def vms(self) -> list[MicroVM]:
264
+ return self.request("GET", "/v1/vms")
265
+
266
+ def nodes(self) -> list[Node]:
267
+ return self.request("GET", "/v1/nodes")
268
+
269
+ def plugins(self) -> list[Plugin]:
270
+ return self.request("GET", "/v1/plugins")
271
+
272
+ def storage(self) -> StorageSettings:
273
+ return self.request("GET", "/v1/settings/storage")
274
+
275
+
276
+ class AsyncCluster:
277
+ """Asynchronous client for a ScrapeRack controller."""
278
+
279
+ def __init__(self, url: str = DEFAULT_URL, *, client: httpx.AsyncClient | None = None) -> None:
280
+ self.url = url.rstrip("/")
281
+ self.client = client or httpx.AsyncClient(base_url=self.url, timeout=60)
282
+
283
+ async def __aenter__(self) -> "AsyncCluster":
284
+ return self
285
+
286
+ async def __aexit__(self, *_: object) -> None:
287
+ await self.close()
288
+
289
+ async def close(self) -> None:
290
+ await self.client.aclose()
291
+
292
+ async def request(self, method: str, path: str, **kwargs: Any) -> Any:
293
+ """Call any controller endpoint, including plugin APIs under ``/plugins/<name>/api``."""
294
+ return _check(await self.client.request(method, path, **kwargs))
295
+
296
+ # Images
297
+ async def images(self) -> list[ImageView]:
298
+ return await self.request("GET", "/v1/images")
299
+
300
+ async def import_image(self, name: str, reference: str, username: str | None = None,
301
+ password: str | None = None) -> Image:
302
+ return await self.request("POST", "/v1/images/import", timeout=None, json={
303
+ "name": name, "reference": reference, "username": username, "password": password,
304
+ })
305
+
306
+ async def rename_image(self, image: str, name: str) -> Image:
307
+ return await self.request("PATCH", f"/v1/images/{_segment(image)}", json={"name": name})
308
+
309
+ async def delete_image(self, image: str) -> None:
310
+ await self.request("DELETE", f"/v1/images/{_segment(image)}")
311
+
312
+ # Pools
313
+ async def pools(self) -> list[PoolView]:
314
+ return await self.request("GET", "/v1/pools")
315
+
316
+ async def pool(self, name: str) -> Pool:
317
+ return await self.request("GET", f"/v1/pools/{_segment(name)}")
318
+
319
+ async def create_pool(self, name: str, image: str, cpu: float = 1, memory_mib: int = 512,
320
+ disk_mib: int = 1024, timeout: int = 300, warm: int = 0,
321
+ max_instances: int | None = None, idle_timeout: int = 30,
322
+ reuse: bool = True, network: bool = False,
323
+ ports: Sequence[Mapping[str, Any]] = (), **extra: Any) -> Pool:
324
+ return await self.request("POST", "/v1/pools", json=_pool_body(
325
+ name, image, cpu, memory_mib, disk_mib, timeout, warm, max_instances,
326
+ idle_timeout, reuse, network, ports, extra))
327
+
328
+ async def update_pool(self, name: str, **changes: Any) -> Pool:
329
+ pool = {**await self.pool(name), **changes}
330
+ return await self.request("PUT", f"/v1/pools/{_segment(name)}", json=pool)
331
+
332
+ async def restart_pool(self, name: str) -> Pool:
333
+ return await self.request("POST", f"/v1/pools/{_segment(name)}/restart")
334
+
335
+ async def delete_pool(self, name: str) -> None:
336
+ await self.request("DELETE", f"/v1/pools/{_segment(name)}")
337
+
338
+ # Disks
339
+ async def disks(self) -> list[DiskView]:
340
+ return await self.request("GET", "/v1/disks")
341
+
342
+ async def upload_disk(self, file: str | Path) -> Disk:
343
+ data = await asyncio.to_thread(Path(file).read_bytes)
344
+ return await self.request("POST", "/v1/disks", content=data, timeout=None)
345
+
346
+ async def delete_disk(self, key: str) -> None:
347
+ await self.request("DELETE", f"/v1/disks/{_segment(key)}")
348
+
349
+ # Tasks
350
+ async def submit(self, pool: str, payload: Mapping[str, Any], queue: bool = True,
351
+ variables: Mapping[str, str] | None = None,
352
+ secrets: Mapping[str, str] | None = None) -> Task:
353
+ return await self.request("POST", "/v1/tasks",
354
+ json=_task_body(pool, payload, queue, variables, secrets))
355
+
356
+ async def tasks(self, limit: int | None = None, pool: str | Sequence[str] | None = None,
357
+ owner: str | None = None) -> list[Task]:
358
+ return await self.request("GET", "/v1/tasks", params=_task_query(limit, pool, owner))
359
+
360
+ async def task(self, task_id: str) -> Task:
361
+ return await self.request("GET", f"/v1/tasks/{_segment(task_id)}")
362
+
363
+ async def wait(self, task_id: str) -> Task:
364
+ while True:
365
+ task = await self.request("GET", f"/v1/tasks/{_segment(task_id)}/wait", timeout=None)
366
+ if task["status"] in FINISHED:
367
+ return task
368
+
369
+ async def result(self, task_id: str) -> TaskResult:
370
+ await self.wait(task_id)
371
+ return await self.request("GET", f"/v1/tasks/{_segment(task_id)}/result")
372
+
373
+ async def logs(self, task_id: str) -> TaskLogs:
374
+ return await self.request("GET", f"/v1/tasks/{_segment(task_id)}/logs")
375
+
376
+ async def payload(self, task_id: str) -> Any:
377
+ return await self.request("GET", f"/v1/tasks/{_segment(task_id)}/payload")
378
+
379
+ async def upload_working_dir(self, path: str | Path) -> str:
380
+ digest, archive = await asyncio.to_thread(archive_directory, path)
381
+ response = await self.client.head(f"/v1/working-dirs/{digest}")
382
+ if response.status_code == 404:
383
+ response = await self.client.put(f"/v1/working-dirs/{digest}", content=archive,
384
+ headers={"Content-Type": "application/zip"})
385
+ _check(response)
386
+ return digest
387
+
388
+ # Inventory
389
+ async def vms(self) -> list[MicroVM]:
390
+ return await self.request("GET", "/v1/vms")
391
+
392
+ async def nodes(self) -> list[Node]:
393
+ return await self.request("GET", "/v1/nodes")
394
+
395
+ async def plugins(self) -> list[Plugin]:
396
+ return await self.request("GET", "/v1/plugins")
397
+
398
+ async def storage(self) -> StorageSettings:
399
+ return await self.request("GET", "/v1/settings/storage")