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/lint.py ADDED
@@ -0,0 +1,631 @@
1
+ """Validation of synced content: front matter, size, references, secrets, markdown, JSON.
2
+
3
+ Each collection selects a lint profile. ``steering`` applies every Kiro front matter
4
+ rule, ``skill`` validates ``SKILL.md`` metadata, ``hooks`` and ``agent`` check the JSON
5
+ shape Kiro expects, ``json`` and ``markdown`` check syntax and style, and ``generic``
6
+ only enforces size limits and secret scanning. Profiles are looked up in
7
+ :data:`PROFILES`, so a new kind of content is one function and one table entry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections.abc import Callable, Iterable, Mapping
15
+ from pathlib import Path, PurePosixPath
16
+ from typing import Literal, NamedTuple
17
+
18
+ from steersync.config import LintConfig, LintProfile
19
+ from steersync.steering import (
20
+ FILE_REFERENCE_RE,
21
+ INCLUSION_MODES,
22
+ KNOWN_KEYS,
23
+ FileSpec,
24
+ SteeringDocument,
25
+ discover_files,
26
+ document_from_bytes,
27
+ file_references,
28
+ )
29
+
30
+ Severity = Literal["error", "warning"]
31
+ _H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
32
+ SKILL_FILE = "SKILL.md"
33
+ HOOK_TRIGGERS: frozenset[str] = frozenset(
34
+ {
35
+ "SessionStart",
36
+ "Stop",
37
+ "PreToolUse",
38
+ "PostToolUse",
39
+ "UserPromptSubmit",
40
+ "PreTaskExec",
41
+ "PostTaskExec",
42
+ "PostFileCreate",
43
+ "PostFileSave",
44
+ "PostFileDelete",
45
+ "Manual",
46
+ }
47
+ )
48
+
49
+
50
+ class LintFinding(NamedTuple):
51
+ path: str
52
+ rule: str
53
+ severity: Severity
54
+ message: str
55
+ line: int | None = None
56
+
57
+ def render(self) -> str:
58
+ location = f"{self.path}:{self.line}" if self.line else self.path
59
+ return f"{location}: {self.severity}: [{self.rule}] {self.message}"
60
+
61
+
62
+ class LintReport(NamedTuple):
63
+ findings: tuple[LintFinding, ...]
64
+ checked: int
65
+
66
+ @property
67
+ def errors(self) -> tuple[LintFinding, ...]:
68
+ return tuple(f for f in self.findings if f.severity == "error")
69
+
70
+ @property
71
+ def warnings(self) -> tuple[LintFinding, ...]:
72
+ return tuple(f for f in self.findings if f.severity == "warning")
73
+
74
+ @property
75
+ def ok(self) -> bool:
76
+ return not self.errors
77
+
78
+ def to_json(self) -> str:
79
+ return json.dumps(
80
+ {
81
+ "checked": self.checked,
82
+ "errors": len(self.errors),
83
+ "warnings": len(self.warnings),
84
+ "findings": [f._asdict() for f in self.findings],
85
+ },
86
+ indent=2,
87
+ )
88
+
89
+
90
+ def _front_matter_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
91
+ findings: list[LintFinding] = []
92
+ fm = doc.front_matter
93
+ if fm is None:
94
+ return findings
95
+ if fm.leading_blank:
96
+ findings.append(
97
+ LintFinding(
98
+ doc.relative_path,
99
+ "front-matter-position",
100
+ "error",
101
+ "front matter must start on line 1; Kiro ignores it after a blank line",
102
+ 1,
103
+ )
104
+ )
105
+ return findings
106
+ if fm.error:
107
+ findings.append(LintFinding(doc.relative_path, "front-matter-yaml", "error", fm.error, 1))
108
+ return findings
109
+ if not fm.present:
110
+ return findings
111
+ meta = fm.metadata
112
+ inclusion = meta.get("inclusion", "always")
113
+ if inclusion not in INCLUSION_MODES:
114
+ findings.append(
115
+ LintFinding(
116
+ doc.relative_path,
117
+ "inclusion-value",
118
+ "error",
119
+ f"inclusion must be one of {', '.join(INCLUSION_MODES)}; got {inclusion!r}",
120
+ 2,
121
+ )
122
+ )
123
+ pattern = meta.get("fileMatchPattern")
124
+ if inclusion == "fileMatch":
125
+ if pattern is None:
126
+ findings.append(
127
+ LintFinding(
128
+ doc.relative_path,
129
+ "filematch-pattern-required",
130
+ "error",
131
+ "fileMatch requires fileMatchPattern",
132
+ 2,
133
+ )
134
+ )
135
+ elif not _is_pattern(pattern):
136
+ findings.append(
137
+ LintFinding(
138
+ doc.relative_path,
139
+ "filematch-pattern-type",
140
+ "error",
141
+ "fileMatchPattern must be a string or a list of strings",
142
+ 2,
143
+ )
144
+ )
145
+ elif pattern is not None:
146
+ findings.append(
147
+ LintFinding(
148
+ doc.relative_path,
149
+ "filematch-pattern-unused",
150
+ "warning",
151
+ "fileMatchPattern is ignored unless inclusion is fileMatch",
152
+ 2,
153
+ )
154
+ )
155
+ if inclusion == "auto":
156
+ for key in ("name", "description"):
157
+ value = meta.get(key)
158
+ if not isinstance(value, str) or not value.strip():
159
+ findings.append(
160
+ LintFinding(
161
+ doc.relative_path,
162
+ "auto-requires-metadata",
163
+ "error",
164
+ f"inclusion: auto requires a non empty {key}",
165
+ 2,
166
+ )
167
+ )
168
+ allowed = KNOWN_KEYS | set(config.allowed_extra_keys)
169
+ findings.extend(
170
+ LintFinding(
171
+ doc.relative_path,
172
+ "unknown-key",
173
+ "warning",
174
+ f"unknown front matter key {key!r} is ignored by Kiro",
175
+ 2,
176
+ )
177
+ for key in meta
178
+ if key not in allowed
179
+ )
180
+ return findings
181
+
182
+
183
+ def _is_pattern(value: object) -> bool:
184
+ if isinstance(value, str):
185
+ return bool(value.strip())
186
+ return (
187
+ isinstance(value, list)
188
+ and bool(value)
189
+ and all(isinstance(v, str) and v.strip() for v in value)
190
+ )
191
+
192
+
193
+ def _secret_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
194
+ """Scan decodable text for the configured forbidden patterns."""
195
+ findings: list[LintFinding] = []
196
+ if doc.text is None:
197
+ return findings
198
+ for item in config.forbidden_patterns:
199
+ for match in re.finditer(item.pattern, doc.text):
200
+ line = doc.text.count("\n", 0, match.start()) + 1
201
+ findings.append(
202
+ LintFinding(doc.relative_path, f"forbidden-{item.id}", "error", item.message, line)
203
+ )
204
+ return findings
205
+
206
+
207
+ def _body_findings(
208
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
209
+ ) -> list[LintFinding]:
210
+ findings: list[LintFinding] = []
211
+ if doc.text is None:
212
+ return findings
213
+ body = doc.front_matter.body if doc.front_matter else doc.text
214
+ if config.require_h1 and not _H1_RE.search(body):
215
+ findings.append(
216
+ LintFinding(
217
+ doc.relative_path, "require-h1", "warning", "document has no level one heading"
218
+ )
219
+ )
220
+ findings.extend(_secret_findings(doc, config))
221
+ if config.file_reference_check and workspace_root is not None:
222
+ for kind, target in file_references(doc.text):
223
+ candidate = (workspace_root / target).resolve()
224
+ if (
225
+ workspace_root.resolve() not in candidate.parents
226
+ and candidate != workspace_root.resolve()
227
+ ):
228
+ findings.append(
229
+ LintFinding(
230
+ doc.relative_path,
231
+ "file-reference-escape",
232
+ "error",
233
+ f"#[[{kind}:{target}]] points outside the workspace",
234
+ )
235
+ )
236
+ elif not candidate.exists():
237
+ findings.append(
238
+ LintFinding(
239
+ doc.relative_path,
240
+ "file-reference-missing",
241
+ "warning",
242
+ f"#[[{kind}:{target}]] does not exist in this workspace",
243
+ )
244
+ )
245
+ return findings
246
+
247
+
248
+ def _markdown_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
249
+ if not config.pymarkdown.enabled or doc.text is None:
250
+ return []
251
+ from pymarkdown.api import PyMarkdownApi, PyMarkdownApiException # noqa: PLC0415 (slow import)
252
+
253
+ api = PyMarkdownApi().enable_extension_by_identifier("front-matter")
254
+ for rule in config.pymarkdown.disabled_rules:
255
+ api = api.disable_rule_by_identifier(rule)
256
+ masked = FILE_REFERENCE_RE.sub(lambda m: "x" * len(m.group(0)), doc.text)
257
+ try:
258
+ result = api.scan_string(masked)
259
+ except PyMarkdownApiException as exc:
260
+ return [
261
+ LintFinding(
262
+ doc.relative_path, "markdown-scan", "warning", f"markdown scan failed: {exc}"
263
+ )
264
+ ]
265
+ return [
266
+ LintFinding(
267
+ doc.relative_path,
268
+ f"markdown-{failure.rule_id}",
269
+ config.pymarkdown.severity,
270
+ failure.rule_description,
271
+ failure.line_number,
272
+ )
273
+ for failure in result.scan_failures
274
+ ]
275
+
276
+
277
+ def _size_findings(doc: SteeringDocument, max_file_bytes: int) -> list[LintFinding]:
278
+ if len(doc.raw) <= max_file_bytes:
279
+ return []
280
+ return [
281
+ LintFinding(
282
+ doc.relative_path,
283
+ "max-size",
284
+ "error",
285
+ f"file is {len(doc.raw)} bytes; limit is {max_file_bytes}",
286
+ )
287
+ ]
288
+
289
+
290
+ def _text_findings(doc: SteeringDocument) -> list[LintFinding]:
291
+ """Findings for content that must be UTF-8 text without NUL bytes."""
292
+ if doc.text is None:
293
+ return [LintFinding(doc.relative_path, "encoding-utf8", "error", "file is not valid UTF-8")]
294
+ if "\0" in doc.text:
295
+ return [LintFinding(doc.relative_path, "encoding-utf8", "error", "file contains NUL bytes")]
296
+ return []
297
+
298
+
299
+ def _is_text(doc: SteeringDocument) -> bool:
300
+ return doc.text is not None and "\0" not in doc.text
301
+
302
+
303
+ def _parse_json(doc: SteeringDocument) -> tuple[object, list[LintFinding]]:
304
+ """Parse the document as JSON; a syntax error is reported as a finding."""
305
+ if doc.text is None:
306
+ return None, []
307
+ try:
308
+ return json.loads(doc.text), []
309
+ except json.JSONDecodeError as exc:
310
+ finding = LintFinding(
311
+ doc.relative_path, "json-syntax", "error", f"invalid JSON: {exc.msg}", exc.lineno
312
+ )
313
+ return None, [finding]
314
+
315
+
316
+ def _profile_steering(
317
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
318
+ ) -> list[LintFinding]:
319
+ findings = _text_findings(doc)
320
+ if findings:
321
+ return findings
322
+ findings.extend(_front_matter_findings(doc, config))
323
+ findings.extend(_body_findings(doc, config, workspace_root))
324
+ findings.extend(_markdown_findings(doc, config))
325
+ return findings
326
+
327
+
328
+ def _profile_markdown(
329
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
330
+ ) -> list[LintFinding]:
331
+ """Plain markdown such as specs: text, secrets and style, no Kiro front matter rules."""
332
+ if PurePosixPath(doc.relative_path).suffix.lower() != ".md":
333
+ return _profile_generic(doc, config, workspace_root)
334
+ findings = _text_findings(doc)
335
+ if findings:
336
+ return findings
337
+ findings.extend(_secret_findings(doc, config))
338
+ findings.extend(_markdown_findings(doc, config))
339
+ return findings
340
+
341
+
342
+ def _skill_metadata_findings(doc: SteeringDocument) -> list[LintFinding]:
343
+ fm = doc.front_matter
344
+ if fm is None or fm.leading_blank or not fm.present:
345
+ return [
346
+ LintFinding(
347
+ doc.relative_path,
348
+ "skill-front-matter-required",
349
+ "error",
350
+ "SKILL.md must start with front matter containing name and description",
351
+ 1,
352
+ )
353
+ ]
354
+ if fm.error:
355
+ return [LintFinding(doc.relative_path, "front-matter-yaml", "error", fm.error, 1)]
356
+ findings: list[LintFinding] = []
357
+ for key in ("name", "description"):
358
+ value = fm.metadata.get(key)
359
+ if not isinstance(value, str) or not value.strip():
360
+ findings.append(
361
+ LintFinding(
362
+ doc.relative_path,
363
+ "skill-requires-metadata",
364
+ "error",
365
+ f"SKILL.md requires a non empty {key}",
366
+ 2,
367
+ )
368
+ )
369
+ name = fm.metadata.get("name")
370
+ folder = PurePosixPath(doc.relative_path).parent.name
371
+ if isinstance(name, str) and folder and name.strip() != folder:
372
+ findings.append(
373
+ LintFinding(
374
+ doc.relative_path,
375
+ "skill-name-mismatch",
376
+ "warning",
377
+ f"skill name {name!r} does not match its directory {folder!r}",
378
+ 2,
379
+ )
380
+ )
381
+ return findings
382
+
383
+
384
+ def _profile_skill(
385
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
386
+ ) -> list[LintFinding]:
387
+ """Agent Skills: SKILL.md metadata, markdown style, secrets in scripts and references."""
388
+ if PurePosixPath(doc.relative_path).name != SKILL_FILE:
389
+ return _profile_markdown(doc, config, workspace_root)
390
+ findings = _text_findings(doc)
391
+ if findings:
392
+ return findings
393
+ findings.extend(_skill_metadata_findings(doc))
394
+ findings.extend(_secret_findings(doc, config))
395
+ findings.extend(_markdown_findings(doc, config))
396
+ return findings
397
+
398
+
399
+ def _profile_json(
400
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
401
+ ) -> list[LintFinding]:
402
+ if PurePosixPath(doc.relative_path).suffix.lower() != ".json":
403
+ return _profile_generic(doc, config, workspace_root)
404
+ findings = _text_findings(doc)
405
+ if findings:
406
+ return findings
407
+ _, syntax = _parse_json(doc)
408
+ findings.extend(syntax)
409
+ findings.extend(_secret_findings(doc, config))
410
+ return findings
411
+
412
+
413
+ def _hook_shape_findings(doc: SteeringDocument, data: object) -> list[LintFinding]:
414
+ if not isinstance(data, dict) or not isinstance(data.get("hooks"), list):
415
+ return [
416
+ LintFinding(
417
+ doc.relative_path,
418
+ "hook-schema",
419
+ "error",
420
+ 'a standalone hook file is an object with "version" and a "hooks" list',
421
+ )
422
+ ]
423
+ findings: list[LintFinding] = []
424
+ if data.get("version") != "v1":
425
+ findings.append(
426
+ LintFinding(doc.relative_path, "hook-schema", "warning", 'expected "version": "v1"')
427
+ )
428
+ for index, hook in enumerate(data["hooks"]):
429
+ label = f"hooks[{index}]"
430
+ if not isinstance(hook, dict):
431
+ findings.append(
432
+ LintFinding(doc.relative_path, "hook-schema", "error", f"{label} must be an object")
433
+ )
434
+ continue
435
+ findings.extend(
436
+ LintFinding(doc.relative_path, "hook-schema", "error", f"{label} lacks {key!r}")
437
+ for key in ("name", "trigger", "action")
438
+ if key not in hook
439
+ )
440
+ trigger = hook.get("trigger")
441
+ if isinstance(trigger, str) and trigger not in HOOK_TRIGGERS:
442
+ findings.append(
443
+ LintFinding(
444
+ doc.relative_path,
445
+ "hook-trigger",
446
+ "warning",
447
+ f"{label}: unknown trigger {trigger!r}; Kiro V3 triggers are PascalCase",
448
+ )
449
+ )
450
+ action = hook.get("action")
451
+ if isinstance(action, dict) and action.get("type") not in {"command", "agent"}:
452
+ findings.append(
453
+ LintFinding(
454
+ doc.relative_path,
455
+ "hook-action",
456
+ "error",
457
+ f'{label}: action.type must be "command" or "agent"',
458
+ )
459
+ )
460
+ return findings
461
+
462
+
463
+ def _profile_hooks(
464
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
465
+ ) -> list[LintFinding]:
466
+ if PurePosixPath(doc.relative_path).suffix.lower() != ".json":
467
+ return _profile_generic(doc, config, workspace_root)
468
+ findings = _text_findings(doc)
469
+ if findings:
470
+ return findings
471
+ data, syntax = _parse_json(doc)
472
+ findings.extend(syntax)
473
+ if not syntax:
474
+ findings.extend(_hook_shape_findings(doc, data))
475
+ findings.extend(_secret_findings(doc, config))
476
+ return findings
477
+
478
+
479
+ def _profile_agent(
480
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
481
+ ) -> list[LintFinding]:
482
+ """Custom agents: JSON definitions need a name; prompt files are plain markdown."""
483
+ suffix = PurePosixPath(doc.relative_path).suffix.lower()
484
+ if suffix == ".md":
485
+ return _profile_markdown(doc, config, workspace_root)
486
+ if suffix != ".json":
487
+ return _profile_generic(doc, config, workspace_root)
488
+ findings = _text_findings(doc)
489
+ if findings:
490
+ return findings
491
+ data, syntax = _parse_json(doc)
492
+ findings.extend(syntax)
493
+ if not syntax:
494
+ if not isinstance(data, dict):
495
+ findings.append(
496
+ LintFinding(
497
+ doc.relative_path, "agent-schema", "error", "an agent file is a JSON object"
498
+ )
499
+ )
500
+ else:
501
+ name = data.get("name")
502
+ if not isinstance(name, str) or not name.strip():
503
+ findings.append(
504
+ LintFinding(
505
+ doc.relative_path, "agent-name", "warning", 'agent definition has no "name"'
506
+ )
507
+ )
508
+ if "toolsSettings" in data:
509
+ findings.append(
510
+ LintFinding(
511
+ doc.relative_path,
512
+ "agent-deprecated-key",
513
+ "warning",
514
+ "toolsSettings is deprecated; use permissions.rules",
515
+ )
516
+ )
517
+ findings.extend(_secret_findings(doc, config))
518
+ return findings
519
+
520
+
521
+ def _profile_generic(
522
+ doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
523
+ ) -> list[LintFinding]:
524
+ """Any file: binary content is allowed, decodable text is scanned for secrets."""
525
+ del workspace_root
526
+ return _secret_findings(doc, config) if _is_text(doc) else []
527
+
528
+
529
+ Profile = Callable[[SteeringDocument, LintConfig, Path | None], list[LintFinding]]
530
+
531
+ PROFILES: dict[str, Profile] = {
532
+ "steering": _profile_steering,
533
+ "markdown": _profile_markdown,
534
+ "skill": _profile_skill,
535
+ "agent": _profile_agent,
536
+ "hooks": _profile_hooks,
537
+ "json": _profile_json,
538
+ "generic": _profile_generic,
539
+ }
540
+
541
+
542
+ def lint_document(
543
+ doc: SteeringDocument,
544
+ config: LintConfig,
545
+ workspace_root: Path | None = None,
546
+ *,
547
+ profile: LintProfile = "steering",
548
+ max_file_bytes: int | None = None,
549
+ ) -> list[LintFinding]:
550
+ """Lint one document with the given profile and return its findings."""
551
+ findings = _size_findings(doc, max_file_bytes or config.max_file_bytes)
552
+ findings.extend(PROFILES[profile](doc, config, workspace_root))
553
+ return list(dict.fromkeys(findings))
554
+
555
+
556
+ def lint_snapshot(
557
+ snapshot: Mapping[str, bytes],
558
+ config: LintConfig,
559
+ workspace_root: Path | None = None,
560
+ *,
561
+ profile: LintProfile = "steering",
562
+ max_file_bytes: int | None = None,
563
+ ) -> LintReport:
564
+ """Lint an in memory snapshot."""
565
+ findings: list[LintFinding] = []
566
+ for relative in sorted(snapshot):
567
+ findings.extend(
568
+ lint_document(
569
+ document_from_bytes(relative, snapshot[relative]),
570
+ config,
571
+ workspace_root,
572
+ profile=profile,
573
+ max_file_bytes=max_file_bytes,
574
+ )
575
+ )
576
+ return LintReport(tuple(findings), len(snapshot))
577
+
578
+
579
+ def lint_paths(
580
+ paths: Iterable[Path],
581
+ config: LintConfig,
582
+ workspace_root: Path | None = None,
583
+ *,
584
+ profile: LintProfile = "steering",
585
+ spec: FileSpec | None = None,
586
+ max_file_bytes: int | None = None,
587
+ ) -> LintReport:
588
+ """Lint files or directories on disk. Directories are expanded with ``spec``."""
589
+ files: list[Path] = []
590
+ for path in paths:
591
+ if path.is_dir():
592
+ files.extend(discover_files(path, spec) if spec else discover_files(path))
593
+ elif path.is_file():
594
+ files.append(path)
595
+ findings: list[LintFinding] = []
596
+ for file in files:
597
+ label = (
598
+ file.relative_to(workspace_root).as_posix()
599
+ if workspace_root and file.is_relative_to(workspace_root)
600
+ else str(file)
601
+ )
602
+ findings.extend(
603
+ lint_document(
604
+ document_from_bytes(label, file.read_bytes()),
605
+ config,
606
+ workspace_root,
607
+ profile=profile,
608
+ max_file_bytes=max_file_bytes,
609
+ )
610
+ )
611
+ return LintReport(tuple(findings), len(files))
612
+
613
+
614
+ def prefix_report(report: LintReport, prefix: str) -> LintReport:
615
+ """Return ``report`` with every finding path prefixed, for combined reports."""
616
+ if not prefix:
617
+ return report
618
+ return LintReport(
619
+ tuple(f._replace(path=f"{prefix.rstrip('/')}/{f.path}") for f in report.findings),
620
+ report.checked,
621
+ )
622
+
623
+
624
+ def merge_reports(reports: Iterable[LintReport]) -> LintReport:
625
+ """Combine several reports into one."""
626
+ findings: list[LintFinding] = []
627
+ checked = 0
628
+ for report in reports:
629
+ findings.extend(report.findings)
630
+ checked += report.checked
631
+ return LintReport(tuple(findings), checked)
steersync/locking.py ADDED
@@ -0,0 +1,69 @@
1
+ """Cross process exclusive file locks.
2
+
3
+ Several steersync processes can share one cache clone (two pushes in different
4
+ repositories, a Kiro session hook and a git hook at the same time). Every
5
+ operation on a cache clone holds an exclusive lock on a sibling lock file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ import time
13
+ from collections.abc import Iterator
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import IO
17
+
18
+ from steersync.errors import LockTimeoutError
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ if sys.platform == "win32": # pragma: no cover - exercised on Windows only
23
+ import msvcrt
24
+
25
+ def _try_lock(handle: IO[bytes]) -> None:
26
+ handle.seek(0)
27
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
28
+
29
+ def _unlock(handle: IO[bytes]) -> None:
30
+ handle.seek(0)
31
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
32
+
33
+ else:
34
+ import fcntl
35
+
36
+ def _try_lock(handle: IO[bytes]) -> None:
37
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
38
+
39
+ def _unlock(handle: IO[bytes]) -> None:
40
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
41
+
42
+
43
+ @contextmanager
44
+ def exclusive_lock(path: Path, *, timeout: float, poll_interval: float = 0.2) -> Iterator[None]:
45
+ """Hold an exclusive lock on ``path`` for the duration of the block.
46
+
47
+ The lock is released automatically by the operating system if the process
48
+ dies, so a crashed run never leaves a stale lock behind.
49
+ """
50
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
51
+ deadline = time.monotonic() + timeout
52
+ with path.open("a+b") as handle:
53
+ announced = False
54
+ while True:
55
+ try:
56
+ _try_lock(handle)
57
+ break
58
+ except OSError as exc:
59
+ if time.monotonic() >= deadline:
60
+ msg = f"timed out after {timeout:.0f}s waiting for lock {path}"
61
+ raise LockTimeoutError(msg) from exc
62
+ if not announced:
63
+ log.info("waiting for another steersync process (%s)", path)
64
+ announced = True
65
+ time.sleep(poll_interval)
66
+ try:
67
+ yield
68
+ finally:
69
+ _unlock(handle)