Skip to content

Commit 123ebe5

Browse files
committed
use requirements class
1 parent 35ecb9b commit 123ebe5

File tree

6 files changed

+86
-54
lines changed

6 files changed

+86
-54
lines changed

src/isolate/backends/common.py

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import threading
1010
import time
1111
from contextlib import contextmanager, suppress
12+
from dataclasses import dataclass, field
1213
from functools import lru_cache
1314
from pathlib import Path
1415
from types import ModuleType
@@ -220,37 +221,40 @@ def _normalize(text: str | bytes) -> bytes:
220221
return hashlib.sha256(inner_text).hexdigest()
221222

222223

223-
def normalize_requirement_layers(
224-
requirements: list[str] | list[list[str]],
225-
) -> list[list[str]]:
226-
"""Return requirements as ordered layers."""
227-
if not requirements:
228-
return []
229-
if all(isinstance(item, str) for item in requirements):
230-
return [list(cast(List[str], requirements))]
231-
if all(isinstance(item, list) for item in requirements):
232-
layers: list[list[str]] = []
233-
for layer in requirements:
234-
if not all(isinstance(item, str) for item in layer):
235-
raise TypeError("Requirements layers must contain strings.")
236-
layers.append(list(layer))
237-
return layers
238-
raise TypeError(
239-
"Requirements must be a list of strings or list of lists of strings."
240-
)
241-
242-
243-
def requirement_key_fields(requirements: list[str] | list[list[str]]) -> list[str]:
244-
"""Return a list of fields to include in environment keys."""
245-
if not requirements:
246-
return []
247-
if all(isinstance(item, str) for item in requirements):
248-
return list(cast(List[str], requirements))
224+
@dataclass
225+
class Requirements:
226+
layers: list[list[str]] = field(default_factory=list)
227+
228+
@classmethod
229+
def from_raw(
230+
cls, raw: Requirements | list[str] | list[list[str]] | None
231+
) -> Requirements:
232+
if raw is None:
233+
return cls()
234+
if isinstance(raw, Requirements):
235+
return raw
236+
if isinstance(raw, list) and all(isinstance(item, str) for item in raw):
237+
return cls([list(cast(List[str], raw))])
238+
if isinstance(raw, list) and all(isinstance(item, list) for item in raw):
239+
layers: list[list[str]] = []
240+
for layer in raw:
241+
if not all(isinstance(item, str) for item in layer):
242+
raise TypeError("Requirements layers must contain strings.")
243+
layers.append(list(layer))
244+
return cls(layers)
245+
raise TypeError(
246+
"Requirements must be a list of strings or list of lists of strings."
247+
)
249248

250-
fields: list[str] = []
251-
for index, layer in enumerate(normalize_requirement_layers(requirements)):
252-
fields.extend(layer)
253-
return fields
249+
def keys(self) -> list[str]:
250+
if not self.layers:
251+
return []
252+
if len(self.layers) == 1:
253+
return list(self.layers[0])
254+
return [
255+
f"layer{index}:\n" + "\n".join(layer)
256+
for index, layer in enumerate(self.layers)
257+
]
254258

255259

256260
def active_python() -> str:

src/isolate/backends/container.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import sys
44
from dataclasses import dataclass, field
55
from pathlib import Path
6-
from typing import Any, ClassVar, cast
6+
from typing import Any, ClassVar
77

88
from isolate.backends import BaseEnvironment
9-
from isolate.backends.common import requirement_key_fields, sha256_digest_of
9+
from isolate.backends.common import Requirements, sha256_digest_of
1010
from isolate.backends.settings import DEFAULT_SETTINGS, IsolateSettings
1111
from isolate.connections import PythonIPC
1212

@@ -17,9 +17,7 @@ class ContainerizedPythonEnvironment(BaseEnvironment[Path]):
1717

1818
image: dict[str, Any] = field(default_factory=dict)
1919
python_version: str | None = None
20-
requirements: list[str] | list[list[str]] = field(
21-
default_factory=lambda: cast(list[str] | list[list[str]], [])
22-
)
20+
requirements: Requirements = field(default_factory=Requirements)
2321
tags: list[str] = field(default_factory=list)
2422
resolver: str | None = None
2523

