fal 1.3.0__py3-none-any.whl → 1.3.2__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/__main__.py CHANGED
@@ -1,4 +1,6 @@
1
+ import sys
2
+
1
3
  from .cli import main
2
4
 
3
5
  if __name__ == "__main__":
4
- main()
6
+ sys.exit(main())
fal/_fal_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.3.0'
16
- __version_tuple__ = version_tuple = (1, 3, 0)
15
+ __version__ = version = '1.3.2'
16
+ __version_tuple__ = version_tuple = (1, 3, 2)
fal/api.py CHANGED
@@ -425,6 +425,7 @@ class FalServerlessHost(Host):
425
425
  application_name: str | None = None,
426
426
  application_auth_mode: Literal["public", "shared", "private"] | None = None,
427
427
  metadata: dict[str, Any] | None = None,
428
+ deployment_strategy: Literal["recreate", "rolling"] = "recreate",
428
429
  ) -> str | None:
429
430
  environment_options = options.environment.copy()
430
431
  environment_options.setdefault("python_version", active_python())
@@ -477,6 +478,7 @@ class FalServerlessHost(Host):
477
478
  application_auth_mode=application_auth_mode,
478
479
  machine_requirements=machine_requirements,
479
480
  metadata=metadata,
481
+ deployment_strategy=deployment_strategy,
480
482
  ):
481
483
  for log in partial_result.logs:
482
484
  self._log_printer.print(log)
fal/app.py CHANGED
@@ -17,6 +17,7 @@ from fastapi import FastAPI
17
17
  import fal.api
18
18
  from fal._serialization import include_modules_from
19
19
  from fal.api import RouteSignature
20
+ from fal.exceptions import RequestCancelledException
20
21
  from fal.logging import get_logger
21
22
  from fal.toolkit.file.providers import fal as fal_provider_module
22
23
 
@@ -143,7 +144,7 @@ class AppClient:
143
144
  with httpx.Client() as client:
144
145
  retries = 100
145
146
  for _ in range(retries):
146
- resp = client.get(info.url + "/health")
147
+ resp = client.get(info.url + "/health", timeout=60)
147
148
 
148
149
  if resp.is_success:
149
150
  break
@@ -205,6 +206,14 @@ class App(fal.api.BaseServable):
205
206
  "Running apps through SDK is not implemented yet."
206
207
  )
207
208
 
209
+ @classmethod
210
+ def get_endpoints(cls) -> list[str]:
211
+ return [
212
+ signature.path
213
+ for _, endpoint in inspect.getmembers(cls, inspect.isfunction)
214
+ if (signature := getattr(endpoint, "route_signature", None))
215
+ ]
216
+
208
217
  def collect_routes(self) -> dict[RouteSignature, Callable[..., Any]]:
209
218
  return {
210
219
  signature: endpoint
@@ -264,6 +273,17 @@ class App(fal.api.BaseServable):
264
273
  )
265
274
  return response
266
275
 
276
+ @app.exception_handler(RequestCancelledException)
277
+ async def value_error_exception_handler(
278
+ request, exc: RequestCancelledException
279
+ ):
280
+ from fastapi.responses import JSONResponse
281
+
282
+ # A 499 status code is not an officially recognized HTTP status code,
283
+ # but it is sometimes used by servers to indicate that a client has closed
284
+ # the connection without receiving a response
285
+ return JSONResponse({"detail": str(exc)}, 499)
286
+
267
287
  def _add_extra_routes(self, app: FastAPI):
268
288
  @app.get("/health")
269
289
  def health():
fal/apps.py CHANGED
@@ -97,6 +97,15 @@ class RequestHandle:
97
97
  else:
98
98
  raise ValueError(f"Unknown status: {data['status']}")
99
99
 
100
+ def cancel(self) -> None:
101
+ """Cancel an async inference request."""
102
+ url = (
103
+ _QUEUE_URL_FORMAT.format(app_id=self.app_id)
104
+ + f"/requests/{self.request_id}/cancel"
105
+ )
106
+ response = _HTTP_CLIENT.put(url, headers=self._creds.to_headers())
107
+ response.raise_for_status()
108
+
100
109
  def iter_events(
101
110
  self,
102
111
  *,
fal/cli/_utils.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from fal.files import find_pyproject_toml, parse_pyproject_toml
4
+
5
+
6
+ def is_app_name(app_ref: tuple[str, str | None]) -> bool:
7
+ is_single_file = app_ref[1] is None
8
+ is_python_file = app_ref[0].endswith(".py")
9
+
10
+ return is_single_file and not is_python_file
11
+
12
+
13
+ def get_app_data_from_toml(app_name):
14
+ toml_path = find_pyproject_toml()
15
+
16
+ if toml_path is None:
17
+ raise ValueError("No pyproject.toml file found.")
18
+
19
+ fal_data = parse_pyproject_toml(toml_path)
20
+ apps = fal_data.get("apps", {})
21
+
22
+ try:
23
+ app_data = apps[app_name]
24
+ except KeyError:
25
+ raise ValueError(f"App {app_name} not found in pyproject.toml")
26
+
27
+ try:
28
+ app_ref = app_data["ref"]
29
+ except KeyError:
30
+ raise ValueError(f"App {app_name} does not have a ref key in pyproject.toml")
31
+
32
+ app_auth = app_data.get("auth", "private")
33
+
34
+ return app_ref, app_auth
fal/cli/deploy.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import argparse
2
2
  from collections import namedtuple
3
3
  from pathlib import Path
4
+ from typing import Optional, Union
4
5
 
6
+ from ._utils import get_app_data_from_toml, is_app_name
5
7
  from .parser import FalClientParser, RefAction
6
8
 
7
9
  User = namedtuple("User", ["user_id", "username"])
@@ -60,11 +62,13 @@ def _get_user() -> User:
60
62
  raise FalServerlessError(f"Could not parse the user data: {e}")
61
63
 
62
64
 
63
- def _deploy(args):
65
+ def _deploy_from_reference(
66
+ app_ref: tuple[Optional[Union[Path, str]], ...], app_name: str, auth: str, args
67
+ ):
64
68
  from fal.api import FalServerlessError, FalServerlessHost
65
69
  from fal.utils import load_function_from
66
70
 
67
- file_path, func_name = args.app_ref
71
+ file_path, func_name = app_ref
68
72
  if file_path is None:
69
73
  # Try to find a python file in the current directory
70
74
  options = list(Path(".").glob("*.py"))
@@ -77,23 +81,27 @@ def _deploy(args):
77
81
  )
