fal 1.13.5__py3-none-any.whl → 1.15.0__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
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.13.5'
21
- __version_tuple__ = version_tuple = (1, 13, 5)
20
+ __version__ = version = '1.15.0'
21
+ __version_tuple__ = version_tuple = (1, 15, 0)
fal/auth/__init__.py CHANGED
@@ -125,6 +125,26 @@ def _fetch_teams(bearer_token: str) -> list[dict]:
125
125
  raise FalServerlessException("Failed to fetch teams") from exc
126
126
 
127
127
 
128
+ def current_user_info(headers: dict[str, str]) -> dict:
129
+ import json
130
+ from urllib.error import HTTPError
131
+ from urllib.request import Request, urlopen
132
+
133
+ from fal.exceptions import FalServerlessException
134
+ from fal.flags import REST_URL
135
+
136
+ request = Request(
137
+ method="GET",
138
+ url=f"{REST_URL}/users/current",
139
+ headers=headers,
140
+ )
141
+ try:
142
+ with urlopen(request) as response:
143
+ return json.load(response)
144
+ except HTTPError as exc:
145
+ raise FalServerlessException("Failed to fetch user info") from exc
146
+
147
+
128
148
  def login(console):
129
149
  token_data = auth0.login(console)
130
150
  with local.lock_token():
fal/cli/_utils.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fal.files import find_project_root, find_pyproject_toml, parse_pyproject_toml
3
+ from fal.project import find_project_root, find_pyproject_toml, parse_pyproject_toml
4
4
 
5
5
 
6
6
  def get_client(host: str, team: str | None = None):
fal/cli/apps.py CHANGED
@@ -123,7 +123,7 @@ def _app_rev_table(revs: list[ApplicationInfo]):
123
123
  def _list_rev(args):
124
124
  client = get_client(args.host, args.team)
125
125
  with client.connect() as connection:
126
- revs = connection.list_applications()
126
+ revs = connection.list_applications(args.app_name)
127
127
  table = _app_rev_table(revs)
128
128
 
129
129
  args.console.print(table)
@@ -137,6 +137,11 @@ def _add_list_rev_parser(subparsers, parents):
137
137
  help=list_help,
138
138
  parents=parents,
139
139
  )
140
+ parser.add_argument(
141
+ "app_name",
142
+ nargs="?",
143
+ help="Application name.",
144
+ )
140
145
  parser.set_defaults(func=_list_rev)
141
146
 
142
147
 
fal/cli/auth.py CHANGED
@@ -1,6 +1,10 @@
1
+ from fal.auth import current_user_info
2
+ from fal.cli import profile
3
+ from fal.sdk import get_default_credentials
4
+
5
+
1
6
  def _login(args):
2
7
  from fal.auth import login
3
- from fal.config import Config
4
8
  from fal.console.icons import CHECK_ICON, CROSS_ICON
5
9
  from fal.exceptions import FalServerlessException
6
10
 
@@ -11,15 +15,12 @@ def _login(args):
11
15
  args.console.print(f"{CROSS_ICON} {e}")
12
16
  return
13
17
 
14
- with Config().edit() as config:
15
- config.unset("team")
16
-
18
+ _unset_account(args)
17
19
  _set_account(args)
18
20
 
19
21
 
20
22
  def _logout(args):
21
23
  from fal.auth import logout
22
- from fal.config import Config
23
24
  from fal.console.icons import CHECK_ICON, CROSS_ICON
24
25
  from fal.exceptions import FalServerlessException
25
26
 
@@ -30,8 +31,7 @@ def _logout(args):
30
31
  args.console.print(f"{CROSS_ICON} {e}")
31
32
  return
32
33
 
33
- with Config().edit() as config:
34
- config.unset("team")
34
+ _unset_account(args)
35
35
 
36
36
 
37
37
  def _list_accounts(args):
@@ -43,7 +43,7 @@ def _list_accounts(args):
43
43
 
44
44
  user_access = UserAccess()
45
45
  config = Config()
46
- current_account = config.get("team") or user_access.info["nickname"]
46
+ current_account_name = config.get_internal("team") or user_access.info["nickname"]
47
47
 
48
48
  table = Table(border_style=Style(frame=False), show_header=False)
49
49
  table.add_column("#")
@@ -51,7 +51,7 @@ def _list_accounts(args):
51
51
  table.add_column("Type")
52
52
 
53
53
  for idx, account in enumerate(user_access.accounts):
54
- selected = account["nickname"] == current_account
54
+ selected = account["nickname"] == current_account_name
55
55
  color = "bold yellow" if selected else None
56
56
 
57
57
  table.add_row(
@@ -64,6 +64,13 @@ def _list_accounts(args):
64
64
  args.console.print(table)
65
65
 
66
66
 
67
+ def _unset_account(args):
68
+ from fal.config import Config
69
+
70
+ with Config().edit() as config:
71
+ config.unset_internal("team")
72
+
73
+
67
74
  def _set_account(args):
68
75
  from rich.prompt import Prompt
69
76
 
@@ -105,25 +112,24 @@ def _set_account(args):
105
112
  )
