runspec-linux-core 0.3.1__tar.gz → 0.3.3__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.
Files changed (30) hide show
  1. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/CHANGELOG.md +42 -0
  2. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/__init__.py +8 -0
  5. runspec_linux_core-0.3.3/runspec_linux_core/commands.py +76 -0
  6. runspec_linux_core-0.3.3/runspec_linux_core/venvs.py +299 -0
  7. runspec_linux_core-0.3.3/tests/test_commands.py +88 -0
  8. runspec_linux_core-0.3.3/tests/test_venvs.py +249 -0
  9. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/.gitignore +0 -0
  10. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/_paths.py +0 -0
  11. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/containers.py +0 -0
  12. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/errors.py +0 -0
  13. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/files.py +0 -0
  14. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/logs.py +0 -0
  15. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/nc.py +0 -0
  16. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/network.py +0 -0
  17. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/packages.py +0 -0
  18. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/power.py +0 -0
  19. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/security.py +0 -0
  20. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/services.py +0 -0
  21. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/sudoers.py +0 -0
  22. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/runspec_linux_core/system.py +0 -0
  23. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/__init__.py +0 -0
  24. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_nc_send.py +0 -0
  25. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_network.py +0 -0
  26. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_packages.py +0 -0
  27. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_paths.py +0 -0
  28. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_power.py +0 -0
  29. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_preconditions.py +0 -0
  30. {runspec_linux_core-0.3.1 → runspec_linux_core-0.3.3}/tests/test_sudoers.py +0 -0
@@ -1,5 +1,47 @@
1
1
  # runspec-linux-core Changelog
2
2
 
3
+ ## [0.3.3] — 2026-06-22
4
+
5
+ Add `commands.py` — the pure logic behind the new `runspec-linux` `which`
6
+ runnable:
7
+
8
+ - **`which(names, *, all_matches=False, path=None)`** — locate executables on the
9
+ search PATH, like the `which` command. `names` is a single command or a list
10
+ (a string is split on whitespace, so `"python3 pip3"` resolves both).
11
+ `all_matches` returns every match across the PATH (not just the first that
12
+ would run); `path` overrides the search path (defaults to `$PATH`, falling back
13
+ to `os.defpath`). A name containing a path separator is checked directly rather
14
+ than searched. Returns `{path, all_found, results: [{name, found, path,
15
+ paths}]}` and raises `ValueError` when no name is given.
16
+
17
+ ## [0.3.2] — 2026-06-21
18
+
19
+ Add `venvs.py` — the pure logic behind three new `runspec-linux` runnables for
20
+ Python virtualenv management:
21
+
22
+ - **`create_venv(python, dest, *, run_as=None, become_method="sudo", group=None,
23
+ system_site_packages=False, upgrade_deps=False, clear=False, prompt=None)`** —
24
+ build a venv at `dest` from a *specific* interpreter (no system default
25
+ fallback; `python` must be an executable file), always with `--symlinks`. Then
26
+ creates `<dest>/logs` mode `2775` (setgid, so audit logs inherit the group) and
27
+ optionally `chgrp -R`s the venv to a shared `group` — the shape a service
28
+ account needs for a venv shared between several consoles.
29
+ - **`install_into_venv(venv, packages="", *, project=None, editable=False,
30
+ upgrade=False, no_cache_dir=False, force_reinstall=False, run_as=None,
31
+ become_method="sudo")`** — `pip install` named packages and/or a local `project`
32
+ (a `pyproject.toml` directory or a wheel/sdist; `editable` for `-e`) into the
33
+ venv's bundled python. Package names reuse the shared injection guard.
34
+ - **`configure_pip(index_url, *, extra_index_url=None, trusted_host=None,
35
+ cert=None, timeout=None, retries=None, proxy=None, require_virtualenv=False,
36
+ scope="user", venv=None, run_as=None, become_method="sudo")`** — write a
37
+ validated `pip.conf` (`[global]`) at user (`~/.config/pip/pip.conf`) or venv
38
+ (`<venv>/pip.conf`) scope for private-PyPI access.
39
+
40
+ All three take a **runtime** `run_as` user (the service account is chosen
41
+ per-invocation, so escalation is internal — `sudo -n -u <user>` or, via
42
+ `become_method`, `su` — not a declarative `run_as`) and raise `ValueError` on bad
43
+ input / `CommandError` on a failed step.
44
+
3
45
  ## [0.3.1] — 2026-06-20
4
46
 
