fal 1.2.1__py3-none-any.whl → 1.7.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.

Files changed (45) hide show
  1. fal/__main__.py +3 -1
  2. fal/_fal_version.py +2 -2
  3. fal/api.py +88 -20
  4. fal/app.py +221 -27
  5. fal/apps.py +147 -3
  6. fal/auth/__init__.py +50 -2
  7. fal/cli/_utils.py +40 -0
  8. fal/cli/apps.py +5 -3
  9. fal/cli/create.py +26 -0
  10. fal/cli/deploy.py +97 -16
  11. fal/cli/main.py +2 -2
  12. fal/cli/parser.py +11 -7
  13. fal/cli/run.py +12 -1
  14. fal/cli/runners.py +44 -0
  15. fal/config.py +23 -0
  16. fal/container.py +1 -1
  17. fal/exceptions/__init__.py +7 -1
  18. fal/exceptions/_base.py +51 -0
  19. fal/exceptions/_cuda.py +44 -0
  20. fal/files.py +81 -0
  21. fal/sdk.py +67 -6
  22. fal/toolkit/file/file.py +103 -13
  23. fal/toolkit/file/providers/fal.py +572 -24
  24. fal/toolkit/file/providers/gcp.py +8 -1
  25. fal/toolkit/file/providers/r2.py +8 -1
  26. fal/toolkit/file/providers/s3.py +80 -0
  27. fal/toolkit/file/types.py +28 -3
  28. fal/toolkit/image/__init__.py +71 -0
  29. fal/toolkit/image/image.py +25 -2
  30. fal/toolkit/image/nsfw_filter/__init__.py +11 -0
  31. fal/toolkit/image/nsfw_filter/env.py +9 -0
  32. fal/toolkit/image/nsfw_filter/inference.py +77 -0
  33. fal/toolkit/image/nsfw_filter/model.py +18 -0
  34. fal/toolkit/image/nsfw_filter/requirements.txt +4 -0
  35. fal/toolkit/image/safety_checker.py +107 -0
  36. fal/toolkit/types.py +140 -0
  37. fal/toolkit/utils/download_utils.py +4 -0
  38. fal/toolkit/utils/retry.py +45 -0
  39. fal/utils.py +20 -4
  40. fal/workflows.py +10 -4
  41. {fal-1.2.1.dist-info → fal-1.7.2.dist-info}/METADATA +47 -40
  42. {fal-1.2.1.dist-info → fal-1.7.2.dist-info}/RECORD +45 -30
  43. {fal-1.2.1.dist-info → fal-1.7.2.dist-info}/WHEEL +1 -1
  44. {fal-1.2.1.dist-info → fal-1.7.2.dist-info}/entry_points.txt +0 -0
  45. {fal-1.2.1.dist-info → fal-1.7.2.dist-info}/top_level.txt +0 -0
fal/cli/apps.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from typing import TYPE_CHECKING
2
4
 
3
5
  from .parser import FalClientParser
@@ -6,7 +8,7 @@ if TYPE_CHECKING:
6
8
  from fal.sdk import AliasInfo, ApplicationInfo
7
9
 
8
10
 
9
- def _apps_table(apps: list["AliasInfo"]):
11
+ def _apps_table(apps: list[AliasInfo]):
10
12
  from rich.table import Table
11
13
 
12
14
  table = Table()
@@ -56,7 +58,7 @@ def _add_list_parser(subparsers, parents):
56
58
  parser.set_defaults(func=_list)
57
59
 
58
60
 
59
- def _app_rev_table(revs: list["ApplicationInfo"]):
61
+ def _app_rev_table(revs: list[ApplicationInfo]):
60
62
  from rich.table import Table
61
63
 
62
64
  table = Table()
@@ -219,7 +221,7 @@ def _runners(args):
219
221
  str(runner.in_flight_requests),
220
222
  (
221
223
  "N/A (active)"
222
- if not runner.expiration_countdown
224
+ if runner.expiration_countdown is None
223
225
  else f"{runner.expiration_countdown}s"
224
226
  ),
225
227
  f"{runner.uptime} ({runner.uptime.total_seconds()}s)",
fal/cli/create.py ADDED
@@ -0,0 +1,26 @@
1
+ PROJECT_TYPES = ["app"]
2
+
3
+
4
+ def _create_project(project_type: str):
5
+ from cookiecutter.main import cookiecutter
6
+
7
+ cookiecutter("https://github.com/fal-ai/cookiecutter-fal.git")
8
+
9
+
10
+ def add_parser(main_subparsers, parents):
11
+ apps_help = "Create fal applications."
12
+ parser = main_subparsers.add_parser(
13
+ "create",
14
+ description=apps_help,
15
+ help=apps_help,
16
+ parents=parents,
17
+ )
18
+
19
+ parser.add_argument(
20
+ metavar="project_type",
21
+ choices=PROJECT_TYPES,
22
+ help="Type of project to create.",
23
+ dest="project_type",
24
+ )
25
+
26
+ parser.set_defaults(func=_create_project)
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 Literal, Optional, Tuple, 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,18 @@ 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]], ...],
67
+ app_name: str,
68
+ args,
69
+ auth: Optional[Literal["public", "shared", "private"]] = None,
70
+ deployment_strategy: Optional[Literal["recreate", "rolling"]] = None,
71
+ no_scale: bool = False,
72
+ ):
64
73
  from fal.api import FalServerlessError, FalServerlessHost