@@ -29,7 +27,11 @@ def from_config(
2927
config: dict[str, Any],
3028
settings: IsolateSettings = DEFAULT_SETTINGS,
3129
) -> BaseEnvironment:
32-
environment = cls(**config)
30+
prepared = dict(config)
31+
prepared["requirements"] = Requirements.from_raw(
32+
config.get("requirements") or []
33+
)
34+
environment = cls(**prepared)
3335
environment.apply_settings(settings)
3436
if environment.resolver not in ("uv", None):
3537
raise ValueError(
@@ -48,7 +50,7 @@ def key(self) -> str:
4850
dockerfile_str = self.image.get("dockerfile_str", "")
4951
return sha256_digest_of(
5052
dockerfile_str,
51-
*requirement_key_fields(self.requirements),
53+
*self.requirements.keys(),
5254
*sorted(self.tags),
5355
*extras,
5456
)

src/isolate/backends/virtualenv.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66
from dataclasses import dataclass, field
77
from functools import partial
88
from pathlib import Path
9-
from typing import Any, ClassVar, cast
9+
from typing import Any, ClassVar
1010

1111
from isolate.backends import BaseEnvironment, EnvironmentCreationError
1212
from isolate.backends.common import (
13+
Requirements,
1314
active_python,
1415
get_executable,
1516
get_executable_path,
1617
logged_io,
17-
normalize_requirement_layers,
1818
optional_import,
19-
requirement_key_fields,
2019
sha256_digest_of,
2120
)
2221
from isolate.backends.settings import DEFAULT_SETTINGS, IsolateSettings
@@ -31,9 +30,7 @@
3130
class VirtualPythonEnvironment(BaseEnvironment[Path]):
3231
BACKEND_NAME: ClassVar[str] = "virtualenv"
3332

34-
requirements: list[str] | list[list[str]] = field(
35-
default_factory=lambda: cast(list[str] | list[list[str]], [])
36-
)
33+
requirements: Requirements = field(default_factory=Requirements)
3734
constraints_file: os.PathLike | None = None
3835
python_version: str | None = None
3936
extra_index_urls: list[str] = field(default_factory=list)
@@ -46,7 +43,11 @@ def from_config(
4643
config: dict[str, Any],
4744
settings: IsolateSettings = DEFAULT_SETTINGS,
4845
) -> BaseEnvironment:
49-
environment = cls(**config)
46+
prepared = dict(config)
47+
prepared["requirements"] = Requirements.from_raw(
48+
config.get("requirements") or []
49+
)
50+
environment = cls(**prepared)
5051
environment.apply_settings(settings)
5152
if environment.resolver not in ("uv", None):
5253
raise ValueError(
@@ -69,7 +70,7 @@ def key(self) -> str:
6970
active_python_version = self.python_version or active_python()
7071
return sha256_digest_of(
7172
active_python_version,
72-
*requirement_key_fields(self.requirements),
73+
*self.requirements.keys(),
7374
*constraints,
7475
*self.extra_index_urls,
7576
*sorted(self.tags),
@@ -187,7 +188,7 @@ def create(self, *, force: bool = False) -> Path:
187188
f"Failed to create the environment at '{venv_path}': {exc}"
188189
)
189190

190-
for layer in normalize_requirement_layers(self.requirements):
191+
for layer in self.requirements.layers:
191192
self._install_packages(venv_path, layer)
192193
completion_marker.touch()
193194

src/isolate/server/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
EnvironmentCreationError,
2626
IsolateSettings,
2727
)
28-
from isolate.backends.common import active_python
28+
from isolate.backends.common import Requirements, active_python
2929
from isolate.backends.local import LocalPythonEnvironment
3030
from isolate.backends.virtualenv import VirtualPythonEnvironment
3131
from isolate.connections.grpc import AgentError, LocalPythonGRPC
@@ -266,7 +266,7 @@ def _run_task(self, task: RunTask) -> Iterator[definitions.PartialRunResult]:
266266
primary_environment, "python_version", active_python()
267267
)
268268
agent_environ = VirtualPythonEnvironment(
269-
requirements=AGENT_REQUIREMENTS,
269+
requirements=Requirements.from_raw(AGENT_REQUIREMENTS),
270270
python_version=python_version,
271271
)
272272
agent_environ.apply_settings(run_settings)