78
82
 
79
83
  [file_path] = options
80
- file_path = str(file_path)
84
+ file_path = str(file_path) # type: ignore
81
85
 
82
86
  user = _get_user()
83
87
  host = FalServerlessHost(args.host)
84
- isolated_function, app_name, app_auth = load_function_from(
88
+ loaded = load_function_from(
85
89
  host,
86
- file_path,
87
- func_name,
90
+ file_path, # type: ignore
91
+ func_name, # type: ignore
88
92
  )
89
- app_name = args.app_name or app_name
90
- app_auth = args.auth or app_auth or "private"
93
+ isolated_function = loaded.function
94
+ app_name = app_name or loaded.app_name # type: ignore
95
+ app_auth = auth or loaded.app_auth or "private"
96
+ deployment_strategy = args.strategy or "default"
97
+
91
98
  app_id = host.register(
92
99
  func=isolated_function.func,
93
100
  options=isolated_function.options,
94
101
  application_name=app_name,
95
102
  application_auth_mode=app_auth,
96
103
  metadata=isolated_function.options.host.get("metadata", {}),
104
+ deployment_strategy=deployment_strategy,
97
105
  )
98
106
 
99
107
  if app_id:
@@ -106,12 +114,36 @@ def _deploy(args):
106
114
  "Registered a new revision for function "
107
115
  f"'{app_name}' (revision='{app_id}')."
108
116
  )
109
- args.console.print(
110
- f"Playground: https://fal.ai/models/{user.username}/{app_name}"
111
- )
112
- args.console.print(
113
- f"Endpoint: https://{gateway_host}/{user.username}/{app_name}"
114
- )
117
+ args.console.print("Playground:")
118
+ for endpoint in loaded.endpoints:
119
+ args.console.print(
120
+ f"\thttps://fal.ai/models/{user.username}/{app_name}{endpoint}"
121
+ )
122
+ args.console.print("Endpoints:")
123
+ for endpoint in loaded.endpoints:
124
+ args.console.print(
125
+ f"\thttps://{gateway_host}/{user.username}/{app_name}{endpoint}"
126
+ )
127
+
128
+
129
+ def _deploy(args):
130
+ # my-app
131
+ if is_app_name(args.app_ref):
132
+ # we do not allow --app-name and --auth to be used with app name
133
+ if args.app_name or args.auth:
134
+ raise ValueError("Cannot use --app-name or --auth with app name reference.")
135
+
136
+ app_name = args.app_ref[0]
137
+ app_ref, app_auth = get_app_data_from_toml(app_name)
138
+ file_path, func_name = RefAction.split_ref(app_ref)
139
+
140
+ # path/to/myfile.py::MyApp
141
+ else:
142
+ file_path, func_name = args.app_ref
143
+ app_name = args.app_name
144
+ app_auth = args.auth
145
+
146
+ _deploy_from_reference((file_path, func_name), app_name, app_auth, args)
115
147
 
116
148
 
117
149
  def add_parser(main_subparsers, parents):
@@ -122,14 +154,22 @@ def add_parser(main_subparsers, parents):
122
154
  raise argparse.ArgumentTypeError(f"{option} is not a auth option")
123
155
  return option
124
156
 
125
- deploy_help = "Deploy a fal application."
157
+ deploy_help = (
158
+ "Deploy a fal application. "
159
+ "If no app reference is provided, the command will look for a "
160
+ "pyproject.toml file with a [tool.fal.apps] section and deploy the "
161
+ "application specified with the provided app name."
162
+ )
163
+
126
164
  epilog = (
127
165
  "Examples:\n"
128
166
  " fal deploy\n"
129
167
  " fal deploy path/to/myfile.py\n"
130
168
  " fal deploy path/to/myfile.py::MyApp\n"
131
169
  " fal deploy path/to/myfile.py::MyApp --app-name myapp --auth public\n"
170
+ " fal deploy my-app\n"
132
171
  )
172
+
133
173
  parser = main_subparsers.add_parser(
134
174
  "deploy",
135
175
  parents=[*parents, FalClientParser(add_help=False)],
@@ -137,21 +177,36 @@ def add_parser(main_subparsers, parents):
137
177
  help=deploy_help,
138
178
  epilog=epilog,
139
179
  )
180
+
140
181
  parser.add_argument(
141
182
  "app_ref",
142
183
  nargs="?",
143
184
  action=RefAction,
144
185
  help=(
145
- "Application reference. " "For example: `myfile.py::MyApp`, `myfile.py`."
186
+ "Application reference. Either a file path or a file path and a "
187
+ "function name separated by '::'. If no reference is provided, the "
188
+ "command will look for a pyproject.toml file with a [tool.fal.apps] "
189
+ "section and deploy the application specified with the provided app name.\n"
190
+ "File path example: path/to/myfile.py::MyApp\n"
191
+ "App name example: my-app\n"
146
192
  ),
147
193
  )
