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,30 @@
1
+ from typing import Final
2
+
3
+
4
+ IGNORE_SYSTEM: Final[frozenset[str]] = frozenset({
5
+ "$RECYCLE.BIN", # Windows recycle bin
6
+ ".DS_Store", # macOS folder metadata
7
+ ".fseventsd", # macOS file system events log
8
+ ".Spotlight-V100", # macOS Spotlight index
9
+ ".TemporaryItems", # macOS temporary items
10
+ ".Trashes", # macOS trash directory
11
+ "System Volume Information", # Windows volume metadata
12
+ "Thumbs.db", # Windows thumbnail cache
13
+ "desktop.ini", # Windows folder customization
14
+ "ehthumbs.db", # Windows Media Center thumbnails
15
+ })
16
+ """Operating-system housekeeping files and folders (both files and directories)."""
17
+
18
+
19
+ _DESIGN_NOTES = """
20
+ # IGNORE_SYSTEM — OS Housekeeping
21
+
22
+ ## Purpose
23
+ Entries the operating system creates on its own. Most are *files*
24
+ (`.DS_Store`, `Thumbs.db`) — the reason the ignore mechanism now applies to
25
+ files as well as directories; previously this whole group was dead.
26
+
27
+ ## Windows Coverage
28
+ `$RECYCLE.BIN` and `System Volume Information` matter when scanning from the
29
+ root of a drive; they are unreadable for normal users anyway.
30
+ """
@@ -0,0 +1,24 @@
1
+ from typing import Final
2
+
3
+
4
+ IGNORE_VCS: Final[frozenset[str]] = frozenset({
5
+ ".bzr",
6
+ ".git",
7
+ ".hg",
8
+ ".svn",
9
+ })
10
+ """Metadata directories of version control systems."""
11
+
12
+
13
+ _DESIGN_NOTES = """
14
+ # IGNORE_VCS — Version Control Metadata
15
+
16
+ ## Purpose
17
+ Names of the directories where Git, Mercurial, Subversion and Bazaar keep
18
+ their internals. They hold no project content, often thousands of objects,
19
+ and are never what a scan is looking for.
20
+
21
+ ## Unchanged
22
+ Same content as the former `ignore_dirs/IGNORE_VCS`; only the package was
23
+ renamed (`ignore_dirs` → `ignore_names`).
24
+ """
@@ -0,0 +1,30 @@
1
+ from .DEFAULT_IGNORE_NAMES import DEFAULT_IGNORE_NAMES
2
+ from .IGNORE_BUILD import IGNORE_BUILD
3
+ from .IGNORE_ENV import IGNORE_ENV
4
+ from .IGNORE_GENERIC import IGNORE_GENERIC
5
+ from .IGNORE_IDE import IGNORE_IDE
6
+ from .IGNORE_SYSTEM import IGNORE_SYSTEM
7
+ from .IGNORE_VCS import IGNORE_VCS
8
+
9
+
10
+ _DESIGN_NOTES = """
11
+ # Scanner Ignore Names Sub-Package
12
+
13
+ ## Purpose
14
+ Named sets of entry names (files and directories) that scanners skip. Names
15
+ containing `*`, `?` or `[` are glob patterns; all others match exactly
16
+ (case-insensitively on Windows). Renamed from `ignore_dirs`, because the
17
+ sets contain files too.
18
+
19
+ ## Internal Components Registry
20
+
21
+ | Component | Type | Description |
22
+ | :--------------------- | :------- | :---------------------------------------------------------------- |
23
+ | `DEFAULT_IGNORE_NAMES` | Constant | Union of VCS, ENV, BUILD, IDE and SYSTEM — the scanners' default. |
24
+ | `IGNORE_BUILD` | Constant | Caches, coverage data and tool output with unmistakable names. |
25
+ | `IGNORE_ENV` | Constant | Virtual environments, installed dependencies, `.env`. |
26
+ | `IGNORE_GENERIC` | Constant | Ambiguous output-folder names (`build`, `dist`, ...) — opt-in. |
27
+ | `IGNORE_IDE` | Constant | Editor and IDE settings. |
28
+ | `IGNORE_SYSTEM` | Constant | OS housekeeping (`.DS_Store`, `Thumbs.db`, ...). |
29
+ | `IGNORE_VCS` | Constant | Version control metadata (`.git`, `.svn`, ...). |
30
+ """
@@ -0,0 +1,91 @@
1
+ from collections.abc import Collection, Iterator
2
+ from pathlib import Path
3
+ from simplibs.validate import validate_call
4
+ # Outers
5
+ from ...annotations import PathLike, PathPredicate, RecursionDepth
6
+ # Inners
7
+ from ._helpers import scan_paths
8
+ from .ignore_names import DEFAULT_IGNORE_NAMES
9
+
10
+
11
+ @validate_call
12
+ def iter_paths(
13
+ root_dir: PathLike,
14
+ *,
15
+ recursive: RecursionDepth = True,
16
+ include_filter: PathPredicate | None = None,
17
+ exclude_filter: PathPredicate | None = None,
18
+ ignore_names: Collection[str] | None = DEFAULT_IGNORE_NAMES,
19
+ collect_files: bool = True,
20
+ collect_dirs: bool = False,
21
+ ) -> Iterator[Path]:
22
+ """Lazily yield the paths of a directory tree, one at a time.
23
+
24
+ The memory-friendly sibling of `collect_paths`: nothing is held back, so
25
+ a huge tree can be processed as it is discovered, and the walk can be
26
+ stopped early. The results are NOT sorted and come in no promised order.
27
+
28
+ Args:
29
+ root_dir: The root directory to scan.
30
+ recursive: `True` for unlimited depth, `False` for the root's own
31
+ entries only, or an `int` for a maximum depth (0 = root's own
32
+ entries, 1 = one level deeper, ...).
33
+ include_filter: A `Rule` or `(Path) -> bool` callable. A path must
34
+ satisfy it to be yielded.
35
+ exclude_filter: A `Rule` or `(Path) -> bool` callable. A path that
36
+ satisfies it is skipped (checked before `include_filter`).
37
+ Excluding a directory does not hide what is inside it.
38
+ ignore_names: Entry names (files and directories) or glob patterns
39
+ (`*.egg-info`) to skip entirely — an ignored directory is not
40
+ entered. Pass a collection, never a bare string; `None` ignores
41
+ nothing.
42
+ collect_files: Whether files are yielded.
43
+ collect_dirs: Whether directories are yielded.
44
+
45
+ Returns:
46
+ A lazy iterator of absolute `Path`s. The root and the options are
47
+ checked when `iter_paths` is called, not at the first `next()`.
48
+
49
+ Raises:
50
+ ParamError: If both collect flags are False, or `ignore_names` is a string.
51
+ NotFoundError: If `root_dir` does not exist.
52
+ StateError: If `root_dir` is not a directory.
53
+ AccessError: If `root_dir` cannot be listed.
54
+ """
55
+
56
+ # 1. Prepare everything now; only the walk itself is lazy
57
+ return scan_paths(
58
+ root_dir,
59
+ recursive=recursive,
60
+ include_filter=include_filter,
61
+ exclude_filter=exclude_filter,
62
+ ignore_names=ignore_names,
63
+ collect_files=collect_files,
64
+ collect_dirs=collect_dirs,
65
+ )
66
+
67
+
68
+ _DESIGN_NOTES = """
69
+ # iter_paths — the Lazy Scanner
70
+
71
+ ## Purpose
72
+ `collect_paths` must finish the whole walk before returning anything.
73
+ For a large tree, or a pipeline that only needs the first match, that is
74
+ wasteful. `iter_paths` hands out paths as they are found.
75
+
76
+ ## No Action Counterpart
77
+ An `Action` takes one value and returns one value; a generator is consumed
78
+ once and cannot be safely logged, validated or re-run by the action
79
+ machinery. Pipelines use `CollectPaths`/`CollectFiles`/`CollectDirs`, which
80
+ return a list.
81
+
82
+ ## Eager Errors, Lazy Results
83
+ `iter_paths` is an ordinary function that returns a generator, not a
84
+ generator function itself. A generator function would run none of its body —
85
+ including the root checks — until the first `next()`, so a typo in the path
86
+ would surface in unrelated code, later.
87
+
88
+ ## Order
89
+ Not sorted, not promised. Anything that needs a stable order uses
90
+ `collect_paths`.
91
+ """
@@ -0,0 +1,27 @@
1
+ from .change_suffix import change_suffix
2
+ from .make_unique_path import make_unique_path
3
+ from .normalize_entry_path import normalize_entry_path
4
+ from .normalize_path import normalize_path
5
+ from .rebase_path import rebase_path
6
+ from .relativize_path import relativize_path
7
+
8
+
9
+ _DESIGN_NOTES = """
10
+ # Path Transformers Sub-Package
11
+
12
+ ## Purpose
13
+ Functions that turn one path into another: normalize it, change its suffix,
14
+ move it between two root directories, express it relative to a root, or find
15
+ a free name for it. None of them changes the disk.
16
+
17
+ ## Internal Components Registry
18
+
19
+ | Component | Type | Description |
20
+ | :---------------------- | :------- | :-------------------------------------------------------------------- |
21
+ | `change_suffix` | Function | Replaces, adds or removes the last suffix of a path. |
22
+ | `make_unique_path` | Function | Returns a path, or a numbered variant of it, that does not exist yet. |
23
+ | `normalize_entry_path` | Function | Resolves the directory part while preserving the entry's final name. |
24
+ | `normalize_path` | Function | Absolute, `~`-expanded, collapsed (and by default resolved) path. |
25
+ | `rebase_path` | Function | Maps a path from a source root to a target root. |
26
+ | `relativize_path` | Function | Expresses a path relative to a root. |
27
+ """
@@ -0,0 +1,17 @@
1
+ from .is_valid_suffix import is_valid_suffix
2
+ from .split_name import split_name
3
+
4
+
5
+ _DESIGN_NOTES = """
6
+ # Path Transformers Internal Helpers Sub-Package
7
+
8
+ ## Purpose
9
+ Small helpers private to `transformers/`.
10
+
11
+ ## Internal Components Registry
12
+
13
+ | Component | Type | Description |
14
+ | :---------------- | :------- | :------------------------------------------------------------------------- |
15
+ | `is_valid_suffix` | Function | Whether a string has the shape of a file suffix (`""`, `.txt`, `.tar.gz`). |
16
+ | `split_name` | Function | Splits a final name into base name and suffix (`.tar.gz` aware). |
17
+ """
@@ -0,0 +1,37 @@
1
+ def is_valid_suffix(suffix: str) -> bool:
2
+ """Check whether a string has the shape of a file suffix.
3
+
4
+ Args:
5
+ suffix: The candidate suffix.
6
+
7
+ Returns:
8
+ True for an empty string (meaning "no suffix") or a string such as
9
+ `.txt` / `.tar.gz` — a dot followed by at least one character and no
10
+ path separator or null byte. False otherwise.
11
+ """
12
+
13
+ # 1. An empty suffix means "no suffix" — valid
14
+ if suffix == "":
15
+ return True
16
+
17
+ # 2. Otherwise: a dot plus at least one character, without separators or null bytes
18
+ return (
19
+ len(suffix) > 1
20
+ and suffix.startswith(".")
21
+ and not any(char in suffix for char in ("/", "\\", "\0"))
22
+ )
23
+
24
+
25
+ _DESIGN_NOTES = """
26
+ # is_valid_suffix — One Definition of "a Suffix"
27
+
28
+ ## Purpose
29
+ `change_suffix` (a suffix to put on a path) and the `HasSuffix` rule (a
30
+ suffix to look for) must agree on what a suffix is. The shape check used to
31
+ live inline in `change_suffix`; it moved here so both share one definition
32
+ and cannot drift apart.
33
+
34
+ ## Not an Extension Registry
35
+ Any dot-prefixed text is accepted (`.tar.gz`, `.v2`). Whether it is a
36
+ *real* extension is not this function's business.
37
+ """
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def split_name(path: Path) -> tuple[str, str]:
5
+ """Split a path's final name into a base name and its (extension) suffix.
6
+
7
+ Args:
8
+ path: The path whose final name is split.
9
+
10
+ Returns:
11
+ `(stem, suffix)`. The suffix is the last extension (`".txt"`), except
12
+ for compound tar archives, where it is the whole `".tar.gz"`.
13
+ `("report", ".txt")`, `("archive", ".tar.gz")`, `(".bashrc", "")`.
14
+ """
15
+
16
+ # 1. Plain split — the last extension only
17
+ stem = path.stem
18
+ suffix = path.suffix
19
+
20
+ # 2. Compound tar archives keep ".tar" together with the compression suffix
21
+ if suffix and len(stem) > 4 and stem.lower().endswith(".tar"):
22
+ return stem[:-4], stem[-4:] + suffix
23
+
24
+ # 3. Everything else
25
+ return stem, suffix
26
+
27
+
28
+ _DESIGN_NOTES = """
29
+ # split_name — Name/Suffix Split for Numbered Variants
30
+
31
+ ## Purpose
32
+ `make_unique_path` inserts " (1)" between the base name and the extension.
33
+ With pathlib's plain `stem`/`suffix`, `archive.tar.gz` would become
34
+ `archive.tar (1).gz` — a name whose "real" extension is lost. Treating
35
+ `.tar.<compression>` as one suffix yields `archive (1).tar.gz`.
36
+
37
+ ## Why Only `.tar` Is Special
38
+ Other multi-dot names (`report.v2.final.txt`) cannot be told apart from a
39
+ base name that simply contains dots, so they keep the last extension only:
40
+ `report.v2.final (1).txt`. The result is still correct and still opens with
41
+ the right program — only `.tar.*` is common enough to deserve a rule.
42
+
43
+ ## Hidden Files
44
+ `.bashrc` has no suffix in pathlib's view (its whole name is the stem), so
45
+ the number goes at the end: `.bashrc (1)`.
46
+ """
@@ -0,0 +1,24 @@
1
+ from .raise_invalid_suffix_error import raise_invalid_suffix_error
2
+ from .raise_normalize_path_error import raise_normalize_path_error
3
+ from .raise_path_has_no_name_error import raise_path_has_no_name_error
4
+ from .raise_path_not_within_root_error import raise_path_not_within_root_error
5
+ from .raise_unique_path_exhausted_error import raise_unique_path_exhausted_error
6
+
7
+
8
+ _DESIGN_NOTES = """
9
+ # Path Transformers Diagnostic Helpers Sub-Package
10
+
11
+ ## Purpose
12
+ Internal diagnostic helpers raising structured exceptions for the functions
13
+ in `transformers/`.
14
+
15
+ ## Internal Components Registry
16
+
17
+ | Component | Type | Description |
18
+ | :---------------------------------- | :------- | :----------------------------------------------------------------------- |
19
+ | `raise_invalid_suffix_error` | Function | Raises `ParamError` for a malformed new suffix (`change_suffix`). |
20
+ | `raise_normalize_path_error` | Function | Raises `AccessError` when a path cannot be resolved (`normalize_path`). |
21
+ | `raise_path_has_no_name_error` | Function | Raises `ParamError` for a root, `.` or `..` (`change_suffix`, `make_unique_path`). |
22
+ | `raise_path_not_within_root_error` | Function | Raises `ParamError` when a path is outside its root (`rebase_path`, `relativize_path`). |
23
+ | `raise_unique_path_exhausted_error` | Function | Raises `OperationError` when no free numbered name exists (`make_unique_path`). |
24
+ """
@@ -0,0 +1,36 @@
1
+ from typing import NoReturn
2
+ from simplibs.exception import ParamError
3
+
4
+
5
+ def raise_invalid_suffix_error(suffix: str) -> NoReturn:
6
+ """Raise ParamError when a new file suffix has an invalid shape."""
7
+
8
+ # 1. Raise structured ParamError wrapping a ValueError
9
+ raise ParamError(
10
+ error_name="INVALID_PATH_SUFFIX",
11
+ label="new_suffix",
12
+ value=suffix,
13
+ problem=(
14
+ f"Suffix {suffix!r} is not a valid file suffix.",
15
+ "A suffix must be empty, or start with a dot followed by at least one character, "
16
+ "without any path separator.",
17
+ ),
18
+ expected="An empty string (removes the suffix) or a string such as '.txt'.",
19
+ how_to_fix=(
20
+ "Start the suffix with a dot, e.g. '.txt' instead of 'txt'.",
21
+ "Remove path separators ('/' or '\\\\') from the suffix.",
22
+ "Use an empty string if the suffix should be removed.",
23
+ ),
24
+ exception=ValueError,
25
+ )
26
+
27
+
28
+ _DESIGN_NOTES = """
29
+ # raise_invalid_suffix_error — change_suffix Argument Diagnostic
30
+
31
+ ## Purpose
32
+ `Path.with_suffix` has its own errors, but they are terse and differ between
33
+ Python versions. Validating the suffix up front gives one consistent,
34
+ actionable card — and prevents a suffix such as '/etc' from silently
35
+ changing the directory part of the path.
36
+ """
@@ -0,0 +1,44 @@
1
+ from typing import Any, NoReturn
2
+ from simplibs.exception import AccessError
3
+
4
+
5
+ def raise_normalize_path_error(
6
+ path: Any,
7
+ caught_exc: Exception,
8
+ ) -> NoReturn:
9
+ """Raise AccessError when path normalization or resolution fails."""
10
+
11
+ # 1. Raise structured AccessError wrapping the caught resolution exception instance
12
+ raise AccessError(
13
+ error_name="PATH_NORMALIZATION_FAILED",
14
+ label=f"path '{path}'",
15
+ value=str(path),
16
+ problem=(
17
+ f"Could not resolve or normalize path '{path}'.",
18
+ # "The underlying operating system or resolver reported an error.",
19
+ f"OS reported: {caught_exc}",
20
+ ),
21
+ expected="A valid path string or Path instance that can be resolved on this system.",
22
+ how_to_fix=(
23
+ "Check for recursive or circular symlinks on the path.",
24
+ "Ensure the path contains no null bytes or characters invalid for the operating system.",
25
+ "Ensure the path format is valid for the operating system.",
26
+ ),
27
+ exception=caught_exc,
28
+ )
29
+
30
+
31
+ _DESIGN_NOTES = """
32
+ # raise_normalize_path_error — normalize_path Failure Diagnostic
33
+
34
+ ## Purpose
35
+ Single point of failure for "the path could not be resolved". Raised from
36
+ `normalize_path` for OS-level errors, symlink loops (`RuntimeError` before
37
+ Python 3.13, `OSError` after) and invalid path text such as an embedded null
38
+ byte (`ValueError`).
39
+
40
+ ## Renamed From `raise_ensure_normalize_path_error`
41
+ `normalize_path` ensures nothing — the old `ensure` prefix was a leftover of
42
+ the former `validators/` folder. Only the name and one added how-to-fix
43
+ line (null bytes) changed.
44
+ """
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+ from typing import NoReturn
3
+ from simplibs.exception import ParamError
4
+
5
+
6
+ def raise_path_has_no_name_error(path: Path) -> NoReturn:
7
+ """Raise ParamError when a path has no final name component to work with."""
8
+
9
+ # 1. Raise structured ParamError wrapping a ValueError (what pathlib raises too)
10
+ raise ParamError(
11
+ error_name="PATH_HAS_NO_NAME",
12
+ label=f"path '{path}'",
13
+ value=str(path),
14
+ problem=(
15
+ f"Path '{path}' has no final name component.",
16
+ "It is empty, the current directory ('.'), '..', or a filesystem root.",
17
+ ),
18
+ expected="A path ending with a file or directory name, e.g. 'dir/report.txt'.",
19
+ how_to_fix=(
20
+ "Provide a path that ends with a file or directory name.",
21
+ "Normalize the path with `normalize_path` first if it is '.' or '..'.",
22
+ ),
23
+ exception=ValueError,
24
+ )
25
+
26
+
27
+ _DESIGN_NOTES = """
28
+ # raise_path_has_no_name_error — Nameless Path Diagnostic
29
+
30
+ ## Purpose
31
+ `change_suffix` and `make_unique_path` both work on a path's final name. A
32
+ filesystem root (`/`, `C:\\`), `.` and `..` have no usable name:
33
+ `Path("/").with_suffix(".x")` raises a terse pathlib error, and
34
+ `Path("..").with_suffix(".x")` does not raise at all but produces the
35
+ nonsense name `...x`. One consistent card covers both cases.
36
+ """
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+ from typing import NoReturn
3
+ from simplibs.exception import ParamError
4
+
5
+
6
+ def raise_path_not_within_root_error(
7
+ path: Path,
8
+ root: Path,
9
+ ) -> NoReturn:
10
+ """Raise ParamError when a path cannot be expressed relative to a root directory."""
11
+
12
+ # 1. Raise structured ParamError wrapping a ValueError (what Path.relative_to raises)
13
+ raise ParamError(
14
+ error_name="PATH_NOT_WITHIN_ROOT",
15
+ label=f"path '{path}'",
16
+ value=str(path),
17
+ problem=(
18
+ f"Path '{path}' is not located inside the root directory '{root}'.",
19
+ "The path cannot be expressed relative to this root.",
20
+ ),
21
+ expected=f"A path located inside '{root}'.",
22
+ how_to_fix=(
23
+ f"Provide a path that starts with '{root}'.",
24
+ "Check that both paths are absolute, or both are relative to the same base.",
25
+ "Normalize both paths with `normalize_path` before comparing them.",
26
+ ),
27
+ exception=ValueError,
28
+ )
29
+
30
+
31
+ _DESIGN_NOTES = """
32
+ # raise_path_not_within_root_error — Containment Failure Diagnostic
33
+
34
+ ## Purpose
35
+ Shared by `rebase_path` (path vs. `source_root`) and `relativize_path`
36
+ (path vs. `root`): both need "this path lives under that root" and fail the
37
+ same way when it does not.
38
+
39
+ ## Why ParamError With a ValueError
40
+ The caller supplied a combination of arguments that cannot work together —
41
+ a parameter problem. `ValueError` is what `Path.relative_to` itself raises,
42
+ so code that already catches it keeps working.
43
+ """
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+ from typing import NoReturn
3
+ from simplibs.exception import OperationError
4
+
5
+
6
+ def raise_unique_path_exhausted_error(
7
+ path: Path,
8
+ attempts: int,
9
+ ) -> NoReturn:
10
+ """Raise OperationError when no free numbered variant of a path was found."""
11
+
12
+ # 1. Raise structured OperationError wrapping a FileExistsError
13
+ raise OperationError(
14
+ error_name="UNIQUE_PATH_EXHAUSTED",
15
+ label=f"path '{path}'",
16
+ value=str(path),
17
+ problem=(
18
+ f"Could not find a free name for '{path}' after {attempts} attempts.",
19
+ "Every numbered candidate already exists.",
20
+ ),
21
+ expected="A path with at least one free numbered variant within the attempt limit.",
22
+ how_to_fix=(
23
+ "Clean up the existing numbered variants of this path.",
24
+ "Use a different base name or target directory.",
25
+ "Raise `max_attempts` if that many variants are really needed.",
26
+ ),
27
+ exception=FileExistsError,
28
+ )
29
+
30
+
31
+ _DESIGN_NOTES = """
32
+ # raise_unique_path_exhausted_error — make_unique_path Diagnostic
33
+
34
+ ## Purpose
35
+ `make_unique_path` tries "name (1).ext", "name (2).ext", ... up to
36
+ `max_attempts`. A hard limit is a safety net against an endless loop on a
37
+ pathological directory; reaching it is an operation failure, not a bad
38
+ argument, hence OperationError.
39
+ """
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+ from simplibs.validate import validate_call
3
+ # Outers
4
+ from ...annotations import PathLike
5
+ # Inners
6
+ from ._helpers import is_valid_suffix
7
+
8
+ from ._validations import raise_invalid_suffix_error, raise_path_has_no_name_error
9
+
10
+
11
+ @validate_call
12
+ def change_suffix(
13
+ path: PathLike,
14
+ new_suffix: str,
15
+ ) -> Path:
16
+ """Replace the last suffix of a path (or add / remove one).
17
+
18
+ Pure path arithmetic: nothing is read from or written to the disk and
19
+ `~` is not expanded — combine with `normalize_path` if you need that.
20
+
21
+ Args:
22
+ path: The path whose suffix changes.
23
+ new_suffix: The new suffix, including the dot (`".md"`), or an
24
+ empty string to remove the current suffix.
25
+
26
+ Returns:
27
+ A new `Path`; only the last suffix is replaced, so
28
+ `a.tar.gz` + `".zip"` becomes `a.tar.zip`.
29
+
30
+ Raises:
31
+ ParamError: If `new_suffix` is malformed, or `path` has no final
32
+ name (a root, `.` or `..`).
33
+ """
34
+
35
+ # 1. Validate the shape of the new suffix
36
+ if not is_valid_suffix(new_suffix):
37
+ raise_invalid_suffix_error(new_suffix)
38
+
39
+ # 2. The path needs a final name to carry a suffix
40
+ result = Path(path)
41
+ if result.name in ("", ".."):
42
+ raise_path_has_no_name_error(result)
43
+
44
+ # 3. Replace, add or remove the suffix
45
+ return result.with_suffix(new_suffix)
46
+
47
+
48
+ _DESIGN_NOTES = """
49
+ # change_suffix — Suffix Replacement With Up-Front Validation
50
+
51
+ ## Purpose
52
+ A thin, safe wrapper over `Path.with_suffix`, existing as its own function
53
+ (and `ChangeSuffix` action) so that "write the result next to the source,
54
+ but as .md" is one named step in a pipeline.
55
+
56
+ ---
57
+
58
+ ## 1. Why Validate Before Calling `with_suffix`
59
+ `with_suffix` has its own checks, but they are terse, differ slightly
60
+ between Python versions, and let `..` through (producing `...md`). Validating
61
+ first gives one consistent card and rules out a suffix such as `"/etc"`,
62
+ which could otherwise reach the directory part of a path.
63
+
64
+ ## 2. The Shape Check Is Shared
65
+ `is_valid_suffix` (package `_helpers/`) defines what a suffix looks like;
66
+ the `HasSuffix` rule uses the very same function.
67
+
68
+ ## 3. Backslash Is Rejected on Every Platform
69
+ It is a legal filename character on POSIX, but a path separator on Windows;
70
+ a suffix containing it cannot be portable, and portability is the point of
71
+ this library's filename handling.
72
+
73
+ ## 4. Lexical on Purpose
74
+ No `expanduser`, no `resolve`, no disk access: a transformer that only
75
+ changes a name has no business touching the filesystem. In a pipeline,
76
+ `NormalizePath() >> ChangeSuffix(new_suffix=".md")` does both explicitly.
77
+ """