simplibs-path 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. simplibs/path/__init__.py +118 -0
  2. simplibs/path/actions/ChangeSuffix.py +23 -0
  3. simplibs/path/actions/CollectDirs.py +24 -0
  4. simplibs/path/actions/CollectFiles.py +25 -0
  5. simplibs/path/actions/CollectPaths.py +24 -0
  6. simplibs/path/actions/EnsureDir.py +22 -0
  7. simplibs/path/actions/EnsureFile.py +19 -0
  8. simplibs/path/actions/EnsureParentDir.py +20 -0
  9. simplibs/path/actions/FilterPaths.py +27 -0
  10. simplibs/path/actions/MakeUniquePath.py +22 -0
  11. simplibs/path/actions/NormalizeEntryPath.py +20 -0
  12. simplibs/path/actions/NormalizePath.py +23 -0
  13. simplibs/path/actions/RebasePath.py +24 -0
  14. simplibs/path/actions/RelativizePath.py +22 -0
  15. simplibs/path/actions/__init__.py +68 -0
  16. simplibs/path/annotations/PathLike.py +43 -0
  17. simplibs/path/annotations/PathPredicate.py +39 -0
  18. simplibs/path/annotations/RecursionDepth.py +33 -0
  19. simplibs/path/annotations/__init__.py +21 -0
  20. simplibs/path/logic/__init__.py +41 -0
  21. simplibs/path/logic/checks/__init__.py +29 -0
  22. simplibs/path/logic/checks/_helpers/__init__.py +21 -0
  23. simplibs/path/logic/checks/_helpers/_find_length_problem.py +48 -0
  24. simplibs/path/logic/checks/_helpers/_find_windows_name_problem.py +55 -0
  25. simplibs/path/logic/checks/_helpers/can_create_inside.py +39 -0
  26. simplibs/path/logic/checks/_helpers/constants/MAX_NAME_LENGTH.py +18 -0
  27. simplibs/path/logic/checks/_helpers/constants/WINDOWS_FORBIDDEN_CHARS.py +25 -0
  28. simplibs/path/logic/checks/_helpers/constants/WINDOWS_RESERVED_NAMES.py +27 -0
  29. simplibs/path/logic/checks/_helpers/constants/__init__.py +20 -0
  30. simplibs/path/logic/checks/_helpers/find_filename_problem.py +75 -0
  31. simplibs/path/logic/checks/_helpers/to_path.py +77 -0
  32. simplibs/path/logic/checks/is_empty_dir.py +59 -0
  33. simplibs/path/logic/checks/is_hidden_path.py +78 -0
  34. simplibs/path/logic/checks/is_path_readable.py +73 -0
  35. simplibs/path/logic/checks/is_path_writable.py +90 -0
  36. simplibs/path/logic/checks/is_valid_filename.py +47 -0
  37. simplibs/path/logic/checks/is_within.py +88 -0
  38. simplibs/path/logic/ensure/__init__.py +22 -0
  39. simplibs/path/logic/ensure/_validations/__init__.py +25 -0
  40. simplibs/path/logic/ensure/_validations/raise_ensure_dir_error.py +40 -0
  41. simplibs/path/logic/ensure/_validations/raise_ensure_dir_not_a_dir_error.py +36 -0
  42. simplibs/path/logic/ensure/_validations/raise_ensure_file_create_error.py +39 -0
  43. simplibs/path/logic/ensure/_validations/raise_ensure_file_not_a_file_error.py +36 -0
  44. simplibs/path/logic/ensure/_validations/raise_ensure_file_not_found_error.py +36 -0
  45. simplibs/path/logic/ensure/ensure_dir.py +83 -0
  46. simplibs/path/logic/ensure/ensure_file.py +103 -0
  47. simplibs/path/logic/ensure/ensure_parent_dir.py +58 -0
  48. simplibs/path/logic/scanners/__init__.py +35 -0
  49. simplibs/path/logic/scanners/_helpers/ScanEntry.py +36 -0
  50. simplibs/path/logic/scanners/_helpers/__init__.py +30 -0
  51. simplibs/path/logic/scanners/_helpers/as_path_predicate.py +38 -0
  52. simplibs/path/logic/scanners/_helpers/build_ignore_matcher.py +88 -0
  53. simplibs/path/logic/scanners/_helpers/generate_paths.py +74 -0
  54. simplibs/path/logic/scanners/_helpers/is_link_like.py +45 -0
  55. simplibs/path/logic/scanners/_helpers/resolve_scan_root.py +66 -0
  56. simplibs/path/logic/scanners/_helpers/scan_paths.py +92 -0
  57. simplibs/path/logic/scanners/_helpers/walk_tree.py +118 -0
  58. simplibs/path/logic/scanners/_validations/__init__.py +24 -0
  59. simplibs/path/logic/scanners/_validations/raise_collect_paths_not_a_dir_error.py +34 -0
  60. simplibs/path/logic/scanners/_validations/raise_collect_paths_not_found_error.py +38 -0
  61. simplibs/path/logic/scanners/_validations/raise_collect_paths_root_unreadable_error.py +38 -0
  62. simplibs/path/logic/scanners/_validations/raise_ignore_names_not_names_error.py +36 -0
  63. simplibs/path/logic/scanners/_validations/raise_nothing_to_collect_error.py +34 -0
  64. simplibs/path/logic/scanners/collect_dirs.py +62 -0
  65. simplibs/path/logic/scanners/collect_files.py +64 -0
  66. simplibs/path/logic/scanners/collect_paths.py +102 -0
  67. simplibs/path/logic/scanners/filter_paths.py +51 -0
  68. simplibs/path/logic/scanners/ignore_names/DEFAULT_IGNORE_NAMES.py +36 -0
  69. simplibs/path/logic/scanners/ignore_names/IGNORE_BUILD.py +48 -0
  70. simplibs/path/logic/scanners/ignore_names/IGNORE_ENV.py +31 -0
  71. simplibs/path/logic/scanners/ignore_names/IGNORE_GENERIC.py +30 -0
  72. simplibs/path/logic/scanners/ignore_names/IGNORE_IDE.py +20 -0
  73. simplibs/path/logic/scanners/ignore_names/IGNORE_SYSTEM.py +30 -0
  74. simplibs/path/logic/scanners/ignore_names/IGNORE_VCS.py +24 -0
  75. simplibs/path/logic/scanners/ignore_names/__init__.py +30 -0
  76. simplibs/path/logic/scanners/iter_paths.py +91 -0
  77. simplibs/path/logic/transformers/__init__.py +27 -0
  78. simplibs/path/logic/transformers/_helpers/__init__.py +17 -0
  79. simplibs/path/logic/transformers/_helpers/is_valid_suffix.py +37 -0
  80. simplibs/path/logic/transformers/_helpers/split_name.py +46 -0
  81. simplibs/path/logic/transformers/_validations/__init__.py +24 -0
  82. simplibs/path/logic/transformers/_validations/raise_invalid_suffix_error.py +36 -0
  83. simplibs/path/logic/transformers/_validations/raise_normalize_path_error.py +44 -0
  84. simplibs/path/logic/transformers/_validations/raise_path_has_no_name_error.py +36 -0
  85. simplibs/path/logic/transformers/_validations/raise_path_not_within_root_error.py +43 -0
  86. simplibs/path/logic/transformers/_validations/raise_unique_path_exhausted_error.py +39 -0
  87. simplibs/path/logic/transformers/change_suffix.py +77 -0
  88. simplibs/path/logic/transformers/make_unique_path.py +88 -0
  89. simplibs/path/logic/transformers/normalize_entry_path.py +67 -0
  90. simplibs/path/logic/transformers/normalize_path.py +85 -0
  91. simplibs/path/logic/transformers/rebase_path.py +78 -0
  92. simplibs/path/logic/transformers/relativize_path.py +56 -0
  93. simplibs/path/rules/__init__.py +66 -0
  94. simplibs/path/rules/_helpers/PathPartIs.py +152 -0
  95. simplibs/path/rules/_helpers/__init__.py +21 -0
  96. simplibs/path/rules/_helpers/build_not_a_path_exception.py +66 -0
  97. simplibs/path/rules/_helpers/build_path_rule_exception.py +71 -0
  98. simplibs/path/rules/_helpers/check_path.py +48 -0
  99. simplibs/path/rules/_validations/__init__.py +24 -0
  100. simplibs/path/rules/_validations/raise_has_suffix_invalid_suffix_error.py +35 -0
  101. simplibs/path/rules/_validations/raise_has_suffix_no_suffixes_error.py +31 -0
  102. simplibs/path/rules/_validations/raise_rule_param_not_bool_error.py +37 -0
  103. simplibs/path/rules/_validations/raise_rule_param_not_callable_error.py +36 -0
  104. simplibs/path/rules/_validations/raise_rule_param_not_path_error.py +37 -0
  105. simplibs/path/rules/filesystem/IsDir.py +70 -0
  106. simplibs/path/rules/filesystem/IsEmptyDir.py +76 -0
  107. simplibs/path/rules/filesystem/IsEmptyFile.py +76 -0
  108. simplibs/path/rules/filesystem/IsFile.py +76 -0
  109. simplibs/path/rules/filesystem/IsPathReadable.py +77 -0
  110. simplibs/path/rules/filesystem/IsPathWritable.py +82 -0
  111. simplibs/path/rules/filesystem/IsSymlink.py +71 -0
  112. simplibs/path/rules/filesystem/PathExists.py +75 -0
  113. simplibs/path/rules/filesystem/__init__.py +31 -0
  114. simplibs/path/rules/lexical/HasSuffix.py +146 -0
  115. simplibs/path/rules/lexical/IsAbsolutePath.py +74 -0
  116. simplibs/path/rules/lexical/IsHidden.py +92 -0
  117. simplibs/path/rules/lexical/IsRelativePath.py +74 -0
  118. simplibs/path/rules/lexical/IsValidFilename.py +136 -0
  119. simplibs/path/rules/lexical/IsWithin.py +116 -0
  120. simplibs/path/rules/lexical/NameIs.py +37 -0
  121. simplibs/path/rules/lexical/ParentNameIs.py +37 -0
  122. simplibs/path/rules/lexical/StemIs.py +36 -0
  123. simplibs/path/rules/lexical/__init__.py +33 -0
  124. simplibs_path-0.1.0.dist-info/METADATA +420 -0
  125. simplibs_path-0.1.0.dist-info/RECORD +128 -0
  126. simplibs_path-0.1.0.dist-info/WHEEL +5 -0
  127. simplibs_path-0.1.0.dist-info/licenses/LICENSE +21 -0
  128. simplibs_path-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ """