latch_persistence 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,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ /credentials
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,14 @@
1
+ # Setup
2
+
3
+ install:
4
+ uv sync
5
+
6
+ # Packaging
7
+
8
+ build:
9
+ rm -rf dist
10
+ uv build
11
+
12
+ publish:
13
+ uv publish --token $(<credentials/pypi_token)
14
+ rm -rf dist
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.3
2
+ Name: latch_persistence
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: Ayush Kamat <ayush@latch.bio>
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: requests>=2.32.3
File without changes
@@ -0,0 +1,12 @@
1
+ [project]
2
+ name = "latch_persistence"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "Ayush Kamat", email = "ayush@latch.bio" }]
7
+ requires-python = ">=3.9"
8
+ dependencies = ["requests>=2.32.3"]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
@@ -0,0 +1,478 @@
1
+ import base64
2
+ import hashlib
3
+ import math
4
+ import mimetypes
5
+ import os as _os
6
+ import time
7
+ import traceback
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from queue import Empty, Queue
12
+ from threading import Thread
13
+ from typing import Dict, List, Optional
14
+ from urllib.parse import urljoin, urlparse
15
+
16
+ import requests
17
+ from requests.adapters import HTTPAdapter, Retry
18
+
19
+
20
+ @dataclass
21
+ class LatchConfig(object):
22
+ """
23
+ Latch specific configuration values
24
+ """
25
+
26
+ endpoint: str = "https://nucleus.latch.bio"
27
+ upload_chunk_size_bytes: int = 10000000
28
+
29
+ @classmethod
30
+ def auto(cls, config_file = None) -> "LatchConfig":
31
+ return LatchConfig()
32
+
33
+ """
34
+ Thread Utils For Downloading Directories
35
+ """
36
+
37
+
38
+ class ThreadPool:
39
+ """Pool of threads consuming tasks from a queue"""
40
+
41
+ def __init__(self, num_threads):
42
+ self.tasks: Queue = Queue(-1)
43
+ self.threads: List[Worker] = []
44
+ self.cache_accum = bytearray([0] * 32)
45
+ for _ in range(num_threads):
46
+ self.threads.append(Worker(self.tasks))
47
+
48
+ def start(self):
49
+ for t in self.threads:
50
+ t.start()
51
+
52
+ def join(self):
53
+ for t in self.threads:
54
+ t.join()
55
+
56
+ for i in range(len(self.cache_accum)):
57
+ self.cache_accum[i] ^= t.cache_accum[i]
58
+
59
+ def add_task(self, func, *args, **kargs):
60
+ """Add a task to the queue"""
61
+ self.tasks.put((func, args, kargs))
62
+
63
+ def map(self, func, args_list):
64
+ """Add a list of tasks to the queue"""
65
+ for args in args_list:
66
+ self.add_task(func, args)
67
+
68
+ def wait_completion(self):
69
+ """Wait for completion of all the tasks in the queue"""
70
+ try:
71
+ self.tasks.join()
72
+ finally:
73
+ self.join()
74
+
75
+
76
+ class Worker(Thread):
77
+ """Thread executing tasks from a given tasks queue"""
78
+
79
+ def __init__(self, tasks: Queue):
80
+ Thread.__init__(self)
81
+ self.tasks = tasks
82
+ self.daemon = True
83
+ self.cache_accum = bytearray([0] * 32)
84
+
85
+ def run(self):
86
+ while True:
87
+ try:
88
+ func, args, kargs = self.tasks.get_nowait()
89
+ except Empty:
90
+ break
91
+
92
+ try:
93
+ attempts = 0
94
+ while True:
95
+ try:
96
+ res = func(*args, **kargs)
97
+
98
+ if res is not None and res["cache"] is not None:
99
+ hash = hashlib.sha256(res["cache"].encode("utf-8")).digest()
100
+ for i in range(len(self.cache_accum)):
101
+ self.cache_accum[i] ^= hash[i]
102
+
103
+ break
104
+ except Exception as e:
105
+ attempts += 1
106
+ if attempts >= 5:
107
+ raise e
108
+ time.sleep(0.1 * 2**attempts)
109
+ except Exception:
110
+ traceback.print_exc()
111
+ finally:
112
+ self.tasks.task_done()
113
+
114
+
115
+ download_part_size = 1024 * 1024 * 100
116
+ write_chunk_size = 1024 * 1024
117
+ num_concurrent_downloads = 8
118
+
119
+
120
+ def urlretrieve(url: str, start: int, end: int, fd: int):
121
+ attempts = 0
122
+ while True:
123
+ try:
124
+ headers = {"Range": f"bytes={start}-{end}"}
125
+ r = _session.get(url, headers=headers, stream=True, timeout=90)
126
+
127
+ bytes_written = 0
128
+ for chunk in r.iter_content(write_chunk_size):
129
+ bytes_written += _os.pwrite(fd, chunk, start + bytes_written)
130
+
131
+ break
132
+ except requests.exceptions.ConnectionError as e:
133
+ attempts += 1
134
+ if attempts >= 3:
135
+ raise e
136
+
137
+ time.sleep(0.1 * 2**attempts)
138
+
139
+
140
+ byte_range_prefix = "bytes 0-0/"
141
+
142
+
143
+ def get(args):
144
+ url = args[0]
145
+ path = args[1]
146
+
147
+ r = _session.get(url, headers={"Range": "bytes=0-0"}, timeout=90)
148
+ if r.status_code == 416:
149
+ Path(path).write_text("")
150
+ return
151
+
152
+ if r.status_code != 206:
153
+ raise RuntimeError(f"failed to get file size for `{url}`: status_code={r.status_code} error={r.content}")
154
+
155
+ content_range = r.headers.get("Content-Range")
156
+ if content_range is None or not content_range.startswith(byte_range_prefix):
157
+ raise RuntimeError(f"invalid Content-Range header for `{url}`: {content_range}")
158
+
159
+ file_size = int(content_range[len(byte_range_prefix) :])
160
+ num_parts = math.ceil(file_size / download_part_size)
161
+
162
+ with open(path, "wb") as output_file:
163
+ output_file.truncate(file_size)
164
+
165
+ futures: list[Future] = []
166
+
167
+ with ThreadPoolExecutor(max_workers=num_concurrent_downloads) as executor:
168
+ for i in range(num_parts):
169
+ start = i * download_part_size
170
+ end = min(start + download_part_size - 1, file_size - 1)
171
+ futures.append(executor.submit(urlretrieve, url, start, end, output_file.fileno()))
172
+
173
+ for future in futures:
174
+ future.result()
175
+
176
+
177
+ def get_auth_header() -> str:
178
+ internal_execution_id = _os.environ.get("FLYTE_INTERNAL_EXECUTION_ID")
179
+ if internal_execution_id is not None:
180
+ return f"Latch-Execution-Token {internal_execution_id}"
181
+
182
+ token_path = Path.home() / ".latch" / "token"
183
+ if token_path.exists():
184
+ return f"Latch-SDK-Token {token_path.read_text().strip()}"
185
+
186
+ raise ValueError("no authentication method found")
187
+
188
+
189
+ def _enforce_trailing_slash(path: str):
190
+ if path[-1] != "/":
191
+ path += "/"
192
+ return path
193
+
194
+
195
+ _session = requests.Session()
196
+
197
+ _adapter = HTTPAdapter(
198
+ max_retries=Retry(
199
+ status_forcelist=[429, 500, 502, 503, 504], backoff_factor=1, allowed_methods=["GET", "PUT", "POST"]
200
+ )
201
+ )
202
+ _session.mount("https://", _adapter)
203
+ _session.mount("http://", _adapter)
204
+
205
+
206
+ MAXIMUM_UPLOAD_SIZE = 5 * 2**40 # 5 TiB
207
+ MAXIMUM_UPLOAD_PARTS = 10000
208
+
209
+
210
+ class LatchPersistence:
211
+ PROTOCOL = "latch://"
212
+
213
+ def __init__(self, default_prefix: Optional[str] = None, data_config = None):
214
+ self._name = "latch"
215
+ self._default_prefix = default_prefix
216
+ default_latch_config = data_config.latch if data_config else LatchConfig.auto()
217
+
218
+ latch_endpoint = _os.environ.get("LATCH_AUTHENTICATION_ENDPOINT")
219
+ if latch_endpoint is None:
220
+ latch_endpoint = default_latch_config.endpoint
221
+ if latch_endpoint is None:
222
+ raise ValueError("LATCH_AUTHENTICATION_ENDPOINT must be set")
223
+
224
+ self._latch_endpoint = latch_endpoint
225
+
226
+ self._default_chunk_size = default_latch_config.upload_chunk_size_bytes
227
+ if self._default_chunk_size is None:
228
+ raise ValueError("S3_UPLOAD_CHUNK_SIZE_BYTES must be set")
229
+
230
+
231
+ @property
232
+ def name(self) -> str:
233
+ return self._name
234
+
235
+ @property
236
+ def default_prefix(self) -> Optional[str]:
237
+ return self._default_prefix
238
+
239
+ @staticmethod
240
+ def _split_s3_path_to_key(path: str) -> str:
241
+ """
242
+ :param str path:
243
+ :rtype: str
244
+ """
245
+ url = urlparse(path)
246
+ return url.path
247
+
248
+ def exists(self, remote_path: str) -> bool:
249
+ """
250
+ :param str remote_path: remote latch:// path
251
+ :rtype bool: whether the s3 file exists or not
252
+ """
253
+ if not remote_path.startswith(self.PROTOCOL):
254
+ raise ValueError(f"expected a Latch URL (latch://...): {remote_path}")
255
+
256
+ attempts = 0
257
+ while True:
258
+ try:
259
+ r = _session.post(
260
+ urljoin(self._latch_endpoint, "/api/object-exists-at-url"),
261
+ json={"object_url": remote_path, "execution_name": _os.environ.get("FLYTE_INTERNAL_EXECUTION_ID")},
262
+ timeout=90,
263
+ )
264
+ break
265
+ except requests.exceptions.ConnectionError as e:
266
+ attempts += 1
267
+ if attempts >= 3:
268
+ raise e
269
+ time.sleep(0.1 * 2**attempts)
270
+
271
+ if r.status_code != 200:
272
+ raise ValueError("failed to check if object exists at url `{}`".format(remote_path))
273
+
274
+ return r.json()["exists"]
275
+
276
+ def download_directory(self, remote_path: str, local_path: str) -> bool:
277
+ """
278
+ :param str remote_path: remote latch:// path
279
+ :param str local_path: directory to copy to
280
+ """
281
+
282
+ auth_headers = {"Authorization": get_auth_header()}
283
+ r = _session.post(
284
+ urljoin(self._latch_endpoint, "/ldata/get-signed-urls-recursive"),
285
+ json={
286
+ "path": remote_path,
287
+ },
288
+ headers=auth_headers,
289
+ timeout=90,
290
+ )
291
+
292
+ res_data = r.json()
293
+ if r.status_code != 200:
294
+ raise ValueError(f"failed to get presigned urls for `{remote_path}`")
295
+
296
+ # todo(aidan, max): write property typed wrappers to validate responses
297
+ key_to_url_map: Dict[str, str] = res_data["data"]["urls"]
298
+
299
+ task_tuples = []
300
+ for key, url in key_to_url_map.items():
301
+ local_file_path = Path(local_path).joinpath(key)
302
+ local_file_path.parent.mkdir(parents=True, exist_ok=True)
303
+
304
+ task_tuples.append((url, local_file_path))
305
+
306
+ pool = ThreadPool(20)
307
+ pool.map(get, task_tuples)
308
+ pool.start()
309
+ pool.wait_completion()
310
+ return True
311
+
312
+ def download(self, remote_path: str, local_path: str) -> bool:
313
+ """
314
+ :param str remote_path: remote latch:// path
315
+ :param str local_path: directory to copy to
316
+ """
317
+
318
+ auth_headers = {"Authorization": get_auth_header()}
319
+ r = _session.post(
320
+ urljoin(self._latch_endpoint, "/ldata/get-signed-url"),
321
+ json={
322
+ "path": remote_path,
323
+ },
324
+ headers=auth_headers,
325
+ timeout=90,
326
+ )
327
+
328
+ res_data = r.json()
329
+ if r.status_code != 200:
330
+ raise ValueError(f"failed to get presigned url for `{remote_path}`")
331
+
332
+ url = res_data["data"]["url"]
333
+ get((url, local_path))
334
+ return _os.path.exists(local_path)
335
+
336
+ @staticmethod
337
+ def __upload(args):
338
+ return LatchPersistence._upload(*args)
339
+
340
+ @staticmethod
341
+ def _upload(file_path: str, to_path: str, default_chunk_size: int, endpoint: str):
342
+ file_size = _os.path.getsize(file_path)
343
+ if file_size > MAXIMUM_UPLOAD_SIZE:
344
+ raise ValueError(f"File {file_path} is {file_size} bytes which exceeds the maximum upload size (5TiB)")
345
+
346
+ nrof_parts = min(
347
+ MAXIMUM_UPLOAD_PARTS,
348
+ math.ceil(file_size / default_chunk_size),
349
+ )
350
+ chunk_size = max(
351
+ default_chunk_size,
352
+ math.ceil(file_size / MAXIMUM_UPLOAD_PARTS),
353
+ )
354
+
355
+ nrof_parts = math.ceil(float(file_size) / chunk_size)
356
+ content_type = mimetypes.guess_type(file_path)[0]
357
+ if content_type is None:
358
+ content_type = "application/octet-stream"
359
+
360
+ auth_headers = {"Authorization": get_auth_header()}
361
+ r = _session.post(
362
+ urljoin(endpoint, "/ldata/start-upload"),
363
+ json={
364
+ "path": to_path,
365
+ "part_count": nrof_parts,
366
+ "content_type": content_type,
367
+ },
368
+ headers=auth_headers,
369
+ timeout=90,
370
+ )
371
+
372
+ res_data = r.json()
373
+ if r.status_code != 200:
374
+ raise ValueError(f"failed to get presigned upload urls for `{to_path}`: {res_data['error']}")
375
+
376
+ data = res_data["data"]
377
+
378
+ if nrof_parts == 0:
379
+ return {"cache": data["version_id"]}
380
+
381
+ presigned_urls = data["urls"]
382
+ upload_id = data["upload_id"]
383
+
384
+ with open(file_path, "rb") as f:
385
+ parts = []
386
+ for idx, url in enumerate(presigned_urls):
387
+ blob = f.read(chunk_size)
388
+ r = _session.put(url, data=blob, timeout=90)
389
+ if r.status_code != 200:
390
+ print(r.status_code)
391
+ print(r.text)
392
+ print(r.headers)
393
+ raise RuntimeError(f"failed to upload part `{idx + 1}` of file `{file_path}`")
394
+ etag = r.headers["ETag"]
395
+ parts.append({"ETag": etag, "PartNumber": idx + 1})
396
+
397
+ r = _session.post(
398
+ urljoin(endpoint, "/ldata/end-upload"),
399
+ json={
400
+ "upload_id": upload_id,
401
+ "parts": parts,
402
+ "path": to_path,
403
+ },
404
+ headers=auth_headers,
405
+ timeout=90,
406
+ )
407
+
408
+ res_data = r.json()
409
+ if r.status_code != 200:
410
+ raise RuntimeError(f"failed to complete upload for `{to_path}`: {res_data['error']}")
411
+
412
+ data = res_data["data"]
413
+ return {"cache": data["version_id"]}
414
+
415
+ def upload(self, file_path: str, to_path: str):
416
+ """
417
+ :param str file_path:
418
+ :param str to_path:
419
+ """
420
+ return LatchPersistence._upload(file_path, to_path, self._default_chunk_size, self._latch_endpoint)
421
+
422
+ def upload_directory(self, local_path: str, remote_path: str):
423
+ """
424
+ :param str local_path:
425
+ :param str remote_path:
426
+ """
427
+ if remote_path == "latch://":
428
+ remote_path = "latch:///"
429
+
430
+ # ensure formatting
431
+ local_path = _enforce_trailing_slash(local_path)
432
+ remote_path = _enforce_trailing_slash(remote_path)
433
+
434
+ task_tuples = []
435
+ files_to_upload = [_os.path.join(dp, f) for dp, __, filenames in _os.walk(local_path) for f in filenames]
436
+ for file_path in files_to_upload:
437
+ relative_name = file_path.replace(local_path, "", 1)
438
+ if relative_name.startswith("/"):
439
+ relative_name = relative_name[1:]
440
+ # TODO(aidan): change this to form data (all at once)
441
+ task_tuples.append((file_path, remote_path + relative_name, self._default_chunk_size, self._latch_endpoint))
442
+
443
+ pool = ThreadPool(100)
444
+ pool.map(LatchPersistence.__upload, task_tuples)
445
+ pool.start()
446
+ pool.wait_completion()
447
+
448
+ cache = base64.standard_b64encode(bytes(pool.cache_accum)).decode("ascii")
449
+ return {"cache": cache}
450
+
451
+ def construct_path(self, add_protocol: bool, add_prefix: bool, *args: str) -> str:
452
+ paths = list(args) # make type check happy
453
+ if add_prefix and self.default_prefix is not None:
454
+ paths.insert(0, self.default_prefix)
455
+ path = "/".join(paths)
456
+ if add_protocol:
457
+ return f"{self.PROTOCOL}{path}"
458
+ return path
459
+
460
+ def get(self, from_path: str, to_path: str, recursive: bool = False):
461
+ if not from_path.startswith(self.PROTOCOL):
462
+ raise ValueError(f"expected a Latch URL (latch://...): {from_path}")
463
+
464
+ if recursive:
465
+ return self.download_directory(from_path, to_path)
466
+
467
+ return self.download(from_path, to_path)
468
+
469
+ def put(self, from_path: str, to_path: str, recursive: bool = False):
470
+ if not to_path.startswith(self.PROTOCOL):
471
+ raise ValueError(f"expected a Latch URL (latch://...): {to_path}")
472
+
473
+ if recursive:
474
+ return self.upload_directory(from_path, to_path)
475
+
476
+ return self.upload(from_path, to_path)
477
+
478
+
File without changes
@@ -0,0 +1,94 @@
1
+ version = 1
2
+ requires-python = ">=3.12"
3
+
4
+ [[package]]
5
+ name = "certifi"
6
+ version = "2024.8.30"
7
+ source = { registry = "https://pypi.org/simple" }
8
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
9
+ wheels = [
10
+ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
11
+ ]
12
+
13
+ [[package]]
14
+ name = "charset-normalizer"
15
+ version = "3.4.0"
16
+ source = { registry = "https://pypi.org/simple" }
17
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 }
18
+ wheels = [
19
+ { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 },
20
+ { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 },
21
+ { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 },
22
+ { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 },
23
+ { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 },
24
+ { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 },
25
+ { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 },
26
+ { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 },
27
+ { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 },
28
+ { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 },
29
+ { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 },
30
+ { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 },
31
+ { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 },
32
+ { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 },
33
+ { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 },
34
+ { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 },
35
+ { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 },
36
+ { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 },
37
+ { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 },
38
+ { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 },
39
+ { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 },
40
+ { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 },
41
+ { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 },
42
+ { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 },
43
+ { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 },
44
+ { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 },
45
+ { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 },
46
+ { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 },
47
+ { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 },
48
+ { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 },
49
+ { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 },
50
+ ]
51
+
52
+ [[package]]
53
+ name = "idna"
54
+ version = "3.10"
55
+ source = { registry = "https://pypi.org/simple" }
56
+ sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
57
+ wheels = [
58
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
59
+ ]
60
+
61
+ [[package]]
62
+ name = "latch-persistence"
63
+ version = "0.1.0"
64
+ source = { editable = "." }
65
+ dependencies = [
66
+ { name = "requests" },
67
+ ]
68
+
69
+ [package.metadata]
70
+ requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
71
+
72
+ [[package]]
73
+ name = "requests"
74
+ version = "2.32.3"
75
+ source = { registry = "https://pypi.org/simple" }
76
+ dependencies = [
77
+ { name = "certifi" },
78
+ { name = "charset-normalizer" },
79
+ { name = "idna" },
80
+ { name = "urllib3" },
81
+ ]
82
+ sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
83
+ wheels = [
84
+ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
85
+ ]
86
+
87
+ [[package]]
88
+ name = "urllib3"
89
+ version = "2.2.3"
90
+ source = { registry = "https://pypi.org/simple" }
91
+ sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
92
+ wheels = [
93
+ { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
94
+ ]