106
113
 
107
114
  with Config().edit() as config:
108
- config.set("team", account["nickname"])
109
-
115
+ config.set_internal("team", account["nickname"])
110
116
 
111
- def _whoami(args):
112
- from fal.auth import UserAccess
113
- from fal.config import Config
117
+ # Unset the profile if set
118
+ if current_profile := config.get_internal("profile"):
119
+ args.console.print(
120
+ f"\n[yellow]Unsetting profile [cyan]{current_profile}[/] "
121
+ "to make team selection effective.[/]"
122
+ )
123
+ profile._unset(args, config=config)
114
124
 
115
- user_access = UserAccess()
116
- config = Config()
117
125
 
118
- team = config.get("team")
119
- if team:
120
- account = user_access.get_account(team)
121
- else:
122
- account = user_access.get_account(user_access.info["nickname"])
126
+ def _whoami(args):
127
+ creds = get_default_credentials()
128
+ user_info = current_user_info(creds.to_headers())
123
129
 
124
- nickname = account["nickname"]
125
- full_name = account["full_name"]
126
- user_id = account["user_id"]
130
+ full_name = user_info["full_name"]
131
+ nickname = user_info["nickname"]
132
+ user_id = user_info["user_id"]
127
133
 
128
134
  args.console.print(f"Hello, {full_name}: {nickname!r} - {user_id!r}")
129
135
 
fal/cli/files.py ADDED
@@ -0,0 +1,70 @@
1
+ from .parser import FalClientParser
2
+
3
+
4
+ def _list(args):
5
+ import posixpath
6
+
7
+ from fal.files import FalFileSystem
8
+
9
+ fs = FalFileSystem()
10
+
11
+ for entry in fs.ls(args.path, detail=True):
12
+ name = posixpath.basename(entry["name"])
13
+ color = "blue" if entry["type"] == "directory" else "default"
14
+ args.console.print(f"[{color}]{name}[/{color}]")
15
+
16
+
17
+ def _download(args):
18
+ from fal.files import FalFileSystem
19
+
20
+ fs = FalFileSystem()
21
+ fs.get(args.remote_path, args.local_path)
22
+
23
+
24
+ def _upload(args):
25
+ from fal.files import FalFileSystem
26
+
27
+ fs = FalFileSystem()
28
+ fs.put(args.local_path, args.remote_path)
29
+
30
+
31
+ def add_parser(main_subparsers, parents):
32
+ files_help = "Manage fal files."
33
+ parser = main_subparsers.add_parser(
34
+ "files",
35
+ aliases=["file"],
36
+ description=files_help,
37
+ help=files_help,
38
+ parents=parents,
39
+ )
40
+
41
+ subparsers = parser.add_subparsers(
42
+ title="Commands",
43
+ metavar="command",
44
+ required=True,
45
+ parser_class=FalClientParser,
46
+ )
47
+
48
+ list_parser = subparsers.add_parser("list", aliases=["ls"], parents=parents)
49
+ list_parser.add_argument(
50
+ "path",
51
+ nargs="?",
52
+ type=str,
53
+ help="The path to list",
54
+ default="/",
55
+ )
56
+ list_parser.set_defaults(func=_list)
57
+
58
+ download_parser = subparsers.add_parser("download", parents=parents)
59
+ download_parser.add_argument(
60
+ "remote_path", type=str, help="Remote path to download"
61
+ )
62
+ download_parser.add_argument(
63
+ "local_path", type=str, help="Local path to download to"
64
+ )
65
+ download_parser.set_defaults(func=_download)
66
+
67
+ upload_parser = subparsers.add_parser("upload", parents=parents)
68
+ upload_parser.add_argument("local_path", type=str, help="Local path to upload")
69
+ upload_parser.add_argument("remote_path", type=str, help="Remote path to upload to")
70
+ upload_parser.set_defaults(func=_upload)
fal/cli/main.py CHANGED
@@ -13,6 +13,7 @@ from . import (
13
13
  create,
14
14
  deploy,
15
15
  doctor,
16
+ files,
16
17
  keys,
17
18
  profile,
18
19
  run,
@@ -57,6 +58,7 @@ def _get_main_parser() -> argparse.ArgumentParser:
57
58
  create,
58
59
  runners,
59
60
  teams,
61
+ files,
60
62
  ]:
61
63
  cmd.add_parser(subparsers, parents)
62
64
 
fal/cli/profile.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from rich.table import Table
2
4
 
3
5
  from fal.config import Config
@@ -31,11 +33,12 @@ def _set(args):
31
33
  )
32
34
 
33
35
 
34
- def _unset(args):
35
- with Config().edit() as config:
36
- config.unset_internal("profile")
37
- args.console.print("Default profile unset.")
36
+ def _unset(args, config: Config | None = None):
37
+ config = config or Config()
38
+
39
+ with config.edit() as config:
38
40
  config.profile = None
41
+ args.console.print("Default profile unset.")
39
42
 
