rootle 0.1.0__tar.gz → 1.2.0__tar.gz

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 (68) hide show
  1. {rootle-0.1.0 → rootle-1.2.0}/.config/rumdl.toml +3 -0
  2. {rootle-0.1.0 → rootle-1.2.0}/CLAUDE.md +2 -2
  3. {rootle-0.1.0 → rootle-1.2.0}/PKG-INFO +1 -1
  4. rootle-1.2.0/docs/functions/ends_like_directory.md +3 -0
  5. rootle-1.2.0/docs/functions/identity.md +3 -0
  6. rootle-1.2.0/docs/functions/relative_within.md +3 -0
  7. rootle-1.2.0/docs/functions/status.md +3 -0
  8. rootle-1.2.0/docs/testing/can_chmod_deny_listing.md +3 -0
  9. rootle-1.2.0/docs/testing/can_chmod_deny_writing.md +3 -0
  10. rootle-1.2.0/docs/testing/is_treated_as_same_name.md +3 -0
  11. rootle-1.2.0/docs/types/identity.md +3 -0
  12. rootle-1.2.0/src/rootle/__init__.py +13 -0
  13. rootle-1.2.0/src/rootle/_identity_of_status.py +22 -0
  14. rootle-1.2.0/src/rootle/_status_of_resolved.py +53 -0
  15. rootle-1.2.0/src/rootle/ends_like_directory.py +55 -0
  16. rootle-1.2.0/src/rootle/exceptions/_not_a_directory_error.py +11 -0
  17. rootle-1.2.0/src/rootle/exceptions/_os_error.py +31 -0
  18. rootle-1.2.0/src/rootle/exceptions/_permission_error.py +11 -0
  19. rootle-1.2.0/src/rootle/identity.py +91 -0
  20. rootle-1.2.0/src/rootle/relative_within.py +207 -0
  21. {rootle-0.1.0 → rootle-1.2.0}/src/rootle/resolve.py +42 -36
  22. rootle-1.2.0/src/rootle/status.py +86 -0
  23. rootle-1.2.0/src/rootle/testing/__init__.py +11 -0
  24. rootle-1.2.0/src/rootle/testing/can_chmod_deny_listing.py +62 -0
  25. {rootle-0.1.0 → rootle-1.2.0}/src/rootle/testing/can_chmod_deny_search.py +6 -4
  26. rootle-1.2.0/src/rootle/testing/can_chmod_deny_writing.py +71 -0
  27. rootle-1.2.0/src/rootle/testing/is_treated_as_same_name.py +82 -0
  28. rootle-1.2.0/src/rootle/types/__init__.py +5 -0
  29. rootle-1.2.0/src/rootle/types/identity.py +26 -0
  30. rootle-1.2.0/src/rootle/winerror.py +32 -0
  31. rootle-1.2.0/tests/__init__.py +0 -0
  32. {rootle-0.1.0 → rootle-1.2.0}/tests/marks.py +5 -0
  33. rootle-1.2.0/tests/test_can_chmod_deny_listing.py +112 -0
  34. {rootle-0.1.0 → rootle-1.2.0}/tests/test_can_chmod_deny_search.py +35 -6
  35. rootle-1.2.0/tests/test_can_chmod_deny_writing.py +177 -0
  36. {rootle-0.1.0 → rootle-1.2.0}/tests/test_docs.py +45 -14
  37. rootle-1.2.0/tests/test_ends_like_directory.py +184 -0
  38. rootle-1.2.0/tests/test_identity.py +416 -0
  39. rootle-1.2.0/tests/test_is_treated_as_same_name.py +320 -0
  40. rootle-1.2.0/tests/test_relative_within.py +1013 -0
  41. {rootle-0.1.0 → rootle-1.2.0}/tests/test_resolve.py +76 -8
  42. rootle-1.2.0/tests/test_status.py +373 -0
  43. {rootle-0.1.0 → rootle-1.2.0}/zensical.toml +32 -16
  44. rootle-0.1.0/src/rootle/__init__.py +0 -5
  45. rootle-0.1.0/src/rootle/testing/__init__.py +0 -5
  46. rootle-0.1.0/src/rootle/winerror.py +0 -14
  47. {rootle-0.1.0 → rootle-1.2.0}/.config/mise.lock +0 -0
  48. {rootle-0.1.0 → rootle-1.2.0}/.config/mise.toml +0 -0
  49. {rootle-0.1.0 → rootle-1.2.0}/.config/tombi.toml +0 -0
  50. {rootle-0.1.0 → rootle-1.2.0}/.editorconfig +0 -0
  51. {rootle-0.1.0 → rootle-1.2.0}/.gitattributes +0 -0
  52. {rootle-0.1.0 → rootle-1.2.0}/.github/dependabot.yml +0 -0
  53. {rootle-0.1.0 → rootle-1.2.0}/.github/workflows/ci.yml +0 -0
  54. {rootle-0.1.0 → rootle-1.2.0}/.github/workflows/release.yml +0 -0
  55. {rootle-0.1.0 → rootle-1.2.0}/.gitignore +0 -0
  56. {rootle-0.1.0 → rootle-1.2.0}/.python-version +0 -0
  57. {rootle-0.1.0 → rootle-1.2.0}/.vscode/settings.json +0 -0
  58. {rootle-0.1.0 → rootle-1.2.0}/CONTRIBUTING.md +0 -0
  59. {rootle-0.1.0 → rootle-1.2.0}/LICENSE +0 -0
  60. {rootle-0.1.0 → rootle-1.2.0}/README.md +0 -0
  61. {rootle-0.1.0 → rootle-1.2.0}/docs/functions/resolve.md +0 -0
  62. {rootle-0.1.0 → rootle-1.2.0}/docs/index.md +0 -0
  63. {rootle-0.1.0 → rootle-1.2.0}/docs/testing/can_chmod_deny_search.md +0 -0
  64. {rootle-0.1.0 → rootle-1.2.0}/docs/winerror.md +0 -0
  65. {rootle-0.1.0 → rootle-1.2.0}/pyproject.toml +0 -0
  66. {rootle-0.1.0/tests → rootle-1.2.0/src/rootle/exceptions}/__init__.py +0 -0
  67. {rootle-0.1.0 → rootle-1.2.0}/src/rootle/py.typed +0 -0
  68. {rootle-0.1.0 → rootle-1.2.0}/uv.lock +0 -0
