Skip to content

Commit f6d62bd

Browse files
feat: deploy from archive (#18)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Deployment switched from archive-based to cache-based storage with explicit pinning and a public cache path for managing cached environment assets. * Environments expose a stable string representation for specs. * Added safe cache removal with improved error handling. * **Compatibility / API** * Deployment and cache lifecycle is now async-aware; plugins should adopt the new async workflow and cache/pin APIs. * Stronger runtime checks for deployment/caching operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bb2067a commit f6d62bd

2 files changed

Lines changed: 95 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ snakemake-software-deployment-plugin-envmodules = "^0.1.2"
2323
omit = [".*", "*/site-packages/*"]
2424

2525
[tool.coverage.report]
26-
fail_under = 63
26+
fail_under = 60
2727

2828
[build-system]
2929
build-backend = "poetry.core.masonry.api"

snakemake_interface_software_deployment_plugins/__init__.py

Lines changed: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
from dataclasses import dataclass, field
99
import hashlib
1010
from pathlib import Path
11+
import shutil
1112
from typing import Any, Dict, Iterable, Optional, Self, Tuple, Type, Union
1213
import subprocess as sp
1314

1415
from snakemake_interface_software_deployment_plugins.settings import (
1516
SoftwareDeploymentSettingsBase,
1617
)
18+
from snakemake_interface_common.exceptions import WorkflowError
1719

1820

1921
@dataclass
@@ -115,6 +117,11 @@ def __eq__(self, other) -> bool:
115117
for attr in self.managed_identity_attributes()
116118
)
117119

120+
@abstractmethod
121+
def __str__(self) -> str:
122+
"""Return a string representation of the environment spec."""
123+
...
124+
118125

119126
class EnvBase(ABC):
120127
_cache: Dict[Tuple[Type["EnvBase"], Optional["EnvBase"]], Any] = {}
@@ -126,8 +133,9 @@ def __init__(
126133
settings: Optional[SoftwareDeploymentSettingsBase],
127134
shell_executable: str,
128135
tempdir: Path,
129-
deployment_prefix: Optional[Path] = None,
130-
archive_prefix: Optional[Path] = None,
136+
cache_prefix: Path,
137+
deployment_prefix: Path,
138+
pinfile_prefix: Path,
131139
):
132140
self.spec: EnvSpecBase = spec
133141
self.within: Optional["EnvBase"] = within
@@ -137,8 +145,9 @@ def __init__(
137145
self._managed_hash_store: Optional[str] = None
138146
self._managed_deployment_hash_store: Optional[str] = None
139147
self._obj_hash: Optional[int] = None
140-
self._deployment_prefix: Optional[Path] = deployment_prefix
141-
self._archive_prefix: Optional[Path] = archive_prefix
148+
self._deployment_prefix: Path = deployment_prefix
149+
self._cache_prefix: Path = cache_prefix
150+
self._pinfile_prefix: Path = pinfile_prefix
142151
self.__post_init__()
143152

144153
def __post_init__(self) -> None: # noqa B027
@@ -232,9 +241,72 @@ def __eq__(self, other) -> bool:
232241
)
233242

234243

