diff --git a/.gitignore b/.gitignore index b5a318c8..bcd772a4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dmypy.json /coverage.xml /.coverage +tmp/ diff --git a/projects/fal/src/fal/__init__.py b/projects/fal/src/fal/__init__.py index cc1a4a45..4ee850b7 100644 --- a/projects/fal/src/fal/__init__.py +++ b/projects/fal/src/fal/__init__.py @@ -4,6 +4,7 @@ from fal.api import FalServerlessHost, LocalHost, cached, function from fal.api import function as isolated # noqa: F401 from fal.app import App, endpoint, realtime, wrap_app # noqa: F401 +from fal.container import ContainerImage from fal.sdk import FalServerlessKeyCredentials from fal.sync import sync_dir @@ -26,4 +27,5 @@ "sync_dir", "__version__", "version_tuple", + "ContainerImage", ] diff --git a/projects/fal/src/fal/api.py b/projects/fal/src/fal/api.py index 599d2d2e..34c18222 100644 --- a/projects/fal/src/fal/api.py +++ b/projects/fal/src/fal/api.py @@ -42,6 +42,7 @@ import fal.flags as flags from fal._serialization import include_modules_from, patch_pickle +from fal.container import ContainerImage from fal.exceptions import FalServerlessException from fal.logging.isolate import IsolateLogPrinter from fal.sdk import ( @@ -523,9 +524,12 @@ def add_requirements(self, requirements: list[str]): pip_requirements = self.environment.setdefault("requirements", []) elif kind == "conda": pip_requirements = self.environment.setdefault("pip", []) + elif kind == "container": + return None else: raise FalServerlessError( - "Only conda and virtualenv is supported as environment options" + "Only {conda, virtualenv, container} " + "are supported as environment options." ) # Already has these. @@ -743,8 +747,55 @@ def function( _scheduler: str | None = None, ) -> Callable[ [Callable[Concatenate[ArgsT], ReturnT]], ServedIsolatedFunction[ArgsT, ReturnT] -]: - ... +]: ... + + +@overload +def function( + kind: Literal["container"], + *, + image: ContainerImage | None = None, + # Common options + host: FalServerlessHost = _DEFAULT_HOST, + serve: Literal[False] = False, + exposed_port: int | None = None, + max_concurrency: int | None = None, + # FalServerlessHost options + metadata: dict[str, Any] | None = None, + machine_type: str = FAL_SERVERLESS_DEFAULT_MACHINE_TYPE, + keep_alive: int = FAL_SERVERLESS_DEFAULT_KEEP_ALIVE, + max_multiplexing: int = FAL_SERVERLESS_DEFAULT_MAX_MULTIPLEXING, + min_concurrency: int = FAL_SERVERLESS_DEFAULT_MIN_CONCURRENCY, + setup_function: Callable[..., None] | None = None, + _base_image: str | None = None, + _scheduler: str | None = None, +) -> Callable[ + [Callable[Concatenate[ArgsT], ReturnT]], IsolatedFunction[ArgsT, ReturnT] +]: ... + + +@overload +def function( + kind: Literal["container"], + *, + image: ContainerImage | None = None, + # Common options + host: FalServerlessHost = _DEFAULT_HOST, + serve: Literal[True], + exposed_port: int | None = None, + max_concurrency: int | None = None, + # FalServerlessHost options + metadata: dict[str, Any] | None = None, + machine_type: str = FAL_SERVERLESS_DEFAULT_MACHINE_TYPE, + keep_alive: int = FAL_SERVERLESS_DEFAULT_KEEP_ALIVE, + max_multiplexing: int = FAL_SERVERLESS_DEFAULT_MAX_MULTIPLEXING, + min_concurrency: int = FAL_SERVERLESS_DEFAULT_MIN_CONCURRENCY, + setup_function: Callable[..., None] | None = None, + _base_image: str | None = None, + _scheduler: str | None = None, +) -> Callable[ + [Callable[Concatenate[ArgsT], ReturnT]], ServedIsolatedFunction[ArgsT, ReturnT] +]: ... # implementation @@ -1121,4 +1172,3 @@ class Server(uvicorn.Server): def install_signal_handlers(self) -> None: pass - diff --git a/projects/fal/src/fal/container.py b/projects/fal/src/fal/container.py new file mode 100644 index 00000000..663a124b --- /dev/null +++ b/projects/fal/src/fal/container.py @@ -0,0 +1,19 @@ +class ContainerImage: + """ContainerImage represents a Docker image that can be built + from a Dockerfile. + """ + + _known_keys = {"dockerfile_str", "build_env", "build_args"} + + @classmethod + def from_dockerfile_str(cls, text: str, **kwargs): + # Check for unknown keys and return them as a dict. + return dict( + dockerfile_str=text, + **{k: v for k, v in kwargs.items() if k in cls._known_keys}, + ) + + @classmethod + def from_dockerfile(cls, path: str, **kwargs): + with open(path) as fobj: + return cls.from_dockerfile_str(fobj.read(), **kwargs) diff --git a/projects/fal/tests/test_apps.py b/projects/fal/tests/test_apps.py index 074e30c9..a308318f 100644 --- a/projects/fal/tests/test_apps.py +++ b/projects/fal/tests/test_apps.py @@ -8,6 +8,7 @@ import httpx import pytest from fal import apps +from fal.container import ContainerImage from fal.rest_client import REST_CLIENT from fal.workflows import Workflow from fastapi import WebSocket @@ -49,6 +50,23 @@ def addition_app(input: Input) -> Output: nomad_addition_app = addition_app.on(_scheduler="nomad") +@fal.function( + kind="container", + image=ContainerImage.from_dockerfile_str("FROM python:3.11"), + keep_alive=60, + machine_type="S", + serve=True, + max_concurrency=1, +) +def container_addition_app(input: Input) -> Output: + print("starting...") + for _ in range(input.wait_time): + print("sleeping...") + time.sleep(1) + + return Output(result=input.lhs + input.rhs) + + @fal.function( keep_alive=300, requirements=["fastapi", "uvicorn", "pydantic==1.10.12"], @@ -201,6 +219,20 @@ def test_nomad_app(): yield f"{user_id}/{app_revision}" +@pytest.mark.xfail(reason="The support needs to be deployed. See https://github.com/fal-ai/isolate-cloud/pull/1809") +@pytest.fixture(scope="module") +def test_container_app(): + # Create a temporary app, register it, and return the ID of it. + + from fal.cli.deploy import _get_user_id + + app_revision = container_addition_app.host.register( + func=container_addition_app.func, + options=container_addition_app.options, + ) + user_id = _get_user_id() + yield f"{user_id}/{app_revision}" + @pytest.fixture(scope="module") def test_fastapi_app(): # Create a temporary app, register it, and return the ID of it. diff --git a/projects/fal/tests/test_stability.py b/projects/fal/tests/test_stability.py index 5de4aa0e..f8e9b5af 100644 --- a/projects/fal/tests/test_stability.py +++ b/projects/fal/tests/test_stability.py @@ -5,6 +5,7 @@ import fal import pytest from fal.api import FalServerlessError, Options +from fal.container import ContainerImage from fal.toolkit.file.file import File from pydantic import __version__ as pydantic_version @@ -54,6 +55,40 @@ def mult(a, b): assert mult(5, 2) == 10 +@pytest.mark.xfail(reason="The support needs to be deployed. See https://github.com/fal-ai/isolate-cloud/pull/1809") +def test_regular_function_in_a_container(isolated_client): + @isolated_client("container") + def regular_function(): + return 42 + + assert regular_function() == 42 + + @isolated_client("container") + def mult(a, b): + return a * b + + assert mult(5, 2) == 10 + +@pytest.mark.xfail(reason="The support needs to be deployed. See https://github.com/fal-ai/isolate-cloud/pull/1809") +def test_regular_function_in_a_container_with_custom_image(isolated_client): + @isolated_client( + "container", + image=ContainerImage.from_dockerfile_str("FROM python:3.9"), + ) + def regular_function(): + return 42 + + assert regular_function() == 42 + + @isolated_client( + "container", + image=ContainerImage.from_dockerfile_str("FROM python:3.9"), + ) + def mult(a, b): + return a * b + + assert mult(5, 2) == 10 + def test_function_pipelining(isolated_client): @isolated_client("virtualenv")