194
+
148
195
  parser.add_argument(
149
196
  "--app-name",
150
197
  help="Application name to deploy with.",
151
198
  )
199
+
152
200
  parser.add_argument(
153
201
  "--auth",
154
202
  type=valid_auth_option,
155
203
  help="Application authentication mode (private, public).",
156
204
  )
205
+ parser.add_argument(
206
+ "--strategy",
207
+ choices=["default", "rolling"],
208
+ help="Deployment strategy.",
209
+ default="default",
210
+ )
211
+
157
212
  parser.set_defaults(func=_deploy)
fal/cli/parser.py CHANGED
@@ -14,14 +14,18 @@ class RefAction(argparse.Action):
14
14
  kwargs.setdefault("default", (None, None))
15
15
  super().__init__(*args, **kwargs)
16
16
 
17
- def __call__(self, parser, args, values, option_string=None): # noqa: ARG002
18
- if isinstance(values, tuple):
19
- file_path, obj_path = values
20
- elif values.find("::") > 1:
21
- file_path, obj_path = values.split("::", 1)
22
- else:
23
- file_path, obj_path = values, None
17
+ @classmethod
18
+ def split_ref(cls, value):
19
+ if isinstance(value, tuple):
20
+ return value
21
+
22
+ if value.find("::") > 1:
23
+ return value.split("::", 1)
24
24
 
25
+ return value, None
26
+
27
+ def __call__(self, parser, args, values, option_string=None): # noqa: ARG002
28
+ file_path, obj_path = self.split_ref(values)
25
29
  setattr(args, self.dest, (file_path, obj_path))
26
30
 
27
31
 
fal/cli/run.py CHANGED
@@ -1,3 +1,4 @@
1
+ from ._utils import get_app_data_from_toml, is_app_name
1
2
  from .parser import FalClientParser, RefAction
2
3
 
3
4
 
@@ -6,7 +7,17 @@ def _run(args):
6
7
  from fal.utils import load_function_from
7
8
 
8
9
  host = FalServerlessHost(args.host)
9
- isolated_function, _, _ = load_function_from(host, *args.func_ref)
10
+
11
+ if is_app_name(args.func_ref):
12
+ app_name = args.func_ref[0]
13
+ app_ref, _ = get_app_data_from_toml(app_name)
14
+ file_path, func_name = RefAction.split_ref(app_ref)
15
+ else:
16
+ file_path, func_name = args.func_ref
17
+
18
+ loaded = load_function_from(host, file_path, func_name)
19
+
20
+ isolated_function = loaded.function
10
21
  # let our exc handlers handle UserFunctionException
11
22
  isolated_function.reraise = False
12
23
  isolated_function()
@@ -1,4 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from ._base import AppException, FalServerlessException, FieldException # noqa: F401
3
+ from ._base import (
4
+ AppException, # noqa: F401
5
+ FalServerlessException, # noqa: F401
6
+ FieldException, # noqa: F401
7
+ RequestCancelledException, # noqa: F401
8
+ )
4
9
  from ._cuda import CUDAOutOfMemoryException # noqa: F401
fal/exceptions/_base.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Sequence
5
4
 
6
5
 
7
6
  class FalServerlessException(Exception):
@@ -40,11 +39,20 @@ class FieldException(FalServerlessException):
40
39
  status_code: int = 422
41
40
  type: str = "value_error"
42
41
 
