horus-environments 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.
- horus_environments-0.1.0/.gitignore +54 -0
- horus_environments-0.1.0/LICENSE +21 -0
- horus_environments-0.1.0/PKG-INFO +9 -0
- horus_environments-0.1.0/pyproject.toml +130 -0
- horus_environments-0.1.0/src/horus_environments/__init__.py +2 -0
- horus_environments-0.1.0/src/horus_environments/executor/__init__.py +1 -0
- horus_environments-0.1.0/src/horus_environments/executor/environment.py +427 -0
- horus_environments-0.1.0/src/horus_environments/i18n.py +18 -0
- horus_environments-0.1.0/src/horus_environments/locale/messages.pot +21 -0
- horus_environments-0.1.0/src/horus_environments/py.typed +0 -0
- horus_environments-0.1.0/tests/__init__.py +2 -0
- horus_environments-0.1.0/tests/conftest.py +52 -0
- horus_environments-0.1.0/tests/unit/__init__.py +2 -0
- horus_environments-0.1.0/tests/unit/test_environment_executor.py +357 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
*.ipynb
|
|
23
|
+
|
|
24
|
+
# Virtual environments
|
|
25
|
+
venv/
|
|
26
|
+
.venv/
|
|
27
|
+
ENV/
|
|
28
|
+
env/
|
|
29
|
+
|
|
30
|
+
# IDEs
|
|
31
|
+
.vscode/
|
|
32
|
+
.idea/
|
|
33
|
+
*.swp
|
|
34
|
+
*.swo
|
|
35
|
+
|
|
36
|
+
# Testing
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
.coverage
|
|
39
|
+
htmlcov/
|
|
40
|
+
.tox/
|
|
41
|
+
|
|
42
|
+
# OS
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
*.xml
|
|
47
|
+
|
|
48
|
+
# i18n
|
|
49
|
+
*.mo
|
|
50
|
+
|
|
51
|
+
# logs
|
|
52
|
+
logs/
|
|
53
|
+
|
|
54
|
+
no-commit/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [year] [fullname]
|
|
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,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: horus-environments
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatically provision python virtual environments for Horus tasks.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: horus-runtime>=0.1.4
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "horus-environments"
|
|
3
|
+
description = "Automatically provision python virtual environments for Horus tasks."
|
|
4
|
+
requires-python = ">=3.13"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
classifiers = [
|
|
7
|
+
"Programming Language :: Python :: 3",
|
|
8
|
+
]
|
|
9
|
+
dependencies = ["horus-runtime>=0.1.4"]
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
|
|
12
|
+
# ================
|
|
13
|
+
# Plugin entry-points
|
|
14
|
+
# ================
|
|
15
|
+
[project.entry-points."horus.executor"]
|
|
16
|
+
python_environment = "horus_environments.executor.environment"
|
|
17
|
+
|
|
18
|
+
# ================
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"babel~=2.0",
|
|
23
|
+
"mypy~=1.19",
|
|
24
|
+
"pre_commit~=4.0",
|
|
25
|
+
"pytest~=9.0",
|
|
26
|
+
"pytest-asyncio~=1.3.0",
|
|
27
|
+
"pytest-cov~=7.0",
|
|
28
|
+
"ruff~=0.15",
|
|
29
|
+
"types-PyYAML~=6.0",
|
|
30
|
+
"uv-dynamic-versioning>=0.14.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling>=1.29,<2.0", "uv-dynamic-versioning>=0.14.0,<1.0"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.coverage.run]
|
|
38
|
+
source = ["horus_environments"]
|
|
39
|
+
|
|
40
|
+
[tool.coverage.report]
|
|
41
|
+
omit = ["tests/*"]
|
|
42
|
+
|
|
43
|
+
# Other tool configurations
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
source = "uv-dynamic-versioning"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/horus_environments"]
|
|
49
|
+
# py.typed ships the type marker; compiled translations are git-ignored (*.mo)
|
|
50
|
+
# but must ship in the wheel.
|
|
51
|
+
artifacts = [
|
|
52
|
+
"src/horus_environments/py.typed",
|
|
53
|
+
"src/horus_environments/locale/**/*.mo"
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.sdist]
|
|
57
|
+
include = ["src/horus_environments", "tests"]
|
|
58
|
+
artifacts = [
|
|
59
|
+
"src/horus_environments/py.typed",
|
|
60
|
+
"src/horus_environments/locale/**/*.mo"
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
python_version = "3.13"
|
|
65
|
+
strict = true
|
|
66
|
+
exclude = ["build", "dist"]
|
|
67
|
+
plugins = ["pydantic.mypy"]
|
|
68
|
+
|
|
69
|
+
[tool.pydantic-mypy]
|
|
70
|
+
init_typed = true
|
|
71
|
+
init_forbid_extra = true
|
|
72
|
+
warn_required_dynamic_aliases = true
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
addopts = """
|
|
76
|
+
--verbose
|
|
77
|
+
--cov=src/horus_environments
|
|
78
|
+
--cov-report=html
|
|
79
|
+
--cov-report=term-missing
|
|
80
|
+
--cov-fail-under=90
|
|
81
|
+
"""
|
|
82
|
+
minversion = "7.0"
|
|
83
|
+
python_classes = ["Test*"]
|
|
84
|
+
python_files = ["test_*.py", "*_test.py"]
|
|
85
|
+
python_functions = ["test_*"]
|
|
86
|
+
testpaths = ["tests"]
|
|
87
|
+
asyncio_mode = "auto"
|
|
88
|
+
|
|
89
|
+
[tool.ruff]
|
|
90
|
+
line-length = 79
|
|
91
|
+
target-version = "py313"
|
|
92
|
+
exclude = [".git", ".venv", "build", "dist", "__pycache__"]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint]
|
|
95
|
+
select = [
|
|
96
|
+
"D", # pydocstyle
|
|
97
|
+
"E", # pycodestyle errors
|
|
98
|
+
"W", # pycodestyle warnings
|
|
99
|
+
"F", # pyflakes
|
|
100
|
+
"I", # isort
|
|
101
|
+
"N", # pep8-naming
|
|
102
|
+
"UP", # pyupgrade
|
|
103
|
+
"B", # flake8-bugbear
|
|
104
|
+
"PL", # pylint
|
|
105
|
+
"RUF", # ruff-specific
|
|
106
|
+
"ARG", # ruff-arglint
|
|
107
|
+
"SLF", # ruff-slf
|
|
108
|
+
]
|
|
109
|
+
extend-select = ["T201"] # Prevent use of print()
|
|
110
|
+
|
|
111
|
+
ignore = [
|
|
112
|
+
"E203", # Whitespace before ':', conflicts with formatter
|
|
113
|
+
"D104", # Missing docstring in __init__ (often redundant)
|
|
114
|
+
"D205", # 1 blank line required between summary line and description
|
|
115
|
+
"D212", # Multi-line docstring summary should start at the first line
|
|
116
|
+
"D200", # One-line docstring should fit on one line
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
[tool.ruff.lint.pydocstyle]
|
|
120
|
+
convention = "google"
|
|
121
|
+
|
|
122
|
+
[tool.ruff.lint.per-file-ignores]
|
|
123
|
+
"tests/**/*.py" = ["SLF001"]
|
|
124
|
+
|
|
125
|
+
[tool.ruff.format]
|
|
126
|
+
quote-style = "double"
|
|
127
|
+
indent-style = "space"
|
|
128
|
+
|
|
129
|
+
[tool.uv-dynamic-versioning]
|
|
130
|
+
fallback-version = "0.0.1"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Executor implementations for horus-environments."""
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python environment executors for Horus tasks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from contextlib import aclosing
|
|
9
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
10
|
+
|
|
11
|
+
from horus_builtin.runtime.command import CommandRuntime
|
|
12
|
+
from horus_builtin.runtime.python_string import PythonCodeStringRuntime
|
|
13
|
+
from horus_runtime.core.executor.base import BaseExecutor, RuntimeFilterType
|
|
14
|
+
from horus_runtime.core.task.exceptions import TaskExecutionError
|
|
15
|
+
from horus_runtime.logging import horus_logger
|
|
16
|
+
from horus_runtime.settings import runtime_settings
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
|
|
19
|
+
from horus_environments.i18n import tr as _
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from horus_runtime.core.task.base import BaseTask
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PythonEnvironmentExecutor(BaseExecutor):
|
|
26
|
+
"""
|
|
27
|
+
Shared implementation for Python environment-backed executors.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
add_to_registry: ClassVar[bool] = False
|
|
31
|
+
|
|
32
|
+
runtimes: ClassVar[RuntimeFilterType] = (
|
|
33
|
+
CommandRuntime,
|
|
34
|
+
PythonCodeStringRuntime,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
requirements: list[str] = Field(default_factory=list)
|
|
38
|
+
"""
|
|
39
|
+
Python package requirements installed into the environment with pip.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
43
|
+
"""
|
|
44
|
+
Extra environment variables passed to the target process.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
environment_dir: str = ".horus_python_environment"
|
|
48
|
+
"""
|
|
49
|
+
Directory, relative to the task working directory, where the environment
|
|
50
|
+
is created.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
recreate: bool = False
|
|
54
|
+
"""
|
|
55
|
+
Recreate the environment before running the task.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def _environment_path(self, task: "BaseTask") -> str:
|
|
59
|
+
"""Return the target-side path where the environment lives."""
|
|
60
|
+
return f"{task.working_dir}/{self.environment_dir}"
|
|
61
|
+
|
|
62
|
+
def _python_bin(self, task: "BaseTask") -> str:
|
|
63
|
+
"""Return the target-side Python executable inside the environment."""
|
|
64
|
+
return f"{self._environment_path(task)}/bin/python"
|
|
65
|
+
|
|
66
|
+
def _environment_log_name(self) -> str:
|
|
67
|
+
"""Return a human-readable backend name for setup logs."""
|
|
68
|
+
return type(self).__name__
|
|
69
|
+
|
|
70
|
+
def _log_command(self, message: str) -> str:
|
|
71
|
+
"""Return a shell command that emits one setup log line."""
|
|
72
|
+
return f"printf '%s\\n' {shlex.quote(message)}"
|
|
73
|
+
|
|
74
|
+
def _create_log_command(self, task: "BaseTask") -> str:
|
|
75
|
+
"""Return a shell log command for environment creation."""
|
|
76
|
+
return self._log_command(
|
|
77
|
+
_("Creating %(executor)s at %(path)s")
|
|
78
|
+
% {
|
|
79
|
+
"executor": self._environment_log_name(),
|
|
80
|
+
"path": self._environment_path(task),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _reuse_log_command(self, task: "BaseTask") -> str:
|
|
85
|
+
"""Return a shell log command for environment reuse."""
|
|
86
|
+
return self._log_command(
|
|
87
|
+
_("Using existing %(executor)s at %(path)s")
|
|
88
|
+
% {
|
|
89
|
+
"executor": self._environment_log_name(),
|
|
90
|
+
"path": self._environment_path(task),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _pip_install_command(self, task: "BaseTask") -> str | None:
|
|
95
|
+
"""Return the pip install command, or ``None`` with no requirements."""
|
|
96
|
+
if not self.requirements:
|
|
97
|
+
return None
|
|
98
|
+
requirements = " ".join(shlex.quote(req) for req in self.requirements)
|
|
99
|
+
return (
|
|
100
|
+
f"{shlex.quote(self._python_bin(task))}"
|
|
101
|
+
f" -m pip install {requirements}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _version_probe_snippet() -> str:
|
|
106
|
+
"""Python one-liner that prints the interpreter's ``major.minor``."""
|
|
107
|
+
return (
|
|
108
|
+
"import sys; "
|
|
109
|
+
"print(f'{sys.version_info.major}.{sys.version_info.minor}')"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _reuse_or_create_command(
|
|
113
|
+
self, task: "BaseTask", create: str, version: str | None
|
|
114
|
+
) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Return a shell snippet that reuses the env when the interpreter exists
|
|
117
|
+
(and, when ``version`` is given, matches ``major.minor``), otherwise
|
|
118
|
+
wipes and recreates it with ``create``.
|
|
119
|
+
"""
|
|
120
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
121
|
+
python_bin = shlex.quote(self._python_bin(task))
|
|
122
|
+
if version is not None:
|
|
123
|
+
probe = shlex.quote(self._version_probe_snippet())
|
|
124
|
+
stale = (
|
|
125
|
+
f"[ ! -x {python_bin} ]"
|
|
126
|
+
f' || [ "$({python_bin} -c {probe} 2>/dev/null)"'
|
|
127
|
+
f" != {shlex.quote(version)} ]"
|
|
128
|
+
)
|
|
129
|
+
return (
|
|
130
|
+
f"if {stale};"
|
|
131
|
+
f" then {self._create_log_command(task)}"
|
|
132
|
+
f" && rm -rf {env_path} && {create};"
|
|
133
|
+
f" else {self._reuse_log_command(task)};"
|
|
134
|
+
f" fi"
|
|
135
|
+
)
|
|
136
|
+
return (
|
|
137
|
+
f"if [ -x {python_bin} ];"
|
|
138
|
+
f" then {self._reuse_log_command(task)};"
|
|
139
|
+
f" else {self._create_log_command(task)} && {create};"
|
|
140
|
+
f" fi"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _create_environment_command(self, task: "BaseTask") -> str:
|
|
144
|
+
"""
|
|
145
|
+
Return the shell command that ensures the environment exists.
|
|
146
|
+
"""
|
|
147
|
+
raise NotImplementedError
|
|
148
|
+
|
|
149
|
+
def _run_command(self, task: "BaseTask", prepared_command: str) -> str:
|
|
150
|
+
"""Return a command runtime invocation inside the environment."""
|
|
151
|
+
activate = shlex.quote(f"{self._environment_path(task)}/bin/activate")
|
|
152
|
+
return f". {activate} && /bin/sh -c {shlex.quote(prepared_command)}"
|
|
153
|
+
|
|
154
|
+
def _run_python_script_command(
|
|
155
|
+
self, task: "BaseTask", script_path: str
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Return a Python runtime invocation inside the environment."""
|
|
158
|
+
return (
|
|
159
|
+
f"{shlex.quote(self._python_bin(task))} {shlex.quote(script_path)}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _setup_commands(self, task: "BaseTask") -> list[str]:
|
|
163
|
+
"""Return all shell commands required before the runtime executes."""
|
|
164
|
+
commands: list[str] = []
|
|
165
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
166
|
+
if self.recreate:
|
|
167
|
+
commands.append(
|
|
168
|
+
self._log_command(
|
|
169
|
+
_("Recreating Python environment at %(path)s")
|
|
170
|
+
% {"path": self._environment_path(task)}
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
commands.append(f"rm -rf {env_path}")
|
|
174
|
+
commands.append(self._create_environment_command(task))
|
|
175
|
+
install = self._pip_install_command(task)
|
|
176
|
+
if install is not None:
|
|
177
|
+
commands.append(
|
|
178
|
+
self._log_command(
|
|
179
|
+
_(
|
|
180
|
+
"Installing %(count)d Python requirement(s) into "
|
|
181
|
+
"%(path)s"
|
|
182
|
+
)
|
|
183
|
+
% {
|
|
184
|
+
"count": len(self.requirements),
|
|
185
|
+
"path": self._environment_path(task),
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
commands.append(install)
|
|
190
|
+
return commands
|
|
191
|
+
|
|
192
|
+
async def _runtime_command(self, task: "BaseTask") -> str:
|
|
193
|
+
"""Prepare the task runtime and return its environment command."""
|
|
194
|
+
if isinstance(task.runtime, CommandRuntime):
|
|
195
|
+
command = await task.runtime.setup_runtime(task)
|
|
196
|
+
return self._run_command(task, command)
|
|
197
|
+
|
|
198
|
+
if isinstance(task.runtime, PythonCodeStringRuntime):
|
|
199
|
+
code = await task.runtime.setup_runtime(task)
|
|
200
|
+
script_path = f"{task.working_dir}/.horus_python_runtime.py"
|
|
201
|
+
await task.target.put_file(code.encode(), script_path)
|
|
202
|
+
return self._run_python_script_command(task, script_path)
|
|
203
|
+
|
|
204
|
+
raise TaskExecutionError(
|
|
205
|
+
_("Unsupported runtime %(runtime)s for %(executor)s")
|
|
206
|
+
% {
|
|
207
|
+
"runtime": type(task.runtime).__name__,
|
|
208
|
+
"executor": type(self).__name__,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def _execute(self, task: "BaseTask") -> None:
|
|
213
|
+
"""
|
|
214
|
+
Provision the selected Python environment and execute the runtime.
|
|
215
|
+
"""
|
|
216
|
+
await task.target.mkdir(task.working_dir)
|
|
217
|
+
run_command = await self._runtime_command(task)
|
|
218
|
+
full_command = " && ".join([*self._setup_commands(task), run_command])
|
|
219
|
+
|
|
220
|
+
horus_logger.log.debug(
|
|
221
|
+
_("Executing task %(task_id)s in %(executor)s: %(command)s")
|
|
222
|
+
% {
|
|
223
|
+
"task_id": task.id,
|
|
224
|
+
"executor": type(self).__name__,
|
|
225
|
+
"command": full_command,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
env = {
|
|
230
|
+
**self.env,
|
|
231
|
+
runtime_settings.SIDE_ARTIFACTS_DIR_ENV: str(
|
|
232
|
+
task.side_artifacts_dir
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
proc = await task.target.run_command(
|
|
236
|
+
full_command,
|
|
237
|
+
cwd=task.working_dir,
|
|
238
|
+
env=env,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
async with aclosing(proc.stream()) as stream:
|
|
243
|
+
async for stream_name, line in stream:
|
|
244
|
+
if stream_name == "stdout":
|
|
245
|
+
horus_logger.log.info(line.decode("utf-8").rstrip())
|
|
246
|
+
elif stream_name == "stderr":
|
|
247
|
+
horus_logger.log.warning(line.decode("utf-8").rstrip())
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
proc.kill()
|
|
250
|
+
await proc.wait()
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
# Draining the stream hits EOF but doesn't reap the process; wait() is
|
|
254
|
+
# what yields a reliable returncode (matches ShellExecutor).
|
|
255
|
+
await proc.wait()
|
|
256
|
+
if proc.returncode != 0:
|
|
257
|
+
raise TaskExecutionError(
|
|
258
|
+
_(
|
|
259
|
+
"Python environment command exited with return code "
|
|
260
|
+
"%(code)s"
|
|
261
|
+
)
|
|
262
|
+
% {"code": proc.returncode}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class CondaPythonEnvironmentExecutor(PythonEnvironmentExecutor):
|
|
267
|
+
"""
|
|
268
|
+
Execute tasks in a Conda environment on the task target.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
add_to_registry: ClassVar[bool] = True
|
|
272
|
+
|
|
273
|
+
kind: str = "conda_python_environment"
|
|
274
|
+
kind_name: ClassVar[str] = "Conda Python Environment"
|
|
275
|
+
kind_description: ClassVar[str] = _(
|
|
276
|
+
"Executes command and Python runtimes inside a Conda environment."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
conda: str = "conda"
|
|
280
|
+
"""Conda executable available on the target."""
|
|
281
|
+
|
|
282
|
+
python_version: str | None = None
|
|
283
|
+
"""Optional Python version passed to ``conda create``."""
|
|
284
|
+
|
|
285
|
+
def _environment_log_name(self) -> str:
|
|
286
|
+
"""Return a human-readable backend name for setup logs."""
|
|
287
|
+
return "Conda Python environment"
|
|
288
|
+
|
|
289
|
+
def _create_environment_command(self, task: "BaseTask") -> str:
|
|
290
|
+
"""Return the Conda environment creation command."""
|
|
291
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
292
|
+
conda = shlex.quote(self.conda)
|
|
293
|
+
python = (
|
|
294
|
+
f"python={shlex.quote(self.python_version)}"
|
|
295
|
+
if self.python_version
|
|
296
|
+
else "python"
|
|
297
|
+
)
|
|
298
|
+
create = f"{conda} create -y -p {env_path} {python} pip"
|
|
299
|
+
return self._reuse_or_create_command(
|
|
300
|
+
task, create, self._requested_python_version()
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _requested_python_version(self) -> str | None:
|
|
304
|
+
"""Return the requested ``major.minor`` version, if one was given."""
|
|
305
|
+
if self.python_version is None:
|
|
306
|
+
return None
|
|
307
|
+
match = re.search(
|
|
308
|
+
r"(?<!\d)(\d+\.\d+)(?:\.\d+)?(?!\d)", self.python_version
|
|
309
|
+
)
|
|
310
|
+
return match.group(1) if match else None
|
|
311
|
+
|
|
312
|
+
def _pip_install_command(self, task: "BaseTask") -> str | None:
|
|
313
|
+
"""Install requirements through ``conda run``."""
|
|
314
|
+
if not self.requirements:
|
|
315
|
+
return None
|
|
316
|
+
conda = shlex.quote(self.conda)
|
|
317
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
318
|
+
requirements = " ".join(shlex.quote(req) for req in self.requirements)
|
|
319
|
+
return (
|
|
320
|
+
f"{conda} run -p {env_path} python -m pip install {requirements}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _run_command(self, task: "BaseTask", prepared_command: str) -> str:
|
|
324
|
+
"""Run a shell command with ``conda run``."""
|
|
325
|
+
conda = shlex.quote(self.conda)
|
|
326
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
327
|
+
return (
|
|
328
|
+
f"{conda} run --no-capture-output -p {env_path}"
|
|
329
|
+
f" /bin/sh -c {shlex.quote(prepared_command)}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _run_python_script_command(
|
|
333
|
+
self, task: "BaseTask", script_path: str
|
|
334
|
+
) -> str:
|
|
335
|
+
"""Run a Python script with ``conda run``."""
|
|
336
|
+
conda = shlex.quote(self.conda)
|
|
337
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
338
|
+
return (
|
|
339
|
+
f"{conda} run --no-capture-output -p {env_path}"
|
|
340
|
+
f" python {shlex.quote(script_path)}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class UvPythonEnvironmentExecutor(PythonEnvironmentExecutor):
|
|
345
|
+
"""
|
|
346
|
+
Execute tasks in a uv-managed virtual environment on the task target.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
add_to_registry: ClassVar[bool] = True
|
|
350
|
+
|
|
351
|
+
kind: str = "uv_python_environment"
|
|
352
|
+
kind_name: ClassVar[str] = "uv Python Environment"
|
|
353
|
+
kind_description: ClassVar[str] = _(
|
|
354
|
+
"Executes command and Python runtimes inside a uv virtual environment."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
uv: str = "uv"
|
|
358
|
+
"""uv executable available on the target."""
|
|
359
|
+
|
|
360
|
+
python: str | None = None
|
|
361
|
+
"""Optional interpreter or Python version passed to ``uv venv``."""
|
|
362
|
+
|
|
363
|
+
def _environment_log_name(self) -> str:
|
|
364
|
+
"""Return a human-readable backend name for setup logs."""
|
|
365
|
+
return "uv Python environment"
|
|
366
|
+
|
|
367
|
+
def _requested_python_version(self) -> str | None:
|
|
368
|
+
"""
|
|
369
|
+
Return the requested major.minor version when it can be inferred.
|
|
370
|
+
"""
|
|
371
|
+
if self.python is None:
|
|
372
|
+
return None
|
|
373
|
+
match = re.search(r"(?<!\d)(\d+\.\d+)(?:\.\d+)?(?!\d)", self.python)
|
|
374
|
+
return match.group(1) if match else None
|
|
375
|
+
|
|
376
|
+
def _create_environment_command(self, task: "BaseTask") -> str:
|
|
377
|
+
"""Return the uv venv creation command."""
|
|
378
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
379
|
+
uv = shlex.quote(self.uv)
|
|
380
|
+
python = f" --python {shlex.quote(self.python)}" if self.python else ""
|
|
381
|
+
create = f"{uv} venv{python} {env_path}"
|
|
382
|
+
return self._reuse_or_create_command(
|
|
383
|
+
task, create, self._requested_python_version()
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def _pip_install_command(self, task: "BaseTask") -> str | None:
|
|
387
|
+
"""Install requirements through uv pip."""
|
|
388
|
+
if not self.requirements:
|
|
389
|
+
return None
|
|
390
|
+
uv = shlex.quote(self.uv)
|
|
391
|
+
requirements = " ".join(shlex.quote(req) for req in self.requirements)
|
|
392
|
+
return (
|
|
393
|
+
f"{uv} pip install --python {shlex.quote(self._python_bin(task))}"
|
|
394
|
+
f" {requirements}"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class VirtualenvPythonEnvironmentExecutor(PythonEnvironmentExecutor):
|
|
399
|
+
"""
|
|
400
|
+
Execute tasks in a standard-library venv on the task target.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
add_to_registry: ClassVar[bool] = True
|
|
404
|
+
|
|
405
|
+
kind: str = "virtualenv_python_environment"
|
|
406
|
+
kind_name: ClassVar[str] = "Virtualenv Python Environment"
|
|
407
|
+
kind_description: ClassVar[str] = _(
|
|
408
|
+
"Executes command and Python runtimes inside a Python virtualenv."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
python: str = "python"
|
|
412
|
+
"""Python interpreter used to create the virtual environment."""
|
|
413
|
+
|
|
414
|
+
def _environment_log_name(self) -> str:
|
|
415
|
+
"""Return a human-readable backend name for setup logs."""
|
|
416
|
+
return "virtualenv Python environment"
|
|
417
|
+
|
|
418
|
+
def _create_environment_command(self, task: "BaseTask") -> str:
|
|
419
|
+
"""Return the stdlib venv creation command."""
|
|
420
|
+
env_path = shlex.quote(self._environment_path(task))
|
|
421
|
+
return (
|
|
422
|
+
f"if [ -x {shlex.quote(self._python_bin(task))} ];"
|
|
423
|
+
f" then {self._reuse_log_command(task)};"
|
|
424
|
+
f" else {self._create_log_command(task)}"
|
|
425
|
+
f" && {shlex.quote(self.python)} -m venv {env_path};"
|
|
426
|
+
f" fi"
|
|
427
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (C) 2026 YOUR_ORGANIZATION_NAME
|
|
2
|
+
# Licensed under the MIT License. See LICENSE for details.
|
|
3
|
+
"""
|
|
4
|
+
Localization for horus_environments.
|
|
5
|
+
|
|
6
|
+
Import ``tr`` (aliased as ``_``) in any module that has user-visible strings::
|
|
7
|
+
|
|
8
|
+
from horus_environments.i18n import tr as _
|
|
9
|
+
|
|
10
|
+
_("Something happened.")
|
|
11
|
+
_("%(n)s item processed", "%(n)s items processed", n=count)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from horus_runtime.i18n import make_translator
|
|
17
|
+
|
|
18
|
+
tr = make_translator("horus_environments", Path(__file__).parent / "locale")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Translations template for horus_environments.
|
|
2
|
+
# Copyright (C) 2026 YOUR_ORGANIZATION_NAME
|
|
3
|
+
# This file is distributed under the same license as the horus_environments project.
|
|
4
|
+
#
|
|
5
|
+
#, fuzzy
|
|
6
|
+
msgid ""
|
|
7
|
+
msgstr ""
|
|
8
|
+
"Project-Id-Version: horus_environments VERSION\n"
|
|
9
|
+
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
|
10
|
+
"POT-Creation-Date: 2026-04-12 00:00+0000\n"
|
|
11
|
+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
12
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
13
|
+
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
14
|
+
"MIME-Version: 1.0\n"
|
|
15
|
+
"Content-Type: text/plain; charset=utf-8\n"
|
|
16
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
17
|
+
"Generated-By: Babel 2.18.0\n"
|
|
18
|
+
|
|
19
|
+
#: src/horus_environments/task/custom_task.py
|
|
20
|
+
msgid "CustomTask._run() is not implemented yet."
|
|
21
|
+
msgstr ""
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright (C) 2026 YOUR_ORGANIZATION_NAME
|
|
2
|
+
# Licensed under the MIT License. See LICENSE for details.
|
|
3
|
+
"""
|
|
4
|
+
Shared pytest configuration and fixtures for horus_environments tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from horus_runtime.context import HorusContext, _runtime_ctx
|
|
11
|
+
from horus_runtime.registry.auto_registry import AutoRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Register custom markers.
|
|
17
|
+
"""
|
|
18
|
+
# Register custom markers for test categorization.
|
|
19
|
+
# This is optional but can be useful for filtering tests.
|
|
20
|
+
config.addinivalue_line("markers", "unit: mark test as a unit test")
|
|
21
|
+
config.addinivalue_line(
|
|
22
|
+
"markers", "integration: mark test as an integration test"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
27
|
+
def init_registry() -> None:
|
|
28
|
+
"""
|
|
29
|
+
Load all registered plugins (including horus_environments) once per
|
|
30
|
+
session.
|
|
31
|
+
|
|
32
|
+
``AutoRegistry.init_registry()`` discovers every installed package that
|
|
33
|
+
declares a ``horus.*`` entry point and imports its module, triggering
|
|
34
|
+
class registration. This must run before any Pydantic model that contains
|
|
35
|
+
a registry field is instantiated.
|
|
36
|
+
"""
|
|
37
|
+
AutoRegistry.init_registry()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def horus_context() -> Generator[HorusContext]:
|
|
42
|
+
"""
|
|
43
|
+
Provide a fresh ``HorusContext`` for each test and reset the context
|
|
44
|
+
variable afterwards so tests do not leak state into each other.
|
|
45
|
+
"""
|
|
46
|
+
ctx = HorusContext()
|
|
47
|
+
ctx.bus.start()
|
|
48
|
+
token = _runtime_ctx.set(ctx)
|
|
49
|
+
try:
|
|
50
|
+
yield ctx
|
|
51
|
+
finally:
|
|
52
|
+
_runtime_ctx.reset(token)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Unit tests for Python environment executors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from horus_builtin.runtime.command import CommandRuntime
|
|
8
|
+
from horus_builtin.runtime.python_string import PythonCodeStringRuntime
|
|
9
|
+
from horus_builtin.task.horus_task import HorusTask
|
|
10
|
+
from horus_runtime.context import HorusContext
|
|
11
|
+
from horus_runtime.core.executor.base import BaseExecutor
|
|
12
|
+
from horus_runtime.core.task.exceptions import TaskExecutionError
|
|
13
|
+
|
|
14
|
+
from horus_environments.executor.environment import (
|
|
15
|
+
CondaPythonEnvironmentExecutor,
|
|
16
|
+
PythonEnvironmentExecutor,
|
|
17
|
+
UvPythonEnvironmentExecutor,
|
|
18
|
+
VirtualenvPythonEnvironmentExecutor,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_NONZERO_CODE = 7
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_mock_proc(
|
|
25
|
+
returncode: int = 0, stdout: bytes = b"", stderr: bytes = b""
|
|
26
|
+
) -> AsyncMock:
|
|
27
|
+
"""Return an AsyncMock channel process."""
|
|
28
|
+
proc = AsyncMock()
|
|
29
|
+
proc.returncode = returncode
|
|
30
|
+
proc.communicate = AsyncMock(return_value=(stdout, stderr))
|
|
31
|
+
proc.wait = AsyncMock(return_value=returncode)
|
|
32
|
+
|
|
33
|
+
async def _stream() -> object:
|
|
34
|
+
for line in stdout.splitlines(keepends=True):
|
|
35
|
+
yield ("stdout", line)
|
|
36
|
+
for line in stderr.splitlines(keepends=True):
|
|
37
|
+
yield ("stderr", line)
|
|
38
|
+
|
|
39
|
+
# The executor consumes proc.stream() with `async for`, so stream must
|
|
40
|
+
# return an async generator (not an awaitable).
|
|
41
|
+
proc.stream = MagicMock(return_value=_stream())
|
|
42
|
+
return proc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _make_mock_target(proc: AsyncMock | None = None) -> MagicMock:
|
|
46
|
+
"""Return a mock target whose channel methods are async."""
|
|
47
|
+
target = MagicMock()
|
|
48
|
+
target.working_directory = "/tmp/horus"
|
|
49
|
+
target.mkdir = AsyncMock()
|
|
50
|
+
target.put_file = AsyncMock()
|
|
51
|
+
target.run_command = AsyncMock(return_value=proc or _make_mock_proc())
|
|
52
|
+
return target
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_command_task(
|
|
56
|
+
executor: PythonEnvironmentExecutor, command: str = "python --version"
|
|
57
|
+
) -> HorusTask:
|
|
58
|
+
"""Create a HorusTask using a command runtime."""
|
|
59
|
+
return HorusTask(
|
|
60
|
+
id="task-1",
|
|
61
|
+
name="task_1",
|
|
62
|
+
executor=executor,
|
|
63
|
+
runtime=CommandRuntime(command=command),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.unit
|
|
68
|
+
class TestEnvironmentExecutorRegistration:
|
|
69
|
+
"""Verify concrete executors register under separate kinds."""
|
|
70
|
+
|
|
71
|
+
def test_base_executor_is_not_registered(self) -> None:
|
|
72
|
+
"""The shared base class must not appear in the registry."""
|
|
73
|
+
assert PythonEnvironmentExecutor not in BaseExecutor.registry.values()
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize(
|
|
76
|
+
("kind", "executor_cls"),
|
|
77
|
+
[
|
|
78
|
+
("conda_python_environment", CondaPythonEnvironmentExecutor),
|
|
79
|
+
("uv_python_environment", UvPythonEnvironmentExecutor),
|
|
80
|
+
(
|
|
81
|
+
"virtualenv_python_environment",
|
|
82
|
+
VirtualenvPythonEnvironmentExecutor,
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
def test_concrete_executors_are_registered(
|
|
87
|
+
self,
|
|
88
|
+
kind: str,
|
|
89
|
+
executor_cls: type[PythonEnvironmentExecutor],
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Each environment backend must have its own registry kind."""
|
|
92
|
+
assert BaseExecutor.registry[kind] is executor_cls
|
|
93
|
+
|
|
94
|
+
def test_runtimes_filter_allows_commands_and_python(self) -> None:
|
|
95
|
+
"""Environment executors support command and Python string runtimes."""
|
|
96
|
+
assert CommandRuntime in PythonEnvironmentExecutor.runtimes
|
|
97
|
+
assert PythonCodeStringRuntime in PythonEnvironmentExecutor.runtimes
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.unit
|
|
101
|
+
class TestEnvironmentCommandBuilders:
|
|
102
|
+
"""Verify backend-specific shell command generation."""
|
|
103
|
+
|
|
104
|
+
def test_virtualenv_setup_uses_stdlib_venv(self) -> None:
|
|
105
|
+
"""Virtualenv executor creates a venv with the configured Python."""
|
|
106
|
+
executor = VirtualenvPythonEnvironmentExecutor(python="python3.13")
|
|
107
|
+
task = _make_command_task(executor)
|
|
108
|
+
|
|
109
|
+
command = executor._create_environment_command(task)
|
|
110
|
+
|
|
111
|
+
assert "Creating virtualenv Python environment" in command
|
|
112
|
+
assert "Using existing virtualenv Python environment" in command
|
|
113
|
+
assert "python3.13 -m venv" in command
|
|
114
|
+
assert ".horus_python_environment" in command
|
|
115
|
+
|
|
116
|
+
def test_uv_setup_uses_uv_venv_with_python(self) -> None:
|
|
117
|
+
"""Uv executor creates the environment through uv."""
|
|
118
|
+
executor = UvPythonEnvironmentExecutor(python="3.13")
|
|
119
|
+
task = _make_command_task(executor)
|
|
120
|
+
|
|
121
|
+
command = executor._create_environment_command(task)
|
|
122
|
+
|
|
123
|
+
assert "sys.version_info.major" in command
|
|
124
|
+
assert "rm -rf" in command
|
|
125
|
+
assert "Creating uv Python environment" in command
|
|
126
|
+
assert "Using existing uv Python environment" in command
|
|
127
|
+
assert "uv venv --python 3.13" in command
|
|
128
|
+
assert "!= 3.13" in command
|
|
129
|
+
|
|
130
|
+
def test_uv_setup_detects_interpreter_style_version(self) -> None:
|
|
131
|
+
"""Uv extracts major.minor versions from interpreter-like strings."""
|
|
132
|
+
executor = UvPythonEnvironmentExecutor(python="python3.11")
|
|
133
|
+
task = _make_command_task(executor)
|
|
134
|
+
|
|
135
|
+
command = executor._create_environment_command(task)
|
|
136
|
+
|
|
137
|
+
assert "uv venv --python python3.11" in command
|
|
138
|
+
assert "!= 3.11" in command
|
|
139
|
+
|
|
140
|
+
def test_conda_setup_uses_conda_create_with_python_version(self) -> None:
|
|
141
|
+
"""Conda executor creates the environment with conda create."""
|
|
142
|
+
executor = CondaPythonEnvironmentExecutor(python_version="3.13")
|
|
143
|
+
task = _make_command_task(executor)
|
|
144
|
+
|
|
145
|
+
command = executor._create_environment_command(task)
|
|
146
|
+
|
|
147
|
+
assert "Creating Conda Python environment" in command
|
|
148
|
+
assert "Using existing Conda Python environment" in command
|
|
149
|
+
assert "conda create -y -p" in command
|
|
150
|
+
assert "python=3.13" in command
|
|
151
|
+
assert " pip" in command
|
|
152
|
+
# A pinned version must recreate the env when it drifts, not blindly
|
|
153
|
+
# reuse whatever interpreter already exists.
|
|
154
|
+
assert "sys.version_info.major" in command
|
|
155
|
+
assert "!= 3.13" in command
|
|
156
|
+
assert "rm -rf" in command
|
|
157
|
+
|
|
158
|
+
def test_conda_setup_without_version_uses_existence_check(self) -> None:
|
|
159
|
+
"""Without a pinned version, conda reuses any existing interpreter."""
|
|
160
|
+
executor = CondaPythonEnvironmentExecutor()
|
|
161
|
+
task = _make_command_task(executor)
|
|
162
|
+
|
|
163
|
+
command = executor._create_environment_command(task)
|
|
164
|
+
|
|
165
|
+
assert "conda create -y -p" in command
|
|
166
|
+
assert "python " in command # unpinned interpreter
|
|
167
|
+
assert "sys.version_info.major" not in command
|
|
168
|
+
assert "rm -rf" not in command
|
|
169
|
+
|
|
170
|
+
def test_requirements_are_installed_with_pip(self) -> None:
|
|
171
|
+
"""Requirements are shell-quoted and installed into the environment."""
|
|
172
|
+
executor = VirtualenvPythonEnvironmentExecutor(
|
|
173
|
+
requirements=["numpy==2.0", "my package"]
|
|
174
|
+
)
|
|
175
|
+
task = _make_command_task(executor)
|
|
176
|
+
|
|
177
|
+
command = executor._pip_install_command(task)
|
|
178
|
+
|
|
179
|
+
assert command is not None
|
|
180
|
+
assert "-m pip install" in command
|
|
181
|
+
assert "numpy==2.0" in command
|
|
182
|
+
assert "'my package'" in command
|
|
183
|
+
|
|
184
|
+
def test_conda_requirements_install_uses_conda_run(self) -> None:
|
|
185
|
+
"""Conda installs requirements from inside the Conda environment."""
|
|
186
|
+
executor = CondaPythonEnvironmentExecutor(requirements=["pandas"])
|
|
187
|
+
task = _make_command_task(executor)
|
|
188
|
+
|
|
189
|
+
command = executor._pip_install_command(task)
|
|
190
|
+
|
|
191
|
+
assert command is not None
|
|
192
|
+
assert command.startswith("conda run -p")
|
|
193
|
+
assert "python -m pip install pandas" in command
|
|
194
|
+
|
|
195
|
+
def test_conda_without_requirements_skips_install(self) -> None:
|
|
196
|
+
"""Conda does not emit an install command without requirements."""
|
|
197
|
+
executor = CondaPythonEnvironmentExecutor()
|
|
198
|
+
task = _make_command_task(executor)
|
|
199
|
+
|
|
200
|
+
assert executor._pip_install_command(task) is None
|
|
201
|
+
|
|
202
|
+
def test_uv_requirements_install_uses_uv_pip(self) -> None:
|
|
203
|
+
"""Uv installs requirements through uv pip."""
|
|
204
|
+
executor = UvPythonEnvironmentExecutor(requirements=["httpx"])
|
|
205
|
+
task = _make_command_task(executor)
|
|
206
|
+
|
|
207
|
+
command = executor._pip_install_command(task)
|
|
208
|
+
|
|
209
|
+
assert command is not None
|
|
210
|
+
assert command.startswith("uv pip install --python")
|
|
211
|
+
assert " httpx" in command
|
|
212
|
+
|
|
213
|
+
def test_recreate_removes_environment_before_setup(self) -> None:
|
|
214
|
+
"""recreate=True removes the old environment before creation."""
|
|
215
|
+
executor = VirtualenvPythonEnvironmentExecutor(recreate=True)
|
|
216
|
+
task = _make_command_task(executor)
|
|
217
|
+
|
|
218
|
+
commands = executor._setup_commands(task)
|
|
219
|
+
|
|
220
|
+
assert "Recreating Python environment" in commands[0]
|
|
221
|
+
assert commands[1].startswith("rm -rf")
|
|
222
|
+
assert "python -m venv" in commands[2]
|
|
223
|
+
|
|
224
|
+
def test_requirements_install_logs_count(self) -> None:
|
|
225
|
+
"""Requirement setup logs the install count before pip runs."""
|
|
226
|
+
executor = VirtualenvPythonEnvironmentExecutor(
|
|
227
|
+
requirements=["rich", "httpx"]
|
|
228
|
+
)
|
|
229
|
+
task = _make_command_task(executor)
|
|
230
|
+
|
|
231
|
+
commands = executor._setup_commands(task)
|
|
232
|
+
|
|
233
|
+
assert "Installing 2 Python requirement(s)" in commands[-2]
|
|
234
|
+
assert "-m pip install rich httpx" in commands[-1]
|
|
235
|
+
|
|
236
|
+
def test_command_runtime_is_wrapped_in_environment_shell(self) -> None:
|
|
237
|
+
"""Command runtimes execute with the virtualenv activated."""
|
|
238
|
+
executor = VirtualenvPythonEnvironmentExecutor()
|
|
239
|
+
task = _make_command_task(executor)
|
|
240
|
+
|
|
241
|
+
command = executor._run_command(task, "python -c 'print(1)'")
|
|
242
|
+
|
|
243
|
+
expected = ".horus_python_environment/bin/activate && /bin/sh -c"
|
|
244
|
+
assert expected in command
|
|
245
|
+
assert "/bin/sh -c" in command
|
|
246
|
+
|
|
247
|
+
def test_conda_runtime_commands_use_conda_run(self) -> None:
|
|
248
|
+
"""Conda wraps shell commands and Python scripts with conda run."""
|
|
249
|
+
executor = CondaPythonEnvironmentExecutor(conda="mamba")
|
|
250
|
+
task = _make_command_task(executor)
|
|
251
|
+
|
|
252
|
+
command = executor._run_command(task, "echo hi")
|
|
253
|
+
python = executor._run_python_script_command(task, "/tmp/run.py")
|
|
254
|
+
|
|
255
|
+
assert command.startswith("mamba run --no-capture-output -p")
|
|
256
|
+
assert "/bin/sh -c" in command
|
|
257
|
+
assert python.startswith("mamba run --no-capture-output -p")
|
|
258
|
+
assert " python /tmp/run.py" in python
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@pytest.mark.unit
|
|
262
|
+
class TestEnvironmentExecutorExecute:
|
|
263
|
+
"""Verify end-to-end executor behavior against the target channel."""
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_execute_runs_setup_install_and_command(
|
|
267
|
+
self, horus_context: HorusContext
|
|
268
|
+
) -> None:
|
|
269
|
+
"""A command runtime is provisioned and delegated to run_command."""
|
|
270
|
+
del horus_context
|
|
271
|
+
executor = VirtualenvPythonEnvironmentExecutor(
|
|
272
|
+
requirements=["rich"], env={"EXTRA": "1"}
|
|
273
|
+
)
|
|
274
|
+
task = _make_command_task(executor, "python -c 'print(42)'")
|
|
275
|
+
target = _make_mock_target(_make_mock_proc(stdout=b"42\n"))
|
|
276
|
+
|
|
277
|
+
with patch.object(task, "target", target):
|
|
278
|
+
await executor._execute(task)
|
|
279
|
+
|
|
280
|
+
expected_working_dir = "/tmp/horus/task-1"
|
|
281
|
+
target.mkdir.assert_called_once_with(expected_working_dir)
|
|
282
|
+
target.run_command.assert_called_once()
|
|
283
|
+
command = target.run_command.call_args[0][0]
|
|
284
|
+
assert "python -m venv" in command
|
|
285
|
+
assert "-m pip install rich" in command
|
|
286
|
+
assert "/bin/sh -c" in command
|
|
287
|
+
kwargs = target.run_command.call_args.kwargs
|
|
288
|
+
assert kwargs["cwd"] == expected_working_dir
|
|
289
|
+
assert kwargs["env"]["EXTRA"] == "1"
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_execute_python_string_uploads_and_runs_script(
|
|
293
|
+
self, horus_context: HorusContext
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Python string runtimes are written as a target-side script."""
|
|
296
|
+
del horus_context
|
|
297
|
+
executor = UvPythonEnvironmentExecutor()
|
|
298
|
+
task = HorusTask(
|
|
299
|
+
id="task-2",
|
|
300
|
+
name="task_2",
|
|
301
|
+
executor=executor,
|
|
302
|
+
runtime=PythonCodeStringRuntime(code="print('hi')"),
|
|
303
|
+
)
|
|
304
|
+
target = _make_mock_target()
|
|
305
|
+
|
|
306
|
+
with patch.object(task, "target", target):
|
|
307
|
+
await executor._execute(task)
|
|
308
|
+
|
|
309
|
+
target.put_file.assert_called_once_with(
|
|
310
|
+
b"print('hi')", "/tmp/horus/task-2/.horus_python_runtime.py"
|
|
311
|
+
)
|
|
312
|
+
command = target.run_command.call_args[0][0]
|
|
313
|
+
assert "uv venv" in command
|
|
314
|
+
assert ".horus_python_environment/bin/python" in command
|
|
315
|
+
assert ".horus_python_runtime.py" in command
|
|
316
|
+
|
|
317
|
+
@pytest.mark.asyncio
|
|
318
|
+
async def test_execute_nonzero_exit_raises(
|
|
319
|
+
self, horus_context: HorusContext
|
|
320
|
+
) -> None:
|
|
321
|
+
"""A non-zero environment command exit code raises a task error."""
|
|
322
|
+
del horus_context
|
|
323
|
+
executor = VirtualenvPythonEnvironmentExecutor()
|
|
324
|
+
task = _make_command_task(executor)
|
|
325
|
+
target = _make_mock_target(
|
|
326
|
+
_make_mock_proc(returncode=_NONZERO_CODE, stderr=b"failed")
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
with patch.object(task, "target", target):
|
|
330
|
+
with pytest.raises(TaskExecutionError, match=str(_NONZERO_CODE)):
|
|
331
|
+
await executor._execute(task)
|
|
332
|
+
|
|
333
|
+
@pytest.mark.asyncio
|
|
334
|
+
async def test_execute_cancellation_kills_process(
|
|
335
|
+
self, horus_context: HorusContext
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Cancellation kills the target process before propagating."""
|
|
338
|
+
del horus_context
|
|
339
|
+
executor = VirtualenvPythonEnvironmentExecutor()
|
|
340
|
+
task = _make_command_task(executor)
|
|
341
|
+
proc = _make_mock_proc()
|
|
342
|
+
proc.kill = MagicMock()
|
|
343
|
+
|
|
344
|
+
# The executor cancels while iterating the stream, so raise there.
|
|
345
|
+
async def _cancelled_stream() -> object:
|
|
346
|
+
raise asyncio.CancelledError
|
|
347
|
+
yield # pragma: no cover - makes this an async generator
|
|
348
|
+
|
|
349
|
+
proc.stream = MagicMock(return_value=_cancelled_stream())
|
|
350
|
+
target = _make_mock_target(proc)
|
|
351
|
+
|
|
352
|
+
with patch.object(task, "target", target):
|
|
353
|
+
with pytest.raises(asyncio.CancelledError):
|
|
354
|
+
await executor._execute(task)
|
|
355
|
+
|
|
356
|
+
proc.kill.assert_called_once()
|
|
357
|
+
proc.wait.assert_awaited_once()
|