Skip to content

Commit 6587d96

Browse files
authored
Merge pull request #1 from fal-ai/add-server
feat: Add isolate server
2 parents c4b3b85 + 7909b8a commit 6587d96

23 files changed

+1165
-164
lines changed

.github/workflows/test.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ jobs:
3838

3939
- name: Install dependencies
4040
run: |
41-
python -m pip install pytest
42-
python -m pip install -e .
41+
python -m pip install -r dev-requirements.txt
42+
python -m pip install -e ".[server]"
4343
4444
- name: Test
4545
run: python -m pytest

dev-requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pytest
2+
cloudpickle>=2.2.0
3+
dill>=0.3.5.1

poetry.lock

+324
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+13-8
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ authors = ["Features & Labels <[email protected]>"]
66

77
[tool.poetry.dependencies]
88
python = ">=3.7"
9-
virtualenv = "^20"
10-
cloudpickle = "^2.2.0"
11-
importlib-metadata = "^5.0.0"
9+
virtualenv = ">=20.4"
10+
importlib-metadata = ">=4.4"
11+
flask = { version = "*", optional = true }
12+
marshmallow = { version = "*", optional = true }
13+
14+
[tool.poetry.extras]
15+
server = ["flask", "marshmallow"]
16+
17+
[tool.poetry.plugins."isolate.backends"]
18+
"virtualenv" = "isolate.backends.virtual_env:VirtualPythonEnvironment"
19+
"conda" = "isolate.backends.conda:CondaEnvironment"
20+
"local" = "isolate.backends.local:LocalPythonEnvironment"
1221

1322
[build-system]
14-
requires = ["poetry-core>=1.0.0"]
23+
requires = ["poetry-core>=1.1.0"]
1524
build-backend = "poetry.core.masonry.api"
1625

1726
[tool.isort]
@@ -20,7 +29,3 @@ force_grid_wrap=0
2029
include_trailing_comma=true
2130
multi_line_output=3
2231
use_parentheses=true
23-
24-
[tool.poetry.plugins."isolate.backends"]
25-
"virtualenv" = "isolate.backends.virtual_env:VirtualPythonEnvironment"
26-
"conda" = "isolate.backends.conda:CondaEnvironment"

src/isolate/__init__.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from isolate.managed import EnvironmentManager
21
from isolate.registry import prepare_environment
32

43
__version__ = "0.1.0"

src/isolate/backends/_base.py

+5
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@
2727
"EnvironmentConnection",
2828
"BaseEnvironment",
2929
"UserException",
30+
"EnvironmentCreationError",
3031
]
3132

3233
ConnectionKeyType = TypeVar("ConnectionKeyType")
3334
CallResultType = TypeVar("CallResultType")
3435
BasicCallable = Callable[[], CallResultType]
3536

3637

38+
class EnvironmentCreationError(Exception):
39+
"""Raised when the environment cannot be created."""
40+
41+
3742
class BaseEnvironment(Generic[ConnectionKeyType]):
3843
"""Represents a managed environment definition for an isolatation backend
3944
that can be used to run Python code with different set of dependencies."""

src/isolate/backends/common.py

+12-5
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
from __future__ import annotations
22

33
import functools
4+
import hashlib
45
import importlib
56
import os
67
import shutil
78
import sysconfig
89
import threading
910
from contextlib import contextmanager
10-
from functools import partial
11+
from functools import lru_cache
1112
from pathlib import Path
12-
from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Tuple
13-
14-
if TYPE_CHECKING:
15-
from isolate.backends._base import BaseEnvironment
13+
from typing import Any, Callable, Iterator, Optional, Tuple
1614

1715

1816
@contextmanager
@@ -167,3 +165,12 @@ def run_serialized(serialization_backend_name: str, data: bytes) -> Any:
167165
serialization_backend = importlib.import_module(serialization_backend_name)
168166
executable = serialization_backend.loads(data)
169167
return executable()
168+
169+
170+
@lru_cache(maxsize=None)
171+
def sha256_digest_of(*unique_fields: str, _join_char: str = "\n") -> str:
172+
"""Return the SHA256 digest that corresponds to the combined version
173+
of 'unique_fields. The order is preserved."""
174+
175+
inner_text = _join_char.join(unique_fields).encode()
176+
return hashlib.sha256(inner_text).hexdigest()

src/isolate/backends/conda.py

+27-18
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22

3-
import hashlib
43
import os
54
import shutil
65
import subprocess
76
from dataclasses import dataclass
87
from pathlib import Path
98
from typing import Any, ClassVar, Dict, List
109