43
- def to_pydantic_format(self) -> Sequence[dict]:
44
- return [
45
- {
46
- "loc": ["body", self.field],
47
- "msg": self.message,
48
- "type": self.type,
49
- }
50
- ]
42
+ def to_pydantic_format(self) -> dict[str, list[dict]]:
43
+ return dict(
44
+ detail=[
45
+ {
46
+ "loc": ["body", self.field],
47
+ "msg": self.message,
48
+ "type": self.type,
49
+ }
50
+ ]
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class RequestCancelledException(FalServerlessException):
56
+ """Exception raised when the request is cancelled by the client."""
57
+
58
+ message: str = "Request cancelled by the client."
fal/files.py ADDED
@@ -0,0 +1,81 @@
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional, Sequence, Tuple, Union
4
+
5
+ import tomli
6
+
7
+
8
+ @lru_cache
9
+ def _load_toml(path: Union[Path, str]) -> Dict[str, Any]:
10
+ with open(path, "rb") as f:
11
+ return tomli.load(f)
12
+
13
+
14
+ @lru_cache
15
+ def _cached_resolve(path: Path) -> Path:
16
+ return path.resolve()
17
+
18
+
19
+ @lru_cache
20
+ def find_project_root(srcs: Optional[Sequence[str]]) -> Tuple[Path, str]:
21
+ """Return a directory containing .git, or pyproject.toml.
22
+
23
+ That directory will be a common parent of all files and directories
24
+ passed in `srcs`.
25
+
26
+ If no directory in the tree contains a marker that would specify it's the
27
+ project root, the root of the file system is returned.
28
+
29
+ Returns a two-tuple with the first element as the project root path and
30
+ the second element as a string describing the method by which the
31
+ project root was discovered.
32
+ """
33
+ if not srcs:
34
+ srcs = [str(_cached_resolve(Path.cwd()))]
35
+
36
+ path_srcs = [_cached_resolve(Path(Path.cwd(), src)) for src in srcs]
37
+
38
+ # A list of lists of parents for each 'src'. 'src' is included as a
39
+ # "parent" of itself if it is a directory
40
+ src_parents = [
41
+ list(path.parents) + ([path] if path.is_dir() else []) for path in path_srcs
42
+ ]
43
+
44
+ common_base = max(
45
+ set.intersection(*(set(parents) for parents in src_parents)),
46
+ key=lambda path: path.parts,
47
+ )
48
+
49
+ for directory in (common_base, *common_base.parents):
50
+ if (directory / ".git").exists():
51
+ return directory, ".git directory"
52
+
53
+ if (directory / "pyproject.toml").is_file():
54
+ pyproject_toml = _load_toml(directory / "pyproject.toml")
55
+ if "fal" in pyproject_toml.get("tool", {}):
56
+ return directory, "pyproject.toml"
57
+
58
+ return directory, "file system root"
59
+
60
+
61
+ def find_pyproject_toml(
62
+ path_search_start: Optional[Tuple[str, ...]] = None,
63
+ ) -> Optional[str]:
64
+ """Find the absolute filepath to a pyproject.toml if it exists"""
65
+ path_project_root, _ = find_project_root(path_search_start)
66
+ path_pyproject_toml = path_project_root / "pyproject.toml"
67
+
68
+ if path_pyproject_toml.is_file():
69
+ return str(path_pyproject_toml)
70
+
71
+
72
+ def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
73
+ """Parse a pyproject toml file, pulling out relevant parts for fal.
74
+
75
+ If parsing fails, will raise a tomli.TOMLDecodeError.
76
+ """
77
+ pyproject_toml = _load_toml(path_config)
78
+ config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("fal", {})
79
+ config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
80
+
81
+ return config
fal/sdk.py CHANGED
@@ -275,6 +275,33 @@ class KeyScope(enum.Enum):
275
275
  raise ValueError(f"Unknown KeyScope: {proto}")
276
276
 
277
277
 
278
+ class DeploymentStrategy(enum.Enum):
279
+ RECREATE = "recreate"
280
+ ROLLING = "rolling"
281
+
282
+ @staticmethod
283
+ def from_proto(
284
+ proto: isolate_proto.DeploymentStrategy.ValueType | None,
285
+ ) -> DeploymentStrategy:
286
+ if proto is None:
287
+ return DeploymentStrategy.RECREATE
288
+
289
+ if proto is isolate_proto.DeploymentStrategy.RECREATE:
290
+ return DeploymentStrategy.RECREATE
291
+ elif proto is isolate_proto.DeploymentStrategy.ROLLING:
292
+ return DeploymentStrategy.ROLLING
293
+ else:
294
+ raise ValueError(f"Unknown DeploymentStrategy: {proto}")
295
+
296
+ def to_proto(self) -> isolate_proto.DeploymentStrategy.ValueType:
297
+ if self is DeploymentStrategy.RECREATE:
298
+ return isolate_proto.DeploymentStrategy.RECREATE
299
+ elif self is DeploymentStrategy.ROLLING:
300
+ return isolate_proto.DeploymentStrategy.ROLLING
301
+ else:
302
+ raise ValueError(f"Unknown DeploymentStrategy: {self}")
303
+
304
+
278
305
  @from_grpc.register(isolate_proto.ApplicationInfo)
279
306
  def _from_grpc_application_info(
280
307
  message: isolate_proto.ApplicationInfo,
@@ -457,6 +484,7 @@ class FalServerlessConnection:
457
484
  serialization_method: str = _DEFAULT_SERIALIZATION_METHOD,
458
485
  machine_requirements: MachineRequirements | None = None,
459
486
  metadata: dict[str, Any] | None = None,
487
+ deployment_strategy: Literal["recreate", "rolling"] = "recreate",
460
488
  ) -> Iterator[isolate_proto.RegisterApplicationResult]:
461
489
  wrapped_function = to_serialized_object(function, serialization_method)
462
490
  if machine_requirements:
@@ -488,6 +516,10 @@ class FalServerlessConnection:
488
516
  struct_metadata = isolate_proto.Struct()
489
517
  struct_metadata.update(metadata)
490
518
 
519
+ deployment_strategy_proto = DeploymentStrategy[
520
+ deployment_strategy.upper()
521
+ ].to_proto()
522
+
491
523
  request = isolate_proto.RegisterApplicationRequest(
492
524
  function=wrapped_function,
493
525
  environments=environments,
@@ -495,6 +527,7 @@ class FalServerlessConnection:
495
527
  application_name=application_name,
496
528
  auth_mode=auth_mode,
497
529
  metadata=struct_metadata,
530
+ deployment_strategy=deployment_strategy_proto,
498
531
  )
499
532
  for partial_result in self.stub.RegisterApplication(request):
500
533
  yield from_grpc(partial_result)
fal/toolkit/file/file.py CHANGED
@@ -149,14 +149,31 @@ class File(BaseModel):
149
149
  path: str | Path,
150
150
  content_type: Optional[str] = None,
151
151
  repository: FileRepository | RepositoryId = DEFAULT_REPOSITORY,
152
+ multipart: bool | None = None,
152
153
  ) -> File:
153
154
  file_path = Path(path)
154
155
  if not file_path.exists():
155
156
  raise FileNotFoundError(f"File {file_path} does not exist")