65
74
  from fal.utils import load_function_from
66
75
 
67
- file_path, func_name = args.app_ref
76
+ file_path, func_name = app_ref
68
77
  if file_path is None:
69
78
  # Try to find a python file in the current directory
70
79
  options = list(Path(".").glob("*.py"))
@@ -77,22 +86,28 @@ def _deploy(args):
77
86
  )
78
87
 
79
88
  [file_path] = options
80
- file_path = str(file_path)
89
+ file_path = str(file_path) # type: ignore
81
90
 
82
91
  user = _get_user()
83
92
  host = FalServerlessHost(args.host)
84
- isolated_function, app_name = load_function_from(
93
+ loaded = load_function_from(
85
94
  host,
86
- file_path,
87
- func_name,
95
+ file_path, # type: ignore
96
+ func_name, # type: ignore
88
97
  )
89
- app_name = args.app_name or app_name
98
+ isolated_function = loaded.function
99
+ app_name = app_name or loaded.app_name # type: ignore
100
+ app_auth = auth or loaded.app_auth or "private"
101
+ deployment_strategy = deployment_strategy or "recreate"
102
+
90
103
  app_id = host.register(
91
104
  func=isolated_function.func,
92
105
  options=isolated_function.options,
93
106
  application_name=app_name,
94
- application_auth_mode=args.auth,
107
+ application_auth_mode=app_auth,
95
108
  metadata=isolated_function.options.host.get("metadata", {}),
109
+ deployment_strategy=deployment_strategy,
110
+ scale=not no_scale,
96
111
  )
97
112
 
98
113
  if app_id:
@@ -105,12 +120,47 @@ def _deploy(args):
105
120
  "Registered a new revision for function "
106
121
  f"'{app_name}' (revision='{app_id}')."
107
122
  )