@@ -16,6 +16,9 @@ strict = true # Check code blocks too
16
16
  "docs/testing/*.md" = [
17
17
  "MD041",
18
18
  ]
19
+ "docs/types/*.md" = [
20
+ "MD041",
21
+ ]
19
22
  "docs/winerror.md" = [
20
23
  "MD041",
21
24
  ]
@@ -1,6 +1,6 @@
1
1
  # CLAUDE.md
2
2
 
3
- rootle is a Python package with functions for interacting with the file systems. Each function lives in a module of its own with the same name (`src/rootle/<name>.py`) and is re-exported from `rootle`. Helpers for testing code that uses the file system live in `rootle.testing` the same way (`src/rootle/testing/<name>.py`). Windows error codes live in `rootle.winerror` (`src/rootle/winerror.py`), like Python's `errno`, documented on one page, `docs/winerror.md`.
3
+ rootle is a Python package with functions for interacting with the file systems. Each function lives in a module of its own with the same name (`src/rootle/<name>.py`) and is re-exported from `rootle`. Helpers for testing code that uses the file system live in `rootle.testing` the same way (`src/rootle/testing/<name>.py`). Types that functions return live in `rootle.types`, each in a module named after it in lowercase (`src/rootle/types/identity.py` for `Identity`). Windows error codes live in `rootle.winerror` (`src/rootle/winerror.py`), like Python's `errno`, documented on one page, `docs/winerror.md`.
4
4
 
5
5
  ## Commands
6
6
 
@@ -22,7 +22,7 @@ mise exec -- uv run --locked ruff format # Format Python
22
22
  - `.config/mise.toml`•`min_version` ↔ the mise-action `version` in every job that installs mise, in `.github/workflows/ci.yml` and `.github/workflows/release.yml`.
23
23
  - After changing `.config/mise.toml`•`tools`, run `mise lock`, because CI sets `MISE_LOCKED=1`.
24
24
  - Every `lint:*` task in `.config/mise.toml` ↔ a step of its own in `.github/workflows/ci.yml`'s `lint` job.
