cloudex 0.1.11__py3-none-any.whl

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.
cloudex/__init__.py ADDED
@@ -0,0 +1,441 @@
1
+ """The Cloudex SDK: create, fork and drive sandboxes from Python.
2
+
3
+ It talks to the /v1 API that `cloudex deploy` set up in your Cloudflare
4
+ account, using only the standard library.
5
+
6
+ from cloudex import Cloudex
7
+
8
+ cx = Cloudex() # the deployment saved by `cloudex login`, or $CLOUDEX_API_URL and $CLOUDEX_API_KEY
9
+ box = cx.create(name="web")
10
+ print(box.exec("uname -a").stdout)
11
+ forks = box.fork(count=3, name="try")
12
+
13
+ agent = box.prompt("make the tests pass") # Claude Code, with no terminal
14
+ for event in agent.events():
15
+ print(event["type"])
16
+ print(agent.last_message)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import shlex
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Iterator, List, Optional, Sequence, Union
30
+
31
+ __all__ = ["Agent", "Cloudex", "CloudexError", "Desktop", "ExecResult", "Health", "ProviderKey", "Sandbox", "Settings"]
32
+ __version__ = "0.1.11"
33
+
34
+
35
+ class CloudexError(Exception):
36
+ """An error from the API, with its HTTP status (0 when there was no request)."""
37
+
38
+ def __init__(self, status: int, message: str):
39
+ super().__init__(message)
40
+ self.status = status
41
+ self.message = message
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ExecResult:
46
+ exit_code: int
47
+ stdout: str # each stream is cut at 1 MiB
48
+ stderr: str
49
+ timed_out: bool
50
+ ms: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Desktop:
55
+ url: str # the viewer, on the preview domain; anyone with it can use the desktop
56
+ port: int
57
+ path: str
58
+ size: str
59
+ installed: bool # this call installed it (the first start takes a minute or two)
60
+ started: bool # this call started it, rather than finding it running
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ProviderKey:
65
+ provider: str # anthropic or openai
66
+ hint: str # the key's last 4 characters; the key itself is never returned
67
+ updated_at: int # milliseconds since the epoch
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Settings:
72
+ """A sandbox's settings. Values and file contents never come back."""
73
+
74
+ env: List[str]
75
+ files: List[str] # as ~/path
76
+ repo: Optional[Dict[str, Any]] # url, dir, branch
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Health:
81
+ ok: bool
82
+ version: str
83
+ preview_zone: Optional[str]
84
+ backups: bool
85
+
86
+
87
+ def _drop_none(**fields: Any) -> Dict[str, Any]:
88
+ return {k: v for k, v in fields.items() if v is not None}
89
+
90
+
91
+ def _saved_deployment(config_dir: Optional[str]) -> Dict[str, Any]:
92
+ # The CLI keeps its deployment in ~/.config/cloudex/config.json.
93
+ path = Path(config_dir) if config_dir else Path.home() / ".config" / "cloudex"
94
+ try:
95
+ return json.loads((path / "config.json").read_text())
96
+ except (OSError, ValueError):
97
+ return {}
98
+
99
+
100
+ class Cloudex:
101
+ """A Cloudex deployment.
102
+
103
+ The URL and key come from the arguments, then $CLOUDEX_API_URL and
104
+ $CLOUDEX_API_KEY, then the deployment saved by `cloudex deploy` or
105
+ `cloudex login`, the same order as the CLI. timeout is in seconds per
106
+ request; by default there's none, since starting or forking can take
107
+ minutes the first time on a host.
108
+ """
109
+
110
+ def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None, timeout: Optional[float] = None):
111
+ url = api_url or os.environ.get("CLOUDEX_API_URL")
112
+ key = api_key or os.environ.get("CLOUDEX_API_KEY")
113
+ if not url or not key:
114
+ saved = _saved_deployment(os.environ.get("CLOUDEX_CONFIG_DIR"))
115
+ url = url or saved.get("apiUrl")
116
+ key = key or saved.get("apiKey")
117
+ if not url:
118
+ raise CloudexError(
119
+ 0,
120
+ "no Cloudex deployment: pass api_url and api_key, set CLOUDEX_API_URL and CLOUDEX_API_KEY, or run cloudex login",
121
+ )
122
+ self.api_url = url.rstrip("/")
123
+ self._key = key or ""
124
+ self._timeout = timeout
125
+
126
+ def __repr__(self) -> str:
127
+ return f"Cloudex({self.api_url!r})"
128
+
129
+ def health(self) -> Health:
130
+ """The deployment's version and features."""
131
+ h = self._call("GET", "/v1/health")
132
+ return Health(ok=h["ok"], version=h["version"], preview_zone=h.get("previewZone"), backups=bool(h.get("backups")))
133
+
134
+ def list(self) -> List["Sandbox"]:
135
+ """All sandboxes, newest first."""
136
+ return [Sandbox(self, info) for info in self._call("GET", "/v1/sandboxes")]
137
+
138
+ def get(self, ref: str) -> "Sandbox":
139
+ """A sandbox by id (cx-xxxxxx) or name."""
140
+ return Sandbox(self, self._call("GET", _sandbox_path(ref)))
141
+
142
+ def create(
143
+ self,
144
+ name: Optional[str] = None,
145
+ size: Optional[str] = None,
146
+ idle_minutes: Optional[int] = None,
147
+ env: Optional[Dict[str, str]] = None,
148
+ files: Optional[Dict[str, str]] = None,
149
+ repo: Union[str, Dict[str, str], None] = None,
150
+ ) -> "Sandbox":
151
+ """Creates a sandbox and returns once it's running, usually in about 10 seconds.
152
+
153
+ size is standard-1 to standard-4 (default standard-2). idle_minutes
154
+ (default 30) is how long without activity before it saves and stops;
155
+ 0 waits for the 6-hour platform limit. env and files (paths in the home
156
+ directory, such as ~/.npmrc) are applied at every start; repo, an
157
+ https:// URL with #branch if wanted, or {"url", "dir", "branch"}, is
158
+ cloned into the home directory. A failed clone leaves a notice.
159
+ """
160
+ body = _drop_none(name=name, size=size, idleMinutes=idle_minutes, env=env, files=files, repo=repo)
161
+ return Sandbox(self, self._call("POST", "/v1/sandboxes", body))
162
+
163
+ def list_keys(self) -> List[ProviderKey]:
164
+ """The AI provider keys sandboxes use; the keys themselves are never returned."""
165
+ return [_provider_key(k) for k in self._call("GET", "/v1/keys")]
166
+
167
+ def set_key(self, provider: str, key: str) -> ProviderKey:
168
+ """Sets a provider's key ("anthropic" or "openai").
169
+
170
+ Sandboxes started from then on get a placeholder, and the real key is
171
+ added to their HTTPS calls to the provider on the way out, so it never
172
+ enters a sandbox, its saves, backups or forks. A changed key reaches
173
+ running sandboxes within a minute.
174
+ """
175
+ return _provider_key(self._call("PUT", "/v1/keys/" + urllib.parse.quote(provider, safe=""), {"key": key}))
176
+
177
+ def delete_key(self, provider: str) -> None:
178
+ self._call("DELETE", "/v1/keys/" + urllib.parse.quote(provider, safe=""))
179
+
180
+ def _call(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
181
+ data = None if body is None else json.dumps(body).encode()
182
+ req = urllib.request.Request(self.api_url + path, data=data, method=method)
183
+ req.add_header("Authorization", f"Bearer {self._key}")
184
+ req.add_header("User-Agent", f"cloudex-python/{__version__}")
185
+ if data is not None:
186
+ req.add_header("Content-Type", "application/json")
187
+ try:
188
+ with urllib.request.urlopen(req, timeout=self._timeout) as res:
189
+ text = res.read().decode()
190
+ except urllib.error.HTTPError as e:
191
+ text = e.read().decode(errors="replace")
192
+ try:
193
+ message = json.loads(text)["error"]
194
+ except (ValueError, KeyError, TypeError):
195
+ message = text.strip() or e.reason
196
+ raise CloudexError(e.code, str(message)) from None
197
+ except urllib.error.URLError as e:
198
+ raise CloudexError(0, f"can't reach {self.api_url}: {e.reason}") from None
199
+ return json.loads(text) if text else None
200
+
201
+
202
+ def _provider_key(k: Dict[str, Any]) -> ProviderKey:
203
+ return ProviderKey(provider=k["provider"], hint=k["hint"], updated_at=k["updatedAt"])
204
+
205
+
206
+ def _settings(s: Dict[str, Any]) -> Settings:
207
+ return Settings(env=s["env"], files=s["files"], repo=s.get("repo"))
208
+
209
+
210
+ def _sandbox_path(ref: str) -> str:
211
+ return "/v1/sandboxes/" + urllib.parse.quote(ref, safe="")
212
+
213
+
214
+ class Sandbox:
215
+ """A sandbox. Its fields are as of the last call that returned it; refresh() updates them.
216
+
217
+ info holds everything the API reports (camelCase keys, times in
218
+ milliseconds since the epoch); id, name and state are the common ones.
219
+ """
220
+
221
+ def __init__(self, client: Cloudex, info: Dict[str, Any]):
222
+ self._client = client
223
+ self.info = info
224
+
225
+ @property
226
+ def id(self) -> str:
227
+ return self.info["id"]
228
+
229
+ @property
230
+ def name(self) -> Optional[str]:
231
+ return self.info.get("name")
232
+
233
+ @property
234
+ def state(self) -> str:
235
+ return self.info["state"]
236
+
237
+ @property
238
+ def forked_from(self) -> Optional[str]:
239
+ return self.info.get("forkedFrom")
240
+
241
+ @property
242
+ def provider_keys(self) -> List[str]:
243
+ """Providers whose keys this run uses without holding them (see Cloudex.set_key)."""
244
+ return self.info.get("providerKeys") or []
245
+
246
+ def __repr__(self) -> str:
247
+ label = f" {self.name!r}" if self.name else ""
248
+ return f"<Sandbox {self.id}{label} {self.state}>"
249
+
250
+ def _path(self, suffix: str = "") -> str:
251
+ return _sandbox_path(self.id) + suffix
252
+
253
+ def _update(self, method: str, suffix: str, body: Optional[Dict[str, Any]] = None) -> "Sandbox":
254
+ self.info = self._client._call(method, self._path(suffix), body)
255
+ return self
256
+
257
+ def refresh(self) -> "Sandbox":
258
+ return self._update("GET", "")
259
+
260
+ def stop(self) -> "Sandbox":
261
+ """Saves the disk, backs up /home and stops."""
262
+ return self._update("POST", "/stop")
263
+
264
+ def resume(self, from_backup: bool = False) -> "Sandbox":
265
+ """Starts from the save, or with from_backup, from a fresh image with /home restored."""
266
+ return self._update("POST", "/resume", {"fromBackup": True} if from_backup else None)
267
+
268
+ def delete(self) -> None:
269
+ """Deletes the sandbox and its backups."""
270
+ self._client._call("DELETE", self._path())
271
+
272
+ def exec(
273
+ self,
274
+ command: Union[str, Sequence[str]],
275
+ root: bool = False,
276
+ cwd: Optional[str] = None,
277
+ timeout_sec: Optional[int] = None,
278
+ ) -> ExecResult:
279
+ """Runs a command with `bash -lc` and waits for it (default limit 600 s, at most 3600).
280
+
281
+ A string is run as shell code; a list is quoted, one argument per
282
+ element. A failing command doesn't raise: check exit_code.
283
+ """
284
+ cmd = command if isinstance(command, str) else shlex.join(command)
285
+ body = _drop_none(cmd=cmd, root=root or None, cwd=cwd, timeoutSec=timeout_sec)
286
+ r = self._client._call("POST", self._path("/exec"), body)
287
+ return ExecResult(exit_code=r["exitCode"], stdout=r["stdout"], stderr=r["stderr"], timed_out=r["timedOut"], ms=r["ms"])
288
+
289
+ def fork(
290
+ self,
291
+ count: int = 1,
292
+ name: Optional[str] = None,
293
+ size: Optional[str] = None,
294
+ idle_minutes: Optional[int] = None,
295
+ ) -> List["Sandbox"]:
296
+ """Makes sandboxes (1 to 10) that start from a copy of this one's whole disk.
297
+
298
+ A running sandbox is saved first, in seconds, and keeps running. With
299
+ count above 1, name is the start of <name>-1, <name>-2 and so on.
300
+ Size and idle minutes default to this sandbox's. Forks that failed to
301
+ start come back in state "error"; resume() tries again.
302
+ """
303
+ body = _drop_none(name=name, count=count, size=size, idleMinutes=idle_minutes)
304
+ return [Sandbox(self._client, info) for info in self._client._call("POST", self._path("/fork"), body)]
305
+
306
+ def host(self, port: int) -> str:
307
+ """An HTTPS link to a port, on the deployment's preview domain. Anyone with it can use it."""
308
+ return self._client._call("POST", self._path("/hosts"), {"port": port})["url"]
309
+
310
+ def desktop(self, size: Optional[str] = None) -> Desktop:
311
+ """Starts the desktop (Chrome and a terminal), or resizes a running one, and links to its viewer.
312
+
313
+ The first start installs it, which takes a minute or two.
314
+ """
315
+ d = self._client._call("POST", self._path("/desktop"), _drop_none(size=size))
316
+ return Desktop(
317
+ url=f"{self.host(d['port'])}/{d['path']}",
318
+ port=d["port"],
319
+ path=d["path"],
320
+ size=d["size"],
321
+ installed=d["installed"],
322
+ started=d["started"],
323
+ )
324
+
325
+ def stop_desktop(self) -> None:
326
+ self._client._call("DELETE", self._path("/desktop"))
327
+
328
+ def settings(self) -> Settings:
329
+ """The sandbox's environment variable names, files and repo."""
330
+ return _settings(self._client._call("GET", self._path("/settings")))
331
+
332
+ def update_settings(
333
+ self, env: Optional[Dict[str, Optional[str]]] = None, files: Optional[Dict[str, Optional[str]]] = None
334
+ ) -> Settings:
335
+ """Sets, or with None removes, environment variables and files.
336
+
337
+ A running sandbox gets the change at once, in new shells; a stopped one
338
+ when it next starts.
339
+ """
340
+ return _settings(self._client._call("PATCH", self._path("/settings"), _drop_none(env=env, files=files)))
341
+
342
+ def prompt(
343
+ self,
344
+ prompt: str,
345
+ agent: Optional[str] = None,
346
+ cwd: Optional[str] = None,
347
+ model: Optional[str] = None,
348
+ ) -> "Agent":
349
+ """Starts Claude Code (or agent="codex") on a task, with no terminal, and returns at once.
350
+
351
+ Follow it with events() or wait(), and continue the conversation with
352
+ send(). cwd defaults to the home directory, model to the agent's own.
353
+ With provider keys set, it needs no sign-in.
354
+ """
355
+ info = self._client._call("POST", self._path("/agents"), _drop_none(prompt=prompt, agent=agent, cwd=cwd, model=model))
356
+ return Agent(self._client, self._path("/agents/" + info["id"]), info)
357
+
358
+ def agents(self) -> List["Agent"]:
359
+ """The agents started in this sandbox, newest first."""
360
+ return [Agent(self._client, self._path("/agents/" + i["id"]), i) for i in self._client._call("GET", self._path("/agents"))]
361
+
362
+ def agent(self, id: str) -> "Agent":
363
+ """An agent by id (ag-xxxxxxxx)."""
364
+ path = self._path("/agents/" + urllib.parse.quote(id, safe=""))
365
+ return Agent(self._client, path, self._client._call("GET", path))
366
+
367
+
368
+ class Agent:
369
+ """Claude Code or Codex working on a task in a sandbox.
370
+
371
+ Its fields are as of the last call that returned it; refresh(), events()
372
+ and wait() update them. info holds everything the API reports.
373
+ """
374
+
375
+ def __init__(self, client: Cloudex, path: str, info: Dict[str, Any]):
376
+ self._client = client
377
+ self._path = path
378
+ self.info = info
379
+
380
+ @property
381
+ def id(self) -> str:
382
+ return self.info["id"]
383
+
384
+ @property
385
+ def state(self) -> str:
386
+ """running while a turn is in progress or messages are queued, idle otherwise."""
387
+ return self.info["state"]
388
+
389
+ @property
390
+ def last_message(self) -> Optional[str]:
391
+ """The agent's final message in the last finished turn."""
392
+ return self.info.get("lastMessage")
393
+
394
+ @property
395
+ def error(self) -> Optional[str]:
396
+ """Why the last turn failed."""
397
+ return self.info.get("error")
398
+
399
+ def __repr__(self) -> str:
400
+ return f"<Agent {self.id} {self.info.get('agent')} {self.state}>"
401
+
402
+ def refresh(self) -> "Agent":
403
+ self.info = self._client._call("GET", self._path)
404
+ return self
405
+
406
+ def send(self, prompt: str, interrupt: bool = False) -> "Agent":
407
+ """Continues the conversation.
408
+
409
+ While a turn runs the message waits for it; with interrupt, the running
410
+ turn stops and queued messages are dropped first.
411
+ """
412
+ body = {"prompt": prompt, "interrupt": True} if interrupt else {"prompt": prompt}
413
+ self.info = self._client._call("POST", self._path + "/messages", body)
414
+ return self
415
+
416
+ def interrupt(self) -> "Agent":
417
+ """Stops the running turn and drops queued messages."""
418
+ self.info = self._client._call("POST", self._path + "/interrupt")
419
+ return self
420
+
421
+ def events(self, after: int = 0, follow: bool = True) -> Iterator[Dict[str, Any]]:
422
+ """The agent's events (dicts) from after (default 0, the start).
423
+
424
+ With follow (the default), waits for new ones until the agent is idle;
425
+ without, stops at the newest. Each request waits up to 25 seconds, so a
426
+ client timeout must be longer than that.
427
+ """
428
+ wait = 25 if follow else 0
429
+ while True:
430
+ page = self._client._call("GET", f"{self._path}/events?after={after}&wait={wait}")
431
+ self.info = page["agent"]
432
+ yield from page["events"]
433
+ after = page["next"]
434
+ if after >= self.info["events"] and (not follow or self.info["state"] == "idle"):
435
+ return
436
+
437
+ def wait(self) -> "Agent":
438
+ """Waits until the agent is idle; then last_message and error tell how its last turn went."""
439
+ for _ in self.events(after=self.info["events"]):
440
+ pass
441
+ return self
cloudex/py.typed ADDED
File without changes
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudex
3
+ Version: 0.1.11
4
+ Summary: SDK for Cloudex: agent sandboxes in your own Cloudflare account
5
+ Project-URL: Homepage, https://cloudex.build
6
+ Project-URL: Source, https://github.com/lucataco/cloudex
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,claude-code,cloudflare,codex,containers,sandbox
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Cloudex SDK for Python
17
+
18
+ Create, fork and drive [Cloudex](https://cloudex.build) sandboxes: agent
19
+ computers in your own Cloudflare account. Python 3.9+, standard library only.
20
+
21
+ ```python
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from cloudex import Cloudex
24
+
25
+ cx = Cloudex() # the deployment saved by `cloudex login`
26
+ box = cx.create(name="web")
27
+ box.exec("git clone https://github.com/you/app && cd app && pip install -e .")
28
+
29
+ # Ten copies of that disk, each trying something different.
30
+ forks = box.fork(count=10, name="try")
31
+ with ThreadPoolExecutor(10) as pool:
32
+ results = list(pool.map(lambda f: f.exec(["pytest", "-x", "app"]), forks))
33
+
34
+ print(forks[0].host(8000)) # an HTTPS link to a port
35
+ for f in forks:
36
+ f.delete()
37
+ box.stop() # saves the disk; box.resume() picks up where it left off
38
+ ```
39
+
40
+ ## Agents
41
+
42
+ `box.prompt()` starts Claude Code, or Codex, on a task with no terminal. Each
43
+ message is one turn of the agent's own conversation, which lives on the
44
+ sandbox's disk.
45
+
46
+ ```python
47
+ agent = box.prompt("make the tests pass", cwd="app")
48
+ for e in agent.events():
49
+ # e["type"] is prompt, event (the agent's own JSON in e["event"]), output or end
50
+ if e["type"] == "event":
51
+ print(e["event"])
52
+ print(agent.last_message, agent.error)
53
+
54
+ agent.send("now update the docs") # waits for a running turn
55
+ agent.send("stop, fix the build first", interrupt=True)
56
+ agent.wait()
57
+ ```
58
+
59
+ `events()` waits up to 25 seconds per request, so give `Cloudex(timeout=)` more
60
+ than that if you set one.
61
+
62
+ ## Connecting
63
+
64
+ `Cloudex(api_url, api_key)` takes the API URL and key that `cloudex deploy`
65
+ printed (`cloudex status --show-key` shows them again). Without them it reads
66
+ `$CLOUDEX_API_URL` and `$CLOUDEX_API_KEY`, then the deployment the CLI saved.
67
+ The key gives full control of your sandboxes.
68
+
69
+ ## API
70
+
71
+ | Call | Does |
72
+ |---|---|
73
+ | `cx.create(name=, size=, idle_minutes=, env=, files=, repo=)` | Create a sandbox; returns once it's running (and the repo is cloned) |
74
+ | `cx.get(ref)`, `cx.list()`, `cx.health()` | Look up by id or name, list, check the deployment |
75
+ | `box.exec(cmd, root=, cwd=, timeout_sec=)` | Run shell code, or an argument list, and wait; check `exit_code` |
76
+ | `box.fork(count=, name=, size=, idle_minutes=)` | Up to 10 sandboxes starting from a copy of the disk |
77
+ | `box.host(port)` | HTTPS link to a port on your preview domain |
78
+ | `box.desktop(size=)`, `box.stop_desktop()` | Start the desktop (Chrome, a terminal) and get a link to it |
79
+ | `box.stop()`, `box.resume(from_backup=)`, `box.delete()` | Save and stop, start again, delete |
80
+ | `box.refresh()` | Fetch the latest state; `box.id`, `box.name`, `box.state` and `box.info` hold it |
81
+ | `box.settings()`, `box.update_settings(env=, files=)` | Environment variables and files the sandbox gets at every start; `None` removes one |
82
+ | `cx.set_key(provider, key)`, `cx.list_keys()`, `cx.delete_key(provider)` | AI provider keys sandboxes use without holding them (`"anthropic"`, `"openai"`) |
83
+ | `box.prompt(prompt, agent=, cwd=, model=)` | Start Claude Code (default) or Codex on a task; returns an `Agent` at once |
84
+ | `box.agents()`, `box.agent(id)` | The sandbox's agents, newest first; one by id |
85
+ | `agent.events(after=, follow=)` | Iterate over its events (dicts); follows until the agent is idle |
86
+ | `agent.send(prompt, interrupt=)`, `agent.interrupt()`, `agent.wait()` | Continue, stop the running turn, wait until idle |
87
+
88
+ Errors from the API raise `CloudexError`, with `status` and the API's message.
89
+ The full API is described at `<your API URL>/v1/openapi.json`.
90
+
91
+ Fetching a preview link with `urllib` needs a `User-Agent` header: Cloudflare's
92
+ Browser Integrity Check, on by default for your domain, answers urllib's default
93
+ one with error 1010.
@@ -0,0 +1,6 @@
1
+ cloudex/__init__.py,sha256=5FmxjrAN4vjm7g9gXiYmggFQ1WtrCI-6h4BT1P_XA3s,17030
2
+ cloudex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ cloudex-0.1.11.dist-info/METADATA,sha256=pymJGBGwcdjbkg0GgH19S8FKkeMJjo61mkVNOhhOGzs,4248
4
+ cloudex-0.1.11.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
5
+ cloudex-0.1.11.dist-info/licenses/LICENSE,sha256=qJPQBS2FgX-B1tqJhL29XFgUQrTOoMCWg0Yc98_R-P0,1070
6
+ cloudex-0.1.11.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis Catacora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.