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,567 @@
1
+ """Workspace registry, remote file, and Git application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Any, Sequence
9
+
10
+ from ..workspace import ResolvedPath, Workspace, normalize_relative
11
+ from ..workspace import client as workspace_client
12
+ from .context import ServiceContext
13
+
14
+
15
+ _MAX_READ_BYTES = 16 * 1024 * 1024
16
+ _MAX_RESULTS = 10_000
17
+ _DIGEST_RE = re.compile(r"^[0-9a-fA-F]{64}$")
18
+ _SAFE_GIT_DIFF_OPTIONS = ("--no-textconv", "--no-ext-diff")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WorkspaceAddRequest:
23
+ name: str
24
+ environment: str
25
+ root: str
26
+ execution_root: str | None = None
27
+ force: bool = False
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class WorkspaceListResult:
32
+ workspaces: tuple[Workspace, ...]
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {"workspaces": [asdict(item) for item in self.workspaces]}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class WorkspaceInspectResult:
40
+ workspace: Workspace
41
+ effective_execution_root: str
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return {
45
+ **asdict(self.workspace),
46
+ "effective_execution_root": self.effective_execution_root,
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class WorkspaceRemoveResult:
52
+ name: str
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {"workspace": self.name, "removed": True}
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class WorkspacePathResult:
60
+ workspace: str
61
+ path: str
62
+ raw: dict[str, Any] = field(repr=False)
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ value = dict(self.raw)
66
+ value["workspace"] = self.workspace
67
+ return value
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class WorkspaceReadResult:
72
+ workspace: str
73
+ path: str
74
+ content: bytes = field(repr=False)
75
+ raw: dict[str, Any] = field(repr=False)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ try:
79
+ rendered = self.content.decode("utf-8")
80
+ encoding = "utf-8"
81
+ except UnicodeDecodeError:
82
+ rendered = base64.b64encode(self.content).decode("ascii")
83
+ encoding = "base64"
84
+ return {
85
+ "workspace": self.workspace,
86
+ "path": self.path,
87
+ "encoding": encoding,
88
+ "size": len(self.content),
89
+ "content": rendered,
90
+ }
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class WorkspaceGlobResult:
95
+ workspace: str
96
+ path: str
97
+ pattern: str
98
+ matches: tuple[str, ...]
99
+ raw: dict[str, Any] = field(repr=False)
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ return {
103
+ "workspace": self.workspace,
104
+ "path": self.path,
105
+ "pattern": self.pattern,
106
+ "matches": list(self.matches),
107
+ }
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class WorkspaceSearchResult:
112
+ workspace: str
113
+ paths: tuple[str, ...]
114
+ pattern: str
115
+ matches: tuple[dict[str, Any], ...]
116
+ raw: dict[str, Any] = field(repr=False)
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ value: dict[str, Any] = {
120
+ "workspace": self.workspace,
121
+ "pattern": self.pattern,
122
+ "matches": [dict(item) for item in self.matches],
123
+ }
124
+ if len(self.paths) == 1:
125
+ value["path"] = self.paths[0]
126
+ else:
127
+ value["paths"] = list(self.paths)
128
+ return value
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class WorkspaceGitResult:
133
+ workspace: str
134
+ returncode: int
135
+ stdout: str
136
+ stderr: str
137
+ stdout_truncated: bool = False
138
+ stderr_truncated: bool = False
139
+ output_drain_truncated: bool = False
140
+ timed_out: bool = False
141
+ raw: dict[str, Any] = field(default_factory=dict, repr=False)
142
+
143
+ def to_dict(self) -> dict[str, Any]:
144
+ value = dict(self.raw)
145
+ value["workspace"] = self.workspace
146
+ return value
147
+
148
+
149
+ class WorkspaceService:
150
+ """Stateless workspace orchestration for the msdev CLI."""
151
+
152
+ def __init__(self, context: ServiceContext):
153
+ self.context = context
154
+
155
+ @staticmethod
156
+ def _required(value: Any, label: str) -> str:
157
+ if not isinstance(value, str) or not value.strip():
158
+ raise ValueError(f"{label} must be a non-empty string")
159
+ if "\0" in value:
160
+ raise ValueError(f"{label} must not contain NUL")
161
+ return value
162
+
163
+ @staticmethod
164
+ def _text(value: Any, label: str) -> str:
165
+ if not isinstance(value, str):
166
+ raise ValueError(f"{label} must be a string")
167
+ if "\0" in value:
168
+ raise ValueError(f"{label} must not contain NUL")
169
+ return value
170
+
171
+ @staticmethod
172
+ def _boolean(value: Any, label: str) -> bool:
173
+ if type(value) is not bool:
174
+ raise ValueError(f"{label} must be a boolean")
175
+ return value
176
+
177
+ @staticmethod
178
+ def _limit(value: Any, label: str, maximum: int) -> int:
179
+ if isinstance(value, bool) or not isinstance(value, int):
180
+ raise ValueError(f"{label} must be an integer")
181
+ if not 1 <= value <= maximum:
182
+ raise ValueError(f"{label} must be between 1 and {maximum}")
183
+ return value
184
+
185
+ @staticmethod
186
+ def _object(value: Any, operation: str) -> dict[str, Any]:
187
+ if not isinstance(value, dict):
188
+ raise RuntimeError(f"{operation} returned a non-object result")
189
+ return dict(value)
190
+
191
+ def _workspace(self, name: Any) -> Workspace:
192
+ return self.context.workspace_store.get(
193
+ self._required(name, "workspace name")
194
+ )
195
+
196
+ def _resolved(self, name: Any, path: Any = ".") -> ResolvedPath:
197
+ workspace = self._workspace(name)
198
+ return ResolvedPath(
199
+ workspace=workspace,
200
+ relative=normalize_relative(path),
201
+ )
202
+
203
+ def _create_transport(self, workspace: Workspace) -> Any:
204
+ return self.context.transport_for_environment(workspace.environment)
205
+
206
+ def add(self, request: WorkspaceAddRequest) -> Workspace:
207
+ if not isinstance(request, WorkspaceAddRequest):
208
+ raise TypeError("workspace add request must be WorkspaceAddRequest")
209
+ force = self._boolean(request.force, "force")
210
+ environment = self._required(
211
+ request.environment,
212
+ "workspace environment",
213
+ )
214
+ self.context.environment_store.get(environment)
215
+ workspace = Workspace(
216
+ name=self._required(request.name, "workspace name"),
217
+ environment=environment,
218
+ root=self._required(request.root, "workspace root"),
219
+ execution_root=request.execution_root,
220
+ )
221
+ self.context.workspace_store.add(workspace, force=force)
222
+ return workspace
223
+
224
+ def list(self) -> WorkspaceListResult:
225
+ return WorkspaceListResult(tuple(self.context.workspace_store.list()))
226
+
227
+ def inspect(self, name: Any) -> WorkspaceInspectResult:
228
+ workspace = self._workspace(name)
229
+ return WorkspaceInspectResult(
230
+ workspace,
231
+ workspace.execution_root or workspace.root,
232
+ )
233
+
234
+ def remove(self, name: Any) -> WorkspaceRemoveResult:
235
+ workspace_name = self._required(name, "workspace name")
236
+ self.context.workspace_store.remove(workspace_name)
237
+ return WorkspaceRemoveResult(workspace_name)
238
+
239
+ def stat(self, name: Any, path: Any = ".") -> WorkspacePathResult:
240
+ resolved = self._resolved(name, path)
241
+ raw = self._object(
242
+ workspace_client.stat_path(
243
+ resolved,
244
+ create_transport=self._create_transport,
245
+ ),
246
+ "workspace.stat",
247
+ )
248
+ return WorkspacePathResult(
249
+ resolved.workspace.name,
250
+ resolved.relative,
251
+ raw,
252
+ )
253
+
254
+ def read(
255
+ self,
256
+ name: Any,
257
+ path: Any,
258
+ *,
259
+ max_bytes: Any = 1024 * 1024,
260
+ ) -> WorkspaceReadResult:
261
+ resolved = self._resolved(name, path)
262
+ limit = self._limit(max_bytes, "max_bytes", _MAX_READ_BYTES)
263
+ content, raw = workspace_client.read_file(
264
+ resolved,
265
+ max_bytes=limit,
266
+ create_transport=self._create_transport,
267
+ )
268
+ return WorkspaceReadResult(
269
+ resolved.workspace.name,
270
+ resolved.relative,
271
+ content,
272
+ raw,
273
+ )
274
+
275
+ def list_directory(
276
+ self,
277
+ name: Any,
278
+ path: Any = ".",
279
+ ) -> WorkspacePathResult:
280
+ resolved = self._resolved(name, path)
281
+ raw = self._object(
282
+ workspace_client.list_directory(
283
+ resolved,
284
+ create_transport=self._create_transport,
285
+ ),
286
+ "workspace.list",
287
+ )
288
+ return WorkspacePathResult(
289
+ resolved.workspace.name,
290
+ resolved.relative,
291
+ raw,
292
+ )
293
+
294
+ def glob(
295
+ self,
296
+ name: Any,
297
+ pattern: Any,
298
+ *,
299
+ path: Any = ".",
300
+ max_results: Any = 1000,
301
+ ) -> WorkspaceGlobResult:
302
+ resolved = self._resolved(name, path)
303
+ requested_pattern = self._text(pattern, "glob pattern")
304
+ limit = self._limit(max_results, "max_results", _MAX_RESULTS)
305
+ raw = self._object(
306
+ workspace_client.glob_result(
307
+ resolved,
308
+ requested_pattern,
309
+ max_results=limit,
310
+ create_transport=self._create_transport,
311
+ ),
312
+ "workspace.glob",
313
+ )
314
+ raw_matches = raw.get("matches")
315
+ if not isinstance(raw_matches, list):
316
+ raise ValueError("workspace.glob returned invalid matches")
317
+ matches: list[str] = []
318
+ for item in raw_matches:
319
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str):
320
+ raise ValueError("workspace.glob returned an invalid match")
321
+ matches.append(item["path"])
322
+ return WorkspaceGlobResult(
323
+ resolved.workspace.name,
324
+ resolved.relative,
325
+ requested_pattern,
326
+ tuple(matches),
327
+ raw,
328
+ )
329
+
330
+ def search(
331
+ self,
332
+ name: Any,
333
+ pattern: Any,
334
+ *,
335
+ paths: Sequence[str] = (".",),
336
+ glob: str | None = None,
337
+ max_results: Any = 1000,
338
+ ) -> WorkspaceSearchResult:
339
+ workspace = self._workspace(name)
340
+ search_pattern = self._text(pattern, "search pattern")
341
+ if (
342
+ isinstance(paths, (str, bytes))
343
+ or not isinstance(paths, (list, tuple))
344
+ or not paths
345
+ or not all(isinstance(item, str) for item in paths)
346
+ ):
347
+ raise ValueError("search paths must be a non-empty string array")
348
+ normalized_paths = tuple(normalize_relative(item) for item in paths)
349
+ glob_filter = normalize_relative(glob) if glob is not None else None
350
+ limit = self._limit(max_results, "max_results", _MAX_RESULTS)
351
+ raw = self._object(
352
+ workspace_client.search_workspace(
353
+ workspace,
354
+ search_pattern,
355
+ paths=normalized_paths,
356
+ glob=glob_filter,
357
+ max_results=limit,
358
+ create_transport=self._create_transport,
359
+ ),
360
+ "workspace.search",
361
+ )
362
+ raw_matches = raw.get("matches")
363
+ if not isinstance(raw_matches, list) or not all(
364
+ isinstance(item, dict) for item in raw_matches
365
+ ):
366
+ raise ValueError("workspace.search returned invalid matches")
367
+ return WorkspaceSearchResult(
368
+ workspace.name,
369
+ normalized_paths,
370
+ search_pattern,
371
+ tuple(dict(item) for item in raw_matches),
372
+ raw,
373
+ )
374
+
375
+ @staticmethod
376
+ def _content(value: Any) -> bytes:
377
+ if not isinstance(value, bytes):
378
+ raise TypeError("workspace content must be bytes")
379
+ return value
380
+
381
+ @staticmethod
382
+ def _digest(value: Any, *, required: bool) -> str | None:
383
+ if value is None and not required:
384
+ return None
385
+ if not isinstance(value, str) or not _DIGEST_RE.fullmatch(value):
386
+ raise ValueError(
387
+ "expected_sha256 must be a 64-character hexadecimal digest"
388
+ )
389
+ return value.lower()
390
+
391
+ def _write(
392
+ self,
393
+ name: Any,
394
+ path: Any,
395
+ content: Any,
396
+ *,
397
+ expected_sha256: Any,
398
+ digest_required: bool,
399
+ ) -> WorkspacePathResult:
400
+ resolved = self._resolved(name, path)
401
+ payload = self._content(content)
402
+ digest = self._digest(expected_sha256, required=digest_required)
403
+ raw = self._object(
404
+ workspace_client.write_bytes(
405
+ resolved,
406
+ payload,
407
+ expected_sha256=digest,
408
+ create_transport=self._create_transport,
409
+ ),
410
+ "workspace.write",
411
+ )
412
+ return WorkspacePathResult(
413
+ resolved.workspace.name,
414
+ resolved.relative,
415
+ raw,
416
+ )
417
+
418
+ def write(
419
+ self,
420
+ name: Any,
421
+ path: Any,
422
+ content: Any,
423
+ *,
424
+ expected_sha256: Any = None,
425
+ ) -> WorkspacePathResult:
426
+ return self._write(
427
+ name,
428
+ path,
429
+ content,
430
+ expected_sha256=expected_sha256,
431
+ digest_required=False,
432
+ )
433
+
434
+ def apply_patch(
435
+ self,
436
+ name: Any,
437
+ path: Any,
438
+ content: Any,
439
+ *,
440
+ expected_sha256: Any,
441
+ ) -> WorkspacePathResult:
442
+ return self._write(
443
+ name,
444
+ path,
445
+ content,
446
+ expected_sha256=expected_sha256,
447
+ digest_required=True,
448
+ )
449
+
450
+ def delete(self, name: Any, path: Any) -> WorkspacePathResult:
451
+ resolved = self._resolved(name, path)
452
+ raw = self._object(
453
+ workspace_client.delete_path(
454
+ resolved,
455
+ create_transport=self._create_transport,
456
+ ),
457
+ "workspace.delete",
458
+ )
459
+ return WorkspacePathResult(
460
+ resolved.workspace.name,
461
+ resolved.relative,
462
+ raw,
463
+ )
464
+
465
+ @staticmethod
466
+ def _result_boolean(raw: dict[str, Any], key: str) -> bool:
467
+ value = raw.get(key, False)
468
+ if type(value) is not bool:
469
+ raise RuntimeError(f"workspace Git result {key} must be a boolean")
470
+ return value
471
+
472
+ def _git_result(
473
+ self,
474
+ name: Any,
475
+ operation: str,
476
+ argv: tuple[str, ...] = (),
477
+ ) -> WorkspaceGitResult:
478
+ workspace = self._workspace(name)
479
+ raw = self._object(
480
+ workspace_client.git_workspace(
481
+ workspace,
482
+ operation,
483
+ argv,
484
+ execution_root=workspace.execution_root or workspace.root,
485
+ create_transport=self._create_transport,
486
+ ),
487
+ f"workspace.git-{operation}",
488
+ )
489
+ returncode = raw.get("returncode")
490
+ if isinstance(returncode, bool) or not isinstance(returncode, int):
491
+ raise RuntimeError("workspace Git result is missing integer returncode")
492
+ stdout = raw.get("stdout", "")
493
+ stderr = raw.get("stderr", "")
494
+ if not isinstance(stdout, str) or not isinstance(stderr, str):
495
+ raise RuntimeError("workspace Git stdout and stderr must be strings")
496
+ return WorkspaceGitResult(
497
+ workspace=workspace.name,
498
+ returncode=returncode,
499
+ stdout=stdout,
500
+ stderr=stderr,
501
+ stdout_truncated=self._result_boolean(raw, "stdout_truncated"),
502
+ stderr_truncated=self._result_boolean(raw, "stderr_truncated"),
503
+ output_drain_truncated=self._result_boolean(
504
+ raw,
505
+ "output_drain_truncated",
506
+ ),
507
+ timed_out=self._result_boolean(raw, "timed_out"),
508
+ raw=raw,
509
+ )
510
+
511
+ def git_status(self, name: Any) -> WorkspaceGitResult:
512
+ return self._git_result(name, "status")
513
+
514
+ def git_diff(
515
+ self,
516
+ name: Any,
517
+ argv: Sequence[str] = (),
518
+ ) -> WorkspaceGitResult:
519
+ extra = self._git_diff_arguments(argv)
520
+ if extra[:1] == ("--",):
521
+ extra = extra[1:]
522
+ self._validate_safe_git_diff_arguments(extra)
523
+ return self._run_git_diff(name, extra)
524
+
525
+ @staticmethod
526
+ def _git_diff_arguments(argv: Any) -> tuple[str, ...]:
527
+ if (
528
+ isinstance(argv, (str, bytes))
529
+ or not isinstance(argv, (list, tuple))
530
+ or not all(isinstance(item, str) for item in argv)
531
+ ):
532
+ raise ValueError("git diff arguments must be a string array")
533
+ return tuple(argv)
534
+
535
+ @staticmethod
536
+ def _validate_safe_git_diff_arguments(argv: tuple[str, ...]) -> None:
537
+ prohibited = (
538
+ "--output",
539
+ "--ext-diff",
540
+ "--textconv",
541
+ "--no-index",
542
+ )
543
+ for argument in argv:
544
+ if argument == "--":
545
+ return
546
+ if argument == "-o" or argument.startswith("-o"):
547
+ raise ValueError(
548
+ f"git diff argument is not allowed: {argument}"
549
+ )
550
+ if any(
551
+ argument == option or argument.startswith(f"{option}=")
552
+ for option in prohibited
553
+ ):
554
+ raise ValueError(
555
+ f"git diff argument is not allowed: {argument}"
556
+ )
557
+
558
+ def _run_git_diff(
559
+ self,
560
+ name: Any,
561
+ extra: tuple[str, ...],
562
+ ) -> WorkspaceGitResult:
563
+ return self._git_result(
564
+ name,
565
+ "diff",
566
+ (*_SAFE_GIT_DIFF_OPTIONS, *extra),
567
+ )