Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.9] - 2025-10-16

Release with minor bug fixes.

### Fixed

- Fixed `P.platformdirs` instance not showing IDE autocompletion.
<img width="845" height="382" alt="ProperPath platformdirs auto-complete screenshot" src="https://github.com/user-attachments/assets/3f9fe093-b509-4459-b30d-22d733c2967a" />
- Fixed type annotations

## [0.2.8] - 2025-10-16

Release with minor features.
Expand Down
15 changes: 10 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "properpath"
version = "0.2.8"
version = "0.2.9"
authors = [{ name = "Alexander Haller, Mahadi Xion", email = "elabftw@uni-heidelberg.de" }]
maintainers = [{ name = "Mahadi Xion", email = "mahadi.xion@urz.uni-heidelberg.de" }]
readme = "README.md"
description = "A pathlib.Path drop-in replacement with extra features. "
requires-python = ">=3.12"
dependencies = [
"platformdirs>=4.4.0,<4.5.0",
"platformdirs>=4.4.0,<4.6.0",
]
license = "MIT"
keywords = [
Expand Down Expand Up @@ -35,7 +35,7 @@ pydantic = [
"pydantic>=2.11.0,<2.13.0",
]
rich = [
"rich>=14.0,<14.2.0",
"rich>=14.0,<14.3.0",
]

[tool.uv.build-backend]
Expand All @@ -50,13 +50,18 @@ build-backend = "uv_build"
dev = [
"mkdocs-material>=9.6.20,<9.7",
"mkdocstrings[python]>=0.30.0,<0.31",
"mypy>=1.18.2",
"pytest>=8.4.2, <8.5",
"rich>=14.1.0, <15.0",
"ruff>=0.13.0,<0.14",
"ruff>=0.13.0,<0.15",
]

[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
"tests/extra_test",
]

[tool.ruff.lint.per-file-ignores]
# F821 is ignored so ruff doesn't complain about
# ProperPath not being found in platfordirs_.py
"src/properpath/platformdirs_.py" = ["F821"]
89 changes: 52 additions & 37 deletions src/properpath/platformdirs_.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import typing
from dataclasses import asdict, dataclass
from pathlib import Path

Expand All @@ -7,40 +8,49 @@
from platformdirs.unix import Unix
from platformdirs.windows import Windows

if typing.TYPE_CHECKING:
from .properpath import ProperPath


@dataclass(frozen=True)
class PlatformDirsCommonAttrs:
site_cache_dir: str = "site_cache_dir"
site_cache_path: str = "site_cache_path"
site_config_dir: str = "site_config_dir"
site_config_path: str = "site_config_path"
site_data_dir: str = "site_data_dir"
site_data_path: str = "site_data_path"
site_runtime_dir: str = "site_runtime_dir"
site_runtime_path: str = "site_runtime_path"
user_cache_dir: str = "user_cache_dir"
user_cache_path: str = "user_cache_path"
user_config_dir: str = "user_config_dir"
user_config_path: str = "user_config_path"
user_data_dir: str = "user_data_dir"
user_data_path: str = "user_data_path"
user_desktop_dir: str = "user_desktop_dir"
user_desktop_path: str = "user_desktop_path"
user_documents_dir: str = "user_documents_dir"
user_documents_path: str = "user_documents_path"
user_downloads_dir: str = "user_downloads_dir"
user_downloads_path: str = "user_downloads_path"
user_log_dir: str = "user_log_dir"
user_log_path: str = "user_log_path"
user_music_dir: str = "user_music_dir"
user_music_path: str = "user_music_path"
user_pictures_dir: str = "user_pictures_dir"
user_pictures_path: str = "user_pictures_path"
user_runtime_dir: str = "user_runtime_dir"
user_runtime_path: str = "user_runtime_path"
user_state_dir: str = "user_state_dir"
user_state_path: str = "user_state_path"
user_videos_dir: str = "user_videos_dir"
"""
PlatformDirsCommonAttrs defines the main attributes of platformdirs.
This class is used as a mixin, and it also ensures that IDEs/editors auto-suggestion works
with an instantiated P.platformdirs() object.
"""

site_cache_dir: "ProperPath" = NotImplemented
site_cache_path: "ProperPath" = NotImplemented
site_config_dir: "ProperPath" = NotImplemented
site_config_path: "ProperPath" = NotImplemented
site_data_dir: "ProperPath" = NotImplemented
site_data_path: "ProperPath" = NotImplemented
site_runtime_dir: "ProperPath" = NotImplemented
site_runtime_path: "ProperPath" = NotImplemented
user_cache_dir: "ProperPath" = NotImplemented
user_cache_path: "ProperPath" = NotImplemented
user_config_dir: "ProperPath" = NotImplemented
user_config_path: "ProperPath" = NotImplemented
user_data_dir: "ProperPath" = NotImplemented
user_data_path: "ProperPath" = NotImplemented
user_desktop_dir: "ProperPath" = NotImplemented
user_desktop_path: "ProperPath" = NotImplemented
user_documents_dir: "ProperPath" = NotImplemented
user_documents_path: "ProperPath" = NotImplemented
user_downloads_dir: "ProperPath" = NotImplemented
user_downloads_path: "ProperPath" = NotImplemented
user_log_dir: "ProperPath" = NotImplemented
user_log_path: "ProperPath" = NotImplemented
user_music_dir: "ProperPath" = NotImplemented
user_music_path: "ProperPath" = NotImplemented
user_pictures_dir: "ProperPath" = NotImplemented
user_pictures_path: "ProperPath" = NotImplemented
user_runtime_dir: "ProperPath" = NotImplemented
user_runtime_path: "ProperPath" = NotImplemented
user_state_dir: "ProperPath" = NotImplemented
user_state_path: "ProperPath" = NotImplemented
user_videos_dir: "ProperPath" = NotImplemented


platformdirs_attrs = PlatformDirsCommonAttrs()
Expand All @@ -55,12 +65,17 @@ def patch(
attr: str,
super_obj: PlatformDirs | Unix | MacOS | Windows | Android,
):
if attr in asdict(platformdirs_attrs).values():
if attr in asdict(platformdirs_attrs).keys():
return self.path_cls(getattr(super_obj, attr))
return super_obj.__getattribute__(attr)


class ProperPlatformDirs(_PlatformDirsGetAttrPatcher, PlatformDirs):
# MyPy complains about the attributes of PlatformDirsCommonAttrs to be of different
# types (ProperPath) from the base class PlatformDirs's attributes' types (str).
# Hence, ProperPlatformDirs and similar classes are type-ignored.
class ProperPlatformDirs( # type: ignore
_PlatformDirsGetAttrPatcher, PlatformDirs, PlatformDirsCommonAttrs
):
def __init__(self, *args, path_cls: type[Path], **kwargs):
super().__init__(path_cls=path_cls)
super(PlatformDirs, self).__init__(*args, **kwargs)
Expand All @@ -69,7 +84,7 @@ def __getattribute__(self, item):
return super().patch(item, super())


class ProperUnix(_PlatformDirsGetAttrPatcher, Unix):
class ProperUnix(_PlatformDirsGetAttrPatcher, Unix, PlatformDirsCommonAttrs): # type: ignore
def __init__(self, *args, path_cls: type[Path], **kwargs):
super().__init__(path_cls=path_cls)
super(Unix, self).__init__(*args, **kwargs)
Expand All @@ -78,7 +93,7 @@ def __getattribute__(self, item):
return super().patch(item, super())


class ProperMacOS(_PlatformDirsGetAttrPatcher, MacOS):
class ProperMacOS(_PlatformDirsGetAttrPatcher, MacOS, PlatformDirsCommonAttrs): # type: ignore
def __init__(self, *args, path_cls: type[Path], **kwargs):
super().__init__(path_cls=path_cls)
super(MacOS, self).__init__(*args, **kwargs)
Expand All @@ -87,7 +102,7 @@ def __getattribute__(self, item):
return super().patch(item, super())


class ProperAndroid(_PlatformDirsGetAttrPatcher, Android):
class ProperAndroid(_PlatformDirsGetAttrPatcher, Android, PlatformDirsCommonAttrs): # type: ignore
def __init__(self, *args, path_cls: type[Path], **kwargs):
super().__init__(path_cls=path_cls)
super(Android, self).__init__(*args, **kwargs)
Expand All @@ -96,7 +111,7 @@ def __getattribute__(self, item):
return super().patch(item, super())


class ProperWindows(_PlatformDirsGetAttrPatcher, Windows):
class ProperWindows(_PlatformDirsGetAttrPatcher, Windows, PlatformDirsCommonAttrs): # type: ignore
def __init__(self, *args, path_cls: type[Path], **kwargs):
super().__init__(path_cls=path_cls)
super(Windows, self).__init__(*args, **kwargs)
Expand Down
33 changes: 21 additions & 12 deletions src/properpath/properpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,28 @@
from .platformdirs_ import ProperPlatformDirs, ProperUnix

try:
# noinspection PyUnusedImports
from pydantic import GetCoreSchemaHandler

# noinspection PyUnusedImports
from pydantic_core import core_schema
except ImportError as import_error:
error = import_error

# noinspection PyUnusedLocal
def _get_pydantic_core_schema(cls, source_type: Any, handler: Any):
def _get_pydantic_core_schema(
cls, source_type: Any, handler: "GetCoreSchemaHandler"
):
raise NotImplementedError(
f"pydantic must be installed for "
f"{_get_pydantic_core_schema.__name__} to work."
) from error
else:
# noinspection PyUnusedLocal
def _get_pydantic_core_schema(
cls, source_type: Any, handler: Any
# Mypy complains: "All conditional function variants must have identical signatures".
# In this case, it doesn't matter and can be ignored.
def _get_pydantic_core_schema( # type: ignore
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
from_string_validator = core_schema.no_info_plain_validator_function(cls)
python_schema = core_schema.union_schema(
Expand Down Expand Up @@ -64,7 +71,7 @@ class ProperPath(Path):

path1 = ProperPath("~/Downloads")
repr(path1)
# Prints: ProperPath(path=/Users/culdesac/Downloads, actual=('~/Downloads',),
# Prints: ProperPath(path=/Users/culdesac/Downloads, actual=('~/Downloads', ),
kind=dir, exists=True, err_logger=<RootLogger root (WARNING)>)
```

Expand Down Expand Up @@ -116,7 +123,7 @@ def platformdirs(
opinion: bool = True,
ensure_exists: bool = False,
follow_unix: bool = False,
) -> ProperPlatformDirs:
) -> ProperPlatformDirs | ProperUnix:
"""
Initializes and returns a `platformdirs.PlatformDirs` instance.

Expand All @@ -133,7 +140,7 @@ def platformdirs(


Example:
```python hl_lines="3 8 13"
```python hl_lines="3 8 13"
app_dirs = ProperPath.platformdirs("MyApp", "MyOrg")
app_dirs.user_data_dir
# Returns ProperPath('/Users/user/Library/Application Support/MyApp')
Expand Down Expand Up @@ -259,7 +266,9 @@ def __deepcopy__(self, memo):
return instance

@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any):
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: "GetCoreSchemaHandler"
) -> "core_schema.CoreSchema":
"""
Enables Pydantic validation support.
See [Pydantic documentation](https://docs.pydantic.dev/latest/concepts/types/#customizing-validation-with-__get_pydantic_core_schema__).
Expand All @@ -276,7 +285,7 @@ def __truediv__(self, other) -> "ProperPath":
def actual(self) -> Iterable[str]:
"""
Provides access to the user-given path (or path segments) that was passed to the constructor of the
`ProperPath` instance. `ProperPath` by default expands any user indicator `"~"`
`ProperPath` instance. `ProperPath`, by default, expands any user indicator `"~"`
automatically and uses the expanded path for all operations. The `actual` attribute will reveal
the non-expanded original value.

Expand Down Expand Up @@ -642,7 +651,7 @@ def get_text(
) -> Optional[str]:
"""
`get_text` method is basically [`read_text`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.read_text)
with support for extra `defaults` parameter. `get_text` passes optional arguments `encoding`, `errors`, `newline` to
with support for an extra `default` parameter. `get_text` passes optional arguments `encoding`, `errors`, `newline` to
`read_text`.

Args:
Expand All @@ -665,7 +674,7 @@ def get_text(
```

Returns:
The decoded contents of the pointed-to file as a string or default (when file does not exist).
The decoded contents of the pointed-to file as a string or default (when the file does not exist).
"""
try:
if sys.version_info.minor >= 13:
Expand All @@ -678,14 +687,14 @@ def get_text(
def get_bytes(self, default: Optional[bytes] = None) -> Optional[bytes]:
"""
`get_bytes` method is basically [`read_bytes`](https://docs.python.org/3.13/library/pathlib.html#pathlib.Path.read_bytes)
with support for extra `defaults` parameter.
with support for an extra `default` parameter.

Args:
default:
If the file does not exist, then `default` is returned. By default, `default` is `None`.

Returns:
The binary contents of the pointed-to file as a bytes object or default (when file does not exist).
The binary contents of the pointed-to file as "bytes" object or default (when the file does not exist).
"""
try:
return super().read_bytes()
Expand Down
Loading