108
- args.console.print(
109
- f"Playground: https://fal.ai/models/{user.username}/{app_name}"
110
- )
111
- args.console.print(
112
- f"Endpoint: https://{gateway_host}/{user.username}/{app_name}"
123
+ args.console.print("Playground:")
124
+ for endpoint in loaded.endpoints:
125
+ args.console.print(
126
+ f"\thttps://fal.ai/models/{user.username}/{app_name}{endpoint}"
127
+ )
128
+ args.console.print("Endpoints:")
129
+ for endpoint in loaded.endpoints:
130
+ args.console.print(
131
+ f"\thttps://{gateway_host}/{user.username}/{app_name}{endpoint}"
132
+ )
133
+
134
+
135
+ def _deploy(args):
136
+ # my-app
137
+ if is_app_name(args.app_ref):
138
+ # we do not allow --app-name and --auth to be used with app name
139
+ if args.app_name or args.auth:
140
+ raise ValueError("Cannot use --app-name or --auth with app name reference.")
141
+
142
+ app_name = args.app_ref[0]
143
+ app_ref, app_auth, app_deployment_strategy, app_no_scale = (
144
+ get_app_data_from_toml(app_name)
113
145
  )
146
+ file_path, func_name = RefAction.split_ref(app_ref)
147
+
148
+ # path/to/myfile.py::MyApp
149
+ else:
150
+ file_path, func_name = args.app_ref
151
+ app_name = args.app_name
152
+ app_auth = args.auth
153
+ app_deployment_strategy = args.strategy
154
+ app_no_scale = args.no_scale
155
+
156
+ _deploy_from_reference(
157
+ (file_path, func_name),
158
+ app_name,
159
+ args,
160
+ app_auth,
161
+ app_deployment_strategy,
162
+ app_no_scale,
163
+ )
114
164
 
115
165
 
116
166
  def add_parser(main_subparsers, parents):
@@ -121,14 +171,22 @@ def add_parser(main_subparsers, parents):
121
171
  raise argparse.ArgumentTypeError(f"{option} is not a auth option")
122
172
  return option
123
173
 
124
- deploy_help = "Deploy a fal application."
174
+ deploy_help = (
175
+ "Deploy a fal application. "
176
+ "If no app reference is provided, the command will look for a "
177
+ "pyproject.toml file with a [tool.fal.apps] section and deploy the "
178
+ "application specified with the provided app name."
179
+ )
180
+
125
181
  epilog = (
126
182
  "Examples:\n"
127
183
  " fal deploy\n"
128
184
  " fal deploy path/to/myfile.py\n"
129
185
  " fal deploy path/to/myfile.py::MyApp\n"
130
186
  " fal deploy path/to/myfile.py::MyApp --app-name myapp --auth public\n"
187
+ " fal deploy my-app\n"
131
188
  )
189
+
132
190
  parser = main_subparsers.add_parser(
133
191
  "deploy",
134
192
  parents=[*parents, FalClientParser(add_help=False)],
@@ -136,22 +194,45 @@ def add_parser(main_subparsers, parents):
136
194
  help=deploy_help,
137
195
  epilog=epilog,
138
196
  )
197
+
139
198
  parser.add_argument(
140
199
  "app_ref",
141
200
  nargs="?",
142
201
  action=RefAction,
143
202
  help=(
144
- "Application reference. " "For example: `myfile.py::MyApp`, `myfile.py`."
203
+ "Application reference. Either a file path or a file path and a "
204
+ "function name separated by '::'. If no reference is provided, the "
205
+ "command will look for a pyproject.toml file with a [tool.fal.apps] "
206
+ "section and deploy the application specified with the provided app name.\n"
207
+ "File path example: path/to/myfile.py::MyApp\n"
208
+ "App name example: my-app\n"
145
209
  ),
146
210
  )
211
+
147
212
  parser.add_argument(
148
213
  "--app-name",
149
214
  help="Application name to deploy with.",
150
215
  )
216
+
151
217
  parser.add_argument(
152
218
  "--auth",
153
219
  type=valid_auth_option,
154
- default="private",
155
220
  help="Application authentication mode (private, public).",
156
221
  )
222
+ parser.add_argument(
223
+ "--strategy",
224
+ choices=["recreate", "rolling"],
225
+ help="Deployment strategy.",
226
+ default="recreate",
227
+ )
228
+ parser.add_argument(
229
+ "--no-scale",
230
+ action="store_true",
231
+ help=(
232
+ "Use min_concurrency/max_concurrency/max_multiplexing from previous "
233
+ "deployment of application with this name, if exists. Otherwise will "
234
+ "use the values from the application code."
235
+ ),
236
+ )
237
+
157
238
  parser.set_defaults(func=_deploy)
fal/cli/main.py CHANGED
@@ -6,7 +6,7 @@ from fal import __version__
6
6
  from fal.console import console
7
7
  from fal.console.icons import CROSS_ICON
8
8
 
9
- from . import apps, auth, deploy, doctor, keys, run, secrets
9
+ from . import apps, auth, create, deploy, doctor, keys, run, runners, secrets
10
10
  from .debug import debugtools, get_debug_parser
11
11
  from .parser import FalParser, FalParserExit
12
12
 
@@ -31,7 +31,7 @@ def _get_main_parser() -> argparse.ArgumentParser:
31
31
  required=True,
32
32
  )
33
33
 
34
- for cmd in [auth, apps, deploy, run, keys, secrets, doctor]:
34
+ for cmd in [auth, apps, deploy, run, keys, secrets, doctor, create, runners]:
35
35
  cmd.add_parser(subparsers, parents)
36
36
 
37
37
  return parser
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()
fal/cli/runners.py ADDED
@@ -0,0 +1,44 @@
1
+ from .parser import FalClientParser
2
+
3
+
4
+ def _kill(args):
5
+ from fal.sdk import FalServerlessClient
6
+
7
+ client = FalServerlessClient(args.host)
8
+ with client.connect() as connection:
9
+ connection.kill_runner(args.id)
10
+
11
+
12
+ def _add_kill_parser(subparsers, parents):
13
+ kill_help = "Kill a runner."
14
+ parser = subparsers.add_parser(
15
+ "kill",
16
+ description=kill_help,
17
+ help=kill_help,
18
+ parents=parents,
19
+ )
20
+ parser.add_argument(
21
+ "id",
22
+ help="Runner ID.",
23
+ )
24
+ parser.set_defaults(func=_kill)
25
+
26
+
27
+ def add_parser(main_subparsers, parents):
28
+ runners_help = "Manage fal runners."
29
+ parser = main_subparsers.add_parser(
30
+ "runners",
31
+ description=runners_help,
32
+ help=runners_help,
33
+ parents=parents,
34
+ aliases=["machine"], # backwards compatibility
35
+ )
36
+
37
+ subparsers = parser.add_subparsers(
38
+ title="Commands",
39
+ metavar="command",
40
+ required=True,
41
+ parser_class=FalClientParser,
42
+ )
43
+
44
+ _add_kill_parser(subparsers, parents)
fal/config.py ADDED
@@ -0,0 +1,23 @@
1
+ import os
2
+
3
+ import tomli
4
+
5
+
6
+ class Config:
7
+ DEFAULT_CONFIG_PATH = "~/.fal/config.toml"
8
+ DEFAULT_PROFILE = "default"
9
+
10
+ def __init__(self):
11
+ self.config_path = os.path.expanduser(
12
+ os.getenv("FAL_CONFIG_PATH", self.DEFAULT_CONFIG_PATH)
13
+ )
14
+ self.profile = os.getenv("FAL_PROFILE", self.DEFAULT_PROFILE)
15
+
16
+ try:
17
+ with open(self.config_path, "rb") as file:
18
+ self.config = tomli.load(file)
19
+ except FileNotFoundError:
20
+ self.config = {}
21
+
22
+ def get(self, key):
23
+ return self.config.get(self.profile, {}).get(key)
fal/container.py CHANGED
@@ -3,7 +3,7 @@ class ContainerImage:
3
3
  from a Dockerfile.
