Repository: seedcase-project/seedcase-sprout
Pull request #1282: feat: :sparkles: finish up `PackagePath` and `resource_batch_files()`
Author: login: martonvago
Created: 2025-04-15T17:15:37Z | Merged: 2025-04-16T08:22:31Z

## Description

This PR finishes up `PackagePath`, especially `resource_batch_files()`. I made it not throw an error if there are no batch files for the resource because the other methods don't throw either. I think returning `[]` when there are no batch files is in keeping with the spirit of the class. But we can always change it!

I also did some general tidying.

This is groundwork for #1210

<!-- Select quick/in-depth as necessary -->
This PR needs a quick review.

## Checklist

- [x] Added or updated tests
- [x] Updated documentation
- [x] Ran `just run-all`



Diff:
diff --git a/src/seedcase_sprout/core/paths.py b/src/seedcase_sprout/core/paths.py
index 218698a6a..5fd3d997e 100644
--- a/src/seedcase_sprout/core/paths.py
+++ b/src/seedcase_sprout/core/paths.py
@@ -28,7 +28,7 @@ class PackagePath:
             directory.
 
     Returns:
-        The absolute path to the data package's file or folder.
+        A `PackagePath` object representing the structure of a data package.
 
     Examples:
         ```{python}
@@ -56,15 +56,15 @@ def root(self) -> Path:
 
     def properties(self) -> Path:
         """Path to the `datapackage.json` file."""
-        return Path(self.path) / "datapackage.json"
+        return self.root() / "datapackage.json"
 
     def readme(self) -> Path:
         """Path to the `README.md` file."""
-        return Path(self.path) / "README.md"
+        return self.root() / "README.md"
 
     def resources(self) -> Path:
         """Path to the `resources/` folder."""
-        return Path(self.path) / "resources"
+        return self.root() / "resources"
 
     def resource(self, resource_name: str) -> Path:
         """Path to the specified `resources/<name>/` folder.
@@ -73,7 +73,7 @@ def resource(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name)
+        return self.resources() / str(resource_name)
 
     def resource_data(self, resource_name: str) -> Path:
         """Path to the specific resource's data file.
@@ -82,7 +82,7 @@ def resource_data(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name) / "data.parquet"
+        return self.resource(resource_name) / "data.parquet"
 
     def resource_batch(self, resource_name: str) -> Path:
         """Path to the specific resource's `batch/` folder.
@@ -91,16 +91,14 @@ def resource_batch(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name) / "batch"
+        return self.resource(resource_name) / "batch"
 
-    def resource_batch_files(self, resource_name: str) -> Path:
-        """Path to all the files in the specific resource's `batch/` folder.
+    def resource_batch_files(self, resource_name: str) -> list[Path]:
+        """Paths to all the files in the specific resource's `batch/` folder.
 
         Args:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        # TODO: This needs a check if the folder exists?
-        return list(
-            Path(Path(self.path) / "resources" / str(resource_name) / "batch").iterdir()
-        )
+        batch_folder = self.resource_batch(resource_name)
+        return list(batch_folder.iterdir()) if batch_folder.is_dir() else []
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..1fe9cdaad
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,27 @@
+import os
+
+from pytest import fixture
+
+
+@fixture
+def tmp_cwd(tmp_path):
+    """Temporarily changes the working directory to a temporary path.
+
+    This fixture changes the current working directory to a temporary one
+    (provided by pytest's `tmp_path`) for the duration of the test. After the
+    test finishes, the original working directory is restored.
+
+    Yields:
+        The temporary path that becomes the current working directory.
+
+    Examples:
+        ```{python}
+        def test_something(tmp_cwd):
+            # cwd is now a temporary directory
+            ...
+        ```
+    """
+    original = os.getcwd()
+    os.chdir(tmp_path)
+    yield tmp_path
+    os.chdir(original)
diff --git a/tests/core/test_paths.py b/tests/core/test_paths.py
index 6661e79ab..40edc974f 100644
--- a/tests/core/test_paths.py
+++ b/tests/core/test_paths.py
@@ -1,5 +1,3 @@
-import os
-
 from seedcase_sprout.core import PackagePath
 
 
@@ -16,16 +14,48 @@ def test_package_path_outputs_an_absolute_path(tmp_path):
     assert path.resource("test").is_absolute()
     assert path.resource_data("test").is_absolute()
     assert path.resource_batch("test").is_absolute()
-    # TODO: Test `resource_batch_files()` after deciding whether to do a check first?
 
 
-def test_path_defaults_to_cwd_at_call_time(tmp_path):
+def test_methods_return_correct_path(tmp_path):
+    """Methods should return the expected path."""
+    package_path = PackagePath(tmp_path)
+    assert package_path.root() == tmp_path
+    assert package_path.properties() == tmp_path / "datapackage.json"
+    assert package_path.readme() == tmp_path / "README.md"
+    assert package_path.resources() == tmp_path / "resources"
+    assert package_path.resource("test") == tmp_path / "resources" / "test"
+    assert (
+        package_path.resource_data("test")
+        == tmp_path / "resources" / "test" / "data.parquet"
+    )
+
+    assert (
+        package_path.resource_batch("test") == tmp_path / "resources" / "test" / "batch"
+    )
+
+
+def test_resource_batch_files_returns_empty_list_when_no_batches(tmp_path):
+    """resource_batch_files() should return an empty list when no batches are found
+    for the resource."""
+    assert PackagePath(tmp_path).resource_batch_files("test") == []
+
+
+def test_resource_batch_files_returns_file_paths_when_batches(tmp_path):
+    """resource_batch_files() should return the file paths to the batch files of the
+    resource."""
+    package_path = PackagePath(tmp_path)
+    # Add batches for 2 resources
+    for file in ["test1", "test2"]:
+        batch_folder = package_path.resource_batch(file)
+        batch_folder.mkdir(parents=True)
+        (batch_folder / "file.txt").touch()
+
+    # Only the batch file for the given resource should be returned
+    assert package_path.resource_batch_files("test2") == [batch_folder / "file.txt"]
+
+
+def test_path_defaults_to_cwd_at_call_time(tmp_cwd):
     """When no root path is provided, the root path should default to the cwd of the
     calling script."""
-    original_cwd = os.getcwd()
-    try:
-        os.chdir(tmp_path)
-        package_path = PackagePath()
-        assert package_path.root() == tmp_path
-    finally:
-        os.chdir(original_cwd)
+    package_path = PackagePath()
+    assert package_path.root() == tmp_cwd


Reviews:
user: login: martonvago
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T17:25:50Z
user: login: lwjohnst86
body: Very nice! :tada: :star_struck: 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T08:22:22Z

Review comments:
diff_hunk: @@ -28,7 +28,7 @@ class PackagePath:
             directory.
 
     Returns:
-        The absolute path to the data package's file or folder.
+        A `PackagePath` object representing the structure of a data package.
path: src/seedcase_sprout/core/paths.py
user: login: martonvago
body: Because the class doesn't return a `Path` I guess, but, well, a `PackagePath`.
But I don't feel strongly about this.
created_at: 2025-04-15T17:17:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,15 +56,15 @@ def root(self) -> Path:
 
     def properties(self) -> Path:
         """Path to the `datapackage.json` file."""
-        return Path(self.path) / "datapackage.json"
+        return self.root() / "datapackage.json"
path: src/seedcase_sprout/core/paths.py
user: login: martonvago
body: Just to avoid repeating ourselves
created_at: 2025-04-15T17:18:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,16 +91,14 @@ def resource_batch(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name) / "batch"
+        return self.resource(resource_name) / "batch"
 
-    def resource_batch_files(self, resource_name: str) -> Path:
+    def resource_batch_files(self, resource_name: str) -> list[Path]:
path: src/seedcase_sprout/core/paths.py
user: login: martonvago
body: This returns all items in the folder, not only Parquet files. Could filter for Parquet files?
created_at: 2025-04-15T17:19:23Z
author_association: CONTRIBUTOR
path: tests/conftest.py
user: login: martonvago
body: Quick shared fixture. This will be use _a lot_, here it's used once.
created_at: 2025-04-15T17:20:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,16 +14,48 @@ def test_package_path_outputs_an_absolute_path(tmp_path):
     assert path.resource("test").is_absolute()
     assert path.resource_data("test").is_absolute()
     assert path.resource_batch("test").is_absolute()
-    # TODO: Test `resource_batch_files()` after deciding whether to do a check first?
 
 
-def test_path_defaults_to_cwd_at_call_time(tmp_path):
+def test_methods_return_correct_path(tmp_path):
+    """Methods should return the expected path."""
+    package_path = PackagePath(tmp_path)
+    assert package_path.root() == tmp_path
+    assert package_path.properties() == tmp_path / "datapackage.json"
+    assert package_path.readme() == tmp_path / "README.md"
+    assert package_path.resources() == tmp_path / "resources"
+    assert package_path.resource("test") == tmp_path / "resources" / "test"
+    assert (
+        package_path.resource_data("test")
+        == tmp_path / "resources" / "test" / "data.parquet"
+    )
+
+    assert (
+        package_path.resource_batch("test") == tmp_path / "resources" / "test" / "batch"
+    )
+
+
+def test_resource_batch_files_returns_empty_list_when_no_batches(tmp_path):
+    """resource_batch_files() should return an empty list when no batches are found
+    for the resource."""
+    assert PackagePath(tmp_path).resource_batch_files("test") == []
+
+
+def test_resource_batch_files_returns_file_paths_when_batches(tmp_path):
+    """resource_batch_files() should return the file paths to the batch files of the
+    resource."""
+    package_path = PackagePath(tmp_path)
+    # Add batches for 2 resources
+    for file in ["test1", "test2"]:
+        batch_folder = package_path.resource_batch(file)
+        batch_folder.mkdir(parents=True)
+        (batch_folder / "file.txt").touch()
+
+    # Only the batch file for the given resource should be returned
+    assert package_path.resource_batch_files("test2") == [batch_folder / "file.txt"]
+
+
+def test_path_defaults_to_cwd_at_call_time(tmp_cwd):
path: tests/core/test_paths.py
user: login: martonvago
body: Note the `tmp_cwd`
created_at: 2025-04-15T17:22:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,16 +91,14 @@ def resource_batch(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name) / "batch"
+        return self.resource(resource_name) / "batch"
 
-    def resource_batch_files(self, resource_name: str) -> Path:
+    def resource_batch_files(self, resource_name: str) -> list[Path]:
         """Path to all the files in the specific resource's `batch/` folder.
path: src/seedcase_sprout/core/paths.py
user: login: martonvago
body: ```suggestion
        """Paths to all the files in the specific resource's `batch/` folder.
```
created_at: 2025-04-15T17:25:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,16 +91,14 @@ def resource_batch(self, resource_name: str) -> Path:
             resource_name: The name of the resource. Use `ResourceProperties.name` to
                 get the correct resource name.
         """
-        return Path(self.path) / "resources" / str(resource_name) / "batch"
+        return self.resource(resource_name) / "batch"
 
-    def resource_batch_files(self, resource_name: str) -> Path:
+    def resource_batch_files(self, resource_name: str) -> list[Path]:
path: src/seedcase_sprout/core/paths.py
user: login: lwjohnst86
body: Yes, definitely filter for Parquet! :grin: 
created_at: 2025-04-16T08:19:50Z
author_association: MEMBER
path: tests/conftest.py
user: login: lwjohnst86
body: Ahh, neat!
created_at: 2025-04-16T08:20:49Z
author_association: MEMBER

PR comments:
user: login: lwjohnst86
created_at: 2025-04-16T08:23:06Z
body: Oops, I merged after saying filter for Parquet!
author_association: MEMBER
user: login: martonvago
created_at: 2025-04-16T08:31:08Z
body: No worries, will do! :robot: 
author_association: CONTRIBUTOR