simplibs-path 0.1.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.
Files changed (128) hide show
  1. simplibs/path/__init__.py +118 -0
  2. simplibs/path/actions/ChangeSuffix.py +23 -0
  3. simplibs/path/actions/CollectDirs.py +24 -0
  4. simplibs/path/actions/CollectFiles.py +25 -0
  5. simplibs/path/actions/CollectPaths.py +24 -0
  6. simplibs/path/actions/EnsureDir.py +22 -0
  7. simplibs/path/actions/EnsureFile.py +19 -0
  8. simplibs/path/actions/EnsureParentDir.py +20 -0
  9. simplibs/path/actions/FilterPaths.py +27 -0
  10. simplibs/path/actions/MakeUniquePath.py +22 -0
  11. simplibs/path/actions/NormalizeEntryPath.py +20 -0
  12. simplibs/path/actions/NormalizePath.py +23 -0
  13. simplibs/path/actions/RebasePath.py +24 -0
  14. simplibs/path/actions/RelativizePath.py +22 -0
  15. simplibs/path/actions/__init__.py +68 -0
  16. simplibs/path/annotations/PathLike.py +43 -0
  17. simplibs/path/annotations/PathPredicate.py +39 -0
  18. simplibs/path/annotations/RecursionDepth.py +33 -0
  19. simplibs/path/annotations/__init__.py +21 -0
  20. simplibs/path/logic/__init__.py +41 -0
  21. simplibs/path/logic/checks/__init__.py +29 -0
  22. simplibs/path/logic/checks/_helpers/__init__.py +21 -0
  23. simplibs/path/logic/checks/_helpers/_find_length_problem.py +48 -0
  24. simplibs/path/logic/checks/_helpers/_find_windows_name_problem.py +55 -0
  25. simplibs/path/logic/checks/_helpers/can_create_inside.py +39 -0
  26. simplibs/path/logic/checks/_helpers/constants/MAX_NAME_LENGTH.py +18 -0
  27. simplibs/path/logic/checks/_helpers/constants/WINDOWS_FORBIDDEN_CHARS.py +25 -0
  28. simplibs/path/logic/checks/_helpers/constants/WINDOWS_RESERVED_NAMES.py +27 -0
  29. simplibs/path/logic/checks/_helpers/constants/__init__.py +20 -0
  30. simplibs/path/logic/checks/_helpers/find_filename_problem.py +75 -0
  31. simplibs/path/logic/checks/_helpers/to_path.py +77 -0
  32. simplibs/path/logic/checks/is_empty_dir.py +59 -0
  33. simplibs/path/logic/checks/is_hidden_path.py +78 -0
  34. simplibs/path/logic/checks/is_path_readable.py +73 -0
  35. simplibs/path/logic/checks/is_path_writable.py +90 -0
  36. simplibs/path/logic/checks/is_valid_filename.py +47 -0
  37. simplibs/path/logic/checks/is_within.py +88 -0
  38. simplibs/path/logic/ensure/__init__.py +22 -0
  39. simplibs/path/logic/ensure/_validations/__init__.py +25 -0
  40. simplibs/path/logic/ensure/_validations/raise_ensure_dir_error.py +40 -0
  41. simplibs/path/logic/ensure/_validations/raise_ensure_dir_not_a_dir_error.py +36 -0
  42. simplibs/path/logic/ensure/_validations/raise_ensure_file_create_error.py +39 -0
  43. simplibs/path/logic/ensure/_validations/raise_ensure_file_not_a_file_error.py +36 -0
  44. simplibs/path/logic/ensure/_validations/raise_ensure_file_not_found_error.py +36 -0
  45. simplibs/path/logic/ensure/ensure_dir.py +83 -0
  46. simplibs/path/logic/ensure/ensure_file.py +103 -0
  47. simplibs/path/logic/ensure/ensure_parent_dir.py +58 -0
  48. simplibs/path/logic/scanners/__init__.py +35 -0
  49. simplibs/path/logic/scanners/_helpers/ScanEntry.py +36 -0
  50. simplibs/path/logic/scanners/_helpers/__init__.py +30 -0
  51. simplibs/path/logic/scanners/_helpers/as_path_predicate.py +38 -0
  52. simplibs/path/logic/scanners/_helpers/build_ignore_matcher.py +88 -0
  53. simplibs/path/logic/scanners/_helpers/generate_paths.py +74 -0
  54. simplibs/path/logic/scanners/_helpers/is_link_like.py +45 -0
  55. simplibs/path/logic/scanners/_helpers/resolve_scan_root.py +66 -0
  56. simplibs/path/logic/scanners/_helpers/scan_paths.py +92 -0
  57. simplibs/path/logic/scanners/_helpers/walk_tree.py +118 -0
  58. simplibs/path/logic/scanners/_validations/__init__.py +24 -0
  59. simplibs/path/logic/scanners/_validations/raise_collect_paths_not_a_dir_error.py +34 -0
  60. simplibs/path/logic/scanners/_validations/raise_collect_paths_not_found_error.py +38 -0
  61. simplibs/path/logic/scanners/_validations/raise_collect_paths_root_unreadable_error.py +38 -0
  62. simplibs/path/logic/scanners/_validations/raise_ignore_names_not_names_error.py +36 -0
  63. simplibs/path/logic/scanners/_validations/raise_nothing_to_collect_error.py +34 -0
  64. simplibs/path/logic/scanners/collect_dirs.py +62 -0
  65. simplibs/path/logic/scanners/collect_files.py +64 -0
  66. simplibs/path/logic/scanners/collect_paths.py +102 -0
  67. simplibs/path/logic/scanners/filter_paths.py +51 -0
  68. simplibs/path/logic/scanners/ignore_names/DEFAULT_IGNORE_NAMES.py +36 -0
  69. simplibs/path/logic/scanners/ignore_names/IGNORE_BUILD.py +48 -0
  70. simplibs/path/logic/scanners/ignore_names/IGNORE_ENV.py +31 -0
  71. simplibs/path/logic/scanners/ignore_names/IGNORE_GENERIC.py +30 -0
  72. simplibs/path/logic/scanners/ignore_names/IGNORE_IDE.py +20 -0
  73. simplibs/path/logic/scanners/ignore_names/IGNORE_SYSTEM.py +30 -0
  74. simplibs/path/logic/scanners/ignore_names/IGNORE_VCS.py +24 -0
  75. simplibs/path/logic/scanners/ignore_names/__init__.py +30 -0
  76. simplibs/path/logic/scanners/iter_paths.py +91 -0
  77. simplibs/path/logic/transformers/__init__.py +27 -0
  78. simplibs/path/logic/transformers/_helpers/__init__.py +17 -0
  79. simplibs/path/logic/transformers/_helpers/is_valid_suffix.py +37 -0
  80. simplibs/path/logic/transformers/_helpers/split_name.py +46 -0
  81. simplibs/path/logic/transformers/_validations/__init__.py +24 -0
  82. simplibs/path/logic/transformers/_validations/raise_invalid_suffix_error.py +36 -0
  83. simplibs/path/logic/transformers/_validations/raise_normalize_path_error.py +44 -0
  84. simplibs/path/logic/transformers/_validations/raise_path_has_no_name_error.py +36 -0
  85. simplibs/path/logic/transformers/_validations/raise_path_not_within_root_error.py +43 -0
  86. simplibs/path/logic/transformers/_validations/raise_unique_path_exhausted_error.py +39 -0
  87. simplibs/path/logic/transformers/change_suffix.py +77 -0
  88. simplibs/path/logic/transformers/make_unique_path.py +88 -0
  89. simplibs/path/logic/transformers/normalize_entry_path.py +67 -0
  90. simplibs/path/logic/transformers/normalize_path.py +85 -0
  91. simplibs/path/logic/transformers/rebase_path.py +78 -0
  92. simplibs/path/logic/transformers/relativize_path.py +56 -0
  93. simplibs/path/rules/__init__.py +66 -0
  94. simplibs/path/rules/_helpers/PathPartIs.py +152 -0
  95. simplibs/path/rules/_helpers/__init__.py +21 -0
  96. simplibs/path/rules/_helpers/build_not_a_path_exception.py +66 -0
  97. simplibs/path/rules/_helpers/build_path_rule_exception.py +71 -0
  98. simplibs/path/rules/_helpers/check_path.py +48 -0
  99. simplibs/path/rules/_validations/__init__.py +24 -0
  100. simplibs/path/rules/_validations/raise_has_suffix_invalid_suffix_error.py +35 -0
  101. simplibs/path/rules/_validations/raise_has_suffix_no_suffixes_error.py +31 -0
  102. simplibs/path/rules/_validations/raise_rule_param_not_bool_error.py +37 -0
  103. simplibs/path/rules/_validations/raise_rule_param_not_callable_error.py +36 -0
  104. simplibs/path/rules/_validations/raise_rule_param_not_path_error.py +37 -0
  105. simplibs/path/rules/filesystem/IsDir.py +70 -0
  106. simplibs/path/rules/filesystem/IsEmptyDir.py +76 -0
  107. simplibs/path/rules/filesystem/IsEmptyFile.py +76 -0
  108. simplibs/path/rules/filesystem/IsFile.py +76 -0
  109. simplibs/path/rules/filesystem/IsPathReadable.py +77 -0
  110. simplibs/path/rules/filesystem/IsPathWritable.py +82 -0
  111. simplibs/path/rules/filesystem/IsSymlink.py +71 -0
  112. simplibs/path/rules/filesystem/PathExists.py +75 -0
  113. simplibs/path/rules/filesystem/__init__.py +31 -0
  114. simplibs/path/rules/lexical/HasSuffix.py +146 -0
  115. simplibs/path/rules/lexical/IsAbsolutePath.py +74 -0
  116. simplibs/path/rules/lexical/IsHidden.py +92 -0
  117. simplibs/path/rules/lexical/IsRelativePath.py +74 -0
  118. simplibs/path/rules/lexical/IsValidFilename.py +136 -0
  119. simplibs/path/rules/lexical/IsWithin.py +116 -0
  120. simplibs/path/rules/lexical/NameIs.py +37 -0
  121. simplibs/path/rules/lexical/ParentNameIs.py +37 -0
  122. simplibs/path/rules/lexical/StemIs.py +36 -0
  123. simplibs/path/rules/lexical/__init__.py +33 -0
  124. simplibs_path-0.1.0.dist-info/METADATA +420 -0
  125. simplibs_path-0.1.0.dist-info/RECORD +128 -0
  126. simplibs_path-0.1.0.dist-info/WHEEL +5 -0
  127. simplibs_path-0.1.0.dist-info/licenses/LICENSE +21 -0
  128. simplibs_path-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,88 @@
