Repository: CWorthy-ocean/roms-tools

Issue:
`to_yaml` does not correctly handle pathlib Paths 
When an object (e.g. `TidalForcing`) has a `source` attribute, and the user provides a `Path` instance, this is incorrectly serialised by `to_yaml`. For example:

```
TidalForcing:
  source:
    name: TPXO
    path: !!python/object/apply:pathlib._local.PosixPath
    - /Users/dafyddstephenson/Code/roms_tools_datasets
    - tpxo9.v2a.nc
  ntides: 14
  allan_factor: 2.0
  model_reference_date: '2000-01-01T00:00:00'
```

Discussion:

Solution patch:
diff --git a/conftest.py b/conftest.py
index b8bb5a8..c02c026 100644
--- a/conftest.py
+++ b/conftest.py
@@ -17,6 +17,7 @@ from roms_tools.setup.datasets import (
 )
 from roms_tools.setup.download import download_test_data
 import hashlib
+from pathlib import Path
 
 
 def pytest_addoption(parser):
@@ -85,7 +86,7 @@ def tidal_forcing(request, use_dask):
     grid = Grid(
         nx=3, ny=3, size_x=1500, size_y=1500, center_lon=235, center_lat=25, rot=-20
     )
-    fname = download_test_data("TPXO_regional_test_data.nc")
+    fname = Path(download_test_data("TPXO_regional_test_data.nc"))
 
     return TidalForcing(
         grid=grid, source={"name": "TPXO", "path": fname}, ntides=1, use_dask=use_dask
@@ -110,7 +111,7 @@ def initial_conditions(request, use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     return InitialConditions(
         grid=grid,
@@ -138,8 +139,8 @@ def initial_conditions_with_bgc(request, use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
-    fname_bgc = download_test_data("CESM_regional_test_data_one_time_slice.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
+    fname_bgc = Path(download_test_data("CESM_regional_test_data_one_time_slice.nc"))
 
     return InitialConditions(
         grid=grid,
@@ -168,8 +169,8 @@ def initial_conditions_with_bgc_from_climatology(request, use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
-    fname_bgc = download_test_data("CESM_regional_test_data_climatology.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
+    fname_bgc = Path(download_test_data("CESM_regional_test_data_climatology.nc"))
 
     return InitialConditions(
         grid=grid,
@@ -202,7 +203,7 @@ def boundary_forcing(request, use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     return BoundaryForcing(
         grid=grid,
@@ -232,7 +233,9 @@ def bgc_boundary_forcing_from_climatology(request, use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname_bgc = download_test_data("CESM_regional_coarse_test_data_climatology.nc")
+    fname_bgc = Path(
+        download_test_data("CESM_regional_coarse_test_data_climatology.nc")
+    )
 
     return BoundaryForcing(
         grid=grid,
@@ -262,7 +265,7 @@ def surface_forcing(request, use_dask):
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_global_test_data.nc")
+    fname = Path(download_test_data("ERA5_global_test_data.nc"))
 
     return SurfaceForcing(
         grid=grid,
@@ -290,7 +293,7 @@ def coarse_surface_forcing(request, use_dask):
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_global_test_data.nc")
+    fname = Path(download_test_data("ERA5_global_test_data.nc"))
 
     return SurfaceForcing(
         grid=grid,
@@ -320,7 +323,7 @@ def corrected_surface_forcing(request, use_dask):
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_global_test_data.nc")
+    fname = Path(download_test_data("ERA5_global_test_data.nc"))
 
     return SurfaceForcing(
         grid=grid,
@@ -348,7 +351,7 @@ def bgc_surface_forcing(request, use_dask):
     start_time = datetime(2020, 2, 1)
     end_time = datetime(2020, 2, 1)
 
-    fname_bgc = download_test_data("CESM_surface_global_test_data.nc")
+    fname_bgc = Path(download_test_data("CESM_surface_global_test_data.nc"))
 
     return SurfaceForcing(
         grid=grid,
@@ -376,7 +379,7 @@ def bgc_surface_forcing_from_climatology(request, use_dask):
     start_time = datetime(2020, 2, 1)
     end_time = datetime(2020, 2, 1)
 
-    fname_bgc = download_test_data("CESM_surface_global_test_data_climatology.nc")
+    fname_bgc = Path(download_test_data("CESM_surface_global_test_data_climatology.nc"))
 
     return SurfaceForcing(
         grid=grid,
diff --git a/docs/releases.md b/docs/releases.md
index 00750ab..076c917 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1,6 +1,6 @@
 # Release notes
 
-## v2.2.0 (unreleased)
+## v2.3.0 (unreleased)
 
 ### New Features
 
@@ -12,7 +12,12 @@
 
 ### Bugfixes
 
+## v2.2.0
+
+### Bugfixes
+
 * Fix bug in validation of tidal forcing which led to memory blow-ups ([#211](https://github.com/CWorthy-ocean/roms-tools/pull/211))
+* Correctly write Pathlib objects to YAML file ([#212](https://github.com/CWorthy-ocean/roms-tools/pull/212))
 
 ## v2.1.0
 
diff --git a/roms_tools/setup/utils.py b/roms_tools/setup/utils.py
index 82d1f62..37a4004 100644
--- a/roms_tools/setup/utils.py
+++ b/roms_tools/setup/utils.py
@@ -1073,6 +1073,10 @@ def _to_yaml(forcing_object, filepath: Union[str, Path]) -> None:
 
         grid_yaml_data = {**parent_grid_yaml_data, **child_grid_yaml_data}
 
+    # Ensure forcing_object.source.path is a string (convert if it's a pathlib object)
+    if hasattr(forcing_object, "source") and "path" in forcing_object.source:
+        forcing_object.source["path"] = str(forcing_object.source["path"])
+
     # Step 2: Get ROMS Tools version
     # Fetch the version of the 'roms-tools' package for inclusion in the YAML header
     try:


Test patch:
diff --git a/roms_tools/tests/test_setup/test_boundary_forcing.py b/roms_tools/tests/test_setup/test_boundary_forcing.py
index a9854c3..e557eac 100644
--- a/roms_tools/tests/test_setup/test_boundary_forcing.py
+++ b/roms_tools/tests/test_setup/test_boundary_forcing.py
@@ -19,7 +19,7 @@ import logging
 def test_boundary_forcing_creation(boundary_forcing_fixture, request):
     """Test the creation of the BoundaryForcing object."""
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
     boundary_forcing = request.getfixturevalue(boundary_forcing_fixture)
     assert boundary_forcing.start_time == datetime(2021, 6, 29)
     assert boundary_forcing.end_time == datetime(2021, 6, 30)
@@ -59,7 +59,9 @@ def test_boundary_forcing_creation(boundary_forcing_fixture, request):
 def test_boundary_forcing_creation_with_bgc(boundary_forcing_fixture, request):
     """Test the creation of the BoundaryForcing object."""
 
-    fname_bgc = download_test_data("CESM_regional_coarse_test_data_climatology.nc")
+    fname_bgc = Path(
+        download_test_data("CESM_regional_coarse_test_data_climatology.nc")
+    )
     boundary_forcing = request.getfixturevalue(boundary_forcing_fixture)
 
     assert boundary_forcing.start_time == datetime(2021, 6, 29)
@@ -103,7 +105,7 @@ def test_unsuccessful_boundary_forcing_creation_with_1d_fill(use_dask):
         hc=250.0,  # critical depth
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     with pytest.raises(ValueError, match="consists entirely of NaNs"):
 
@@ -138,7 +140,7 @@ def test_boundary_divided_by_land_warning(caplog, use_dask):
         nx=5, ny=5, size_x=500, size_y=500, center_lon=-10, center_lat=65, rot=0
     )
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     with caplog.at_level(logging.WARNING):
         BoundaryForcing(
@@ -158,7 +160,7 @@ def test_1d_and_2d_fill_coincide_if_no_land(use_dask):
     # this grid lies entirely over open ocean
     grid = Grid(nx=5, ny=5, size_x=300, size_y=300, center_lon=-5, center_lat=65, rot=0)
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     kwargs = {
         "grid": grid,
diff --git a/roms_tools/tests/test_setup/test_initial_conditions.py b/roms_tools/tests/test_setup/test_initial_conditions.py
index a184408..7f810ac 100644
--- a/roms_tools/tests/test_setup/test_initial_conditions.py
+++ b/roms_tools/tests/test_setup/test_initial_conditions.py
@@ -26,7 +26,7 @@ def test_initial_conditions_creation(ic_fixture, request):
     assert ic.ini_time == datetime(2021, 6, 29)
     assert ic.source == {
         "name": "GLORYS",
-        "path": download_test_data("GLORYS_coarse_test_data.nc"),
+        "path": Path(download_test_data("GLORYS_coarse_test_data.nc")),
         "climatology": False,
     }
     assert isinstance(ic.ds, xr.Dataset)
@@ -83,7 +83,7 @@ def test_initial_conditions_missing_physics_path(example_grid, use_dask):
 # Test initialization with missing 'name' in bgc_source
 def test_initial_conditions_missing_bgc_name(example_grid, use_dask):
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
     with pytest.raises(
         ValueError, match="`bgc_source` must include a 'name' if it is provided."
     ):
@@ -99,7 +99,7 @@ def test_initial_conditions_missing_bgc_name(example_grid, use_dask):
 # Test initialization with missing 'path' in bgc_source
 def test_initial_conditions_missing_bgc_path(example_grid, use_dask):
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
     with pytest.raises(
         ValueError, match="`bgc_source` must include a 'path' if it is provided."
     ):
@@ -115,7 +115,7 @@ def test_initial_conditions_missing_bgc_path(example_grid, use_dask):
 # Test default climatology value
 def test_initial_conditions_default_climatology(example_grid, use_dask):
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
 
     initial_conditions = InitialConditions(
         grid=example_grid,
@@ -130,8 +130,8 @@ def test_initial_conditions_default_climatology(example_grid, use_dask):
 
 def test_initial_conditions_default_bgc_climatology(example_grid, use_dask):
 
-    fname = download_test_data("GLORYS_coarse_test_data.nc")
-    fname_bgc = download_test_data("CESM_regional_test_data_one_time_slice.nc")
+    fname = Path(download_test_data("GLORYS_coarse_test_data.nc"))
+    fname_bgc = Path(download_test_data("CESM_regional_test_data_one_time_slice.nc"))
 
     initial_conditions = InitialConditions(
         grid=example_grid,
diff --git a/roms_tools/tests/test_setup/test_surface_forcing.py b/roms_tools/tests/test_setup/test_surface_forcing.py
index ce1a5aa..c27d6f6 100644
--- a/roms_tools/tests/test_setup/test_surface_forcing.py
+++ b/roms_tools/tests/test_setup/test_surface_forcing.py
@@ -174,7 +174,7 @@ def test_successful_initialization_with_regional_data(grid_fixture, request, use
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_regional_test_data.nc")
+    fname = Path(download_test_data("ERA5_regional_test_data.nc"))
 
     grid = request.getfixturevalue(grid_fixture)
 
@@ -235,7 +235,7 @@ def test_nan_detection_initialization_with_regional_data(
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_regional_test_data.nc")
+    fname = Path(download_test_data("ERA5_regional_test_data.nc"))
 
     grid = request.getfixturevalue(grid_fixture)
 
@@ -264,7 +264,7 @@ def test_no_longitude_intersection_initialization_with_regional_data(
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_regional_test_data.nc")
+    fname = Path(download_test_data("ERA5_regional_test_data.nc"))
 
     for use_coarse_grid in [True, False]:
         with pytest.raises(
@@ -304,7 +304,7 @@ def test_successful_initialization_with_global_data(grid_fixture, request, use_d
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_global_test_data.nc")
+    fname = Path(download_test_data("ERA5_global_test_data.nc"))
 
     grid = request.getfixturevalue(grid_fixture)
 
@@ -353,8 +353,8 @@ def test_nans_filled_in(grid_that_straddles_dateline, use_dask):
     start_time = datetime(2020, 1, 31)
     end_time = datetime(2020, 2, 2)
 
-    fname = download_test_data("ERA5_regional_test_data.nc")
-    fname_bgc = download_test_data("CESM_surface_global_test_data_climatology.nc")
+    fname = Path(download_test_data("ERA5_regional_test_data.nc"))
+    fname_bgc = Path(download_test_data("CESM_surface_global_test_data_climatology.nc"))
 
     for use_coarse_grid in [True, False]:
         sfc_forcing = SurfaceForcing(
@@ -417,12 +417,12 @@ def test_time_attr(bgc_surface_forcing):
         (
             "bgc_surface_forcing",
             False,
-            download_test_data("CESM_surface_global_test_data.nc"),
+            Path(download_test_data("CESM_surface_global_test_data.nc")),
         ),
         (
             "bgc_surface_forcing_from_climatology",
             True,
-            download_test_data("CESM_surface_global_test_data_climatology.nc"),
+            Path(download_test_data("CESM_surface_global_test_data_climatology.nc")),
         ),
     ],
 )
diff --git a/roms_tools/tests/test_setup/test_tides.py b/roms_tools/tests/test_setup/test_tides.py
index 6c105eb..b6fc08a 100644
--- a/roms_tools/tests/test_setup/test_tides.py
+++ b/roms_tools/tests/test_setup/test_tides.py
@@ -67,7 +67,7 @@ def grid_that_straddles_180_degree_meridian():
 )
 def test_successful_initialization_with_global_data(grid_fixture, request, use_dask):
 
-    fname = download_test_data("TPXO_global_test_data.nc")
+    fname = Path(download_test_data("TPXO_global_test_data.nc"))
 
     grid = request.getfixturevalue(grid_fixture)
 
@@ -94,7 +94,7 @@ def test_successful_initialization_with_regional_data(
     grid_that_lies_within_bounds_of_regional_tpxo_data, use_dask
 ):
 
-    fname = download_test_data("TPXO_regional_test_data.nc")
+    fname = Path(download_test_data("TPXO_regional_test_data.nc"))
 
     tidal_forcing = TidalForcing(
         grid=grid_that_lies_within_bounds_of_regional_tpxo_data,
@@ -122,7 +122,7 @@ def test_unsuccessful_initialization_with_regional_data_due_to_nans(
     grid_that_is_out_of_bounds_of_regional_tpxo_data, use_dask
 ):
 
-    fname = download_test_data("TPXO_regional_test_data.nc")
+    fname = Path(download_test_data("TPXO_regional_test_data.nc"))
 
     with pytest.raises(ValueError, match="NaN values found"):
         TidalForcing(
@@ -141,7 +141,7 @@ def test_unsuccessful_initialization_with_regional_data_due_to_no_overlap(
     grid_fixture, request, use_dask
 ):
 
-    fname = download_test_data("TPXO_regional_test_data.nc")
+    fname = Path(download_test_data("TPXO_regional_test_data.nc"))
 
     grid = request.getfixturevalue(grid_fixture)
 
@@ -158,7 +158,7 @@ def test_unsuccessful_initialization_with_regional_data_due_to_no_overlap(
 
 def test_insufficient_number_of_consituents(grid_that_straddles_dateline, use_dask):
 
-    fname = download_test_data("TPXO_global_test_data.nc")
+    fname = Path(download_test_data("TPXO_global_test_data.nc"))
 
     with pytest.raises(ValueError, match="The dataset contains fewer"):
         TidalForcing(