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.
- simplibs/path/__init__.py +118 -0
- simplibs/path/actions/ChangeSuffix.py +23 -0
- simplibs/path/actions/CollectDirs.py +24 -0
- simplibs/path/actions/CollectFiles.py +25 -0
- simplibs/path/actions/CollectPaths.py +24 -0
- simplibs/path/actions/EnsureDir.py +22 -0
- simplibs/path/actions/EnsureFile.py +19 -0
- simplibs/path/actions/EnsureParentDir.py +20 -0
- simplibs/path/actions/FilterPaths.py +27 -0
- simplibs/path/actions/MakeUniquePath.py +22 -0
- simplibs/path/actions/NormalizeEntryPath.py +20 -0
- simplibs/path/actions/NormalizePath.py +23 -0
- simplibs/path/actions/RebasePath.py +24 -0
- simplibs/path/actions/RelativizePath.py +22 -0
- simplibs/path/actions/__init__.py +68 -0
- simplibs/path/annotations/PathLike.py +43 -0
- simplibs/path/annotations/PathPredicate.py +39 -0
- simplibs/path/annotations/RecursionDepth.py +33 -0
- simplibs/path/annotations/__init__.py +21 -0
- simplibs/path/logic/__init__.py +41 -0
- simplibs/path/logic/checks/__init__.py +29 -0
- simplibs/path/logic/checks/_helpers/__init__.py +21 -0
- simplibs/path/logic/checks/_helpers/_find_length_problem.py +48 -0
- simplibs/path/logic/checks/_helpers/_find_windows_name_problem.py +55 -0
- simplibs/path/logic/checks/_helpers/can_create_inside.py +39 -0
- simplibs/path/logic/checks/_helpers/constants/MAX_NAME_LENGTH.py +18 -0
- simplibs/path/logic/checks/_helpers/constants/WINDOWS_FORBIDDEN_CHARS.py +25 -0
- simplibs/path/logic/checks/_helpers/constants/WINDOWS_RESERVED_NAMES.py +27 -0
- simplibs/path/logic/checks/_helpers/constants/__init__.py +20 -0
- simplibs/path/logic/checks/_helpers/find_filename_problem.py +75 -0
- simplibs/path/logic/checks/_helpers/to_path.py +77 -0
- simplibs/path/logic/checks/is_empty_dir.py +59 -0
- simplibs/path/logic/checks/is_hidden_path.py +78 -0
- simplibs/path/logic/checks/is_path_readable.py +73 -0
- simplibs/path/logic/checks/is_path_writable.py +90 -0
- simplibs/path/logic/checks/is_valid_filename.py +47 -0
- simplibs/path/logic/checks/is_within.py +88 -0
- simplibs/path/logic/ensure/__init__.py +22 -0
- simplibs/path/logic/ensure/_validations/__init__.py +25 -0
- simplibs/path/logic/ensure/_validations/raise_ensure_dir_error.py +40 -0
- simplibs/path/logic/ensure/_validations/raise_ensure_dir_not_a_dir_error.py +36 -0
- simplibs/path/logic/ensure/_validations/raise_ensure_file_create_error.py +39 -0
- simplibs/path/logic/ensure/_validations/raise_ensure_file_not_a_file_error.py +36 -0
- simplibs/path/logic/ensure/_validations/raise_ensure_file_not_found_error.py +36 -0
- simplibs/path/logic/ensure/ensure_dir.py +83 -0
- simplibs/path/logic/ensure/ensure_file.py +103 -0
- simplibs/path/logic/ensure/ensure_parent_dir.py +58 -0
- simplibs/path/logic/scanners/__init__.py +35 -0
- simplibs/path/logic/scanners/_helpers/ScanEntry.py +36 -0
- simplibs/path/logic/scanners/_helpers/__init__.py +30 -0
- simplibs/path/logic/scanners/_helpers/as_path_predicate.py +38 -0
- simplibs/path/logic/scanners/_helpers/build_ignore_matcher.py +88 -0
- simplibs/path/logic/scanners/_helpers/generate_paths.py +74 -0
- simplibs/path/logic/scanners/_helpers/is_link_like.py +45 -0
- simplibs/path/logic/scanners/_helpers/resolve_scan_root.py +66 -0
- simplibs/path/logic/scanners/_helpers/scan_paths.py +92 -0
- simplibs/path/logic/scanners/_helpers/walk_tree.py +118 -0
- simplibs/path/logic/scanners/_validations/__init__.py +24 -0
- simplibs/path/logic/scanners/_validations/raise_collect_paths_not_a_dir_error.py +34 -0
- simplibs/path/logic/scanners/_validations/raise_collect_paths_not_found_error.py +38 -0
- simplibs/path/logic/scanners/_validations/raise_collect_paths_root_unreadable_error.py +38 -0
- simplibs/path/logic/scanners/_validations/raise_ignore_names_not_names_error.py +36 -0
- simplibs/path/logic/scanners/_validations/raise_nothing_to_collect_error.py +34 -0
- simplibs/path/logic/scanners/collect_dirs.py +62 -0
- simplibs/path/logic/scanners/collect_files.py +64 -0
- simplibs/path/logic/scanners/collect_paths.py +102 -0
- simplibs/path/logic/scanners/filter_paths.py +51 -0
- simplibs/path/logic/scanners/ignore_names/DEFAULT_IGNORE_NAMES.py +36 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_BUILD.py +48 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_ENV.py +31 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_GENERIC.py +30 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_IDE.py +20 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_SYSTEM.py +30 -0
- simplibs/path/logic/scanners/ignore_names/IGNORE_VCS.py +24 -0
- simplibs/path/logic/scanners/ignore_names/__init__.py +30 -0
- simplibs/path/logic/scanners/iter_paths.py +91 -0
- simplibs/path/logic/transformers/__init__.py +27 -0
- simplibs/path/logic/transformers/_helpers/__init__.py +17 -0
- simplibs/path/logic/transformers/_helpers/is_valid_suffix.py +37 -0
- simplibs/path/logic/transformers/_helpers/split_name.py +46 -0
- simplibs/path/logic/transformers/_validations/__init__.py +24 -0
- simplibs/path/logic/transformers/_validations/raise_invalid_suffix_error.py +36 -0
- simplibs/path/logic/transformers/_validations/raise_normalize_path_error.py +44 -0
- simplibs/path/logic/transformers/_validations/raise_path_has_no_name_error.py +36 -0
- simplibs/path/logic/transformers/_validations/raise_path_not_within_root_error.py +43 -0
- simplibs/path/logic/transformers/_validations/raise_unique_path_exhausted_error.py +39 -0
- simplibs/path/logic/transformers/change_suffix.py +77 -0
- simplibs/path/logic/transformers/make_unique_path.py +88 -0
- simplibs/path/logic/transformers/normalize_entry_path.py +67 -0
- simplibs/path/logic/transformers/normalize_path.py +85 -0
- simplibs/path/logic/transformers/rebase_path.py +78 -0
- simplibs/path/logic/transformers/relativize_path.py +56 -0
- simplibs/path/rules/__init__.py +66 -0
- simplibs/path/rules/_helpers/PathPartIs.py +152 -0
- simplibs/path/rules/_helpers/__init__.py +21 -0
- simplibs/path/rules/_helpers/build_not_a_path_exception.py +66 -0
- simplibs/path/rules/_helpers/build_path_rule_exception.py +71 -0
- simplibs/path/rules/_helpers/check_path.py +48 -0
- simplibs/path/rules/_validations/__init__.py +24 -0
- simplibs/path/rules/_validations/raise_has_suffix_invalid_suffix_error.py +35 -0
- simplibs/path/rules/_validations/raise_has_suffix_no_suffixes_error.py +31 -0
- simplibs/path/rules/_validations/raise_rule_param_not_bool_error.py +37 -0
- simplibs/path/rules/_validations/raise_rule_param_not_callable_error.py +36 -0
- simplibs/path/rules/_validations/raise_rule_param_not_path_error.py +37 -0
- simplibs/path/rules/filesystem/IsDir.py +70 -0
- simplibs/path/rules/filesystem/IsEmptyDir.py +76 -0
- simplibs/path/rules/filesystem/IsEmptyFile.py +76 -0
- simplibs/path/rules/filesystem/IsFile.py +76 -0
- simplibs/path/rules/filesystem/IsPathReadable.py +77 -0
- simplibs/path/rules/filesystem/IsPathWritable.py +82 -0
- simplibs/path/rules/filesystem/IsSymlink.py +71 -0
- simplibs/path/rules/filesystem/PathExists.py +75 -0
- simplibs/path/rules/filesystem/__init__.py +31 -0
- simplibs/path/rules/lexical/HasSuffix.py +146 -0
- simplibs/path/rules/lexical/IsAbsolutePath.py +74 -0
- simplibs/path/rules/lexical/IsHidden.py +92 -0
- simplibs/path/rules/lexical/IsRelativePath.py +74 -0
- simplibs/path/rules/lexical/IsValidFilename.py +136 -0
- simplibs/path/rules/lexical/IsWithin.py +116 -0
- simplibs/path/rules/lexical/NameIs.py +37 -0
- simplibs/path/rules/lexical/ParentNameIs.py +37 -0
- simplibs/path/rules/lexical/StemIs.py +36 -0
- simplibs/path/rules/lexical/__init__.py +33 -0
- simplibs_path-0.1.0.dist-info/METADATA +420 -0
- simplibs_path-0.1.0.dist-info/RECORD +128 -0
- simplibs_path-0.1.0.dist-info/WHEEL +5 -0
- simplibs_path-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
"""
|