40
43
 
41
44
  def _key_set(args):
fal/cli/teams.py CHANGED
@@ -1,11 +1,4 @@
1
- from fal.cli.auth import _list_accounts, _set_account
2
-
3
-
4
- def _unset(args):
5
- from fal.config import Config
6
-
7
- with Config().edit() as config:
8
- config.unset("team")
1
+ from fal.cli.auth import _list_accounts, _set_account, _unset_account
9
2
 
10
3
 
11
4
  def add_parser(main_subparsers, parents):
@@ -51,4 +44,4 @@ def add_parser(main_subparsers, parents):
51
44
  help=unset_help,
52
45
  parents=parents,
53
46
  )
54
- unset_parser.set_defaults(func=_unset)
47
+ unset_parser.set_defaults(func=_unset_account)
fal/config.py CHANGED
@@ -1,6 +1,8 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  from contextlib import contextmanager
3
- from typing import Dict, List, Optional
5
+ from typing import Dict, Iterator, List, Optional
4
6
 
5
7
  SETTINGS_SECTION = "__internal__" # legacy
6
8
  DEFAULT_PROFILE = "default"
@@ -9,6 +11,7 @@ DEFAULT_PROFILE = "default"
9
11
  class Config:
10
12
  _config: Dict[str, Dict[str, str]]
11
13
  _profile: Optional[str]
14
+ _editing: bool = False
12
15
 
13
16
  DEFAULT_CONFIG_PATH = "~/.fal/config.toml"
14
17
 
@@ -38,7 +41,11 @@ class Config:
38
41
  @profile.setter
39
42
  def profile(self, value: Optional[str]) -> None:
40
43
  if value and value not in self._config:
44
+ # Make sure the section exists
41
45
  self._config[value] = {}
46
+ self.set_internal("profile", value)
47
+ elif not value:
48
+ self.unset_internal("profile")
42
49
 
43
50
  self._profile = value
44
51
 
@@ -96,6 +103,12 @@ class Config:
96
103
  del self._config[profile]
97
104
 
98
105
  @contextmanager
99
- def edit(self):
100
- yield self
101
- self.save()
106
+ def edit(self) -> Iterator[Config]:
107
+ if self._editing:
108
+ # no-op
109
+ yield self
110
+ else:
111
+ self._editing = True
112
+ yield self
113
+ self.save()
114
+ self._editing = False
fal/container.py CHANGED
@@ -1,9 +1,8 @@
1
1
  from dataclasses import dataclass, field
2
- from typing import Dict, Literal
2
+ from typing import Dict, Literal, Optional
3
3
 
4
4
  Builder = Literal["depot", "service", "worker"]
5
5
  BUILDERS = {"depot", "service", "worker"}
6
- DEFAULT_BUILDER: Builder = "depot"
7
6
  DEFAULT_COMPRESSION: str = "gzip"
8
7
  DEFAULT_FORCE_COMPRESSION: bool = False
9
8
 
@@ -17,7 +16,7 @@ class ContainerImage:
17
16
  dockerfile_str: str
18
17
  build_args: Dict[str, str] = field(default_factory=dict)
19
18
  registries: Dict[str, Dict[str, str]] = field(default_factory=dict)
20
- builder: Builder = field(default=DEFAULT_BUILDER)
19
+ builder: Optional[Builder] = field(default=None)
21
20
  compression: str = DEFAULT_COMPRESSION
22
21
  force_compression: bool = DEFAULT_FORCE_COMPRESSION
23
22
 
@@ -30,7 +29,7 @@ class ContainerImage:
30
29
  "Username and password are required for each registry"
31
30
  )
32
31
 
33
- if self.builder not in BUILDERS:
32
+ if self.builder and self.builder not in BUILDERS:
34
33
  raise ValueError(
35
34
  f"Invalid builder: {self.builder}, must be one of {BUILDERS}"
36
35
  )
