Skip to content

Commit

Permalink
feat: publicize the interface module
Browse files Browse the repository at this point in the history
  • Loading branch information
isidentical committed Nov 7, 2022
1 parent 834bcd8 commit bb4bb76
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 44 deletions.
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,37 @@
Run any Python function, with any dependencies, in any machine you want. Isolate offers a
pluggable end-to-end solution for building, managing, and using isolated environments (virtualenv,
conda, and possibly more).
conda, remote, and more).

## Try it!

```py
from isolate import Template, LocalBox

# Build you first environment by specifying its kind (like virtualenv or conda)
template = Template("virtualenv")

# Add some packages to it.
template << "pyjokes==0.5.0"

# Forward it to a box (your local machine, or a remote machine)
environment = template >> LocalBox()

# And then, finally try executing some code

def get_pyjokes_version():
import pyjokes

return pyjokes.__version__

# If pyjokes==0.6.0 is installed in your local environment, it is going to print
# 0.6.0 here.
print("Installed pyjokes version: ", get_pyjokes_version())

# But if you run the same function in an isolated environment, you'll get
# 0.5.0.
print("Isolated pyjokes version: ", environment.run(get_pyjokes_version))
```

## Motivation

Expand Down
9 changes: 7 additions & 2 deletions src/isolate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from isolate.interface import (
Box,
BoxedEnvironment,
LocalBox,
RemoteBox,
Template,
)
from isolate.registry import prepare_environment

__version__ = "0.1.0"
57 changes: 36 additions & 21 deletions src/isolate/_interactive.py → src/isolate/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ def _decide_default_backend():
return "pickle"


def Environment(kind: str, **config: Any) -> _EnvironmentBuilder:
def Template(kind: str, **config: Any) -> _EnvironmentBuilder:
"""Create a new environment builder for the given kind (it can be virtualenv
or conda, depending on the flavor of packages you'd like to add). You can also
pass any configuration options that the backend supports."""

default_pkgs = []

default_backend = _decide_default_backend()
Expand Down Expand Up @@ -91,7 +95,16 @@ def __rshift__(self, left: Any) -> BoxedEnvironment:
return NotImplemented

definition, settings = self.get_definition()
return left.wrap_it(definition, settings)
return left.wrap(definition, settings)

def _not_supported(self, *args: Any, **kwargs: Any) -> None:
raise ValueError(
"Can't run a function on an environment template!"
"Be sure to forward it into a box first. Like: `environment = template >> box`"
)

run = _not_supported
map = _not_supported


@dataclass(repr=False)
Expand Down Expand Up @@ -120,9 +133,9 @@ def __repr__(self):

@dataclass
class Box:
"""Some sort of a box."""
"""Some sort of a box/machine to run Python on."""