156
- with open(file_path, "rb") as f:
157
- data = f.read()
158
- return File.from_bytes(
159
- data, content_type, file_name=file_path.name, repository=repository
157
+
158
+ repo = (
159
+ repository
160
+ if isinstance(repository, FileRepository)
161
+ else get_builtin_repository(repository)
162
+ )
163
+
164
+ content_type = content_type or "application/octet-stream"
165
+
166
+ url, data = repo.save_file(
167
+ file_path,
168
+ content_type=content_type,
169
+ multipart=multipart,
170
+ )
171
+ return cls(
172
+ url=url,
173
+ file_data=data.data if data else None,
174
+ content_type=content_type,
175
+ file_name=file_path.name,
176
+ file_size=file_path.stat().st_size,
160
177
  )
161
178
 
162
179
  def as_bytes(self) -> bytes:
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
  import json
5
+ import math
5
6
  import os
6
7
  from base64 import b64encode
7
8
  from dataclasses import dataclass
9
+ from pathlib import Path
8
10
  from urllib.error import HTTPError
9
11
  from urllib.request import Request, urlopen
10
12
 
@@ -87,11 +89,185 @@ class FalFileRepository(FalFileRepositoryBase):
87
89
  return self._save(file, "gcs")
88
90
 
89
91
 
92
+ class MultipartUpload:
93
+ MULTIPART_THRESHOLD = 100 * 1024 * 1024
94
+ MULTIPART_CHUNK_SIZE = 100 * 1024 * 1024
95
+ MULTIPART_MAX_CONCURRENCY = 10
96
+
97
+ def __init__(
98
+ self,
99
+ file_path: str | Path,
100
+ chunk_size: int | None = None,
101
+ content_type: str | None = None,
102
+ max_concurrency: int | None = None,
103
+ ) -> None:
104
+ self.file_path = file_path
105
+ self.chunk_size = chunk_size or self.MULTIPART_CHUNK_SIZE
106
+ self.content_type = content_type or "application/octet-stream"
107
+ self.max_concurrency = max_concurrency or self.MULTIPART_MAX_CONCURRENCY
108
+
109
+ self._parts: list[dict] = []
110
+
111
+ key_creds = key_credentials()
112
+ if not key_creds:
113
+ raise FileUploadException("FAL_KEY must be set")
114
+
115
+ key_id, key_secret = key_creds
116
+
117
+ self._auth_headers = {
118
+ "Authorization": f"Key {key_id}:{key_secret}",
119
+ }
120
+ grpc_host = os.environ.get("FAL_HOST", "api.alpha.fal.ai")
121
+ rest_host = grpc_host.replace("api", "rest", 1)
122
+ self._storage_upload_url = f"https://{rest_host}/storage/upload"
123
+
124
+ def create(self):
125
+ try:
126
+ req = Request(
127
+ f"{self._storage_upload_url}/initiate-multipart",
128
+ method="POST",
129
+ headers={
130
+ **self._auth_headers,
131
+ "Accept": "application/json",
132
+ "Content-Type": "application/json",
133
+ },
134
+ data=json.dumps(
135
+ {
136
+ "file_name": os.path.basename(self.file_path),
137
+ "content_type": self.content_type,
138
+ }
139
+ ).encode(),
140
+ )
141
+ with urlopen(req) as response:
142
+ result = json.load(response)
143
+ self._upload_id = result["upload_id"]
144
+ self._file_url = result["file_url"]
145
+ except HTTPError as exc:
146
+ raise FileUploadException(
147
+ f"Error initiating upload. Status {exc.status}: {exc.reason}"
148
+ )
149
+
150
+ def _upload_part(self, url: str, part_number: int) -> dict:
151
+ with open(self.file_path, "rb") as f:
152
+ start = (part_number - 1) * self.chunk_size
153
+ f.seek(start)
154
+ data = f.read(self.chunk_size)
155
+ req = Request(
156
+ url,
157
+ method="PUT",
158
+ headers={"Content-Type": self.content_type},
159
+ data=data,
160
+ )
161
+
162
+ try:
163
+ with urlopen(req) as resp:
164
+ return {
165
+ "part_number": part_number,
166
+ "etag": resp.headers["ETag"],
167
+ }
168
+ except HTTPError as exc:
169
+ raise FileUploadException(
170
+ f"Error uploading part {part_number} to {url}. "
171
+ f"Status {exc.status}: {exc.reason}"
172
+ )
173
+
174
+ def upload(self) -> None:
175
+ import concurrent.futures
176
+
177
+ parts = math.ceil(os.path.getsize(self.file_path) / self.chunk_size)
178
+ with concurrent.futures.ThreadPoolExecutor(
179
+ max_workers=self.max_concurrency
180
+ ) as executor:
181
+ futures = []
182
+ for part_number in range(1, parts + 1):
183
+ upload_url = (
184
+ f"{self._file_url}?upload_id={self._upload_id}"
185
+ f"&part_number={part_number}"
186
+ )
187
+ futures.append(
188
+ executor.submit(self._upload_part, upload_url, part_number)
189
+ )
190
+
191
+ for future in concurrent.futures.as_completed(futures):
192
+ entry = future.result()
193
+ self._parts.append(entry)
194
+
195
+ def complete(self):
196
+ url = f"{self._file_url}?upload_id={self._upload_id}"
197
+ try:
198
+ req = Request(
199
+ url,
200
+ method="POST",
201
+ headers={
202
+ "Accept": "application/json",
203
+ "Content-Type": "application/json",
204
+ },
205
+ data=json.dumps({"parts": self._parts}).encode(),
206
+ )
207
+ with urlopen(req):
208
+ pass
209
+ except HTTPError as e:
210
+ raise FileUploadException(
211
+ f"Error completing upload {url}. Status {e.status}: {e.reason}"
212
+ )
213
+
214
+ return self._file_url
215
+
216
+
90
217
  @dataclass
91
218
  class FalFileRepositoryV2(FalFileRepositoryBase):
92
219
  def save(self, file: FileData) -> str:
93
220
  return self._save(file, "fal-cdn")
94
221
 
222
+ def _save_multipart(
223
+ self,
224
+ file_path: str | Path,
225
+ chunk_size: int | None = None,
226
+ content_type: str | None = None,
227
+ max_concurrency: int | None = None,
228
+ ) -> str:
229
+ multipart = MultipartUpload(
230
+ file_path,
231
+ chunk_size=chunk_size,
232
+ content_type=content_type,
233
+ max_concurrency=max_concurrency,
234
+ )
235
+ multipart.create()
236
+ multipart.upload()
237
+ return multipart.complete()
238
+
239
+ def save_file(
240
+ self,
241
+ file_path: str | Path,
242
+ content_type: str,
243
+ multipart: bool | None = None,
244
+ multipart_threshold: int | None = None,
245
+ multipart_chunk_size: int | None = None,
246
+ multipart_max_concurrency: int | None = None,
247
+ ) -> tuple[str, FileData | None]:
248
+ if multipart is None:
249
+ threshold = multipart_threshold or MultipartUpload.MULTIPART_THRESHOLD
250
+ multipart = os.path.getsize(file_path) > threshold
251
+
252
+ if multipart:
253
+ url = self._save_multipart(
254
+ file_path,
255
+ chunk_size=multipart_chunk_size,
256
+ content_type=content_type,
257
+ max_concurrency=multipart_max_concurrency,
258
+ )
259
+ data = None
260
+ else:
261
+ with open(file_path, "rb") as f:
262
+ data = FileData(
263
+ f.read(),
264
+ content_type=content_type,
265
+ file_name=os.path.basename(file_path),
266
+ )
267
+ url = self.save(data)
268
+
269
+ return url, data
270
+
95
271
 
96
272
  @dataclass
97
273
  class InMemoryRepository(FileRepository):
fal/toolkit/file/types.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from mimetypes import guess_extension, guess_type
5
+ from pathlib import Path
5
6
  from typing import Literal
6
7
  from uuid import uuid4
7
8
 
@@ -35,3 +36,20 @@ RepositoryId = Literal["fal", "fal_v2", "in_memory", "gcp_storage", "r2", "cdn"]
35
36
  class FileRepository:
36
37
  def save(self, data: FileData) -> str:
37
38
  raise NotImplementedError()
39
+
40
+ def save_file(
41
+ self,
42
+ file_path: str | Path,
43
+ content_type: str,
44
+ multipart: bool | None = None,
45
+ multipart_threshold: int | None = None,
46
+ multipart_chunk_size: int | None = None,
47
+ multipart_max_concurrency: int | None = None,
48
+ ) -> tuple[str, FileData | None]:
49
+ if multipart:
50
+ raise NotImplementedError()
51
+
52
+ with open(file_path, "rb") as fobj:
53
+ data = FileData(fobj.read(), content_type, Path(file_path).name)
54
+
55
+ return self.save(data), data
fal/utils.py CHANGED
@@ -1,16 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
4
+
3
5
  import fal._serialization
4
6
  from fal import App, wrap_app
5
7
 
6
8
  from .api import FalServerlessError, FalServerlessHost, IsolatedFunction
7
9
 
8
10
 
11
+ @dataclass
12
+ class LoadedFunction:
13
+ function: IsolatedFunction
14
+ endpoints: list[str]
15
+ app_name: str | None
16
+ app_auth: str | None
17
+
18
+
9
19
  def load_function_from(
10
20
  host: FalServerlessHost,
11
21
  file_path: str,
12
22
  function_name: str | None = None,
13
- ) -> tuple[IsolatedFunction, str | None, str | None]:
23
+ ) -> LoadedFunction:
14
24
  import runpy
