Skip to content

Commit 49ae109

Browse files
fix: use Protocol type
1 parent 0c1a2ee commit 49ae109

2 files changed

Lines changed: 46 additions & 22 deletions

File tree

snakemake_interface_software_deployment_plugins/__init__.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import hashlib
1010
from pathlib import Path
1111
import shutil
12-
from typing import Any, Dict, Iterable, Optional, Self, Tuple, Type, Union
12+
from typing import Any, ClassVar, Dict, Iterable, Optional, Protocol, Self, Tuple, Type, Union, runtime_checkable
1313
import subprocess as sp
1414

1515
from snakemake_interface_software_deployment_plugins.settings import (
@@ -123,8 +123,19 @@ def __str__(self) -> str:
123123
...
124124

125125

126-
class EnvBase(ABC):
127-
_cache: Dict[Tuple[Type["EnvBase"], Optional["EnvBase"]], Any] = {}
126+
class EnvBase(Protocol):
127+
_cache: ClassVar[Dict[Tuple[Type["EnvBase"], Optional["EnvBase"]], Any]] = {}
128+
spec: EnvSpecBase
129+
within: Optional["EnvBase"]
130+
settings: Optional[SoftwareDeploymentSettingsBase]
131+
shell_executable: str
132+
tempdir: Path
133+
_cache_prefix: Path
134+
_deployment_prefix: Path
135+
_pinfile_prefix: Path
136+
_managed_hash_store: Optional[str] = None
137+
_managed_deployment_hash_store: Optional[str] = None
138+
_obj_hash: Optional[int] = None
128139

129140
def __init__(
130141
self,
@@ -142,9 +153,6 @@ def __init__(
142153
self.settings: Optional[SoftwareDeploymentSettingsBase] = settings
143154
self.shell_executable: str = shell_executable
144155
self.tempdir = tempdir
145-
self._managed_hash_store: Optional[str] = None
146-
self._managed_deployment_hash_store: Optional[str] = None
147-
self._obj_hash: Optional[int] = None
148156
self._deployment_prefix: Path = deployment_prefix
149157
self._cache_prefix: Path = cache_prefix
150158
self._pinfile_prefix: Path = pinfile_prefix
@@ -177,6 +185,21 @@ def decorate_shellcmd(self, cmd: str) -> str:
177185
"""
178186
...
179187

188+
def is_deployable(self) -> bool:
189+
"""Overwrite this in case the deployability of the environment depends on
190+
the spec or settings."""
191+
return isinstance(self, DeployableEnvBase)
192+
193+
def is_pinnable(self) -> bool:
194+
"""Overwrite this in case the pinability of the environment depends on
195+
the spec or settings."""
196+
return isinstance(self, PinnableEnvBase)
197+
198+
def is_cacheable(self) -> bool:
199+
"""Overwrite this in case the cacheability of the environment depends on
200+
the spec or settings."""
201+
return isinstance(self, CacheableEnvBase)
202+
180203
@abstractmethod
181204
def record_hash(self, hash_object) -> None:
182205
"""Update given hash object (using hash_object.update()) such that it changes
@@ -241,7 +264,8 @@ def __eq__(self, other) -> bool:
241264
)
242265

243266

244-
class PinnableEnvBase(ABC):
267+
@runtime_checkable
268+
class PinnableEnvBase(EnvBase, Protocol):
245269
@classmethod
246270
@abstractmethod
247271
def pinfile_extension(cls) -> str: ...
@@ -256,7 +280,6 @@ async def pin(self) -> None:
256280

257281
@property
258282
def pinfile(self) -> Path:
259-
assert isinstance(self, EnvBase)
260283
ext = self.pinfile_extension()
261284
if not ext.startswith("."):
262285
raise ValueError("pinfile_extension must start with a dot.")
@@ -265,7 +288,8 @@ def pinfile(self) -> Path:
265288
)
266289

267290

268-
class CacheableEnvBase(ABC):
291+
@runtime_checkable
292+
class CacheableEnvBase(EnvBase, Protocol):
269293
async def get_cache_assets(self) -> Iterable[str]: ...
270294

271295
@abstractmethod
@@ -277,12 +301,10 @@ async def cache_assets(self) -> None:
277301

278302
@property
279303
def cache_path(self) -> Path:
280-
assert isinstance(self, EnvBase)
281304
return self._cache_prefix
282305

283306
async def remove_cache(self) -> None:
284307
"""Remove the cached environment assets."""
285-
assert isinstance(self, EnvBase)
286308
for asset in await self.get_cache_assets():
287309
asset_path = self.cache_path / asset
288310
if asset_path.exists():
@@ -297,7 +319,8 @@ async def remove_cache(self) -> None:
297319
)
298320