5
47
  Fix `enable_passwordless_sudo` failing with "visudo not installed" (and the same
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-linux-core"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  requires-python = ">=3.10"
9
9
  description = "Pure-Python Linux system-admin helpers — the importable core behind runspec-linux (no runspec dependency, no runnables)"
10
10
  dependencies = []
@@ -9,6 +9,7 @@ Each function does the work and returns plain data; failures raise (see
9
9
  ``runspec_linux_core.errors``).
10
10
  """
11
11
 
12
+ from runspec_linux_core.commands import which
12
13
  from runspec_linux_core.containers import (
13
14
  container_logs,
14
15
  list_containers,
@@ -32,6 +33,7 @@ from runspec_linux_core.security import last_logins, who
32
33
  from runspec_linux_core.services import check_service, list_services, restart_service
33
34
  from runspec_linux_core.sudoers import enable_passwordless_sudo
34
35
  from runspec_linux_core.system import check_memory, disk_usage, list_processes, system_info
36
+ from runspec_linux_core.venvs import configure_pip, create_venv, install_into_venv
35
37
 
36
38
  __all__ = [
37
39
  # errors
@@ -76,6 +78,12 @@ __all__ = [
76
78
  "reboot_host",
77
79
  # sudoers
78
80
  "enable_passwordless_sudo",
81
+ # venvs
82
+ "create_venv",
83
+ "install_into_venv",
84
+ "configure_pip",
79
85
  # tcp
80
86
  "nc_send",
87
+ # commands
88
+ "which",
81
89
  ]
@@ -0,0 +1,76 @@
1
+ """Command-location helpers — find executables on the search PATH."""
2
+
3
+ import os
4
+
5
+
6
+ def _is_executable(path: str) -> bool:
7
+ return os.path.isfile(path) and os.access(path, os.X_OK)
8
+
9
+
10
+ def _resolve(name: str, dirs: list[str]) -> list[str]:
11
+ """Return every executable match for ``name`` across ``dirs`` (in order)."""
12
+ # A name with a path separator is a path, not a bare command: check it
13
+ # directly rather than searching the PATH (mirrors `which`/`shutil.which`).
14
+ if os.sep in name or (os.altsep and os.altsep in name):
15
+ return [os.path.abspath(name)] if _is_executable(name) else []
16
+ seen: set[str] = set()
17
+ matches: list[str] = []
18
+ for d in dirs:
19
+ full = os.path.join(d, name)
20
+ if full in seen:
21
+ continue
22
+ seen.add(full)
23
+ if _is_executable(full):
24
+ matches.append(full)
25
+ return matches
26
+
27
+
28
+ def which(
29
+ names: str | list[str],
30
+ *,
31
+ all_matches: bool = False,
32
+ path: str | None = None,
33
+ ) -> dict:
34
+ """Locate executables on the search PATH, like the ``which`` command.
35
+
36
+ ``names`` is a single command name or a list of them; a string is split on
37
+ whitespace so ``"python3 pip3"`` resolves both. ``all_matches`` returns every
38
+ match found across the PATH (not just the first one that would run). ``path``
39
+ overrides the search path (defaults to ``$PATH``, falling back to
40
+ ``os.defpath``).
41
+
42
+ Returns::
43
+
44
+ {"path": "<search path used>",
45
+ "all_found": bool, # every requested name resolved
46
+ "results": [{"name": str, "found": bool,
47
+ "path": str | None, # first match, else None
48
+ "paths": [str, ...]}]} # all matches if all_matches
49
+
50
+ Raises ``ValueError`` when no name is given.
51
+ """
52
+ if isinstance(names, str):
53
+ names = names.split()
54
+ names = [n for n in names if n]
55
+ if not names:
56
+ raise ValueError("no command name given")
57
+
58
+ search = os.environ.get("PATH", os.defpath) if path is None else path
59
+ dirs = [d or os.curdir for d in search.split(os.pathsep)]
60
+
61
+ results = []
62
+ for name in names:
63
+ matches = _resolve(name, dirs)
64
+ results.append(
65
+ {
66
+ "name": name,
67
+ "found": bool(matches),
68
+ "path": matches[0] if matches else None,
69
+ "paths": matches if all_matches else matches[:1],
70
+ }
71
+ )
72
+ return {
73
+ "path": search,
74
+ "all_found": all(r["found"] for r in results),
75
+ "results": results,
76
+ }
@@ -0,0 +1,299 @@
1
+ """Python virtualenv management: create venvs, install into them, write pip.conf.
2
+
3
+ All three helpers operate in user space (no root): a venv, a pip install into it,
4
+ and a ``pip.conf`` under a home or a venv. The driving use case is a **service
5
+ account that owns a venv shared between several consoles**, so each helper takes an
6
+ optional ``run_as`` user and escalates *internally* — ``sudo -n -u <user>`` (or
7
+ ``su``) wrapping every command — rather than the console's declarative ``run_as``
8
+ (the account is chosen per-invocation, not fixed in ``runspec.toml``). ``-n`` makes
9
+ sudo fail fast instead of hanging on the TTY-less SSH channel; passwordless sudo is
10
+ assumed (see ``enable-passwordless-sudo``).
11
+
12
+ Each function returns plain data and raises on failure (``CommandError`` for a
13
+ non-zero subprocess, ``ValueError`` for bad input). The thin ``runspec-linux``
14
+ wrappers render the JSON/exit.
15
+ """
16
+
17
+ import configparser
18
+ import contextlib
19
+ import io
20
+ import os
21
+ import pwd
22
+ import re
23
+ import shlex
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+
28
+ from runspec_linux_core.errors import CommandError
29
+
30
+ # Mirrors sudoers._USER_RE: a POSIX-ish user/group name. Validated before it is
31
+ # interpolated into a become command or a chgrp, so a crafted value can't inject
32
+ # extra tokens (everything still goes to argv, never a shell, but this keeps the
33
+ # error early and clear).
34
+ _NAME_RE = re.compile(r"^[a-z_][a-z0-9_.-]*\$?$")
35
+
36
+ _BECOME_METHODS = ("sudo", "su")
37
+
38
+
39
+ def _tail(output: str, limit: int = 40) -> list[str]:
40
+ """The last ``limit`` non-empty-trimmed lines, so payloads stay bounded."""
41
+ return output.strip().splitlines()[-limit:]
42
+
43
+
44
+ def _validate_name(value: str, what: str) -> str:
45
+ if not _NAME_RE.match(value):
46
+ raise ValueError(f"invalid {what}: {value!r}")
47
+ return value
48
+
49
+
50
+ def _become(argv: list[str], run_as: str | None, method: str = "sudo") -> list[str]:
51
+ """Wrap ``argv`` so it runs as ``run_as`` via ``method`` (no escalation if empty).
52
+
53
+ ``sudo`` → ``sudo -n -u <user> <argv...>`` (``-n`` never prompts).
54
+ ``su`` → ``su <user> -c "<argv joined+quoted>"`` (su has no ``-n``; it only
55
+ works without a password when the caller is already root).
56
+ """
57
+ if not run_as:
58
+ return argv
59
+ _validate_name(run_as, "run-as user")
60
+ if method not in _BECOME_METHODS:
61
+ raise ValueError(f"invalid become-method: {method!r} (expected sudo or su)")
62
+ if method == "su":
63
+ return ["su", run_as, "-c", shlex.join(argv)]
64
+ return ["sudo", "-n", "-u", run_as, *argv]
65
+
66
+
67
+ def _run(argv: list[str]) -> subprocess.CompletedProcess[str]:
68
+ return subprocess.run(argv, capture_output=True, text=True)
69
+
70
+
71
+ def _check(result: subprocess.CompletedProcess[str], what: str, run_as: str | None) -> None:
72
+ """Raise CommandError on non-zero, with a passwordless-sudo hint when relevant."""
73
+ if result.returncode == 0:
74
+ return
75
+ err = result.stderr.strip()
76
+ if run_as and ("password is required" in err.lower() or "a terminal is required" in err.lower()):
77
+ raise CommandError(f"{what}: passwordless sudo is not configured for run-as {run_as!r} ({err})")
78
+ raise CommandError(err or f"{what} failed")
79
+
80
+
81
+ def create_venv(
82
+ python: str,
83
+ dest: str,
84
+ *,
85
+ run_as: str | None = None,
86
+ become_method: str = "sudo",
87
+ group: str | None = None,
88
+ system_site_packages: bool = False,
89
+ upgrade_deps: bool = False,
90
+ clear: bool = False,
91
+ prompt: str | None = None,
92
+ ) -> dict:
93
+ """Create a virtualenv at ``dest`` from a specific ``python`` interpreter.
94
+
95
+ Always uses ``--symlinks``. Then creates ``<dest>/logs`` mode ``2775`` (setgid,
96
+ so new audit-log files inherit the directory's group) and, if ``group`` is set,
97
+ ``chgrp -R`` the venv to it — enabling a shared service-account venv that several
98
+ consoles read/write. With ``run_as`` set every step runs as that user (so the
99
+ venv is owned by it). Raises ValueError on a non-executable interpreter / bad
100
+ name, or CommandError on a failed step.
101
+ """
102
+ if not (os.path.isfile(python) and os.access(python, os.X_OK)):
103
+ raise ValueError(f"not an executable file: {python!r} (pick a real interpreter, not the system default)")
104
+
105
+ flags: list[str] = ["--symlinks"]
106
+ if system_site_packages:
107
+ flags.append("--system-site-packages")
108
+ if upgrade_deps:
109
+ flags.append("--upgrade-deps")
110
+ if clear:
111
+ flags.append("--clear")
112
+ if prompt:
113
+ flags += ["--prompt", prompt]
114
+
115
+ _check(_run(_become([python, "-m", "venv", *flags, dest], run_as, become_method)), "venv creation", run_as)
116
+
117
+ logs_dir = os.path.join(dest, "logs")
118
+ _check(_run(_become(["mkdir", "-p", logs_dir], run_as, become_method)), "logs dir", run_as)
119
+ if group:
120
+ _validate_name(group, "group")
121
+ _check(_run(_become(["chgrp", "-R", group, dest], run_as, become_method)), "chgrp", run_as)
122
+ _check(_run(_become(["chmod", "2775", logs_dir], run_as, become_method)), "chmod logs", run_as)
123
+
124
+ return {
125
+ "python": python,
126
+ "dest": dest,
127
+ "run_as": run_as or "",
128
+ "group": group or "",
129
+ "logs_dir": logs_dir,
130
+ "created": True,
131
+ }
132
+
133
+
134
+ def install_into_venv(
135
+ venv: str,
136
+ packages: str = "",
137
+ *,
138
+ project: str | None = None,
139
+ editable: bool = False,
140
+ upgrade: bool = False,
141
+ no_cache_dir: bool = False,
142
+ force_reinstall: bool = False,
143
+ run_as: str | None = None,
144
+ become_method: str = "sudo",
145
+ ) -> dict:
146
+ """Install named packages and/or a local project into an existing venv's pip.
147
+
148
+ Targets ``<venv>/bin/python -m pip install``. ``packages`` is space-separated
149
+ named specs (validated against the shared package-token guard); ``project`` is a
150
+ path to a directory with a ``pyproject.toml`` (or a wheel/sdist), installed
151
+ editable when ``editable`` is set. At least one of ``packages``/``project`` is
152
+ required. Raises ValueError on bad input, CommandError on a failed install.
153
+ """
154
+ from runspec_linux_core.packages import _validate_packages
155
+
156
+ venv_python = os.path.join(venv, "bin", "python")
157
+ if not (os.path.isfile(venv_python) and os.access(venv_python, os.X_OK)):
158
+ raise ValueError(f"no python in venv: {venv_python!r}")
159
+
160
+ names = _validate_packages(packages) if packages.strip() else []
161
+ if project is not None and (project.startswith("-") or not os.path.exists(project)):
162
+ raise ValueError(f"project path not found: {project!r}")
163
+ if editable and project is None:
164
+ raise ValueError("--editable requires a project path")
165
+ if not names and project is None:
166
+ raise ValueError("nothing to install: give a package and/or a project")
167
+
168
+ opts: list[str] = []
169
+ if upgrade:
170
+ opts.append("-U")
171
+ if no_cache_dir:
172
+ opts.append("--no-cache-dir")
173
+ if force_reinstall:
174
+ opts.append("--force-reinstall")
175
+
176
+ target: list[str] = list(names)
177
+ if project is not None:
178
+ target += ["-e", project] if editable else [project]
179
+
180
+ cmd = _become([venv_python, "-m", "pip", "install", *opts, *target], run_as, become_method)
181
+ result = _run(cmd)
182
+ _check(result, "pip install", run_as)
183
+ return {
184
+ "venv": venv,
185
+ "packages": names,
186
+ "project": project or "",
187
+ "editable": editable,
188
+ "installed": True,
189
+ "output": _tail(result.stdout),
190
+ }
191
+
192
+
193
+ def configure_pip(
194
+ index_url: str,
195
+ *,
196
+ extra_index_url: str | None = None,
197
+ trusted_host: str | None = None,
198
+ cert: str | None = None,
199
+ timeout: int | None = None,
200
+ retries: int | None = None,
201
+ proxy: str | None = None,
202
+ require_virtualenv: bool = False,
203
+ scope: str = "user",
204
+ venv: str | None = None,
205
+ run_as: str | None = None,
206
+ become_method: str = "sudo",
207
+ ) -> dict:
208
+ """Write a ``pip.conf`` at user or venv scope for private-PyPI access.
209
+
210
+ Builds a ``[global]`` section from the given settings (``index-url`` is the only
211
+ required one) and, when ``require_virtualenv`` is set, also pins
212
+ ``require-virtualenv`` + ``disable-pip-version-check``. The file is validated by
213
+ re-parsing before it is installed. ``scope="user"`` targets
214
+ ``<home>/.config/pip/pip.conf`` (the ``run_as`` user's home when escalating, else
215
+ the caller's); ``scope="venv"`` targets ``<venv>/pip.conf`` and requires ``venv``.
216
+ With ``run_as`` the file is installed via ``install -D -m 0644`` as that user (so
217
+ it is owned by it); otherwise it is written atomically with a ``.bak`` of any
218
+ existing file. Raises ValueError on bad input, CommandError on a failed step.
219
+ """
220
+ if scope not in ("user", "venv"):
221
+ raise ValueError(f"invalid scope: {scope!r} (expected user or venv)")
222
+ if scope == "venv":
223
+ if not venv:
224
+ raise ValueError("scope=venv requires a venv path")
225
+ path = os.path.join(venv, "pip.conf")
226
+ else:
227
+ if run_as:
228
+ _validate_name(run_as, "run-as user")
229
+ home = pwd.getpwnam(run_as).pw_dir
230
+ else:
231
+ home = os.path.expanduser("~")
232
+ path = os.path.join(home, ".config", "pip", "pip.conf")
233
+
234
+ cfg = configparser.ConfigParser()
235
+ section: dict[str, str] = {"index-url": index_url}
236
+ if extra_index_url:
237
+ section["extra-index-url"] = extra_index_url
238
+ if trusted_host:
239
+ section["trusted-host"] = trusted_host
240
+ if cert:
241
+ section["cert"] = cert
242
+ if timeout is not None:
243
+ section["timeout"] = str(timeout)
244
+ if retries is not None:
245
+ section["retries"] = str(retries)
246
+ if proxy:
247
+ section["proxy"] = proxy
248
+ if require_virtualenv:
249
+ section["require-virtualenv"] = "true"
250
+ section["disable-pip-version-check"] = "true"
251
+ cfg["global"] = section
252
+
253
+ buf = io.StringIO()
254
+ cfg.write(buf)
255
+ content = buf.getvalue()
256
+
257
+ # Validate it re-parses before we install it anywhere.
258
+ check = configparser.ConfigParser()
259
+ check.read_string(content)
260
+
261
+ # Write to a temp file the run-as user can read (0644 — pip.conf is non-secret),
262
+ # then install it atomically into place as that user (creating parent dirs).
263
+ fd, tmp = tempfile.mkstemp(prefix="pip.conf.", suffix=".tmp")
264
+ try:
265
+ with os.fdopen(fd, "w") as fh:
266
+ fh.write(content)
267
+ os.chmod(tmp, 0o644)
268
+ if run_as:
269
+ if _path_exists_as(path, run_as, become_method):
270
+ _check(
271
+ _run(_become(["cp", path, f"{path}.bak"], run_as, become_method)),
272
+ "backup pip.conf",
273
+ run_as,
274
+ )
275
+ _check(
276
+ _run(_become(["install", "-D", "-m", "0644", tmp, path], run_as, become_method)),
277
+ "install pip.conf",
278
+ run_as,
279
+ )
280
+ else:
281
+ os.makedirs(os.path.dirname(path), exist_ok=True)
282
+ if os.path.exists(path):
283
+ shutil.copy2(path, f"{path}.bak")
284
+ shutil.copy2(tmp, path)
285
+ finally:
286
+ with contextlib.suppress(OSError):
287
+ os.unlink(tmp)
288
+
289
+ return {
290
+ "path": path,
291
+ "scope": scope,
292
+ "run_as": run_as or "",
293
+ "configured": True,
294
+ }
295
+
296
+
297
+ def _path_exists_as(path: str, run_as: str | None, become_method: str) -> bool:
298
+ """True if ``path`` exists from the run-as user's view (``test -e``)."""
299
+ return _run(_become(["test", "-e", path], run_as, become_method)).returncode == 0
@@ -0,0 +1,88 @@
1
+ import os
2
+ import stat
3
+
4
+ import pytest
5
+
6
+ from runspec_linux_core import which
7
+
8
+
9
+ def _make_exec(path):
10
+ path.write_text("#!/bin/sh\n")
11
+ path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
12
+
13
+
14
+ def test_which_finds_executable(tmp_path):
15
+ binp = tmp_path / "mytool"
16
+ _make_exec(binp)
17
+ res = which("mytool", path=str(tmp_path))
18
+ assert res["all_found"] is True
19
+ assert res["path"] == str(tmp_path)
20
+ (r,) = res["results"]
21
+ assert r == {
22
+ "name": "mytool",
23
+ "found": True,
24
+ "path": str(binp),
25
+ "paths": [str(binp)],
26
+ }
27
+
28
+
29
+ def test_which_not_found(tmp_path):
30
+ res = which("nope", path=str(tmp_path))
31
+ assert res["all_found"] is False
32
+ assert res["results"][0] == {
33
+ "name": "nope",
34
+ "found": False,
35
+ "path": None,
36
+ "paths": [],
37
+ }
38
+
39
+
40
+ def test_which_skips_non_executable(tmp_path):
41
+ (tmp_path / "plain").write_text("data")
42
+ res = which("plain", path=str(tmp_path))
43
+ assert res["results"][0]["found"] is False
44
+
45
+
46
+ def test_which_multiple_names_space_separated(tmp_path):
47
+ _make_exec(tmp_path / "a")
48
+ res = which("a b", path=str(tmp_path))
49
+ assert [r["name"] for r in res["results"]] == ["a", "b"]
50
+ assert res["results"][0]["found"] is True
51
+ assert res["results"][1]["found"] is False
52
+ assert res["all_found"] is False
53
+
54
+
55
+ def test_which_accepts_list(tmp_path):
56
+ _make_exec(tmp_path / "a")
57
+ res = which(["a"], path=str(tmp_path))
58
+ assert res["all_found"] is True
59
+
60
+
61
+ def test_which_first_match_wins_all_matches_lists_every_dir(tmp_path):
62
+ d1 = tmp_path / "d1"
63
+ d2 = tmp_path / "d2"
64
+ d1.mkdir()
65
+ d2.mkdir()
66
+ _make_exec(d1 / "dup")
67
+ _make_exec(d2 / "dup")
68
+ search = os.pathsep.join([str(d1), str(d2)])
69
+
70
+ first = which("dup", path=search)
71
+ assert first["results"][0]["path"] == str(d1 / "dup")
72
+ assert first["results"][0]["paths"] == [str(d1 / "dup")]
73
+
74
+ allm = which("dup", all_matches=True, path=search)
75
+ assert allm["results"][0]["paths"] == [str(d1 / "dup"), str(d2 / "dup")]
76
+
77
+
78
+ def test_which_name_with_path_separator_checked_directly(tmp_path):
79
+ binp = tmp_path / "tool"
80
+ _make_exec(binp)
81
+ res = which(str(binp))
82
+ assert res["results"][0]["found"] is True
83
+ assert res["results"][0]["path"] == str(binp)
84
+
85
+
86
+ def test_which_empty_raises():
87
+ with pytest.raises(ValueError):
88
+ which(" ")
@@ -0,0 +1,249 @@
1
+ """venv creation, install-into-venv, and pip.conf writing.
2
+
3
+ The subprocess layer (``_run``) is stubbed so no real venv/pip is touched; the
4
+ tests assert on the argv that *would* run (escalation prefix, flags, ordering)
5
+ and on the input validation that happens before any subprocess.
6
+ """
7
+
8
+ import configparser
9
+ import os
10
+ import subprocess
11
+
12
+ import pytest
13
+
14
+ from runspec_linux_core import venvs
15
+ from runspec_linux_core.errors import CommandError
16
+
17
+
18
+ def _completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
19
+ return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
20
+
21
+
22
+ def _record(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
23
+ """Stub ``venvs._run`` to record every argv and always succeed."""
24
+ calls: list[list[str]] = []
25
+
26
+ def fake_run(argv: list[str]) -> subprocess.CompletedProcess:
27
+ calls.append(argv)
28
+ return _completed("Successfully installed foo\n")
29
+
30
+ monkeypatch.setattr(venvs, "_run", fake_run)
31
+ return calls
32
+
33
+
34
+ # ── _become ───────────────────────────────────────────────────────────────────
35
+
36
+
37
+ def test_become_no_user_is_noop() -> None:
38
+ assert venvs._become(["echo", "hi"], None) == ["echo", "hi"]
39
+ assert venvs._become(["echo", "hi"], "") == ["echo", "hi"]
40
+
41
+
42
+ def test_become_sudo_prefix() -> None:
43
+ assert venvs._become(["echo", "hi"], "svc") == ["sudo", "-n", "-u", "svc", "echo", "hi"]
44
+
45
+
46
+ def test_become_su_quotes() -> None:
47
+ assert venvs._become(["pip", "install", "a b"], "svc", "su") == ["su", "svc", "-c", "pip install 'a b'"]
48
+
49
+
50
+ def test_become_rejects_bad_user() -> None:
51
+ with pytest.raises(ValueError):
52
+ venvs._become(["echo"], "bad;user")
53
+
54
+
55
+ def test_become_rejects_bad_method() -> None:
56
+ with pytest.raises(ValueError):
57
+ venvs._become(["echo"], "svc", "doas")
58
+
59
+
60
+ # ── create_venv ───────────────────────────────────────────────────────────────
61
+
62
+
63
+ def test_create_venv_uses_symlinks_and_logs(monkeypatch: pytest.MonkeyPatch) -> None:
64
+ calls = _record(monkeypatch)
65
+ import sys
66
+
67
+ result = venvs.create_venv(sys.executable, "/opt/v1")
68
+
69
+ venv_cmd = calls[0]
70
+ assert venv_cmd[:4] == [sys.executable, "-m", "venv", "--symlinks"]
71
+ assert venv_cmd[-1] == "/opt/v1"
72
+ # mkdir -p logs, then chmod 2775 logs (no group → no chgrp step).
73
+ assert calls[1] == ["mkdir", "-p", "/opt/v1/logs"]
74
+ assert calls[2] == ["chmod", "2775", "/opt/v1/logs"]
75
+ assert result == {
76
+ "python": sys.executable,
77
+ "dest": "/opt/v1",
78
+ "run_as": "",
79
+ "group": "",
80
+ "logs_dir": "/opt/v1/logs",
81
+ "created": True,
82
+ }
83
+
84
+
85
+ def test_create_venv_only_sets_chosen_flags(monkeypatch: pytest.MonkeyPatch) -> None:
86
+ calls = _record(monkeypatch)
87
+ import sys
88
+
89
+ venvs.create_venv(sys.executable, "/opt/v1", system_site_packages=True, clear=True)
90
+ venv_cmd = calls[0]
91
+ assert "--system-site-packages" in venv_cmd
92
+ assert "--clear" in venv_cmd
93
+ assert "--upgrade-deps" not in venv_cmd
94
+
95
+
96
+ def test_create_venv_run_as_escalates_every_step(monkeypatch: pytest.MonkeyPatch) -> None:
97
+ calls = _record(monkeypatch)
98
+ import sys
99
+
100
+ venvs.create_venv(sys.executable, "/opt/v1", run_as="svc", group="consoles")
101
+ for cmd in calls:
102
+ assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
103
+ # chgrp -R consoles /opt/v1 runs between mkdir and chmod.
104
+ assert ["sudo", "-n", "-u", "svc", "chgrp", "-R", "consoles", "/opt/v1"] in calls
105
+
106
+
107
+ def test_create_venv_rejects_non_executable(monkeypatch: pytest.MonkeyPatch) -> None:
108
+ _record(monkeypatch)
109
+ with pytest.raises(ValueError):
110
+ venvs.create_venv("/no/such/python", "/opt/v1")
111
+
112
+
113
+ def test_create_venv_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ import sys
115
+
116
+ monkeypatch.setattr(venvs, "_run", lambda argv: _completed("", returncode=1, stderr="boom"))
117
+ with pytest.raises(CommandError):
118
+ venvs.create_venv(sys.executable, "/opt/v1")
119
+
120
+
121
+ # ── install_into_venv ─────────────────────────────────────────────────────────
122
+
123
+
124
+ def _fake_venv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> str:
125
+ """A venv dir whose bin/python passes the executable check."""
126
+ import sys
127
+
128
+ venv = tmp_path / "venv"
129
+ (venv / "bin").mkdir(parents=True)
130
+ py = venv / "bin" / "python"
131
+ os.symlink(sys.executable, py)
132
+ return str(venv)
133
+
134
+
135
+ def test_install_into_venv_builds_pip_argv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
136
+ venv = _fake_venv(tmp_path, monkeypatch)
137
+ calls = _record(monkeypatch)
138
+
139
+ result = venvs.install_into_venv(venv, "httpx rich", upgrade=True)
140
+ cmd = calls[0]
141
+ assert cmd[:4] == [f"{venv}/bin/python", "-m", "pip", "install"]
142
+ assert "-U" in cmd
143
+ assert cmd[-2:] == ["httpx", "rich"]
144
+ assert result["installed"] is True
145
+ assert result["output"] == ["Successfully installed foo"]
146
+
147
+
148
+ def test_install_into_venv_editable_project(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
149
+ venv = _fake_venv(tmp_path, monkeypatch)
150
+ proj = tmp_path / "proj"
151
+ proj.mkdir()
152
+ calls = _record(monkeypatch)
153
+
154
+ venvs.install_into_venv(venv, project=str(proj), editable=True, run_as="svc")
155
+ cmd = calls[0]
156
+ assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
157
+ assert cmd[-2:] == ["-e", str(proj)]
158
+
159
+
160
+ def test_install_into_venv_requires_a_target(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
161
+ venv = _fake_venv(tmp_path, monkeypatch)
162
+ _record(monkeypatch)
163
+ with pytest.raises(ValueError):
164
+ venvs.install_into_venv(venv)
165
+
166
+
167
+ def test_install_into_venv_editable_needs_project(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
168
+ venv = _fake_venv(tmp_path, monkeypatch)
169
+ _record(monkeypatch)
170
+ with pytest.raises(ValueError):
171
+ venvs.install_into_venv(venv, "httpx", editable=True)
172
+
173
+
174
+ def test_install_into_venv_rejects_missing_venv(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
175
+ _record(monkeypatch)
176
+ with pytest.raises(ValueError):
177
+ venvs.install_into_venv(str(tmp_path / "nope"), "httpx")
178
+
179
+
180
+ def test_install_into_venv_rejects_injection(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
181
+ venv = _fake_venv(tmp_path, monkeypatch)
182
+ _record(monkeypatch)
183
+ with pytest.raises(ValueError):
184
+ venvs.install_into_venv(venv, "httpx; rm -rf /")
185
+
186
+
187
+ # ── configure_pip ─────────────────────────────────────────────────────────────
188
+
189
+
190
+ def test_configure_pip_user_scope_writes_ini(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
191
+ monkeypatch.setattr(os.path, "expanduser", lambda p: str(tmp_path) if p == "~" else p)
192
+ result = venvs.configure_pip(
193
+ "https://nexus/repository/pypi/simple",
194
+ trusted_host="nexus",
195
+ timeout=60,
196
+ retries=5,
197
+ require_virtualenv=True,
198
+ )
199
+ path = result["path"]
200
+ assert path == str(tmp_path / ".config" / "pip" / "pip.conf")
201
+
202
+ cfg = configparser.ConfigParser()
203
+ cfg.read(path)
204
+ assert cfg["global"]["index-url"] == "https://nexus/repository/pypi/simple"
205
+ assert cfg["global"]["trusted-host"] == "nexus"
206
+ assert cfg["global"]["timeout"] == "60"
207
+ assert cfg["global"]["retries"] == "5"
208
+ assert cfg["global"]["require-virtualenv"] == "true"
209
+ assert cfg["global"]["disable-pip-version-check"] == "true"
210
+
211
+
212
+ def test_configure_pip_venv_scope(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
213
+ venv = tmp_path / "venv"
214
+ venv.mkdir()
215
+ result = venvs.configure_pip("https://nexus/simple", scope="venv", venv=str(venv))
216
+ assert result["path"] == str(venv / "pip.conf")
217
+ cfg = configparser.ConfigParser()
218
+ cfg.read(result["path"])
219
+ assert cfg["global"]["index-url"] == "https://nexus/simple"
220
+
221
+
222
+ def test_configure_pip_venv_scope_requires_venv() -> None:
223
+ with pytest.raises(ValueError):
224
+ venvs.configure_pip("https://nexus/simple", scope="venv")
225
+
226
+
227
+ def test_configure_pip_bad_scope() -> None:
228
+ with pytest.raises(ValueError):
229
+ venvs.configure_pip("https://nexus/simple", scope="global")
230
+
231
+
232
+ def test_configure_pip_run_as_installs_via_become(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
233
+ calls = _record(monkeypatch)
234
+ # run_as path resolves the user's home via pwd; stub it to tmp_path.
235
+ monkeypatch.setattr(
236
+ venvs.pwd,
237
+ "getpwnam",
238
+ lambda u: type("PW", (), {"pw_dir": str(tmp_path)})(),
239
+ )
240
+ result = venvs.configure_pip("https://nexus/simple", run_as="svc")
241
+ target = str(tmp_path / ".config" / "pip" / "pip.conf")
242
+ assert result["path"] == target
243
+ # An install -D -m 0644 <tmp> <target> ran as svc.
244
+ install_cmds = [c for c in calls if "install" in c and "-D" in c]
245
+ assert install_cmds, calls
246
+ cmd = install_cmds[0]
247
+ assert cmd[:4] == ["sudo", "-n", "-u", "svc"]
248
+ assert cmd[4:7] == ["install", "-D", "-m"]
249
+ assert cmd[-1] == target