fal/files.py CHANGED
@@ -1,81 +1,74 @@
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
1
+ import posixpath
2
+ from functools import cached_property
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fsspec import AbstractFileSystem
6
+
7
+ if TYPE_CHECKING:
8
+ import httpx
9
+
10
+ USER_AGENT = "fal-sdk/1.14.0 (python)"
11
+
12
+
13
+ class FalFileSystem(AbstractFileSystem):
14
+ @cached_property
15
+ def _client(self) -> "httpx.Client":
16
+ from httpx import Client
17
+
18
+ from fal.flags import REST_URL
19
+ from fal.sdk import get_default_credentials
20
+
21
+ creds = get_default_credentials()
22
+ return Client(
23
+ base_url=REST_URL,
24
+ headers={
25
+ **creds.to_headers(),
26
+ "User-Agent": USER_AGENT,
27
+ },
28
+ )
29
+
30
+ def ls(self, path, detail=True, **kwargs):
31
+ response = self._client.get(f"/files/list/{path.lstrip('/')}")
32
+ response.raise_for_status()
33
+ files = response.json()
34
+ if detail:
35
+ return sorted(
36
+ (
37
+ {
38
+ "name": entry["path"],
39
+ "size": entry["size"],
40
+ "type": "file" if entry["is_file"] else "directory",
41
+ "mtime": entry["updated_time"],
42
+ }
43
+ for entry in files
44
+ ),
45
+ key=lambda x: x["name"],
46
+ )
47
+ else:
48
+ return sorted(entry["path"] for entry in files)
49
+
50
+ def info(self, path, **kwargs):
51
+ parent = posixpath.dirname(path)
52
+ entries = self.ls(parent, detail=True)
53
+ for entry in entries:
54
+ if entry["name"] == path:
55
+ return entry
56
+ raise FileNotFoundError(f"File not found: {path}")
57
+
58
+ def get_file(self, rpath, lpath, **kwargs):
59
+ with open(lpath, "wb") as fobj:
60
+ response = self._client.get(f"/files/file/{rpath.lstrip('/')}")
61
+ response.raise_for_status()
62
+ fobj.write(response.content)
63
+
64
+ def put_file(self, lpath, rpath, mode="overwrite", **kwargs):
65
+ with open(lpath, "rb") as fobj:
66
+ response = self._client.post(
67
+ f"/files/file/local/{rpath.lstrip('/')}",
68
+ files={"file_upload": (posixpath.basename(lpath), fobj, "text/plain")},
69
+ )
70
+ response.raise_for_status()
71
+
72
+ def rm(self, path, **kwargs):
73
+ response = self._client.delete(f"/files/file/{path.lstrip('/')}")
74
+ response.raise_for_status()
fal/project.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
@@ -157,7 +157,15 @@ class AuthenticatedCredentials(Credentials):
157
157
 
158
158
  def to_headers(self) -> dict[str, str]:
159
159
  token = self.user.bearer_token
160
- return {"Authorization": token}
160
+ headers = {
161
+ "Authorization": token,
162
+ }
163
+
164
+ if self.team:
165
+ team_id = self.user.get_account(self.team)["user_id"]
166
+ headers["X-Fal-User-Id"] = team_id
167
+
168
+ return headers
161
169
 
162
170
 
163
171
  @dataclass
@@ -193,7 +201,7 @@ def get_default_credentials(team: str | None = None) -> Credentials:
193
201
  return FalServerlessKeyCredentials(key_creds[0], key_creds[1])
194
202
  else:
195
203
  config = Config()
196
- team = team or config.get("team")
204
+ team = team or config.get_internal("team")
197
205
  return AuthenticatedCredentials(team=team)
198
206
 
199
207
 
@@ -650,8 +658,12 @@ class FalServerlessConnection:
650
658
  )
651
659
  return from_grpc(res.alias_info)
652
660
 
653
- def list_applications(self) -> list[ApplicationInfo]:
654
- request = isolate_proto.ListApplicationsRequest()
661
+ def list_applications(
662
+ self, application_name: str | None = None
663
+ ) -> list[ApplicationInfo]:
664
+ request = isolate_proto.ListApplicationsRequest(
665
+ application_name=application_name
666
+ )
655
667
  res: isolate_proto.ListApplicationsResult = self.stub.ListApplications(request)
656
668
  return [from_grpc(app) for app in res.applications]
657
669
 
@@ -5,13 +5,15 @@ import math
5
5
  import os
6
6
  import threading
7
7
  from base64 import b64encode
8
+ from contextlib import contextmanager
8
9
  from dataclasses import dataclass
9
10
  from datetime import datetime, timezone
10
11
  from pathlib import Path
11
- from typing import Generic, TypeVar
12
+ from typing import Generator, Generic, TypeVar
12
13
  from urllib.error import HTTPError
13
14
  from urllib.parse import urlparse, urlunparse
14
15
  from urllib.request import Request, urlopen
16
+ from urllib.response import addinfourl
15
17
 
16
18
  from fal.auth import key_credentials
17
19
  from fal.toolkit.exceptions import FileUploadException
@@ -21,6 +23,17 @@ from fal.toolkit.utils.retry import retry
21
23
  _FAL_CDN = "https://fal.media"
22
24
  _FAL_CDN_V3 = "https://v3.fal.media"
23
25
 
26
+ DEFAULT_REQUEST_TIMEOUT = 10
27
+ PUT_REQUEST_TIMEOUT = 5 * 60
28
+
29
+
30
+ @contextmanager
31
+ def _urlopen(
32
+ request: Request, timeout: int = DEFAULT_REQUEST_TIMEOUT
33
+ ) -> Generator[addinfourl, None, None]:
34
+ with urlopen(request, timeout=timeout) as response:
35
+ yield response
36
+
24
37
 
25
38
  @dataclass
26
39
  class FalV2Token:
@@ -79,7 +92,7 @@ class FalV2TokenManager:
79
92
  data=b"{}",
80
93
  method="POST",
81
94
  )
82
- with urlopen(req) as response:
95
+ with _urlopen(req) as response:
83
96
  result = json.load(response)
