diff --git a/docs/guide/packages.qmd b/docs/guide/packages.qmd index 4da382878..a0b9db803 100644 --- a/docs/guide/packages.qmd +++ b/docs/guide/packages.qmd @@ -90,7 +90,7 @@ properties, you can use the `update_package_properties()` function: ``` {python} package_properties = sp.edit_package_properties( path=sp.path_package_properties(package_id=1), - properties=properties.compact_dict + properties=properties, ) pprint(package_properties) ``` diff --git a/poetry.lock b/poetry.lock index 0b8b4daed..273518cd5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -571,6 +571,19 @@ questionary = ">=2.0,<3.0" termcolor = ">=1.1,<3" tomlkit = ">=0.5.3,<1.0.0" +[[package]] +name = "dacite" +version = "1.8.1" +description = "Simple creation of data classes from dictionaries." +optional = false +python-versions = ">=3.6" +files = [ + {file = "dacite-1.8.1-py3-none-any.whl", hash = "sha256:cc31ad6fdea1f49962ea42db9421772afe01ac5442380d9a99fcf3d188c61afe"}, +] + +[package.extras] +dev = ["black", "coveralls", "mypy", "pre-commit", "pylint", "pytest (>=5)", "pytest-benchmark", "pytest-cov"] + [[package]] name = "datamodel-code-generator" version = "0.26.5" @@ -744,18 +757,18 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc [[package]] name = "filelock" -version = "3.16.1" +version = "3.17.0" description = "A platform independent file lock." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, + {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, + {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] typing = ["typing-extensions (>=4.12.2)"] [[package]] @@ -3560,4 +3573,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "da84dd50f3526861359c19b51e914fde32daae4de6c052a8a9153e84ecd81b74" +content-hash = "9a5796adad98b8bd00c4dce99c2a3e61483242dbdca20a566bee489e3585101a" diff --git a/pyproject.toml b/pyproject.toml index 4f920d08d..aeac77747 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ frictionless = {extras = ["sql"], version = "^5.17.0"} requests = "^2.32.3" platformdirs = "^4.3.2" jsonschema = "^4.23.0" +dacite = "^1.8.1" [tool.poetry.group.test.dependencies] pytest = "^8.3.2" diff --git a/seedcase_sprout/core/create_resource_properties.py b/seedcase_sprout/core/create_resource_properties.py index 3badacd02..99ca160ef 100644 --- a/seedcase_sprout/core/create_resource_properties.py +++ b/seedcase_sprout/core/create_resource_properties.py @@ -7,12 +7,15 @@ create_relative_resource_data_path, ) from seedcase_sprout.core.edit_property_field import edit_property_field +from seedcase_sprout.core.properties import ResourceProperties from seedcase_sprout.core.verify_properties_are_well_formed import ( verify_properties_are_well_formed, ) -def create_resource_properties(path: Path, properties: dict) -> dict: +def create_resource_properties( + path: Path, properties: ResourceProperties +) -> ResourceProperties: """Creates a valid properties object for the specified resource. This function sets up and structures a new resource property by taking @@ -37,7 +40,10 @@ def create_resource_properties(path: Path, properties: dict) -> dict: NotPropertiesError: If properties are not correct Frictionless resource properties. """ + properties = properties.compact_dict check_is_dir(path) verify_properties_are_well_formed(properties, ResourceError.type) data_path = create_relative_resource_data_path(path) - return edit_property_field(properties, "path", str(data_path)) + edited_properties = edit_property_field(properties, "path", str(data_path)) + + return ResourceProperties.from_dict(edited_properties) diff --git a/seedcase_sprout/core/edit_package_properties.py b/seedcase_sprout/core/edit_package_properties.py index afa56a726..15a661a86 100644 --- a/seedcase_sprout/core/edit_package_properties.py +++ b/seedcase_sprout/core/edit_package_properties.py @@ -3,6 +3,7 @@ from frictionless.errors import PackageError from seedcase_sprout.core.check_is_file import check_is_file +from seedcase_sprout.core.properties import PackageProperties from seedcase_sprout.core.read_json import read_json from seedcase_sprout.core.verify_package_properties import ( verify_package_properties, @@ -12,7 +13,9 @@ ) -def edit_package_properties(path: Path, properties: dict) -> dict: +def edit_package_properties( + path: Path, properties: PackageProperties +) -> PackageProperties: """Edits the properties of an existing package. Use this any time you want to edit the package's properties and particularly @@ -47,6 +50,8 @@ def edit_package_properties(path: Path, properties: dict) -> dict: package properties are not well-formed. JSONDecodeError: If the `datapackage.json` file couldn't be read. """ + properties = properties.compact_dict + check_is_file(path) verify_properties_are_well_formed(properties, PackageError.type) @@ -55,4 +60,6 @@ def edit_package_properties(path: Path, properties: dict) -> dict: current_properties.update(properties) - return verify_package_properties(current_properties) + verify_package_properties(current_properties) + + return PackageProperties.from_dict(current_properties) diff --git a/seedcase_sprout/core/properties.py b/seedcase_sprout/core/properties.py index 0a2d0a602..b481a789a 100644 --- a/seedcase_sprout/core/properties.py +++ b/seedcase_sprout/core/properties.py @@ -7,6 +7,8 @@ from typing import Any, Literal, Self from uuid import uuid4 +from dacite import from_dict + from seedcase_sprout.core.get_iso_timestamp import get_iso_timestamp @@ -35,6 +37,19 @@ def compact_dict(self) -> dict: }, ) + @classmethod + def from_dict(cls: type[Self], data: dict) -> Self: + """Creates an instance populated with data from a dictionary. + + Args: + cls: The class to create an instance of. + data: The data to populate the instance with. + + Returns: + An instance of the class with the properties from the dictionary. + """ + return from_dict(data_class=cls, data=data) + @dataclass class ContributorProperties(Properties): diff --git a/seedcase_sprout/core/write_resource_properties.py b/seedcase_sprout/core/write_resource_properties.py index d0442f32f..c40bb281a 100644 --- a/seedcase_sprout/core/write_resource_properties.py +++ b/seedcase_sprout/core/write_resource_properties.py @@ -2,13 +2,16 @@ from seedcase_sprout.core.check_data_path import check_data_path from seedcase_sprout.core.check_is_file import check_is_file +from seedcase_sprout.core.properties import ResourceProperties from seedcase_sprout.core.read_json import read_json from seedcase_sprout.core.verify_package_properties import verify_package_properties from seedcase_sprout.core.verify_resource_properties import verify_resource_properties from seedcase_sprout.core.write_json import write_json -def write_resource_properties(path: Path, resource_properties: dict) -> Path: +def write_resource_properties( + path: Path, resource_properties: ResourceProperties +) -> Path: """Writes the specified resource properties to the `datapackage.json` file. This functions verifies `resource_properties`, and if a @@ -29,6 +32,7 @@ def write_resource_properties(path: Path, resource_properties: dict) -> Path: they are incomplete or don't follow the Data Package specification. JSONDecodeError: If the `datapackage.json` file couldn't be read. """ + resource_properties = resource_properties.compact_dict check_is_file(path) verify_resource_properties(resource_properties) diff --git a/tests/core/test_create_resource_properties.py b/tests/core/test_create_resource_properties.py index f25b14fe0..7cf9f23de 100644 --- a/tests/core/test_create_resource_properties.py +++ b/tests/core/test_create_resource_properties.py @@ -1,40 +1,44 @@ from pathlib import Path -from pytest import raises +from pytest import fixture, raises from seedcase_sprout.core.create_resource_properties import create_resource_properties from seedcase_sprout.core.not_properties_error import NotPropertiesError +from seedcase_sprout.core.properties import ResourceProperties -def test_creates_properties_correctly(tmp_path): +@fixture +def resource_properties(): + return ResourceProperties( + name="resource-name", + path="", + ) + + +def test_creates_properties_correctly(tmp_path, resource_properties): """Given valid inputs, should create properties object correctly.""" resource_path = tmp_path / "resources" / "1" resource_path.mkdir(parents=True) - properties = { - "name": "test", - "path": "", - } - expected_properties = { - "name": "test", - "path": str(Path("resources", "1", "data.parquet")), - } - assert create_resource_properties(resource_path, properties) == expected_properties + expected_properties = ResourceProperties( + name="resource-name", path=str(Path("resources", "1", "data.parquet")) + ) + + assert ( + create_resource_properties(resource_path, resource_properties) + == expected_properties + ) -def test_rejects_path_if_invalid(tmp_path): +def test_rejects_path_if_invalid(tmp_path, resource_properties): """Given an invalid path input, should raise NotADirectoryError.""" resource_path = tmp_path / "nonexistent" - properties = { - "name": "test", - "path": "", - } with raises(NotADirectoryError): - create_resource_properties(resource_path, properties) + create_resource_properties(resource_path, resource_properties) def test_rejects_properties_if_incorrect(tmp_path): - """Given an incorrect properties input, should raise NotPropertiesError.""" + """Given an incorrect/empty properties input, should raise NotPropertiesError.""" with raises(NotPropertiesError): - create_resource_properties(tmp_path, {}) + create_resource_properties(tmp_path, ResourceProperties()) diff --git a/tests/core/test_edit_package_properties.py b/tests/core/test_edit_package_properties.py index 3ea5d7d99..45eed7929 100644 --- a/tests/core/test_edit_package_properties.py +++ b/tests/core/test_edit_package_properties.py @@ -20,7 +20,7 @@ def properties(): description="This is my package.", version="2.0.0", created="2024-05-14T05:00:01+00:00", - ).compact_dict + ) @fixture @@ -48,20 +48,22 @@ def test_edits_only_changed_package_properties(properties_path, properties): current_properties = read_json(properties_path) # When, Then - expected_properties = current_properties | properties - assert edit_package_properties(properties_path, properties) == expected_properties + expected_properties = current_properties | properties.compact_dict + assert edit_package_properties( + properties_path, properties + ) == PackageProperties.from_dict(expected_properties) def test_throws_error_if_path_points_to_dir(tmp_path): """Should throw FileNotFoundError if the path points to a folder.""" with raises(FileNotFoundError): - edit_package_properties(tmp_path, {}) + edit_package_properties(tmp_path, PackageProperties()) def test_throws_error_if_path_points_to_nonexistent_file(tmp_path): """Should throw FileNotFoundError if the path points to a nonexistent file.""" with raises(FileNotFoundError): - edit_package_properties(tmp_path / "datapackage.json", {}) + edit_package_properties(tmp_path / "datapackage.json", PackageProperties()) def test_throws_error_if_properties_file_cannot_be_read(tmp_path, properties): @@ -83,22 +85,7 @@ def test_throws_error_if_current_package_properties_are_malformed(tmp_path, prop edit_package_properties(path, properties) -def test_adds_custom_fields( - properties_path, -): - """Should add custom fields to properties.""" - # Given - current_properties = read_json(properties_path) - new_properties = {"custom-field": "custom-value"} - - # When, Then - assert ( - edit_package_properties(properties_path, new_properties) - == current_properties | new_properties - ) - - def test_throws_error_if_new_properties_are_empty(properties_path): """Should throw NotPropertiesError if the new properties are empty.""" with raises(NotPropertiesError): - edit_package_properties(properties_path, {}) + edit_package_properties(properties_path, PackageProperties()) diff --git a/tests/core/test_properties.py b/tests/core/test_properties.py index 24be2c60b..e379f0231 100644 --- a/tests/core/test_properties.py +++ b/tests/core/test_properties.py @@ -99,3 +99,41 @@ def test_creates_package_properties_with_correct_defaults(mock_uuid): assert properties.id == str(mock_uuid()) assert properties.version == "0.1.0" assert properties.created == "2024-05-14T05:00:01+00:00" + + +@mark.parametrize( + "dict, expected_properties", + [ + ({"family_name": "Doe"}, ContributorProperties(family_name="Doe")), + ({"name": "a licence"}, LicenseProperties(name="a licence")), + ({"title": "a source"}, SourceProperties(title="a source")), + ({"header": True}, TableDialectProperties(header=True)), + ({"resource": "a resource"}, ReferenceProperties(resource="a resource")), + ({"fields": ["a field"]}, TableSchemaForeignKeyProperties(fields=["a field"])), + ({"value": "NA"}, MissingValueProperties(value="NA")), + ({"required": False}, ConstraintsProperties(required=False)), + ({"name": "a field name"}, FieldProperties(name="a field name")), + ({"fields_match": "exact"}, TableSchemaProperties(fields_match="exact")), + ( + { + "name": "resource name", + "licenses": [{"name": "MIT"}], + }, + ResourceProperties( + name="resource name", licenses=[LicenseProperties(name="MIT")] + ), + ), + ( + {"version": "1.0.0", "contributors": [{"family_name": "Doe"}]}, + PackageProperties( + version="1.0.0", contributors=[ContributorProperties(family_name="Doe")] + ), + ), + ], +) +def test_transforms_dict_to_properties(dict, expected_properties): + """Should transform a (nested) dictionary to a properties object.""" + properties_cls = type(expected_properties) + properties = properties_cls.from_dict(dict) + + assert properties == expected_properties diff --git a/tests/core/test_write_resource_properties.py b/tests/core/test_write_resource_properties.py index 6a040ec0e..4d07333c9 100644 --- a/tests/core/test_write_resource_properties.py +++ b/tests/core/test_write_resource_properties.py @@ -74,9 +74,7 @@ def test_updates_existing_resource_in_package( ] # when - path = write_resource_properties( - package_properties_path, new_resource_properties.compact_dict - ) + path = write_resource_properties(package_properties_path, new_resource_properties) # then assert path == package_properties_path @@ -103,9 +101,7 @@ def test_adds_new_resource_to_package( ] # when - path = write_resource_properties( - package_properties_path, resource_properties_3.compact_dict - ) + path = write_resource_properties(package_properties_path, resource_properties_3) # then assert path == package_properties_path @@ -116,13 +112,13 @@ def test_adds_new_resource_to_package( def test_throws_error_if_path_points_to_dir(tmp_path): """Should throw FileNotFoundError if the path points to a folder.""" with raises(FileNotFoundError): - write_resource_properties(tmp_path, {}) + write_resource_properties(tmp_path, ResourceProperties()) def test_throws_error_if_path_points_to_nonexistent_file(tmp_path): """Should throw FileNotFoundError if the path points to a nonexistent file.""" with raises(FileNotFoundError): - write_resource_properties(tmp_path / "datapackage.json", {}) + write_resource_properties(tmp_path / "datapackage.json", ResourceProperties()) def test_throws_error_if_properties_file_cannot_be_read( @@ -133,13 +129,13 @@ def test_throws_error_if_properties_file_cannot_be_read( file_path.write_text(",,, this is not, JSON") with raises(JSONDecodeError): - write_resource_properties(file_path, resource_properties_1.compact_dict) + write_resource_properties(file_path, resource_properties_1) def test_throws_error_if_resource_properties_are_incorrect(package_properties_path): """Should throw NotPropertiesError if the resource properties are incorrect.""" with raises(NotPropertiesError): - write_resource_properties(package_properties_path, {}) + write_resource_properties(package_properties_path, ResourceProperties()) def test_throws_error_if_data_path_malformed_on_new_resource( @@ -150,9 +146,7 @@ def test_throws_error_if_data_path_malformed_on_new_resource( resource_properties_1.path = str(Path("no", "id")) with raises(NotPropertiesError): - write_resource_properties( - package_properties_path, resource_properties_1.compact_dict - ) + write_resource_properties(package_properties_path, resource_properties_1) def test_throws_error_if_data_path_malformed_on_existing_resource( @@ -177,9 +171,7 @@ def test_throws_error_if_data_path_malformed_on_existing_resource( # when + then with raises(NotPropertiesError): - write_resource_properties( - package_properties_path, resource_properties_2.compact_dict - ) + write_resource_properties(package_properties_path, resource_properties_2) def test_throws_error_if_package_properties_are_incorrect( @@ -189,4 +181,4 @@ def test_throws_error_if_package_properties_are_incorrect( path = write_json({}, tmp_path / "datapackage.json") with raises(NotPropertiesError): - write_resource_properties(path, resource_properties_1.compact_dict) + write_resource_properties(path, resource_properties_1)