Skip to content

Commit decbb31

Browse files
committed
Add EnsureWriteableDirectory.__fspath__ method
* Using `__fspath__` method makes the class explicitly conform to the `os.PathLike` protocol. It signals that the object is designed to be used as file path. * Relying `__getattr__` can sometimes have subtle side effects. An explicit implementation of `__fspath__` method is always safer.
1 parent fdc35c6 commit decbb31

2 files changed

Lines changed: 55 additions & 16 deletions

File tree

src/docbuild/models/path.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,28 @@
33
import os
44
from pathlib import Path
55
from typing import Any, Self
6+
67
from pydantic import GetCoreSchemaHandler
7-
from pydantic_core import core_schema
8+
from pydantic_core import core_schema
89

910

1011
class EnsureWritableDirectory:
11-
"""
12-
A Pydantic custom type that ensures a directory exists and is writable.
12+
"""A Pydantic custom type that ensures a directory exists and is writable.
1313
1414
Behavior:
1515
1. Expands user paths (e.g., "~/data" -> "/home/user/data").
1616
2. Validates input is a path.
1717
3. If path DOES NOT exist: It creates it (including parents).
18-
4. If path DOES exist (or was just created): It checks is_dir() and R/W/X permissions.
18+
4. If path DOES exist (or was just created): It checks is_dir()
19+
and R/W/X permissions.
1920
"""
2021

21-
2222
def __init__(self, path: str | Path) -> None:
23-
"""
24-
Initializes the instance with the fully resolved and expanded path.
23+
"""Initialize the instance with the fully resolved and expanded path.
24+
2525
Assumes the validation step (validate_and_create) has already handled
2626
creation and permission checks.
2727
"""
28-
2928
self._path: Path = Path(path).expanduser().resolve()
3029

3130
# --- Pydantic V2 Core Schema ---
@@ -59,10 +58,10 @@ def __get_pydantic_core_schema__(
5958

6059
@classmethod
6160
def validate_and_create(cls, path: Path) -> Self:
62-
"""
63-
Expands user, checks if path exists. If not, creates it. Then checks permissions.
64-
"""
61+
"""Expand user, checks if path exists.
6562
63+
If not, creates it. Then checks permissions.
64+
"""
6665
# Ensure user expansion happens before any filesystem operations
6766
path = path.expanduser()
6867

@@ -73,7 +72,7 @@ def validate_and_create(cls, path: Path) -> Self:
7372
# exist_ok=True: prevents race conditions
7473
path.mkdir(parents=True, exist_ok=True)
7574
except OSError as e:
76-
raise ValueError(f"Could not create directory '{path}': {e}")
75+
raise ValueError(f"Could not create directory '{path}': {e}") from e
7776

7877
# 2. Type Check
7978
if not path.is_dir():
@@ -95,13 +94,24 @@ def validate_and_create(cls, path: Path) -> Self:
9594
return cls(path)
9695

9796
# --- Usability Methods ---
98-
9997
def __str__(self) -> str:
98+
"""Return the string representation of the path."""
10099
return str(self._path)
101-
100+
102101
def __repr__(self) -> str:
102+
"""Return the developer-friendly representation of the object."""
103103
return f"{self.__class__.__name__}('{self._path}')"
104-
105-
# Allows access to methods/attributes of the underlying Path object (e.g., .joinpath)
104+
105+
def __truediv__(self, other: str) -> Path:
106+
"""Implement the / operator to delegate to the underlying Path object."""
107+
return self._path / other
108+
109+
# Allows access to methods/attributes of the underlying Path object
110+
# (e.g., .joinpath)
106111
def __getattr__(self, name: str) -> Any:
112+
"""Delegate attribute access to the underlying Path object."""
107113
return getattr(self._path, name)
114+
115+
def __fspath__(self) -> str:
116+
"""Return the string path for os.PathLike compatibility."""
117+
return str(self._path)

tests/models/test_path.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,32 @@ def test_writable_directory_attribute_access(tmp_path: Path):
185185
# Test string representation
186186
assert str(model.writable_dir) == str(test_dir.resolve())
187187
assert repr(model.writable_dir).startswith("EnsureWritableDirectory")
188+
189+
190+
@pytest.mark.parametrize(
191+
"path_consumer, expected_factory",
192+
[
193+
(os.fspath, lambda p: str(p.resolve())),
194+
(Path, lambda p: p.resolve()),
195+
(lambda p: (p / "test.txt").read_text(), lambda p: "hello"),
196+
],
197+
ids=["os.fspath", "Path constructor", "open and read"],
198+
)
199+
def test_fspath_protocol_compatibility(
200+
request: pytest.FixtureRequest,
201+
tmp_path: Path,
202+
path_consumer, expected_factory
203+
):
204+
"""Test that EnsureWritableDirectory works with functions expecting os.PathLike."""
205+
test_dir = tmp_path / "fspath_test"
206+
model = PathTestModel(writable_dir=test_dir) # type: ignore
207+
custom_path_obj = model.writable_dir
208+
209+
# Pre-condition for the open() test case
210+
if request.node.callspec.id == "open and read":
211+
(test_dir / "test.txt").write_text("hello")
212+
213+
# Execute the function that consumes the path-like object
214+
result = path_consumer(custom_path_obj)
215+
expected = expected_factory(test_dir)
216+
assert result == expected

0 commit comments

Comments
 (0)