84
97
 
85
98
  parsed_base_url = urlparse(result["base_url"])
@@ -158,7 +171,7 @@ class FalFileRepositoryBase(FileRepository):
158
171
  headers=headers,
159
172
  method="POST",
160
173
  )
161
- with urlopen(req) as response:
174
+ with _urlopen(req) as response:
162
175
  result = json.load(response)
163
176
 
164
177
  upload_url = result["upload_url"]
@@ -175,7 +188,7 @@ class FalFileRepositoryBase(FileRepository):
175
188
  headers={"Content-Type": file.content_type},
176
189
  )
177
190
 
178
- with urlopen(req):
191
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT):
179
192
  pass
180
193
 
181
194
  return result["file_url"]
@@ -252,7 +265,7 @@ class MultipartUploadGCS:
252
265
  ).encode(),
253
266
  )
254
267
 
255
- with urlopen(req) as response:
268
+ with _urlopen(req) as response:
256
269
  result = json.load(response)
257
270
  self._access_url = result["file_url"]
258
271
  self._upload_url = result["upload_url"]
@@ -272,7 +285,7 @@ class MultipartUploadGCS:
272
285
  )
273
286
 
274
287
  try:
275
- with urlopen(req) as response:
288
+ with _urlopen(req) as response:
276
289
  result = json.load(response)
277
290
  upload_url = result["upload_url"]
278
291
  except HTTPError as exc:
@@ -288,7 +301,7 @@ class MultipartUploadGCS:
288
301
  )
289
302
 
290
303
  try:
291
- with urlopen(req) as resp:
304
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
292
305
  self._parts.append(
293
306
  {
294
307
  "part_number": part_number,
@@ -318,7 +331,7 @@ class MultipartUploadGCS:
318
331
  }
319
332
  ).encode(),
320
333
  )
321
- with urlopen(req):
334
+ with _urlopen(req):
322
335
  pass
323
336
  except HTTPError as e:
324
337
  raise FileUploadException(
@@ -523,7 +536,7 @@ class MultipartUpload:
523
536
  }
524
537
  ).encode(),
525
538
  )
526
- with urlopen(req) as response:
539
+ with _urlopen(req) as response:
527
540
  result = json.load(response)
528
541
  self._upload_url = result["upload_url"]
529
542
  self._file_url = result["file_url"]
@@ -543,7 +556,7 @@ class MultipartUpload:
543
556
  )
544
557
 
545
558
  try:
546
- with urlopen(req) as resp:
559
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
547
560
  self._parts.append(
548
561
  {
549
562
  "part_number": part_number,
@@ -568,7 +581,7 @@ class MultipartUpload:
568
581
  },
569
582
  data=json.dumps({"parts": self._parts}).encode(),
570
583
  )
571
- with urlopen(req):
584
+ with _urlopen(req):
572
585
  pass
573
586
  except HTTPError as e:
574
587
  raise FileUploadException(
@@ -721,7 +734,7 @@ class MultipartUploadV3:
721
734
  ).encode(),
722
735
  )
723
736
 
724
- with urlopen(req) as response:
737
+ with _urlopen(req) as response:
725
738
  result = json.load(response)
726
739
  self._access_url = result["file_url"]
727
740
  self._upload_url = result["upload_url"]
@@ -747,7 +760,7 @@ class MultipartUploadV3:
747
760
  )
748
761
 
749
762
  try:
750
- with urlopen(req) as resp:
763
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
751
764
  self._parts.append(
752
765
  {
753
766
  "partNumber": part_number,
@@ -775,7 +788,7 @@ class MultipartUploadV3:
775
788
  },
776
789
  data=json.dumps({"parts": self._parts}).encode(),
777
790
  )
778
- with urlopen(req):
791
+ with _urlopen(req):
779
792
  pass
780
793
  except HTTPError as e:
781
794
  raise FileUploadException(
@@ -915,7 +928,7 @@ class InternalMultipartUploadV3:
915
928
  "X-Fal-File-Name": self.file_name,
916
929
  },
917
930
  )
918
- with urlopen(req) as response:
931
+ with _urlopen(req) as response:
919
932
  result = json.load(response)
920
933
  self._access_url = result["access_url"]
921
934
  self._upload_id = result["uploadId"]
@@ -940,7 +953,7 @@ class InternalMultipartUploadV3:
940
953
  )
941
954
 
942
955
  try:
943
- with urlopen(req) as resp:
956
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
944
957
  self._parts.append(
945
958
  {
946
959
  "partNumber": part_number,
@@ -966,7 +979,7 @@ class InternalMultipartUploadV3:
966
979
  },
967
980
  data=json.dumps({"parts": self._parts}).encode(),
968
981
  )
969
- with urlopen(req):
982
+ with _urlopen(req):
970
983
  pass
971
984
  except HTTPError as e:
972
985
  raise FileUploadException(
@@ -1092,7 +1105,7 @@ class FalFileRepositoryV2(FalFileRepositoryBase):
1092
1105
  headers=headers,
1093
1106
  method="PUT",
1094
1107
  )