tests/test_backends.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import isolate
1010
import pytest
1111
from isolate.backends import BaseEnvironment, EnvironmentCreationError
12-
from isolate.backends.common import get_executable, sha256_digest_of
12+
from isolate.backends.common import Requirements, get_executable, sha256_digest_of
1313
from isolate.backends.conda import CondaEnvironment
1414
from isolate.backends.local import LocalPythonEnvironment
1515
from isolate.backends.pyenv import PyenvEnvironment, _get_pyenv_executable
@@ -402,7 +402,7 @@ def test_layered_requirements_in_key(self, tmp_path):
402402
"requirements": [["pyjokes==0.6.0"], ["pip==23.0.1"]],
403403
},
404404
)
405-
assert base.key == layered.key
405+
assert base.key != layered.key
406406

407407
def test_layered_requirements_order_affects_key(self, tmp_path):
408408
first = self.get_environment(
@@ -438,7 +438,7 @@ def cli_run(args):
438438
)
439439

440440
environment = VirtualPythonEnvironment(
441-
requirements=[["pip==23.0.1"], ["pyjokes==0.6.0"]],
441+
requirements=Requirements.from_raw([["pip==23.0.1"], ["pyjokes==0.6.0"]]),
442442
)
443443
environment.apply_settings(IsolateSettings(Path(tmp_path)))
444444
environment.create()
@@ -459,6 +459,28 @@ def test_try_using_uv(self, tmp_path):
459459
assert pyjokes_version == "0.5.0"
460460

461461

462+
class TestRequirements:
463+
def test_from_raw_single_layer(self):
464+
requirements = Requirements.from_raw(["a", "b"])
465+
assert requirements.layers == [["a", "b"]]
466+
467+
def test_from_raw_multi_layer(self):
468+
requirements = Requirements.from_raw([["a"], ["b", "c"]])
469+
assert requirements.layers == [["a"], ["b", "c"]]
470+
471+
def test_from_raw_none(self):
472+
requirements = Requirements.from_raw(None)
473+
assert requirements.layers == []
474+
475+
def test_keys_single_layer_backward_compat(self):
476+
requirements = Requirements.from_raw(["a", "b"])
477+
assert requirements.keys() == ["a", "b"]
478+
479+
def test_keys_multi_layer_prefixes(self):
480+
requirements = Requirements.from_raw([["a"], ["b", "c"]])
481+
assert requirements.keys() == ["layer0:\na", "layer1:\nb\nc"]
482+
483+
462484
# Since mamba is an external dependency, we'll skip tests using it
463485
# if it is not installed.
464486
try:
@@ -933,7 +955,7 @@ def test_pyenv_environment(python_version, tmp_path):
933955
@pytest.mark.skipif(not IS_PYENV_AVAILABLE, reason="Pyenv is not available")
934956
def test_virtual_env_custom_python_version_with_pyenv(tmp_path, monkeypatch):
935957
pyjokes_env = VirtualPythonEnvironment(
936-
requirements=["pyjokes==0.6.0"],
958+
requirements=Requirements.from_raw(["pyjokes==0.6.0"]),
937959
python_version="3.9",
938960
)
939961

tests/test_connections.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99
from isolate.backends import BaseEnvironment, EnvironmentConnection
10+
from isolate.backends.common import Requirements
1011
from isolate.backends.local import LocalPythonEnvironment
1112
from isolate.backends.settings import IsolateSettings
1213
from isolate.backends.virtualenv import VirtualPythonEnvironment
@@ -42,7 +43,7 @@ def make_venv(
4243
self, tmp_path: Any, requirements: List[str]
4344
) -> VirtualPythonEnvironment:
4445
"""Create a new virtual env with the specified requirements."""
45-
env = VirtualPythonEnvironment(requirements)
46+
env = VirtualPythonEnvironment(Requirements.from_raw(requirements))
4647
env.apply_settings(IsolateSettings(Path(tmp_path)))
4748
return env
4849

@@ -206,6 +207,8 @@ def make_venv(
206207
) -> VirtualPythonEnvironment:
207208
# Since gRPC agent requires isolate to be installed, we
208209
# have to add it to the requirements.
209-
env = VirtualPythonEnvironment(requirements + [f"{REPO_DIR}[grpc]"])
210+
env = VirtualPythonEnvironment(
211+
Requirements.from_raw(requirements + [f"{REPO_DIR}[grpc]"])
212+
)
210213
env.apply_settings(IsolateSettings(Path(tmp_path)))
211214
return env

0 commit comments

Comments
 (0)