25
- - Every function exported from `rootle` ↔ its own page `docs/functions/<name>.md` and its entry under "Functions" in `zensical.toml`•`project`•`nav`. Likewise for `rootle.testing`, with `docs/testing/<name>.md` and "Testing". `tests/test_docs.py` checks both, for every module in its `SECTIONS`.
25
+ - Every function exported from `rootle` ↔ its own page `docs/functions/<name>.md` and its entry under "Functions" in `zensical.toml`•`project`•`nav`. Likewise for `rootle.testing`, with `docs/testing/<name>.md` and "Testing", and for `rootle.types`, with `docs/types/<module>.md` and "Types". `tests/test_docs.py` checks both, for every module in its `SECTIONS`.
26
26
  - `.config/mise.toml`•`tools`•`uv` ↔ `pyproject.toml`•`tool.uv`•`required-version`, which it must satisfy. The range's floor must never be above the uv that Dependabot's uv updater bundles (0.12.19 on 2026-10-18), or every one of Dependabot's uv updates fails.
27
27
  - `pyproject.toml`•`project`•`requires-python` ↔ `pyproject.toml`•`tool.pyright`•`pythonVersion` ↔ `.python-version`.
28
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rootle
3
- Version: 0.1.0
3
+ Version: 1.2.0
4
4
  Summary: Python functions to rootle around in your file system
5
5
  Project-URL: Issues, https://github.com/cariad/rootle/issues
6
6
  Project-URL: Source, https://github.com/cariad/rootle