1095
- with urlopen(req) as response:
1108
+ with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as response:
1096
1109
  result = json.load(response)
1097
1110
 
1098
1111
  return result["file_url"]
@@ -1186,7 +1199,7 @@ class FalCDNFileRepository(FileRepository):
1186
1199
  url = os.getenv("FAL_CDN_HOST", _FAL_CDN) + "/files/upload"
1187
1200
  request = Request(url, headers=headers, method="POST", data=file.data)
1188
1201
  try:
1189
- with urlopen(request) as response:
1202
+ with _urlopen(request) as response:
1190
1203
  result = json.load(response)
1191
1204
  except HTTPError as e:
1192
1205
  raise FileUploadException(
@@ -1266,7 +1279,7 @@ class FalFileRepositoryV3(FileRepository):
1266
1279
  ).encode(),
1267
1280
  )
1268
1281
  try:
1269
- with urlopen(request) as response:
1282
+ with _urlopen(request) as response:
1270
1283
  result = json.load(response)
1271
1284
  file_url = result["file_url"]
1272
1285
  upload_url = result["upload_url"]
@@ -1282,7 +1295,7 @@ class FalFileRepositoryV3(FileRepository):
1282
1295
  data=file.data,
1283
1296
  )
1284
1297
  try:
1285
- with urlopen(request):
1298
+ with _urlopen(request, timeout=PUT_REQUEST_TIMEOUT):
1286
1299
  pass
1287
1300
  except HTTPError as e:
