steersync 0.2.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.
- steersync/__init__.py +5 -0
- steersync/__main__.py +6 -0
- steersync/cli.py +768 -0
- steersync/config.py +532 -0
- steersync/errors.py +52 -0
- steersync/gitops.py +550 -0
- steersync/hooks.py +146 -0
- steersync/lint.py +631 -0
- steersync/locking.py +69 -0
- steersync/logging_utils.py +60 -0
- steersync/mcp_server.py +171 -0
- steersync/paths.py +98 -0
- steersync/resources/default_config.yaml +154 -0
- steersync/steering.py +457 -0
- steersync/sync.py +726 -0
- steersync-0.2.0.dist-info/METADATA +342 -0
- steersync-0.2.0.dist-info/RECORD +20 -0
- steersync-0.2.0.dist-info/WHEEL +4 -0
- steersync-0.2.0.dist-info/entry_points.txt +2 -0
- steersync-0.2.0.dist-info/licenses/LICENSE +190 -0
steersync/steering.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""File discovery, front matter parsing, snapshots, change plans and lock files.
|
|
2
|
+
|
|
3
|
+
The module is named after steering, its first use, but every function works on
|
|
4
|
+
any collection of files described by a :class:`FileSpec`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import glob
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Callable, Collection, Iterable, Mapping
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from typing import Any, Literal, NamedTuple
|
|
21
|
+
|
|
22
|
+
import frontmatter
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from steersync.errors import LimitExceededError, UnsafePathError
|
|
26
|
+
from steersync.paths import ensure_within
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
LOCK_VERSION = 2
|
|
31
|
+
FILE_MODE = 0o644
|
|
32
|
+
EXECUTABLE_MODE = 0o755
|
|
33
|
+
DEFAULT_COLLECTION = "steering"
|
|
34
|
+
_USERINFO_RE = re.compile(r"(?<=://)[^/@\s]+@")
|
|
35
|
+
STEERING_SUFFIX = ".md"
|
|
36
|
+
INCLUSION_MODES: tuple[str, ...] = ("always", "fileMatch", "manual", "auto")
|
|
37
|
+
KNOWN_KEYS: frozenset[str] = frozenset({"inclusion", "fileMatchPattern", "name", "description"})
|
|
38
|
+
FILE_REFERENCE_RE = re.compile(r"#\[\[(file|folder):([^\]\n]+?)\]\]")
|
|
39
|
+
_FRONT_MATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)", re.DOTALL)
|
|
40
|
+
|
|
41
|
+
Snapshot = dict[str, bytes]
|
|
42
|
+
InclusionMode = Literal["always", "fileMatch", "manual", "auto"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FileSpec(NamedTuple):
|
|
46
|
+
"""Which files inside a collection directory take part in a sync."""
|
|
47
|
+
|
|
48
|
+
include: tuple[str, ...] = ("**/*",)
|
|
49
|
+
exclude: tuple[str, ...] = ()
|
|
50
|
+
max_depth: int | None = None
|
|
51
|
+
|
|
52
|
+
def matcher(self) -> Callable[[str], bool]:
|
|
53
|
+
"""Return a predicate over posix relative paths."""
|
|
54
|
+
includes = [_glob_regex(p) for p in self.include]
|
|
55
|
+
excludes = [_glob_regex(p) for p in self.exclude]
|
|
56
|
+
return lambda relative: (
|
|
57
|
+
any(r.match(relative) for r in includes)
|
|
58
|
+
and not any(r.match(relative) for r in excludes)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
STEERING_SPEC = FileSpec(include=(f"**/*{STEERING_SUFFIX}",))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@functools.cache
|
|
66
|
+
def _glob_regex(pattern: str) -> re.Pattern[str]:
|
|
67
|
+
return re.compile(glob.translate(pattern, recursive=True, include_hidden=True))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FrontMatter(NamedTuple):
|
|
71
|
+
"""Parsed front matter of a steering document."""
|
|
72
|
+
|
|
73
|
+
present: bool
|
|
74
|
+
leading_blank: bool
|
|
75
|
+
metadata: dict[str, Any]
|
|
76
|
+
body: str
|
|
77
|
+
error: str | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SteeringDocument(NamedTuple):
|
|
81
|
+
relative_path: str
|
|
82
|
+
raw: bytes
|
|
83
|
+
text: str | None
|
|
84
|
+
front_matter: FrontMatter | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ChangePlan(NamedTuple):
|
|
88
|
+
"""Result of comparing a source snapshot with a target snapshot."""
|
|
89
|
+
|
|
90
|
+
add: tuple[str, ...]
|
|
91
|
+
update: tuple[str, ...]
|
|
92
|
+
delete: tuple[str, ...]
|
|
93
|
+
conflict: tuple[str, ...]
|
|
94
|
+
unchanged: tuple[str, ...]
|
|
95
|
+
local_only: tuple[str, ...]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_empty(self) -> bool:
|
|
99
|
+
return not (self.add or self.update or self.delete)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def change_count(self) -> int:
|
|
103
|
+
return len(self.add) + len(self.update) + len(self.delete)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_relative_path(relative: str) -> str:
|
|
107
|
+
"""Reject paths that could escape a target directory or hide from discovery."""
|
|
108
|
+
posix = PurePosixPath(relative)
|
|
109
|
+
if posix.is_absolute() or not relative or "\0" in relative or "\\" in relative:
|
|
110
|
+
msg = f"unsafe steering path: {relative!r}"
|
|
111
|
+
raise UnsafePathError(msg)
|
|
112
|
+
if any(part in {"..", ""} or part.startswith(".git") for part in posix.parts):
|
|
113
|
+
msg = f"unsafe steering path: {relative!r}"
|
|
114
|
+
raise UnsafePathError(msg)
|
|
115
|
+
return posix.as_posix()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def discover_files(
|
|
119
|
+
directory: Path, spec: FileSpec = STEERING_SPEC, *, follow_symlinks: bool = True
|
|
120
|
+
) -> list[Path]:
|
|
121
|
+
"""Return the files under ``directory`` selected by ``spec`` sorted by relative path.
|
|
122
|
+
|
|
123
|
+
Hidden files and directories are skipped and symlinked directories are never
|
|
124
|
+
entered, to match the Kiro IDE. Symlinked files are included when
|
|
125
|
+
``follow_symlinks`` is true (kiro-cli follows them). Content from the central
|
|
126
|
+
repository is read with ``follow_symlinks=False`` so a malicious link such as
|
|
127
|
+
``global/x.md -> ~/.ssh/id_rsa`` cannot copy local secrets into the sync.
|
|
128
|
+
"""
|
|
129
|
+
if not directory.is_dir():
|
|
130
|
+
return []
|
|
131
|
+
matches = spec.matcher()
|
|
132
|
+
found: list[Path] = []
|
|
133
|
+
for root, dirs, files in os.walk(directory, followlinks=False):
|
|
134
|
+
depth = len(Path(root).relative_to(directory).parts)
|
|
135
|
+
if spec.max_depth is not None and depth + 1 >= spec.max_depth:
|
|
136
|
+
dirs[:] = []
|
|
137
|
+
else:
|
|
138
|
+
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
|
|
139
|
+
for name in sorted(files):
|
|
140
|
+
if name.startswith("."):
|
|
141
|
+
continue
|
|
142
|
+
path = Path(root) / name
|
|
143
|
+
if not matches(path.relative_to(directory).as_posix()):
|
|
144
|
+
continue
|
|
145
|
+
if path.is_symlink() and not follow_symlinks:
|
|
146
|
+
log.warning("ignoring symlink %s in central repository content", path)
|
|
147
|
+
continue
|
|
148
|
+
found.append(path)
|
|
149
|
+
return sorted(found, key=lambda p: p.relative_to(directory).as_posix())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def discover_steering_files(directory: Path, *, follow_symlinks: bool = True) -> list[Path]:
|
|
153
|
+
"""Return steering markdown files under ``directory`` sorted by relative path."""
|
|
154
|
+
return discover_files(directory, STEERING_SPEC, follow_symlinks=follow_symlinks)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def snapshot_directory(
|
|
158
|
+
directory: Path, spec: FileSpec = STEERING_SPEC, *, follow_symlinks: bool = True
|
|
159
|
+
) -> Snapshot:
|
|
160
|
+
"""Read every selected file under ``directory`` into memory keyed by relative path."""
|
|
161
|
+
result: Snapshot = {}
|
|
162
|
+
for path in discover_files(directory, spec, follow_symlinks=follow_symlinks):
|
|
163
|
+
relative = validate_relative_path(path.relative_to(directory).as_posix())
|
|
164
|
+
result[relative] = path.read_bytes()
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def executable_paths(directory: Path, relatives: Iterable[str]) -> frozenset[str]:
|
|
169
|
+
"""Return the relative paths whose file has an execute bit set (skill scripts)."""
|
|
170
|
+
executable: set[str] = set()
|
|
171
|
+
for relative in relatives:
|
|
172
|
+
path = directory / relative
|
|
173
|
+
try:
|
|
174
|
+
mode = path.stat().st_mode
|
|
175
|
+
except OSError:
|
|
176
|
+
continue
|
|
177
|
+
if mode & 0o111:
|
|
178
|
+
executable.add(relative)
|
|
179
|
+
return frozenset(executable)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def enforce_limits(snapshot: Mapping[str, bytes], *, max_files: int, max_total_bytes: int) -> None:
|
|
183
|
+
"""Refuse snapshots that are larger than the configured safety limits."""
|
|
184
|
+
if len(snapshot) > max_files:
|
|
185
|
+
msg = f"{len(snapshot)} files exceed sync.max_files ({max_files})"
|
|
186
|
+
raise LimitExceededError(msg)
|
|
187
|
+
total = sum(len(data) for data in snapshot.values())
|
|
188
|
+
if total > max_total_bytes:
|
|
189
|
+
msg = f"{total} bytes exceed sync.max_total_bytes ({max_total_bytes})"
|
|
190
|
+
raise LimitExceededError(msg)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def file_digest(data: bytes) -> str:
|
|
194
|
+
return hashlib.sha256(data).hexdigest()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def snapshot_digest(snapshot: Mapping[str, bytes]) -> str:
|
|
198
|
+
"""Return a stable digest for an entire snapshot (paths and content)."""
|
|
199
|
+
hasher = hashlib.sha256()
|
|
200
|
+
for relative in sorted(snapshot):
|
|
201
|
+
data = snapshot[relative]
|
|
202
|
+
hasher.update(relative.encode("utf-8"))
|
|
203
|
+
hasher.update(b"\0")
|
|
204
|
+
hasher.update(str(len(data)).encode("ascii"))
|
|
205
|
+
hasher.update(b"\0")
|
|
206
|
+
hasher.update(data)
|
|
207
|
+
hasher.update(b"\n")
|
|
208
|
+
return hasher.hexdigest()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def snapshot_hashes(snapshot: Mapping[str, bytes]) -> dict[str, str]:
|
|
212
|
+
return {relative: file_digest(data) for relative, data in snapshot.items()}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_front_matter(text: str) -> FrontMatter:
|
|
216
|
+
"""Parse YAML front matter the way Kiro reads it.
|
|
217
|
+
|
|
218
|
+
Front matter must start on the very first byte. A leading blank line means
|
|
219
|
+
Kiro treats the whole file as ``inclusion: always`` with no metadata.
|
|
220
|
+
"""
|
|
221
|
+
leading_blank = text[:1] in {"\n", "\r"} and text.lstrip("\r\n").startswith("---")
|
|
222
|
+
if leading_blank:
|
|
223
|
+
return FrontMatter(False, True, {}, text, None)
|
|
224
|
+
match = _FRONT_MATTER_RE.match(text)
|
|
225
|
+
if not match:
|
|
226
|
+
return FrontMatter(False, False, {}, text, None)
|
|
227
|
+
try:
|
|
228
|
+
post = frontmatter.loads(text)
|
|
229
|
+
except (yaml.YAMLError, ValueError) as exc:
|
|
230
|
+
return FrontMatter(
|
|
231
|
+
True, False, {}, text[match.end() :], f"invalid YAML front matter: {exc}"
|
|
232
|
+
)
|
|
233
|
+
metadata = post.metadata if isinstance(post.metadata, dict) else {}
|
|
234
|
+
return FrontMatter(True, False, dict(metadata), post.content, None)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def read_document(directory: Path, relative: str) -> SteeringDocument:
|
|
238
|
+
path = ensure_within(directory, Path(relative))
|
|
239
|
+
raw = path.read_bytes()
|
|
240
|
+
return document_from_bytes(relative, raw)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def document_from_bytes(relative: str, raw: bytes) -> SteeringDocument:
|
|
244
|
+
try:
|
|
245
|
+
text = raw.decode("utf-8")
|
|
246
|
+
except UnicodeDecodeError:
|
|
247
|
+
return SteeringDocument(relative, raw, None, None)
|
|
248
|
+
return SteeringDocument(relative, raw, text, parse_front_matter(text))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def file_references(text: str) -> list[tuple[str, str]]:
|
|
252
|
+
"""Return ``(kind, target)`` pairs for ``#[[file:...]]`` and ``#[[folder:...]]`` references."""
|
|
253
|
+
refs: list[tuple[str, str]] = []
|
|
254
|
+
for kind, target in FILE_REFERENCE_RE.findall(text):
|
|
255
|
+
path = target.strip()
|
|
256
|
+
if kind == "file":
|
|
257
|
+
path = re.sub(r":\d+(-\d+)?$", "", path)
|
|
258
|
+
refs.append((kind, path))
|
|
259
|
+
return refs
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def plan_changes(
|
|
263
|
+
source: Mapping[str, bytes],
|
|
264
|
+
target: Mapping[str, bytes],
|
|
265
|
+
lock_hashes: Mapping[str, str],
|
|
266
|
+
*,
|
|
267
|
+
prune: bool = True,
|
|
268
|
+
) -> ChangePlan:
|
|
269
|
+
"""Plan how to make ``target`` match ``source`` while respecting local edits.
|
|
270
|
+
|
|
271
|
+
``lock_hashes`` records what the target looked like after the last sync. A
|
|
272
|
+
target file that differs from both the lock and the source was edited locally
|
|
273
|
+
and is reported as a conflict instead of being overwritten.
|
|
274
|
+
"""
|
|
275
|
+
add: list[str] = []
|
|
276
|
+
update: list[str] = []
|
|
277
|
+
delete: list[str] = []
|
|
278
|
+
conflict: list[str] = []
|
|
279
|
+
unchanged: list[str] = []
|
|
280
|
+
local_only: list[str] = []
|
|
281
|
+
target_hashes = snapshot_hashes(target)
|
|
282
|
+
for relative in sorted(source):
|
|
283
|
+
source_hash = file_digest(source[relative])
|
|
284
|
+
current = target_hashes.get(relative)
|
|
285
|
+
if current is None:
|
|
286
|
+
add.append(relative)
|
|
287
|
+
elif current == source_hash:
|
|
288
|
+
unchanged.append(relative)
|
|
289
|
+
elif lock_hashes.get(relative) == current:
|
|
290
|
+
update.append(relative)
|
|
291
|
+
else:
|
|
292
|
+
conflict.append(relative)
|
|
293
|
+
for relative in sorted(set(target) - set(source)):
|
|
294
|
+
if relative not in lock_hashes:
|
|
295
|
+
local_only.append(relative)
|
|
296
|
+
elif lock_hashes[relative] == target_hashes[relative]:
|
|
297
|
+
if prune:
|
|
298
|
+
delete.append(relative)
|
|
299
|
+
else:
|
|
300
|
+
local_only.append(relative)
|
|
301
|
+
else:
|
|
302
|
+
conflict.append(relative)
|
|
303
|
+
return ChangePlan(
|
|
304
|
+
tuple(add),
|
|
305
|
+
tuple(update),
|
|
306
|
+
tuple(delete),
|
|
307
|
+
tuple(sorted(conflict)),
|
|
308
|
+
tuple(unchanged),
|
|
309
|
+
tuple(local_only),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _atomic_write(path: Path, data: bytes, *, mode: int = FILE_MODE) -> None:
|
|
314
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
316
|
+
tmp = Path(tmp_name)
|
|
317
|
+
try:
|
|
318
|
+
with os.fdopen(fd, "wb") as handle:
|
|
319
|
+
handle.write(data)
|
|
320
|
+
handle.flush()
|
|
321
|
+
os.fsync(handle.fileno())
|
|
322
|
+
tmp.chmod(mode)
|
|
323
|
+
tmp.replace(path)
|
|
324
|
+
except BaseException:
|
|
325
|
+
tmp.unlink(missing_ok=True)
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def apply_plan(
|
|
330
|
+
target_dir: Path,
|
|
331
|
+
source: Mapping[str, bytes],
|
|
332
|
+
plan: ChangePlan,
|
|
333
|
+
*,
|
|
334
|
+
force: bool = False,
|
|
335
|
+
executable: Collection[str] = (),
|
|
336
|
+
) -> list[str]:
|
|
337
|
+
"""Write adds and updates, remove deletes, and optionally overwrite conflicts.
|
|
338
|
+
|
|
339
|
+
Files listed in ``executable`` are written with mode 0755 so skill scripts
|
|
340
|
+
stay runnable. Returns the list of relative paths that were written or removed.
|
|
341
|
+
"""
|
|
342
|
+
touched: list[str] = []
|
|
343
|
+
writes = list(plan.add) + list(plan.update)
|
|
344
|
+
if force:
|
|
345
|
+
writes.extend(r for r in plan.conflict if r in source)
|
|
346
|
+
for relative in writes:
|
|
347
|
+
validate_relative_path(relative)
|
|
348
|
+
path = ensure_within(target_dir, Path(relative))
|
|
349
|
+
mode = EXECUTABLE_MODE if relative in executable else FILE_MODE
|
|
350
|
+
_atomic_write(path, source[relative], mode=mode)
|
|
351
|
+
touched.append(relative)
|
|
352
|
+
removals = list(plan.delete)
|
|
353
|
+
if force:
|
|
354
|
+
removals.extend(r for r in plan.conflict if r not in source)
|
|
355
|
+
for relative in removals:
|
|
356
|
+
validate_relative_path(relative)
|
|
357
|
+
path = ensure_within(target_dir, Path(relative))
|
|
358
|
+
path.unlink(missing_ok=True)
|
|
359
|
+
_remove_empty_parents(target_dir, path.parent)
|
|
360
|
+
touched.append(relative)
|
|
361
|
+
return touched
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _remove_empty_parents(stop: Path, directory: Path) -> None:
|
|
365
|
+
current = directory
|
|
366
|
+
stop_resolved = stop.resolve()
|
|
367
|
+
while current.resolve() != stop_resolved and stop_resolved in current.resolve().parents:
|
|
368
|
+
try:
|
|
369
|
+
current.rmdir()
|
|
370
|
+
except OSError:
|
|
371
|
+
return
|
|
372
|
+
current = current.parent
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def replace_directory_contents(
|
|
376
|
+
target_dir: Path,
|
|
377
|
+
source: Mapping[str, bytes],
|
|
378
|
+
spec: FileSpec = STEERING_SPEC,
|
|
379
|
+
*,
|
|
380
|
+
executable: Collection[str] = (),
|
|
381
|
+
) -> list[str]:
|
|
382
|
+
"""Make the files selected by ``spec`` in ``target_dir`` match ``source`` exactly.
|
|
383
|
+
|
|
384
|
+
Used inside the cache clone. Files that ``spec`` does not select are left alone.
|
|
385
|
+
"""
|
|
386
|
+
existing = snapshot_directory(target_dir, spec)
|
|
387
|
+
plan = plan_changes(source, existing, snapshot_hashes(existing), prune=True)
|
|
388
|
+
return apply_plan(target_dir, source, plan, force=True, executable=executable)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _empty_lock() -> dict[str, Any]:
|
|
392
|
+
return {"version": LOCK_VERSION, "collections": {}}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def read_lock(path: Path) -> dict[str, Any]:
|
|
396
|
+
"""Read a lock file; a missing or corrupt lock yields an empty record.
|
|
397
|
+
|
|
398
|
+
Version 1 locks (one ``files`` mapping) are read as the steering collection so
|
|
399
|
+
an upgrade keeps telling local edits from stale copies.
|
|
400
|
+
"""
|
|
401
|
+
if not path.is_file():
|
|
402
|
+
return _empty_lock()
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(path.read_text("utf-8"))
|
|
405
|
+
except OSError, json.JSONDecodeError:
|
|
406
|
+
log.warning("ignoring unreadable lock file %s", path)
|
|
407
|
+
return _empty_lock()
|
|
408
|
+
if not isinstance(data, dict):
|
|
409
|
+
log.warning("ignoring malformed lock file %s", path)
|
|
410
|
+
return _empty_lock()
|
|
411
|
+
if isinstance(data.get("collections"), dict):
|
|
412
|
+
return data
|
|
413
|
+
if isinstance(data.get("files"), dict):
|
|
414
|
+
upgraded = {k: v for k, v in data.items() if k != "files"}
|
|
415
|
+
upgraded["version"] = LOCK_VERSION
|
|
416
|
+
upgraded["collections"] = {DEFAULT_COLLECTION: {"files": data["files"]}}
|
|
417
|
+
return upgraded
|
|
418
|
+
log.warning("ignoring malformed lock file %s", path)
|
|
419
|
+
return _empty_lock()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def lock_collections(lock: Mapping[str, Any]) -> dict[str, dict[str, str]]:
|
|
423
|
+
"""Return every collection's recorded hashes from a lock record."""
|
|
424
|
+
collections = lock.get("collections", {})
|
|
425
|
+
if not isinstance(collections, dict):
|
|
426
|
+
return {}
|
|
427
|
+
result: dict[str, dict[str, str]] = {}
|
|
428
|
+
for name, entry in collections.items():
|
|
429
|
+
files = entry.get("files") if isinstance(entry, dict) else None
|
|
430
|
+
if isinstance(files, dict):
|
|
431
|
+
result[str(name)] = {str(k): str(v) for k, v in files.items()}
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def lock_hashes(lock: Mapping[str, Any], collection: str = DEFAULT_COLLECTION) -> dict[str, str]:
|
|
436
|
+
"""Return the recorded hashes of one collection."""
|
|
437
|
+
return lock_collections(lock).get(collection, {})
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def write_lock(
|
|
441
|
+
path: Path,
|
|
442
|
+
*,
|
|
443
|
+
remote: str,
|
|
444
|
+
commit: str | None,
|
|
445
|
+
collections: Mapping[str, Mapping[str, str]],
|
|
446
|
+
) -> None:
|
|
447
|
+
record = {
|
|
448
|
+
"version": LOCK_VERSION,
|
|
449
|
+
"remote": _USERINFO_RE.sub("", remote),
|
|
450
|
+
"commit": commit,
|
|
451
|
+
"synced_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
452
|
+
"collections": {
|
|
453
|
+
name: {"files": dict(sorted(files.items()))}
|
|
454
|
+
for name, files in sorted(collections.items())
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
_atomic_write(path, (json.dumps(record, indent=2, sort_keys=False) + "\n").encode("utf-8"))
|