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/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
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)
|