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,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Outers
|
|
4
|
+
from ...annotations import PathLike
|
|
5
|
+
# Inners
|
|
6
|
+
from ._helpers import can_create_inside, to_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def is_path_writable(
|
|
11
|
+
path: PathLike,
|
|
12
|
+
*,
|
|
13
|
+
_validate_call: bool = True,
|
|
14
|
+
) -> bool:
|
|
15
|
+
"""Check whether a path could be written to right now, without changing anything.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: The path to check — an existing file, an existing directory,
|
|
19
|
+
or a file that does not exist yet.
|
|
20
|
+
_validate_call: Internal bypass switch for `@validate_call` — rules
|
|
21
|
+
pass `False` because they have already checked the input type.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
True if an existing file can be opened for appending, if a file can
|
|
25
|
+
be created inside an existing directory, or if a missing file's
|
|
26
|
+
parent directory exists and accepts new files. False otherwise —
|
|
27
|
+
including a missing parent directory (nothing is created here).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# 1. Anything that cannot be turned into a path is not writable
|
|
31
|
+
candidate = to_path(path)
|
|
32
|
+
if candidate is None:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
# 2. Probe the real target — the only check that matches a later write
|
|
36
|
+
try:
|
|
37
|
+
|
|
38
|
+
# 2.1 An existing directory — can a file be created inside?
|
|
39
|
+
if candidate.is_dir():
|
|
40
|
+
return can_create_inside(candidate)
|
|
41
|
+
|
|
42
|
+
# 2.2 An existing file — append mode never changes its content
|
|
43
|
+
if candidate.is_file():
|
|
44
|
+
with candidate.open("ab"):
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
# 2.3 Something else already occupies the name (broken symlink, device)
|
|
48
|
+
if os.path.lexists(candidate):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
# 2.4 A file that does not exist yet — its parent must accept new files
|
|
52
|
+
parent = candidate.parent
|
|
53
|
+
return parent.is_dir() and can_create_inside(parent)
|
|
54
|
+
|
|
55
|
+
# 3. Permission and OS-level failures mean "not writable"
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_DESIGN_NOTES = """
|
|
61
|
+
# is_path_writable — Writable Without Side Effects
|
|
62
|
+
|
|
63
|
+
## Purpose
|
|
64
|
+
The predicate behind `IsPathWritable`: "if I write here next, will the OS
|
|
65
|
+
let me?".
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 1. Fixes Two Problems of the Old Version
|
|
70
|
+
The former version opened *any* path in append mode. For a path that did not
|
|
71
|
+
exist that **created an empty file** — a "check" with a lasting side effect,
|
|
72
|
+
contradicting its own docstring. It also reported `False` for every
|
|
73
|
+
directory. Now a missing file is judged by probing its parent directory
|
|
74
|
+
(`can_create_inside`, which leaves no trace), and a directory is writable
|
|
75
|
+
when files can be created inside it.
|
|
76
|
+
|
|
77
|
+
## 2. Existing Files: Append Mode
|
|
78
|
+
`open("ab")` fails on a read-only file, locked file or denied ACL, yet
|
|
79
|
+
neither truncates nor modifies anything.
|
|
80
|
+
|
|
81
|
+
## 3. Missing Parent Means False
|
|
82
|
+
Creating parent directories is `ensure_parent_dir`'s job. "Is it writable
|
|
83
|
+
*now*?" is honestly answered `False` when the folder is not there; a
|
|
84
|
+
pipeline that wants to create it first simply orders the steps that way.
|
|
85
|
+
|
|
86
|
+
## 4. Not a Reservation
|
|
87
|
+
The answer holds at the moment of the call. Another process can change
|
|
88
|
+
permissions or fill the disk before the actual write, so a real write must
|
|
89
|
+
still handle its own errors.
|
|
90
|
+
"""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from simplibs.validate import validate_call
|
|
2
|
+
# Inners
|
|
3
|
+
from ._helpers import find_filename_problem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@validate_call
|
|
7
|
+
def is_valid_filename(
|
|
8
|
+
name: str,
|
|
9
|
+
*,
|
|
10
|
+
portable: bool = True,
|
|
11
|
+
_validate_call: bool = True,
|
|
12
|
+
) -> bool:
|
|
13
|
+
"""Check whether a string is a usable single file or directory name.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
name: The candidate name — a name only, not a path.
|
|
17
|
+
portable: If True (default), the name must be valid on both Windows
|
|
18
|
+
and POSIX systems. If False, only the current OS's rules apply.
|
|
19
|
+
_validate_call: Internal bypass switch for `@validate_call` — rules
|
|
20
|
+
pass `False` because they have already checked the input type.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if the name has no problem (see `find_filename_problem` for the
|
|
24
|
+
exact rules); False otherwise.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# 1. A name is valid exactly when no problem is found
|
|
28
|
+
return find_filename_problem(name, portable=portable) is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_DESIGN_NOTES = """
|
|
32
|
+
# is_valid_filename — Is This String Safe as a File Name?
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
For code that builds file names from content (titles, identifiers, user
|
|
36
|
+
input) and must not produce a name the filesystem refuses or silently alters.
|
|
37
|
+
|
|
38
|
+
## Thin on Purpose
|
|
39
|
+
All rules live in `find_filename_problem`, which also explains *why* a name
|
|
40
|
+
fails — the `IsValidFilename` rule shows that text in its failure card. This
|
|
41
|
+
function only turns "no problem" into `True`.
|
|
42
|
+
|
|
43
|
+
## A Name, Not a Path
|
|
44
|
+
`"dir/file.txt"` is not a valid *name* (it contains a separator). Check
|
|
45
|
+
a path's last part with `Path(...).name` first, or use the rule, which does
|
|
46
|
+
that for `Path` values.
|
|
47
|
+
"""
|
|
@@ -0,0 +1,88 @@
|
|
|
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 ._helpers import to_path
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def is_within(
|
|
11
|
+
path: PathLike,
|
|
12
|
+
root: PathLike,
|
|
13
|
+
*,
|
|
14
|
+
resolve_symlinks: bool = True,
|
|
15
|
+
allow_equal: bool = True,
|
|
16
|
+
_validate_call: bool = True,
|
|
17
|
+
) -> bool:
|
|
18
|
+
"""Check whether a path lies inside a root directory, without touching its content.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: The path to test.
|
|
22
|
+
root: The directory it is supposed to be inside.
|
|
23
|
+
resolve_symlinks: If True (default), symlinks and `..` are resolved
|
|
24
|
+
before comparing, so a link pointing out of `root` does not
|
|
25
|
+
count as inside. If False, the comparison is purely lexical.
|
|
26
|
+
allow_equal: Whether a path equal to `root` itself counts as inside.
|
|
27
|
+
_validate_call: Internal bypass switch for `@validate_call` — rules
|
|
28
|
+
pass `False` because they have already checked the input type.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if `path` is inside `root` (or equal to it, when allowed).
|
|
32
|
+
Never raises for unresolvable input — that is simply False.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# 1. Anything that cannot be turned into a path is not inside anything
|
|
36
|
+
candidate = to_path(path)
|
|
37
|
+
base = to_path(root)
|
|
38
|
+
if candidate is None or base is None:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
# 2. Make both absolute the same way
|
|
42
|
+
try:
|
|
43
|
+
|
|
44
|
+
# 2.1 Follow symlinks and collapse ".." through the real filesystem
|
|
45
|
+
if resolve_symlinks:
|
|
46
|
+
candidate = candidate.resolve()
|
|
47
|
+
base = base.resolve()
|
|
48
|
+
|
|
49
|
+
# 2.2 Purely lexical — absolute and collapsed, symlinks untouched
|
|
50
|
+
else:
|
|
51
|
+
candidate = Path(os.path.abspath(candidate))
|
|
52
|
+
base = Path(os.path.abspath(base))
|
|
53
|
+
|
|
54
|
+
# 3. Paths that cannot be resolved cannot be proven inside
|
|
55
|
+
except (OSError, RuntimeError, ValueError):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# 4. Equal paths are decided by the flag, everything else by containment
|
|
59
|
+
if candidate == base:
|
|
60
|
+
return allow_equal
|
|
61
|
+
return candidate.is_relative_to(base)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_DESIGN_NOTES = """
|
|
65
|
+
# is_within — Containment Without String Tricks
|
|
66
|
+
|
|
67
|
+
## Purpose
|
|
68
|
+
The guard against path traversal ("`../../etc/passwd` must not leave the
|
|
69
|
+
output folder") and the predicate behind `IsWithin`.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 1. Why Not `str(path).startswith(str(root))`
|
|
74
|
+
`/data/src2` starts with `/data/src` but is not inside it, `..` segments
|
|
75
|
+
hide an escape, and Windows compares case-insensitively. `Path.is_relative_to`
|
|
76
|
+
on normalized absolute paths compares whole path *parts*, with the right
|
|
77
|
+
case rules for the platform.
|
|
78
|
+
|
|
79
|
+
## 2. Safe by Default: Symlinks Are Resolved
|
|
80
|
+
A symlink inside `root` that points outside it must not pass. Resolving both
|
|
81
|
+
sides handles that. The cost is a few syscalls per call, so
|
|
82
|
+
`resolve_symlinks=False` exists for filters over paths that `collect_paths`
|
|
83
|
+
already produced in normalized form.
|
|
84
|
+
|
|
85
|
+
## 3. Unresolvable Means False
|
|
86
|
+
A security check that raises can be skipped by a broad `except`; one that
|
|
87
|
+
answers `False` fails closed.
|
|
88
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .ensure_dir import ensure_dir
|
|
2
|
+
from .ensure_file import ensure_file
|
|
3
|
+
from .ensure_parent_dir import ensure_parent_dir
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_DESIGN_NOTES = """
|
|
7
|
+
# Path Ensure Sub-Package
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
Functions that make a filesystem state true — a directory exists, a file
|
|
11
|
+
exists, a path's parent exists — and return the normalized path. These are
|
|
12
|
+
the library's only functions that change the disk (besides what the caller
|
|
13
|
+
writes itself), which is why they live apart from the pure `transformers/`.
|
|
14
|
+
|
|
15
|
+
## Internal Components Registry
|
|
16
|
+
|
|
17
|
+
| Component | Type | Description |
|
|
18
|
+
| :------------------ | :------- | :----------------------------------------------------------------------- |
|
|
19
|
+
| `ensure_dir` | Function | Guarantees a directory exists, creating it and its parents. |
|
|
20
|
+
| `ensure_file` | Function | Guarantees a file exists, optionally creating it and its parent directory. |
|
|
21
|
+
| `ensure_parent_dir` | Function | Guarantees the directory a path lives in exists; returns the path itself. |
|
|
22
|
+
"""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .raise_ensure_dir_error import raise_ensure_dir_error
|
|
2
|
+
from .raise_ensure_dir_not_a_dir_error import raise_ensure_dir_not_a_dir_error
|
|
3
|
+
from .raise_ensure_file_create_error import raise_ensure_file_create_error
|
|
4
|
+
from .raise_ensure_file_not_a_file_error import raise_ensure_file_not_a_file_error
|
|
5
|
+
from .raise_ensure_file_not_found_error import raise_ensure_file_not_found_error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_DESIGN_NOTES = """
|
|
9
|
+
# Path Ensure Diagnostic Helpers Sub-Package
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
Internal diagnostic helpers raising structured exceptions for the
|
|
13
|
+
functions in `ensure/` (`ensure_dir`, `ensure_file`, `ensure_parent_dir`).
|
|
14
|
+
These five files moved here unchanged from `validators/_validations/`.
|
|
15
|
+
|
|
16
|
+
## Internal Components Registry
|
|
17
|
+
|
|
18
|
+
| Component | Type | Description |
|
|
19
|
+
| :------------------------------------- | :------- | :--------------------------------------------------------------- |
|
|
20
|
+
| `raise_ensure_dir_error` | Function | Raises `AccessError` when a directory cannot be created. |
|
|
21
|
+
| `raise_ensure_dir_not_a_dir_error` | Function | Raises `StateError` when a file blocks a wanted directory. |
|
|
22
|
+
| `raise_ensure_file_create_error` | Function | Raises `AccessError` when a file cannot be created. |
|
|
23
|
+
| `raise_ensure_file_not_a_file_error` | Function | Raises `StateError` when a directory blocks a wanted file. |
|
|
24
|
+
| `raise_ensure_file_not_found_error` | Function | Raises `NotFoundError` for a missing file when `create=False`. |
|
|
25
|
+
"""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
from simplibs.exception import AccessError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_ensure_dir_error(
|
|
7
|
+
path: Path,
|
|
8
|
+
caught_exc: Exception,
|
|
9
|
+
) -> NoReturn:
|
|
10
|
+
"""Raise AccessError when directory creation fails due to OS or permission errors."""
|
|
11
|
+
|
|
12
|
+
# 1. Raise structured AccessError wrapping the caught OS exception
|
|
13
|
+
raise AccessError(
|
|
14
|
+
error_name="DIRECTORY_CREATION_FAILED",
|
|
15
|
+
label=f"directory '{path}'",
|
|
16
|
+
value=str(path),
|
|
17
|
+
problem=(
|
|
18
|
+
f"Failed to guarantee directory at '{path}'.",
|
|
19
|
+
f"OS reported: {caught_exc}",
|
|
20
|
+
),
|
|
21
|
+
expected="A writable parent filesystem location where directory can be created.",
|
|
22
|
+
how_to_fix=(
|
|
23
|
+
f"Check write permissions for parent directory '{path.parent}'.",
|
|
24
|
+
"Verify that the storage device is mounted and has sufficient free space.",
|
|
25
|
+
),
|
|
26
|
+
exception=type(caught_exc),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DESIGN_NOTES = """
|
|
31
|
+
# raise_ensure_dir_error — Directory Creation Validation Helper
|
|
32
|
+
|
|
33
|
+
## Purpose
|
|
34
|
+
Provides a structured exception when `ensure_dir` encounters an OS-level or
|
|
35
|
+
permission error while attempting to create a directory tree using `mkdir`.
|
|
36
|
+
|
|
37
|
+
## Integration
|
|
38
|
+
- **Caller**: `ensure_dir` in `simplibs/path/base/ensure_dir.py`.
|
|
39
|
+
- Emits a structured `AccessError` wrapping the caught exception instance.
|
|
40
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
from simplibs.exception import StateError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_ensure_dir_not_a_dir_error(path: Path) -> NoReturn:
|
|
7
|
+
"""Raise StateError when a path exists but is a file instead of a directory."""
|
|
8
|
+
|
|
9
|
+
# 1. Raise structured StateError wrapping a NotADirectoryError exception
|
|
10
|
+
raise StateError(
|
|
11
|
+
error_name="PATH_IS_NOT_A_DIRECTORY",
|
|
12
|
+
label=f"path '{path}'",
|
|
13
|
+
value=str(path),
|
|
14
|
+
problem=(
|
|
15
|
+
f"Expected directory at '{path}', but an existing file was found at this location.",
|
|
16
|
+
),
|
|
17
|
+
expected="A directory path or a non-existent path where a directory can be created.",
|
|
18
|
+
how_to_fix=(
|
|
19
|
+
"Provide a path to a directory or a location that does not yet exist.",
|
|
20
|
+
f"Remove or rename the existing file at '{path}' if it should be replaced by a directory.",
|
|
21
|
+
),
|
|
22
|
+
exception=NotADirectoryError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_DESIGN_NOTES = """
|
|
27
|
+
# raise_ensure_dir_not_a_dir_error — Directory Type Validation Helper
|
|
28
|
+
|
|
29
|
+
## Purpose
|
|
30
|
+
Provides a structured exception when `ensure_dir` encounters an existing filesystem item
|
|
31
|
+
at the given target path that is a file rather than a directory.
|
|
32
|
+
|
|
33
|
+
## Integration
|
|
34
|
+
- **Caller**: `ensure_dir` in `simplibs/path/base/ensure_dir.py`.
|
|
35
|
+
- Emits a structured `StateError` wrapping a `NotADirectoryError`.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
from simplibs.exception import AccessError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_ensure_file_create_error(
|
|
7
|
+
path: Path,
|
|
8
|
+
caught_exc: Exception,
|
|
9
|
+
) -> NoReturn:
|
|
10
|
+
"""Raise AccessError when file creation fails due to OS or permission errors."""
|
|
11
|
+
|
|
12
|
+
# 1. Raise structured AccessError wrapping the caught OS exception
|
|
13
|
+
raise AccessError(
|
|
14
|
+
error_name="FILE_CREATION_FAILED",
|
|
15
|
+
label=f"file '{path}'",
|
|
16
|
+
value=str(path),
|
|
17
|
+
problem=(
|
|
18
|
+
f"Failed to create file at '{path}'.",
|
|
19
|
+
f"OS reported: {caught_exc}",
|
|
20
|
+
),
|
|
21
|
+
expected="A writable directory location where a new file can be created.",
|
|
22
|
+
how_to_fix=(
|
|
23
|
+
f"Check write permissions for directory '{path.parent}'.",
|
|
24
|
+
"Verify that the storage device is accessible and not read-only.",
|
|
25
|
+
),
|
|
26
|
+
exception=type(caught_exc),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DESIGN_NOTES = """
|
|
31
|
+
# raise_ensure_file_create_error — File Creation Validation Helper
|
|
32
|
+
|
|
33
|
+
## Purpose
|
|
34
|
+
Provides a structured exception when `ensure_file` fails to touch or create a file on disk.
|
|
35
|
+
|
|
36
|
+
## Integration
|
|
37
|
+
- **Caller**: `ensure_file` in `simplibs/path/base/ensure_file.py`.
|
|
38
|
+
- Emits a structured `AccessError` wrapping the caught exception instance.
|
|
39
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
from simplibs.exception import StateError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_ensure_file_not_a_file_error(path: Path) -> NoReturn:
|
|
7
|
+
"""Raise StateError when a path exists but is a directory instead of a file."""
|
|
8
|
+
|
|
9
|
+
# 1. Raise structured StateError wrapping an IsADirectoryError exception
|
|
10
|
+
raise StateError(
|
|
11
|
+
error_name="PATH_IS_NOT_A_FILE",
|
|
12
|
+
label=f"path '{path}'",
|
|
13
|
+
value=str(path),
|
|
14
|
+
problem=(
|
|
15
|
+
f"Expected file at '{path}', but an existing directory was found at this location.",
|
|
16
|
+
),
|
|
17
|
+
expected="A regular file path or a non-existent path where a file can be created.",
|
|
18
|
+
how_to_fix=(
|
|
19
|
+
"Provide a path pointing to a file rather than a directory.",
|
|
20
|
+
f"Remove or rename the existing directory at '{path}' if a file is intended.",
|
|
21
|
+
),
|
|
22
|
+
exception=IsADirectoryError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_DESIGN_NOTES = """
|
|
27
|
+
# raise_ensure_file_not_a_file_error — File Type Validation Helper
|
|
28
|
+
|
|
29
|
+
## Purpose
|
|
30
|
+
Provides a structured exception when `ensure_file` encounters an existing directory
|
|
31
|
+
where a regular file was expected.
|
|
32
|
+
|
|
33
|
+
## Integration
|
|
34
|
+
- **Caller**: `ensure_file` in `simplibs/path/base/ensure_file.py`.
|
|
35
|
+
- Emits a structured `StateError` wrapping an `IsADirectoryError`.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import NoReturn
|
|
3
|
+
from simplibs.exception import NotFoundError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def raise_ensure_file_not_found_error(path: Path) -> NoReturn:
|
|
7
|
+
"""Raise NotFoundError when a required file is missing and create=False."""
|
|
8
|
+
|
|
9
|
+
# 1. Raise structured NotFoundError wrapping a FileNotFoundError exception
|
|
10
|
+
raise NotFoundError(
|
|
11
|
+
error_name="FILE_NOT_FOUND",
|
|
12
|
+
label=f"file '{path}'",
|
|
13
|
+
value=str(path),
|
|
14
|
+
problem=(
|
|
15
|
+
f"Required file '{path}' does not exist on disk.",
|
|
16
|
+
"Automatic creation was disabled because `create=False` was passed.",
|
|
17
|
+
),
|
|
18
|
+
expected="An existing file path, or call ensure_file with create=True.",
|
|
19
|
+
how_to_fix=(
|
|
20
|
+
f"Ensure the file exists at '{path}' before invoking ensure_file.",
|
|
21
|
+
"Set `create=True` if you want ensure_file to create missing files automatically.",
|
|
22
|
+
),
|
|
23
|
+
exception=FileNotFoundError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_DESIGN_NOTES = """
|
|
28
|
+
# raise_ensure_file_not_found_error — File Absence Validation Helper
|
|
29
|
+
|
|
30
|
+
## Purpose
|
|
31
|
+
Provides a structured exception when `ensure_file` is called with `create=False` and the target file does not exist.
|
|
32
|
+
|
|
33
|
+
## Integration
|
|
34
|
+
- **Caller**: `ensure_file` in `simplibs/path/base/ensure_file.py`.
|
|
35
|
+
- Emits a structured `NotFoundError` wrapping a `FileNotFoundError`.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from simplibs.validate import validate_call
|
|
4
|
+
# Outers
|
|
5
|
+
from ...annotations import PathLike
|
|
6
|
+
from ..transformers import normalize_path
|
|
7
|
+
# Inners
|
|
8
|
+
from ._validations import raise_ensure_dir_error, raise_ensure_dir_not_a_dir_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@validate_call
|
|
12
|
+
def ensure_dir(
|
|
13
|
+
path: PathLike,
|
|
14
|
+
) -> Path:
|
|
15
|
+
"""Guarantee that a directory exists, creating it (and any missing
|
|
16
|
+
parents) if necessary.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
path: The directory path to guarantee.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The normalized, guaranteed-to-exist directory `Path`.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
StateError: If something that is not a directory (a file) already
|
|
26
|
+
occupies the path.
|
|
27
|
+
AccessError: If the directory cannot be created due to
|
|
28
|
+
insufficient permissions or another OS-level failure.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# 1. Normalize the target path
|
|
32
|
+
normalized_path = normalize_path(path)
|
|
33
|
+
|
|
34
|
+
# 2. Already exists — must be a directory, not a file
|
|
35
|
+
if normalized_path.exists():
|
|
36
|
+
if not normalized_path.is_dir():
|
|
37
|
+
raise_ensure_dir_not_a_dir_error(normalized_path)
|
|
38
|
+
return normalized_path
|
|
39
|
+
|
|
40
|
+
# 3. Create the directory (and parents) if missing
|
|
41
|
+
try:
|
|
42
|
+
normalized_path.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# 4. Creation failed
|
|
45
|
+
except OSError as caught_exc:
|
|
46
|
+
|
|
47
|
+
# 4.1 Something else took the name meanwhile (a file created in the
|
|
48
|
+
# gap since step 2) — a state problem, not a permission problem
|
|
49
|
+
if os.path.lexists(normalized_path) and not normalized_path.is_dir():
|
|
50
|
+
raise_ensure_dir_not_a_dir_error(normalized_path)
|
|
51
|
+
|
|
52
|
+
# 4.2 Permissions, a read-only disk, a file in the parent chain, ...
|
|
53
|
+
raise_ensure_dir_error(normalized_path, caught_exc)
|
|
54
|
+
|
|
55
|
+
# 5. Return the guaranteed directory
|
|
56
|
+
return normalized_path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DESIGN_NOTES = """
|
|
60
|
+
# ensure_dir — "This Directory Exists Now"
|
|
61
|
+
|
|
62
|
+
## Purpose
|
|
63
|
+
Turns "I need this folder" into one step that either succeeds or explains
|
|
64
|
+
why not. Returns the normalized path, so it chains
|
|
65
|
+
(`NormalizePath >> EnsureDir >> CollectFiles`).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 1. Moved From `validators/`, Two Small Corrections
|
|
70
|
+
* `except (PermissionError, OSError)` is now `except OSError` —
|
|
71
|
+
`PermissionError` is a subclass, the tuple said nothing extra.
|
|
72
|
+
* The race "a file appeared between the check and `mkdir`" used to surface
|
|
73
|
+
as `AccessError` with a how-to-fix about permissions. It is now reported
|
|
74
|
+
as the `StateError` it really is (step 4.1).
|
|
75
|
+
|
|
76
|
+
## 2. A Symlink Is Followed
|
|
77
|
+
`normalize_path` resolves symlinks first, so `ensure_dir("link")` for a
|
|
78
|
+
dangling link creates the directory the link *points to*. That is the
|
|
79
|
+
consistent meaning of "this path" everywhere in the library.
|
|
80
|
+
|
|
81
|
+
## 3. Idempotent
|
|
82
|
+
Calling it on an existing directory touches nothing and costs one `stat`.
|
|
83
|
+
"""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from simplibs.validate import validate_call
|
|
4
|
+
# Outers
|
|
5
|
+
from ...annotations import PathLike
|
|
6
|
+
from ..transformers import normalize_path
|
|
7
|
+
# Inners
|
|
8
|
+
from .ensure_dir import ensure_dir
|
|
9
|
+
from ._validations import (
|
|
10
|
+
raise_ensure_file_create_error,
|
|
11
|
+
raise_ensure_file_not_a_file_error,
|
|
12
|
+
raise_ensure_file_not_found_error,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@validate_call
|
|
17
|
+
def ensure_file(
|
|
18
|
+
path: PathLike,
|
|
19
|
+
*,
|
|
20
|
+
create: bool = True,
|
|
21
|
+
) -> Path:
|
|
22
|
+
"""Guarantee that a path points to a file, optionally creating it
|
|
23
|
+
(and its parent directory) if missing.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: The file path to guarantee.
|
|
27
|
+
create: If True (default), create the file and its parent
|
|
28
|
+
directory when missing. If False, a missing file raises
|
|
29
|
+
instead.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The normalized, guaranteed-to-exist file `Path`.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
StateError: If the path exists but is a directory instead of a
|
|
36
|
+
file, or a parent in the chain is a file.
|
|
37
|
+
NotFoundError: If the file is missing and `create=False`.
|
|
38
|
+
AccessError: If the file cannot be created due to missing permissions
|
|
39
|
+
or an OS-level failure.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# 1. Normalize the target path
|
|
43
|
+
normalized_path = normalize_path(path)
|
|
44
|
+
|
|
45
|
+
# 2. Already exists — must be a file, not a directory
|
|
46
|
+
if normalized_path.exists():
|
|
47
|
+
if not normalized_path.is_file():
|
|
48
|
+
raise_ensure_file_not_a_file_error(normalized_path)
|
|
49
|
+
return normalized_path
|
|
50
|
+
|
|
51
|
+
# 3. Missing, and creation was not requested
|
|
52
|
+
if not create:
|
|
53
|
+
raise_ensure_file_not_found_error(normalized_path)
|
|
54
|
+
|
|
55
|
+
# 4. Missing, creation requested — guarantee the parent directory first
|
|
56
|
+
ensure_dir(normalized_path.parent)
|
|
57
|
+
|
|
58
|
+
# 5. Create the empty file
|
|
59
|
+
try:
|
|
60
|
+
normalized_path.touch(exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# 6. Creation failed
|
|
63
|
+
except OSError as caught_exc:
|
|
64
|
+
|
|
65
|
+
# 6.1 A directory took the name meanwhile (created in the gap since step 2)
|
|
66
|
+
if os.path.lexists(normalized_path) and not normalized_path.is_file():
|
|
67
|
+
raise_ensure_file_not_a_file_error(normalized_path)
|
|
68
|
+
|
|
69
|
+
# 6.2 Permissions, a read-only disk, ...
|
|
70
|
+
raise_ensure_file_create_error(normalized_path, caught_exc)
|
|
71
|
+
|
|
72
|
+
# 7. Return the normalized, guaranteed-to-exist file
|
|
73
|
+
return normalized_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_DESIGN_NOTES = """
|
|
77
|
+
# ensure_file — "This File Exists Now"
|
|
78
|
+
|
|
79
|
+
## Purpose
|
|
80
|
+
The file counterpart of `ensure_dir`: returns a path that is certainly a
|
|
81
|
+
file, creating an empty one (and the folders above it) when allowed.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 1. Moved From `validators/`, Same Two Corrections as `ensure_dir`
|
|
86
|
+
`except OSError` instead of the redundant tuple, and the "a directory took
|
|
87
|
+
the name between the check and `touch`" race is reported as a `StateError`
|
|
88
|
+
(step 6.1).
|
|
89
|
+
|
|
90
|
+
## 2. Existing Content Is Never Touched
|
|
91
|
+
`touch(exist_ok=True)` only runs for a path that did not exist. An existing
|
|
92
|
+
file is returned as it is — no truncation, no timestamp change.
|
|
93
|
+
|
|
94
|
+
## 3. `create=False` — A Pure Assertion
|
|
95
|
+
With creation off the function becomes "this file must already be there":
|
|
96
|
+
it returns the path, or raises `NotFoundError`. That makes it usable as a
|
|
97
|
+
precondition step in front of a read.
|
|
98
|
+
|
|
99
|
+
## 4. A File in the Parent Chain
|
|
100
|
+
`ensure_file("a_file/new.txt")` fails inside `ensure_dir` with a
|
|
101
|
+
`StateError` naming the blocking file — the real cause, not a vague
|
|
102
|
+
creation failure.
|
|
103
|
+
"""
|