isolate 0.22.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.
- isolate/__init__.py +3 -0
- isolate/_isolate_version.py +34 -0
- isolate/_version.py +6 -0
- isolate/backends/__init__.py +2 -0
- isolate/backends/_base.py +132 -0
- isolate/backends/common.py +259 -0
- isolate/backends/conda.py +215 -0
- isolate/backends/container.py +64 -0
- isolate/backends/local.py +46 -0
- isolate/backends/pyenv.py +143 -0
- isolate/backends/remote.py +141 -0
- isolate/backends/settings.py +121 -0
- isolate/backends/virtualenv.py +204 -0
- isolate/common/__init__.py +0 -0
- isolate/common/timestamp.py +15 -0
- isolate/connections/__init__.py +21 -0
- isolate/connections/_local/__init__.py +2 -0
- isolate/connections/_local/_base.py +190 -0
- isolate/connections/_local/agent_startup.py +53 -0
- isolate/connections/common.py +121 -0
- isolate/connections/grpc/__init__.py +1 -0
- isolate/connections/grpc/_base.py +175 -0
- isolate/connections/grpc/agent.py +284 -0
- isolate/connections/grpc/configuration.py +23 -0
- isolate/connections/grpc/definitions/__init__.py +11 -0
- isolate/connections/grpc/definitions/agent.proto +18 -0
- isolate/connections/grpc/definitions/agent_pb2.py +29 -0
- isolate/connections/grpc/definitions/agent_pb2.pyi +44 -0
- isolate/connections/grpc/definitions/agent_pb2_grpc.py +68 -0
- isolate/connections/grpc/definitions/common.proto +49 -0
- isolate/connections/grpc/definitions/common_pb2.py +35 -0
- isolate/connections/grpc/definitions/common_pb2.pyi +152 -0
- isolate/connections/grpc/definitions/common_pb2_grpc.py +4 -0
- isolate/connections/grpc/interface.py +71 -0
- isolate/connections/ipc/__init__.py +5 -0
- isolate/connections/ipc/_base.py +225 -0
- isolate/connections/ipc/agent.py +205 -0
- isolate/logger.py +53 -0
- isolate/logs.py +76 -0
- isolate/py.typed +0 -0
- isolate/registry.py +53 -0
- isolate/server/__init__.py +1 -0
- isolate/server/definitions/__init__.py +13 -0
- isolate/server/definitions/server.proto +80 -0
- isolate/server/definitions/server_pb2.py +56 -0
- isolate/server/definitions/server_pb2.pyi +241 -0
- isolate/server/definitions/server_pb2_grpc.py +205 -0
- isolate/server/health/__init__.py +11 -0
- isolate/server/health/health.proto +23 -0
- isolate/server/health/health_pb2.py +32 -0
- isolate/server/health/health_pb2.pyi +66 -0
- isolate/server/health/health_pb2_grpc.py +99 -0
- isolate/server/health_server.py +40 -0
- isolate/server/interface.py +27 -0
- isolate/server/server.py +735 -0
- isolate-0.22.0.dist-info/METADATA +88 -0
- isolate-0.22.0.dist-info/RECORD +61 -0
- isolate-0.22.0.dist-info/WHEEL +5 -0
- isolate-0.22.0.dist-info/entry_points.txt +7 -0
- isolate-0.22.0.dist-info/licenses/LICENSE +201 -0
- isolate-0.22.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from isolate.backends import BaseEnvironment, EnvironmentCreationError
|
|
12
|
+
from isolate.backends.common import (
|
|
13
|
+
active_python,
|
|
14
|
+
get_executable,
|
|
15
|
+
get_executable_path,
|
|
16
|
+
logged_io,
|
|
17
|
+
optional_import,
|
|
18
|
+
sha256_digest_of,
|
|
19
|
+
)
|
|
20
|
+
from isolate.backends.settings import DEFAULT_SETTINGS, IsolateSettings
|
|
21
|
+
from isolate.connections import PythonIPC
|
|
22
|
+
from isolate.logs import LogLevel
|
|
23
|
+
|
|
24
|
+
_UV_RESOLVER_EXECUTABLE = os.environ.get("ISOLATE_UV_EXE", "uv")
|
|
25
|
+
_UV_RESOLVER_HOME = os.getenv("ISOLATE_UV_HOME")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class VirtualPythonEnvironment(BaseEnvironment[Path]):
|
|
30
|
+
BACKEND_NAME: ClassVar[str] = "virtualenv"
|
|
31
|
+
|
|
32
|
+
requirements: list[str] = field(default_factory=list)
|
|
33
|
+
constraints_file: os.PathLike | None = None
|
|
34
|
+
python_version: str | None = None
|
|
35
|
+
extra_index_urls: list[str] = field(default_factory=list)
|
|
36
|
+
tags: list[str] = field(default_factory=list)
|
|
37
|
+
resolver: str | None = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_config(
|
|
41
|
+
cls,
|
|
42
|
+
config: dict[str, Any],
|
|
43
|
+
settings: IsolateSettings = DEFAULT_SETTINGS,
|
|
44
|
+
) -> BaseEnvironment:
|
|
45
|
+
environment = cls(**config)
|
|
46
|
+
environment.apply_settings(settings)
|
|
47
|
+
if environment.resolver not in ("uv", None):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"Only 'uv' is supported as a resolver for virtualenv environments."
|
|
50
|
+
)
|
|
51
|
+
return environment
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def key(self) -> str:
|
|
55
|
+
if self.constraints_file is not None:
|
|
56
|
+
with open(self.constraints_file) as stream:
|
|
57
|
+
constraints = stream.read().splitlines()
|
|
58
|
+
else:
|
|
59
|
+
constraints = []
|
|
60
|
+
|
|
61
|
+
extras = []
|
|
62
|
+
if self.resolver is not None:
|
|
63
|
+
extras.append(f"resolver={self.resolver}")
|
|
64
|
+
|
|
65
|
+
active_python_version = self.python_version or active_python()
|
|
66
|
+
return sha256_digest_of(
|
|
67
|
+
active_python_version,
|
|
68
|
+
*self.requirements,
|
|
69
|
+
*constraints,
|
|
70
|
+
*self.extra_index_urls,
|
|
71
|
+
*sorted(self.tags),
|
|
72
|
+
# This is backwards compatible with environments not using
|
|
73
|
+
# the 'resolver' field.
|
|
74
|
+
*extras,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def install_requirements(self, path: Path) -> None:
|
|
78
|
+
"""Install the requirements of this environment using 'pip' to the
|
|
79
|
+
given virtualenv path.
|
|
80
|
+
|
|
81
|
+
If there are any constraint files specified, they will be also passed to
|
|
82
|
+
the package resolver.
|
|
83
|
+
"""
|
|
84
|
+
if not self.requirements:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
self.log(f"Installing requirements: {', '.join(self.requirements)}")
|
|
88
|
+
environ = os.environ.copy()
|
|
89
|
+
|
|
90
|
+
if self.resolver == "uv":
|
|
91
|
+
# Set VIRTUAL_ENV to the actual path of the environment since that is
|
|
92
|
+
# how uv discovers the environment. This is necessary when using uv
|
|
93
|
+
# as the resolver.
|
|
94
|
+
environ["VIRTUAL_ENV"] = str(path)
|
|
95
|
+
base_pip_cmd = [
|
|
96
|
+
get_executable(_UV_RESOLVER_EXECUTABLE, _UV_RESOLVER_HOME),
|
|
97
|
+
"pip",
|
|
98
|
+
]
|
|
99
|
+
else:
|
|
100
|
+
base_pip_cmd = [get_executable_path(path, "pip")]
|
|
101
|
+
|
|
102
|
+
pip_cmd: list[str | os.PathLike] = [
|
|
103
|
+
*base_pip_cmd, # type: ignore
|
|
104
|
+
"install",
|
|
105
|
+
*self.requirements,
|
|
106
|
+
]
|
|
107
|
+
if self.constraints_file:
|
|
108
|
+
pip_cmd.extend(["-c", self.constraints_file])
|
|
109
|
+
|
|
110
|
+
for extra_index_url in self.extra_index_urls:
|
|
111
|
+
pip_cmd.extend(["--extra-index-url", extra_index_url])
|
|
112
|
+
|
|
113
|
+
with logged_io(partial(self.log, level=LogLevel.INFO)) as (stdout, stderr, _):
|
|
114
|
+
try:
|
|
115
|
+
subprocess.check_call(
|
|
116
|
+
pip_cmd,
|
|
117
|
+
stdout=stdout,
|
|
118
|
+
stderr=stderr,
|
|
119
|
+
env=environ,
|
|
120
|
+
)
|
|
121
|
+
except subprocess.SubprocessError as exc:
|
|
122
|
+
raise EnvironmentCreationError(f"Failure during 'pip install': {exc}")
|
|
123
|
+
|
|
124
|
+
def _install_python_through_pyenv(self) -> str:
|
|
125
|
+
from isolate.backends.pyenv import PyenvEnvironment
|
|
126
|
+
|
|
127
|
+
self.log(
|
|
128
|
+
f"Requested Python version of {self.python_version} is not available "
|
|
129
|
+
"in the system, attempting to install it through pyenv."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
pyenv = PyenvEnvironment.from_config(
|
|
133
|
+
{"python_version": self.python_version},
|
|
134
|
+
settings=self.settings,
|
|
135
|
+
)
|
|
136
|
+
return str(get_executable_path(pyenv.create(), "python"))
|
|
137
|
+
|
|
138
|
+
def _decide_python(self) -> str:
|
|
139
|
+
from isolate.backends.pyenv import _get_pyenv_executable
|
|
140
|
+
|
|
141
|
+
builtin_discovery = optional_import("virtualenv.discovery.builtin")
|
|
142
|
+
interpreter = builtin_discovery.get_interpreter(self.python_version, ())
|
|
143
|
+
if interpreter is not None:
|
|
144
|
+
return interpreter.executable
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
_get_pyenv_executable()
|
|
148
|
+
except Exception:
|
|
149
|
+
raise EnvironmentCreationError(
|
|
150
|
+
f"Python {self.python_version} is not available in your "
|
|
151
|
+
"system. Please install it first."
|
|
152
|
+
) from None
|
|
153
|
+
else:
|
|
154
|
+
return self._install_python_through_pyenv()
|
|
155
|
+
|
|
156
|
+
def create(self, *, force: bool = False) -> Path:
|
|
157
|
+
virtualenv = optional_import("virtualenv")
|
|
158
|
+
|
|
159
|
+
venv_path = self.settings.cache_dir_for(self)
|
|
160
|
+
completion_marker = self.settings.completion_marker_for(venv_path)
|
|
161
|
+
with self.settings.cache_lock_for(venv_path):
|
|
162
|
+
if not force:
|
|
163
|
+
is_cached = venv_path.exists()
|
|
164
|
+
if self.settings.strict_cache:
|
|
165
|
+
is_cached &= completion_marker.exists()
|
|
166
|
+
|
|
167
|
+
if is_cached:
|
|
168
|
+
return venv_path
|
|
169
|
+
|
|
170
|
+
self.log(f"Creating the environment at '{venv_path}'")
|
|
171
|
+
|
|
172
|
+
args = [str(venv_path)]
|
|
173
|
+
if self.python_version:
|
|
174
|
+
args.append(f"--python={self._decide_python()}")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
# This is not an official API, so it can throw anything at us.
|
|
178
|
+
virtualenv.cli_run(args)
|
|
179
|
+
except (SystemExit, RuntimeError, OSError) as exc:
|
|
180
|
+
raise EnvironmentCreationError(
|
|
181
|
+
f"Failed to create the environment at '{venv_path}': {exc}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self.install_requirements(venv_path)
|
|
185
|
+
completion_marker.touch()
|
|
186
|
+
|
|
187
|
+
self.log(f"New environment cached at '{venv_path}'")
|
|
188
|
+
return venv_path
|
|
189
|
+
|
|
190
|
+
def destroy(self, connection_key: Path) -> None:
|
|
191
|
+
with self.settings.cache_lock_for(connection_key):
|
|
192
|
+
# It might be destroyed already (when we are awaiting
|
|
193
|
+
# for the lock to be released).
|
|
194
|
+
if not connection_key.exists():
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
shutil.rmtree(connection_key)
|
|
198
|
+
|
|
199
|
+
def exists(self) -> bool:
|
|
200
|
+
path = self.settings.cache_dir_for(self)
|
|
201
|
+
return path.exists()
|
|
202
|
+
|
|
203
|
+
def open_connection(self, connection_key: Path) -> PythonIPC:
|
|
204
|
+
return PythonIPC(self, connection_key)
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def from_datetime(time: datetime) -> Timestamp:
|
|
9
|
+
timestamp = Timestamp()
|
|
10
|
+
timestamp.FromDatetime(time)
|
|
11
|
+
return timestamp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def to_datetime(timestamp: Timestamp) -> datetime:
|
|
15
|
+
return timestamp.ToDatetime(tzinfo=timezone.utc)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
from isolate.connections.ipc import IsolatedProcessConnection, PythonIPC # noqa: F401
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name):
|
|
7
|
+
if name == "LocalPythonGRPC":
|
|
8
|
+
extra = "grpc"
|
|
9
|
+
module_name = "isolate.connections.grpc"
|
|
10
|
+
else:
|
|
11
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
module = importlib.import_module(module_name)
|
|
15
|
+
except ImportError:
|
|
16
|
+
raise AttributeError(
|
|
17
|
+
f"For using {name!r} you need to install isolate with {extra!r} support."
|
|
18
|
+
f'\n $ pip install "isolate[{extra}]"'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
return getattr(module, name)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sysconfig
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import partial
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import (
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
Any,
|
|
13
|
+
Generic,
|
|
14
|
+
Iterator,
|
|
15
|
+
TypeVar,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from isolate import __version__ as isolate_version
|
|
19
|
+
from isolate.backends.common import active_python, get_executable_path, logged_io
|
|
20
|
+
from isolate.connections.common import AGENT_SIGNATURE
|
|
21
|
+
from isolate.logs import LogLevel, LogSource
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from isolate.backends import BaseEnvironment
|
|
25
|
+
|
|
26
|
+
ConnectionType = TypeVar("ConnectionType")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def binary_path_for(*search_paths: Path) -> str:
|
|
30
|
+
"""Return the binary search path for the given 'search_paths'.
|
|
31
|
+
It will be a combination of the 'bin/' folders in them and
|
|
32
|
+
the existing PATH environment variable."""
|
|
33
|
+
|
|
34
|
+
paths = []
|
|
35
|
+
for search_path in search_paths:
|
|
36
|
+
path = sysconfig.get_path("scripts", vars={"base": search_path})
|
|
37
|
+
paths.append(path)
|
|
38
|
+
# Some distributions (conda) might include both a 'bin' and
|
|
39
|
+
# a 'scripts' folder.
|
|
40
|
+
|
|
41
|
+
auxilary_binary_path = search_path / "bin"
|
|
42
|
+
if path != auxilary_binary_path and auxilary_binary_path.exists():
|
|
43
|
+
paths.append(str(auxilary_binary_path))
|
|
44
|
+
|
|
45
|
+
if "PATH" in os.environ:
|
|
46
|
+
paths.append(os.environ["PATH"])
|
|
47
|
+
|
|
48
|
+
return os.pathsep.join(paths)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def python_path_for(*search_paths: Path) -> str:
|
|
52
|
+
"""Return the PYTHONPATH for the library paths residing
|
|
53
|
+
in the given 'search_paths'. The order of the paths is
|
|
54
|
+
preserved."""
|
|
55
|
+
assert len(search_paths) >= 1
|
|
56
|
+
lib_paths = []
|
|
57
|
+
for search_path in search_paths:
|
|
58
|
+
# sysconfig defines the schema of the directories under
|
|
59
|
+
# any comforming Python installation (like venv, conda, etc.).
|
|
60
|
+
#
|
|
61
|
+
# Be aware that Debian's system installation does not
|
|
62
|
+
# comform sysconfig.
|
|
63
|
+
raw_glob_expr = sysconfig.get_path(
|
|
64
|
+
"purelib",
|
|
65
|
+
vars={
|
|
66
|
+
"base": search_path,
|
|
67
|
+
"python_version": "*",
|
|
68
|
+
"py_version_short": "*",
|
|
69
|
+
"py_version_nodot": "*",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
relative_glob_expr = Path(raw_glob_expr).relative_to(search_path).as_posix()
|
|
73
|
+
|
|
74
|
+
# Try to find expand the Python version in the path. This is
|
|
75
|
+
# necessary for supporting multiple Python versions in the same
|
|
76
|
+
# environment.
|
|
77
|
+
for file in search_path.glob(relative_glob_expr):
|
|
78
|
+
lib_paths.append(str(file))
|
|
79
|
+
|
|
80
|
+
return os.pathsep.join(lib_paths)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class PythonExecutionBase(Generic[ConnectionType]):
|
|
85
|
+
"""A generic Python execution implementation that can trigger a new process
|
|
86
|
+
and start watching stdout/stderr for the logs. The environment_path must be
|
|
87
|
+
the base directory of a new Python environment (structure that complies with
|
|
88
|
+
sysconfig). Python binary inside that environment will be used to run the
|
|
89
|
+
agent process.
|
|
90
|
+
|
|
91
|
+
If set, extra_inheritance_paths allows extending the custom package search
|
|
92
|
+
system with additional environments. As an example, the current environment_path
|
|
93
|
+
might point to an environment with numpy and the extra_inheritance_paths might
|
|
94
|
+
point to an environment with pandas. In this case, the agent process will have
|
|
95
|
+
access to both numpy and pandas. The order is important here, as the first
|
|
96
|
+
path in the list will be the first one to be looked up (so if there is multiple
|
|
97
|
+
versions of the same package in different environments, the one in the first
|
|
98
|
+
path will take precedence). Dependency resolution and compatibility must be
|
|
99
|
+
handled by the user."""
|
|
100
|
+
|
|
101
|
+
environment: BaseEnvironment
|
|
102
|
+
environment_path: Path
|
|
103
|
+
extra_inheritance_paths: list[Path] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@contextmanager
|
|
106
|
+
def start_process(
|
|
107
|
+
self,
|
|
108
|
+
connection: ConnectionType,
|
|
109
|
+
*args: Any,
|
|
110
|
+
**kwargs: Any,
|
|
111
|
+
) -> Iterator[subprocess.Popen]:
|
|
112
|
+
"""Start the agent process with the Python binary available inside the
|
|
113
|
+
bound environment."""
|
|
114
|
+
|
|
115
|
+
python_version = getattr(self.environment, "python_version", active_python())
|
|
116
|
+
try:
|
|
117
|
+
# prefer the specific version if available.
|
|
118
|
+
python_executable = get_executable_path(
|
|
119
|
+
self.environment_path, f"python{python_version}"
|
|
120
|
+
)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
# fallback to the generic binary.
|
|
123
|
+
python_executable = get_executable_path(self.environment_path, "python")
|
|
124
|
+
|
|
125
|
+
with logged_io(
|
|
126
|
+
partial(
|
|
127
|
+
self.handle_agent_log, source=LogSource.USER, level=LogLevel.STDOUT
|
|
128
|
+
),
|
|
129
|
+
partial(
|
|
130
|
+
self.handle_agent_log, source=LogSource.USER, level=LogLevel.STDERR
|
|
131
|
+
),
|
|
132
|
+
partial(
|
|
133
|
+
self.handle_agent_log, source=LogSource.BRIDGE, level=LogLevel.TRACE
|
|
134
|
+
),
|
|
135
|
+
) as (stdout, stderr, log_fd):
|
|
136
|
+
yield subprocess.Popen(
|
|
137
|
+
self.get_python_cmd(python_executable, connection, log_fd),
|
|
138
|
+
env=self.get_env_vars(),
|
|
139
|
+
stdout=stdout,
|
|
140
|
+
stderr=stderr,
|
|
141
|
+
pass_fds=(log_fd,),
|
|
142
|
+
text=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def get_env_vars(self) -> dict[str, str]:
|
|
146
|
+
"""Return the environment variables to run the agent process with. By default
|
|
147
|
+
PYTHONUNBUFFERED is set to 1 to ensure the prints to stdout/stderr are reflect
|
|
148
|
+
immediately (so that we can seamlessly transfer logs)."""
|
|
149
|
+
|
|
150
|
+
custom_vars = {}
|
|
151
|
+
custom_vars["ISOLATE_SERVER_VERSION"] = isolate_version
|
|
152
|
+
custom_vars[AGENT_SIGNATURE] = "1"
|
|
153
|
+
custom_vars["PYTHONUNBUFFERED"] = "1"
|
|
154
|
+
|
|
155
|
+
# NOTE: we don't have to manually set PYTHONPATH here if we are
|
|
156
|
+
# using a single environment since python will automatically
|
|
157
|
+
# use the proper path.
|
|
158
|
+
if self.extra_inheritance_paths:
|
|
159
|
+
# The order here should reflect the order of the inheritance
|
|
160
|
+
# where the actual environment already takes precedence.
|
|
161
|
+
custom_vars["PYTHONPATH"] = python_path_for(
|
|
162
|
+
self.environment_path, *self.extra_inheritance_paths
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# But the PATH must be always set since it will be not be
|
|
166
|
+
# automatically set by Python (think of this as ./venv/bin/activate)
|
|
167
|
+
custom_vars["PATH"] = binary_path_for(
|
|
168
|
+
self.environment_path, *self.extra_inheritance_paths
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
**os.environ,
|
|
173
|
+
**custom_vars,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def get_python_cmd(
|
|
177
|
+
self,
|
|
178
|
+
executable: Path,
|
|
179
|
+
connection: ConnectionType,
|
|
180
|
+
log_fd: int,
|
|
181
|
+
) -> list[str | Path]:
|
|
182
|
+
"""Return the command to run the agent process with."""
|
|
183
|
+
raise NotImplementedError
|
|
184
|
+
|
|
185
|
+
def handle_agent_log(
|
|
186
|
+
self, line: str, *, level: LogLevel, source: LogSource
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Handle a log line emitted by the agent process. The level will be either
|
|
189
|
+
STDOUT or STDERR."""
|
|
190
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent process execution wrapper for handling extended PYTHONPATH.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import runpy
|
|
7
|
+
import site
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_pth_files() -> None:
|
|
13
|
+
"""Each site dir in Python can contain some .pth files, which are
|
|
14
|
+
basically instructions that tell Python to load other stuff. This is
|
|
15
|
+
generally used for editable installations, and just setting PYTHONPATH
|
|
16
|
+
won't make them expand so we need manually process them. Luckily, site
|
|
17
|
+
module can simply take the list of new paths and recognize them.
|
|
18
|
+
|
|
19
|
+
https://docs.python.org/3/tutorial/modules.html#the-module-search-path
|
|
20
|
+
"""
|
|
21
|
+
python_path = os.getenv("PYTHONPATH")
|
|
22
|
+
if python_path is None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
# TODO: The order here is the same as the one that is used for generating the
|
|
26
|
+
# PYTHONPATH. The only problem that might occur is that, on a chain with
|
|
27
|
+
# 3 ore more nodes (A, B, C), if X is installed as an editable package to
|
|
28
|
+
# B and a normal package to C, then C might actually take precedence. This
|
|
29
|
+
# will need to be fixed once we are dealing with more than 2 nodes and editable
|
|
30
|
+
# packages.
|
|
31
|
+
for site_dir in python_path.split(os.pathsep):
|
|
32
|
+
try:
|
|
33
|
+
site.addsitedir(site_dir)
|
|
34
|
+
except Exception:
|
|
35
|
+
# NOTE: there could be .pth files that are model weights and not
|
|
36
|
+
# python path configuration files.
|
|
37
|
+
traceback.print_exc()
|
|
38
|
+
print(f"Error adding site directory {site_dir}, skipping...")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
real_agent, *real_arguments = sys.argv[1:]
|
|
43
|
+
|
|
44
|
+
load_pth_files()
|
|
45
|
+
# TODO(feat): implement a check to parse "agent-requires" line and see if
|
|
46
|
+
# all the dependencies are installed.
|
|
47
|
+
sys.argv = [real_agent] + real_arguments
|
|
48
|
+
runpy.run_path(real_agent, run_name="__main__")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
load_pth_files()
|
|
53
|
+
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Iterator, cast
|
|
8
|
+
|
|
9
|
+
from tblib import Traceback, TracebackParseError
|
|
10
|
+
from tblib.pickling_support import install as tblib_install
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
class SerializationBackend(Protocol):
|
|
16
|
+
def loads(self, data: bytes) -> Any: ...
|
|
17
|
+
|
|
18
|
+
def dumps(self, obj: Any) -> bytes: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
AGENT_SIGNATURE = "IS_ISOLATE_AGENT"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SerializationError(Exception):
|
|
26
|
+
"""An error that happened during the serialization process."""
|
|
27
|
+
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# NOTE: tblib's install() will search for BaseException subclasses,
|
|
32
|
+
# so we have to call it after the SerializationError is defined.
|
|
33
|
+
tblib_install()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def _step(message: str) -> Iterator[None]:
|
|
38
|
+
"""A context manager to capture every expression
|
|
39
|
+
underneath it and if any of them fails for any reason
|
|
40
|
+
then it will raise a SerializationError with the
|
|
41
|
+
given message."""
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
except BaseException as exception:
|
|
46
|
+
raise SerializationError("Error while " + message) from exception
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def as_serialization_method(backend: Any) -> SerializationBackend:
|
|
50
|
+
"""Ensures that the given backend has loads/dumps methods, and returns
|
|
51
|
+
it as is (also convinces type checkers that the given object satisfies
|
|
52
|
+
the serialization protocol)."""
|
|
53
|
+
|
|
54
|
+
if not hasattr(backend, "loads") or not hasattr(backend, "dumps"):
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"The given serialization backend ({backend.__name__}) does "
|
|
57
|
+
"not have one of the required methods (loads/dumps)."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return cast("SerializationBackend", backend)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_serialized_object(
|
|
64
|
+
serialization_method: str,
|
|
65
|
+
raw_object: bytes,
|
|
66
|
+
*,
|
|
67
|
+
was_it_raised: bool = False,
|
|
68
|
+
stringized_traceback: str | None = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Load the given serialized object using the given serialization method. If
|
|
71
|
+
anything fails, then a SerializationError will be raised. If the was_it_raised
|
|
72
|
+
flag is set to true, then the given object will be raised as an exception (instead
|
|
73
|
+
of being returned)."""
|
|
74
|
+
|
|
75
|
+
with _step(f"preparing the serialization backend ({serialization_method})"):
|
|
76
|
+
serialization_backend = as_serialization_method(
|
|
77
|
+
importlib.import_module(serialization_method)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with _step("deserializing the given object"):
|
|
81
|
+
result = serialization_backend.loads(raw_object)
|
|
82
|
+
|
|
83
|
+
if was_it_raised:
|
|
84
|
+
raise prepare_exc(result, stringized_traceback=stringized_traceback)
|
|
85
|
+
else:
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def serialize_object(serialization_method: str, object: Any) -> bytes:
|
|
90
|
+
"""Serialize the given object using the given serialization method. If
|
|
91
|
+
anything fails, then a SerializationError will be raised."""
|
|
92
|
+
|
|
93
|
+
with _step(f"preparing the serialization backend ({serialization_method})"):
|
|
94
|
+
serialization_backend = as_serialization_method(
|
|
95
|
+
importlib.import_module(serialization_method)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
with _step("serializing the given object"):
|
|
99
|
+
return serialization_backend.dumps(object)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_agent() -> bool:
|
|
103
|
+
"""Returns true if the current process is an isolate agent."""
|
|
104
|
+
return os.environ.get(AGENT_SIGNATURE) == "1"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prepare_exc(
|
|
108
|
+
exc: BaseException,
|
|
109
|
+
*,
|
|
110
|
+
stringized_traceback: str | None = None,
|
|
111
|
+
) -> BaseException:
|
|
112
|
+
if stringized_traceback:
|
|
113
|
+
try:
|
|
114
|
+
traceback = Traceback.from_string(stringized_traceback).as_traceback()
|
|
115
|
+
except TracebackParseError:
|
|
116
|
+
traceback = None
|
|
117
|
+
else:
|
|
118
|
+
traceback = None
|
|
119
|
+
|
|
120
|
+
exc.__traceback__ = traceback
|
|
121
|
+
return exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from isolate.connections.grpc._base import AgentError, LocalPythonGRPC # noqa: F401
|