1
+ import os
2
+ from pathlib import Path
3
+ from simplibs.types import int_positive
4
+ from simplibs.validate import validate_call
5
+ # Outers
6
+ from ...annotations import PathLike
7
+ # Inners
8
+ from ._helpers import split_name
9
+ from ._validations import raise_unique_path_exhausted_error
10
+ from .normalize_entry_path import normalize_entry_path
11
+
12
+
13
+ @validate_call
14
+ def make_unique_path(
15
+ path: PathLike,
16
+ *,
17
+ max_attempts: int_positive = 1000,
18
+ ) -> Path:
19
+ """Return `path` itself if it is free, otherwise a numbered variant that is.
20
+
21
+ `report.txt` becomes `report (1).txt`, `report (2).txt`, ... — the first
22
+ name that does not exist yet. Nothing is created.
23
+
24
+ Args:
25
+ path: The wanted path.
26
+ max_attempts: How many numbered variants to try before giving up.
27
+
28
+ Returns:
29
+ An absolute `Path` that did not exist at the moment of the call. Its
30
+ directory is resolved (symlinks followed); the final name is kept as
31
+ written, so a symlink there is never followed.
32
+
33
+ Raises:
34
+ ParamError: If `path` has no final name (a filesystem root).
35
+ OperationError: If every variant up to `max_attempts` is taken.
36
+ AccessError: If the path cannot be normalized.
37
+ """
38
+
39
+ # 1. Normalize the wanted path as an entry (directory resolved, name kept)
40
+ normalized = normalize_entry_path(path)
41
+
42
+ # 2. The wanted path is free — keep it as it is
43
+ if not os.path.lexists(normalized):
44
+ return normalized
45
+
46
+ # 3. Try "name (1).ext", "name (2).ext", ... until one is free
47
+ stem, suffix = split_name(normalized)
48
+ for number in range(1, max_attempts + 1):
49
+ candidate = normalized.with_name(f"{stem} ({number}){suffix}")
50
+ if not os.path.lexists(candidate):
51
+ return candidate
52
+
53
+ # 4. Every candidate within the limit is taken
54
+ raise_unique_path_exhausted_error(normalized, max_attempts)
55
+
56
+
57
+ _DESIGN_NOTES = """
58
+ # make_unique_path — Collision-Free Output Names
59
+
60
+ ## Purpose
61
+ Lets a pipeline write a result next to others without overwriting them,
62
+ choosing the name the way file managers do ("report (1).txt").
63
+
64
+ ---
65
+
66
+ ## 1. `os.path.lexists`, Not `Path.exists`
67
+ `exists()` follows symlinks, so a *broken* symlink reports "does not
68
+ exist" — and writing to that name would write through the link to its
69
+ missing target. `lexists` treats the link itself as occupying the name.
70
+
71
+ ## 2. Only the Directory Is Resolved
72
+ Delegated to `normalize_entry_path`: the final name is judged as written, so a
73
+ dangling link `out.txt -> nowhere` occupies the name `out.txt`.
74
+
75
+ ## 3. Not Atomic — By Nature
76
+ The name is free at the moment of the call, not reserved. To claim a name for
77
+ certain, open the returned path with mode `"x"`; this function only chooses a
78
+ good candidate.
79
+
80
+ ## 4. A Hard Attempt Limit
81
+ `max_attempts` stops the search on a directory where thousands of variants
82
+ exist. Reaching it is an operation failure (`OperationError`), not silent
83
+ success with a wrong name.
84
+
85
+ ## 5. Compound Suffixes
86
+ `split_name` keeps `.tar.gz` together, so `archive.tar.gz` becomes
87
+ `archive (1).tar.gz` instead of `archive.tar (1).gz`.
88
+ """
@@ -0,0 +1,67 @@
1
+ from pathlib import Path
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ...annotations import PathLike
5
+ # Inners
6
+ from .normalize_path import normalize_path
7
+ from ._validations import raise_path_has_no_name_error
8
+
9
+
10
+ @validate_call
11
+ def normalize_entry_path(
12
+ path: PathLike,
13
+ ) -> Path:
14
+ """Normalize the path of a directory ENTRY — resolve its folder, keep its own name as written.
15
+
16
+ `normalize_path` follows symlinks all the way, which is right when you mean
17
+ "the content behind this path". This function is for operations on the
18
+ *entry itself* — deleting, renaming, moving, checking whether a name is
19
+ taken — where a symlink in the final position must stay a symlink:
20
+ `delete_file("link")` must remove the link, not the file it points to.
21
+
22
+ Args:
23
+ path: The path of the entry.
24
+
25
+ Returns:
26
+ An absolute `Path`: `~` expanded, relative parts collapsed, symlinks
27
+ in the *directory part* resolved, the final name untouched. The entry
28
+ does not have to exist.
29
+
30
+ Raises:
31
+ ParamError: If `path` has no final name (a filesystem root).
32
+ AccessError: If the directory part cannot be resolved.
33
+ """
34
+
35
+ # 1. Make the path absolute without following a symlink in its final name
36
+ lexical = normalize_path(path, resolve_symlinks=False)
37
+
38
+ # 2. A nameless path (a root) is not an entry that can be addressed by name
39
+ if not lexical.name:
40
+ raise_path_has_no_name_error(lexical)
41
+
42
+ # 3. Resolve only the directory part — the final name stays as written
43
+ return normalize_path(lexical.parent) / lexical.name
44
+
45
+
46
+ _DESIGN_NOTES = """
47
+ # normalize_entry_path — Normalizing a Name, Not Its Target
48
+
49
+ ## Purpose
50
+ Two different questions hide behind "normalize this path":
51
+ * *Where is the content?* — `normalize_path` (symlinks resolved all the way).
52
+ * *Which directory entry is this?* — this function (the folder resolved, the
53
+ final name kept).
54
+
55
+ Operations on entries (`delete_file`, `move_file`, `make_unique_path`) need the
56
+ second one; reading and writing content needs the first.
57
+
58
+ ## Extracted From `make_unique_path`
59
+ `make_unique_path` already contained exactly this logic inline (a dangling link
60
+ `out.txt -> nowhere` must be judged as the name `out.txt`, not as `.../nowhere`).
61
+ It now calls this function, and `simplibs-file` shares it.
62
+
63
+ ## Why Not `normalize_path(resolve_symlinks=False)`
64
+ That is purely lexical: a symlinked *directory* above the entry stays
65
+ unresolved, so two spellings of the same folder compare as different. Resolving
66
+ the directory part (and only that) gives one canonical address per entry.
67
+ """
@@ -0,0 +1,85 @@
1
+ import os
2
+ from pathlib import Path
3
+ from simplibs.validate import validate_call
4
+ # Outers
5
+ from ...annotations import PathLike
6
+ # Inners
7
+ from ._validations import raise_normalize_path_error
8
+
9
+
10
+ @validate_call
11
+ def normalize_path(
12
+ path: PathLike,
13
+ *,
14
+ resolve_symlinks: bool = True,
15
+ ) -> Path:
16
+ """Normalize a path into an absolute, user-expanded `Path`.
17
+
18
+ Args:
19
+ path: The path to normalize.
20
+ resolve_symlinks: If True (default), symlinks are followed and the
21
+ real location is returned. If False, the result is only made
22
+ absolute and `..`/`.` are collapsed lexically — symlinks stay
23
+ as they are.
24
+
25
+ Returns:
26
+ An absolute `Path` — `~` expanded, relative components collapsed
27
+ and (by default) symlinks resolved. The path does not have to exist.
28
+
29
+ Raises:
30
+ AccessError: If the path cannot be resolved (an OS-level error, a
31
+ symlink loop, or invalid text such as an embedded null byte).
32
+ """
33
+
34
+ # 1. A null byte is invalid in every path — report it the same way in both modes
35
+ if "\0" in str(path):
36
+ raise_normalize_path_error(path, ValueError("embedded null byte"))
37
+
38
+ # 2. Expand user (~) and make the path absolute
39
+ try:
40
+ expanded = Path(path).expanduser()
41
+
42
+ # 2.1 Follow symlinks and collapse ".." through the real filesystem
43
+ if resolve_symlinks:
44
+ return expanded.resolve()
45
+
46
+ # 2.2 Purely lexical — absolute and collapsed, symlinks untouched
47
+ return Path(os.path.abspath(expanded))
48
+
49
+ # 3. Catch OS-level failures, symlink loops and invalid path text
50
+ except (OSError, RuntimeError, ValueError) as caught_exc:
51
+ raise_normalize_path_error(path, caught_exc)
52
+
53
+
54
+ _DESIGN_NOTES = """
55
+ # normalize_path — the Library's Single Path Normalizer
56
+
57
+ ## Purpose
58
+ Every function that touches the disk starts here, so all of them agree on
59
+ what "the same path" means. It does not require the path to exist.
60
+
61
+ ---
62
+
63
+ ## 1. Moved From `validators/`, Gained `resolve_symlinks`
64
+ The function itself is unchanged. The new keyword `resolve_symlinks=False`
65
+ covers the case where a symlinked location must be kept as written
66
+ (`data -> /mnt/storage` stays `data`, not `/mnt/storage`). The default keeps
67
+ the old behavior, which the scanners rely on: `collect_paths` resolves its
68
+ root, so entries and any root passed to `rebase_path`/`relativize_path`
69
+ agree only if both are resolved the same way.
70
+
71
+ ## 2. Why the Null Byte Is Checked Explicitly
72
+ `Path.resolve()` raises `ValueError` for it, but `os.path.abspath` (the
73
+ non-resolving branch) does not. Checking first makes both modes fail
74
+ identically, with the same card.
75
+
76
+ ## 3. Why `RuntimeError` Is Caught
77
+ Before Python 3.13 a symlink loop surfaces from `resolve()` as
78
+ `RuntimeError`, afterwards as `OSError`; an unknown `~user` is also a
79
+ `RuntimeError`. Catching all three keeps the card stable across versions.
80
+
81
+ ## 4. Errors Are `AccessError`, Not `ValidationError`
82
+ A bad *type* or a blank string never gets here — the `PathLike` annotation
83
+ rejects it with a `ValidationError` before the body runs. What remains is
84
+ the environment refusing to resolve a well-formed path.
85
+ """
@@ -0,0 +1,78 @@
1
+ from pathlib import Path
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ...annotations import PathLike
5
+ # Inners
6
+ from .change_suffix import change_suffix
7
+ from .normalize_path import normalize_path
8
+ from ._validations import raise_path_not_within_root_error
9
+
10
+
11
+ @validate_call
12
+ def rebase_path(
13
+ path: PathLike,
14
+ *,
15
+ source_root: PathLike,
16
+ target_root: PathLike,
17
+ new_suffix: str | None = None,
18
+ ) -> Path:
19
+ """Map a path inside one root directory to the same place inside another.
20
+
21
+ `src/pkg/a.py` with `source_root="src"`, `target_root="out"` becomes
22
+ `out/pkg/a.py` — the "where does the output of this input go" step of
23
+ an extraction pipeline. Nothing is created on the disk.
24
+
25
+ Args:
26
+ path: The path inside `source_root`.
27
+ source_root: The directory `path` currently lives in.
28
+ target_root: The directory the result should live in.
29
+ new_suffix: If given, the suffix of the result is replaced (see
30
+ `change_suffix`); `None` keeps the original name.
31
+
32
+ Returns:
33
+ The rebased, normalized `Path`.
34
+
35
+ Raises:
36
+ ParamError: If `path` is not located inside `source_root`, or
37
+ `new_suffix` is malformed.
38
+ AccessError: If any of the paths cannot be normalized.
39
+ """
40
+
41
+ # 1. Normalize all three paths the same way, so they are comparable
42
+ normalized_path = normalize_path(path)
43
+ normalized_source = normalize_path(source_root)
44
+ normalized_target = normalize_path(target_root)
45
+
46
+ # 2. Express the path relative to the source root
47
+ try:
48
+ relative = normalized_path.relative_to(normalized_source)
49
+ except ValueError:
50
+ raise_path_not_within_root_error(normalized_path, normalized_source)
51
+
52
+ # 3. Re-attach the relative part to the target root
53
+ rebased = normalized_target / relative
54
+
55
+ # 4. Optionally replace the suffix
56
+ if new_suffix is not None:
57
+ return change_suffix(rebased, new_suffix)
58
+ return rebased
59
+
60
+
61
+ _DESIGN_NOTES = """
62
+ # rebase_path — Source Tree to Target Tree
63
+
64
+ ## Purpose
65
+ The path arithmetic behind "mirror this tree somewhere else": each
66
+ collected input path is mapped to its output path, keeping the structure
67
+ below the root and optionally swapping the extension (`.py` → `.md`).
68
+
69
+ ## Pure Path Arithmetic
70
+ No directory is created and nothing is checked for existence. Creating the
71
+ parent directory is a separate, explicit step (`ensure_parent_dir`), so a
72
+ pipeline can compute all targets first, check them, and only then write.
73
+
74
+ ## `new_suffix=None` vs `""`
75
+ `None` means "keep the name as is"; an empty string means "remove the
76
+ suffix" — two different, both meaningful requests, which is why the
77
+ default is not an empty string.
78
+ """
@@ -0,0 +1,56 @@
1
+ from pathlib import Path
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ...annotations import PathLike
5
+ # Inners
6
+ from .normalize_path import normalize_path
7
+ from ._validations import raise_path_not_within_root_error
8
+
9
+
10
+ @validate_call
11
+ def relativize_path(
12
+ path: PathLike,
13
+ *,
14
+ root: PathLike,
15
+ ) -> Path:
16
+ """Express a path relative to a root directory.
17
+
18
+ Args:
19
+ path: The path to relativize.
20
+ root: The directory the result is relative to.
21
+
22
+ Returns:
23
+ The path relative to `root` (`Path(".")` when both are the same).
24
+
25
+ Raises:
26
+ ParamError: If `path` is not located inside `root`.
27
+ AccessError: If either path cannot be normalized.
28
+ """
29
+
30
+ # 1. Normalize both paths the same way, so they are comparable
31
+ normalized_path = normalize_path(path)
32
+ normalized_root = normalize_path(root)
33
+
34
+ # 2. Express the path relative to the root
35
+ try:
36
+ return normalized_path.relative_to(normalized_root)
37
+
38
+ # 3. Not inside the root — report it instead of a bare pathlib error
39
+ except ValueError:
40
+ raise_path_not_within_root_error(normalized_path, normalized_root)
41
+
42
+
43
+ _DESIGN_NOTES = """
44
+ # relativize_path — Path Relative to a Root
45
+
46
+ ## Purpose
47
+ Shows a collected path as it appears inside the scanned tree
48
+ (`src/a/b.py`, not `/home/me/project/src/a/b.py`) — for reports, logs and
49
+ as the first half of `rebase_path`.
50
+
51
+ ## Why Both Sides Are Normalized
52
+ `Path.relative_to` compares path parts textually. `./src` vs `/abs/src`, or
53
+ a symlinked vs. a resolved root, would wrongly fail. Normalizing both with
54
+ the same function removes that class of surprise (and makes Windows'
55
+ case-insensitive comparison apply to real, absolute parts).
56
+ """
@@ -0,0 +1,66 @@
1
+ from .filesystem import (
2
+ IsDir,
3
+ IsEmptyDir,
4
+ IsEmptyFile,
5
+ IsFile,
6
+ IsPathReadable,
7
+ IsPathWritable,
8
+ IsSymlink,
9
+ PathExists,
10
+ )
11
+ from .lexical import (
12
+ HasSuffix,
13
+ IsAbsolutePath,
14
+ IsHidden,
15
+ IsRelativePath,
16
+ IsValidFilename,
17
+ IsWithin,
18
+ NameIs,
19
+ ParentNameIs,
20
+ StemIs,
21
+ )
22
+
23
+ __all__ = [
24
+ # lexical — judge a path by its text
25
+ "HasSuffix",
26
+ "IsAbsolutePath",
27
+ "IsHidden",
28
+ "IsRelativePath",
29
+ "IsValidFilename",
30
+ "IsWithin",
31
+ "NameIs",
32
+ "ParentNameIs",
33
+ "StemIs",
34
+ # filesystem — ask the disk
35
+ "IsDir",
36
+ "IsEmptyDir",
37
+ "IsEmptyFile",
38
+ "IsFile",
39
+ "IsPathReadable",
40
+ "IsPathWritable",
41
+ "IsSymlink",
42
+ "PathExists",
43
+ ]
44
+
45
+
46
+ _DESIGN_NOTES = """
47
+ # Path Rules Sub-Package
48
+
49
+ ## Purpose
50
+ `Rule` classes for paths: filters for scanners (`include_filter`,
51
+ `exclude_filter`, `filter_paths`), annotations for `@validate_call`
52
+ (`validated_type(Path, IsDir())`) and pipeline gates (`IsDir() >> CollectFiles()`).
53
+ Each is a thin wrapper over a function in `checks/` or a pathlib call.
54
+
55
+ ## Two Groups
56
+ * `lexical/` — judge the path's *text*. Cheap; safe to run per entry.
57
+ * `filesystem/` — ask the disk. One or two syscalls per call.
58
+
59
+ ## Composition
60
+ Combine them with the usual operators: `IsFile() & HasSuffix(".py") & ~IsHidden()`.
61
+ The description of that rule reads `existing file & suffix '.py' & not hidden path`.
62
+
63
+ ## Domain Folder: `__all__`
64
+ As the other domain folders, this package defines `__all__` (the public
65
+ rules); the sub-packages and helpers are importable but not part of it.
66
+ """
@@ -0,0 +1,152 @@
1
+ from pathlib import Path
2
+ from typing import Any, Callable
3
+ from simplibs.exception import ValidationError
4
+ from simplibs.rules import AllOf, AnyOf, Compose, Rule, UserRule
5
+ # Outers
6
+ from ...logic.checks._helpers import to_path
7
+ from .._validations import raise_rule_param_not_callable_error
8
+ # Inners
9
+ from .build_not_a_path_exception import build_not_a_path_exception
10
+
11
+
12
+ class PathPartIs(Rule):
13
+ """Base of the rules that check one text part of a path with another rule.
14
+
15
+ A subclass names the part (`_part_name`) and extracts it (`_get_part`);
16
+ everything else — validation, evaluation, description, failure card —
17
+ is shared. Not meant to be used directly.
18
+ """
19
+
20
+ __slots__ = ("rule",)
21
+
22
+ _part_name: str = "part"
23
+
24
+ # ----------------------------------------------------------------------
25
+ # Part extraction (the one thing a subclass defines)
26
+ # ----------------------------------------------------------------------
27
+ @staticmethod
28
+ def _get_part(path: Path) -> str:
29
+ raise NotImplementedError
30
+
31
+ # ----------------------------------------------------------------------
32
+ # Constructor initialization
33
+ # ----------------------------------------------------------------------
34
+ def __init__(self, rule: Rule | Callable[[Any], bool]) -> None:
35
+
36
+ # 1. Parameter validation
37
+ if not callable(rule):
38
+ raise_rule_param_not_callable_error(type(self).__name__, "rule", rule)
39
+
40
+ # 2. A plain callable gets the missing Rule contract (describe, build_exception)
41
+ self.rule = rule if isinstance(rule, Rule) else UserRule(rule)
42
+
43
+ # ----------------------------------------------------------------------
44
+ # Rule definition
45
+ # ----------------------------------------------------------------------
46
+ def is_valid(self, value: Any) -> bool:
47
+
48
+ # 1. Anything that cannot be turned into a path fails
49
+ path = to_path(value)
50
+ if path is None:
51
+ return False
52
+
53
+ # 2. Let the wrapped rule judge the extracted text
54
+ return bool(self.rule.is_valid(self._get_part(path)))
55
+
56
+ # ----------------------------------------------------------------------
57
+ # Description definition
58
+ # ----------------------------------------------------------------------
59
+ def describe(self) -> str:
60
+
61
+ # 1. The wrapped rule describes itself — a faulty describe() must never break this one
62
+ # noinspection PyBroadException
63
+ try:
64
+ text = self.rule.describe()
65
+ except Exception:
66
+ text = None
67
+ if not isinstance(text, str) or not text:
68
+ text = type(self.rule).__name__
69
+
70
+ # 2. A compound operand needs parentheses so the prefix reads as covering all of it
71
+ if isinstance(self.rule, (AllOf, AnyOf, Compose)):
72
+ text = f"({text})"
73
+
74
+ # 3. "name: <rule>"
75
+ return f"{self._part_name}: {text}"
76
+
77
+ # ----------------------------------------------------------------------
78
+ # Exception definition
79
+ # ----------------------------------------------------------------------
80
+ def build_exception(
81
+ self,
82
+ value: Any,
83
+ value_name: str | None = None,
84
+ context: str | None = None,
85
+ ) -> Exception:
86
+
87
+ # 1. Prepare data
88
+ prefix = f"{self._part_name.upper().replace(' ', '_')}_IS"
89
+
90
+ # 2. Not a usable path at all
91
+ path = to_path(value)
92
+ if path is None:
93
+ return build_not_a_path_exception(
94
+ error_name=f"{prefix}_ERROR",
95
+ expected=f"a path whose {self.describe()}",
96
+ value=value,
97
+ value_name=value_name,
98
+ context=context,
99
+ )
100
+
101
+ # 3. The part fails the wrapped rule — vend the wrapped rule's own card
102
+ part = self._get_part(path)
103
+ if not self.rule.is_valid(part):
104
+ label = f"{value_name}.{self._part_name}" if value_name else self._part_name
105
+ return self.rule.build_exception(part, value_name=label, context=context)
106
+
107
+ # 4. Fallback (build exception if the part unexpectedly passed)
108
+ return ValidationError(
109
+ error_name=f"{prefix}_UNREACHABLE_ERROR",
110
+ label=value_name,
111
+ expected=f"a path whose {self.describe()}",
112
+ value=value,
113
+ problem="The path part unexpectedly passed during error construction.",
114
+ context=context,
115
+ how_to_fix=("Check validation logic state.",),
116
+ exception=RuntimeError,
117
+ )
118
+
119
+
120
+ _DESIGN_NOTES = """
121
+ # PathPartIs — a Lens From a Path to One of Its Text Parts
122
+
123
+ ## Purpose
124
+ Matching file names ("starts with `test_`", "matches this regex") needs no
125
+ new string logic: `StartsWith`, `Regex`, `Contains` and every other string
126
+ rule already exists. `NameIs(StartsWith("test_"))` simply points them at the
127
+ right piece of the path. The three lenses (`NameIs`, `StemIs`,
128
+ `ParentNameIs`) differ only in which piece; this base holds the rest.
129
+
130
+ ---
131
+
132
+ ## 1. Plain Callables Are Wrapped in `UserRule`
133
+ Same reasoning as `validate_call`'s `overrides`: a bare function has no
134
+ `describe()` and no `build_exception()`, so a failure could not be
135
+ explained. `UserRule` supplies both.
136
+
137
+ ## 2. The Failure Card Is the Wrapped Rule's Own
138
+ `NameIs(StartsWith("test_"))` on `src/a.py` reports *that `a.py` does not
139
+ start with `test_`*, labelled `source.name` — the same "delegate to
140
+ whoever actually failed" principle `AllOf` and `ForEach` use. Consequently
141
+ `exc.value` is the part, not the whole path (contract tests run with
142
+ `check_value=False`).
143
+
144
+ ## 3. Description
145
+ `name: string starting with 'test_'`. A compound operand is wrapped in
146
+ parentheses (`name: (a | b)`), exactly as `ForEach` does for `each (a | b)`.
147
+
148
+ ## 4. Why a Base Class and Not Three Copies
149
+ The three rules are 90% identical; a bug fixed in one (say, how the label
150
+ is built) must be fixed in all. `__slots__` is declared on the base and
151
+ as `()` on each subclass, so no instance carries a `__dict__`.
152
+ """
@@ -0,0 +1,21 @@
1
+ from .PathPartIs import PathPartIs
2
+ from .build_not_a_path_exception import build_not_a_path_exception
3
+ from .build_path_rule_exception import build_path_rule_exception
4
+ from .check_path import check_path
5
+
6
+
7
+ _DESIGN_NOTES = """
8
+ # Path Rules Internal Helpers Sub-Package
9
+
10
+ ## Purpose
11
+ Building blocks shared by the rules in `lexical/` and `filesystem/`.
12
+
13
+ ## Internal Components Registry
14
+
15
+ | Component | Type | Description |
16
+ | :--------------------------- | :---- | :----------------------------------------------------------------------- |
17
+ | `PathPartIs` | Class | Base of the lens rules (`NameIs`, `StemIs`, `ParentNameIs`). |
18
+ | `build_not_a_path_exception` | Function | Failure card for a value that is not a usable path. |
19
+ | `build_path_rule_exception` | Function | A path rule's whole `build_exception`, from its three sentences. |
20
+ | `check_path` | Function | Runs a filesystem check and answers `False` instead of raising. |
21
+ """
@@ -0,0 +1,66 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+ from simplibs.exception import ValidationError
4
+
5
+
6
+ def build_not_a_path_exception(
7
+ *,
8
+ error_name: str,
9
+ expected: str,
10
+ value: Any,
11
+ value_name: str | None,
12
+ context: str | None,
13
+ ) -> Exception:
14
+ """Build the failure card for a value that cannot even be treated as a path.
15
+
16
+ Args:
17
+ error_name: The calling rule's `error_name`.
18
+ expected: The calling rule's `expected` sentence.
19
+ value: The rejected value.
20
+ value_name: The label passed to `build_exception`.
21
+ context: The context passed to `build_exception`.
22
+
23
+ Returns:
24
+ A `ValidationError` — wrapping `TypeError` for a non-string, non-Path
25
+ value, and `ValueError` for a blank string or one with a null byte.
26
+ """
27
+
28
+ # 1. A str/Path that is still unusable — blank or containing a null byte
29
+ if isinstance(value, (str, Path)):
30
+ problem = f"Value {value!r} cannot be used as a path (it is blank or contains a null byte)."
31
+ how_to_fix = "Provide a non-blank path without null bytes."
32
+ exception_type = ValueError
33
+
34
+ # 2. Not a string or Path at all
35
+ else:
36
+ problem = f"Value {value!r} of type '{type(value).__name__}' is not a string or Path."
37
+ how_to_fix = "Provide a path as a str or a Path object."
38
+ exception_type = TypeError
39
+
40
+ # 3. Build the exception
41
+ return ValidationError(
42
+ error_name=error_name,
43
+ label=value_name,
44
+ expected=expected,
45
+ value=value,
46
+ problem=problem,
47
+ context=context,
48
+ how_to_fix=how_to_fix,
49
+ exception=exception_type,
50
+ )
51
+
52
+
53
+ _DESIGN_NOTES = """
54
+ # build_not_a_path_exception — Wrong Kind of Value, Said Once
55
+
56
+ ## Purpose
57
+ Every path rule has the same first failure branch: the value is not a path
58
+ at all. This builds that card for all of them, mirroring the `if not
59
+ isinstance(value, str)` branch of `Contains`/`IsString` — with the one extra
60
+ distinction a path needs: a *blank* string is a `str` and still not a path.
61
+
62
+ ## Same `error_name` in Both Branches
63
+ A rule's card keeps one `error_name` whatever went wrong (as `Contains` uses
64
+ `CONTAINS_ERROR` for both its type and its value failure); only the
65
+ wrapped `exception` type (`TypeError` / `ValueError`) differs.
66
+ """