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.
@@ -0,0 +1,274 @@
1
+ """Client-side RPC mapping for registered workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import glob as globlib
8
+ from collections.abc import Callable, Sequence
9
+ from typing import Any
10
+
11
+ from ..resources import EnvironmentStore, NodeStore
12
+ from ..transport import RpcTransport, SshRpcTransport, UnixRpcTransport
13
+ from .paths import ResolvedPath, normalize_relative
14
+ from .registry import Workspace
15
+
16
+
17
+ WorkspaceTransportFactory = Callable[[Workspace], RpcTransport]
18
+
19
+
20
+ def transport_for_workspace(
21
+ workspace: Workspace,
22
+ *,
23
+ environment_store: EnvironmentStore | None = None,
24
+ ) -> RpcTransport:
25
+ """Select the daemon transport declared by a workspace."""
26
+ if workspace.environment == "local":
27
+ return UnixRpcTransport()
28
+ environments = environment_store or EnvironmentStore()
29
+ environment = environments.get(workspace.environment)
30
+ node = NodeStore(environments.path).get(environment.node)
31
+ return SshRpcTransport(node, environment)
32
+
33
+
34
+ def workspace_params(workspace: Workspace, **values: Any) -> dict[str, Any]:
35
+ """Build daemon workspace parameters, omitting optional ``None`` values."""
36
+ params: dict[str, Any] = {"root": workspace.root}
37
+ params.update({key: value for key, value in values.items() if value is not None})
38
+ return params
39
+
40
+
41
+ def _call_workspace(
42
+ workspace: Workspace,
43
+ method: str,
44
+ params: dict[str, Any],
45
+ *,
46
+ environment_store: EnvironmentStore | None,
47
+ create_transport: WorkspaceTransportFactory | None,
48
+ ) -> Any:
49
+ transport = (
50
+ create_transport(workspace)
51
+ if create_transport is not None
52
+ else transport_for_workspace(
53
+ workspace,
54
+ environment_store=environment_store,
55
+ )
56
+ )
57
+ return transport.call(method, params)
58
+
59
+
60
+ def _call(
61
+ resolved: ResolvedPath,
62
+ method: str,
63
+ params: dict[str, Any],
64
+ *,
65
+ environment_store: EnvironmentStore | None,
66
+ create_transport: WorkspaceTransportFactory | None,
67
+ ) -> Any:
68
+ return _call_workspace(
69
+ resolved.workspace,
70
+ method,
71
+ params,
72
+ environment_store=environment_store,
73
+ create_transport=create_transport,
74
+ )
75
+
76
+
77
+ def stat_path(
78
+ resolved: ResolvedPath,
79
+ *,
80
+ environment_store: EnvironmentStore | None = None,
81
+ create_transport: WorkspaceTransportFactory | None = None,
82
+ ) -> dict[str, Any]:
83
+ return _call(
84
+ resolved,
85
+ "workspace.stat",
86
+ workspace_params(resolved.workspace, path=resolved.relative),
87
+ environment_store=environment_store,
88
+ create_transport=create_transport,
89
+ )
90
+
91
+
92
+ def read_file(
93
+ resolved: ResolvedPath,
94
+ *,
95
+ max_bytes: int = 1024 * 1024,
96
+ environment_store: EnvironmentStore | None = None,
97
+ create_transport: WorkspaceTransportFactory | None = None,
98
+ ) -> tuple[bytes, dict[str, Any]]:
99
+ result = _call(
100
+ resolved,
101
+ "workspace.read",
102
+ workspace_params(
103
+ resolved.workspace,
104
+ path=resolved.relative,
105
+ max_bytes=max_bytes,
106
+ ),
107
+ environment_store=environment_store,
108
+ create_transport=create_transport,
109
+ )
110
+ try:
111
+ encoded = result["content_base64"]
112
+ except (KeyError, TypeError) as exc:
113
+ raise ValueError("workspace.read returned no base64 content") from exc
114
+ if not isinstance(encoded, str):
115
+ raise ValueError("workspace.read returned non-string base64 content")
116
+ try:
117
+ content = base64.b64decode(encoded, validate=True)
118
+ except (binascii.Error, ValueError) as exc:
119
+ raise ValueError("workspace.read returned invalid base64 content") from exc
120
+ if base64.b64encode(content).decode("ascii") != encoded:
121
+ raise ValueError("workspace.read returned non-canonical base64 content")
122
+ return content, dict(result)
123
+
124
+
125
+ def list_directory(
126
+ resolved: ResolvedPath,
127
+ *,
128
+ environment_store: EnvironmentStore | None = None,
129
+ create_transport: WorkspaceTransportFactory | None = None,
130
+ ) -> dict[str, Any]:
131
+ return _call(
132
+ resolved,
133
+ "workspace.list",
134
+ workspace_params(resolved.workspace, path=resolved.relative),
135
+ environment_store=environment_store,
136
+ create_transport=create_transport,
137
+ )
138
+
139
+
140
+ def _scoped_glob(relative: str, pattern: str) -> str:
141
+ normalized_pattern = normalize_relative(pattern)
142
+ if relative == ".":
143
+ return normalized_pattern
144
+ if normalized_pattern == ".":
145
+ return globlib.escape(relative)
146
+ return f"{globlib.escape(relative)}/{normalized_pattern}"
147
+
148
+
149
+ def glob_result(
150
+ resolved: ResolvedPath,
151
+ pattern: str,
152
+ *,
153
+ max_results: int = 1000,
154
+ environment_store: EnvironmentStore | None = None,
155
+ create_transport: WorkspaceTransportFactory | None = None,
156
+ ) -> dict[str, Any]:
157
+ result = _call(
158
+ resolved,
159
+ "workspace.glob",
160
+ workspace_params(
161
+ resolved.workspace,
162
+ pattern=_scoped_glob(resolved.relative, pattern),
163
+ max_results=max_results,
164
+ ),
165
+ environment_store=environment_store,
166
+ create_transport=create_transport,
167
+ )
168
+ matches = result.get("matches") if isinstance(result, dict) else None
169
+ if not isinstance(matches, list):
170
+ raise ValueError("workspace.glob returned invalid matches")
171
+ for item in matches:
172
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str):
173
+ raise ValueError("workspace.glob returned an invalid match")
174
+ return dict(result)
175
+
176
+
177
+ def search_workspace(
178
+ workspace: Workspace,
179
+ pattern: str,
180
+ *,
181
+ paths: Sequence[str],
182
+ glob: str | None = None,
183
+ max_results: int = 1000,
184
+ environment_store: EnvironmentStore | None = None,
185
+ create_transport: WorkspaceTransportFactory | None = None,
186
+ ) -> dict[str, Any]:
187
+ if isinstance(paths, (str, bytes)) or not isinstance(paths, Sequence):
188
+ raise ValueError("search paths must be a string array")
189
+ normalized_paths = [normalize_relative(path) for path in paths]
190
+ result = _call_workspace(
191
+ workspace,
192
+ "workspace.search",
193
+ workspace_params(
194
+ workspace,
195
+ pattern=pattern,
196
+ paths=normalized_paths,
197
+ glob=glob,
198
+ max_results=max_results,
199
+ ),
200
+ environment_store=environment_store,
201
+ create_transport=create_transport,
202
+ )
203
+ matches = result.get("matches") if isinstance(result, dict) else None
204
+ if not isinstance(matches, list) or not all(
205
+ isinstance(item, dict) for item in matches
206
+ ):
207
+ raise ValueError("workspace.search returned invalid matches")
208
+ return dict(result)
209
+
210
+
211
+ def write_bytes(
212
+ resolved: ResolvedPath,
213
+ content: bytes,
214
+ *,
215
+ environment_store: EnvironmentStore | None = None,
216
+ expected_sha256: str | None = None,
217
+ create_transport: WorkspaceTransportFactory | None = None,
218
+ ) -> dict[str, Any]:
219
+ if not isinstance(content, bytes):
220
+ raise TypeError("workspace content must be bytes")
221
+ return _call(
222
+ resolved,
223
+ "workspace.write",
224
+ workspace_params(
225
+ resolved.workspace,
226
+ path=resolved.relative,
227
+ content_base64=base64.b64encode(content).decode("ascii"),
228
+ expected_sha256=expected_sha256,
229
+ ),
230
+ environment_store=environment_store,
231
+ create_transport=create_transport,
232
+ )
233
+
234
+
235
+ def delete_path(
236
+ resolved: ResolvedPath,
237
+ *,
238
+ environment_store: EnvironmentStore | None = None,
239
+ create_transport: WorkspaceTransportFactory | None = None,
240
+ ) -> dict[str, Any]:
241
+ return _call(
242
+ resolved,
243
+ "workspace.delete",
244
+ workspace_params(resolved.workspace, path=resolved.relative),
245
+ environment_store=environment_store,
246
+ create_transport=create_transport,
247
+ )
248
+
249
+
250
+ def git_workspace(
251
+ workspace: Workspace,
252
+ operation: str,
253
+ argv: Sequence[str] = (),
254
+ *,
255
+ execution_root: str,
256
+ environment_store: EnvironmentStore | None = None,
257
+ create_transport: WorkspaceTransportFactory | None = None,
258
+ ) -> dict[str, Any]:
259
+ if operation not in {"status", "diff"}:
260
+ raise ValueError(f"unsupported workspace Git operation: {operation}")
261
+ result = _call_workspace(
262
+ workspace,
263
+ f"workspace.git_{operation}",
264
+ workspace_params(
265
+ workspace,
266
+ execution_root=execution_root,
267
+ argv=list(argv),
268
+ ),
269
+ environment_store=environment_store,
270
+ create_transport=create_transport,
271
+ )
272
+ if not isinstance(result, dict):
273
+ raise ValueError("workspace Git operation returned a non-object result")
274
+ return dict(result)
@@ -0,0 +1,45 @@
1
+ """Portable workspace-path parsing and normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from .registry import Workspace
9
+
10
+
11
+ _WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:($|/)")
12
+
13
+
14
+ def normalize_relative(path: str) -> str:
15
+ """Return a normalized workspace-relative POSIX path."""
16
+ if not isinstance(path, str):
17
+ raise ValueError("workspace path must be a string")
18
+ if "\0" in path:
19
+ raise ValueError("workspace path must not contain NUL")
20
+
21
+ portable = path.replace("\\", "/")
22
+ if portable.startswith("/") or _WINDOWS_ABSOLUTE_RE.match(portable):
23
+ raise ValueError(f"workspace path must be relative: {path!r}")
24
+
25
+ parts: list[str] = []
26
+ for part in portable.split("/"):
27
+ if part in {"", "."}:
28
+ continue
29
+ if part == "..":
30
+ raise ValueError(f"workspace path traversal is not allowed: {path!r}")
31
+ parts.append(part)
32
+ return "/".join(parts) or "."
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ResolvedPath:
37
+ """A normalized relative path paired with its registered workspace."""
38
+
39
+ workspace: Workspace
40
+ relative: str
41
+
42
+ def __post_init__(self) -> None:
43
+ if not isinstance(self.workspace, Workspace):
44
+ raise TypeError("workspace must be a Workspace")
45
+ object.__setattr__(self, "relative", normalize_relative(self.relative))
@@ -0,0 +1,151 @@
1
+ """Local registry for named execution workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..config import _exclusive_file_lock, config_home
13
+
14
+
15
+ WORKSPACE_CONFIG_VERSION = 2
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Workspace:
20
+ """A named root in a local or remote msdev environment."""
21
+
22
+ name: str
23
+ environment: str
24
+ root: str
25
+ execution_root: str | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ for field_name in ("name", "environment", "root"):
29
+ value = getattr(self, field_name)
30
+ if not isinstance(value, str) or not value.strip():
31
+ raise ValueError(f"workspace {field_name} must be a non-empty string")
32
+ if "\0" in value:
33
+ raise ValueError(f"workspace {field_name} must not contain NUL")
34
+ if self.execution_root is not None:
35
+ if (
36
+ not isinstance(self.execution_root, str)
37
+ or not self.execution_root.strip()
38
+ ):
39
+ raise ValueError(
40
+ "workspace execution_root must be a non-empty string or None"
41
+ )
42
+ if "\0" in self.execution_root:
43
+ raise ValueError("workspace execution_root must not contain NUL")
44
+
45
+
46
+ class WorkspaceStore:
47
+ """Atomic, versioned JSON-backed workspace registry."""
48
+
49
+ def __init__(self, path: Path | None = None):
50
+ self.path = path or config_home() / "workspaces.json"
51
+ self.lock_path = self.path.with_name(f".{self.path.name}.lock")
52
+
53
+ @staticmethod
54
+ def _empty() -> dict[str, Any]:
55
+ return {"version": WORKSPACE_CONFIG_VERSION, "workspaces": {}}
56
+
57
+ def load(self) -> dict[str, Any]:
58
+ if not self.path.exists():
59
+ return self._empty()
60
+ try:
61
+ value = json.loads(self.path.read_text(encoding="utf-8"))
62
+ except (OSError, json.JSONDecodeError) as exc:
63
+ raise ValueError(f"invalid workspace config {self.path}: {exc}") from exc
64
+ if not isinstance(value, dict):
65
+ raise ValueError(f"unsupported workspace config schema in {self.path}")
66
+ if "active" in value:
67
+ raise ValueError(
68
+ "global active workspace selection is unsupported; "
69
+ "remove the 'active' field and name the workspace explicitly"
70
+ )
71
+ if (
72
+ value.get("version") != WORKSPACE_CONFIG_VERSION
73
+ or not isinstance(value.get("workspaces"), dict)
74
+ ):
75
+ raise ValueError(f"unsupported workspace config schema in {self.path}")
76
+
77
+ normalized: dict[str, dict[str, Any]] = {}
78
+ for name, raw in value["workspaces"].items():
79
+ if not isinstance(name, str) or not isinstance(raw, dict):
80
+ raise ValueError(f"invalid workspace entry in {self.path}")
81
+ try:
82
+ workspace = Workspace(**raw)
83
+ except (TypeError, ValueError) as exc:
84
+ raise ValueError(
85
+ f"invalid workspace entry {name!r} in {self.path}: {exc}"
86
+ ) from exc
87
+ if workspace.name != name:
88
+ raise ValueError(
89
+ f"workspace key {name!r} does not match entry name "
90
+ f"{workspace.name!r}"
91
+ )
92
+ normalized[name] = asdict(workspace)
93
+ return {
94
+ "version": WORKSPACE_CONFIG_VERSION,
95
+ "workspaces": normalized,
96
+ }
97
+
98
+ def _save(self, value: dict[str, Any]) -> None:
99
+ self.path.parent.mkdir(parents=True, exist_ok=True)
100
+ fd, temporary_name = tempfile.mkstemp(
101
+ prefix=".workspaces-",
102
+ suffix=".json",
103
+ dir=self.path.parent,
104
+ )
105
+ try:
106
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
107
+ json.dump(
108
+ value,
109
+ handle,
110
+ ensure_ascii=False,
111
+ indent=2,
112
+ sort_keys=True,
113
+ )
114
+ handle.write("\n")
115
+ handle.flush()
116
+ os.fsync(handle.fileno())
117
+ os.replace(temporary_name, self.path)
118
+ finally:
119
+ try:
120
+ os.unlink(temporary_name)
121
+ except FileNotFoundError:
122
+ pass
123
+
124
+ def add(self, workspace: Workspace, *, force: bool = False) -> None:
125
+ with _exclusive_file_lock(self.lock_path):
126
+ value = self.load()
127
+ if workspace.name in value["workspaces"] and not force:
128
+ raise ValueError(
129
+ f"workspace {workspace.name!r} already exists; "
130
+ "use a different name or pass --force"
131
+ )
132
+ value["workspaces"][workspace.name] = asdict(workspace)
133
+ self._save(value)
134
+
135
+ def get(self, name: str) -> Workspace:
136
+ raw = self.load()["workspaces"].get(name)
137
+ if raw is None:
138
+ raise KeyError(f"unknown workspace: {name}")
139
+ return Workspace(**raw)
140
+
141
+ def list(self) -> list[Workspace]:
142
+ values = self.load()["workspaces"]
143
+ return [Workspace(**values[name]) for name in sorted(values)]
144
+
145
+ def remove(self, name: str) -> None:
146
+ with _exclusive_file_lock(self.lock_path):
147
+ value = self.load()
148
+ if name not in value["workspaces"]:
149
+ raise KeyError(f"unknown workspace: {name}")
150
+ del value["workspaces"][name]
151
+ self._save(value)