299321

300-
class DeployableEnvBase(ABC):
322+
@runtime_checkable
323+
class DeployableEnvBase(EnvBase, Protocol):
301324
@abstractmethod
302325
def is_deployment_path_portable(self) -> bool:
303326
"""Return whether the deployment path matters for the environment, i.e.
@@ -325,7 +348,6 @@ def record_deployment_hash(self, hash_object) -> None:
325348
deployment is senstivive to the path (e.g. in case of conda, which patches
326349
the RPATH in binaries).
327350
"""
328-
assert isinstance(self, EnvBase)
329351
self.record_hash(hash_object)
330352
if not self.is_deployment_path_portable():
331353
hash_object.update(str(self._deployment_prefix).encode())
@@ -337,24 +359,21 @@ def remove(self) -> None:
337359

338360
def managed_remove(self) -> None:
339361
"""Remove the deployed environment, handling exceptions."""
340-
assert isinstance(self, EnvBase)
341362
try:
342363
self.remove()
343364
except Exception as e:
344365
raise WorkflowError(f"Removal of {self.spec} failed: {e}")
345366

346367
async def managed_deploy(self) -> None:
347-
assert isinstance(self, EnvBase)
348368
try:
349369
await self.deploy()
350370
except Exception as e:
351371
raise WorkflowError(f"Deployment of {self.spec} failed: {e}")
352372

353373
def deployment_hash(self) -> str:
354-
assert isinstance(self, EnvBase)
355374
return self._managed_generic_hash("deployment_hash")
356375

357376
@property
358377
def deployment_path(self) -> Path:
359-
assert isinstance(self, EnvBase) and self._deployment_prefix is not None
378+
assert self._deployment_prefix is not None
360379
return self._deployment_prefix / self.deployment_hash()

snakemake_interface_software_deployment_plugins/tests.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,13 @@ def test_deploy(self, tmp_path):
9090
cmd = env.managed_decorate_shellcmd(self.get_test_cmd())
9191
assert sp.run(cmd, shell=True, executable=self.shell_executable).returncode == 0
9292

93-
def test_archive(self, tmp_path):
93+
def test_cache(self, tmp_path):
9494
env = self._get_env(tmp_path)
95-
if not isinstance(env, CacheableEnvBase):
95+
if not env.is_cacheable():
9696
pytest.skip("Environment either not deployable or not cacheable.")
9797

98+
assert isinstance(env, CacheableEnvBase)
99+
98100
asyncio.run(env.cache_assets())
99101

100102
self._deploy(env, tmp_path)
@@ -103,9 +105,11 @@ def test_archive(self, tmp_path):
103105

104106
def test_pin(self, tmp_path):
105107
env = self._get_env(tmp_path)
106-
if not isinstance(env, PinnableEnvBase):
108+
if not env.is_pinnable():
107109
pytest.skip("Environment is not pinnable.")
108110

111+
assert isinstance(env, PinnableEnvBase)
112+
109113
asyncio.run(env.pin())
110114
assert env.pinfile.exists()
111115
print("Pinfile content:", env.pinfile.read_text(), sep="\n")
@@ -164,9 +168,10 @@ def _get_env(self, tmp_path) -> EnvBase:
164168
pinfile_prefix=pinfile_prefix,
165169
)
166170

167-
def _deploy(self, env: DeployableEnvBase, tmp_path):
168-
if not isinstance(env, DeployableEnvBase):
171+
def _deploy(self, env: EnvBase, tmp_path):
172+
if not env.is_deployable():
169173
pytest.skip("Environment is not deployable.")
170174

175+
assert isinstance(env, DeployableEnvBase)
171176
asyncio.run(env.deploy())
172177
assert any((tmp_path / "deployments").iterdir())

0 commit comments

Comments
 (0)