msdev 0.9.0__py3-none-any.whl

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.
msdev/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """msdev core capabilities: nodes, environments, workspaces, and inventory."""
msdev/core/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """Shared local paths, locking, and environment-layer parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import Iterator
9
+
10
+ try:
11
+ import fcntl
12
+ except ImportError: # pragma: no cover - exercised on Windows.
13
+ fcntl = None # type: ignore[assignment]
14
+
15
+ try:
16
+ import msvcrt
17
+ except ImportError: # pragma: no cover - exercised on POSIX.
18
+ msvcrt = None # type: ignore[assignment]
19
+
20
+
21
+ @contextmanager
22
+ def _exclusive_file_lock(path: Path) -> Iterator[None]:
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
25
+ flags |= getattr(os, "O_NOFOLLOW", 0)
26
+ descriptor = os.open(path, flags, 0o600)
27
+ try:
28
+ if fcntl is not None:
29
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
30
+ elif msvcrt is not None: # pragma: no cover - Windows only.
31
+ if os.fstat(descriptor).st_size == 0:
32
+ os.write(descriptor, b"\0")
33
+ os.lseek(descriptor, 0, os.SEEK_SET)
34
+ msvcrt.locking(descriptor, msvcrt.LK_LOCK, 1)
35
+ else: # pragma: no cover
36
+ raise RuntimeError("no supported interprocess file-lock API")
37
+ yield
38
+ finally:
39
+ if fcntl is not None:
40
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
41
+ elif msvcrt is not None: # pragma: no cover - Windows only.
42
+ os.lseek(descriptor, 0, os.SEEK_SET)
43
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
44
+ os.close(descriptor)
45
+
46
+
47
+ def parse_environment_spec(spec: str) -> tuple[str, str]:
48
+ kind, separator, value = spec.partition(":")
49
+ kind = kind.strip().lower()
50
+ value = value.strip()
51
+ if not separator or kind not in {"conda", "venv", "uv"} or not value:
52
+ raise ValueError(
53
+ f"invalid environment {spec!r}; expected "
54
+ "conda:<name-or-prefix>, venv:<path>, or uv:<project-path>"
55
+ )
56
+ return kind, value
57
+
58
+
59
+ _CONDA_RUN_SCRIPT = """
60
+ selector=$1
61
+ shift
62
+ conda_bin=$(command -v conda 2>/dev/null || true)
63
+ if [ -z "$conda_bin" ] && [ "${selector#*/}" != "$selector" ]; then
64
+ candidate=$selector
65
+ while [ -n "$candidate" ] && [ "$candidate" != "/" ]; do
66
+ if [ -x "$candidate/bin/conda" ]; then
67
+ conda_bin=$candidate/bin/conda
68
+ break
69
+ fi
70
+ parent=$(dirname -- "$candidate")
71
+ [ "$parent" = "$candidate" ] && break
72
+ candidate=$parent
73
+ done
74
+ fi
75
+ if [ -z "$conda_bin" ]; then
76
+ for candidate in \
77
+ "$HOME/miniconda3/bin/conda" \
78
+ "$HOME/anaconda3/bin/conda" \
79
+ "$HOME/miniforge3/bin/conda"; do
80
+ if [ -x "$candidate" ]; then
81
+ conda_bin=$candidate
82
+ break
83
+ fi
84
+ done
85
+ fi
86
+ if [ -z "$conda_bin" ]; then
87
+ echo "conda executable not found for environment: $selector" >&2
88
+ exit 127
89
+ fi
90
+ case "$selector" in
91
+ */*) exec "$conda_bin" run --no-capture-output -p "$selector" "$@" ;;
92
+ *) exec "$conda_bin" run --no-capture-output -n "$selector" "$@" ;;
93
+ esac
94
+ """.strip()
95
+
96
+
97
+ def wrap_conda_command(command: list[str], selector: str) -> list[str]:
98
+ """Run argv in a Conda env even when conda is absent from non-login PATH."""
99
+ return [
100
+ "sh",
101
+ "-c",
102
+ _CONDA_RUN_SCRIPT,
103
+ "msdev-conda",
104
+ selector,
105
+ *command,
106
+ ]
107
+
108
+
109
+ _CONDA_ACTIVATION_ENV_SCRIPT = """
110
+ selector=$1
111
+ shift
112
+ conda_bin=$(command -v conda 2>/dev/null || true)
113
+ if [ -z "$conda_bin" ] && [ "${selector#*/}" != "$selector" ]; then
114
+ candidate=$selector
115
+ while [ -n "$candidate" ] && [ "$candidate" != "/" ]; do
116
+ if [ -x "$candidate/bin/conda" ]; then
117
+ conda_bin=$candidate/bin/conda
118
+ break
119
+ fi
120
+ parent=$(dirname -- "$candidate")
121
+ [ "$parent" = "$candidate" ] && break
122
+ candidate=$parent
123
+ done
124
+ fi
125
+ if [ -z "$conda_bin" ]; then
126
+ for candidate in \
127
+ "$HOME/miniconda3/bin/conda" \
128
+ "$HOME/anaconda3/bin/conda" \
129
+ "$HOME/miniforge3/bin/conda"; do
130
+ if [ -x "$candidate" ]; then
131
+ conda_bin=$candidate
132
+ break
133
+ fi
134
+ done
135
+ fi
136
+ if [ -z "$conda_bin" ]; then
137
+ echo "conda executable not found for environment: $selector" >&2
138
+ exit 127
139
+ fi
140
+ resolved=$(readlink -f "$conda_bin" 2>/dev/null || printf '%s' "$conda_bin")
141
+ conda_base=$(dirname -- "$(dirname -- "$resolved")")
142
+ conda_sh=$conda_base/etc/profile.d/conda.sh
143
+ if [ ! -r "$conda_sh" ]; then
144
+ echo "conda activation script not found: $conda_sh" >&2
145
+ exit 127
146
+ fi
147
+ exec 3>&1
148
+ exec 1>&2
149
+ . "$conda_sh"
150
+ conda activate "$selector"
151
+ exec 1>&3
152
+ exec "$@"
153
+ """.strip()
154
+
155
+
156
+ def wrap_conda_activation_command(
157
+ command: list[str],
158
+ selector: str,
159
+ ) -> list[str]:
160
+ """Activate Conda through shell functions, then execute argv."""
161
+ return [
162
+ "bash",
163
+ "-c",
164
+ _CONDA_ACTIVATION_ENV_SCRIPT,
165
+ "msdev-conda-activate",
166
+ selector,
167
+ *command,
168
+ ]
169
+
170
+
171
+ def config_home() -> Path:
172
+ override = os.environ.get("MSDEV_CONFIG_HOME")
173
+ if override:
174
+ return Path(override).expanduser()
175
+ base = os.environ.get("XDG_CONFIG_HOME")
176
+ return Path(base).expanduser() / "msdev" if base else Path.home() / ".config" / "msdev"
177
+
178
+
179
+ def data_home() -> Path:
180
+ override = os.environ.get("MSDEV_DATA_HOME")
181
+ if override:
182
+ return Path(override).expanduser()
183
+ base = os.environ.get("XDG_DATA_HOME")
184
+ return Path(base).expanduser() / "msdevd" if base else Path.home() / ".local" / "share" / "msdevd"
185
+
186
+
187
+ def default_socket_path() -> Path:
188
+ override = os.environ.get("MSDEVD_SOCKET")
189
+ if override:
190
+ return Path(override).expanduser()
191
+ # SSH sessions frequently lack XDG_RUNTIME_DIR while systemd user services
192
+ # set it to /run/user/<uid>. A stable per-user path keeps both sides aligned.
193
+ return data_home() / "msdevd.sock"
msdev/core/guides.py ADDED
@@ -0,0 +1,100 @@
1
+ """Private local Markdown guides for nodes and execution environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from urllib.parse import quote
9
+
10
+ from .config import config_home
11
+
12
+
13
+ MAX_GUIDE_BYTES = 256 * 1024
14
+
15
+
16
+ class ResourceGuideStore:
17
+ def __init__(self, scope: str, root: Path | None = None):
18
+ if scope not in {"nodes", "environments"}:
19
+ raise ValueError(f"unsupported guide scope: {scope}")
20
+ self.root = root or config_home() / "guides" / scope
21
+
22
+ @staticmethod
23
+ def _filename(resource: str) -> str:
24
+ if not isinstance(resource, str) or not resource.strip() or "\0" in resource:
25
+ raise ValueError("resource name must be a non-empty string")
26
+ return f"{quote(resource, safe='')}.md"
27
+
28
+ def path(self, resource: str) -> Path:
29
+ return self.root / self._filename(resource)
30
+
31
+ def read(self, resource: str) -> str | None:
32
+ path = self.path(resource)
33
+ try:
34
+ if path.is_symlink():
35
+ raise ValueError(f"guide must not be a symlink: {path}")
36
+ with path.open("rb") as handle:
37
+ raw = handle.read(MAX_GUIDE_BYTES + 1)
38
+ except FileNotFoundError:
39
+ return None
40
+ if len(raw) > MAX_GUIDE_BYTES:
41
+ raise ValueError(
42
+ f"guide exceeds {MAX_GUIDE_BYTES} bytes: {path}"
43
+ )
44
+ return raw.decode("utf-8")
45
+
46
+ def write_bytes(self, resource: str, raw: bytes) -> Path:
47
+ if len(raw) > MAX_GUIDE_BYTES:
48
+ raise ValueError(
49
+ f"guide exceeds {MAX_GUIDE_BYTES} bytes"
50
+ )
51
+ return self.write(resource, raw.decode("utf-8"))
52
+
53
+ def write(self, resource: str, content: str) -> Path:
54
+ raw = content.encode("utf-8")
55
+ if len(raw) > MAX_GUIDE_BYTES:
56
+ raise ValueError(
57
+ f"guide exceeds {MAX_GUIDE_BYTES} bytes"
58
+ )
59
+ path = self.path(resource)
60
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
61
+ if os.name == "posix":
62
+ os.chmod(self.root, 0o700)
63
+ descriptor, temporary = tempfile.mkstemp(
64
+ prefix=f".{path.name}.",
65
+ dir=self.root,
66
+ )
67
+ try:
68
+ if os.name == "posix":
69
+ os.fchmod(descriptor, 0o600)
70
+ with os.fdopen(descriptor, "wb") as handle:
71
+ handle.write(raw)
72
+ handle.flush()
73
+ os.fsync(handle.fileno())
74
+ os.replace(temporary, path)
75
+ if os.name == "posix":
76
+ os.chmod(path, 0o600)
77
+ return path
78
+ finally:
79
+ try:
80
+ os.unlink(temporary)
81
+ except FileNotFoundError:
82
+ pass
83
+
84
+ def delete(self, resource: str) -> bool:
85
+ path = self.path(resource)
86
+ try:
87
+ path.unlink()
88
+ return True
89
+ except FileNotFoundError:
90
+ return False
91
+
92
+
93
+ class NodeGuideStore(ResourceGuideStore):
94
+ def __init__(self, root: Path | None = None):
95
+ super().__init__("nodes", root)
96
+
97
+
98
+ class EnvironmentGuideStore(ResourceGuideStore):
99
+ def __init__(self, root: Path | None = None):
100
+ super().__init__("environments", root)