agent2learn 0.1.2__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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/paths.py ADDED
@@ -0,0 +1,683 @@
1
+ """Cross-platform path naming and durable atomic filesystem primitives.
2
+
3
+ All vault component naming, collision allocation, and state-file replacement lives here.
4
+ Callers keep ordinary ``Path`` objects; ``long_path`` is applied only at the syscall
5
+ boundary because Windows' ``\\?\\`` paths are not safe to join or compare with pathlib.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import errno
11
+ import os
12
+ import re
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import unicodedata
19
+ from collections.abc import Collection, Iterator
20
+ from contextlib import suppress
21
+ from pathlib import Path
22
+
23
+ WINDOWS = sys.platform == "win32"
24
+ DEFAULT_MAXLEN = 60
25
+
26
+ RESERVED = frozenset(
27
+ {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
28
+ | {f"COM{digit}" for digit in "123456789"}
29
+ | {f"COM{digit}" for digit in "¹²³"}
30
+ | {f"LPT{digit}" for digit in "123456789"}
31
+ | {f"LPT{digit}" for digit in "¹²³"}
32
+ )
33
+
34
+ _RESERVED_CASEFOLD = frozenset(name.casefold() for name in RESERVED)
35
+ _RESERVED_CHARACTERS = re.compile(r'[<>:"/\\|?*]')
36
+ _WHITESPACE = re.compile(r"\s+")
37
+ _SIMPLE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,15}$")
38
+ _WINDOWS_EXTENDED_PREFIX = "\\\\?\\"
39
+ _WINDOWS_UNC_PREFIX = "\\\\?\\UNC\\"
40
+ _UNSUPPORTED_DIRECTORY_FSYNC = frozenset(
41
+ {errno.EBADF, errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP}
42
+ )
43
+
44
+
45
+ def safe_name(name: str, *, maxlen: int | None = None) -> str:
46
+ """Return a deterministic, Windows-safe filename component.
47
+
48
+ The operation order is part of the vault contract: normalize, sanitize, collapse
49
+ whitespace, trim leading/trailing whitespace, truncate, then repair a reserved device
50
+ name. The same rules run on every platform so a vault has identical names after moving
51
+ between operating systems.
52
+ """
53
+ budget = DEFAULT_MAXLEN if maxlen is None else _positive_budget(maxlen)
54
+ value = unicodedata.normalize("NFC", name)
55
+ value = _RESERVED_CHARACTERS.sub("_", value)
56
+ value = "".join("_" if unicodedata.category(char) in {"Cc", "Cf"} else char for char in value)
57
+ value = _WHITESPACE.sub(" ", value).lstrip().rstrip(" .")
58
+ if not value:
59
+ value = "untitled"
60
+
61
+ value = _truncate_component(value, budget)
62
+ value = value.rstrip(" .") or "untitled"
63
+ if len(value) > budget:
64
+ value = value[:budget].rstrip(" .") or "untitled"[:budget]
65
+
66
+ return _repair_reserved_name(value, budget)
67
+
68
+
69
+ def long_path(path: Path) -> Path:
70
+ """Return a Windows extended-length path without following symlinks.
71
+
72
+ ``resolve()`` is deliberately not used here. It follows a symlink before the caller
73
+ reaches the syscall, which can turn a safe identity check or an atomic replacement into an
74
+ operation on the symlink target. ``abspath`` normalizes the lexical path and preserves the
75
+ final component as the object the caller actually named.
76
+ """
77
+ if not WINDOWS:
78
+ return path
79
+
80
+ raw = os.fspath(path)
81
+ if raw.startswith(_WINDOWS_EXTENDED_PREFIX):
82
+ return path
83
+
84
+ absolute_raw = os.path.abspath(raw)
85
+ if absolute_raw.startswith(_WINDOWS_EXTENDED_PREFIX):
86
+ return Path(absolute_raw)
87
+ if len(absolute_raw) <= 240:
88
+ return path
89
+ if absolute_raw.startswith(r"\\"):
90
+ return Path(_WINDOWS_UNC_PREFIX + absolute_raw[2:])
91
+ return Path(_WINDOWS_EXTENDED_PREFIX + absolute_raw)
92
+
93
+
94
+ def is_link(path: Path) -> bool:
95
+ """Return whether ``path`` is a symlink or Windows reparse-point link."""
96
+ candidate = long_path(path)
97
+ try:
98
+ if candidate.is_symlink():
99
+ return True
100
+ file_stat = os.lstat(os.fspath(candidate))
101
+ except FileNotFoundError:
102
+ return False
103
+ return bool(getattr(file_stat, "st_file_attributes", 0) & 0x400)
104
+
105
+
106
+ def collides(destination: Path) -> bool:
107
+ """Return whether a normalized, case-folded sibling already has this name."""
108
+ try:
109
+ with os.scandir(os.fspath(long_path(destination.parent))) as iterator:
110
+ entries = tuple(entry.name for entry in iterator)
111
+ except FileNotFoundError:
112
+ return False
113
+
114
+ wanted = _canonical_component(destination.name)
115
+ return any(_canonical_component(entry) == wanted for entry in entries)
116
+
117
+
118
+ def walk(root: Path) -> Iterator[Path]:
119
+ """Yield ordinary paths from a tree while applying ``long_path`` at each scan boundary."""
120
+
121
+ def visit(directory: Path) -> Iterator[Path]:
122
+ with os.scandir(os.fspath(long_path(directory))) as iterator:
123
+ for entry in iterator:
124
+ child = directory / entry.name
125
+ yield child
126
+ if not is_link(child) and entry.is_dir(follow_symlinks=False):
127
+ yield from visit(child)
128
+
129
+ yield from visit(root)
130
+
131
+
132
+ def remove_tree(root: Path, *, ignore_errors: bool = False) -> None:
133
+ """Remove a tree while applying ``long_path`` to each filesystem operation."""
134
+ try:
135
+ if is_link(root):
136
+ _remove_link(root)
137
+ return
138
+ if not long_path(root).is_dir():
139
+ os.unlink(os.fspath(long_path(root)))
140
+ return
141
+ candidates = list(walk(root))
142
+ for candidate in sorted(
143
+ candidates,
144
+ key=lambda value: len(value.relative_to(root).parts),
145
+ reverse=True,
146
+ ):
147
+ if is_link(candidate):
148
+ _remove_link(candidate)
149
+ elif not long_path(candidate).is_dir():
150
+ os.unlink(os.fspath(long_path(candidate)))
151
+ else:
152
+ os.rmdir(os.fspath(long_path(candidate)))
153
+ os.rmdir(os.fspath(long_path(root)))
154
+ except OSError:
155
+ if not ignore_errors:
156
+ raise
157
+
158
+
159
+ def unique_path(destination: Path, *, reserved: Collection[Path] = ()) -> Path:
160
+ """Return ``destination`` or the next available ``_2``/``_3`` sibling."""
161
+ reserved_names = {
162
+ _canonical_component(os.fspath(_plain_absolute(plain_path(path)))) for path in reserved
163
+ }
164
+
165
+ def available(path: Path) -> bool:
166
+ name = _canonical_component(os.fspath(_plain_absolute(plain_path(path))))
167
+ return name not in reserved_names and not collides(path)
168
+
169
+ candidate_name = _truncate_component(destination.name, DEFAULT_MAXLEN).rstrip(" .")
170
+ if not candidate_name:
171
+ candidate_name = "untitled"
172
+ candidate = destination.with_name(candidate_name)
173
+ if available(candidate):
174
+ return candidate
175
+
176
+ stem, extension = _split_extension(candidate.name)
177
+ for number in range(2, 100_000):
178
+ suffix = f"_{number}"
179
+ available_stem_length = DEFAULT_MAXLEN - len(extension) - len(suffix)
180
+ if available_stem_length < 1:
181
+ raise ValueError("filename budget cannot fit a collision suffix")
182
+ candidate_name = f"{stem[:available_stem_length]}{suffix}{extension}"
183
+ candidate = destination.with_name(candidate_name)
184
+ if available(candidate):
185
+ return candidate
186
+ raise RuntimeError("could not allocate a unique path")
187
+
188
+
189
+ def reveal(path: Path) -> None:
190
+ """Ask the platform file manager to reveal a path, swallowing launcher failures."""
191
+ try:
192
+ if WINDOWS:
193
+ command = ["explorer", os.fspath(long_path(path))]
194
+ elif sys.platform == "darwin":
195
+ command = ["open", os.fspath(long_path(path))]
196
+ else:
197
+ command = ["xdg-open", os.fspath(long_path(path))]
198
+ subprocess.Popen(command) # noqa: S603 - fixed executable, no shell
199
+ except OSError:
200
+ return
201
+
202
+
203
+ def ensure_dir(directory: Path, *, root: Path | None = None, mode: int | None = None) -> None:
204
+ """Create a directory tree at a syscall boundary.
205
+
206
+ When ``root`` is supplied, the complete lexical path is checked immediately before and after
207
+ the mkdir. Vault writers use this opt-in contract so a moved or malformed vault cannot turn a
208
+ course path into a write through a symlink/junction outside the vault. Configuration and
209
+ other non-vault callers retain the historical unconstrained behaviour by omitting ``root``.
210
+ """
211
+
212
+ _validate_root_bound(directory, root)
213
+ if mode is not None:
214
+ if isinstance(mode, bool) or not isinstance(mode, int) or mode < 0:
215
+ raise ValueError("mode must be a non-negative integer")
216
+ long_path(directory).mkdir(mode=mode, parents=True, exist_ok=True)
217
+ else:
218
+ long_path(directory).mkdir(parents=True, exist_ok=True)
219
+ _validate_root_bound(directory, root)
220
+
221
+
222
+ def temporary_directory(parent: Path, *, prefix: str, root: Path | None = None) -> Path:
223
+ """Create a sibling temporary directory at a syscall boundary."""
224
+
225
+ ensure_dir(parent, root=root)
226
+ raw_path = tempfile.mkdtemp(prefix=prefix, dir=os.fspath(long_path(parent)))
227
+ result = plain_path(Path(raw_path))
228
+ _validate_root_bound(result, root)
229
+ return result
230
+
231
+
232
+ def has_link_component(path: Path, *, root: Path | None = None) -> bool:
233
+ """Return whether ``path`` has a link component inside the trusted root."""
234
+
235
+ absolute_path = Path(os.path.abspath(os.fspath(path)))
236
+ if root is None:
237
+ parts = absolute_path.parts
238
+ if not parts:
239
+ return False
240
+ trusted_root = Path(parts[0])
241
+ relative_parts = parts[1:]
242
+ else:
243
+ trusted_root = Path(os.path.abspath(os.fspath(root)))
244
+ try:
245
+ relative_parts = absolute_path.relative_to(trusted_root).parts
246
+ except ValueError:
247
+ return True
248
+
249
+ candidate = trusted_root
250
+ if is_link(candidate):
251
+ return True
252
+ for part in relative_parts:
253
+ if not long_path(candidate).exists():
254
+ return False
255
+ candidate = candidate / part
256
+ if is_link(candidate):
257
+ return True
258
+ return False
259
+
260
+
261
+ def symlink_dir(source: Path, destination: Path) -> None:
262
+ """Create an opt-in directory symlink at a syscall boundary."""
263
+
264
+ ensure_dir(destination.parent)
265
+ os.symlink(
266
+ os.fspath(long_path(source)),
267
+ os.fspath(long_path(destination)),
268
+ target_is_directory=True,
269
+ )
270
+ _fsync_directory(destination.parent)
271
+
272
+
273
+ def replace_link(
274
+ destination: Path,
275
+ source: Path,
276
+ *,
277
+ root: Path | None = None,
278
+ retries: int = 5,
279
+ ) -> None:
280
+ """Atomically install a directory link while retaining the old object on failure.
281
+
282
+ A directory cannot be replaced in place by ``os.replace`` on every supported platform.
283
+ The old object is therefore moved to a sibling backup before the staged link is installed;
284
+ a failed install rolls that backup back into place. ``source`` is trusted by the caller and
285
+ must be an ordinary directory, while ``root`` protects the destination parent.
286
+ """
287
+
288
+ _validate_retries(retries)
289
+ if not long_path(source).is_dir() or is_link(source):
290
+ raise ValueError("link source must be an ordinary directory")
291
+ if root is not None and has_link_component(destination.parent, root=root):
292
+ raise ValueError("link destination path contains a link component")
293
+ _validate_within_root(destination, root)
294
+
295
+ # Revalidate immediately before reserving the sibling link: a linked parent introduced after
296
+ # the initial check must not redirect the temporary object outside ``root``.
297
+ _validate_root_bound(destination.parent, root)
298
+ temporary = _create_temporary_link(destination, source)
299
+ try:
300
+ _validate_root_bound(temporary.parent, root)
301
+ _validate_root_bound(destination.parent, root)
302
+ backup = _backup_path(destination)
303
+ had_destination = long_path(destination).exists() or is_link(destination)
304
+ if had_destination:
305
+ _replace_with_retries(destination, backup, retries)
306
+ try:
307
+ _replace_with_retries(temporary, destination, retries)
308
+ except OSError:
309
+ if had_destination and not (long_path(destination).exists() or is_link(destination)):
310
+ _replace_with_retries(backup, destination, retries)
311
+ raise
312
+ if had_destination:
313
+ remove_tree(backup, ignore_errors=True)
314
+ finally:
315
+ _remove_quietly(temporary)
316
+
317
+
318
+ def atomic_write_text(
319
+ destination: Path, text: str, *, root: Path | None = None, retries: int = 5
320
+ ) -> None:
321
+ """Write UTF-8 LF text and atomically install it at ``destination``."""
322
+ _validate_retries(retries)
323
+ _validate_root_bound(destination, root)
324
+ # ``_create_temporary`` performs its own filesystem operation. Check the parent again at that
325
+ # boundary rather than relying only on the earlier destination validation.
326
+ _validate_root_bound(destination.parent, root)
327
+ temporary, file_descriptor = _create_temporary_file(destination, suffix=".tmp")
328
+ # ``mkstemp`` is a separate filesystem operation from the destination check above. Recheck
329
+ # the complete temporary path before opening it so a parent swapped to a link between those
330
+ # operations fails closed instead of writing through the replacement.
331
+ # This temporary contains content generated by this call and is cheap to recreate;
332
+ # unlike atomic_install_temp, it is correct to remove it when installation fails.
333
+ descriptor_open = True
334
+ try:
335
+ _validate_root_bound(temporary, root)
336
+ with os.fdopen(file_descriptor, "w", encoding="utf-8", newline="\n") as handle:
337
+ descriptor_open = False
338
+ handle.write(text)
339
+ handle.flush()
340
+ os.fsync(handle.fileno())
341
+ _tighten_permissions(temporary)
342
+ _validate_root_bound(destination, root)
343
+ _replace_with_retries(temporary, destination, retries)
344
+ finally:
345
+ if descriptor_open:
346
+ with suppress(OSError):
347
+ os.close(file_descriptor)
348
+ _remove_quietly(temporary)
349
+
350
+
351
+ def atomic_write_bytes(
352
+ destination: Path, data: bytes, *, root: Path | None = None, retries: int = 5
353
+ ) -> None:
354
+ """Write exact bytes and atomically install them at ``destination``."""
355
+ _validate_retries(retries)
356
+ _validate_root_bound(destination, root)
357
+ _validate_root_bound(destination.parent, root)
358
+ temporary, file_descriptor = _create_temporary_file(destination, suffix=".tmp")
359
+ # This temporary contains content generated by this call and is cheap to recreate;
360
+ # unlike atomic_install_temp, it is correct to remove it when installation fails.
361
+ descriptor_open = True
362
+ try:
363
+ _validate_root_bound(temporary, root)
364
+ with os.fdopen(file_descriptor, "wb") as handle:
365
+ descriptor_open = False
366
+ handle.write(data)
367
+ handle.flush()
368
+ os.fsync(handle.fileno())
369
+ _tighten_permissions(temporary)
370
+ _validate_root_bound(destination, root)
371
+ _replace_with_retries(temporary, destination, retries)
372
+ finally:
373
+ if descriptor_open:
374
+ with suppress(OSError):
375
+ os.close(file_descriptor)
376
+ _remove_quietly(temporary)
377
+
378
+
379
+ def atomic_install_temp(
380
+ destination: Path,
381
+ temporary: Path,
382
+ *,
383
+ root: Path | None = None,
384
+ retries: int = 5,
385
+ ) -> None:
386
+ """Fsync and atomically install a validated sibling download ``.part`` file."""
387
+ _validate_retries(retries)
388
+ _validate_root_bound(destination, root)
389
+ _validate_root_bound(temporary, root)
390
+ _validate_part_file(destination, temporary)
391
+ # This .part is a completed download, so retain it when fsync or installation fails;
392
+ # the next sync can retry the expensive download instead of fetching it again.
393
+ _fsync_file(temporary)
394
+ _tighten_permissions(temporary)
395
+ _validate_root_bound(destination, root)
396
+ _validate_root_bound(temporary, root)
397
+ _replace_with_retries(temporary, destination, retries)
398
+
399
+
400
+ def replace_tree(
401
+ destination: Path, staged: Path, *, root: Path | None = None, retries: int = 5
402
+ ) -> None:
403
+ """Atomically move a staged tree into place and restore the old tree on failure."""
404
+
405
+ _validate_retries(retries)
406
+ if _plain_absolute(staged.parent) != _plain_absolute(destination.parent):
407
+ raise ValueError("staged tree must be a sibling of destination")
408
+ # Replacing a final destination symlink is an intentional operation for the skill installer;
409
+ # only its parent and the staged tree must remain inside the trusted root.
410
+ _validate_root_bound(destination.parent, root)
411
+ _validate_within_root(destination, root)
412
+ _validate_root_bound(staged, root)
413
+ # The checks above protect the caller's path construction; repeat them at the replacement
414
+ # boundary after backup allocation so a parent swapped during staging fails closed.
415
+ _validate_root_bound(destination.parent, root)
416
+ _validate_root_bound(staged, root)
417
+ backup = _backup_path(destination)
418
+ had_destination = long_path(destination).exists() or is_link(destination)
419
+ if had_destination:
420
+ _validate_root_bound(destination.parent, root)
421
+ _validate_root_bound(staged, root)
422
+ _replace_with_retries(destination, backup, retries)
423
+ try:
424
+ _validate_root_bound(destination.parent, root)
425
+ _validate_root_bound(staged, root)
426
+ _replace_with_retries(staged, destination, retries)
427
+ except OSError:
428
+ if had_destination and not long_path(destination).exists():
429
+ _replace_with_retries(backup, destination, retries)
430
+ raise
431
+ if had_destination:
432
+ remove_tree(backup, ignore_errors=True)
433
+
434
+
435
+ def rel_posix(path: Path, root: Path) -> str:
436
+ """Return a non-escaping vault-relative path using forward slashes."""
437
+ resolved_path = path.resolve()
438
+ resolved_root = root.resolve()
439
+ try:
440
+ relative = resolved_path.relative_to(resolved_root)
441
+ except ValueError as exc:
442
+ raise ValueError("path is not relative to root") from exc
443
+ return relative.as_posix()
444
+
445
+
446
+ def _positive_budget(value: int) -> int:
447
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
448
+ raise ValueError("maxlen must be a positive integer")
449
+ return value
450
+
451
+
452
+ def _truncate_component(value: str, budget: int) -> str:
453
+ match = _SIMPLE_EXTENSION.search(value)
454
+ if match is None:
455
+ return value[:budget]
456
+
457
+ extension = match.group(0)
458
+ stem = value[: -len(extension)]
459
+ if stem and budget > len(extension):
460
+ return stem[: budget - len(extension)] + extension
461
+ return value[:budget]
462
+
463
+
464
+ def _repair_reserved_name(value: str, budget: int) -> str:
465
+ reserved_stem = value.split(".", 1)[0]
466
+ if reserved_stem.casefold() not in _RESERVED_CASEFOLD:
467
+ return value[:budget]
468
+
469
+ remainder = value[len(reserved_stem) :]
470
+ if len(reserved_stem) >= budget:
471
+ if budget == 1:
472
+ return "_"
473
+ return reserved_stem[: budget - 1] + "_"
474
+
475
+ repaired = f"{reserved_stem}_{remainder[: budget - len(reserved_stem) - 1]}".rstrip(" .")
476
+ if not repaired:
477
+ return "untitled"[:budget]
478
+ return repaired[:budget]
479
+
480
+
481
+ def _split_extension(name: str) -> tuple[str, str]:
482
+ first_dot = name.find(".")
483
+ if first_dot <= 0:
484
+ return name, ""
485
+ extension_match = _SIMPLE_EXTENSION.search(name)
486
+ if extension_match is None:
487
+ return name, ""
488
+ return name[: -len(extension_match.group(0))], extension_match.group(0)
489
+
490
+
491
+ def _canonical_component(name: str) -> str:
492
+ return unicodedata.normalize("NFC", name).casefold()
493
+
494
+
495
+ def _validate_retries(retries: int) -> None:
496
+ if isinstance(retries, bool) or not isinstance(retries, int) or retries <= 0:
497
+ raise ValueError("retries must be a positive integer")
498
+
499
+
500
+ def _create_temporary(destination: Path, *, suffix: str) -> Path:
501
+ path, file_descriptor = _create_temporary_file(destination, suffix=suffix)
502
+ os.close(file_descriptor)
503
+ return path
504
+
505
+
506
+ def _create_temporary_file(destination: Path, *, suffix: str) -> tuple[Path, int]:
507
+ file_descriptor, raw_path = tempfile.mkstemp(
508
+ prefix=f".{destination.name}.",
509
+ suffix=suffix,
510
+ dir=os.fspath(long_path(destination.parent)),
511
+ )
512
+ return plain_path(Path(raw_path)), file_descriptor
513
+
514
+
515
+ def _create_temporary_link(destination: Path, source: Path) -> Path:
516
+ """Create a private sibling link without exposing a partially written destination."""
517
+
518
+ temporary = _create_temporary(destination, suffix=".link")
519
+ try:
520
+ os.unlink(os.fspath(long_path(temporary)))
521
+ os.symlink(
522
+ os.fspath(long_path(source)),
523
+ os.fspath(long_path(temporary)),
524
+ target_is_directory=True,
525
+ )
526
+ except BaseException:
527
+ # Do not unlink an unexpected object that another local process inserted after the
528
+ # placeholder was removed; the hidden name is harmless debris and data safety wins.
529
+ if is_link(temporary):
530
+ _remove_quietly(temporary)
531
+ raise
532
+ return temporary
533
+
534
+
535
+ def _backup_path(destination: Path) -> Path:
536
+ for index in range(1, 100_000):
537
+ backup = destination.parent / f".{destination.name}.backup.{index}"
538
+ if not long_path(backup).exists() and not is_link(backup):
539
+ return backup
540
+ raise RuntimeError("could not allocate a backup path")
541
+
542
+
543
+ def _plain_absolute(path: Path) -> Path:
544
+ return Path(os.path.abspath(os.fspath(path)))
545
+
546
+
547
+ def _validate_root_bound(path: Path, root: Path | None) -> None:
548
+ """Reject linked components for an operation explicitly bound to ``root``."""
549
+
550
+ if root is None:
551
+ return
552
+ if has_link_component(path, root=root):
553
+ raise ValueError("path contains a link component outside the trusted root")
554
+
555
+
556
+ def _validate_within_root(path: Path, root: Path | None) -> None:
557
+ """Reject a lexical path escape while allowing an intentional final link."""
558
+
559
+ if root is None:
560
+ return
561
+ absolute = Path(os.path.abspath(os.fspath(path)))
562
+ trusted = Path(os.path.abspath(os.fspath(root)))
563
+ try:
564
+ absolute.relative_to(trusted)
565
+ except ValueError as exc:
566
+ raise ValueError("path is outside the trusted root") from exc
567
+
568
+
569
+ def plain_path(path: Path) -> Path:
570
+ """Remove an inherited Windows extended prefix before a path returns to application code."""
571
+ if not WINDOWS:
572
+ return path
573
+ raw = os.fspath(path)
574
+ if raw.startswith(_WINDOWS_UNC_PREFIX):
575
+ return Path("\\\\" + raw[len(_WINDOWS_UNC_PREFIX) :])
576
+ if raw.startswith(_WINDOWS_EXTENDED_PREFIX):
577
+ return Path(raw[4:])
578
+ return path
579
+
580
+
581
+ def _tighten_permissions(path: Path) -> None:
582
+ if not WINDOWS:
583
+ os.chmod(os.fspath(long_path(path)), 0o600)
584
+
585
+
586
+ def _fsync_file(path: Path) -> None:
587
+ # Windows requires a writable handle for fsync; POSIX accepts a read-only one.
588
+ mode = "r+b" if WINDOWS else "rb"
589
+ with open(os.fspath(long_path(path)), mode) as handle:
590
+ os.fsync(handle.fileno())
591
+
592
+
593
+ def _fsync_directory(directory: Path) -> None:
594
+ if WINDOWS:
595
+ return
596
+ try:
597
+ file_descriptor = os.open(os.fspath(long_path(directory)), os.O_RDONLY)
598
+ except OSError as exc:
599
+ if exc.errno in _UNSUPPORTED_DIRECTORY_FSYNC:
600
+ return
601
+ raise
602
+ try:
603
+ try:
604
+ os.fsync(file_descriptor)
605
+ except OSError as exc:
606
+ if exc.errno not in _UNSUPPORTED_DIRECTORY_FSYNC:
607
+ raise
608
+ finally:
609
+ os.close(file_descriptor)
610
+
611
+
612
+ def _replace_with_retries(temporary: Path, destination: Path, retries: int) -> None:
613
+ for attempt in range(retries):
614
+ try:
615
+ os.replace(
616
+ os.fspath(long_path(temporary)),
617
+ os.fspath(long_path(destination)),
618
+ )
619
+ except PermissionError:
620
+ if attempt == retries - 1:
621
+ raise
622
+ time.sleep(0.01 * (2**attempt))
623
+ else:
624
+ _fsync_directory(destination.parent)
625
+ return
626
+
627
+
628
+ def _validate_part_file(destination: Path, temporary: Path) -> None:
629
+ if temporary.parent.resolve() != destination.parent.resolve():
630
+ raise ValueError("temporary file must be a sibling of destination")
631
+ if not temporary.name.endswith(".part"):
632
+ raise ValueError("temporary download must end with .part")
633
+ try:
634
+ file_stat = os.lstat(os.fspath(long_path(temporary)))
635
+ except FileNotFoundError:
636
+ raise
637
+ if is_link(temporary) or stat.S_ISLNK(file_stat.st_mode):
638
+ raise ValueError("temporary download must not be a symlink")
639
+ if not stat.S_ISREG(file_stat.st_mode):
640
+ raise ValueError("temporary download must be a regular file")
641
+ if getattr(file_stat, "st_nlink", 1) != 1:
642
+ raise ValueError("temporary download must not be a hard link")
643
+
644
+
645
+ def _remove_quietly(path: Path) -> None:
646
+ try:
647
+ os.unlink(os.fspath(long_path(path)))
648
+ except OSError:
649
+ return
650
+
651
+
652
+ def _remove_link(path: Path) -> None:
653
+ """Remove a link itself, using directory removal for Windows junctions."""
654
+ if WINDOWS and long_path(path).is_dir():
655
+ os.rmdir(os.fspath(long_path(path)))
656
+ else:
657
+ os.unlink(os.fspath(long_path(path)))
658
+
659
+
660
+ __all__ = [
661
+ "DEFAULT_MAXLEN",
662
+ "RESERVED",
663
+ "WINDOWS",
664
+ "atomic_install_temp",
665
+ "atomic_write_bytes",
666
+ "atomic_write_text",
667
+ "collides",
668
+ "ensure_dir",
669
+ "has_link_component",
670
+ "is_link",
671
+ "long_path",
672
+ "plain_path",
673
+ "remove_tree",
674
+ "replace_link",
675
+ "replace_tree",
676
+ "rel_posix",
677
+ "walk",
678
+ "reveal",
679
+ "safe_name",
680
+ "symlink_dir",
681
+ "temporary_directory",
682
+ "unique_path",
683
+ ]