4
4
  """
5
5
 
6
- _known_keys = {"dockerfile_str", "build_env", "build_args"}
6
+ _known_keys = {"dockerfile_str", "build_args", "registries", "builder"}
7
7
 
8
8
  @classmethod
9
9
  def from_dockerfile_str(cls, text: str, **kwargs):
@@ -1,3 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from ._base import FalServerlessException # noqa: F401
3
+ from ._base import (
4
+ AppException, # noqa: F401
5
+ FalServerlessException, # noqa: F401
6
+ FieldException, # noqa: F401
7
+ RequestCancelledException, # noqa: F401
8
+ )
9
+ from ._cuda import CUDAOutOfMemoryException # noqa: F401
fal/exceptions/_base.py CHANGED
@@ -1,7 +1,58 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
4
+
3
5
 
4
6
  class FalServerlessException(Exception):
5
7
  """Base exception type for fal Serverless related flows and APIs."""
6
8
 
7
9
  pass
10
+
11
+
12
+ @dataclass
13
+ class AppException(FalServerlessException):
14
+ """
15
+ Base exception class for application-specific errors.
16
+
17
+ Attributes:
18
+ message: A descriptive message explaining the error.
19
+ status_code: The HTTP status code associated with the error.
20
+ """
21
+
22
+ message: str
23
+ status_code: int
24
+
25
+
26
+ @dataclass
27
+ class FieldException(FalServerlessException):
28
+ """Exception raised for errors related to specific fields.
29
+
30
+ Attributes:
31
+ field: The field that caused the error.
32
+ message: A descriptive message explaining the error.
33
+ status_code: The HTTP status code associated with the error. Defaults to 422
34
+ type: The type of error. Defaults to "value_error"
35
+ """
36
+
37
+ field: str
38
+ message: str
39
+ status_code: int = 422
40
+ type: str = "value_error"
41
+
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."
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ._base import AppException
6
+
7
+ # PyTorch error message for out of memory
8
+ _CUDA_OOM_MESSAGE = "CUDA error: out of memory"
9
+
10
+ # Special status code for CUDA out of memory errors
11
+ _CUDA_OOM_STATUS_CODE = 503
12
+
13
+
14
+ @dataclass
15
+ class CUDAOutOfMemoryException(AppException):
16
+ """Exception raised when a CUDA operation runs out of memory."""
17
+
18
+ message: str = _CUDA_OOM_MESSAGE
19
+ status_code: int = _CUDA_OOM_STATUS_CODE
20
+
21
+
22
+ # based on https://github.com/Lightning-AI/pytorch-lightning/blob/37e04d075a5532c69b8ac7457795b4345cca30cc/src/lightning/pytorch/utilities/memory.py#L49
23
+ def _is_cuda_oom_exception(exception: BaseException) -> bool:
24
+ return _is_cuda_out_of_memory(exception) or _is_cudnn_snafu(exception)
25
+
26
+
27
+ # based on https://github.com/BlackHC/toma/blob/master/toma/torch_cuda_memory.py
28
+ def _is_cuda_out_of_memory(exception: BaseException) -> bool:
29
+ return (
30
+ isinstance(exception, RuntimeError)
31
+ and len(exception.args) == 1
32
+ and "CUDA" in exception.args[0]
33
+ and "out of memory" in exception.args[0]
34
+ )
35
+
36
+
37
+ # based on https://github.com/BlackHC/toma/blob/master/toma/torch_cuda_memory.py
38
+ def _is_cudnn_snafu(exception: BaseException) -> bool:
39
+ # For/because of https://github.com/pytorch/pytorch/issues/4107
40
+ return (
41
+ isinstance(exception, RuntimeError)
42
+ and len(exception.args) == 1
43
+ and "cuDNN error: CUDNN_STATUS_NOT_SUPPORTED." in exception.args[0]
44
+ )
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