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/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"))