ciderstack 0.1.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.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciderstack
3
+ Version: 0.1.0
4
+ Summary: CiderStack Fleet SDK for Python
5
+ Home-page: https://ciderstack.io
6
+ Author: CiderStack
7
+ Author-email: support@ciderstack.io
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: requests>=2.28.0
21
+ Provides-Extra: pairing
22
+ Requires-Dist: cryptography>=41.0.0; extra == "pairing"
23
+ Dynamic: author
24
+ Dynamic: author-email
25
+ Dynamic: classifier
26
+ Dynamic: home-page
27
+ Dynamic: provides-extra
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
@@ -0,0 +1,211 @@
1
+ # CiderStack Fleet SDK for Python
2
+
3
+ A Python client for managing macOS VMs across CiderStack Fleet nodes.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install ciderstack
9
+ ```
10
+
11
+ Or install from source:
12
+
13
+ ```bash
14
+ cd sdk/python
15
+ pip install -e .
16
+ ```
17
+
18
+ For pairing support (optional — not needed if using API tokens):
19
+
20
+ ```bash
21
+ pip install -e ".[pairing]"
22
+ ```
23
+
24
+ ## Authentication
25
+
26
+ All API calls require authentication. Two methods are available:
27
+
28
+ ### API Tokens (recommended)
29
+
30
+ The simplest way to authenticate. Generate a token from the CiderStack CLI or app, then pass it to the client:
31
+
32
+ ```python
33
+ from ciderstack import FleetClient
34
+
35
+ client = FleetClient("192.168.1.100", api_token="csk_abc123...")
36
+ ```
37
+
38
+ Tokens can be **read-only** (can only list/get data) or **full-access** (can also start/stop/delete VMs, etc.).
39
+
40
+ ### Node ID (via pairing)
41
+
42
+ For advanced use cases, you can pair the SDK as a fleet node using a 6-digit code from the CiderStack UI:
43
+
44
+ ```python
45
+ from ciderstack import FleetClient
46
+
47
+ # One-time pairing (requires `cryptography` package)
48
+ creds = FleetClient.pair("192.168.1.100", "123456", "my-ci-script")
49
+ print(f"Save this node ID: {creds['node_id']}")
50
+
51
+ # Use the node ID for all future connections
52
+ client = FleetClient("192.168.1.100", node_id=creds["node_id"])
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from ciderstack import FleetClient
59
+
60
+ client = FleetClient("192.168.1.100", api_token="csk_abc123...")
61
+
62
+ # List VMs
63
+ vms = client.list_vms()
64
+ for vm in vms:
65
+ print(f"{vm.name}: {vm.state}")
66
+
67
+ # Start a VM
68
+ client.start_vm("vm-uuid")
69
+
70
+ # Execute a command
71
+ result = client.exec_command(
72
+ vm_id="vm-uuid",
73
+ command="uname -a",
74
+ ssh_user="admin",
75
+ ssh_password="password"
76
+ )
77
+ print(result.stdout)
78
+ ```
79
+
80
+ ## Examples
81
+
82
+ ### VM Management
83
+
84
+ ```python
85
+ # Clone a VM
86
+ new_vm = client.clone_vm("vm-uuid", "my-clone")
87
+
88
+ # Update VM settings
89
+ client.update_vm_settings(
90
+ vm_id="vm-uuid",
91
+ cpu_count=8,
92
+ memory_size=16384 # 16 GB
93
+ )
94
+
95
+ # Delete a VM
96
+ client.delete_vm("vm-uuid")
97
+ ```
98
+
99
+ ### Snapshots
100
+
101
+ ```python
102
+ # Create a snapshot
103
+ snapshot = client.create_snapshot(
104
+ vm_id="vm-uuid",
105
+ name="pre-update",
106
+ description="Before system update"
107
+ )
108
+
109
+ # List snapshots
110
+ snapshots = client.list_snapshots("vm-uuid")
111
+
112
+ # Restore snapshot
113
+ client.restore_snapshot("vm-uuid", snapshot.id)
114
+ ```
115
+
116
+ ### Fleet Overview
117
+
118
+ ```python
119
+ # Get cluster-wide status
120
+ raw = client.get_fleet_overview()
121
+ stats = raw["overview"]["stats"]
122
+ print(f"Total nodes: {stats['totalNodes']}")
123
+ print(f"Running VMs: {stats['runningVMs']}")
124
+
125
+ # Get node stats
126
+ stats = client.get_node_stats()
127
+ print(f"CPU: {stats.cpu_usage_percent}%")
128
+ print(f"Memory: {stats.memory_used_gb}/{stats.memory_total_gb} GB")
129
+ ```
130
+
131
+ ### Image Management
132
+
133
+ ```python
134
+ # Pull an OCI image
135
+ client.pull_oci_image("ghcr.io/myorg/macos-base:latest")
136
+
137
+ # Create a VM from image
138
+ vm_id = client.create_vm(
139
+ name="ci-runner",
140
+ cpu_count=4,
141
+ memory_mb=8192,
142
+ disk_gb=64,
143
+ oci_image="ghcr.io/myorg/macos-base:latest"
144
+ )
145
+ ```
146
+
147
+ ## API Reference
148
+
149
+ ### FleetClient
150
+
151
+ ```python
152
+ FleetClient(host, node_id=None, api_token=None, port=9473, timeout=30)
153
+ ```
154
+
155
+ | Parameter | Type | Default | Description |
156
+ |-------------|-------|---------|------------------------------------------|
157
+ | `host` | `str` | — | IP address or hostname |
158
+ | `node_id` | `str` | `None` | Trusted node ID (from pairing) |
159
+ | `api_token` | `str` | `None` | API token (from CiderStack CLI/UI) |
160
+ | `port` | `int` | `9473` | Port number |
161
+ | `timeout` | `int` | `30` | Request timeout in seconds |
162
+
163
+ Provide exactly one of `node_id` or `api_token`.
164
+
165
+ **Node Info**
166
+ - `get_node_info()` -> NodeInfo
167
+ - `get_node_stats()` -> NodeStats
168
+
169
+ **VM Management**
170
+ - `list_vms()` -> List[VM]
171
+ - `get_vm(vm_id)` -> VM | None
172
+ - `start_vm(vm_id)` -> bool
173
+ - `stop_vm(vm_id)` -> bool
174
+ - `start_vm_recovery(vm_id)` -> bool
175
+ - `clone_vm(vm_id, new_name)` -> VM
176
+ - `rename_vm(vm_id, new_name)` -> bool
177
+ - `delete_vm(vm_id)` -> bool
178
+ - `get_vm_settings(vm_id)` -> Dict
179
+ - `update_vm_settings(vm_id, ...)` -> bool
180
+
181
+ **Snapshots**
182
+ - `list_snapshots(vm_id)` -> List[Snapshot]
183
+ - `create_snapshot(vm_id, name, description)` -> Snapshot
184
+ - `restore_snapshot(vm_id, snapshot_id)` -> bool
185
+ - `delete_snapshot(vm_id, snapshot_id)` -> bool
186
+
187
+ **Command Execution**
188
+ - `exec_command(vm_id, command, ssh_user, ssh_password, timeout=300)` -> ExecResult (timeout in seconds; default 300 for long-running steps like artifact download)
189
+
190
+ **Tasks**
191
+ - `get_tasks(include_completed)` -> List[Task]
192
+
193
+ **Images**
194
+ - `push_image(vm_id, image_name, insecure)` -> str
195
+ - `list_ipsws()` -> List[Dict]
196
+ - `list_oci_images()` -> List[Dict]
197
+ - `download_ipsw(url, name, version)` -> bool
198
+ - `pull_oci_image(image_reference, username, password)` -> bool
199
+ - `create_vm(name, cpu_count, memory_mb, disk_gb, ipsw_path, oci_image)` -> str
200
+
201
+ **Fleet**
202
+ - `get_fleet_overview()` -> Dict
203
+ - `get_fleet_events(limit, event_type)` -> List[Dict]
204
+ - `get_remote_resources()` -> Dict
205
+
206
+ **Pairing** (static method, requires `cryptography`)
207
+ - `FleetClient.pair(host, code, name, port)` -> Dict
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,32 @@
1
+ """
2
+ CiderStack Fleet SDK for Python
3
+
4
+ A Python client for managing macOS VMs across CiderStack Fleet nodes.
5
+
6
+ Example:
7
+ from ciderstack import FleetClient
8
+
9
+ # Using an API token (recommended)
10
+ client = FleetClient("192.168.1.100", api_token="csk_...")
11
+
12
+ # Or using a trusted node ID (from pairing)
13
+ client = FleetClient("192.168.1.100", node_id="your-trusted-node-id")
14
+
15
+ # List VMs
16
+ vms = client.list_vms()
17
+ for vm in vms:
18
+ print(f"{vm.name}: {vm.state}")
19
+ """
20
+
21
+ from .client import FleetClient, FleetError
22
+ from .types import (
23
+ VMState, TaskStatus, NodeRole,
24
+ Template, APIToken, APITokenSummary, APITokenPermissions,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+ __all__ = [
29
+ "FleetClient", "FleetError",
30
+ "VMState", "TaskStatus", "NodeRole",
31
+ "Template", "APIToken", "APITokenSummary", "APITokenPermissions",
32
+ ]
@@ -0,0 +1,650 @@
1
+ """
2
+ CiderStack Fleet Client
3
+
4
+ HTTP client for the CiderStack Fleet JSON-RPC API.
5
+ """
6
+
7
+ import uuid
8
+
9
+ import requests
10
+ from typing import List, Optional, Dict, Any
11
+ from .types import (
12
+ NodeInfo, NodeStats, VM, Snapshot, Task, ExecResult,
13
+ VMState, TaskStatus, Template, APIToken, APITokenSummary
14
+ )
15
+
16
+
17
+ class FleetError(Exception):
18
+ """Exception raised when a Fleet API call fails."""
19
+ def __init__(self, message: str, response: Optional[Dict] = None):
20
+ super().__init__(message)
21
+ self.response = response
22
+
23
+
24
+ class FleetClient:
25
+ """
26
+ Client for the CiderStack Fleet API.
27
+
28
+ Authenticate with either an API token (recommended) or a trusted node ID.
29
+
30
+ Examples:
31
+ # Using an API token (recommended)
32
+ client = FleetClient("192.168.1.100", api_token="csk_...")
33
+
34
+ # Using a trusted node ID (from pairing)
35
+ client = FleetClient("192.168.1.100", node_id="your-trusted-node-id")
36
+ """
37
+
38
+ DEFAULT_PORT = 9473
39
+ DEFAULT_TIMEOUT = 30
40
+
41
+ def __init__(
42
+ self,
43
+ host: str,
44
+ node_id: Optional[str] = None,
45
+ api_token: Optional[str] = None,
46
+ port: int = DEFAULT_PORT,
47
+ timeout: int = DEFAULT_TIMEOUT,
48
+ ):
49
+ """
50
+ Initialize the Fleet client.
51
+
52
+ Provide exactly one of node_id or api_token for authentication.
53
+
54
+ Args:
55
+ host: IP address or hostname of the Fleet node
56
+ node_id: Trusted node ID (obtained via pairing)
57
+ api_token: API token string (generated via CiderStack CLI/UI)
58
+ port: Port number (default: 9473)
59
+ timeout: Request timeout in seconds (default: 30)
60
+ """
61
+ if not node_id and not api_token:
62
+ raise FleetError("Either node_id or api_token is required for authentication")
63
+ if node_id and api_token:
64
+ raise FleetError("Provide either node_id or api_token, not both")
65
+ self.host = host
66
+ self.port = port
67
+ self.timeout = timeout
68
+ self.node_id = node_id
69
+ self.api_token = api_token
70
+ self.base_url = f"http://{host}:{port}"
71
+
72
+ def _rpc(
73
+ self,
74
+ method: str,
75
+ payload: Optional[Dict] = None,
76
+ request_timeout: Optional[int] = None,
77
+ ) -> Dict:
78
+ """Make an RPC call to the Fleet server."""
79
+ url = f"{self.base_url}/rpc/{method}"
80
+ timeout_sec = request_timeout if request_timeout is not None else self.timeout
81
+
82
+ # Inject authentication into every request
83
+ if self.api_token:
84
+ authenticated_payload: Dict[str, Any] = {"apiToken": self.api_token}
85
+ else:
86
+ authenticated_payload = {"callerNodeID": self.node_id}
87
+ if payload:
88
+ authenticated_payload.update(payload)
89
+
90
+ try:
91
+ response = requests.post(
92
+ url,
93
+ json=authenticated_payload,
94
+ timeout=timeout_sec,
95
+ headers={"Content-Type": "application/json"}
96
+ )
97
+
98
+ if response.status_code == 403:
99
+ try:
100
+ body = response.json()
101
+ error_msg = body.get("error", "Authentication failed")
102
+ except Exception:
103
+ error_msg = "Authentication failed"
104
+ raise FleetError(error_msg)
105
+
106
+ response.raise_for_status()
107
+ return response.json()
108
+ except FleetError:
109
+ raise
110
+ except requests.exceptions.RequestException as e:
111
+ raise FleetError(f"Request failed: {e}")
112
+
113
+ def _check_response(self, response: Dict, operation: str) -> Dict:
114
+ """Check if response indicates success."""
115
+ if not response.get("success", True):
116
+ error = response.get("error") or response.get("message") or "Unknown error"
117
+ raise FleetError(f"{operation} failed: {error}", response)
118
+ return response
119
+
120
+ # =========================================================================
121
+ # Node Information
122
+ # =========================================================================
123
+
124
+ def get_node_info(self) -> NodeInfo:
125
+ """Get information about the connected node."""
126
+ data = self._rpc("GetNodeInfo")
127
+ return NodeInfo.from_dict(data)
128
+
129
+ def get_node_stats(self) -> NodeStats:
130
+ """Get real-time statistics for the node."""
131
+ data = self._rpc("GetNodeStats")
132
+ return NodeStats.from_dict(data)
133
+
134
+ # =========================================================================
135
+ # VM Management
136
+ # =========================================================================
137
+
138
+ def list_vms(self) -> List[VM]:
139
+ """List all VMs on the node."""
140
+ data = self._rpc("ListVMs")
141
+ vms = data.get("vms", [])
142
+ return [VM.from_dict(vm) for vm in vms]
143
+
144
+ def get_vm(self, vm_id: str) -> Optional[VM]:
145
+ """Get a specific VM by ID."""
146
+ vms = self.list_vms()
147
+ for vm in vms:
148
+ if vm.id == vm_id:
149
+ return vm
150
+ return None
151
+
152
+ def start_vm(self, vm_id: str) -> bool:
153
+ """Start a VM."""
154
+ response = self._rpc("StartVM", {"vmID": vm_id})
155
+ self._check_response(response, "Start VM")
156
+ return True
157
+
158
+ def stop_vm(self, vm_id: str) -> bool:
159
+ """Stop a VM."""
160
+ response = self._rpc("StopVM", {"vmID": vm_id})
161
+ self._check_response(response, "Stop VM")
162
+ return True
163
+
164
+ def suspend_vm(self, vm_id: str) -> bool:
165
+ """Suspend (pause) a running VM."""
166
+ response = self._rpc("SuspendVM", {"vmID": vm_id})
167
+ self._check_response(response, "Suspend VM")
168
+ return True
169
+
170
+ def start_vm_recovery(self, vm_id: str) -> bool:
171
+ """Start a VM in recovery mode."""
172
+ response = self._rpc("StartVMRecovery", {"vmID": vm_id})
173
+ self._check_response(response, "Start VM recovery")
174
+ return True
175
+
176
+ def clone_vm(self, vm_id: str, new_name: str) -> VM:
177
+ """Clone a VM."""
178
+ response = self._rpc("CloneVM", {"vmID": vm_id, "newName": new_name})
179
+ self._check_response(response, "Clone VM")
180
+ return VM.from_dict(response.get("newVM", {}))
181
+
182
+ def rename_vm(self, vm_id: str, new_name: str) -> bool:
183
+ """Rename a VM."""
184
+ response = self._rpc("RenameVM", {"vmID": vm_id, "newName": new_name})
185
+ self._check_response(response, "Rename VM")
186
+ return True
187
+
188
+ def delete_vm(self, vm_id: str) -> bool:
189
+ """Delete a VM."""
190
+ response = self._rpc("DeleteVM", {"vmID": vm_id})
191
+ self._check_response(response, "Delete VM")
192
+ return True
193
+
194
+ def get_vm_settings(self, vm_id: str) -> Dict:
195
+ """Get VM settings/configuration."""
196
+ response = self._rpc("GetVMSettings", {"vmID": vm_id})
197
+ self._check_response(response, "Get VM settings")
198
+ return response.get("configuration", {})
199
+
200
+ def update_vm_settings(
201
+ self,
202
+ vm_id: str,
203
+ cpu_count: Optional[int] = None,
204
+ memory_size: Optional[int] = None,
205
+ display_width: Optional[int] = None,
206
+ display_height: Optional[int] = None,
207
+ intent: Optional[str] = None,
208
+ ttl_seconds: Optional[int] = None,
209
+ ) -> bool:
210
+ """Update VM settings.
211
+
212
+ Args:
213
+ vm_id: VM identifier.
214
+ cpu_count: Number of virtual CPUs.
215
+ memory_size: Memory in MB.
216
+ display_width: Display width in pixels.
217
+ display_height: Display height in pixels.
218
+ intent: VM intent ("disposable", "persistent", "interactive", "ci").
219
+ ttl_seconds: TTL in seconds from now. Pass -1 to clear the TTL.
220
+ """
221
+ payload: Dict[str, Any] = {"vmID": vm_id}
222
+ if cpu_count is not None:
223
+ payload["cpuCount"] = cpu_count
224
+ if memory_size is not None:
225
+ payload["memorySize"] = memory_size
226
+ if display_width is not None:
227
+ payload["displayWidth"] = display_width
228
+ if display_height is not None:
229
+ payload["displayHeight"] = display_height
230
+ if intent is not None:
231
+ payload["intent"] = intent
232
+ if ttl_seconds is not None:
233
+ payload["ttlSeconds"] = ttl_seconds
234
+
235
+ response = self._rpc("UpdateVMSettings", payload)
236
+ self._check_response(response, "Update VM settings")
237
+ return True
238
+
239
+ # =========================================================================
240
+ # Snapshot Management
241
+ # =========================================================================
242
+
243
+ def list_snapshots(self, vm_id: str) -> List[Snapshot]:
244
+ """List snapshots for a VM."""
245
+ data = self._rpc("ListSnapshots", {"vmID": vm_id})
246
+ snapshots = data.get("snapshots", [])
247
+ return [Snapshot.from_dict(s) for s in snapshots]
248
+
249
+ def create_snapshot(
250
+ self,
251
+ vm_id: str,
252
+ name: str,
253
+ description: Optional[str] = None
254
+ ) -> Snapshot:
255
+ """Create a snapshot."""
256
+ payload = {"vmID": vm_id, "name": name}
257
+ if description:
258
+ payload["snapshotDescription"] = description
259
+
260
+ response = self._rpc("CreateSnapshot", payload)
261
+ self._check_response(response, "Create snapshot")
262
+ return Snapshot.from_dict(response.get("snapshot", {}))
263
+
264
+ def restore_snapshot(self, vm_id: str, snapshot_id: str) -> bool:
265
+ """Restore a VM to a snapshot."""
266
+ response = self._rpc("RestoreSnapshot", {"vmID": vm_id, "snapshotID": snapshot_id})
267
+ self._check_response(response, "Restore snapshot")
268
+ return True
269
+
270
+ def delete_snapshot(self, vm_id: str, snapshot_id: str) -> bool:
271
+ """Delete a snapshot."""
272
+ response = self._rpc("DeleteSnapshot", {"vmID": vm_id, "snapshotID": snapshot_id})
273
+ self._check_response(response, "Delete snapshot")
274
+ return True
275
+
276
+ # =========================================================================
277
+ # Command Execution
278
+ # =========================================================================
279
+
280
+ DEFAULT_EXEC_TIMEOUT = 300 # seconds (5 min); increase for large downloads
281
+
282
+ def exec_command(
283
+ self,
284
+ vm_id: str,
285
+ command: str,
286
+ ssh_user: Optional[str] = None,
287
+ ssh_password: Optional[str] = None,
288
+ timeout: int = DEFAULT_EXEC_TIMEOUT,
289
+ ) -> ExecResult:
290
+ """Execute a command on a VM via SSH. Use a longer timeout for long-running steps (e.g. curl + unzip)."""
291
+ payload = {
292
+ "vmID": vm_id,
293
+ "command": command,
294
+ "timeout": timeout,
295
+ }
296
+ if ssh_user:
297
+ payload["sshUser"] = ssh_user
298
+ if ssh_password:
299
+ payload["sshPassword"] = ssh_password
300
+
301
+ # HTTP request must stay open at least as long as the command (+ buffer) to avoid broken pipe
302
+ request_timeout = max(self.timeout, timeout + 60)
303
+ response = self._rpc("ExecCommand", payload, request_timeout=request_timeout)
304
+ return ExecResult.from_dict(response)
305
+
306
+ # =========================================================================
307
+ # Task Management
308
+ # =========================================================================
309
+
310
+ def get_tasks(self, include_completed: bool = False) -> List[Task]:
311
+ """Get tasks running on the node."""
312
+ data = self._rpc("GetTasks", {"includeCompleted": include_completed})
313
+ tasks = data.get("tasks", [])
314
+ return [Task.from_dict(t) for t in tasks]
315
+
316
+ # =========================================================================
317
+ # Image Management
318
+ # =========================================================================
319
+
320
+ def push_image(self, vm_id: str, image_name: str, insecure: bool = False) -> str:
321
+ """Push a VM as an OCI image."""
322
+ response = self._rpc("PushImage", {
323
+ "vmID": vm_id,
324
+ "imageName": image_name,
325
+ "insecure": insecure,
326
+ })
327
+ self._check_response(response, "Push image")
328
+ return response.get("taskID", "")
329
+
330
+ def list_ipsws(self) -> List[Dict]:
331
+ """List available IPSW files on the node."""
332
+ data = self._rpc("ListRemoteIPSWs")
333
+ return data.get("ipsws", [])
334
+
335
+ def list_oci_images(self) -> List[Dict]:
336
+ """List OCI images on the node."""
337
+ data = self._rpc("ListRemoteOCIImages")
338
+ return data.get("images", [])
339
+
340
+ def download_ipsw(self, url: str, name: str, version: str) -> bool:
341
+ """Download an IPSW on the remote node."""
342
+ response = self._rpc("DownloadIPSWOnNode", {
343
+ "requestID": str(uuid.uuid4()),
344
+ "downloadURL": url,
345
+ "expectedName": name,
346
+ "expectedVersion": version,
347
+ })
348
+ self._check_response(response, "Download IPSW")
349
+ return True
350
+
351
+ def pull_oci_image(
352
+ self,
353
+ image_reference: str,
354
+ username: Optional[str] = None,
355
+ password: Optional[str] = None,
356
+ ) -> bool:
357
+ """Pull an OCI image on the remote node."""
358
+ payload = {
359
+ "requestID": str(uuid.uuid4()),
360
+ "imageReference": image_reference,
361
+ }
362
+ if username and password:
363
+ payload["credentials"] = {"username": username, "password": password}
364
+
365
+ response = self._rpc("PullOCIImageOnNode", payload)
366
+ self._check_response(response, "Pull OCI image")
367
+ return True
368
+
369
+ def create_vm(
370
+ self,
371
+ name: str,
372
+ cpu_count: int = 4,
373
+ memory_mb: int = 8192,
374
+ disk_gb: int = 64,
375
+ ipsw_path: Optional[str] = None,
376
+ oci_image: Optional[str] = None,
377
+ ) -> str:
378
+ """Create a VM on the remote node."""
379
+ payload = {
380
+ "requestID": str(uuid.uuid4()),
381
+ "name": name,
382
+ "cpuCount": cpu_count,
383
+ "memorySizeMB": memory_mb,
384
+ "diskSizeGB": disk_gb,
385
+ }
386
+ if ipsw_path:
387
+ payload["ipswPath"] = ipsw_path
388
+ if oci_image:
389
+ payload["ociImageReference"] = oci_image
390
+
391
+ response = self._rpc("CreateVMOnNode", payload)
392
+ self._check_response(response, "Create VM")
393
+ return response.get("vmID", "")
394
+
395
+ # =========================================================================
396
+ # Fleet Overview
397
+ # =========================================================================
398
+
399
+ def get_fleet_overview(self) -> Dict:
400
+ """Get aggregated fleet status."""
401
+ return self._rpc("GetFleetOverview")
402
+
403
+ def get_fleet_events(self, limit: int = 100, event_type: Optional[str] = None) -> List[Dict]:
404
+ """Get fleet activity events."""
405
+ payload = {"limit": limit}
406
+ if event_type:
407
+ payload["eventType"] = event_type
408
+
409
+ data = self._rpc("GetFleetEvents", payload)
410
+ return data.get("events", [])
411
+
412
+ def get_remote_resources(self) -> Dict:
413
+ """Get available resources for VM creation."""
414
+ return self._rpc("GetRemoteResources")
415
+
416
+ # =========================================================================
417
+ # Pairing
418
+ # =========================================================================
419
+
420
+ @staticmethod
421
+ def pair(
422
+ host: str,
423
+ code: str,
424
+ name: Optional[str] = None,
425
+ port: int = 9473,
426
+ ) -> Dict:
427
+ """
428
+ Pair this SDK client with a Fleet node using a 6-digit pairing code.
429
+
430
+ The pairing code is displayed in the CiderStack app UI.
431
+ On success, returns credentials that can be used to create a FleetClient.
432
+
433
+ Args:
434
+ host: IP address or hostname of the Fleet node
435
+ code: 6-digit pairing code from the CiderStack UI
436
+ name: Display name for this SDK client (auto-generated if omitted)
437
+ port: Port number (default: 9473)
438
+
439
+ Returns:
440
+ Dict with keys: node_id, private_key_hex, public_key_hex,
441
+ responder_node_id, responder_name
442
+
443
+ Example:
444
+ creds = FleetClient.pair("192.168.1.100", "123456", "my-ci-script")
445
+ # Store creds["node_id"] securely for future use
446
+ client = FleetClient("192.168.1.100", node_id=creds["node_id"])
447
+ """
448
+ from cryptography.hazmat.primitives.asymmetric import ec
449
+ from cryptography.hazmat.primitives import serialization
450
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
451
+ import base64
452
+
453
+ # Generate a new node identity
454
+ node_id = str(uuid.uuid4()).upper()
455
+ client_name = name or f"sdk-{node_id[:8].lower()}"
456
+
457
+ # Generate P-256 keypair
458
+ private_key = ec.generate_private_key(ec.SECP256R1())
459
+ public_key_bytes = private_key.public_key().public_bytes(
460
+ Encoding.X962, PublicFormat.UncompressedPoint
461
+ )
462
+ private_key_bytes = private_key.private_numbers().private_value.to_bytes(32, "big")
463
+
464
+ public_key_hex = public_key_bytes.hex()
465
+ private_key_hex = private_key_bytes.hex()
466
+ public_key_b64 = base64.b64encode(public_key_bytes).decode()
467
+
468
+ # Send pairing request — ensure code is always a string
469
+ url = f"http://{host}:{port}/rpc/RequestPairing"
470
+ payload = {
471
+ "requestID": str(uuid.uuid4()).upper(),
472
+ "requesterNodeID": {"value": node_id},
473
+ "requesterName": client_name,
474
+ "requesterIP": "0.0.0.0",
475
+ "publicKey": public_key_b64,
476
+ "code": str(code),
477
+ }
478
+
479
+ try:
480
+ response = requests.post(
481
+ url,
482
+ json=payload,
483
+ timeout=30,
484
+ headers={"Content-Type": "application/json"},
485
+ )
486
+ response.raise_for_status()
487
+ result = response.json()
488
+ except requests.exceptions.RequestException as e:
489
+ raise FleetError(f"Pairing request failed: {e}")
490
+
491
+ if not result.get("accepted"):
492
+ error_msg = result.get("errorMessage", "Pairing rejected")
493
+ raise FleetError(f"Pairing failed: {error_msg}")
494
+
495
+ responder_node_id = result.get("responderNodeID", {})
496
+
497
+ return {
498
+ "node_id": node_id,
499
+ "private_key_hex": private_key_hex,
500
+ "public_key_hex": public_key_hex,
501
+ "responder_node_id": responder_node_id.get("value", ""),
502
+ "responder_name": result.get("responderName", ""),
503
+ }
504
+
505
+ # =========================================================================
506
+ # Template Management
507
+ # =========================================================================
508
+
509
+ def list_templates(self) -> List[Template]:
510
+ """List all VM templates."""
511
+ data = self._rpc("ListTemplates")
512
+ templates = data.get("templates", [])
513
+ return [Template.from_dict(t) for t in templates]
514
+
515
+ def get_template(self, template_id: str) -> Optional[Template]:
516
+ """Get a template by ID."""
517
+ response = self._rpc("GetTemplate", {"templateID": template_id})
518
+ if not response.get("success") or not response.get("template"):
519
+ return None
520
+ return Template.from_dict(response["template"])
521
+
522
+ def get_template_by_name(self, name: str) -> Optional[Template]:
523
+ """Get a template by name."""
524
+ response = self._rpc("GetTemplate", {"templateName": name})
525
+ if not response.get("success") or not response.get("template"):
526
+ return None
527
+ return Template.from_dict(response["template"])
528
+
529
+ def create_template_from_vm(
530
+ self,
531
+ vm_id: str,
532
+ name: str,
533
+ description: Optional[str] = None,
534
+ category: Optional[str] = None,
535
+ keep_original_vm: bool = True,
536
+ ) -> Template:
537
+ """Create a template from an existing VM."""
538
+ payload: Dict[str, Any] = {"vmID": vm_id, "name": name}
539
+ if description is not None:
540
+ payload["description"] = description
541
+ if category is not None:
542
+ payload["category"] = category
543
+ payload["keepOriginalVM"] = keep_original_vm
544
+
545
+ response = self._rpc("CreateTemplateFromVM", payload)
546
+ self._check_response(response, "Create template from VM")
547
+ return Template.from_dict(response.get("template", {}))
548
+
549
+ def delete_template(self, template_id: str) -> bool:
550
+ """Delete a template."""
551
+ response = self._rpc("DeleteTemplate", {"templateID": template_id})
552
+ self._check_response(response, "Delete template")
553
+ return True
554
+
555
+ # =========================================================================
556
+ # API Token Management
557
+ # =========================================================================
558
+
559
+ def generate_api_token(
560
+ self,
561
+ name: str,
562
+ permissions: str = "fullAccess",
563
+ ) -> APIToken:
564
+ """Generate a new API token for SDK/script access."""
565
+ response = self._rpc("GenerateAPIToken", {
566
+ "name": name,
567
+ "permissions": permissions,
568
+ })
569
+ if not response.get("success"):
570
+ raise FleetError(response.get("error", "Failed to generate API token"), response)
571
+ return APIToken.from_dict(response.get("token", {}))
572
+
573
+ def revoke_api_token(self, token_id: str) -> bool:
574
+ """Revoke an API token by its ID."""
575
+ response = self._rpc("RevokeAPIToken", {"tokenID": token_id})
576
+ return response.get("revoked", False)
577
+
578
+ def list_api_tokens(self) -> List[APITokenSummary]:
579
+ """List all API tokens (token strings are redacted)."""
580
+ response = self._rpc("ListAPITokens")
581
+ tokens = response.get("tokens", [])
582
+ return [APITokenSummary.from_dict(t) for t in tokens]
583
+
584
+ # =========================================================================
585
+ # Shared Folder Management
586
+ # =========================================================================
587
+
588
+ def list_shared_folders(self, vm_id: str) -> List[Dict]:
589
+ """List shared folders for a VM."""
590
+ response = self._rpc("ListSharedFolders", {"vmID": vm_id})
591
+ self._check_response(response, "List shared folders")
592
+ return response.get("folders", [])
593
+
594
+ def add_shared_folder(
595
+ self,
596
+ vm_id: str,
597
+ name: str,
598
+ host_path: str,
599
+ read_only: bool = False,
600
+ mount_tag: Optional[str] = None,
601
+ ) -> bool:
602
+ """Add a shared folder to a VM."""
603
+ payload: Dict[str, Any] = {
604
+ "vmID": vm_id,
605
+ "name": name,
606
+ "hostPath": host_path,
607
+ "readOnly": read_only,
608
+ }
609
+ if mount_tag is not None:
610
+ payload["mountTag"] = mount_tag
611
+
612
+ response = self._rpc("AddSharedFolder", payload)
613
+ self._check_response(response, "Add shared folder")
614
+ return True
615
+
616
+ def remove_shared_folder(self, vm_id: str, name: str) -> bool:
617
+ """Remove a shared folder from a VM."""
618
+ response = self._rpc("RemoveSharedFolder", {"vmID": vm_id, "name": name})
619
+ self._check_response(response, "Remove shared folder")
620
+ return True
621
+
622
+ def set_shared_folder_enabled(self, vm_id: str, name: str, enabled: bool) -> bool:
623
+ """Enable or disable a shared folder."""
624
+ response = self._rpc("SetSharedFolderEnabled", {
625
+ "vmID": vm_id,
626
+ "name": name,
627
+ "enabled": enabled,
628
+ })
629
+ self._check_response(response, "Set shared folder enabled")
630
+ return True
631
+
632
+ # =========================================================================
633
+ # Fleet Utilities
634
+ # =========================================================================
635
+
636
+ def unpair(self, node_id: str) -> bool:
637
+ """Unpair a node from the fleet."""
638
+ response = self._rpc("Unpair", {"nodeID": node_id})
639
+ self._check_response(response, "Unpair")
640
+ return True
641
+
642
+ def cleanup_vms(self, force: bool = False) -> int:
643
+ """Clean up retired VMs whose TTL has passed."""
644
+ response = self._rpc("CleanupVMs", {"force": force})
645
+ self._check_response(response, "Cleanup VMs")
646
+ return response.get("cleanedCount", 0)
647
+
648
+ def get_ipsw_info(self, path: str) -> Dict:
649
+ """Get metadata about an IPSW file on the node."""
650
+ return self._rpc("GetIPSWInfo", {"path": path})
@@ -0,0 +1,286 @@
1
+ """
2
+ CiderStack Fleet SDK Types
3
+ """
4
+
5
+ from enum import Enum
6
+ from dataclasses import dataclass
7
+ from typing import Optional, List
8
+ from datetime import datetime
9
+
10
+
11
+ class VMState(str, Enum):
12
+ """VM state enumeration."""
13
+ STOPPED = "stopped"
14
+ STARTING = "starting"
15
+ RUNNING = "running"
16
+ PAUSED = "paused"
17
+ STOPPING = "stopping"
18
+ ERROR = "error"
19
+
20
+
21
+ class TaskStatus(str, Enum):
22
+ """Task status enumeration."""
23
+ PENDING = "pending"
24
+ RUNNING = "running"
25
+ COMPLETED = "completed"
26
+ FAILED = "failed"
27
+ CANCELLED = "cancelled"
28
+
29
+
30
+ class NodeRole(str, Enum):
31
+ """Node role enumeration."""
32
+ WORKER = "worker"
33
+ MANAGER = "manager"
34
+ OPERATOR = "operator"
35
+
36
+
37
+ @dataclass
38
+ class NodeInfo:
39
+ """Information about a Fleet node."""
40
+ node_id: str
41
+ name: str
42
+ hostname: str
43
+ ip_address: str
44
+ port: int
45
+ machine_model: str
46
+ os_version: str
47
+ cpu_cores: int
48
+ total_memory_gb: int
49
+ fleet_version: str
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> "NodeInfo":
53
+ return cls(
54
+ node_id=data.get("nodeID", ""),
55
+ name=data.get("name", ""),
56
+ hostname=data.get("hostname", ""),
57
+ ip_address=data.get("ipAddress", ""),
58
+ port=data.get("port", 9473),
59
+ machine_model=data.get("machineModel", ""),
60
+ os_version=data.get("osVersion", ""),
61
+ cpu_cores=data.get("cpuCores", 0),
62
+ total_memory_gb=data.get("totalMemoryGB", 0),
63
+ fleet_version=data.get("fleetVersion", ""),
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class NodeStats:
69
+ """Real-time statistics for a node."""
70
+ node_id: str
71
+ timestamp: datetime
72
+ cpu_usage_percent: float
73
+ memory_used_gb: float
74
+ memory_total_gb: float
75
+ disk_used_gb: float
76
+ disk_total_gb: float
77
+ running_vm_count: int
78
+ total_vm_count: int
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: dict) -> "NodeStats":
82
+ return cls(
83
+ node_id=data.get("nodeID", ""),
84
+ timestamp=datetime.fromisoformat(data.get("timestamp", "").replace("Z", "+00:00")) if data.get("timestamp") else datetime.now(),
85
+ cpu_usage_percent=data.get("cpuUsagePercent", 0.0),
86
+ memory_used_gb=data.get("memoryUsedGB", 0.0),
87
+ memory_total_gb=data.get("memoryTotalGB", 0.0),
88
+ disk_used_gb=data.get("diskUsedGB", 0.0),
89
+ disk_total_gb=data.get("diskTotalGB", 0.0),
90
+ running_vm_count=data.get("runningVMCount", 0),
91
+ total_vm_count=data.get("totalVMCount", 0),
92
+ )
93
+
94
+
95
+ @dataclass
96
+ class VM:
97
+ """A virtual machine."""
98
+ id: str
99
+ name: str
100
+ state: VMState
101
+ cpu_count: int
102
+ memory_mb: int
103
+ disk_size_gb: Optional[int]
104
+ os_version: Optional[str]
105
+ ip_address: Optional[str]
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: dict) -> "VM":
109
+ return cls(
110
+ id=data.get("id", ""),
111
+ name=data.get("name", ""),
112
+ state=VMState(data.get("state", "stopped")),
113
+ cpu_count=data.get("cpuCount", 0),
114
+ memory_mb=data.get("memoryMB", 0),
115
+ disk_size_gb=data.get("diskSizeGB"),
116
+ os_version=data.get("osVersion"),
117
+ ip_address=data.get("ipAddress"),
118
+ )
119
+
120
+
121
+ @dataclass
122
+ class Snapshot:
123
+ """A VM snapshot."""
124
+ id: str
125
+ name: str
126
+ description: Optional[str]
127
+ created_at: datetime
128
+ size_bytes: int
129
+
130
+ @classmethod
131
+ def from_dict(cls, data: dict) -> "Snapshot":
132
+ return cls(
133
+ id=data.get("id", ""),
134
+ name=data.get("name", ""),
135
+ description=data.get("description"),
136
+ created_at=datetime.fromisoformat(data.get("createdAt", "").replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
137
+ size_bytes=data.get("sizeBytes", 0),
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class Task:
143
+ """A background task."""
144
+ id: str
145
+ type: str
146
+ title: str
147
+ status: TaskStatus
148
+ progress: float
149
+ progress_text: Optional[str]
150
+ started_at: datetime
151
+ completed_at: Optional[datetime]
152
+ vm_id: Optional[str]
153
+ vm_name: Optional[str]
154
+ error_message: Optional[str]
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: dict) -> "Task":
158
+ completed_at = None
159
+ if data.get("completedAt"):
160
+ completed_at = datetime.fromisoformat(data["completedAt"].replace("Z", "+00:00"))
161
+
162
+ return cls(
163
+ id=data.get("id", ""),
164
+ type=data.get("type", ""),
165
+ title=data.get("title", ""),
166
+ status=TaskStatus(data.get("status", "pending")),
167
+ progress=data.get("progress", 0.0),
168
+ progress_text=data.get("progressText"),
169
+ started_at=datetime.fromisoformat(data.get("startedAt", "").replace("Z", "+00:00")) if data.get("startedAt") else datetime.now(),
170
+ completed_at=completed_at,
171
+ vm_id=data.get("vmID"),
172
+ vm_name=data.get("vmName"),
173
+ error_message=data.get("errorMessage"),
174
+ )
175
+
176
+
177
+ @dataclass
178
+ class Template:
179
+ """A VM template."""
180
+ id: str
181
+ name: str
182
+ description: Optional[str]
183
+ macos_version: Optional[str]
184
+ icon_name: str
185
+ category: str
186
+ source_type: str
187
+ created_at: datetime
188
+ modified_at: datetime
189
+ disk_size_bytes: Optional[int]
190
+ default_cpu: int
191
+ default_memory_mb: int
192
+ default_disk_gb: int
193
+
194
+ @classmethod
195
+ def from_dict(cls, data: dict) -> "Template":
196
+ return cls(
197
+ id=data.get("id", ""),
198
+ name=data.get("name", ""),
199
+ description=data.get("description"),
200
+ macos_version=data.get("macOSVersion"),
201
+ icon_name=data.get("iconName", ""),
202
+ category=data.get("category", ""),
203
+ source_type=data.get("sourceType", ""),
204
+ created_at=datetime.fromisoformat(data.get("createdAt", "").replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
205
+ modified_at=datetime.fromisoformat(data.get("modifiedAt", "").replace("Z", "+00:00")) if data.get("modifiedAt") else datetime.now(),
206
+ disk_size_bytes=data.get("diskSizeBytes"),
207
+ default_cpu=data.get("defaultCPU", 0),
208
+ default_memory_mb=data.get("defaultMemoryMB", 0),
209
+ default_disk_gb=data.get("defaultDiskGB", 0),
210
+ )
211
+
212
+
213
+ class APITokenPermissions(str, Enum):
214
+ """API token permission level."""
215
+ READ_ONLY = "readOnly"
216
+ FULL_ACCESS = "fullAccess"
217
+
218
+
219
+ @dataclass
220
+ class APIToken:
221
+ """An API token for SDK/script access."""
222
+ id: str
223
+ name: str
224
+ token: str
225
+ permissions: APITokenPermissions
226
+ created_at: datetime
227
+ last_used_at: Optional[datetime]
228
+
229
+ @classmethod
230
+ def from_dict(cls, data: dict) -> "APIToken":
231
+ last_used = None
232
+ if data.get("lastUsedAt"):
233
+ last_used = datetime.fromisoformat(str(data["lastUsedAt"]).replace("Z", "+00:00"))
234
+ return cls(
235
+ id=data.get("id", ""),
236
+ name=data.get("name", ""),
237
+ token=data.get("token", ""),
238
+ permissions=APITokenPermissions(data.get("permissions", "fullAccess")),
239
+ created_at=datetime.fromisoformat(str(data.get("createdAt", "")).replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
240
+ last_used_at=last_used,
241
+ )
242
+
243
+
244
+ @dataclass
245
+ class APITokenSummary:
246
+ """Summary of an API token (token string redacted)."""
247
+ id: str
248
+ name: str
249
+ token_prefix: str
250
+ permissions: APITokenPermissions
251
+ created_at: datetime
252
+ last_used_at: Optional[datetime]
253
+
254
+ @classmethod
255
+ def from_dict(cls, data: dict) -> "APITokenSummary":
256
+ last_used = None
257
+ if data.get("lastUsedAt"):
258
+ last_used = datetime.fromisoformat(str(data["lastUsedAt"]).replace("Z", "+00:00"))
259
+ return cls(
260
+ id=data.get("id", ""),
261
+ name=data.get("name", ""),
262
+ token_prefix=data.get("tokenPrefix", ""),
263
+ permissions=APITokenPermissions(data.get("permissions", "fullAccess")),
264
+ created_at=datetime.fromisoformat(str(data.get("createdAt", "")).replace("Z", "+00:00")) if data.get("createdAt") else datetime.now(),
265
+ last_used_at=last_used,
266
+ )
267
+
268
+
269
+ @dataclass
270
+ class ExecResult:
271
+ """Result of executing a command on a VM."""
272
+ success: bool
273
+ exit_code: int
274
+ stdout: str
275
+ stderr: str
276
+ duration: float
277
+
278
+ @classmethod
279
+ def from_dict(cls, data: dict) -> "ExecResult":
280
+ return cls(
281
+ success=data.get("success", False),
282
+ exit_code=data.get("exitCode", -1),
283
+ stdout=data.get("stdout", ""),
284
+ stderr=data.get("stderr", ""),
285
+ duration=data.get("duration", 0.0),
286
+ )
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciderstack
3
+ Version: 0.1.0
4
+ Summary: CiderStack Fleet SDK for Python
5
+ Home-page: https://ciderstack.io
6
+ Author: CiderStack
7
+ Author-email: support@ciderstack.io
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: requests>=2.28.0
21
+ Provides-Extra: pairing
22
+ Requires-Dist: cryptography>=41.0.0; extra == "pairing"
23
+ Dynamic: author
24
+ Dynamic: author-email
25
+ Dynamic: classifier
26
+ Dynamic: home-page
27
+ Dynamic: provides-extra
28
+ Dynamic: requires-dist
29
+ Dynamic: requires-python
30
+ Dynamic: summary
@@ -0,0 +1,10 @@
1
+ README.md
2
+ setup.py
3
+ ciderstack/__init__.py
4
+ ciderstack/client.py
5
+ ciderstack/types.py
6
+ ciderstack.egg-info/PKG-INFO
7
+ ciderstack.egg-info/SOURCES.txt
8
+ ciderstack.egg-info/dependency_links.txt
9
+ ciderstack.egg-info/requires.txt
10
+ ciderstack.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ requests>=2.28.0
2
+
3
+ [pairing]
4
+ cryptography>=41.0.0
@@ -0,0 +1 @@
1
+ ciderstack
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="ciderstack",
5
+ version="0.1.0",
6
+ description="CiderStack Fleet SDK for Python",
7
+ author="CiderStack",
8
+ author_email="support@ciderstack.io",
9
+ url="https://ciderstack.io",
10
+ packages=find_packages(),
11
+ install_requires=[
12
+ "requests>=2.28.0",
13
+ ],
14
+ extras_require={
15
+ "pairing": ["cryptography>=41.0.0"],
16
+ },
17
+ python_requires=">=3.8",
18
+ classifiers=[
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.8",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ ],
31
+ )