@@ -0,0 +1,3 @@
1
+ ::: rootle.ends_like_directory.ends_like_directory
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.identity.identity
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.relative_within.relative_within
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.status.status
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.testing.can_chmod_deny_listing.can_chmod_deny_listing
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.testing.can_chmod_deny_writing.can_chmod_deny_writing
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.testing.is_treated_as_same_name.is_treated_as_same_name
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,3 @@
1
+ ::: rootle.types.identity.Identity
2
+ options:
3
+ heading_level: 1
@@ -0,0 +1,13 @@
1
+ from .ends_like_directory import ends_like_directory
2
+ from .identity import identity
3
+ from .relative_within import relative_within
4
+ from .resolve import resolve
5
+ from .status import status
6
+
7
+ __all__ = [
8
+ "ends_like_directory",
9
+ "identity",
10
+ "relative_within",
11
+ "resolve",
12
+ "status",
13
+ ]
@@ -0,0 +1,22 @@
1
+ from os import stat_result
2
+
3
+ from .types import Identity
4
+
5
+
6
+ def identity_of_status(st: stat_result) -> Identity | None:
7
+ """
8
+ Get the identity in `st`, a file's status, or `None` when it has none, like when
9
+ Windows can't open the file.
10
+ """
11
+ # A file's number identifies it only when it isn't zero.
12
+ #
13
+ # When Windows can't open a file, it reads the file's attributes from its directory
14
+ # instead, which has no file index, and some file systems never report one. Every
15
+ # such file would share that identity, so it mustn't pass as one.
16
+ if st.st_ino == 0:
17
+ return None
18
+
19
+ return Identity(
20
+ device=st.st_dev,
21
+ inode=st.st_ino,
22
+ )
@@ -0,0 +1,53 @@
1
+ from os import PathLike, stat, stat_result
2
+ from pathlib import Path
3
+
4
+ from rootle.exceptions import _os_error
5
+
6
+ from . import winerror
7
+
8
+
9
+ def status_of_resolved(
10
+ resolved: Path,
11
+ path: PathLike[str] | str,
12
+ *,
13
+ follow_symlinks: bool = True,
14
+ ) -> stat_result | None:
15
+ """
16
+ Get the status of the file at `resolved`, which `path` already resolved to, or
17
+ `None` when nothing exists there. Errors name `path` as it was given.
18
+
19
+ Unless `follow_symlinks` is set, a symlink at `resolved` isn't followed, like when
20
+ it appeared after `path` was resolved.
21
+ """
22
+ try:
23
+ return stat(
24
+ resolved,
25
+ follow_symlinks=follow_symlinks,
26
+ )
27
+
28
+ # Windows reports a path through a file as missing, so `NotADirectoryError` is
29
+ # raised here only on POSIX. Either way, nothing exists at the path.
30
+ except NotADirectoryError: # pragma: posix-only
31
+ # `NotADirectoryError` means that one of the path's parents is a file, so
32
+ # nothing can exist at the path.
33
+ return None
34
+
35
+ except FileNotFoundError as ex:
36
+ # Windows maps more than a missing file to `ENOENT`, like a network path that
37
+ # can't be reached, and that mustn't pass as nothing existing there.
38
+ missing = (
39
+ None,
40
+ winerror.FILE_NOT_FOUND,
41
+ winerror.PATH_NOT_FOUND,
42
+ )
43
+
44
+ if getattr(ex, "winerror", None) in missing:
45
+ return None
46
+
47
+ raise _os_error.with_path(ex, path) from ex
48
+
49
+ except OSError as ex:
50
+ # The path can change after it's resolved, so `stat` can still fail. `ex`
51
+ # names the resolved path, so describe the path as it was given instead, like
52
+ # `resolve` does.
53
+ raise _os_error.with_path(ex, path) from ex
@@ -0,0 +1,55 @@
1
+ from os import PathLike, altsep, fspath, sep
2
+ from os.path import splitdrive
3
+
4
+
5
+ def ends_like_directory(path: PathLike[str] | str) -> bool:
6
+ r"""
7
+ Check whether `path` ends like a directory, with a separator or a `.` or `..` name,
8
+ which only a directory can have, whether or not one exists there.
9
+
10
+ [`Path`][pathlib.Path] drops a trailing separator and `.`, so `Path("docs/")` and
11
+ `Path("docs/.")` can't say this, but the path as it was given still can. Only the
12
+ spelling is checked, never the file system.
13
+
14
+ **Linux & macOS:** The separator is `/`.
15
+
16
+ **Windows:** The separator is `\` or `/`, and a drive or share comes off first, so
17
+ `C:.` and `C:..` end like a directory but `C:` alone doesn't.
18
+
19
+ Args:
20
+ path: Path to check.
21
+
22
+ Returns:
23
+ `True` when `path` ends with a separator or a `.` or `..` name, otherwise
24
+ `False`.
25
+
26
+ Examples:
27
+ ```python
28
+ ends_like_directory("docs/")
29
+ # True
30
+
31
+ ends_like_directory("docs/.")
32
+ # True
33
+
34
+ ends_like_directory("docs/..")
35
+ # True
36
+
37
+ ends_like_directory("docs")
38
+ # False
39
+ ```
40
+ """
41
+ name = fspath(path)
42
+
43
+ # Only Windows has drives, and a drive on its own names no path component.
44
+ name = splitdrive(name)[1]
45
+
46
+ # An empty path names nothing, so it doesn't end like anything.
47
+ if not name:
48
+ return False
49
+
50
+ # Only Windows has an alternative separator.
51
+ for separator in (s for s in (sep, altsep) if s):
52
+ name = name.rpartition(separator)[2]
53
+
54
+ # A path that ends with a separator has an empty last name.
55
+ return name in ("", ".", "..")
@@ -0,0 +1,11 @@
1
+ from errno import ENOTDIR
2
+ from os import PathLike, fspath, strerror
3
+
4
+
5
+ def not_a_directory(path: PathLike[str] | str) -> NotADirectoryError:
6
+ """Make the error that says `path`, as the caller gave it, isn't a directory."""
7
+ return NotADirectoryError(
8
+ ENOTDIR,
9
+ strerror(ENOTDIR),
10
+ fspath(path),
11
+ )
@@ -0,0 +1,31 @@
1
+ from errno import EINVAL
2
+ from os import PathLike, fspath
3
+
4
+
5
+ def null_character(path: PathLike[str] | str) -> OSError:
6
+ """Make an error that says `path` holds a null character."""
7
+ return OSError(
8
+ # `EINVAL` rather than the `ENOENT` of an empty path, so it can't be mistaken
9
+ # for a missing file.
10
+ EINVAL,
11
+ "The path holds a null character",
12
+ fspath(path),
13
+ )
14
+
15
+
16
+ def with_path(ex: OSError, path: PathLike[str] | str) -> OSError:
17
+ """
18
+ Copy `ex` so that it describes `path` as the caller gave it, rather than whichever
19
+ part of that path, or wherever it leads, the error happened at.
20
+
21
+ Keep Windows' own error code, because Windows maps many of them to the same `errno`.
22
+ Other platforms ignore it.
23
+
24
+ An error made from a message alone has no `strerror`, so keep its message.
25
+ """
26
+ return OSError(
27
+ ex.errno,
28
+ ex.strerror or str(ex),
29
+ fspath(path),
30
+ getattr(ex, "winerror", None),
31
+ )
@@ -0,0 +1,11 @@
1
+ from errno import EACCES
2
+ from os import PathLike, fspath
3
+
4
+
5
+ def unreadable_identity_error(path: PathLike[str] | str) -> PermissionError:
6
+ """Make the error that says the identity of the file at `path` can't be read."""
7
+ return PermissionError(
8
+ EACCES,
9
+ "The file's identity can't be read",
10
+ fspath(path),
11
+ )
@@ -0,0 +1,91 @@
1
+ from os import PathLike
2
+
3
+ from rootle.exceptions import _permission_error
4
+
5
+ from ._identity_of_status import identity_of_status
6
+ from .status import status
7
+ from .types import Identity
8
+
9
+
10
+ def identity(path: PathLike[str] | str) -> Identity | None:
11
+ """
12
+ Identify the file at `path`, following every symlink it contains.
13
+
14
+ Two paths have the same identity when they name the same file. That's true of a
15
+ symlink or a hard link to the file, and of a spelling that a case- or
16
+ normalisation-insensitive file system treats as the same name, like `README.md` and
17
+ `readme.md` on macOS's default APFS.
18
+
19
+ Comparing paths as strings can't see any of those, so compare identities instead.
20
+ Nothing exists at a missing path, so it has no identity, `None`, and two missing
21
+ paths compare as equal. Check for `None` before comparing.
22
+
23
+ The path is resolved with [`resolve`][rootle.resolve.resolve] before it's
24
+ identified, so a `..` after a directory that doesn't exist yet steps up the same
25
+ way it will once the directory is created. For example, `missing/../doc.md` is
26
+ identified as `doc.md`.
27
+
28
+ **Linux & macOS:** A `..` after a file raises an error.
29
+
30
+ **Windows:** A `..` after a file steps up. Windows can't read the identity of a
31
+ file that it can't open, like one that's locked or whose permissions deny it, so
32
+ that raises an error. So does a network path that can't be reached, which Windows
33
+ reports as missing.
34
+
35
+ Args:
36
+ path: Path to the file to identify.
37
+
38
+ Returns:
39
+ The file's [identity][rootle.types.identity.Identity], or `None` when nothing
40
+ exists at `path`.
41
+
42
+ Raises:
43
+ OSError:
44
+ - When a part of the path can't be read — for example, when permissions deny
45
+ it, symlinks loop, a `..` steps up from a file on Linux or macOS, or when
46
+ the path is relative and the working directory is gone. A safety check
47
+ that can't be made must fail rather than pass.
48
+ - When `path` ends with a separator or `.`, which says it's a directory, but
49
+ leads to a file.
50
+ - When `path` is empty, which names nothing.
51
+ - When `path` contains a null character.
52
+ - When the file exists but its identity can't be read, like when Windows
53
+ can't open it.
54
+
55
+ The error names `path` as it was given.
56
+
57
+ Examples:
58
+ Given this directory, where `latest` is a symlink to `v2`:
59
+
60
+ ```text
61
+ /docs
62
+ ├── latest -> v2
63
+ └── v2
64
+ └── guide.md
65
+ ```
66
+
67
+ Every name for a file has the same identity:
68
+
69
+ ```python
70
+ identity("/docs/latest/guide.md") == identity("/docs/v2/guide.md")
71
+ # True
72
+ ```
73
+
74
+ Nothing exists at a missing path, so it has no identity:
75
+
76
+ ```python
77
+ identity("/docs/v2/missing.md")
78
+ # None
79
+ ```
80
+ """
81
+ st = status(path)
82
+
83
+ if st is None:
84
+ return None
85
+
86
+ found = identity_of_status(st)
87
+
88
+ if found is None:
89
+ raise _permission_error.unreadable_identity_error(path)
90
+
91
+ return found
@@ -0,0 +1,207 @@
1
+ from os import PathLike, stat_result
2
+ from os.path import ALLOW_MISSING, realpath
3
+ from pathlib import Path, PurePath
4
+ from stat import S_ISDIR
5
+
6
+ from rootle.exceptions import _not_a_directory_error, _os_error, _permission_error
7
+
8
+ from ._identity_of_status import identity_of_status
9
+ from ._status_of_resolved import status_of_resolved
10
+ from .resolve import resolve
11
+ from .status import status
12
+
13
+
14
+ def _resolves_to_itself(part: Path, path: PathLike[str] | str) -> bool:
15
+ """
16
+ Check whether `part`, a part of the path that `path` resolved to, still resolves to
17
+ itself, so that a path through it isn't redirected somewhere else, like by a symlink
18
+ or a Windows directory junction that's appeared since. Errors name `path` as it was
19
+ given.
20
+ """
21
+ try:
22
+ canon = realpath(
23
+ part,
24
+ strict=ALLOW_MISSING,
25
+ )
26
+
27
+ except OSError as ex:
28
+ raise _os_error.with_path(ex, path) from ex
29
+
30
+ return Path(canon) == part
31
+
32
+
33
+ def relative_within(
34
+ path: PathLike[str] | str,
35
+ directory: PathLike[str] | str,
36
+ ) -> PurePath | None:
37
+ """
38
+ Get the path of `path` relative to `directory`, or `None` when `path` isn't
39
+ `directory` or within it.
40
+
41
+ The path is resolved with [`resolve`][rootle.resolve.resolve] first, so every
42
+ symlink it contains is followed, even a dangling one at its end, and a `..` after a
43
+ directory that doesn't exist yet steps up the same way it will once the directory
44
+ is created. A path that leads out of `directory` through a symlink isn't within it,
45
+ even when it's named within it.
46
+
47
+ Then the resolved path and each of its parents are compared with `directory` by
48
+ [`identity`][rootle.identity.identity], so a spelling that a case- or
49
+ normalisation-insensitive file system treats as the same name, like
50
+ `TEMPLATES/build` for `templates/build` on macOS's default APFS, is still within
51
+ `templates`. A path that doesn't exist yet is judged by its nearest parents that
52
+ do.
53
+
54
+ Each part of the resolved path is checked again as it's compared, so a symlink that
55
+ appears in the path after it's resolved isn't followed into `directory`. The answer
56
+ is only true at the moment it's made, though. A path that changes while it's
57
+ checked, or afterwards, can lead somewhere else by the time it's used.
58
+
59
+ Nothing can be within a directory that doesn't exist, so a missing `directory`
60
+ returns `None`. Nothing can be within a file either, so a `directory` that's a file
61
+ raises an error.
62
+
63
+ The relative path is a [`PurePath`][pathlib.PurePath], which can't touch the file
64
+ system, because it's relative to `directory` rather than to the working directory.
65
+ Join it to `directory` to make a [`Path`][pathlib.Path], or call
66
+ [`as_posix()`][pathlib.PurePath.as_posix] to spell it with forward slashes on every
67
+ operating system.
68
+
69
+ **Linux & macOS:** The relative path keeps the spelling that `path` gave each name,
70
+ or that a symlink gave the names it leads to, so `docs/GUIDE.md` is `GUIDE.md`
71
+ within `docs`, even when the file system stores `guide.md`. A `..` after a file
72
+ raises an error.
73
+
74
+ **Windows:** The relative path uses the spelling that the file system stores for
75
+ each name that exists, so `docs/GUIDE.md` is `guide.md` within `docs` when the file
76
+ system stores `guide.md`. A name that doesn't exist keeps the spelling that `path`
77
+ gave it. A `..` after a file steps up. Windows can't read the identity of a file
78
+ that it can't open, like one that's locked or whose permissions deny it. When
79
+ that's `directory`, it raises an error, but when it's a part of `path`, it can't be
80
+ `directory`, so `path` is judged by its parents.
81
+
82
+ A network path that can't be reached raises an error when `directory` exists, even
83
+ though Windows reports it as missing.
84
+
85
+ Args:
86
+ path: Path to find within `directory`.
87
+ directory: Path to the directory.
88
+
89
+ Returns:
90
+ The path of `path` relative to `directory`, `PurePath(".")` when `path` is
91
+ `directory` itself, or `None` when `path` is neither `directory` nor within it,
92
+ or when `directory` doesn't exist.
93
+
94
+ Raises:
95
+ OSError:
96
+ - When a part of `path` or `directory` can't be read — for example, when
97
+ permissions deny it, symlinks loop, a `..` steps up from a file on Linux
98
+ or macOS, or when the path is relative and the working directory is gone.
99
+ A safety check that can't be made must fail rather than pass.
100
+ - When `path` or `directory` ends with a separator or `.`, which says it's
101
+ a directory, but leads to a file.
102
+ - When `path` or `directory` is empty, which names nothing.
103
+ - When `path` or `directory` contains a null character.
104
+ - When `directory` is a file.
105
+ - When `directory` exists but its identity can't be read, like when Windows
106
+ can't open it.
107
+
108
+ Errors in resolving `path` are raised even when `directory` doesn't exist.
109
+ The error names `path` or `directory` as it was given.
110
+
111
+ Examples:
112
+ Given this directory, where `latest` is a symlink to `v2` and `shared` is a
113
+ symlink to `/assets`:
114
+
115
+ ```text
116
+ /docs
117
+ ├── latest -> v2
118
+ ├── shared -> /assets
119
+ └── v2
120
+ └── guide.md
121
+ ```
122
+
123
+ A path within the directory, whether or not it exists, is relative to it:
124
+
125
+ ```python
126
+ relative_within(
127
+ "/docs/latest/guide.md",
128
+ "/docs/v2",
129
+ )
130
+ # PurePath("guide.md")
131
+
132
+ relative_within(
133
+ "/docs/v2/drafts/intro.md",
134
+ "/docs/v2",
135
+ )
136
+ # PurePath("drafts/intro.md")
137
+ ```
138
+
139
+ A path that leads out of the directory isn't within it:
140
+
141
+ ```python
142
+ relative_within(
143
+ "/docs/shared/logo.svg",
144
+ "/docs",
145
+ )
146
+ # None
147
+ ```
148
+ """
149
+ # Resolve before looking for the directory, so that a path that can't be resolved
150
+ # raises even when the directory doesn't exist.
151
+ #
152
+ # `resolve` can keep the caller's spelling, which a case- or normalisation-
153
+ # insensitive file system can store differently, so find the directory by identity
154
+ # rather than by name.
155
+ resolved = resolve(path)
156
+
157
+ directory_status = status(directory)
158
+
159
+ if directory_status is None:
160
+ # The paths that don't exist have no identity either, so they mustn't be
161
+ # compared with it.
162
+ return None
163
+
164
+ if not S_ISDIR(directory_status.st_mode):
165
+ raise _not_a_directory_error.not_a_directory(directory)
166
+
167
+ directory_identity = identity_of_status(directory_status)
168
+
169
+ if directory_identity is None:
170
+ raise _permission_error.unreadable_identity_error(directory)
171
+
172
+ # The resolved path had no symlinks, so one that's appeared since mustn't be
173
+ # followed out of the path that was checked.
174
+ #
175
+ # Reading a part's status follows any symlink in the parts before it, so walk down
176
+ # from the root, and stop at the first part that doesn't resolve to itself any more.
177
+ # The parts after it, if any, aren't on the path that was checked.
178
+ #
179
+ # A part that resolved to itself, like a folder that Windows mounts a volume in,
180
+ # still does, so it doesn't stop the walk.
181
+ parts: list[tuple[Path, stat_result]] = []
182
+
183
+ for part in reversed((resolved, *resolved.parents)):
184
+ part_status = status_of_resolved(
185
+ part,
186
+ path,
187
+ follow_symlinks=False,
188
+ )
189
+
190
+ if part_status is None:
191
+ break
192
+
193
+ parts.append((part, part_status))
194
+
195
+ if not _resolves_to_itself(part, path):
196
+ break
197
+
198
+ # Compare from the end, so the nearest part that's the directory wins.
199
+ for part, part_status in reversed(parts):
200
+ # The directory's identity could be read, so a part whose identity can't be
201
+ # read, `None`, isn't the directory, and never compares as equal to it.
202
+ part_identity = identity_of_status(part_status)
203
+
204
+ if part_identity == directory_identity:
205
+ return PurePath(resolved.relative_to(part))
206
+
207
+ return None