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,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
|
+
"""
|