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