1288
1301
  raise FileUploadException(
@@ -1380,7 +1393,7 @@ class InternalFalFileRepositoryV3(FileRepository):
1380
1393
  url = os.getenv("FAL_CDN_V3_HOST", _FAL_CDN_V3) + "/files/upload"
1381
1394
  request = Request(url, headers=headers, method="POST", data=file.data)
1382
1395
  try:
1383
- with urlopen(request) as response:
1396
+ with _urlopen(request) as response:
1384
1397
  result = json.load(response)
1385
1398
  except HTTPError as e:
1386
1399
  raise FileUploadException(
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fal
3
- Version: 1.13.5
3
+ Version: 1.15.0
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]<0.17.0,>=0.16.2
9
- Requires-Dist: isolate-proto<0.8.0,>=0.7.2
9
+ Requires-Dist: isolate-proto<0.9.0,>=0.8.1
10
10
  Requires-Dist: grpcio==1.64.0
11
11
  Requires-Dist: dill==0.3.7
12
12
  Requires-Dist: cloudpickle==3.0.0
@@ -38,6 +38,7 @@ Requires-Dist: uvicorn<1,>=0.29.0
38
38
  Requires-Dist: cookiecutter
39
39
  Requires-Dist: tomli<3,>2
40
40
  Requires-Dist: tomli-w<2,>=1
41
+ Requires-Dist: fsspec
41
42
  Provides-Extra: docs
42
43
  Requires-Dist: sphinx<8.2.0; extra == "docs"
43
44
  Requires-Dist: sphinx-rtd-theme; extra == "docs"
@@ -1,42 +1,44 @@
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=wNBo9Et0ZuMuyxkOVRCmEvmamdGfbunJjwXY4viGgLY,513
3
+ fal/_fal_version.py,sha256=bw-GHVwYsU9TasIoWctP5ethaWthhk1oAuWz-86NHtc,513
4
4
  fal/_serialization.py,sha256=rD2YiSa8iuzCaZohZwN_MPEB-PpSKbWRDeaIDpTEjyY,7653
5
5
  fal/_version.py,sha256=EBGqrknaf1WygENX-H4fBefLvHryvJBBGtVJetaB0NY,266
6
6
  fal/api.py,sha256=gVZKtdMRNKacBCNVmdZZRGMyF3hrR2bqGiAzUBstkDM,45661
7
7
  fal/app.py,sha256=aRb8t-5QCrIPeKHY39yJ3231T5uHGZLhSurkRBtzyu8,24216
8
8
  fal/apps.py,sha256=pzCd2mrKl5J_4oVc40_pggvPtFahXBCdrZXWpnaEJVs,12130
9
- fal/config.py,sha256=mS38EIwjR6h2x5wdrTU5E2hubSZm6D35Qigjteg0RJk,2707
10
- fal/container.py,sha256=PM7e1RloTCexZ64uAv7sa2RSZxPI-X8KcxkdaZqEfjw,1914
11
- fal/files.py,sha256=QgfYfMKmNobMPufrAP_ga1FKcIAlSbw18Iar1-0qepo,2650
9
+ fal/config.py,sha256=19Q7fymEkfxCd9AIy8SxhaQaRvb_vKvYAG3AeZAI6uk,3116
10
+ fal/container.py,sha256=OvR-Zq-NPbYFHTnw0SBUUFxr890Fgbe68J2kSJEpLOk,1905
11
+ fal/files.py,sha256=HgXD8q9-RKQGIaN7lXtht2BOFFsdMozJcLr3DwLx4x8,2387
12
12
  fal/flags.py,sha256=oWN_eidSUOcE9wdPK_77si3A1fpgOC0UEERPsvNLIMc,842
13
+ fal/project.py,sha256=QgfYfMKmNobMPufrAP_ga1FKcIAlSbw18Iar1-0qepo,2650
13
14
  fal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
15
  fal/rest_client.py,sha256=kGBGmuyHfX1lR910EoKCYPjsyU8MdXawT_cW2q8Sajc,568
15
- fal/sdk.py,sha256=d50umE2XpmNwfOJ17wGfeFciTzj9IZksskk_IXCD4yg,25515
16
+ fal/sdk.py,sha256=OvNgoV6ERnFup7ulylBDSohiXQpBa1ycqNuycPZb1-Q,25816
16
17
  fal/sync.py,sha256=ZuIJA2-hTPNANG9B_NNJZUsO68EIdTH0dc9MzeVE2VU,4340
17
18
  fal/utils.py,sha256=9q_QrQBlQN3nZYA1kEGRfhJWi4RjnO4H1uQswfaei9w,2146
18
19
  fal/workflows.py,sha256=Zl4f6Bs085hY40zmqScxDUyCu7zXkukDbW02iYOLTTI,14805
19
- fal/auth/__init__.py,sha256=PqWNK1OmaRA_bfKP5ySBW-LQL9PqQCoRRgYNNR1xRhU,5593
20
+ fal/auth/__init__.py,sha256=2tki_o_IaQbaZeCTDAS1wBtrvcAOPRTQMtPSXAqk_Ig,6157
20
21
  fal/auth/auth0.py,sha256=g5OgEKe4rsbkLQp6l7EauOAVL6WsmKjuA1wmzmyvvhc,5354
21
22
  fal/auth/local.py,sha256=sndkM6vKpeVny6NHTacVlTbiIFqaksOmw0Viqs_RN1U,1790
22
23
  fal/cli/__init__.py,sha256=padK4o0BFqq61kxAA1qQ0jYr2SuhA2mf90B3AaRkmJA,37
23
- fal/cli/_utils.py,sha256=pHmKzpUWc2n4yPv4R0Y6DuZC5j-rVKU8oqdQDVW_-Lo,1591
24
+ fal/cli/_utils.py,sha256=anFfy6qouB8QzH0Yho41GulGiJu3q1KKIwgyVQCzgRQ,1593
24
25
  fal/cli/api.py,sha256=ZuDE_PIC-czzneTAWMwvC7P7WnwIyluNZSuJqzCFhqI,2640
25
- fal/cli/apps.py,sha256=vKeTUw_uUxz5M9hlP0jcJ23qjR0GTz7ifeS4HBjKECo,10101
26
- fal/cli/auth.py,sha256=CIxeuDmZGGK1B2zvUMIGCprLEKY4XfjFVDqYSEz3vxA,4920
26
+ fal/cli/apps.py,sha256=bhkakN9VoAXNoWEKwEvx4YKiZ-kMp3Dc1NmMRFakexM,10218
27
+ fal/cli/auth.py,sha256=Qe-Z3ycXJnOzHimz5PjCQYoni8MF4csmdL19yGN7a1o,5171
27
28
  fal/cli/cli_nested_json.py,sha256=veSZU8_bYV3Iu1PAoxt-4BMBraNIqgH5nughbs2UKvE,13539
28
29
  fal/cli/create.py,sha256=a8WDq-nJLFTeoIXqpb5cr7GR7YR9ZZrQCawNm34KXXE,627
29
30
  fal/cli/debug.py,sha256=u_urnyFzSlNnrq93zz_GXE9FX4VyVxDoamJJyrZpFI0,1312
30
31
  fal/cli/deploy.py,sha256=CWf0Y56w-hNCrht-qrfgiOi9nuvve1Kl5NFZJpt_oRA,7770
31
32
  fal/cli/doctor.py,sha256=U4ne9LX5gQwNblsYQ27XdO8AYDgbYjTO39EtxhwexRM,983
33
+ fal/cli/files.py,sha256=SN0pr6bvdss8_EjmaB2kai5VYVacLRF_oH6it4Ni8-M,1936
32
34
  fal/cli/keys.py,sha256=7Sf4DT4le89G42eAOt0ltRjbZAtE70AVQ62hmjZhUy0,3059
33
- fal/cli/main.py,sha256=4TnIno7fvFJbMPlpb8mnT7meKAR-UAOerxuo5qqPZRQ,2234
35
+ fal/cli/main.py,sha256=CNh-i1xL0G2pbYMsk0VUC6qsxBT9rrQuLCIeDSiRuQs,2260
34
36
  fal/cli/parser.py,sha256=jYsGQ0BLQuKI7KtN1jnLVYKMbLtez7hPjwTNfG3UPSk,2964
35
- fal/cli/profile.py,sha256=_freBFQ0M7gCpHcmbmQfkcwmVelSnywYeeIX9tL6yCY,3392
37
+ fal/cli/profile.py,sha256=9i0pY0Jhm_ziEDdSXgFMGuXUh3Xx3f5S1xBkuuUbH2I,3448
36
38
  fal/cli/run.py,sha256=nAC12Qss4Fg1XmV0qOS9RdGNLYcdoHeRgQMvbTN4P9I,1202
37
39
  fal/cli/runners.py,sha256=z7WkZZC9rCW2mU5enowVQsxd1W18iBtLNOnPjrzhEf0,3491
38
40
  fal/cli/secrets.py,sha256=QKSmazu-wiNF6fOpGL9v2TDYxAjX9KTi7ot7vnv6f5E,2474
39
- fal/cli/teams.py,sha256=lIY4uT8TGjk9g0z2tY4cGDU8PGqowncEMKh9K_dJYUY,1314
41
+ fal/cli/teams.py,sha256=6fR2rKJtiUJPThP7QsO4NLo9UdhUxraGvQZk3_Di6Ow,1218
40
42
  fal/console/__init__.py,sha256=ernZ4bzvvliQh5SmrEqQ7lA5eVcbw6Ra2jalKtA7dxg,132
41
43
  fal/console/icons.py,sha256=De9MfFaSkO2Lqfne13n3PrYfTXJVIzYZVqYn5BWsdrA,108
42
44
  fal/console/ux.py,sha256=KMQs3UHQvVHDxDQQqlot-WskVKoMQXOE3jiVkkfmIMY,356
@@ -56,7 +58,7 @@ fal/toolkit/types.py,sha256=kkbOsDKj1qPGb1UARTBp7yuJ5JUuyy7XQurYUBCdti8,4064
56
58
  fal/toolkit/file/__init__.py,sha256=FbNl6wD-P0aSSTUwzHt4HujBXrbC3ABmaigPQA4hRfg,70
57
59
  fal/toolkit/file/file.py,sha256=Kb-mdR66OiSNTS2EGLLJYUqnAw-KN7diqhxvjS7EAZ0,9353
58
60
  fal/toolkit/file/types.py,sha256=MMAH_AyLOhowQPesOv1V25wB4qgbJ3vYNlnTPbdSv1M,2304
59
- fal/toolkit/file/providers/fal.py,sha256=NI9TX5gdFkyc6hHl-5FKNuYvGYhYFHD5FvXRf3d-oRU,46409
61
+ fal/toolkit/file/providers/fal.py,sha256=cbND8tjEJvKklrmYgrjr6RtAcVLJIQ9VwhBhNCqgKc8,46992
60
62
  fal/toolkit/file/providers/gcp.py,sha256=DKeZpm1MjwbvEsYvkdXUtuLIJDr_UNbqXj_Mfv3NTeo,2437
61
63
  fal/toolkit/file/providers/r2.py,sha256=YqnYkkAo_ZKIa-xoSuDnnidUFwJWHdziAR34PE6irdI,3061
62
64
  fal/toolkit/file/providers/s3.py,sha256=EI45T54Mox7lHZKROss_O8o0DIn3CHP9k1iaNYVrxvg,2714
@@ -134,8 +136,8 @@ openapi_fal_rest/models/workflow_node_type.py,sha256=-FzyeY2bxcNmizKbJI8joG7byRi
134
136
  openapi_fal_rest/models/workflow_schema.py,sha256=4K5gsv9u9pxx2ItkffoyHeNjBBYf6ur5bN4m_zePZNY,2019
135
137
  openapi_fal_rest/models/workflow_schema_input.py,sha256=2OkOXWHTNsCXHWS6EGDFzcJKkW5FIap-2gfO233EvZQ,1191
136
138
  openapi_fal_rest/models/workflow_schema_output.py,sha256=EblwSPAGfWfYVWw_WSSaBzQVju296is9o28rMBAd0mc,1196
137
- fal-1.13.5.dist-info/METADATA,sha256=WKXSB0wWCy7LdarKL3a5hg5Q8RRwI5hLtaQjAfegyZE,4062
138
- fal-1.13.5.dist-info/WHEEL,sha256=wXxTzcEDnjrTwFYjLPcsW_7_XihufBwmpiBeiXNBGEA,91
139
- fal-1.13.5.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
140
- fal-1.13.5.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
141
- fal-1.13.5.dist-info/RECORD,,
139
+ fal-1.15.0.dist-info/METADATA,sha256=_ngkJv-caqQuXH8IS_jwmliPyjEOs0I7sIcQ2QPtPN0,4084
140
+ fal-1.15.0.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
141
+ fal-1.15.0.dist-info/entry_points.txt,sha256=32zwTUC1U1E7nSTIGCoANQOQ3I7-qHG5wI6gsVz5pNU,37
142
+ fal-1.15.0.dist-info/top_level.txt,sha256=r257X1L57oJL8_lM0tRrfGuXFwm66i1huwQygbpLmHw,21
143
+ fal-1.15.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.1.0)
2
+ Generator: setuptools (80.4.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5