runspec-linux-core 0.3.2__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.2 → runspec_linux_core-0.3.3}/CHANGELOG.md +14 -0
  2. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/PKG-INFO +1 -1
  3. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/pyproject.toml +1 -1
  4. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/__init__.py +3 -0
  5. runspec_linux_core-0.3.3/runspec_linux_core/commands.py +76 -0
  6. runspec_linux_core-0.3.3/tests/test_commands.py +88 -0
  7. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/.gitignore +0 -0
  8. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/_paths.py +0 -0
  9. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/containers.py +0 -0
  10. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/errors.py +0 -0
  11. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/files.py +0 -0
  12. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/logs.py +0 -0
  13. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/nc.py +0 -0
  14. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/network.py +0 -0
  15. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/packages.py +0 -0
  16. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/power.py +0 -0
  17. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/security.py +0 -0
  18. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/services.py +0 -0
  19. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/sudoers.py +0 -0
  20. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/system.py +0 -0
  21. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/runspec_linux_core/venvs.py +0 -0
  22. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/__init__.py +0 -0
  23. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_nc_send.py +0 -0
  24. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_network.py +0 -0
  25. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_packages.py +0 -0
  26. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_paths.py +0 -0
  27. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_power.py +0 -0
  28. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_preconditions.py +0 -0
  29. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_sudoers.py +0 -0
  30. {runspec_linux_core-0.3.2 → runspec_linux_core-0.3.3}/tests/test_venvs.py +0 -0
@@ -1,5 +1,19 @@
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
+
3
17
  ## [0.3.2] — 2026-06-21
4
18
 
5
19
  Add `venvs.py` — the pure logic behind three new `runspec-linux` runnables for
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-linux-core
3
- Version: 0.3.2
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.2"
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,
@@ -83,4 +84,6 @@ __all__ = [
83
84
  "configure_pip",
84
85
  # tcp
85
86
  "nc_send",
87
+ # commands
88
+ "which",
86
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,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(" ")