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,29 @@
|
|
|
1
|
+
from .is_empty_dir import is_empty_dir
|
|
2
|
+
from .is_hidden_path import is_hidden_path
|
|
3
|
+
from .is_path_readable import is_path_readable
|
|
4
|
+
from .is_path_writable import is_path_writable
|
|
5
|
+
from .is_valid_filename import is_valid_filename
|
|
6
|
+
from .is_within import is_within
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_DESIGN_NOTES = """
|
|
10
|
+
# Path Checks Sub-Package
|
|
11
|
+
|
|
12
|
+
## Purpose
|
|
13
|
+
Plain predicate functions with real logic behind them. Each returns a `bool`
|
|
14
|
+
and never raises for disk problems; the matching `Rule` classes in `rules/`
|
|
15
|
+
are thin wrappers over them. Checks that are a single pathlib call
|
|
16
|
+
(`exists`, `is_file`, `is_dir`, ...) have no function here — the rule calls
|
|
17
|
+
pathlib directly.
|
|
18
|
+
|
|
19
|
+
## Internal Components Registry
|
|
20
|
+
|
|
21
|
+
| Component | Type | Description |
|
|
22
|
+
| :----------------- | :------- | :-------------------------------------------------------------------- |
|
|
23
|
+
| `is_empty_dir` | Function | An existing, listable directory with no entries. |
|
|
24
|
+
| `is_hidden_path` | Function | The final name starts with a dot (optionally: Windows hidden attribute). |
|
|
25
|
+
| `is_path_readable` | Function | A file that can be opened, or a directory that can be listed. |
|
|
26
|
+
| `is_path_writable` | Function | A path that could be written to now — with no side effects. |
|
|
27
|
+
| `is_valid_filename`| Function | A string usable as a file name (portable by default). |
|
|
28
|
+
| `is_within` | Function | A path lies inside a root directory (symlink-safe by default). |
|
|
29
|
+
"""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .can_create_inside import can_create_inside
|
|
2
|
+
from .find_filename_problem import find_filename_problem
|
|
3
|
+
from .to_path import to_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_DESIGN_NOTES = """
|
|
7
|
+
# Path Checks Internal Helpers Sub-Package
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
Building blocks private to `checks/`.
|
|
11
|
+
|
|
12
|
+
## Internal Components Registry
|
|
13
|
+
|
|
14
|
+
| Component | Type | Description |
|
|
15
|
+
| :--------------------------- | :------- | :--------------------------------------------------------------------- |
|
|
16
|
+
| `can_create_inside` | Function | Probes whether a file can be created in a directory, leaving no trace. |
|
|
17
|
+
| `find_filename_problem` | Function | Why a string is not a usable file name (`None` if it is). |
|
|
18
|
+
| `_find_length_problem` | Function | Why a name is too long for POSIX and/or Windows. |
|
|
19
|
+
| `_find_windows_name_problem` | Function | Windows-specific name problems (forbidden characters, device names). |
|
|
20
|
+
| `to_path` | Function | Safely converts a candidate (`str` or `Path`) into a `Path` or `None`. |
|
|
21
|
+
"""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Inners
|
|
2
|
+
from .constants import MAX_NAME_LENGTH
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _find_length_problem(
|
|
6
|
+
name: str,
|
|
7
|
+
*,
|
|
8
|
+
windows: bool,
|
|
9
|
+
posix: bool,
|
|
10
|
+
) -> str | None:
|
|
11
|
+
"""Return why a name is too long, or None if its length is acceptable.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
name: A single file or directory name (no path).
|
|
15
|
+
windows: Apply the NTFS limit (255 UTF-16 code units).
|
|
16
|
+
posix: Apply the limit of most POSIX filesystems (255 bytes in UTF-8).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A short description of the problem, or `None`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# 1. POSIX filesystems count bytes — multi-byte characters use more of the limit
|
|
23
|
+
if posix and len(name.encode("utf-8", "surrogatepass")) > MAX_NAME_LENGTH:
|
|
24
|
+
return f"is longer than {MAX_NAME_LENGTH} bytes in UTF-8"
|
|
25
|
+
|
|
26
|
+
# 2. NTFS counts UTF-16 code units — two bytes each
|
|
27
|
+
if windows and len(name.encode("utf-16-le", "surrogatepass")) // 2 > MAX_NAME_LENGTH:
|
|
28
|
+
return f"is longer than {MAX_NAME_LENGTH} UTF-16 characters"
|
|
29
|
+
|
|
30
|
+
# 3. Within the limit
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DESIGN_NOTES = """
|
|
35
|
+
# find_length_problem — Name Length on Both Platform Families
|
|
36
|
+
|
|
37
|
+
## Purpose
|
|
38
|
+
The two limits are different units of the same number: bytes on POSIX,
|
|
39
|
+
UTF-16 units on Windows. A name of 128 `é` characters is 256 bytes (too long
|
|
40
|
+
on ext4) but only 128 units (fine on NTFS), so each platform family gets its
|
|
41
|
+
own count and a *portable* check applies both.
|
|
42
|
+
|
|
43
|
+
## `surrogatepass`
|
|
44
|
+
POSIX names may contain undecodable bytes that Python represents as lone
|
|
45
|
+
surrogates. Encoding them strictly would raise; `surrogatepass` encodes
|
|
46
|
+
them anyway, so a weird-but-existing name is measured, not rejected by
|
|
47
|
+
accident.
|
|
48
|
+
"""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Inners
|
|
2
|
+
from .constants import WINDOWS_FORBIDDEN_CHARS, WINDOWS_RESERVED_NAMES
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _find_windows_name_problem(name: str) -> str | None:
|
|
6
|
+
"""Return why a name is invalid on Windows, or None if it is fine there.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
name: A single file or directory name (no path).
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
A short description of the first problem found, or `None`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# 1. A backslash is a path separator on Windows
|
|
16
|
+
if "\\" in name:
|
|
17
|
+
return "contains a path separator '\\'"
|
|
18
|
+
|
|
19
|
+
# 2. Control characters and characters Windows forbids in names
|
|
20
|
+
for char in name:
|
|
21
|
+
if ord(char) < 32:
|
|
22
|
+
return f"contains a control character (code {ord(char)})"
|
|
23
|
+
if char in WINDOWS_FORBIDDEN_CHARS:
|
|
24
|
+
return f"contains the character {char!r}, which Windows forbids in names"
|
|
25
|
+
|
|
26
|
+
# 3. Windows silently strips a trailing dot or space
|
|
27
|
+
if name.endswith((".", " ")):
|
|
28
|
+
return "ends with a dot or a space, which Windows removes"
|
|
29
|
+
|
|
30
|
+
# 4. Reserved device names — the part before the first dot decides
|
|
31
|
+
base = name.split(".", 1)[0].rstrip(" ").upper()
|
|
32
|
+
if base in WINDOWS_RESERVED_NAMES:
|
|
33
|
+
return f"'{base}' is a reserved Windows device name"
|
|
34
|
+
|
|
35
|
+
# 5. Nothing wrong
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_DESIGN_NOTES = """
|
|
40
|
+
# find_windows_name_problem — Windows-Specific Name Rules
|
|
41
|
+
|
|
42
|
+
## Purpose
|
|
43
|
+
The Windows half of the filename check, separated from the platform-neutral
|
|
44
|
+
rules so `find_filename_problem` can apply it on Windows, or everywhere when
|
|
45
|
+
a *portable* name is wanted.
|
|
46
|
+
|
|
47
|
+
## Returns Text, Not a Bool
|
|
48
|
+
The same function feeds both `is_valid_filename` (needs a yes/no) and the
|
|
49
|
+
`IsValidFilename` rule's failure card (needs the reason). Returning the
|
|
50
|
+
reason once serves both and keeps a single source of truth.
|
|
51
|
+
|
|
52
|
+
## Order of Checks
|
|
53
|
+
Cheapest and most specific first; the reserved-name test comes last because
|
|
54
|
+
it needs the string split and upper-cased.
|
|
55
|
+
"""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def can_create_inside(directory: Path) -> bool:
|
|
6
|
+
"""Check whether a file can be created inside a directory — and leave no trace.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
directory: An existing directory.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
True if an anonymous temporary file could be created (and was
|
|
13
|
+
removed again); False if the OS refused.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# 1. Create and immediately discard an anonymous temporary file
|
|
17
|
+
try:
|
|
18
|
+
with tempfile.TemporaryFile(dir=directory):
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
# 2. Any permission or OS-level refusal means "not writable"
|
|
22
|
+
except OSError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_DESIGN_NOTES = """
|
|
27
|
+
# can_create_inside — the Honest Write Probe for a Directory
|
|
28
|
+
|
|
29
|
+
## Purpose
|
|
30
|
+
`os.access(path, W_OK)` is unreliable for directories on Windows (it mostly
|
|
31
|
+
reports "yes") and ignores ACLs and read-only mounts everywhere. Actually
|
|
32
|
+
creating a file is the only check that matches what a later write will
|
|
33
|
+
experience.
|
|
34
|
+
|
|
35
|
+
## Why It Leaves No Trace
|
|
36
|
+
`TemporaryFile` is unlinked on creation on POSIX and opened with
|
|
37
|
+
`O_TEMPORARY` on Windows: it never appears under a name that survives, even
|
|
38
|
+
if the process is killed in the middle.
|
|
39
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
MAX_NAME_LENGTH: Final[int] = 255
|
|
5
|
+
"""Longest single file or directory name that common filesystems accept.
|
|
6
|
+
|
|
7
|
+
255 bytes on ext4/APFS-style filesystems, 255 UTF-16 code units on NTFS.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DESIGN_NOTES = """
|
|
12
|
+
# MAX_NAME_LENGTH — the 255 Limit
|
|
13
|
+
|
|
14
|
+
## Purpose
|
|
15
|
+
One named number for `find_length_problem`, instead of a bare 255 in the
|
|
16
|
+
code. It limits a single *name*, not a whole path (a full Windows path has a
|
|
17
|
+
separate, much larger limit that is not checked here).
|
|
18
|
+
"""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
WINDOWS_FORBIDDEN_CHARS: Final[frozenset[str]] = frozenset('<>:"|?*')
|
|
5
|
+
"""Characters Windows does not allow inside a file name.
|
|
6
|
+
|
|
7
|
+
The path separators `/` and `\\` are handled separately (they are never
|
|
8
|
+
valid in a *name* on any platform / on Windows respectively), as are
|
|
9
|
+
control characters.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_DESIGN_NOTES = """
|
|
14
|
+
# WINDOWS_FORBIDDEN_CHARS — Characters NTFS Rejects
|
|
15
|
+
|
|
16
|
+
## Purpose
|
|
17
|
+
The set `find_windows_name_problem` checks each character against. Kept
|
|
18
|
+
as its own constant, like the other name lists in this library, so it can
|
|
19
|
+
be read (and tested) at a glance.
|
|
20
|
+
|
|
21
|
+
## Why `:` Is Included
|
|
22
|
+
On Windows a colon in a name is not an error at the API level — it opens an
|
|
23
|
+
NTFS alternate data stream (`file.txt:hidden`). For a portable filename
|
|
24
|
+
check that is exactly the surprise to rule out.
|
|
25
|
+
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
WINDOWS_RESERVED_NAMES: Final[frozenset[str]] = frozenset(
|
|
5
|
+
{"CON", "PRN", "AUX", "NUL"}
|
|
6
|
+
| {f"COM{number}" for number in range(10)}
|
|
7
|
+
| {f"LPT{number}" for number in range(10)}
|
|
8
|
+
)
|
|
9
|
+
"""Device names Windows reserves, in upper case.
|
|
10
|
+
|
|
11
|
+
A file name is reserved when its part before the first dot, ignoring case
|
|
12
|
+
and trailing spaces, is one of these — `con`, `CON.txt` and `nul.log` all are.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DESIGN_NOTES = """
|
|
17
|
+
# WINDOWS_RESERVED_NAMES — Legacy DOS Device Names
|
|
18
|
+
|
|
19
|
+
## Purpose
|
|
20
|
+
`CON`, `PRN`, `AUX`, `NUL`, `COM0`–`COM9`, `LPT0`–`LPT9` still refer to
|
|
21
|
+
devices on Windows, whatever extension follows. Creating a file with such a
|
|
22
|
+
name fails or silently writes to the device — a classic portability trap
|
|
23
|
+
for tools that generate file names from content.
|
|
24
|
+
|
|
25
|
+
## Upper Case, Matched Case-Insensitively
|
|
26
|
+
The names are stored in upper case; the caller upper-cases the candidate.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .MAX_NAME_LENGTH import MAX_NAME_LENGTH
|
|
2
|
+
from .WINDOWS_FORBIDDEN_CHARS import WINDOWS_FORBIDDEN_CHARS
|
|
3
|
+
from .WINDOWS_RESERVED_NAMES import WINDOWS_RESERVED_NAMES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_DESIGN_NOTES = """
|
|
7
|
+
# Path Checks Constants Sub-Package
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
Platform-specific and universal name constraints (lengths and forbidden elements)
|
|
11
|
+
used during filename validation.
|
|
12
|
+
|
|
13
|
+
## Internal Components Registry
|
|
14
|
+
|
|
15
|
+
| Component | Type | Description |
|
|
16
|
+
| :------------------------ | :------- | :------------------------------------------------------------------- |
|
|
17
|
+
| `MAX_NAME_LENGTH` | Constant | The 255 limit on a single name. |
|
|
18
|
+
| `WINDOWS_FORBIDDEN_CHARS` | Constant | Characters Windows forbids in names. |
|
|
19
|
+
| `WINDOWS_RESERVED_NAMES` | Constant | Reserved Windows device names (`CON`, `NUL`, `COM1`, ...). |
|
|
20
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
# Inners
|
|
3
|
+
from ._find_length_problem import _find_length_problem
|
|
4
|
+
from ._find_windows_name_problem import _find_windows_name_problem
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_filename_problem(
|
|
8
|
+
name: str,
|
|
9
|
+
*,
|
|
10
|
+
portable: bool = True,
|
|
11
|
+
) -> str | None:
|
|
12
|
+
"""Return why a string is not a usable file name, or None if it is.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
name: A single file or directory name (no path).
|
|
16
|
+
portable: If True (default), the name must be valid on both Windows
|
|
17
|
+
and POSIX systems. If False, only the rules of the current
|
|
18
|
+
operating system apply.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A short description of the first problem found
|
|
22
|
+
(`"contains the character '?', which Windows forbids in names"`),
|
|
23
|
+
or `None` when the name is acceptable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# 1. Rules that hold on every platform
|
|
27
|
+
if not name.strip():
|
|
28
|
+
return "is empty or blank"
|
|
29
|
+
if name in (".", ".."):
|
|
30
|
+
return f"'{name}' is a reserved directory reference"
|
|
31
|
+
if "/" in name:
|
|
32
|
+
return "contains a path separator '/'"
|
|
33
|
+
if "\0" in name:
|
|
34
|
+
return "contains a null byte"
|
|
35
|
+
|
|
36
|
+
# 2. Decide which platform families apply
|
|
37
|
+
is_windows = os.name == "nt"
|
|
38
|
+
check_windows = portable or is_windows
|
|
39
|
+
check_posix = portable or not is_windows
|
|
40
|
+
|
|
41
|
+
# 3. Windows-specific rules
|
|
42
|
+
if check_windows:
|
|
43
|
+
problem = _find_windows_name_problem(name)
|
|
44
|
+
if problem is not None:
|
|
45
|
+
return problem
|
|
46
|
+
|
|
47
|
+
# 4. Length, counted the way each applicable platform counts it
|
|
48
|
+
return _find_length_problem(name, windows=check_windows, posix=check_posix)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DESIGN_NOTES = """
|
|
52
|
+
# find_filename_problem — the One Source of Truth for Name Validity
|
|
53
|
+
|
|
54
|
+
## Purpose
|
|
55
|
+
Answers "is this string a usable file name?" with a reason, so both
|
|
56
|
+
`is_valid_filename` (bool) and the `IsValidFilename` rule's failure card
|
|
57
|
+
(text) are built on the same check.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 1. Portable by Default
|
|
62
|
+
This library generates names from content (extraction output), so the
|
|
63
|
+
default is the strictest union of Windows and POSIX rules: a name that passes
|
|
64
|
+
works everywhere the result may later be copied. `portable=False` relaxes it
|
|
65
|
+
to the current OS only.
|
|
66
|
+
|
|
67
|
+
## 2. Blank Is a Problem on Every Platform
|
|
68
|
+
A name of spaces is legal on POSIX but unusable in practice and silently
|
|
69
|
+
altered on Windows; treating it as invalid everywhere is the useful answer.
|
|
70
|
+
|
|
71
|
+
## 3. What Is Not Checked
|
|
72
|
+
The *existence* of the name, the total path length (Windows' 260-character
|
|
73
|
+
`MAX_PATH`), and names that are valid but unwise (leading dashes, `~`). This
|
|
74
|
+
is a check of the string itself, not of the disk.
|
|
75
|
+
"""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def to_path(value: Any) -> Path | None:
|
|
6
|
+
"""Turn a path candidate into a `Path` without touching the disk, or return None.
|
|
7
|
+
|
|
8
|
+
Used by every rule, which must never raise on a bad input — a value
|
|
9
|
+
that cannot be a path simply fails the rule.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
value: Any value; only a `str` or a `Path` is a path candidate.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The `Path` with a leading `~` expanded, or `None` if `value` is not
|
|
16
|
+
a `str`/`Path`, is blank, contains a null byte, or cannot be
|
|
17
|
+
expanded.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# 1. Only str and Path instances are path candidates
|
|
21
|
+
if not isinstance(value, (str, Path)):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
# 2. A blank string would silently become the current directory
|
|
25
|
+
if isinstance(value, str) and not value.strip():
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
# 3. A null byte makes every later filesystem call raise ValueError
|
|
29
|
+
if "\0" in str(value):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# 4. Expand "~" — pure string work, no stat/resolve syscalls
|
|
33
|
+
try:
|
|
34
|
+
return Path(value).expanduser()
|
|
35
|
+
|
|
36
|
+
# 5. "~unknown_user" or another unusable value — not a path
|
|
37
|
+
except (RuntimeError, OSError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DESIGN_NOTES = """
|
|
42
|
+
# to_path — the one safe str/Path → Path conversion
|
|
43
|
+
|
|
44
|
+
## Purpose
|
|
45
|
+
`Rule.is_valid` must never raise, and every path rule starts the same way:
|
|
46
|
+
"is this even a path, and which one?". `to_path` is that shared first step,
|
|
47
|
+
so no rule repeats the `isinstance` / blank / null-byte / `expanduser`
|
|
48
|
+
dance.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 1. Returns None Instead of Raising
|
|
53
|
+
|
|
54
|
+
The caller decides what a non-path means: a rule answers `False` (and its
|
|
55
|
+
`build_exception` reports a type problem), a function would have been
|
|
56
|
+
rejected earlier by the `PathLike` annotation.
|
|
57
|
+
|
|
58
|
+
## 2. Why Only `expanduser`, Never `resolve`
|
|
59
|
+
|
|
60
|
+
Rules run inside filters over thousands of entries. `resolve()` is a
|
|
61
|
+
syscall per call and follows symlinks; `expanduser()` is string work. The
|
|
62
|
+
entries produced by `collect_paths` are already absolute and normalized, so
|
|
63
|
+
nothing more is needed — while a user-typed `~/project` still means what
|
|
64
|
+
they expect.
|
|
65
|
+
|
|
66
|
+
## 3. Why the Null-Byte Check Is Explicit
|
|
67
|
+
|
|
68
|
+
`Path("a\\0b")` constructs fine; the `ValueError: embedded null byte` only
|
|
69
|
+
appears later, at the first `stat`/`open`. Catching it here keeps that
|
|
70
|
+
failure out of every rule's `is_valid`.
|
|
71
|
+
|
|
72
|
+
## 4. `~unknown_user`
|
|
73
|
+
|
|
74
|
+
On POSIX `Path("~unknown").expanduser()` raises `RuntimeError`; on Windows it
|
|
75
|
+
is expanded to a sibling of the home directory. Both outcomes are handled:
|
|
76
|
+
a `RuntimeError` becomes `None`, a successful expansion is returned.
|
|
77
|
+
"""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Outers
|
|
4
|
+
from ...annotations import PathLike
|
|
5
|
+
# Inners
|
|
6
|
+
from ._helpers import to_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@validate_call
|
|
10
|
+
def is_empty_dir(
|
|
11
|
+
path: PathLike,
|
|
12
|
+
*,
|
|
13
|
+
_validate_call: bool = True,
|
|
14
|
+
) -> bool:
|
|
15
|
+
"""Check whether a path is an existing directory with no entries.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: The path to check.
|
|
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 only for an existing, listable, empty directory. False for a
|
|
24
|
+
non-empty directory, a file, a missing path, or a directory that
|
|
25
|
+
cannot be listed (its emptiness is unknown).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# 1. Anything that cannot be turned into a path is not an empty directory
|
|
29
|
+
candidate = to_path(path)
|
|
30
|
+
if candidate is None:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
# 2. Ask for the first entry only — never list the whole directory
|
|
34
|
+
try:
|
|
35
|
+
with os.scandir(candidate) as entries:
|
|
36
|
+
return next(entries, None) is None
|
|
37
|
+
|
|
38
|
+
# 3. Not a directory, missing, or not listable — not (known to be) empty
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_DESIGN_NOTES = """
|
|
44
|
+
# is_empty_dir — Cheap Emptiness Test
|
|
45
|
+
|
|
46
|
+
## Purpose
|
|
47
|
+
Used by the `IsEmptyDir` rule, and useful before cleanup or output steps
|
|
48
|
+
("only write into an empty folder").
|
|
49
|
+
|
|
50
|
+
## First Entry Only
|
|
51
|
+
`next(entries, None)` stops after one entry; a directory with a million
|
|
52
|
+
files costs the same as an empty one. `os.scandir` also never raises for
|
|
53
|
+
`.` and `..` — they are not yielded.
|
|
54
|
+
|
|
55
|
+
## Unknown Is False
|
|
56
|
+
A directory that cannot be listed (permissions) is neither known to be
|
|
57
|
+
empty nor known to be non-empty. The predicate answers `False` — "not
|
|
58
|
+
proven empty" — which is the safe side for a cleanup decision.
|
|
59
|
+
"""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
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_hidden_path(
|
|
11
|
+
path: PathLike,
|
|
12
|
+
*,
|
|
13
|
+
check_attributes: bool = False,
|
|
14
|
+
_validate_call: bool = True,
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Check whether a path's final name marks it as hidden.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
path: The path to check. Only its final name is considered, not the
|
|
20
|
+
directories above it.
|
|
21
|
+
check_attributes: If True, on Windows an existing path that carries
|
|
22
|
+
the "hidden" file attribute also counts. No effect elsewhere.
|
|
23
|
+
_validate_call: Internal bypass switch for `@validate_call` — rules
|
|
24
|
+
pass `False` because they have already checked the input type.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True if the final name starts with a dot (`.git`, `.env`), or — with
|
|
28
|
+
`check_attributes` on Windows — the hidden attribute is set. `.` and
|
|
29
|
+
`..` are never hidden.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# 1. Anything that cannot be turned into a path is not hidden
|
|
33
|
+
candidate = to_path(path)
|
|
34
|
+
if candidate is None:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
# 2. The Unix convention — a leading dot in the final name
|
|
38
|
+
name = candidate.name
|
|
39
|
+
if name.startswith(".") and name not in (".", ".."):
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
# 3. The Windows convention — an attribute on the file, only when asked for
|
|
43
|
+
if check_attributes and os.name == "nt":
|
|
44
|
+
try:
|
|
45
|
+
attributes = getattr(candidate.stat(), "st_file_attributes", 0)
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
return bool(attributes & stat.FILE_ATTRIBUTE_HIDDEN)
|
|
49
|
+
|
|
50
|
+
# 4. Nothing marks it as hidden
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_DESIGN_NOTES = """
|
|
55
|
+
# is_hidden_path — Hidden by Name, Optionally by Attribute
|
|
56
|
+
|
|
57
|
+
## Purpose
|
|
58
|
+
The predicate behind `IsHidden`, meant for filters such as
|
|
59
|
+
`exclude_filter=IsHidden()`.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 1. Name-Based by Default
|
|
64
|
+
The dot convention needs no disk access, which matters in a filter running
|
|
65
|
+
over thousands of entries, and it behaves identically on every platform
|
|
66
|
+
(`.git` is hidden to a scanner on Windows too).
|
|
67
|
+
|
|
68
|
+
## 2. The Windows Attribute Is Opt-In
|
|
69
|
+
Reading it costs a `stat` call per path, and it only exists on Windows.
|
|
70
|
+
`check_attributes=True` asks for it; elsewhere the flag is simply ignored so
|
|
71
|
+
the same code runs everywhere.
|
|
72
|
+
|
|
73
|
+
## 3. Only the Final Name
|
|
74
|
+
`/home/me/.config/tool/settings.json` is not "hidden" by this function —
|
|
75
|
+
its *name* is not. Whether a file lives under a hidden directory is a
|
|
76
|
+
question about the path's parents, which scanners answer by pruning
|
|
77
|
+
(`ignore_names`) rather than by testing each entry.
|
|
78
|
+
"""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from simplibs.validate import validate_call
|
|
3
|
+
# Outers
|
|
4
|
+
from ...annotations import PathLike
|
|
5
|
+
# Inners
|
|
6
|
+
from ._helpers import to_path
|
|
7
|
+
|
|
8
|
+
@validate_call
|
|
9
|
+
def is_path_readable(
|
|
10
|
+
path: PathLike,
|
|
11
|
+
*,
|
|
12
|
+
_validate_call: bool = True,
|
|
13
|
+
) -> bool:
|
|
14
|
+
"""Check whether a file can be read, or a directory listed, without raising.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
path: The file or directory to check.
|
|
18
|
+
_validate_call: Internal bypass switch for `@validate_call` — rules
|
|
19
|
+
pass `False` because they have already checked the input type.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True for an existing regular file that can be opened for reading, or
|
|
23
|
+
an existing directory whose entries can be listed; False otherwise
|
|
24
|
+
(including a missing path, a broken symlink, or a special file).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# 1. Anything that cannot be turned into a path is not readable
|
|
28
|
+
candidate = to_path(path)
|
|
29
|
+
if candidate is None:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
# 2. Actually try — the only check that matches what a later read experiences
|
|
33
|
+
try:
|
|
34
|
+
|
|
35
|
+
# 2.1 A directory is readable when its entries can be listed
|
|
36
|
+
if candidate.is_dir():
|
|
37
|
+
with os.scandir(candidate):
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
# 2.2 A file is readable when it can be opened for reading
|
|
41
|
+
if candidate.is_file():
|
|
42
|
+
with candidate.open("rb"):
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
# 3. Permission and OS-level failures mean "not readable"
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# 4. Missing paths, broken symlinks and special files
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_DESIGN_NOTES = """
|
|
54
|
+
# is_path_readable — Can This Be Read Right Now?
|
|
55
|
+
|
|
56
|
+
## Purpose
|
|
57
|
+
The predicate behind the `IsPathReadable` rule and a natural precondition
|
|
58
|
+
for any extraction step ("is the source even readable?").
|
|
59
|
+
|
|
60
|
+
## Why Open It Instead of `os.access`
|
|
61
|
+
On Windows `os.access(path, R_OK)` ignores ACLs, and on every platform it
|
|
62
|
+
cannot see a file locked by another process. Opening the file (or listing
|
|
63
|
+
the directory) is the only check that agrees with what the next real read
|
|
64
|
+
will experience. Opening for reading changes nothing on disk.
|
|
65
|
+
|
|
66
|
+
## Regular Files and Directories Only
|
|
67
|
+
FIFOs, sockets and devices can block or have side effects when opened, so
|
|
68
|
+
they answer `False` without being touched.
|
|
69
|
+
|
|
70
|
+
## Never Raises for Disk Problems
|
|
71
|
+
Anything the OS refuses is a legitimate `False`. A wrong *type* of argument
|
|
72
|
+
is still rejected by the `PathLike` annotation before the body runs.
|
|
73
|
+
"""
|