Tutorial¶
In the examples below, we will use the shorthand P: from properpath import P, instead of
from properpath import ProperPath, where P == ProperPath.
That is because P is much shorter and easier to type, and makes working with paths on the REPL
more enjoyable.
Drop-in pathlib.Path replacement¶
Since ProperPath is a subclass of pathlib.Path it
supports all the methods and attributes supported by
pathlib.Path. We can pass a pathlib.Path instance or a string path or multiple path segments or os.path values to
ProperPath.
>>> from properpath import P
>>> p = P("~/foo")
>>> p
ProperPath(path=/Users/username/foo, actual=('~/foo',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> isinstance(p, pathlib.Path)
True
>>> P.home() # pathlib.Path's method
ProperPath(path=/Users/username, actual=('/Users/username',), kind=dir, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
ProperPath shows more information about the path on the REPL (or a
repr call from inside a script). Notice, how ProperPath
always expands the username (~) segment by default.
A ProperPath instance can also be passed to pathlib.Path or os.path methods.
Is a file or a dir?¶
A ProperPath instance stores whether the path is a file or a directory in the kind attribute. If the path doesn't
exist beforehand, PropePath will try to assume it from the path's extension.
ProperPath also knows how to handle special files like /dev/null.
>>> p = P("~/foo.txt")
>>> p.exists()
False
>>> p.kind # Kind is determined from the file extension.
'file'
>>> p = P("~/foo")
>>> p.exists()
True
>>> p.kind
'dir'
In this code block though we could just use is_dir(). The real power of kind comes when we're working with
files/directories that aren't strictly created or handled by us, but we know what kind we are expecting. When kind
attribute is modified by the developer,
the kind is treated as the developer-expected kind. When kind is not modified, ProperPath determines the
appropriate kind.
We can modify kind by passing it as an argument to the
constructor during the path instance creation, or later on by simply updating the value of the attribute
p.kind = "<file or dir>".
Pure ProperPath operations will expect that kind for all
future operations. This can help catch unexpected errors or even prevent unexpected file operation.
An example: Let's consider a situation where we expect a
file named foo to exist in user's ~/Downloads folder. But for whatever reason, a directory with the exact the
same name already exists in ~/Downloads. If we want to create the file with
pathlib.Path("~/Downloads/foo").expanduser().touch(exist_ok=True), the method will succeed, and we will have assumed a
file was indeed created! ProperPath's create method will use kind to find out the mismatch in expectation, and
throw an error.
>>> q = P("~/Downloads", "foo", kind="file")
ProperPath(path=/Users/username/Downloads/foo, actual=('/Users/username/Downloads/foo',), kind=file, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> q.create()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/username/Workshop/properpath/src/properpath/properpath.py", line 441, in create
raise e
File "/Users/username/Workshop/properpath/src/properpath/properpath.py", line 421, in create
raise is_a_dir_exception(message)
IsADirectoryError: File was expected but a directory with the same name was found: PATH=/Users/username/Downloads from SOURCE=('~/Downloads', 'foo').
Summary
In short, when we don't modify the kind attribute, kind simply gives the path's
is_file() or
is_dir() status. When
we do modify the attribute, our modified kind is cached, and is treated as the expected kind for all future
operations. When this expected kind doesn't match the actual kind of the path in the system for whatever reason,
ProperPath will attempt to throw an error before irrecoverable operations like deleting files.
Built-in error logging¶
A custom logger can be passed to ProperPath instance. This logger will be used throughout path operations for that
path instance. If no
logger is passed, ProperPath will use P.default_err_logger class attribute (which by default is the Python
root logger).
>>> import logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> p = P("/var/log/my_app.log")
>>> with p.open("w") as f:
... f.write("Hello, world!")
...
DEBUG:root:Could not open file PATH=/private/var/log/my_app.log from SOURCE=('/var/log/my_app.log',).
Exception: PermissionError(13, 'Permission denied')
Traceback (most recent call last):
File "<python-input-4>", line 1, in <module>
with p.open("w") as f:
~~~~~~^^^^^
# Any exception raised during path operations will be logged to the new_logger,
# before being raised.
Note
All log messages are logged as DEBUG messages. So the default logging level or handler level should be set
to DEBUG. This is so that path logs don't overwhelm the regular users, and the DEBUG level is only set for
debugging/development.
We can also pass our own custom logger to
P("/var/log/my_app.log", err_logger=logging.getLogger("my_logger")), or modify the err_logger attribute at
runtime. Each logger is tied to the instance it was passed to. If we want to have a single logger to be shared with all
instances of ProperPath, we just set the class attribute
P.default_err_logger = logging.getLogger("my_logger").
create and remove paths¶
To create a new file or directory, pathlib.Path would require a boilerplate if path.is_file(): or
if path.is_dir(): block if the path is unknown. ProperPath provides the create method that simplifies this step.
Just call create on any path to create it. If the path already exists, nothing happens.
Similarly, the remove removes the need to boilerplate check for if the path is a file or a directory, or if it is
empty
or not. If the path is a directory, everything inside it will be removed recursively by default. remove method accepts
a parent_only argument, which if True, will only remove the top-level contents only (i.e., will remove only the
files,
will not do a recursion into other directories).
The code above will only ~/.local/share/my_app/config.toml, and leave custom/ and plugins/ directories as is. If
parents_only=False is passed (the default), everything inside my_app directory will be deleted recursively. Under
the hood both create and remove methods take advantage of the kind attribute.
Better Platformdirs¶
ProperPath comes integrated with a popular library used for managing common application
paths: platformdirs. E.g., to get OS-standard locations for configuration
files, logs, caches, etc. See
platformdirs documentation
for more details and examples for other operating systems. Values from platformdirs by default are strings. But
with P.platformdirs, you can get ProperPath instances instead.
>>> from properpath import P
>>> app_dirs = P.platformdirs("my_app", "my_org")
>>> app_dirs.user_config_dir
ProperPath(path=/Users/username/Library/Application Support/my_app, actual=('/Users/username/Library/Application Support/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_data_dir
ProperPath(path=/Users/username/Library/Application Support/my_app, actual=('/Users/username/Library/Application Support/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_cache_dir
ProperPath(path=/Users/username/Library/Caches/my_app, actual=('/Users/username/Library/Caches/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.site_data_dir
ProperPath(path=/Library/Application Support/my_app, actual=('/Library/Application Support/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.site_config_dir
ProperPath(path=/Library/Application Support/my_app, actual=('/Library/Application Support/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_documents_dir
ProperPath(path=/Users/username/Documents, actual=('/Users/username/Documents',), kind=dir, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_downloads_dir
ProperPath(path=/Users/username/Downloads, actual=('/Users/username/Downloads',), kind=dir, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> # Etc. See whole list: https://github.com/tox-dev/platformdirs?tab=readme-ov-file#platformdirs-for-convenience
Platformdirs enforces a strict directory structure for macOS, but many tools out there follow the Unix-style directory
structures on macOS as well. ProperPath provides an additional follow_unix argument to ProperPath.platformdirs
that will enforce Unix-style directory structure on macOS, but will leave Windows as is.
>>> app_dirs = P.platformdirs("my_app", "my_org", follow_unix=True)
>>> app_dirs.user_config_dir
ProperPath(path=/Users/username/.config/my_app, actual=('/Users/username/.config/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_data_dir
ProperPath(path=/Users/username/.local/share/my_app, actual=('/Users/username/.local/share/my_app',), kind=dir, is_symlink=False, exists=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_cache_dir
ProperPath(path=/Users/username/.cache/my_app, actual=('/Users/username/.cache/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.site_data_dir
ProperPath(path=/usr/local/share/my_app, actual=('/usr/local/share/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.site_config_dir
ProperPath(path=/etc/xdg/my_app, actual=('/etc/xdg/my_app',), kind=dir, exists=False, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_documents_dir
ProperPath(path=/Users/username/Documents, actual=('/Users/username/Documents',), kind=dir, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
>>> app_dirs.user_downloads_dir
ProperPath(path=/Users/username/Downloads, actual=('/Users/username/Downloads',), kind=dir, exists=True, is_symlink=False, err_logger=<RootLogger root (WARNING)>)
Path validation¶
We often write to files, so we need to make sure if the file we're writing to is even writable; i.e., if the file
exists, if there is enough storage space, if there is sufficient permission, etc. ProperPath comes with a
PathWriteValidator class that can be used to do exactly that. Example: we want to write to a file from a list of
fallback files, and we want to write to the first one that works.
Of course, a single path can also be passed to PathWriteValidator.
ProperPath comes with a PathException attribute that stores any exception raised during any path operation. In other
words, an error raised for a path is tied to that path only. We can use this PathException to implement a fallback
mechanism. I.e., if we want to just forget about the error from one path, and move onto the next path. From one of our
previous examples:
In some ways, PathException treats errors as values.