From adab90736c0fef4a5a3d51a6e29f9400619357d3 Mon Sep 17 00:00:00 2001 From: Ethan Paul <24588726+enpaul@users.noreply.github.com> Date: Wed, 24 Nov 2021 17:13:41 -0500 Subject: [PATCH] Add PathField class for storing pathlib objects Add tests for pathfield class Add database fixture for generating test databases --- peewee_plus.py | 68 +++++++++++++++++++++++++++++++++++++++ tests/fixtures.py | 22 +++++++++++++ tests/test_pathfield.py | 70 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 tests/fixtures.py create mode 100644 tests/test_pathfield.py diff --git a/peewee_plus.py b/peewee_plus.py index 2b8009a..5f2ba9d 100644 --- a/peewee_plus.py +++ b/peewee_plus.py @@ -1,6 +1,74 @@ +from pathlib import Path +from typing import Optional + +import peewee + __title__ = "peewee-plus" __version__ = "0.1.0" __license__ = "MIT" __summary__ = "Various extensions, helpers, and utilities for Peewee" __url__ = "https://github.com/enpaul/peewee-plus/" __authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"] + + +__all__ = ["PathField"] + + +class PathField(peewee.CharField): + """Field class for storing file paths + + This field can be used to simply store pathlib paths in the database without needing to + cast to ``str`` on write and ``Path`` on read. + + It can also serve to save paths relative to a root path defined at runtime. This can be + useful when an application stores files under a directory defined in the app configuration, + such as in an environment variable or a config file. + + For example, if a model is defined like below to load a path from the ``MYAPP_DATA_DIR`` + environment variable: + + .. code-block:: python + + class MyModel(peewee.Model): + some_path = peewee_plus.PathField(relative_to=Path(os.environ["MYAPP_DATA_DIR"])) + + + p1 = MyModel(some_path=Path(os.environ["MYAPP_DATA_DIR"]) / "foo.json").save() + p2 = MyModel(some_path=Path("bar.json")).save() + + Then the data directory can be changed without updating the database, and the code can + still rely on the database always returning absolute paths: + + :: + + >>> os.environ["MYAPP_DATA_DIR"] = "/etc/myapp" + >>> [item.some_path for item in MyModel.select()] + [PosixPath('/etc/myapp/foo.json'), PosixPath('/etc/myapp/bar.json')] + >>> + >>> os.environ["MYAPP_DATA_DIR"] = "/opt/myapp/data" + >>> [item.some_path for item in MyModel.select()] + [PosixPath('/opt/myapp/data/foo.json'), PosixPath('/opt/myapp/data/bar.json')] + >>> + + :param relative_to: Optional root path that paths should be stored relative to. If specified + then values being set will be converted to relative paths under this path, + and values being read will always be absolute paths under this path. + """ + + def __init__(self, *args, relative_to: Optional[Path] = None, **kwargs): + super().__init__(*args, **kwargs) + self.relative_to = relative_to + + def db_value(self, value: Path) -> str: + """Serialize a :class:`pathlib.Path` to a database string""" + if value.is_absolute() and self.relative_to: + value = value.relative_to(self.relative_to) + return super().db_value(value) + + def python_value(self, value: str) -> Path: + """Serialize a database string to a :class:`pathlib.path` object""" + return ( + self.relative_to / Path(super().python_value(value)) + if self.relative_to + else Path(super().python_value(value)) + ) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..f598d94 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,22 @@ +import uuid + +import peewee +import pytest + + +@pytest.fixture(scope="function") +def fakedb(tmp_path): + """Create a temporary pho-database (fakedb) for testing fields""" + + sqlite = peewee.SqliteDatabase( + tmp_path / f"{uuid.uuid4()}.db", + pragmas={ + "journal_mode": "wal", + "cache_size": -1 * 64000, + "foreign_keys": 1, + "ignore_check_constraints": 0, + "synchronous": 0, + }, + ) + + yield sqlite diff --git a/tests/test_pathfield.py b/tests/test_pathfield.py new file mode 100644 index 0000000..f2fb3f3 --- /dev/null +++ b/tests/test_pathfield.py @@ -0,0 +1,70 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=missing-class-docstring +# pylint: disable=too-few-public-methods +# pylint: disable=unused-import +from pathlib import Path + +import peewee + +import peewee_plus +from .fixtures import fakedb + + +def test_conversion(fakedb): + """Test basic usage of PathField for roundtrip compatibility""" + + class TestModel(peewee.Model): + class Meta: + database = fakedb + + name = peewee.CharField() + some_path = peewee_plus.PathField() + + fakedb.create_tables([TestModel]) + + path1 = Path("foo", "bar", "baz") + model1 = TestModel(name="one", some_path=path1) + model1.save() + + model1 = TestModel.get(TestModel.name == "one") + assert model1.some_path == path1 + assert not model1.some_path.is_absolute() + + path2 = Path("/etc", "fizz", "buzz") + model2 = TestModel(name="two", some_path=path2) + model2.save() + + model2 = TestModel.get(TestModel.name == "two") + assert model2.some_path == path2 + assert model2.some_path.is_absolute() + + +def test_relative_to(fakedb): + """Test usage of the ``relative_to`` parameter""" + + base_path = Path("/etc", "foobar") + + class TestModel(peewee.Model): + class Meta: + database = fakedb + + name = peewee.CharField() + some_path = peewee_plus.PathField(relative_to=base_path) + + fakedb.create_tables([TestModel]) + + path1 = Path("foo", "bar", "baz") + model1 = TestModel(name="one", some_path=path1) + model1.save() + + model1 = TestModel.get(TestModel.name == "one") + assert model1.some_path.is_absolute() + assert model1.some_path == base_path / path1 + + path2 = Path("fizz", "buzz") + model2 = TestModel(name="two", some_path=base_path / path2) + model2.save() + + model2 = TestModel.get(TestModel.name == "two") + assert model2.some_path.is_absolute() + assert model2.some_path == base_path / path2