From 4afdb8a744523026ecab41b2dddcbcc22c3d46c9 Mon Sep 17 00:00:00 2001 From: ilias Date: Thu, 15 May 2025 22:39:34 +0300 Subject: [PATCH] Add path types (#149) --- pydantic_extra_types/path.py | 60 +++++++++++++++++ tests/test_path.py | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 pydantic_extra_types/path.py create mode 100644 tests/test_path.py diff --git a/pydantic_extra_types/path.py b/pydantic_extra_types/path.py new file mode 100644 index 0000000..b8c550f --- /dev/null +++ b/pydantic_extra_types/path.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import typing +from dataclasses import dataclass +from pathlib import Path + +import pydantic +from pydantic.types import PathType +from pydantic_core import core_schema +from typing_extensions import Annotated + +ExistingPath = typing.Union[pydantic.FilePath, pydantic.DirectoryPath] + + +@dataclass +class ResolvedPathType(PathType): + """A custom PathType that resolves the path to its absolute form. + + Args: + path_type (typing.Literal['file', 'dir', 'new']): The type of path to resolve. Can be 'file', 'dir' or 'new'. + + Returns: + Resolved path as a pathlib.Path object. + + Example: + ```python + from pydantic import BaseModel + from pydantic_extra_types.path import ResolvedFilePath, ResolvedDirectoryPath, ResolvedNewPath + + + class MyModel(BaseModel): + file_path: ResolvedFilePath + dir_path: ResolvedDirectoryPath + new_path: ResolvedNewPath + + + model = MyModel(file_path='~/myfile.txt', dir_path='~/mydir', new_path='~/newfile.txt') + print(model.file_path) + # > file_path=PosixPath('/home/user/myfile.txt') dir_path=PosixPath('/home/user/mydir') new_path=PosixPath('/home/user/newfile.txt')""" + + @staticmethod + def validate_file(path: Path, _: core_schema.ValidationInfo) -> Path: + return PathType.validate_file(path.expanduser().resolve(), _) + + @staticmethod + def validate_directory(path: Path, _: core_schema.ValidationInfo) -> Path: + return PathType.validate_directory(path.expanduser().resolve(), _) + + @staticmethod + def validate_new(path: Path, _: core_schema.ValidationInfo) -> Path: + return PathType.validate_new(path.expanduser().resolve(), _) + + def __hash__(self) -> int: + return hash(type(self.path_type)) + + +ResolvedFilePath = Annotated[Path, ResolvedPathType('file')] +ResolvedDirectoryPath = Annotated[Path, ResolvedPathType('dir')] +ResolvedNewPath = Annotated[Path, ResolvedPathType('new')] +ResolvedExistingPath = typing.Union[ResolvedFilePath, ResolvedDirectoryPath] diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..cae85ff --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,127 @@ +import os +import pathlib + +import pytest +from pydantic import BaseModel + +from pydantic_extra_types.path import ( + ExistingPath, + ResolvedDirectoryPath, + ResolvedExistingPath, + ResolvedFilePath, + ResolvedNewPath, +) + + +class File(BaseModel): + file: ResolvedFilePath + + +class Directory(BaseModel): + directory: ResolvedDirectoryPath + + +class NewPath(BaseModel): + new_path: ResolvedNewPath + + +class Existing(BaseModel): + existing: ExistingPath + + +class ResolvedExisting(BaseModel): + resolved_existing: ResolvedExistingPath + + +@pytest.fixture +def absolute_file_path(tmp_path: pathlib.Path) -> pathlib.Path: + directory = tmp_path / 'test-relative' + directory.mkdir() + file_path = directory / 'test-relative.txt' + file_path.touch() + return file_path + + +@pytest.fixture +def relative_file_path(absolute_file_path: pathlib.Path) -> pathlib.Path: + return pathlib.Path(os.path.relpath(absolute_file_path, os.getcwd())) + + +@pytest.fixture +def absolute_directory_path(tmp_path: pathlib.Path) -> pathlib.Path: + directory = tmp_path / 'test-relative' + directory.mkdir() + return directory + + +@pytest.fixture +def relative_directory_path(absolute_directory_path: pathlib.Path) -> pathlib.Path: + return pathlib.Path(os.path.relpath(absolute_directory_path, os.getcwd())) + + +@pytest.fixture +def absolute_new_path(tmp_path: pathlib.Path) -> pathlib.Path: + return tmp_path / 'test-relative' + + +@pytest.fixture +def relative_new_path(absolute_new_path: pathlib.Path) -> pathlib.Path: + return pathlib.Path(os.path.relpath(absolute_new_path, os.getcwd())) + + +def test_relative_file(absolute_file_path: pathlib.Path, relative_file_path: pathlib.Path): + file = File(file=relative_file_path) + assert file.file == absolute_file_path + + +def test_absolute_file(absolute_file_path: pathlib.Path): + file = File(file=absolute_file_path) + assert file.file == absolute_file_path + + +def test_relative_directory(absolute_directory_path: pathlib.Path, relative_directory_path: pathlib.Path): + directory = Directory(directory=relative_directory_path) + assert directory.directory == absolute_directory_path + + +def test_absolute_directory(absolute_directory_path: pathlib.Path): + directory = Directory(directory=absolute_directory_path) + assert directory.directory == absolute_directory_path + + +def test_relative_new_path(absolute_new_path: pathlib.Path, relative_new_path: pathlib.Path): + new_path = NewPath(new_path=relative_new_path) + assert new_path.new_path == absolute_new_path + + +def test_absolute_new_path(absolute_new_path: pathlib.Path): + new_path = NewPath(new_path=absolute_new_path) + assert new_path.new_path == absolute_new_path + + +@pytest.mark.parametrize( + ('pass_fixture', 'expect_fixture'), + ( + ('relative_file_path', 'relative_file_path'), + ('absolute_file_path', 'absolute_file_path'), + ('relative_directory_path', 'relative_directory_path'), + ('absolute_directory_path', 'absolute_directory_path'), + ), +) +def test_existing_path(request: pytest.FixtureRequest, pass_fixture: str, expect_fixture: str): + existing = Existing(existing=request.getfixturevalue(pass_fixture)) + assert existing.existing == request.getfixturevalue(expect_fixture) + + +@pytest.mark.parametrize( + ('pass_fixture', 'expect_fixture'), + ( + ('relative_file_path', 'absolute_file_path'), + ('absolute_file_path', 'absolute_file_path'), + ('relative_directory_path', 'absolute_directory_path'), + ('absolute_directory_path', 'absolute_directory_path'), + ), +) +def test_resolved_existing_path(request: pytest.FixtureRequest, pass_fixture: str, expect_fixture: str): + resolved_existing = ResolvedExisting(resolved_existing=request.getfixturevalue(pass_fixture)) + assert resolved_existing.resolved_existing == request.getfixturevalue(expect_fixture)