snakemake-interface-software-deployment-plugins 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Snakemake Community
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.3
2
+ Name: snakemake-interface-software-deployment-plugins
3
+ Version: 0.1.0
4
+ Summary: This package provides a stable interface for interactions between Snakemake and its software deployment plugins.
5
+ License: MIT
6
+ Author: Johannes Köster
7
+ Author-email: johannes.koester@uni-due.de
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: argparse-dataclass (>=2.0.0,<3.0.0)
15
+ Requires-Dist: snakemake-interface-common (>=1.17.4,<2.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Stable interfaces and functionality for Snakemake software deployment plugins
19
+
20
+ This package provides a stable interface for interactions between Snakemake and its software deployment plugins.
21
+
@@ -0,0 +1,3 @@
1
+ # Stable interfaces and functionality for Snakemake software deployment plugins
2
+
3
+ This package provides a stable interface for interactions between Snakemake and its software deployment plugins.
@@ -0,0 +1,29 @@
1
+ [tool.poetry]
2
+ authors = ["Johannes Köster <johannes.koester@uni-due.de>"]
3
+ description = "This package provides a stable interface for interactions between Snakemake and its software deployment plugins."
4
+ license = "MIT"
5
+ name = "snakemake-interface-software-deployment-plugins"
6
+ packages = [{include = "snakemake_interface_software_deployment_plugins"}]
7
+ readme = "README.md"
8
+ version = "0.1.0"
9
+
10
+ [tool.poetry.dependencies]
11
+ argparse-dataclass = "^2.0.0"
12
+ python = "^3.11"
13
+ snakemake-interface-common = "^1.17.4"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ coverage = {extras = ["toml"], version = "^6.3.1"}
17
+ flake8-bugbear = "^22.1.11"
18
+ pytest = "^7.0"
19
+ ruff = "^0.9.9"
20
+
21
+ [tool.coverage.run]
22
+ omit = [".*", "*/site-packages/*"]
23
+
24
+ [tool.coverage.report]
25
+ fail_under = 63
26
+
27
+ [build-system]
28
+ build-backend = "poetry.core.masonry.api"
29
+ requires = ["poetry-core"]
@@ -0,0 +1,177 @@
1
+ __author__ = "Johannes Köster"
2
+ __copyright__ = "Copyright 2024, Johannes Köster"
3
+ __email__ = "johannes.koester@uni-due.de"
4
+ __license__ = "MIT"
5
+
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field, fields
8
+ import hashlib
9
+ from pathlib import Path
10
+ import sys
11
+ from typing import Optional
12
+ import subprocess as sp
13
+
14
+ from snakemake_interface_software_deployment_plugins.settings import (
15
+ SoftwareDeploymentSettingsBase,
16
+ )
17
+
18
+
19
+ _MANAGED_FIELDS = {
20
+ "settings",
21
+ "_managed_hash_store",
22
+ "_managed_deployment_hash_store",
23
+ "_obj_hash",
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class EnvSpecBase(ABC):
29
+ within: Optional["EnvSpecBase"]
30
+
31
+ @classmethod
32
+ def env_cls(cls):
33
+ return sys.modules[__name__].EnvBase
34
+
35
+
36
+ @dataclass
37
+ class EnvBase:
38
+ spec: EnvSpecBase
39
+ within: Optional["EnvBase"]
40
+ settings: Optional[SoftwareDeploymentSettingsBase]
41
+ _managed_hash_store: Optional[str] = field(init=False, default=None)
42
+ _managed_deployment_hash_store: Optional[str] = field(init=False, default=None)
43
+ _obj_hash: Optional[int] = field(init=False, default=None)
44
+
45
+ def __post_init__(self) -> None: # noqa B027
46
+ """Do stuff after object initialization."""
47
+ pass
48
+
49
+ @abstractmethod
50
+ def decorate_shellcmd(self, cmd: str) -> str:
51
+ """Decorate given shell command such that it runs within the environment,
52
+ using self.spec.
53
+ """
54
+ ...
55
+
56
+ @abstractmethod
57
+ def record_hash(self, hash_object) -> None:
58
+ """Update given hash object such that it changes whenever the environment
59
+ specified via self.spec could potentially contain a different set of
60
+ software (in terms of versions or packages).
61
+ """
62
+ ...
63
+
64
+ def run_cmd(self, cmd: str, **kwargs) -> sp.CompletedProcess:
65
+ """Run a command while potentially respecting the self.within environment,
66
+ returning the result of subprocess.run.
67
+
68
+ kwargs is passed to subprocess.run, shell=True is always set.
69
+ """
70
+ if self.within is not None:
71
+ cmd = self.within.managed_decorate_shellcmd(cmd)
72
+ return sp.run(cmd, shell=True, **kwargs)
73
+
74
+ def managed_decorate_shellcmd(self, cmd: str) -> str:
75
+ cmd = self.decorate_shellcmd(cmd)
76
+ if self.within is not None:
77
+ cmd = self.within.managed_decorate_shellcmd(cmd)
78
+ return cmd
79
+
80
+ def hash(self) -> str:
81
+ return self._managed_generic_hash("hash")
82
+
83
+ def _managed_generic_hash(self, kind: str) -> str:
84
+ store = getattr(self, f"_managed_{kind}_store")
85
+ if store is None:
86
+ record_hash = f"record_{kind}"
87
+ hash_object = hashlib.md5()
88
+ if self.within is not None:
89
+ getattr(self.within, record_hash)(hash_object)
90
+ getattr(self, record_hash)(hash_object)
91
+ store = hash_object.hexdigest()
92
+ return store
93
+
94
+ def __hash__(self) -> int:
95
+ # take the hash of all fields by settings, _managed_hash_store and _managed_deployment_hash_store
96
+ if self._obj_hash is None:
97
+ self._obj_hash = hash(
98
+ tuple(
99
+ getattr(self, field.name)
100
+ for field in fields(self)
101
+ if field.name not in _MANAGED_FIELDS
102
+ )
103
+ )
104
+ return self._obj_hash
105
+
106
+ def __eq__(self, other) -> bool:
107
+ return self.__class__ == other.__class__ and all(
108
+ getattr(self, field.name) == getattr(other, field.name)
109
+ for field in fields(self)
110
+ if field.name not in _MANAGED_FIELDS
111
+ )
112
+
113
+
114
+ @dataclass
115
+ class DeployableEnvBase(EnvBase):
116
+ _deployment_prefix: Optional[Path] = None
117
+
118
+ @abstractmethod
119
+ def deploy(self) -> None:
120
+ """Deploy the environment to self.deployment_path.
121
+
122
+ When issuing shell commands, the environment should use
123
+ self.provider.run(cmd: str) in order to ensure that it runs within eventual
124
+ parent environments (e.g. a container or an env module).
125
+ """
126
+ ...
127
+
128
+ @abstractmethod
129
+ def record_deployment_hash(self, hash_object) -> None:
130
+ """Update given hash such that it changes whenever the environment
131
+ needs to be redeployed, e.g. because its content has changed or the
132
+ deployment location has changed. The latter is only relevant if the
133
+ deployment is senstivive to the path (e.g. in case of conda, which patches
134
+ the RPATH in binaries).
135
+ """
136
+ ...
137
+
138
+ @abstractmethod
139
+ def remove(self) -> None:
140
+ """Remove the deployed environment."""
141
+ ...
142
+
143
+ def managed_deploy(self) -> None:
144
+ if isinstance(self, ArchiveableEnvBase) and self.archive_path.exists():
145
+ self.deploy_from_archive()
146
+ else:
147
+ self.deploy()
148
+
149
+ def deployment_hash(self) -> str:
150
+ return self._managed_generic_hash("deployment_hash")
151
+
152
+ @property
153
+ def deployment_path(self) -> Path:
154
+ return self.provider.deployment_prefix / self.deployment_hash()
155
+
156
+
157
+ class ArchiveableEnvBase(EnvBase):
158
+ archive_prefix: Optional[Path] = None
159
+
160
+ @abstractmethod
161
+ def archive(self) -> None:
162
+ """Archive the environment to self.archive_path.
163
+
164
+ When issuing shell commands, the environment should use
165
+ self.provider.run(cmd: str) in order to ensure that it runs within eventual
166
+ parent environments (e.g. a container or an env module).
167
+ """
168
+ ...
169
+
170
+ @abstractmethod
171
+ def deploy_from_archive(self) -> None:
172
+ """Deploy the environment from an archive."""
173
+ ...
174
+
175
+ @property
176
+ def archive_path(self) -> Path:
177
+ return self.archive_prefix / self.hash()
@@ -0,0 +1,10 @@
1
+ __author__ = "Johannes Köster"
2
+ __copyright__ = "Copyright 2024, Johannes Köster"
3
+ __email__ = "johannes.koester@uni-due.de"
4
+ __license__ = "MIT"
5
+
6
+
7
+ software_deployment_plugin_prefix = "snakemake-software-deployment-plugin-"
8
+ software_deployment_plugin_module_prefix = software_deployment_plugin_prefix.replace(
9
+ "-", "_"
10
+ )
@@ -0,0 +1,51 @@
1
+ __author__ = "Johannes Köster"
2
+ __copyright__ = "Copyright 2024, Johannes Köster"
3
+ __email__ = "johannes.koester@uni-due.de"
4
+ __license__ = "MIT"
5
+
6
+ import types
7
+ from typing import Mapping
8
+ from snakemake_interface_software_deployment_plugins.settings import (
9
+ SoftwareDeploymentSettingsBase,
10
+ )
11
+
12
+ from snakemake_interface_common.plugin_registry.attribute_types import (
13
+ AttributeKind,
14
+ AttributeMode,
15
+ AttributeType,
16
+ )
17
+ from snakemake_interface_software_deployment_plugins.registry.plugin import Plugin
18
+ from snakemake_interface_common.plugin_registry import PluginRegistryBase
19
+ from snakemake_interface_software_deployment_plugins import EnvBase, _common as common
20
+
21
+
22
+ class SoftwareDeploymentPluginRegistry(PluginRegistryBase):
23
+ """This class is a singleton that holds all registered executor plugins."""
24
+
25
+ @property
26
+ def module_prefix(self) -> str:
27
+ return common.software_deployment_plugin_module_prefix
28
+
29
+ def load_plugin(self, name: str, module: types.ModuleType) -> Plugin:
30
+ """Load a plugin by name."""
31
+ return Plugin(
32
+ _name=name,
33
+ _software_deployment_settings_cls=getattr(
34
+ module, "SoftwareDeploymentSettings", None
35
+ ),
36
+ _env_cls=module.EnvBase,
37
+ )
38
+
39
+ def expected_attributes(self) -> Mapping[str, AttributeType]:
40
+ return {
41
+ "SoftwareDeploymentSettings": AttributeType(
42
+ cls=SoftwareDeploymentSettingsBase,
43
+ mode=AttributeMode.OPTIONAL,
44
+ kind=AttributeKind.CLASS,
45
+ ),
46
+ "Env": AttributeType(
47
+ cls=EnvBase,
48
+ mode=AttributeMode.REQUIRED,
49
+ kind=AttributeKind.CLASS,
50
+ ),
51
+ }
@@ -0,0 +1,46 @@
1
+ __author__ = "Johannes Köster"
2
+ __copyright__ = "Copyright 2024, Johannes Köster"
3
+ __email__ = "johannes.koester@uni-due.de"
4
+ __license__ = "MIT"
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Optional, Type
8
+ from snakemake_interface_software_deployment_plugins import EnvBase, EnvSpecBase
9
+ from snakemake_interface_software_deployment_plugins.settings import (
10
+ SoftwareDeploymentSettingsBase,
11
+ )
12
+ import snakemake_interface_software_deployment_plugins._common as common
13
+
14
+ from snakemake_interface_common.plugin_registry.plugin import PluginBase
15
+
16
+
17
+ @dataclass
18
+ class Plugin(PluginBase):
19
+ _software_deployment_settings_cls: Optional[Type[SoftwareDeploymentSettingsBase]]
20
+ _env_cls: Type[EnvBase]
21
+ _env_spec_cls: Type[EnvSpecBase]
22
+ _name: str
23
+
24
+ @property
25
+ def name(self):
26
+ return self._name
27
+
28
+ @property
29
+ def cli_prefix(self):
30
+ return "sdm-" + self._only_name
31
+
32
+ @property
33
+ def _only_name(self):
34
+ return self.name.replace(common.software_deployment_plugin_module_prefix, "")
35
+
36
+ @property
37
+ def settings_cls(self):
38
+ return self._software_deployment_settings_cls
39
+
40
+ @property
41
+ def env_cls(self):
42
+ return self._env_cls
43
+
44
+ @property
45
+ def env_spec_cls(self):
46
+ return self._env_spec_cls
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ import snakemake_interface_common.plugin_registry.plugin
5
+
6
+
7
+ @dataclass
8
+ class SoftwareDeploymentSettingsBase(
9
+ snakemake_interface_common.plugin_registry.plugin.SettingsBase
10
+ ):
11
+ """Base class for software deployment settings.
12
+
13
+ Software deployment plugins can define a subclass of this class,
14
+ named 'SoftwareDeploymentProviderSettings'.
15
+ """
16
+
17
+ pass
@@ -0,0 +1,95 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional, Type
3
+ import subprocess as sp
4
+
5
+ import pytest
6
+
7
+ from snakemake_interface_software_deployment_plugins import (
8
+ ArchiveableEnvBase,
9
+ DeployableEnvBase,
10
+ EnvBase,
11
+ EnvSpecBase,
12
+ )
13
+ from snakemake_interface_software_deployment_plugins.settings import (
14
+ SoftwareDeploymentSettingsBase,
15
+ )
16
+
17
+
18
+ _TEST_SDM_NAME = "test-sdm"
19
+
20
+
21
+ class TestSoftwareDeploymentBase(ABC):
22
+ __test__ = False
23
+
24
+ @abstractmethod
25
+ def get_env_spec(self) -> EnvSpecBase:
26
+ """
27
+ If the software deployment provider does not support deployable environments,
28
+ this method should return args for an existing environment spec that can be used
29
+ for testing
30
+ """
31
+ ...
32
+
33
+ @abstractmethod
34
+ def get_env_cls(self) -> Type[EnvBase]:
35
+ """
36
+ Return the environment class that should be tested.
37
+ """
38
+ ...
39
+
40
+ @abstractmethod
41
+ def get_test_cmd(self) -> str:
42
+ """
43
+ Return a test command that should be executed within the environment
44
+ with exit code 0 (i.e. without error).
45
+ """
46
+ ...
47
+
48
+ def get_software_deployment_provider_settings(
49
+ self,
50
+ ) -> Optional[SoftwareDeploymentSettingsBase]:
51
+ return None
52
+
53
+ def test_shellcmd(self, tmp_path):
54
+ env = self._get_env(tmp_path)
55
+
56
+ if isinstance(env, DeployableEnvBase):
57
+ pytest.skip("Environment is deployable, using test_deploy instead.")
58
+
59
+ cmd = self.get_test_cmd()
60
+ decorated_cmd = env.managed_decorate_shellcmd(cmd)
61
+ assert cmd != decorated_cmd
62
+ assert sp.run(decorated_cmd, shell=True).returncode == 0
63
+
64
+ def test_deploy(self, tmp_path):
65
+ env = self._get_env(tmp_path)
66
+ self._deploy(env, tmp_path)
67
+ cmd = env.managed_decorate_shellcmd(self.get_test_cmd())
68
+ assert sp.run(cmd, shell=True).returncode == 0
69
+
70
+ def test_archive(self, tmp_path):
71
+ env = self._get_env(tmp_path)
72
+ if not isinstance(env, ArchiveableEnvBase):
73
+ pytest.skip("Environment either not deployable or not archiveable.")
74
+
75
+ self._deploy(env, tmp_path)
76
+
77
+ env.archive()
78
+ assert any((tmp_path / "{_TEST_SDM_NAME}-archive").iterdir())
79
+
80
+ def _get_env(self, tmp_path) -> EnvBase:
81
+ env_cls = self.get_env_cls()
82
+ spec = self.get_env_spec()
83
+ args = {"settings": self.get_software_deployment_provider_settings()}
84
+ if issubclass(env_cls, DeployableEnvBase):
85
+ args["deployment_prefix"] = tmp_path / "deployments"
86
+ if issubclass(env_cls, ArchiveableEnvBase):
87
+ args["archive_prefix"] = tmp_path / "archives"
88
+ return env_cls(spec=spec, within=None, **args)
89
+
90
+ def _deploy(self, env: DeployableEnvBase, tmp_path):
91
+ if not isinstance(env, DeployableEnvBase):
92
+ pytest.skip("Environment is not deployable.")
93
+
94
+ env.deploy()
95
+ assert any((tmp_path / _TEST_SDM_NAME).iterdir())