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
|
@@ -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)
|