Skip to content

Commit 6424afe

Browse files
committed
feat(cli): add fal build to create a revision without deploying it
1 parent b507051 commit 6424afe

3 files changed

Lines changed: 223 additions & 0 deletions

File tree

projects/fal/src/fal/api/deploy.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ class DeploymentResult:
9292
auth_mode: str
9393

9494

95+
@dataclass
96+
class BuildResult:
97+
revision: str
98+
app_name: str | None
99+
100+
95101
@dataclass
96102
class PreparedDeployment:
97103
host: FalServerlessHost
@@ -412,6 +418,110 @@ def _execute_loaded_deployment(
412418
)
413419

414420

421+
def _execute_loaded_build(
422+
*,
423+
host: FalServerlessHost,
424+
loaded: LoadedFunction,
425+
app_data: AppData,
426+
environment_name: str | None = None,
427+
result_handler: ResultHandler | None = None,
428+
build_result_handler: ResultHandler | None = None,
429+
prepare_options_handler: ProgressCallback | None = None,
430+
) -> BuildResult:
431+
from fal.api import FalServerlessError
432+
433+
build_result_handler = (
434+
result_handler if build_result_handler is None else build_result_handler
435+
)
436+
437+
isolated_function = replace(
438+
loaded.function,
439+
options=host.prepare_options(
440+
loaded.function.options,
441+
func=loaded.function.func,
442+
on_progress=prepare_options_handler,
443+
),
444+
)
445+
446+
host.build_environment(
447+
isolated_function.options,
448+
application_name=loaded.app_name,
449+
environment_name=environment_name,
450+
result_handler=build_result_handler,
451+
)
452+
453+
isolated_function.fetch_metadata(build_environment=False)
454+
455+
metadata = dict(isolated_function.build_metadata())
456+
if app_data.message is not None:
457+
metadata["message"] = app_data.message
458+
if app_data.annotations:
459+
metadata["annotations"] = dict(app_data.annotations)
460+
461+
# Registering without an application_name stores the revision and leaves
462+
# every alias pointing where it already pointed.
463+
result = host.register(
464+
func=isolated_function.func,
465+
options=isolated_function.options,
466+
application_name=None,
467+
source_code=loaded.source_code,
468+
metadata=metadata,
469+
deployment_strategy=app_data.deployment_strategy or "rolling",
470+
scale=app_data.reset_scale,
471+
environment_name=environment_name,
472+
result_handler=result_handler,
473+
entrypoint=isolated_function.run_entrypoint,
474+
build_environment=False,
475+
)
476+
477+
if not result or not result.result:
478+
raise FalServerlessError(
479+
"Build failed: The server did not confirm the revision. "
480+
"This may indicate a network issue or server error. "
481+
"Please try again."
482+
)
483+
484+
return BuildResult(
485+
revision=result.result.application_id,
486+
app_name=loaded.app_name,
487+
)
488+
489+
490+
def build(
491+
client: SyncServerlessClient,
492+
app_ref: str | tuple[str | None, str | None] | None = None,
493+
*,
494+
app_name: str | None = None,
495+
force_env_build: bool = False,
496+
environment_name: str | None = None,
497+
message: str | None = None,
498+
annotations: dict[str, str] | None = None,
499+
result_handler: ResultHandler | None = None,
500+
build_result_handler: ResultHandler | None = None,
501+
) -> BuildResult:
502+
resolved_app_ref, app_data = _resolve_deployment_reference(
503+
app_ref,
504+
app_name=app_name,
505+
message=message,
506+
annotations=annotations,
507+
)
508+
prepared = _prepare_deployment_from_reference(
509+
client,
510+
resolved_app_ref,
511+
app_data,
512+
force_env_build=force_env_build,
513+
environment_name=environment_name,
514+
)
515+
return _execute_loaded_build(
516+
host=prepared.host,
517+
loaded=prepared.loaded,
518+
app_data=prepared.app_data,
519+
environment_name=prepared.environment_name,
520+
result_handler=result_handler,
521+
build_result_handler=build_result_handler,
522+
)
523+
524+
415525
def prepare_deployment(
416526
client: SyncServerlessClient,
417527
app_ref: str | tuple[str | None, str | None] | None = None,

projects/fal/src/fal/cli/build.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from fal.api.client import SyncServerlessClient
6+
7+
from .deploy import _resolve_team_and_app_ref
8+
from .parser import FalClientParser, RefAction, add_env_argument, get_output_parser
9+
10+
11+
def _build(args):
12+
from fal.api.deploy import build as build_api
13+
14+
from ._result_handlers import (
15+
CliBuildEnvironmentResultHandler,
16+
CliRegisterResultHandler,
17+
)
18+
19+
team, app_ref = _resolve_team_and_app_ref(args)
20+
21+
client = SyncServerlessClient(host=args.host, team=team)
22+
res = build_api(
23+
client,
24+
app_ref,
25+
app_name=args.app_name,
26+
force_env_build=args.no_cache,
27+
environment_name=args.env,
28+
result_handler=CliRegisterResultHandler(console=args.console),
29+
build_result_handler=CliBuildEnvironmentResultHandler(console=args.console),
30+
)
31+
32+
_render_build_result(args, res)
33+
34+
35+
def _render_build_result(args, res) -> None:
36+
if args.output == "json":
37+
args.console.print(
38+
json.dumps({"revision": res.revision, "app_name": res.app_name})
39+
)
40+
elif args.output == "pretty":
41+
from fal.console.icons import get_check_icon
42+
43+
args.console.print(
44+
f"{get_check_icon(args.console)} Built successfully",
45+
style="bold green",
46+
)
47+
args.console.print("")
48+
args.console.print(f"Revision: {res.revision}")
49+
args.console.print("")
50+
args.console.print(
51+
"[dim]This revision is not serving traffic. "
52+
"Deploy it with `fal deploy` to point an alias at it.[/dim]"
53+
)
54+
else:
55+
raise AssertionError(f"Invalid output format: {args.output}")
56+
57+
58+
def add_parser(main_subparsers, parents):
59+
build_help = (
60+
"Build a fal application into a new revision without deploying it. "
61+
"No alias is pointed at the revision, so it does not serve traffic."
62+
)
63+
64+
epilog = (
65+
"Examples:\n"
66+
" fal build\n"
67+
" fal build path/to/myfile.py\n"
68+
" fal build path/to/myfile.py::MyApp\n"
69+
" fal build my-app\n"
70+
)
71+
72+
parser = main_subparsers.add_parser(
73+
"build",
74+
parents=[
75+
*parents,
76+
get_output_parser(),
77+
FalClientParser(add_help=False),
78+
],
79+
description=build_help,
80+
help=build_help,
81+
epilog=epilog,
82+
)
83+
84+
parser.add_argument(
85+
"app_ref",
86+
nargs="?",
87+
action=RefAction,
88+
help=(
89+
"Application reference. Either a file path or a file path and a "
90+
"function name separated by '::'. If no reference is provided, the "
91+
"command will look for a pyproject.toml file with a [tool.fal.apps] "
92+
"section and build the application specified with the provided app name.\n"
93+
"File path example: path/to/myfile.py::MyApp\n"
94+
"App name example: my-app (configure team in pyproject.toml)\n"
95+
),
96+
)
97+
98+
parser.add_argument(
99+
"--app-name",
100+
help="Application name to build with.",
101+
)
102+
103+
parser.add_argument(
104+
"--no-cache",
105+
action="store_true",
106+
help="Do not use the cache for the environment build.",
107+
)
108+
109+
add_env_argument(parser)
110+
111+
parser.set_defaults(func=_build)

projects/fal/src/fal/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
api,
1212
apps,
1313
auth,
14+
build,
1415
completion,
1516
create,
1617
deploy,
@@ -57,6 +58,7 @@ def _get_main_parser() -> argparse.ArgumentParser:
5758
apps,
5859
environments,
5960
queue,
61+
build,
6062
deploy,
6163
run,
6264
keys,

0 commit comments

Comments
 (0)