15
25
 
16
26
  module = runpy.run_path(file_path)
@@ -45,13 +55,15 @@ def load_function_from(
45
55
  fal._serialization.include_package_from_path(file_path)
46
56
 
47
57
  target = module[function_name]
58
+ endpoints = ["/"]
48
59
  if isinstance(target, type) and issubclass(target, App):
49
60
  app_name = target.app_name
50
61
  app_auth = target.app_auth
62
+ endpoints = target.get_endpoints() or ["/"]
51
63
  target = wrap_app(target, host=host)
52
64
 
53
65
  if not isinstance(target, IsolatedFunction):
54
66
  raise FalServerlessError(
55
67
  f"Function '{function_name}' is not a fal.function or a fal.App"
56
68
  )
57
- return target, app_name, app_auth
69
+ return LoadedFunction(target, endpoints, app_name=app_name, app_auth=app_auth)
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fal
3
- Version: 1.3.0
3
+ Version: 1.3.2
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
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: isolate[build]<1.14.0,>=0.13.0
9
- Requires-Dist: isolate-proto==0.5.1
9
+ Requires-Dist: isolate-proto==0.5.3
10
10
  Requires-Dist: grpcio==1.64.0
11
11
  Requires-Dist: dill==0.3.7
12
12
  Requires-Dist: cloudpickle==3.0.0
@@ -35,6 +35,7 @@ Requires-Dist: pillow<11,>=10.2.0
35
35
  Requires-Dist: pyjwt[crypto]<3,>=2.8.0
36
36
  Requires-Dist: uvicorn<1,>=0.29.0
37
37
  Requires-Dist: cookiecutter
38
+ Requires-Dist: tomli
38
39
  Requires-Dist: importlib-metadata>=4.4; python_version < "3.10"
39
40
  Provides-Extra: dev
40
41
  Requires-Dist: fal[test]; extra == "dev"
@@ -1,39 +1,41 @@
1
1
  fal/__init__.py,sha256=wXs1G0gSc7ZK60-bHe-B2m0l_sA6TrFk4BxY0tMoLe8,784
2
- fal/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
- fal/_fal_version.py,sha256=HGwtpza1HCPtlyqElUvIyH97K44TO13CYiYVZNezQ1M,411
2
+ fal/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ fal/_fal_version.py,sha256=ik9WUFYTvRcmzFEUSqkI2H_62uw0XFdUT3mTLq8-_RY,411
4
4
  fal/_serialization.py,sha256=rD2YiSa8iuzCaZohZwN_MPEB-PpSKbWRDeaIDpTEjyY,7653
5
5
  fal/_version.py,sha256=EBGqrknaf1WygENX-H4fBefLvHryvJBBGtVJetaB0NY,266
6
- fal/api.py,sha256=bOCxmOQSbdcR6h6VLPEuvsD4i0j_Mod6E2UNP07cAQo,41893
7
- fal/app.py,sha256=PGx-6Zr4evqe4Fzs4g4-MxKnaOp_7hW5G7vU1PpPvkI,16800
8
- fal/apps.py,sha256=FrKmaAUo8U9vE_fcva0GQvk4sCrzaTEr62lGtu3Ld5M,6825
6
+ fal/api.py,sha256=xOPRO8-Y-7tgab_yKkQ2Lh_n4l8av5zd7srs9PCgJ5U,42020
7
+ fal/app.py,sha256=mBBwTi6IldCEN-IEeznpEwyjUydqB4HkVCu49J3Vsfw,17639
8
+ fal/apps.py,sha256=lge7-HITzI20l1oXdlkAzqxdMVtXRfnACIylKRWgCNQ,7151
9
9
  fal/container.py,sha256=V7riyyq8AZGwEX9QaqRQDZyDN_bUKeRKV1OOZArXjL0,622
10
+ fal/files.py,sha256=QgfYfMKmNobMPufrAP_ga1FKcIAlSbw18Iar1-0qepo,2650
10
11
  fal/flags.py,sha256=oWN_eidSUOcE9wdPK_77si3A1fpgOC0UEERPsvNLIMc,842
11
12
  fal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
13
  fal/rest_client.py,sha256=kGBGmuyHfX1lR910EoKCYPjsyU8MdXawT_cW2q8Sajc,568
13
- fal/sdk.py,sha256=wA58DYnSK1vdsBi8Or9Z8kvMMEyBNfeZYk_xulSfTWE,20078
14
+ fal/sdk.py,sha256=Vp5S3FNMNzSLN3p7I_bv5TPzJHflr1JE3QgDm5-BeI0,21300
14
15
  fal/sync.py,sha256=ZuIJA2-hTPNANG9B_NNJZUsO68EIdTH0dc9MzeVE2VU,4340
15
- fal/utils.py,sha256=4-V6iGSRd3kG_-UP6OdZp_-EhAkl3zectFlFKkCsS0Q,1884
16
+ fal/utils.py,sha256=9q_QrQBlQN3nZYA1kEGRfhJWi4RjnO4H1uQswfaei9w,2146
16
17
  fal/workflows.py,sha256=jx3tGy2R7cN6lLvOzT6lhhlcjmiq64iZls2smVrmQj0,14657
17
18
  fal/auth/__init__.py,sha256=r8iA2-5ih7-Fik3gEC4HEWNFbGoxpYnXpZu1icPIoS0,3561
18
19
  fal/auth/auth0.py,sha256=rSG1mgH-QGyKfzd7XyAaj1AYsWt-ho8Y_LZ-FUVWzh4,5421
19
20
  fal/auth/local.py,sha256=sndkM6vKpeVny6NHTacVlTbiIFqaksOmw0Viqs_RN1U,1790
20
21
  fal/cli/__init__.py,sha256=padK4o0BFqq61kxAA1qQ0jYr2SuhA2mf90B3AaRkmJA,37
22
+ fal/cli/_utils.py,sha256=DSfHZ6qna4jLffs-N4F3XbV-9ydF_OSlP2-GRounWsY,911
21
23
  fal/cli/apps.py,sha256=-DDp-Gvxz5kHho5YjAhbri8vOny_9cftAI_wP2KR5nU,8175
22
24
  fal/cli/auth.py,sha256=--MhfHGwxmtHbRkGioyn1prKn_U-pBzbz0G_QeZou-U,1352
23
25
  fal/cli/create.py,sha256=a8WDq-nJLFTeoIXqpb5cr7GR7YR9ZZrQCawNm34KXXE,627
24
26
  fal/cli/debug.py,sha256=u_urnyFzSlNnrq93zz_GXE9FX4VyVxDoamJJyrZpFI0,1312
25
- fal/cli/deploy.py,sha256=1e4OERVGtfwgM0VEFjlCLpNyuOl1BiLI-dx8u-71PVs,4817
27
+ fal/cli/deploy.py,sha256=JCTQRNzbPt7Bn7lR8byJ38Ff-vQ2BQoSdzmdp9OlF3A,6790
26
28
  fal/cli/doctor.py,sha256=U4ne9LX5gQwNblsYQ27XdO8AYDgbYjTO39EtxhwexRM,983
27
29
  fal/cli/keys.py,sha256=trDpA3LJu9S27qE_K8Hr6fKLK4vwVzbxUHq8TFrV4pw,3157
28
30
  fal/cli/main.py,sha256=_Wh_DQc02qwh-ZN7v41lZm0lDR1WseViXVOcqUlyWLg,2009
29
- fal/cli/parser.py,sha256=r1hd5e8Jq6yzDZw8-S0On1EjJbjRtHMuVuHC6MlvUj4,2835
30
- fal/cli/run.py,sha256=8wHNDruIr8i21JwbfFzS389C-y0jktM5zN5iDnJHsvA,873
31
+ fal/cli/parser.py,sha256=edCqFWYAQSOhrxeEK9BtFRlTEUAlG2JUDjS_vhZ_nHE,2868
32
+ fal/cli/run.py,sha256=uscbLBfTe8-UAbqh8h1iWuGD_G9UNNrj3k8bF5rtzy4,1201
31
33
  fal/cli/secrets.py,sha256=740msFm7d41HruudlcfqUXlFl53N-WmChsQP9B9M9Po,2572
32
34
  fal/console/__init__.py,sha256=ernZ4bzvvliQh5SmrEqQ7lA5eVcbw6Ra2jalKtA7dxg,132
33
35
  fal/console/icons.py,sha256=De9MfFaSkO2Lqfne13n3PrYfTXJVIzYZVqYn5BWsdrA,108
34
36
  fal/console/ux.py,sha256=KMQs3UHQvVHDxDQQqlot-WskVKoMQXOE3jiVkkfmIMY,356
35
- fal/exceptions/__init__.py,sha256=5b2rFnvLf2Q4fPGedcYN_JVLv-j6iOy5DNG7jB6FNUk,180
36
- fal/exceptions/_base.py,sha256=zFiiRceThgdnR8DEYtzpkq-IDO_D0EHGLd0oZjX5Gqk,1256
37
+ fal/exceptions/__init__.py,sha256=m2okJEpax11mnwmoqO_pCGtbt-FvzKiiuMhKo2ok-_8,270
38
+ fal/exceptions/_base.py,sha256=LwzpMaW_eYQEC5s26h2qGXbNA-S4bOqC8s-bMCX6HjE,1491
37
39
  fal/exceptions/_cuda.py,sha256=q5EPFYEb7Iyw03cHrQlRHnH5xOvjwTwQdM6a9N3GB8k,1494
38
40
  fal/exceptions/auth.py,sha256=gxRago5coI__vSIcdcsqhhq1lRPkvCnwPAueIaXTAdw,329
39
41
  fal/logging/__init__.py,sha256=snqprf7-sKw6oAATS_Yxklf-a3XhLg0vIHICPwLp6TM,1583
@@ -45,9 +47,9 @@ fal/toolkit/__init__.py,sha256=sV95wiUzKoiDqF9vDgq4q-BLa2sD6IpuKSqp5kdTQNE,658
45
47
  fal/toolkit/exceptions.py,sha256=elHZ7dHCJG5zlHGSBbz-ilkZe9QUvQMomJFi8Pt91LA,198
46
48
  fal/toolkit/optimize.py,sha256=p75sovF0SmRP6zxzpIaaOmqlxvXB_xEz3XPNf59EF7w,1339
47
49
  fal/toolkit/file/__init__.py,sha256=FbNl6wD-P0aSSTUwzHt4HujBXrbC3ABmaigPQA4hRfg,70
48
- fal/toolkit/file/file.py,sha256=_OCtg0Po3wlDT41dMThJJ1Z9XLtnajyjYaupO9DhfeQ,6137
49
- fal/toolkit/file/types.py,sha256=bJCeV5NPcpJYJoglailiRgFsuNAfcextYA8Et5-XUag,1060
50
- fal/toolkit/file/providers/fal.py,sha256=65-BkK9jhGBwYI_OjhHJsL2DthyKxBBRrqXPI_ZN4-k,4115
50
+ fal/toolkit/file/file.py,sha256=qk_hj7U3cfvuWO-qF_eC_R8lzzVhudfnt1erWEa8eDQ,6578
51
+ fal/toolkit/file/types.py,sha256=GymH0CJesJvsZ6wph7GqTGTuNjzvyMgLxQmBBxoKzS0,1627
52
+ fal/toolkit/file/providers/fal.py,sha256=ClCWM4GI11hOjEIVv2IJZj2SdzBNO8iS1r1WaXFcF6I,10090
51
53
  fal/toolkit/file/providers/gcp.py,sha256=pUVH2qNcnO_VrDQQU8MmfYOQZMGaKQIqE4yGnYdQhAc,2003
52
54
  fal/toolkit/file/providers/r2.py,sha256=WxmOHF5WxHt6tKMcFjWj7ZWO8a1EXysO9lfYv_tB3MI,2627
53
55
  fal/toolkit/image/__init__.py,sha256=aLcU8HzD7HyOxx-C-Bbx9kYCMHdBhy9tR98FSVJ6gSA,1830
@@ -123,8 +125,8 @@ openapi_fal_rest/models/workflow_node_type.py,sha256=-FzyeY2bxcNmizKbJI8joG7byRi
123
125
  openapi_fal_rest/models/workflow_schema.py,sha256=4K5gsv9u9pxx2ItkffoyHeNjBBYf6ur5bN4m_zePZNY,2019
124
126
  openapi_fal_rest/models/workflow_schema_input.py,sha256=2OkOXWHTNsCXHWS6EGDFzcJKkW5FIap-2gfO233EvZQ,1191
125
127
  openapi_fal_rest/models/workflow_schema_output.py,sha256=EblwSPAGfWfYVWw_WSSaBzQVju296is9o28rMBAd0mc,1196
126
- fal-1.3.0.dist-info/METADATA,sha256=sP1Mdzh8ciPGfXFrY_GzmRm8IH0Ew6gv9IUjBsHWCe4,3766
127
- fal-1.3.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
128
- fal-1.3.0.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
129
- fal-1.3.0.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
130
- fal-1.3.0.dist-info/RECORD,,
128
+ fal-1.3.2.dist-info/METADATA,sha256=i6GfuQ5bUpOFzv9yBJDJ2UI14NFYrBwt96HtyXq-g5Q,3787
129
+ fal-1.3.2.dist-info/WHEEL,sha256=UvcQYKBHoFqaQd6LKyqHw9fxEolWLQnlzP0h_LgJAfI,91
130
+ fal-1.3.2.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
131
+ fal-1.3.2.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
132
+ fal-1.3.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (72.1.0)
2
+ Generator: setuptools (74.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5