def wrap_it(
def wrap(
self,
definition: Dict[str, Any],
settings: IsolateSettings,
Expand All @@ -136,9 +149,9 @@ def wrap_it(
class LocalBox(Box):
"""Run locally."""

parallelism: int = 1
pool_size: int = 1

def wrap_it(
def wrap(
self,
definition: Dict[str, Any],
settings: IsolateSettings,
Expand All @@ -148,24 +161,24 @@ def wrap_it(
**definition,
context=settings,
),
parallelism=self.parallelism,
pool_size=self.pool_size,
)

def __mul__(self, right: int) -> LocalBox:
if not isinstance(right, int):
return NotImplemented

return self.replace(parallelism=self.parallelism * right)
return self.replace(pool_size=self.pool_size * right)


@dataclass
class RemoteBox(Box):
"""Run on an hosted isolate server."""

host: str
parallelism: int = 1
pool_size: int = 1

def wrap_it(
def wrap(
self,
definition: Dict[str, Any],
settings: IsolateSettings,
Expand All @@ -185,14 +198,14 @@ def wrap_it(
target_environment_config=definition,
context=settings,
),
parallelism=self.parallelism,
pool_size=self.pool_size,
)

def __mul__(self, right: int) -> RemoteBox:
if not isinstance(right, int):
return NotImplemented

return self.replace(parallelism=self.parallelism * right)
return self.replace(pool_size=self.pool_size * right)


@dataclass
Expand All @@ -201,13 +214,12 @@ class BoxedEnvironment:
environments!"""

environment: BaseEnvironment
parallelism: int = 1
pool_size: int = 1
_console: Console = field(
default_factory=partial(Console, highlighter=None), repr=False
)
_is_building: bool = field(default=True, repr=False)
_status: Optional[Status] = field(default=None, repr=False)
_active_parallelism: Optional[str] = field(default=None, repr=False)
_active_pool_size: Optional[str] = field(default=None, repr=False)

def __post_init__(self):
existing_settings = self.environment.settings
Expand All @@ -219,9 +231,9 @@ def _update_status(self, from_builder: bool = False) -> None:
if from_builder:
self._status.update("Building the environment...", spinner="clock")
else:
if self._active_parallelism:
if self._active_pool_size:
self._status.update(
f"Running the isolated tasks {self._active_parallelism}",
f"Running the isolated tasks {self._active_pool_size}",
spinner="runner",
)
else:
Expand Down Expand Up @@ -258,7 +270,7 @@ def _status_display(self, message: str) -> Iterator[None]:
yield
finally:
self._status = None
self._active_parallelism = None
self._active_pool_size = None

def run(
self,
Expand All @@ -277,15 +289,18 @@ def map(
func: Callable[..., ReturnType],
*iterables: Iterable[Any],
) -> Iterable[ReturnType]:
"""Map the given `func` over the given iterables in parallel. pool_size
is determined by the originating box."""

with self._status_display("Preparing for execution..."):
with ThreadPoolExecutor(max_workers=self.parallelism) as executor:
with ThreadPoolExecutor(max_workers=self.pool_size) as executor:
with self.environment.connect() as connection:
futures = [
executor.submit(connection.run, partial(func, *args))
for args in zip(*iterables)
]
self._active_parallelism = f"0/{len(futures)}"
self._active_pool_size = f"0/{len(futures)}"
for n, future in enumerate(as_completed(futures), 1):
yield cast(ReturnType, future.result())
self._active_parallelism = f"{n}/{len(futures)}"
self._active_pool_size = f"{n}/{len(futures)}"
self._update_status()
42 changes: 22 additions & 20 deletions tests/test_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@
import importlib_metadata
import pytest

from isolate._interactive import (
BoxedEnvironment,
Environment,
LocalBox,
RemoteBox,
)
from isolate.interface import BoxedEnvironment, LocalBox, RemoteBox, Template

cp_version = importlib_metadata.version("cloudpickle")
dill_version = importlib_metadata.version("dill")
Expand Down Expand Up @@ -58,10 +53,10 @@
)
def test_builder(kind, params, serialization_backend, expected, monkeypatch):
monkeypatch.setattr(
"isolate._interactive._decide_default_backend", lambda: serialization_backend
"isolate.interface._decide_default_backend", lambda: serialization_backend
)

builder = Environment(kind, **params)
builder = Template(kind, **params)
assert repr(builder) == expected


Expand Down Expand Up @@ -98,11 +93,9 @@ def test_builder_forwarding(
kind, init_params, forwarded_packages, expected, monkeypatch
):
# Use pickle to avoid adding the default backend to the requirements
monkeypatch.setattr(
"isolate._interactive._decide_default_backend", lambda: "pickle"
)
monkeypatch.setattr("isolate.interface._decide_default_backend", lambda: "pickle")

builder = Environment(kind, **init_params)
builder = Template(kind, **init_params)
for forwarded_package in forwarded_packages:
builder << forwarded_package
assert repr(builder) == expected
Expand All @@ -115,18 +108,18 @@ class UncachedLocalBox(LocalBox):

cache_dir: Optional[Any] = None

def wrap_it(self, *args: Any, **kwargs: Any) -> BoxedEnvironment:
def wrap(self, *args: Any, **kwargs: Any) -> BoxedEnvironment:
assert self.cache_dir is not None, "cache_dir must be set"

boxed_env = super().wrap_it(*args, **kwargs)
boxed_env = super().wrap(*args, **kwargs)
boxed_env.environment.apply_settings(
boxed_env.environment.settings.replace(cache_dir=self.cache_dir)
)
return boxed_env


def test_local_box(tmp_path):
builder = Environment("virtualenv")
builder = Template("virtualenv")
builder << "pyjokes==0.5.0"

environment = builder >> UncachedLocalBox(cache_dir=tmp_path)
Expand All @@ -135,7 +128,7 @@ def test_local_box(tmp_path):


def test_remote_box(isolate_server):
builder = Environment("virtualenv")
builder = Template("virtualenv")
builder << "pyjokes==0.5.0"

# Remote box is uncached by default (isolate_server handles it).
Expand All @@ -145,7 +138,7 @@ def test_remote_box(isolate_server):


def test_parallelism_local(tmp_path):
builder = Environment("virtualenv")
builder = Template("virtualenv")
environment = builder >> UncachedLocalBox(cache_dir=tmp_path)

assert set(environment.map(eval, ["1", "2", "3", "4", "5", "6"])) == {
Expand All @@ -159,7 +152,7 @@ def test_parallelism_local(tmp_path):


def test_parallelism_local_threads(tmp_path):
builder = Environment("virtualenv")
builder = Template("virtualenv")
environment = builder >> UncachedLocalBox(cache_dir=tmp_path) * 3

assert set(environment.map(eval, ["1", "2", "3", "4", "5", "6"])) == {
Expand All @@ -173,7 +166,7 @@ def test_parallelism_local_threads(tmp_path):


def test_parallelism_remote(isolate_server):
builder = Environment("virtualenv")
builder = Template("virtualenv")
environment = builder >> RemoteBox(isolate_server)

assert set(environment.map(eval, ["1", "2", "3", "4", "5", "6"])) == {
Expand All @@ -187,7 +180,7 @@ def test_parallelism_remote(isolate_server):


def test_parallelism_remote_threads(isolate_server):
builder = Environment("virtualenv")
builder = Template("virtualenv")
environment = builder >> RemoteBox(isolate_server) * 3

assert set(environment.map(eval, ["1", "2", "3", "4", "5", "6"])) == {
Expand All @@ -198,3 +191,12 @@ def test_parallelism_remote_threads(isolate_server):
5,
6,
}


def test_error_on_template_run():
builder = Template("virtualenv")
with pytest.raises(ValueError):
builder.run(eval, "1")

with pytest.raises(ValueError):
builder.map(eval, ["1", "2", "3"])

0 comments on commit bb4bb76

Please sign in to comment.