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 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.44.1'
32
- __version_tuple__ = version_tuple = (1, 44, 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
- _log_printer = IsolateLogPrinter(debug=flags.DEBUG)
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(default_factory=ThreadPoolExecutor)
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", 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
 
@@ -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
@@ -56,6 +56,7 @@ def load_function_from(
56
56
  fal._serialization.include_package_from_path(file_path)
57
57
 
58
58
  target = module[function_name]
59
+
59
60
  endpoints = ["/"]
60
61
  if isinstance(target, type) and issubclass(target, App):
61
62
  app_name = target.app_name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fal
3
- Version: 1.44.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=73blOGzFK4FrWGbYE2HJnV8ith86Rc7LVyHw9fSMK94,706
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=6LkGbbqGUC4tcMBlTL-l7DBkl7t9FpZFSZY1doIdI5o,50284
7
- fal/app.py,sha256=dZm2PW4zLH7JBeueN-s3vvpWty66M-ujCSgN33ipXQY,26909
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=13NXGsuoiXM94zzZi9p7PwWSeucYH8Yez6obWa64LBc,28891
17
+ fal/sdk.py,sha256=Nk3dbVyYXQi2XtzBd5Bdex-6TK8iYMS6vPDqJvlhxyk,29431
17
18
  fal/sync.py,sha256=ZuIJA2-hTPNANG9B_NNJZUsO68EIdTH0dc9MzeVE2VU,4340
18
- fal/utils.py,sha256=Xt-05UzuyNMkjrYAM3eLX9fMdm2VzSFNfM6mBtIrh2o,2178
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=vX8TpLwoyoLZnK03B005MEBi3wP0M5Pm6AKQ2tHOyjM,8903
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=nAC12Qss4Fg1XmV0qOS9RdGNLYcdoHeRgQMvbTN4P9I,1202
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=m2okJEpax11mnwmoqO_pCGtbt-FvzKiiuMhKo2ok-_8,270
47
- fal/exceptions/_base.py,sha256=LwzpMaW_eYQEC5s26h2qGXbNA-S4bOqC8s-bMCX6HjE,1491
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.44.1.dist-info/METADATA,sha256=qePSYb_G_ZJHCGhXE9Mf9H0dFkVUmlQkMe3Ey_Y4ziQ,4157
147
- fal-1.44.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
148
- fal-1.44.1.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
149
- fal-1.44.1.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
150
- fal-1.44.1.dist-info/RECORD,,
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