11-
from isolate.backends import BaseEnvironment
12-
from isolate.backends.common import cache_static, logged_io, rmdir_on_fail
10+
from isolate.backends import BaseEnvironment, EnvironmentCreationError
11+
from isolate.backends.common import (
12+
cache_static,
13+
logged_io,
14+
rmdir_on_fail,
15+
sha256_digest_of,
16+
)
1317
from isolate.backends.connections import PythonIPC
1418
from isolate.backends.context import GLOBAL_CONTEXT, ContextType
1519

@@ -38,7 +42,7 @@ def from_config(
3842

3943
@property
4044
def key(self) -> str:
41-
return hashlib.sha256(" ".join(self.packages).encode()).hexdigest()
45+
return sha256_digest_of(*self.packages)
4246

4347
def create(self) -> Path:
4448
path = self.context.get_cache_dir(self) / self.key
@@ -52,20 +56,25 @@ def create(self) -> Path:
5256
self.log(f"Installing packages: {', '.join(self.packages)}")
5357

5458
with logged_io(self.log) as (stdout, stderr):
55-
subprocess.check_call(
56-
[
57-
conda_executable,
58-
"create",
59-
"--yes",
60-
# The environment will be created under $BASE_CACHE_DIR/conda
61-
# so that in the future we can reuse it.
62-
"--prefix",
63-
path,
64-
*self.packages,
65-
],
66-
stdout=stdout,
67-
stderr=stderr,
68-
)
59+
try:
60+
subprocess.check_call(
61+
[
62+
conda_executable,
63+
"create",
64+
"--yes",
65+
# The environment will be created under $BASE_CACHE_DIR/conda
66+
# so that in the future we can reuse it.
67+
"--prefix",
68+
path,
69+
*self.packages,
70+
],
71+
stdout=stdout,
72+
stderr=stderr,
73+
)
74+
except subprocess.SubprocessError as exc:
75+
raise EnvironmentCreationError(
76+
"Failure during 'conda create'"
77+
) from exc
6978

7079
return path
7180

Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from isolate.backends.connections.ipc import (
2-
DualPythonIPC,
2+
ExtendedPythonIPC,
33
IsolatedProcessConnection,
44
PythonIPC,
55
)
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from isolate.backends.connections.ipc._base import (
2-
DualPythonIPC,
2+
ExtendedPythonIPC,
33
IsolatedProcessConnection,
44
PythonIPC,
55
)

src/isolate/backends/connections/ipc/_base.py

+32-22
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
import subprocess
55
import time
66
from contextlib import ExitStack, closing, contextmanager
7-
from dataclasses import dataclass
7+
from dataclasses import dataclass, field
88
from functools import partial
99
from multiprocessing.connection import ConnectionWrapper, Listener
1010
from pathlib import Path
11-
from typing import Any, ContextManager, Iterator, List, Tuple, Union
11+
from typing import Any, ContextManager, Dict, Iterator, List, Tuple, Union
1212

1313
from isolate.backends import (
1414
BasicCallable,
@@ -208,8 +208,8 @@ def start_process(
208208

209209
def _get_python_env(self):
210210
return {
211-
"PYTHONUNBUFFERED": "1", # We want to stream the logs as they come.
212211
**os.environ,
212+
"PYTHONUNBUFFERED": "1", # We want to stream the logs as they come.
213213
}
214214

215215
def _get_python_cmd(
@@ -247,26 +247,36 @@ def _parse_agent_and_log(self, line: str, level: LogLevel) -> None:
247247
self.log(line, level=level, source=source)
248248

249249

250+
# TODO: should we actually merge this with PythonIPC since it is
251+
# simple enough and interchangeable?
250252
@dataclass
251-
class DualPythonIPC(PythonIPC):
252-
"""A dual-environment Python IPC implementation that
253-
can run the agent process in an environment with its
254-
Python and also load the shared libraries from a different
255-
one.
256-
257-
The user of DualPythonIPC must ensure that the Python versions from
258-
both of these environments are the same. Using different versions is
259-
an undefined behavior.
253+
class ExtendedPythonIPC(PythonIPC):
254+
"""A Python IPC implementation that can also inherit packages from
255+
other environments (e.g. a virtual environment that has the core
256+
requirements like `dill` can be inherited on a new environment).
257+
258+
The given extra_inheritance_paths should be a list of paths that
259+
comply with the sysconfig, and it should be ordered in terms of
260+
priority (e.g. the first path will be the most prioritized one,
261+
right after the current environment). So if two environments have
262+
conflicting versions of the same package, the first one present in
263+
the inheritance chain will be used.
264+
265+
This works by including the `site-packages` directory of the
266+
inherited environment in the `PYTHONPATH` when starting the
267+
agent process.
260268
"""
261269

262-
secondary_path: Path
270+
extra_inheritance_paths: List[Path] = field(default_factory=list)
263271

264-
def _get_python_env(self):
265-
# We are going to use the primary environment to run the Python
266-
# interpreter, but at the same time we are going to inherit all
267-
# the packages from the secondary environment.
268-
269-
# The search order is important, we want the primary path to
270-
# take precedence.
271-
python_path = python_path_for(self.environment_path, self.secondary_path)
272-
return {"PYTHONPATH": python_path, **super()._get_python_env()}
272+
def _get_python_env(self) -> Dict[str, str]:
273+
env_variables = super()._get_python_env()
274+
275+
if self.extra_inheritance_paths:
276+
# The order here should reflect the order of the inheritance
277+
# where the actual environment already takes precedence.
278+
python_path = python_path_for(
279+
self.environment_path, *self.extra_inheritance_paths
280+
)
281+
env_variables["PYTHONPATH"] = python_path
282+
return env_variables

src/isolate/backends/connections/ipc/agent.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
# one being the actual result of the given callable, and the other one is a boolean flag
1616
# indicating whether the callable has raised an exception or not.
1717

18+
# WARNING: Please do not import anything outside of standard library before
19+
# the call to load_pth_files(). After that point, imports to installed packages
20+
# are allowed.
21+
1822
import base64
1923
import importlib
2024
import os
25+
import site
2126
import sys
2227
import time
2328
from argparse import ArgumentParser
@@ -26,6 +31,29 @@
2631
from typing import ContextManager, Tuple
2732

2833

34+
def load_pth_files() -> None:
35+
"""Each site dir in Python can contain some .pth files, which are
36+
basically instructions that tell Python to load other stuff. This is
37+
generally used for editable installations, and just setting PYTHONPATH
38+
won't make them expand so we need manually process them. Luckily, site
39+
module can simply take the list of new paths and recognize them.
40+
41+
https://docs.python.org/3/tutorial/modules.html#the-module-search-path
42+
"""
43+
python_path = os.getenv("PYTHONPATH")
44+
if python_path is None:
45+
return None
46+
47+
# TODO: The order here is the same as the one that is used for generating the
48+
# PYTHONPATH. The only problem that might occur is that, on a chain with
49+
# 3 ore more nodes (A, B, C), if X is installed as an editable package to
50+
# B and a normal package to C, then C might actually take precedence. This
51+
# will need to be fixed once we are dealing with more than 2 nodes and editable
52+
# packages.
53+
for site_dir in python_path.split(os.pathsep):
54+
site.addsitedir(site_dir)
55+
56+
2957
def decode_service_address(address: str) -> Tuple[str, int]:
3058
host, port = base64.b64decode(address).decode("utf-8").rsplit(":", 1)
3159
return host, int(port)
@@ -115,7 +143,7 @@ def _get_shell_bootstrap() -> str:
115143
return " ".join(
116144
f"{session_variable}={os.getenv(session_variable)}"
117145
for session_variable in [
118-
# PYTHONPATH is customized by the Dual Environment IPC
146+
# PYTHONPATH is customized by the Extended Environment IPC
119147
# system to make sure that the isolated process can
120148
# import stuff from the primary environment. Without this
121149
# the isolated process will not be able to run properly
@@ -158,4 +186,5 @@ def main() -> int:
158186

159187

160188
if __name__ == "__main__":
189+
load_pth_files()
161190
sys.exit(main())

src/isolate/backends/local.py

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Any, ClassVar, Dict
7+
8+
from isolate.backends import BaseEnvironment
9+
from isolate.backends.common import sha256_digest_of
10+
from isolate.backends.connections import PythonIPC
11+
from isolate.backends.context import GLOBAL_CONTEXT, ContextType
12+
13+
14+
@dataclass
15+
class LocalPythonEnvironment(BaseEnvironment[Path]):
16+
BACKEND_NAME: ClassVar[str] = "local"
17+
18+
@classmethod
19+
def from_config(
20+
cls,
21+
config: Dict[str, Any],
22+
context: ContextType = GLOBAL_CONTEXT,
23+
) -> BaseEnvironment:
24+
environment = cls()
25+
environment.set_context(context)
26+
return environment
27+
28+
@property
29+
def key(self) -> str:
30+
return sha256_digest_of(sys.exec_prefix)
31+
32+
def create(self) -> Path:
33+
return Path(sys.exec_prefix)
34+
35+
def destroy(self, connection_key: Path) -> None:
36+
raise NotImplementedError("LocalPythonEnvironment cannot be destroyed")
37+
38+
def exists(self) -> bool:
39+
return True
40+
41+
def open_connection(self, connection_key: Path) -> PythonIPC:
42+
return PythonIPC(self, connection_key)

0 commit comments

Comments
 (0)