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.
- latch_persistence-0.1.0/.gitignore +11 -0
- latch_persistence-0.1.0/.python-version +1 -0
- latch_persistence-0.1.0/Justfile +14 -0
- latch_persistence-0.1.0/PKG-INFO +7 -0
- latch_persistence-0.1.0/README.md +0 -0
- latch_persistence-0.1.0/pyproject.toml +12 -0
- latch_persistence-0.1.0/src/latch_persistence/__init__.py +478 -0
- latch_persistence-0.1.0/src/latch_persistence/py.typed +0 -0
- latch_persistence-0.1.0/uv.lock +94 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
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
|
+
]
|