fal 1.44.1__py3-none-any.whl → 1.45.1__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.
Potentially problematic release.
This version of fal might be problematic. Click here for more details.
- fal/_fal_version.py +2 -2
- fal/api.py +50 -3
- fal/app.py +29 -1
- fal/cli/deploy.py +2 -1
- fal/cli/run.py +6 -2
- fal/exceptions/__init__.py +2 -0
- fal/exceptions/_base.py +15 -0
- fal/file_sync.py +361 -0
- fal/sdk.py +21 -1
- fal/utils.py +1 -0
- {fal-1.44.1.dist-info → fal-1.45.1.dist-info}/METADATA +2 -1
- {fal-1.44.1.dist-info → fal-1.45.1.dist-info}/RECORD +15 -14
- {fal-1.44.1.dist-info → fal-1.45.1.dist-info}/WHEEL +0 -0
- {fal-1.44.1.dist-info → fal-1.45.1.dist-info}/entry_points.txt +0 -0
- {fal-1.44.1.dist-info → fal-1.45.1.dist-info}/top_level.txt +0 -0
fal/_fal_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '1.
|
|
32
|
-
__version_tuple__ = version_tuple = (1,
|
|
31
|
+
__version__ = version = '1.45.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 45, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
fal/api.py
CHANGED
|
@@ -43,6 +43,7 @@ from typing_extensions import Concatenate, ParamSpec
|
|
|
43
43
|
|
|
44
44
|
import fal.flags as flags
|
|
45
45
|
from fal._serialization import include_module, include_modules_from, patch_pickle
|
|
46
|
+
from fal.console import console
|
|
46
47
|
from fal.container import ContainerImage
|
|
47
48
|
from fal.exceptions import (
|
|
48
49
|
AppException,
|
|
@@ -51,6 +52,7 @@ from fal.exceptions import (
|
|
|
51
52
|
FieldException,
|
|
52
53
|
)
|
|
53
54
|
from fal.exceptions._cuda import _is_cuda_oom_exception
|
|
55
|
+
from fal.file_sync import FileSync
|
|
54
56
|
from fal.logging.isolate import IsolateLogPrinter
|
|
55
57
|
from fal.sdk import (
|
|
56
58
|
FAL_SERVERLESS_DEFAULT_CONCURRENCY_BUFFER,
|
|
@@ -63,6 +65,7 @@ from fal.sdk import (
|
|
|
63
65
|
DeploymentStrategyLiteral,
|
|
64
66
|
FalServerlessClient,
|
|
65
67
|
FalServerlessConnection,
|
|
68
|
+
File,
|
|
66
69
|
HostedRunState,
|
|
67
70
|
MachineRequirements,
|
|
68
71
|
get_agent_credentials,
|
|
@@ -438,16 +441,25 @@ class FalServerlessHost(Host):
|
|
|
438
441
|
"_base_image",
|
|
439
442
|
"_scheduler",
|
|
440
443
|
"_scheduler_options",
|
|
444
|
+
"app_files",
|
|
445
|
+
"app_files_ignore",
|
|
446
|
+
"app_files_context_dir",
|
|
441
447
|
}
|
|
442
448
|
)
|
|
443
449
|
|
|
444
450
|
url: str = FAL_SERVERLESS_DEFAULT_URL
|
|
451
|
+
local_file_path: str = ""
|
|
445
452
|
credentials: Credentials = field(default_factory=get_default_credentials)
|
|
446
|
-
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
447
453
|
|
|
448
|
-
|
|
454
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
455
|
+
|
|
456
|
+
_log_printer: IsolateLogPrinter = field(
|
|
457
|
+
default_factory=lambda: IsolateLogPrinter(debug=flags.DEBUG), init=False
|
|
458
|
+
)
|
|
449
459
|
|
|
450
|
-
_thread_pool: ThreadPoolExecutor = field(
|
|
460
|
+
_thread_pool: ThreadPoolExecutor = field(
|
|
461
|
+
default_factory=ThreadPoolExecutor, init=False
|
|
462
|
+
)
|
|
451
463
|
|
|
452
464
|
def __getstate__(self) -> dict[str, Any]:
|
|
453
465
|
state = self.__dict__.copy()
|
|
@@ -465,6 +477,35 @@ class FalServerlessHost(Host):
|
|
|
465
477
|
client = FalServerlessClient(self.url, self.credentials)
|
|
466
478
|
return client.connect()
|
|
467
479
|
|
|
480
|
+
def _app_files_sync(self, options: Options) -> list[File]:
|
|
481
|
+
import re # noqa: PLC0415
|
|
482
|
+
|
|
483
|
+
app_files: list[str] = options.host.get("app_files", [])
|
|
484
|
+
app_files_ignore_str = options.host.get("app_files_ignore", [])
|
|
485
|
+
app_files_ignore = [re.compile(pattern) for pattern in app_files_ignore_str]
|
|
486
|
+
app_files_context_dir = options.host.get("app_files_context_dir", None)
|
|
487
|
+
res = []
|
|
488
|
+
if app_files:
|
|
489
|
+
sync = FileSync(self.local_file_path)
|
|
490
|
+
files, errors = sync.sync_files(
|
|
491
|
+
app_files,
|
|
492
|
+
files_ignore=app_files_ignore,
|
|
493
|
+
files_context_dir=app_files_context_dir,
|
|
494
|
+
)
|
|
495
|
+
if errors:
|
|
496
|
+
for error in errors:
|
|
497
|
+
console.print(
|
|
498
|
+
f"Error uploading file {error.relative_path}: {error.message}"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
raise FalServerlessException("Error uploading files")
|
|
502
|
+
|
|
503
|
+
res = [
|
|
504
|
+
File(relative_path=file.relative_path, hash=file.hash) for file in files
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
return res
|
|
508
|
+
|
|
468
509
|
@_handle_grpc_error()
|
|
469
510
|
def register(
|
|
470
511
|
self,
|
|
@@ -515,6 +556,8 @@ class FalServerlessHost(Host):
|
|
|
515
556
|
startup_timeout=startup_timeout,
|
|
516
557
|
)
|
|
517
558
|
|
|
559
|
+
app_files = self._app_files_sync(options)
|
|
560
|
+
|
|
518
561
|
partial_func = _prepare_partial_func(func)
|
|
519
562
|
|
|
520
563
|
if metadata is None:
|
|
@@ -537,6 +580,7 @@ class FalServerlessHost(Host):
|
|
|
537
580
|
scale=scale,
|
|
538
581
|
# By default, logs are public
|
|
539
582
|
private_logs=options.host.get("private_logs", False),
|
|
583
|
+
files=app_files,
|
|
540
584
|
):
|
|
541
585
|
for log in partial_result.logs:
|
|
542
586
|
self._log_printer.print(log)
|
|
@@ -594,6 +638,8 @@ class FalServerlessHost(Host):
|
|
|
594
638
|
startup_timeout=startup_timeout,
|
|
595
639
|
)
|
|
596
640
|
|
|
641
|
+
app_files = self._app_files_sync(options)
|
|
642
|
+
|
|
597
643
|
return_value = _UNSET
|
|
598
644
|
# Allow isolate provided arguments (such as setup function) to take
|
|
599
645
|
# precedence over the ones provided by the user.
|
|
@@ -603,6 +649,7 @@ class FalServerlessHost(Host):
|
|
|
603
649
|
environments,
|
|
604
650
|
machine_requirements=machine_requirements,
|
|
605
651
|
setup_function=setup_function,
|
|
652
|
+
files=app_files,
|
|
606
653
|
):
|
|
607
654
|
result_handler(partial_result)
|
|
608
655
|
|
fal/app.py
CHANGED
|
@@ -35,6 +35,13 @@ from fal.toolkit.file.providers.fal import LIFECYCLE_PREFERENCE
|
|
|
35
35
|
|
|
36
36
|
REALTIME_APP_REQUIREMENTS = ["websockets", "msgpack"]
|
|
37
37
|
REQUEST_ID_KEY = "x-fal-request-id"
|
|
38
|
+
DEFAULT_APP_FILES_IGNORE = [
|
|
39
|
+
r"\.pyc$",
|
|
40
|
+
r"__pycache__/",
|
|
41
|
+
r"\.git/",
|
|
42
|
+
r"\.DS_Store$",
|
|
43
|
+
]
|
|
44
|
+
|
|
38
45
|
|
|
39
46
|
EndpointT = TypeVar("EndpointT", bound=Callable[..., Any])
|
|
40
47
|
logger = get_logger(__name__)
|
|
@@ -298,6 +305,14 @@ def _print_python_packages() -> None:
|
|
|
298
305
|
print("[debug] Python packages installed:", ", ".join(packages))
|
|
299
306
|
|
|
300
307
|
|
|
308
|
+
def _include_app_files_path():
|
|
309
|
+
import sys # noqa: PLC0415
|
|
310
|
+
|
|
311
|
+
# Add local files deployment path to sys.path so imports
|
|
312
|
+
# work correctly in the isolate agent
|
|
313
|
+
sys.path.append("/app_files")
|
|
314
|
+
|
|
315
|
+
|
|
301
316
|
class App(BaseServable):
|
|
302
317
|
requirements: ClassVar[list[str]] = []
|
|
303
318
|
local_python_modules: ClassVar[list[str]] = []
|
|
@@ -313,6 +328,9 @@ class App(BaseServable):
|
|
|
313
328
|
}
|
|
314
329
|
app_name: ClassVar[Optional[str]] = None
|
|
315
330
|
app_auth: ClassVar[Optional[AuthModeLiteral]] = None
|
|
331
|
+
app_files: ClassVar[list[str]] = []
|
|
332
|
+
app_files_ignore: ClassVar[list[str]] = DEFAULT_APP_FILES_IGNORE
|
|
333
|
+
app_files_context_dir: ClassVar[Optional[str]] = None
|
|
316
334
|
request_timeout: ClassVar[Optional[int]] = None
|
|
317
335
|
startup_timeout: ClassVar[Optional[int]] = None
|
|
318
336
|
min_concurrency: ClassVar[Optional[int]] = None
|
|
@@ -334,6 +352,15 @@ class App(BaseServable):
|
|
|
334
352
|
if cls.startup_timeout is not None:
|
|
335
353
|
cls.host_kwargs["startup_timeout"] = cls.startup_timeout
|
|
336
354
|
|
|
355
|
+
if cls.app_files:
|
|
356
|
+
cls.host_kwargs["app_files"] = cls.app_files
|
|
357
|
+
|
|
358
|
+
if cls.app_files_ignore:
|
|
359
|
+
cls.host_kwargs["app_files_ignore"] = cls.app_files_ignore
|
|
360
|
+
|
|
361
|
+
if cls.app_files_context_dir is not None:
|
|
362
|
+
cls.host_kwargs["app_files_context_dir"] = cls.app_files_context_dir
|
|
363
|
+
|
|
337
364
|
if cls.min_concurrency is not None:
|
|
338
365
|
cls.host_kwargs["min_concurrency"] = cls.min_concurrency
|
|
339
366
|
|
|
@@ -349,7 +376,7 @@ class App(BaseServable):
|
|
|
349
376
|
if cls.max_multiplexing is not None:
|
|
350
377
|
cls.host_kwargs["max_multiplexing"] = cls.max_multiplexing
|
|
351
378
|
|
|
352
|
-
cls.app_name = getattr(cls, "app_name"
|
|
379
|
+
cls.app_name = getattr(cls, "app_name") or app_name
|
|
353
380
|
|
|
354
381
|
if cls.__init__ is not App.__init__:
|
|
355
382
|
raise ValueError(
|
|
@@ -382,6 +409,7 @@ class App(BaseServable):
|
|
|
382
409
|
|
|
383
410
|
@asynccontextmanager
|
|
384
411
|
async def lifespan(self, app: fastapi.FastAPI):
|
|
412
|
+
_include_app_files_path()
|
|
385
413
|
_print_python_packages()
|
|
386
414
|
await _call_any_fn(self.setup)
|
|
387
415
|
try:
|
fal/cli/deploy.py
CHANGED
|
@@ -92,7 +92,7 @@ def _deploy_from_reference(
|
|
|
92
92
|
file_path = str(file_path) # type: ignore
|
|
93
93
|
|
|
94
94
|
user = _get_user()
|
|
95
|
-
host = FalServerlessHost(args.host)
|
|
95
|
+
host = FalServerlessHost(args.host, local_file_path=str(file_path))
|
|
96
96
|
loaded = load_function_from(
|
|
97
97
|
host,
|
|
98
98
|
file_path, # type: ignore
|
|
@@ -180,6 +180,7 @@ def _deploy(args):
|
|
|
180
180
|
# default comes from the CLI
|
|
181
181
|
app_deployment_strategy = cast(DeploymentStrategyLiteral, args.strategy)
|
|
182
182
|
app_scale_settings = cast(bool, args.app_scale_settings)
|
|
183
|
+
file_path = str(Path(file_path).absolute())
|
|
183
184
|
|
|
184
185
|
_deploy_from_reference(
|
|
185
186
|
(file_path, func_name),
|
fal/cli/run.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
1
3
|
from ._utils import get_app_data_from_toml, is_app_name
|
|
2
4
|
from .parser import FalClientParser, RefAction
|
|
3
5
|
|
|
@@ -6,14 +8,16 @@ def _run(args):
|
|
|
6
8
|
from fal.api import FalServerlessHost
|
|
7
9
|
from fal.utils import load_function_from
|
|
8
10
|
|
|
9
|
-
host = FalServerlessHost(args.host)
|
|
10
|
-
|
|
11
11
|
if is_app_name(args.func_ref):
|
|
12
12
|
app_name = args.func_ref[0]
|
|
13
13
|
app_ref, *_ = get_app_data_from_toml(app_name)
|
|
14
14
|
file_path, func_name = RefAction.split_ref(app_ref)
|
|
15
15
|
else:
|
|
16
16
|
file_path, func_name = args.func_ref
|
|
17
|
+
# Turn relative path into absolute path for files
|
|
18
|
+
file_path = str(Path(file_path).absolute())
|
|
19
|
+
|
|
20
|
+
host = FalServerlessHost(args.host, local_file_path=file_path)
|
|
17
21
|
|
|
18
22
|
loaded = load_function_from(host, file_path, func_name)
|
|
19
23
|
|
fal/exceptions/__init__.py
CHANGED
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from ._base import (
|
|
4
4
|
AppException, # noqa: F401
|
|
5
|
+
AppFileUploadException, # noqa: F401
|
|
5
6
|
FalServerlessException, # noqa: F401
|
|
6
7
|
FieldException, # noqa: F401
|
|
8
|
+
FileTooLargeError, # noqa: F401
|
|
7
9
|
RequestCancelledException, # noqa: F401
|
|
8
10
|
)
|
|
9
11
|
from ._cuda import CUDAOutOfMemoryException # noqa: F401
|
fal/exceptions/_base.py
CHANGED
|
@@ -56,3 +56,18 @@ class RequestCancelledException(FalServerlessException):
|
|
|
56
56
|
"""Exception raised when the request is cancelled by the client."""
|
|
57
57
|
|
|
58
58
|
message: str = "Request cancelled by the client."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class FileTooLargeError(FalServerlessException):
|
|
63
|
+
"""Exception raised when the file is too large."""
|
|
64
|
+
|
|
65
|
+
message: str = "File is too large."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AppFileUploadException(FalServerlessException):
|
|
70
|
+
"""Raised when file upload fails"""
|
|
71
|
+
|
|
72
|
+
message: str
|
|
73
|
+
relative_path: str
|
fal/file_sync.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
from fal._version import version_tuple
|
|
14
|
+
from fal.console import console
|
|
15
|
+
from fal.console.icons import CROSS_ICON
|
|
16
|
+
from fal.exceptions import (
|
|
17
|
+
AppFileUploadException,
|
|
18
|
+
FalServerlessException,
|
|
19
|
+
FileTooLargeError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
USER_AGENT = f"fal-sdk/{'.'.join(map(str, version_tuple))} (python)"
|
|
23
|
+
FILE_SIZE_LIMIT = 1024 * 1024 * 1024 # 1GB
|
|
24
|
+
DEFAULT_CONCURRENCY_UPLOADS = 10
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_path_tree(file_paths):
|
|
28
|
+
tree = Tree("/app_files")
|
|
29
|
+
|
|
30
|
+
nodes = {"": tree}
|
|
31
|
+
|
|
32
|
+
for file_path in sorted(file_paths):
|
|
33
|
+
parts = Path(file_path).parts
|
|
34
|
+
|
|
35
|
+
for i, part in enumerate(parts):
|
|
36
|
+
current_path = str(Path(*parts[: i + 1]))
|
|
37
|
+
|
|
38
|
+
if current_path not in nodes:
|
|
39
|
+
parent_path = str(Path(*parts[:i])) if i > 0 else ""
|
|
40
|
+
parent_node = nodes[parent_path]
|
|
41
|
+
|
|
42
|
+
nodes[current_path] = parent_node.add(f"{part}")
|
|
43
|
+
|
|
44
|
+
console.print(tree)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sanitize_relative_path(rel_path: str) -> str:
|
|
48
|
+
pure_path = PurePosixPath(rel_path)
|
|
49
|
+
|
|
50
|
+
# Block files that are absolute or contain parent directory references
|
|
51
|
+
if pure_path.is_absolute():
|
|
52
|
+
raise FalServerlessException(f"Absolute Path is not allowed: {rel_path}")
|
|
53
|
+
if ".." in pure_path.parts or "." in pure_path.parts:
|
|
54
|
+
raise FalServerlessException(
|
|
55
|
+
f"Parent directory reference is not allowed: {rel_path}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return pure_path.as_posix()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# This is the same function we use in the backend to compute file hashes
|
|
62
|
+
# It is important that this is the same
|
|
63
|
+
def compute_hash(file_path: Path, mode: int) -> str:
|
|
64
|
+
file_hash = hashlib.sha256()
|
|
65
|
+
with file_path.open("rb") as f:
|
|
66
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
67
|
+
file_hash.update(chunk)
|
|
68
|
+
|
|
69
|
+
# Include metadata in hash
|
|
70
|
+
metadata_string = f"{mode}"
|
|
71
|
+
file_hash.update(metadata_string.encode("utf-8"))
|
|
72
|
+
|
|
73
|
+
return file_hash.hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_path(
|
|
77
|
+
path_str: str, base_path_str: str, files_context_dir: Optional[str] = None
|
|
78
|
+
) -> Tuple[str, str]:
|
|
79
|
+
path = Path(path_str)
|
|
80
|
+
base_path = Path(base_path_str).resolve()
|
|
81
|
+
|
|
82
|
+
if base_path.is_dir():
|
|
83
|
+
script_dir = base_path
|
|
84
|
+
else:
|
|
85
|
+
script_dir = base_path.parent
|
|
86
|
+
|
|
87
|
+
if files_context_dir:
|
|
88
|
+
context_path = Path(files_context_dir)
|
|
89
|
+
if context_path.is_absolute():
|
|
90
|
+
script_dir = context_path.resolve()
|
|
91
|
+
else:
|
|
92
|
+
script_dir = (script_dir / context_path).resolve()
|
|
93
|
+
|
|
94
|
+
absolute_path = (
|
|
95
|
+
path.resolve() if path.is_absolute() else (script_dir / path).resolve()
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
relative_path = os.path.relpath(absolute_path, script_dir)
|
|
100
|
+
relative_path = sanitize_relative_path(relative_path)
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise ValueError(f"Invalid relative path: {absolute_path}")
|
|
103
|
+
|
|
104
|
+
return absolute_path.as_posix(), relative_path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class FileMetadata:
|
|
109
|
+
size: int
|
|
110
|
+
mtime: float
|
|
111
|
+
mode: int
|
|
112
|
+
hash: str
|
|
113
|
+
relative_path: str
|
|
114
|
+
absolute_path: str
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_path(
|
|
118
|
+
cls, file_path: Path, *, relative: str, absolute: str
|
|
119
|
+
) -> "FileMetadata":
|
|
120
|
+
stat = file_path.stat()
|
|
121
|
+
# Limit allowed individual file size
|
|
122
|
+
if stat.st_size > FILE_SIZE_LIMIT:
|
|
123
|
+
raise FileTooLargeError(
|
|
124
|
+
message=f"{file_path} is larger than {FILE_SIZE_LIMIT} bytes."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
file_hash = compute_hash(file_path, stat.st_mode)
|
|
128
|
+
return FileMetadata(
|
|
129
|
+
size=stat.st_size,
|
|
130
|
+
mtime=stat.st_mtime,
|
|
131
|
+
mode=stat.st_mode,
|
|
132
|
+
hash=file_hash,
|
|
133
|
+
relative_path=relative,
|
|
134
|
+
absolute_path=absolute,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> Dict[str, str]:
|
|
138
|
+
return {
|
|
139
|
+
"size": str(self.size),
|
|
140
|
+
"mtime": str(self.mtime),
|
|
141
|
+
"mode": str(self.mode),
|
|
142
|
+
"hash": self.hash,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FileSync:
|
|
147
|
+
def __init__(self, local_file_path: str):
|
|
148
|
+
from fal.sdk import get_default_credentials # noqa: PLC0415
|
|
149
|
+
|
|
150
|
+
self.creds = get_default_credentials()
|
|
151
|
+
self.local_file_path = local_file_path
|
|
152
|
+
|
|
153
|
+
@cached_property
|
|
154
|
+
def _client(self) -> httpx.Client:
|
|
155
|
+
from fal.flags import REST_URL
|
|
156
|
+
|
|
157
|
+
return httpx.Client(
|
|
158
|
+
base_url=REST_URL,
|
|
159
|
+
headers={
|
|
160
|
+
**self.creds.to_headers(),
|
|
161
|
+
"User-Agent": USER_AGENT,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@cached_property
|
|
166
|
+
def _tus_client(self):
|
|
167
|
+
# Import it here to avoid loading unless we use it
|
|
168
|
+
from tusclient import client # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
from fal.flags import REST_URL # noqa: PLC0415
|
|
171
|
+
from fal.sdk import get_default_credentials # noqa: PLC0415
|
|
172
|
+
|
|
173
|
+
creds = get_default_credentials()
|
|
174
|
+
return client.TusClient(
|
|
175
|
+
f"{REST_URL}/files/tus",
|
|
176
|
+
headers={
|
|
177
|
+
**creds.to_headers(),
|
|
178
|
+
"User-Agent": USER_AGENT,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
183
|
+
response = self._client.request(method, path, **kwargs)
|
|
184
|
+
if response.status_code == 404:
|
|
185
|
+
raise FalServerlessException("Not Found")
|
|
186
|
+
elif response.status_code != 200:
|
|
187
|
+
try:
|
|
188
|
+
detail = response.json()["detail"]
|
|
189
|
+
except Exception:
|
|
190
|
+
detail = response.text
|
|
191
|
+
raise FalServerlessException(detail)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
def collect_files(self, paths: List[str], files_context_dir: Optional[str] = None):
|
|
195
|
+
collected_files: List[FileMetadata] = []
|
|
196
|
+
|
|
197
|
+
for path in paths:
|
|
198
|
+
abs_path_str, rel_path = normalize_path(
|
|
199
|
+
path, self.local_file_path, files_context_dir
|
|
200
|
+
)
|
|
201
|
+
abs_path = Path(abs_path_str)
|
|
202
|
+
if not abs_path.exists():
|
|
203
|
+
console.print(f"{abs_path} was not found, it will be skipped")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if abs_path.is_file():
|
|
207
|
+
metadata = FileMetadata.from_path(
|
|
208
|
+
abs_path, relative=rel_path, absolute=abs_path_str
|
|
209
|
+
)
|
|
210
|
+
collected_files.append(metadata)
|
|
211
|
+
|
|
212
|
+
elif abs_path.is_dir():
|
|
213
|
+
# Recursively walk directory tree
|
|
214
|
+
for file_path in abs_path.rglob("*"):
|
|
215
|
+
if file_path.is_file():
|
|
216
|
+
file_abs_str, file_rel_path = normalize_path(
|
|
217
|
+
str(file_path), self.local_file_path, files_context_dir
|
|
218
|
+
)
|
|
219
|
+
metadata = FileMetadata.from_path(
|
|
220
|
+
Path(file_abs_str),
|
|
221
|
+
relative=file_rel_path,
|
|
222
|
+
absolute=file_abs_str,
|
|
223
|
+
)
|
|
224
|
+
collected_files.append(metadata)
|
|
225
|
+
|
|
226
|
+
return collected_files
|
|
227
|
+
|
|
228
|
+
def check_hashes_on_server(self, hashes: List[str]) -> List[str]:
|
|
229
|
+
try:
|
|
230
|
+
response = self._request(
|
|
231
|
+
"POST", "/files/missing_hashes", json={"hashes": hashes}
|
|
232
|
+
)
|
|
233
|
+
response.raise_for_status()
|
|
234
|
+
data = response.json()
|
|
235
|
+
return data
|
|
236
|
+
except Exception as e:
|
|
237
|
+
console.print(f"{CROSS_ICON} Failed to check hashes on server: {e}")
|
|
238
|
+
|
|
239
|
+
return hashes
|
|
240
|
+
|
|
241
|
+
def upload_file_tus(
|
|
242
|
+
self,
|
|
243
|
+
file_path: str,
|
|
244
|
+
metadata: FileMetadata,
|
|
245
|
+
chunk_size: int = 5 * 1024 * 1024,
|
|
246
|
+
) -> str:
|
|
247
|
+
uploader = self._tus_client.uploader(
|
|
248
|
+
file_path, chunk_size=chunk_size, metadata=metadata.to_dict()
|
|
249
|
+
)
|
|
250
|
+
uploader.upload()
|
|
251
|
+
if not uploader.url:
|
|
252
|
+
raise AppFileUploadException("Upload failed, no URL returned", file_path)
|
|
253
|
+
|
|
254
|
+
return uploader.url
|
|
255
|
+
|
|
256
|
+
def _matches_patterns(self, relative_path: str, patterns: List[re.Pattern]) -> bool:
|
|
257
|
+
"""Check if a file matches any of the patterns."""
|
|
258
|
+
return any(pattern.search(relative_path) for pattern in patterns)
|
|
259
|
+
|
|
260
|
+
def sync_files(
|
|
261
|
+
self,
|
|
262
|
+
paths: List[str],
|
|
263
|
+
chunk_size: int = 5 * 1024 * 1024,
|
|
264
|
+
max_concurrency_uploads: int = DEFAULT_CONCURRENCY_UPLOADS,
|
|
265
|
+
files_ignore: List[re.Pattern] = [],
|
|
266
|
+
files_context_dir: Optional[str] = None,
|
|
267
|
+
) -> Tuple[List[FileMetadata], List[AppFileUploadException]]:
|
|
268
|
+
files = self.collect_files(paths, files_context_dir)
|
|
269
|
+
existing_hashes: List[FileMetadata] = []
|
|
270
|
+
uploaded_files: List[Tuple[FileMetadata, str]] = []
|
|
271
|
+
errors: List[AppFileUploadException] = []
|
|
272
|
+
|
|
273
|
+
# Filter out ignored files
|
|
274
|
+
if files_ignore:
|
|
275
|
+
filtered_files: List[FileMetadata] = []
|
|
276
|
+
for metadata in files:
|
|
277
|
+
if self._matches_patterns(metadata.relative_path, files_ignore):
|
|
278
|
+
# TODO: hide behind DEBUG flag?
|
|
279
|
+
console.print(f"Ignoring file: {metadata.relative_path}")
|
|
280
|
+
else:
|
|
281
|
+
filtered_files.append(metadata)
|
|
282
|
+
|
|
283
|
+
# Update files list
|
|
284
|
+
files = filtered_files
|
|
285
|
+
|
|
286
|
+
# Remove duplicate files by absolute path
|
|
287
|
+
unique_files: List[FileMetadata] = []
|
|
288
|
+
seen_paths = set()
|
|
289
|
+
seen_relative_paths = set()
|
|
290
|
+
for metadata in files:
|
|
291
|
+
abs_path = metadata.absolute_path
|
|
292
|
+
rel_path = metadata.relative_path
|
|
293
|
+
if abs_path not in seen_paths:
|
|
294
|
+
seen_paths.add(abs_path)
|
|
295
|
+
if rel_path in seen_relative_paths:
|
|
296
|
+
raise Exception(
|
|
297
|
+
f"Duplicate relative path '{rel_path}' found for '{abs_path}'"
|
|
298
|
+
)
|
|
299
|
+
seen_relative_paths.add(rel_path)
|
|
300
|
+
unique_files.append(metadata)
|
|
301
|
+
else:
|
|
302
|
+
if rel_path not in seen_relative_paths:
|
|
303
|
+
seen_relative_paths.add(rel_path)
|
|
304
|
+
|
|
305
|
+
files_to_check: List[FileMetadata] = []
|
|
306
|
+
for metadata in unique_files:
|
|
307
|
+
files_to_check.append(metadata)
|
|
308
|
+
|
|
309
|
+
if not files_to_check:
|
|
310
|
+
return existing_hashes, errors
|
|
311
|
+
|
|
312
|
+
hashes_to_check = [metadata.hash for metadata in files_to_check]
|
|
313
|
+
missing_hashes = self.check_hashes_on_server(hashes_to_check)
|
|
314
|
+
|
|
315
|
+
# Categorize based on server response
|
|
316
|
+
files_to_upload: List[FileMetadata] = []
|
|
317
|
+
for file in files_to_check:
|
|
318
|
+
if file.hash not in missing_hashes:
|
|
319
|
+
existing_hashes.append(file)
|
|
320
|
+
else:
|
|
321
|
+
files_to_upload.append(file)
|
|
322
|
+
|
|
323
|
+
# Upload missing files in parallel with bounded concurrency
|
|
324
|
+
if files_to_upload:
|
|
325
|
+
# Embed it here to be able to pass it to the executor
|
|
326
|
+
def upload_single_file(metadata: FileMetadata):
|
|
327
|
+
console.print(f"Uploading file: {metadata.relative_path}")
|
|
328
|
+
return self.upload_file_tus(
|
|
329
|
+
metadata.absolute_path,
|
|
330
|
+
chunk_size=chunk_size,
|
|
331
|
+
metadata=metadata,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
335
|
+
max_workers=max_concurrency_uploads
|
|
336
|
+
) as executor:
|
|
337
|
+
futures = [
|
|
338
|
+
executor.submit(upload_single_file, metadata)
|
|
339
|
+
for metadata in files_to_upload
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
concurrent.futures.wait(futures)
|
|
343
|
+
for metadata, future in zip(files_to_upload, futures):
|
|
344
|
+
if exc := future.exception():
|
|
345
|
+
errors.append(
|
|
346
|
+
AppFileUploadException(str(exc), metadata.relative_path)
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
uploaded_files.append((metadata, future.result()))
|
|
350
|
+
|
|
351
|
+
all_files = existing_hashes + [file for file, _ in uploaded_files]
|
|
352
|
+
|
|
353
|
+
# TODO: hide behind DEBUG flag?
|
|
354
|
+
console.print("File Structure:")
|
|
355
|
+
print_path_tree([m.relative_path for m in all_files])
|
|
356
|
+
|
|
357
|
+
return all_files, errors
|
|
358
|
+
|
|
359
|
+
def close(self):
|
|
360
|
+
"""Close HTTP client."""
|
|
361
|
+
self._client.close()
|
fal/sdk.py
CHANGED
|
@@ -230,6 +230,12 @@ class HostedRunStatus:
|
|
|
230
230
|
state: HostedRunState
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
@dataclass
|
|
234
|
+
class File:
|
|
235
|
+
hash: str
|
|
236
|
+
relative_path: str
|
|
237
|
+
|
|
238
|
+
|
|
233
239
|
@dataclass
|
|
234
240
|
class ApplicationInfo:
|
|
235
241
|
application_id: str
|
|
@@ -632,6 +638,7 @@ class FalServerlessConnection:
|
|
|
632
638
|
deployment_strategy: DeploymentStrategyLiteral,
|
|
633
639
|
scale: bool = True,
|
|
634
640
|
private_logs: bool = False,
|
|
641
|
+
files: list[File] | None = None,
|
|
635
642
|
) -> Iterator[isolate_proto.RegisterApplicationResult]:
|
|
636
643
|
wrapped_function = to_serialized_object(function, serialization_method)
|
|
637
644
|
if machine_requirements:
|
|
@@ -658,6 +665,12 @@ class FalServerlessConnection:
|
|
|
658
665
|
else:
|
|
659
666
|
wrapped_requirements = None
|
|
660
667
|
|
|
668
|
+
if files:
|
|
669
|
+
files = [
|
|
670
|
+
isolate_proto.File(hash=file.hash, relative_path=file.relative_path)
|
|
671
|
+
for file in files
|
|
672
|
+
]
|
|
673
|
+
|
|
661
674
|
if auth_mode == "public":
|
|
662
675
|
auth = isolate_proto.ApplicationAuthMode.PUBLIC
|
|
663
676
|
elif auth_mode == "shared":
|
|
@@ -686,6 +699,7 @@ class FalServerlessConnection:
|
|
|
686
699
|
deployment_strategy=deployment_strategy_proto,
|
|
687
700
|
scale=scale,
|
|
688
701
|
private_logs=private_logs,
|
|
702
|
+
files=files,
|
|
689
703
|
)
|
|
690
704
|
for partial_result in self.stub.RegisterApplication(request):
|
|
691
705
|
yield from_grpc(partial_result)
|
|
@@ -749,6 +763,7 @@ class FalServerlessConnection:
|
|
|
749
763
|
serialization_method: str = _DEFAULT_SERIALIZATION_METHOD,
|
|
750
764
|
machine_requirements: MachineRequirements | None = None,
|
|
751
765
|
setup_function: Callable[[], InputT] | None = None,
|
|
766
|
+
files: list[File] | None = None,
|
|
752
767
|
) -> Iterator[HostedRunResult[ResultT]]:
|
|
753
768
|
wrapped_function = to_serialized_object(function, serialization_method)
|
|
754
769
|
if machine_requirements:
|
|
@@ -774,11 +789,16 @@ class FalServerlessConnection:
|
|
|
774
789
|
)
|
|
775
790
|
else:
|
|
776
791
|
wrapped_requirements = None
|
|
777
|
-
|
|
792
|
+
if files:
|
|
793
|
+
files = [
|
|
794
|
+
isolate_proto.File(hash=file.hash, relative_path=file.relative_path)
|
|
795
|
+
for file in files
|
|
796
|
+
]
|
|
778
797
|
request = isolate_proto.HostedRun(
|
|
779
798
|
function=wrapped_function,
|
|
780
799
|
environments=environments,
|
|
781
800
|
machine_requirements=wrapped_requirements,
|
|
801
|
+
files=files,
|
|
782
802
|
)
|
|
783
803
|
if setup_function:
|
|
784
804
|
request.setup_func.MergeFrom(
|
fal/utils.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fal
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.45.1
|
|
4
4
|
Summary: fal is an easy-to-use Serverless Python Framework
|
|
5
5
|
Author: Features & Labels <support@fal.ai>
|
|
6
6
|
Requires-Python: >=3.8
|
|
@@ -31,6 +31,7 @@ Requires-Dist: python-dateutil<3,>=2.8.0
|
|
|
31
31
|
Requires-Dist: types-python-dateutil<3,>=2.8.0
|
|
32
32
|
Requires-Dist: dateparser<2,>=1.2.0
|
|
33
33
|
Requires-Dist: types-dateparser<2,>=1.2.0
|
|
34
|
+
Requires-Dist: tuspy==1.1.0
|
|
34
35
|
Requires-Dist: importlib-metadata>=4.4; python_version < "3.10"
|
|
35
36
|
Requires-Dist: msgpack<2,>=1.0.7
|
|
36
37
|
Requires-Dist: websockets>=12.0
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
fal/__init__.py,sha256=wXs1G0gSc7ZK60-bHe-B2m0l_sA6TrFk4BxY0tMoLe8,784
|
|
2
2
|
fal/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
-
fal/_fal_version.py,sha256=
|
|
3
|
+
fal/_fal_version.py,sha256=hS2nkZk1TAFymzpFeuitVosHfjkjk8EjDI7oUpx2yT8,706
|
|
4
4
|
fal/_serialization.py,sha256=npXNsFJ5G7jzBeBIyVMH01Ww34mGY4XWhHpRbSrTtnQ,7598
|
|
5
5
|
fal/_version.py,sha256=1BbTFnucNC_6ldKJ_ZoC722_UkW4S9aDBSW9L0fkKAw,2315
|
|
6
|
-
fal/api.py,sha256=
|
|
7
|
-
fal/app.py,sha256=
|
|
6
|
+
fal/api.py,sha256=tW3B4AbprSnpKDtGb_mjUwUwImd9VaaxUpMtS0f45ks,51860
|
|
7
|
+
fal/app.py,sha256=MsHx1UeUbV-FLqAifS6XkUfa0jYYl00UK51yHuqY9fE,27739
|
|
8
8
|
fal/apps.py,sha256=pzCd2mrKl5J_4oVc40_pggvPtFahXBCdrZXWpnaEJVs,12130
|
|
9
9
|
fal/config.py,sha256=1HRaOJFOAjB7fbQoEPCSH85gMvEEMIMPeupVWgrHVgU,3572
|
|
10
10
|
fal/container.py,sha256=FTsa5hOW4ars-yV1lUoc0BNeIIvAZcpw7Ftyt3A4m_w,2000
|
|
11
|
+
fal/file_sync.py,sha256=Ql8mB5tb-qz8z-IK3QmhD_bxWrx2tay8bLdeykzPIr8,12143
|
|
11
12
|
fal/files.py,sha256=9hA7mC3Xm794I-P2_YMf0QRebrnBIDz_kUnUd4O3BiQ,7904
|
|
12
13
|
fal/flags.py,sha256=QonyDM7R2GqfAB1bJr46oriu-fHJCkpUwXuSdanePWg,987
|
|
13
14
|
fal/project.py,sha256=QgfYfMKmNobMPufrAP_ga1FKcIAlSbw18Iar1-0qepo,2650
|
|
14
15
|
fal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
16
|
fal/rest_client.py,sha256=kGBGmuyHfX1lR910EoKCYPjsyU8MdXawT_cW2q8Sajc,568
|
|
16
|
-
fal/sdk.py,sha256=
|
|
17
|
+
fal/sdk.py,sha256=Nk3dbVyYXQi2XtzBd5Bdex-6TK8iYMS6vPDqJvlhxyk,29431
|
|
17
18
|
fal/sync.py,sha256=ZuIJA2-hTPNANG9B_NNJZUsO68EIdTH0dc9MzeVE2VU,4340
|
|
18
|
-
fal/utils.py,sha256=
|
|
19
|
+
fal/utils.py,sha256=sYjJLl68AG21BOmmE9H2aW161DMvCh3qIduH-CyZhk4,2179
|
|
19
20
|
fal/workflows.py,sha256=Zl4f6Bs085hY40zmqScxDUyCu7zXkukDbW02iYOLTTI,14805
|
|
20
21
|
fal/auth/__init__.py,sha256=mtyQou8DGHC-COjW9WbtRyyzjyt7fMlhVmsB4U-CBh4,6509
|
|
21
22
|
fal/auth/auth0.py,sha256=g5OgEKe4rsbkLQp6l7EauOAVL6WsmKjuA1wmzmyvvhc,5354
|
|
@@ -28,7 +29,7 @@ fal/cli/auth.py,sha256=ZLjxuF4LobETJ2CLGMj_QurE0PiJxzKdFJZkux8uLHM,5977
|
|
|
28
29
|
fal/cli/cli_nested_json.py,sha256=veSZU8_bYV3Iu1PAoxt-4BMBraNIqgH5nughbs2UKvE,13539
|
|
29
30
|
fal/cli/create.py,sha256=a8WDq-nJLFTeoIXqpb5cr7GR7YR9ZZrQCawNm34KXXE,627
|
|
30
31
|
fal/cli/debug.py,sha256=mTCjSpEZaNKcX225VZtry-BspFKSHURUuxUFuX6x5Cc,1488
|
|
31
|
-
fal/cli/deploy.py,sha256=
|
|
32
|
+
fal/cli/deploy.py,sha256=fxqfYmLgFveSNMF-13rlAqwJ531dP2iIyTAuxBI9YEo,8987
|
|
32
33
|
fal/cli/doctor.py,sha256=8SZrYG9Ku0F6LLUHtFdKopdIgZfFkw5E3Mwrxa9KOSk,1613
|
|
33
34
|
fal/cli/files.py,sha256=-j0q4g53A7CWSczGLdfeUCTSd4zXoV3pfZFdman7JOw,3450
|
|
34
35
|
fal/cli/keys.py,sha256=iQVMr3WT8CUqSQT3qeCCiy6rRwoux9F-UEaC4bCwMWo,3754
|
|
@@ -36,15 +37,15 @@ fal/cli/main.py,sha256=LDy3gze9TRsvGa4uSNc8NMFmWMLpsyoC-msteICNiso,3371
|
|
|
36
37
|
fal/cli/parser.py,sha256=siSY1kxqczZIs3l_jLwug_BpVzY_ZqHpewON3am83Ow,6658
|
|
37
38
|
fal/cli/profile.py,sha256=PAY_ffifCT71VJ8VxfDVaXPT0U1oN8drvWZDFRXwvek,6678
|
|
38
39
|
fal/cli/queue.py,sha256=9Kid3zR6VOFfAdDgnqi2TNN4ocIv5Vs61ASEZnwMa9o,2713
|
|
39
|
-
fal/cli/run.py,sha256=
|
|
40
|
+
fal/cli/run.py,sha256=xFcNxBWD60PTbW51R4qqLifUL7dqgYYvw38Nz-18ZWc,1365
|
|
40
41
|
fal/cli/runners.py,sha256=OWSsvk01IkwQhibewZQgC-iWMOXl43tWJSi9F81x8n4,17481
|
|
41
42
|
fal/cli/secrets.py,sha256=HfIeO2IZpCEiBC6Cs5Kpi3zckfDnc7GsLwLdgj3NnPU,3085
|
|
42
43
|
fal/cli/teams.py,sha256=_JcNcf659ZoLBFOxKnVP5A6Pyk1jY1vh4_xzMweYIDo,1285
|
|
43
44
|
fal/console/__init__.py,sha256=lGPUuTqIM9IKTa1cyyA-MA2iZJKVHp2YydsITZVlb6g,148
|
|
44
45
|
fal/console/icons.py,sha256=De9MfFaSkO2Lqfne13n3PrYfTXJVIzYZVqYn5BWsdrA,108
|
|
45
46
|
fal/console/ux.py,sha256=KMQs3UHQvVHDxDQQqlot-WskVKoMQXOE3jiVkkfmIMY,356
|
|
46
|
-
fal/exceptions/__init__.py,sha256=
|
|
47
|
-
fal/exceptions/_base.py,sha256=
|
|
47
|
+
fal/exceptions/__init__.py,sha256=4hq-sy3dMZs6YxvbO_p6R-bK4Tzf7ubvA8AyUR0GVPo,349
|
|
48
|
+
fal/exceptions/_base.py,sha256=PLSOHQs7lftDaRYDHKz9xkB6orQvynmUTi4DrdPnYMs,1797
|
|
48
49
|
fal/exceptions/_cuda.py,sha256=L3qvDNaPTthp95IFSBI6pMt3YbRfn1H0inQkj_7NKF8,1719
|
|
49
50
|
fal/exceptions/auth.py,sha256=fHea3SIeguInJVB5M33IuP4I5e_pVEifck1C_XJTYvc,351
|
|
50
51
|
fal/logging/__init__.py,sha256=U7DhMpnNqmVdC2XCT5xZkNmYhpL0Q85iDYPeSo_56LU,1532
|
|
@@ -143,8 +144,8 @@ openapi_fal_rest/models/workflow_node_type.py,sha256=-FzyeY2bxcNmizKbJI8joG7byRi
|
|
|
143
144
|
openapi_fal_rest/models/workflow_schema.py,sha256=4K5gsv9u9pxx2ItkffoyHeNjBBYf6ur5bN4m_zePZNY,2019
|
|
144
145
|
openapi_fal_rest/models/workflow_schema_input.py,sha256=2OkOXWHTNsCXHWS6EGDFzcJKkW5FIap-2gfO233EvZQ,1191
|
|
145
146
|
openapi_fal_rest/models/workflow_schema_output.py,sha256=EblwSPAGfWfYVWw_WSSaBzQVju296is9o28rMBAd0mc,1196
|
|
146
|
-
fal-1.
|
|
147
|
-
fal-1.
|
|
148
|
-
fal-1.
|
|
149
|
-
fal-1.
|
|
150
|
-
fal-1.
|
|
147
|
+
fal-1.45.1.dist-info/METADATA,sha256=DlOG91iUSQLMXxdumfXsxAb2wV93HO_Or6EWj5RlYk0,4185
|
|
148
|
+
fal-1.45.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
149
|
+
fal-1.45.1.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
|
|
150
|
+
fal-1.45.1.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
|
|
151
|
+
fal-1.45.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|