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.
@@ -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,2 @@
1
+ # Copyright (C) 2026 YOUR_ORGANIZATION_NAME
2
+ # Licensed under the MIT License. See LICENSE for details.
@@ -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 ""
@@ -0,0 +1,2 @@
1
+ # Copyright (C) 2026 YOUR_ORGANIZATION_NAME
2
+ # Licensed under the MIT License. See LICENSE for details.
@@ -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,2 @@
1
+ # Copyright (C) 2026 YOUR_ORGANIZATION_NAME
2
+ # Licensed under the MIT License. See LICENSE for details.
@@ -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()