244+
class PinnableEnvBase(ABC):
245+
@classmethod
246+
@abstractmethod
247+
def pinfile_extension(cls) -> str: ...
248+
249+
@abstractmethod
250+
def pin(self) -> None:
251+
"""Pin the environment to potentially more concrete versions than defined.
252+
Only implement this base class if pinning makes sense for your kind of
253+
environment. Pinfile has to be written to self.pinfile.
254+
"""
255+
...
256+
257+
@property
258+
def pinfile(self) -> Path:
259+
assert isinstance(self, EnvBase)
260+
ext = self.pinfile_extension()
261+
if not ext.startswith("."):
262+
raise ValueError("pinfile_extension must start with a dot.")
263+
return (self._pinfile_prefix / self.hash()).with_suffix(
264+
self.pinfile_extension()
265+
)
266+
267+
268+
class CacheableEnvBase(ABC):
269+
async def get_cache_assets(self) -> Iterable[str]: ...
270+
271+
@abstractmethod
272+
def cache_assets(self) -> None:
273+
"""Determine environment assets and store any associated information or data to
274+
self.cache_path.
275+
"""
276+
...
277+
278+
@property
279+
def cache_path(self) -> Path:
280+
assert isinstance(self, EnvBase)
281+
return self._cache_prefix
282+
283+
async def remove_cache(self) -> None:
284+
"""Remove the cached environment assets."""
285+
assert isinstance(self, EnvBase)
286+
for asset in await self.get_cache_assets():
287+
asset_path = self.cache_path / asset
288+
if asset_path.exists():
289+
try:
290+
if asset_path.is_dir():
291+
shutil.rmtree(asset_path)
292+
else:
293+
asset_path.unlink()
294+
except Exception as e:
295+
raise WorkflowError(
296+
f"Removal of cache asset {asset_path} for {self.spec} failed: {e}"
297+
)
298+
299+
235300
class DeployableEnvBase(ABC):
236301
@abstractmethod
237-
def is_deployment_path_portable(self) -> bool: ...
302+
def is_deployment_path_portable(self) -> bool:
303+
"""Return whether the deployment path matters for the environment, i.e.
304+
whether the environment is portable. If this returns False, the deployment
305+
path is considered for the deployment hash. For example, conda environments are not
306+
portable because they hardcode the path in binaries, while containers are
307+
portable.
308+
"""
309+
...
238310

239311
@abstractmethod
240312
async def deploy(self) -> None:
@@ -253,6 +325,7 @@ def record_deployment_hash(self, hash_object) -> None:
253325
deployment is senstivive to the path (e.g. in case of conda, which patches
254326
the RPATH in binaries).
255327
"""
328+
assert isinstance(self, EnvBase)
256329
self.record_hash(hash_object)
257330
if not self.is_deployment_path_portable():
258331
hash_object.update(str(self._deployment_prefix).encode())
@@ -262,31 +335,26 @@ def remove(self) -> None:
262335
"""Remove the deployed environment."""
263336
...
264337

265-
def managed_deploy(self) -> None:
266-
if isinstance(self, ArchiveableEnvBase) and self.archive_path.exists():
267-
self.deploy_from_archive()
268-
else:
269-
self.deploy()
338+
def managed_remove(self) -> None:
339+
"""Remove the deployed environment, handling exceptions."""
340+
assert isinstance(self, EnvBase)
341+
try:
342+
self.remove()
343+
except Exception as e:
344+
raise WorkflowError(f"Removal of {self.spec} failed: {e}")
345+
346+
async def managed_deploy(self) -> None:
347+
assert isinstance(self, EnvBase)
348+
try:
349+
await self.deploy()
350+
except Exception as e:
351+
raise WorkflowError(f"Deployment of {self.spec} failed: {e}")
270352

271353
def deployment_hash(self) -> str:
354+
assert isinstance(self, EnvBase)
272355
return self._managed_generic_hash("deployment_hash")
273356

274357
@property
275358
def deployment_path(self) -> Path:
359+
assert isinstance(self, EnvBase) and self._deployment_prefix is not None
276360
return self._deployment_prefix / self.deployment_hash()
277-
278-
279-
class ArchiveableEnvBase(ABC):
280-
@abstractmethod
281-
async def archive(self) -> None:
282-
"""Archive the environment to self.archive_path.
283-
284-
When issuing shell commands, the environment should use
285-
self.run_cmd(cmd: str) in order to ensure that it runs within eventual
286-
parent environments (e.g. a container or an env module).
287-
"""
288-
...
289-
290-
@property
291-
def archive_path(self) -> Path:
292-
return self._archive_prefix / self.hash()

0 commit comments

Comments
 (0)