climate-ref-core 0.14.2__tar.gz → 0.14.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/PKG-INFO +2 -2
  2. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/pyproject.toml +2 -2
  3. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/dataset_registry.py +25 -25
  4. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/testing.py +84 -1
  5. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_dataset_registry/test_dataset_registry.py +1 -5
  6. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_testing.py +119 -0
  7. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/.gitignore +0 -0
  8. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/LICENCE +0 -0
  9. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/NOTICE +0 -0
  10. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/README.md +0 -0
  11. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/__init__.py +0 -0
  12. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/cmip6_to_cmip7.py +0 -0
  13. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/constraints.py +0 -0
  14. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  15. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/datasets.py +0 -0
  16. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/diagnostics.py +0 -0
  17. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/env.py +0 -0
  18. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/__init__.py +0 -0
  19. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/base.py +0 -0
  20. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/cmip6.py +0 -0
  21. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/cmip7.py +0 -0
  22. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/fetcher.py +0 -0
  23. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  24. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/esgf/registry.py +0 -0
  25. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/exceptions.py +0 -0
  26. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/executor.py +0 -0
  27. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/logging.py +0 -0
  28. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/metric_values/__init__.py +0 -0
  29. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/metric_values/typing.py +0 -0
  30. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/providers.py +0 -0
  31. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/py.typed +0 -0
  32. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/README.md +0 -0
  33. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/__init__.py +0 -0
  34. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
  35. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
  36. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/metric.py +0 -0
  37. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/pycmec/output.py +0 -0
  38. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/source_types.py +0 -0
  39. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/src/climate_ref_core/summary.py +0 -0
  40. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_base.py +0 -0
  41. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_cmip6.py +0 -0
  42. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_cmip7.py +0 -0
  43. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_fetcher.py +0 -0
  44. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_obs4mips.py +0 -0
  45. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/esgf/test_registry.py +0 -0
  46. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/metric_values/test_typing.py +0 -0
  47. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  48. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  49. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  50. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  51. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  52. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/conftest.py +0 -0
  53. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  54. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/test_cmec_output.py +0 -0
  55. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
  56. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_cmip6_to_cmip7.py +0 -0
  57. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_constraints.py +0 -0
  58. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
  59. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
  60. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
  61. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_datasets.py +0 -0
  62. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_diagnostics.py +0 -0
  63. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_exceptions.py +0 -0
  64. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_executor.py +0 -0
  65. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_logging.py +0 -0
  66. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_packaging.py +0 -0
  67. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_providers.py +0 -0
  68. {climate_ref_core-0.14.2 → climate_ref_core-0.14.4}/tests/unit/test_summary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref-core
3
- Version: 0.14.2
3
+ Version: 0.14.4
4
4
  Summary: Core library for the CMIP Rapid Evaluation Framework
5
5
  Author-email: Jared Lewis <jared.lewis@climate-resource.com>, Mika Pflueger <mika.pflueger@climate-resource.com>, Bouwe Andela <b.andela@esciencecenter.nl>, Jiwoo Lee <lee1043@llnl.gov>, Min Xu <xum1@ornl.gov>, Nathan Collier <collierno@ornl.gov>, Dora Hegedus <dora.hegedus@stfc.ac.uk>
6
6
  License-Expression: Apache-2.0
@@ -25,7 +25,7 @@ Requires-Dist: fastprogress==1.0.5
25
25
  Requires-Dist: intake-esgf>=2025.7.16
26
26
  Requires-Dist: loguru>=0.7.0
27
27
  Requires-Dist: numpy>=2.0.0
28
- Requires-Dist: pandas<3,>=2.1.0
28
+ Requires-Dist: pandas>=2.1.0
29
29
  Requires-Dist: pooch<2,>=1.8.0
30
30
  Requires-Dist: pyarrow>=17.0.0
31
31
  Requires-Dist: pydantic>=2.10.6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "climate-ref-core"
3
- version = "0.14.2"
3
+ version = "0.14.4"
4
4
  description = "Core library for the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -48,7 +48,7 @@ dependencies = [
48
48
  # SPEC 0000 constraints
49
49
  # We follow [SPEC-0000](https://scientific-python.org/specs/spec-0000/)
50
50
  # which defines a 2-year support window for key libraries and 3-year window for Python versions
51
- "pandas>=2.1.0,<3",
51
+ "pandas>=2.1.0",
52
52
  "numpy>=2.0.0",
53
53
 
54
54
  # Temporarily pin fastprogress dependency (from intake-esm) due to bug in recent version
@@ -21,6 +21,30 @@ from climate_ref_core.env import env
21
21
  DATASET_URL = env.str("REF_DATASET_URL", default="https://obs4ref.climate-ref.org")
22
22
 
23
23
 
24
+ def resolve_cache_dir(cache_name: str) -> pathlib.Path:
25
+ """
26
+ Resolve the cache directory for a registry.
27
+
28
+ If the ``REF_DATASET_CACHE_DIR`` environment variable is set, use that as the root.
29
+ Otherwise, fall back to the OS cache under ``climate_ref``.
30
+
31
+ Parameters
32
+ ----------
33
+ cache_name
34
+ Subdirectory name within the cache root.
35
+
36
+ Returns
37
+ -------
38
+ The resolved cache directory path.
39
+ """
40
+ if env_cache_dir := os.environ.get("REF_DATASET_CACHE_DIR"):
41
+ cache_dir = pathlib.Path(os.path.expandvars(env_cache_dir)).expanduser()
42
+ else:
43
+ cache_dir = pooch.os_cache("climate_ref")
44
+
45
+ return cache_dir / cache_name
46
+
47
+
24
48
  def _verify_hash_matches(fname: str | pathlib.Path, known_hash: str) -> bool:
25
49
  """
26
50
  Check if the hash of a file matches a known hash.
@@ -207,30 +231,6 @@ class DatasetRegistryManager:
207
231
  return list(self._registries.keys())
208
232
 
209
233
  @staticmethod
210
- def _resolve_cache_dir(cache_name: str) -> pathlib.Path:
211
- """
212
- Resolve the cache directory for a registry.
213
-
214
- If the ``REF_DATASET_CACHE_DIR`` environment variable is set,
215
- use that as the root. Otherwise, fall back to the OS cache
216
- under ``climate_ref``.
217
-
218
- Parameters
219
- ----------
220
- cache_name
221
- Subdirectory name within the cache root.
222
-
223
- Returns
224
- -------
225
- The resolved cache directory path.
226
- """
227
- if env_cache_dir := os.environ.get("REF_DATASET_CACHE_DIR"):
228
- cache_dir = pathlib.Path(os.path.expandvars(env_cache_dir)).expanduser()
229
- else:
230
- cache_dir = pooch.os_cache("climate_ref")
231
-
232
- return cache_dir / cache_name
233
-
234
234
  @staticmethod
235
235
  def _migrate_cache(
236
236
  registry: pooch.Pooch,
@@ -314,7 +314,7 @@ class DatasetRegistryManager:
314
314
  if cache_name is None:
315
315
  cache_name = name
316
316
 
317
- cache_path = self._resolve_cache_dir(cache_name)
317
+ cache_path = resolve_cache_dir(cache_name)
318
318
 
319
319
  # Before v0.13.0 everything was cached directly under
320
320
  # pooch.os_cache("climate_ref") with no per-registry subdirectory.
@@ -10,6 +10,7 @@ This module provides:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import json
13
14
  import shutil
14
15
  import sys
15
16
  from pathlib import Path
@@ -28,6 +29,7 @@ from climate_ref_core.datasets import (
28
29
  )
29
30
  from climate_ref_core.diagnostics import ExecutionDefinition, ExecutionResult
30
31
  from climate_ref_core.esgf.base import ESGFRequest
32
+ from climate_ref_core.metric_values.typing import SeriesMetricValue
31
33
  from climate_ref_core.pycmec.metric import CMECMetric
32
34
  from climate_ref_core.pycmec.output import CMECOutput
33
35
 
@@ -499,6 +501,13 @@ def validate_cmec_bundles(diagnostic: Diagnostic, result: ExecutionResult) -> No
499
501
  AssertionError
500
502
  If the result is not successful or bundles are invalid
501
503
  """
504
+ # TODO: Add content regression checks for the CMEC bundles (diagnostic.json /
505
+ # output.json), mirroring `validate_series_regression`. These bundles are only
506
+ # structurally validated here, so a diagnostic can change the metric/output
507
+ # values without any regression test failing (see issue #703 for the series
508
+ # equivalent). A content comparison must sanitise the regenerated bundle first
509
+ # via `ExecutionRegression.output_replacements`, since the committed bundles
510
+ # store `<OUTPUT_DIR>` / `<TEST_DATA_DIR>` placeholders.
502
511
  assert result.successful, f"Execution failed: {result}"
503
512
 
504
513
  # Validate metric bundle
@@ -517,6 +526,71 @@ def validate_cmec_bundles(diagnostic: Diagnostic, result: ExecutionResult) -> No
517
526
  CMECOutput.load_from_json(result.to_output_path(result.output_bundle_filename))
518
527
 
519
528
 
529
+ def _load_series_sanitised(path: Path, replacements: dict[str, str]) -> list[SeriesMetricValue]:
530
+ """
531
+ Load series from ``path``, replacing real paths with regression placeholders.
532
+ """
533
+ text = path.read_text(encoding="utf-8")
534
+ # Apply replacements longest-key-first in case of nested repleacements
535
+ for real, placeholder in sorted(replacements.items(), key=lambda kv: len(kv[0]), reverse=True):
536
+ text = text.replace(real, placeholder)
537
+ data = json.loads(text)
538
+ if not isinstance(data, list):
539
+ raise ValueError(f"Expected a list of series values, got {type(data)}")
540
+ return [SeriesMetricValue.model_validate(s, strict=True) for s in data]
541
+
542
+
543
+ def validate_series_regression(
544
+ expected_path: Path,
545
+ actual_path: Path,
546
+ *,
547
+ slug: str,
548
+ replacements: dict[str, str] | None = None,
549
+ ) -> None:
550
+ """
551
+ Assert that regenerated series match the committed regression series.
552
+
553
+ If ``expected_path`` does not exist (legacy regression data without a stored series),
554
+ the check is skipped.
555
+
556
+ Parameters
557
+ ----------
558
+ expected_path
559
+ Path to the committed ``series.json`` regression artifact. This file
560
+ stores sanitized placeholders (e.g. ``<OUTPUT_DIR>``).
561
+ actual_path
562
+ Path to the freshly regenerated ``series.json`` in the output directory.
563
+ This file contains expanded absolute paths.
564
+ slug
565
+ The diagnostic slug, used for error messages.
566
+ replacements
567
+ Optional ``real path -> placeholder`` mapping applied to the regenerated series before comparison,
568
+ mirroring the sanitisation used when the regression data was written.
569
+
570
+ Raises
571
+ ------
572
+ AssertionError
573
+ If the regenerated series differ from the committed series.
574
+ """
575
+ if not expected_path.exists():
576
+ return
577
+
578
+ expected = SeriesMetricValue.load_from_json(expected_path)
579
+ actual = _load_series_sanitised(actual_path, replacements or {})
580
+
581
+ def _sorted_dump(series: list[SeriesMetricValue]) -> list[dict[str, Any]]:
582
+ return sorted((s.model_dump(mode="json") for s in series), key=lambda s: repr(s["dimensions"]))
583
+
584
+ assert len(actual) == len(expected), (
585
+ f"Diagnostic {slug} produced {len(actual)} series but the committed series.json "
586
+ f"has {len(expected)}. Regenerate the regression data with `--force-regen`."
587
+ )
588
+ assert _sorted_dump(actual) == _sorted_dump(expected), (
589
+ f"Diagnostic {slug} produced series that differ from the committed series.json. "
590
+ f"Regenerate the regression data with `--force-regen`."
591
+ )
592
+
593
+
520
594
  @frozen
521
595
  class RegressionValidator:
522
596
  """
@@ -585,10 +659,19 @@ class RegressionValidator:
585
659
  )
586
660
 
587
661
  def validate(self, definition: ExecutionDefinition) -> None:
588
- """Validate CMEC bundles in the regression output."""
662
+ """Validate CMEC bundles and series in the regression output."""
589
663
  result = self.diagnostic.build_execution_result(definition)
590
664
  result.to_output_path("out.log").touch() # Log file not tracked in regression
591
665
  validate_cmec_bundles(self.diagnostic, result)
666
+ validate_series_regression(
667
+ expected_path=self.paths.regression / "series.json",
668
+ actual_path=definition.output_directory / "series.json",
669
+ slug=self.diagnostic.slug,
670
+ replacements={
671
+ str(definition.output_directory): "<OUTPUT_DIR>",
672
+ str(self.test_data_dir): "<TEST_DATA_DIR>",
673
+ },
674
+ )
592
675
 
593
676
 
594
677
  def collect_test_case_params(provider: DiagnosticProvider) -> list[ParameterSet]:
@@ -252,11 +252,7 @@ class TestMigrateCache:
252
252
 
253
253
  new_dir = tmp_path / "new_cache" / "test_registry"
254
254
 
255
- mocker.patch.object(
256
- DatasetRegistryManager,
257
- "_resolve_cache_dir",
258
- return_value=new_dir,
259
- )
255
+ mocker.patch("climate_ref_core.dataset_registry.resolve_cache_dir", return_value=new_dir)
260
256
 
261
257
  registry_path, package, resource = fake_registry_file
262
258
  with registry_path.open("w") as f:
@@ -8,6 +8,7 @@ import yaml
8
8
 
9
9
  from climate_ref_core.datasets import DatasetCollection, ExecutionDatasetCollection, SourceDatasetType
10
10
  from climate_ref_core.diagnostics import ExecutionResult
11
+ from climate_ref_core.metric_values.typing import SeriesMetricValue
11
12
  from climate_ref_core.testing import (
12
13
  RegressionValidator,
13
14
  TestCase,
@@ -19,6 +20,7 @@ from climate_ref_core.testing import (
19
20
  load_datasets_from_yaml,
20
21
  save_datasets_to_yaml,
21
22
  validate_cmec_bundles,
23
+ validate_series_regression,
22
24
  )
23
25
 
24
26
 
@@ -912,3 +914,120 @@ class TestRegressionValidator:
912
914
 
913
915
  # Verify build_execution_result was called
914
916
  mock_diagnostic.build_execution_result.assert_called_once_with(mock_definition)
917
+
918
+
919
+ def _series(value: float, *, dim: str = "M1", index_name: str = "year", attrs=None) -> SeriesMetricValue:
920
+ """Build a small SeriesMetricValue for testing."""
921
+ return SeriesMetricValue(
922
+ dimensions={"source_id": dim},
923
+ values=[value, value + 1.0],
924
+ index=["2000", "2001"],
925
+ index_name=index_name,
926
+ attributes=attrs,
927
+ )
928
+
929
+
930
+ def _write_series(path: Path, series: list[SeriesMetricValue]) -> Path:
931
+ path.parent.mkdir(parents=True, exist_ok=True)
932
+ SeriesMetricValue.dump_to_json(path, series)
933
+ return path
934
+
935
+
936
+ class TestValidateSeriesRegression:
937
+ def test_matching_series_passes(self, tmp_path):
938
+ series = [_series(1.0), _series(5.0, dim="M2")]
939
+ expected = _write_series(tmp_path / "expected" / "series.json", series)
940
+ actual = _write_series(tmp_path / "actual" / "series.json", series)
941
+
942
+ validate_series_regression(expected, actual, slug="my-diag")
943
+
944
+ def test_order_insensitive(self, tmp_path):
945
+ a, b = _series(1.0, dim="M1"), _series(5.0, dim="M2")
946
+ expected = _write_series(tmp_path / "expected" / "series.json", [a, b])
947
+ actual = _write_series(tmp_path / "actual" / "series.json", [b, a])
948
+
949
+ validate_series_regression(expected, actual, slug="my-diag")
950
+
951
+ def test_missing_expected_is_skipped(self, tmp_path):
952
+ actual = _write_series(tmp_path / "actual" / "series.json", [_series(1.0)])
953
+ missing_expected = tmp_path / "expected" / "series.json"
954
+
955
+ # Must not raise, even though actual is non-empty.
956
+ validate_series_regression(missing_expected, actual, slug="my-diag")
957
+
958
+ def test_count_mismatch_raises(self, tmp_path):
959
+ """A different number of series fails with a count message (Check for #703)."""
960
+ expected = _write_series(tmp_path / "expected" / "series.json", [])
961
+ actual = _write_series(tmp_path / "actual" / "series.json", [_series(1.0)])
962
+
963
+ with pytest.raises(AssertionError, match=r"produced 1 series but the committed series\.json has 0"):
964
+ validate_series_regression(expected, actual, slug="my-diag")
965
+
966
+ def test_value_mismatch_raises(self, tmp_path):
967
+ """Same count but different values fails with a content message."""
968
+ expected = _write_series(tmp_path / "expected" / "series.json", [_series(1.0)])
969
+ actual = _write_series(tmp_path / "actual" / "series.json", [_series(2.0)])
970
+
971
+ with pytest.raises(AssertionError, match="produced series that differ"):
972
+ validate_series_regression(expected, actual, slug="my-diag")
973
+
974
+ def test_attribute_mismatch_raises(self, tmp_path):
975
+ """Differences in series attributes are detected."""
976
+ expected = _write_series(
977
+ tmp_path / "expected" / "series.json", [_series(1.0, attrs={"caption": "old"})]
978
+ )
979
+ actual = _write_series(tmp_path / "actual" / "series.json", [_series(1.0, attrs={"caption": "new"})])
980
+
981
+ with pytest.raises(AssertionError, match="produced series that differ"):
982
+ validate_series_regression(expected, actual, slug="my-diag")
983
+
984
+ def test_slug_in_error_message(self, tmp_path):
985
+ """The diagnostic slug is surfaced in failure messages."""
986
+ expected = _write_series(tmp_path / "expected" / "series.json", [])
987
+ actual = _write_series(tmp_path / "actual" / "series.json", [_series(1.0)])
988
+
989
+ with pytest.raises(AssertionError, match="Diagnostic some-slug"):
990
+ validate_series_regression(expected, actual, slug="some-slug")
991
+
992
+ def test_sanitised_path_placeholders_are_normalised(self, tmp_path):
993
+ """Committed placeholders compare equal to expanded regenerated paths."""
994
+ expected = _write_series(
995
+ tmp_path / "expected" / "series.json",
996
+ [_series(1.0, attrs={"source_file": "<TEST_DATA_DIR>/input.nc"})],
997
+ )
998
+ actual = _write_series(
999
+ tmp_path / "actual" / "series.json",
1000
+ [_series(1.0, attrs={"source_file": f"{tmp_path}/input.nc"})],
1001
+ )
1002
+
1003
+ validate_series_regression(
1004
+ expected,
1005
+ actual,
1006
+ slug="my-diag",
1007
+ replacements={str(tmp_path): "<TEST_DATA_DIR>"},
1008
+ )
1009
+
1010
+ def test_nested_replacements(self, tmp_path):
1011
+ """A nested output dir is substituted before its parent test-data dir."""
1012
+ test_data_dir = tmp_path / "test-data"
1013
+ output_dir = test_data_dir / "diag" / "default" / "output"
1014
+ output_dir.mkdir(parents=True)
1015
+
1016
+ expected = _write_series(
1017
+ test_data_dir / "expected" / "series.json",
1018
+ [_series(1.0, attrs={"plot": "<OUTPUT_DIR>/plot.png", "input": "<TEST_DATA_DIR>/in.nc"})],
1019
+ )
1020
+ actual = _write_series(
1021
+ output_dir / "series.json",
1022
+ [_series(1.0, attrs={"plot": f"{output_dir}/plot.png", "input": f"{test_data_dir}/in.nc"})],
1023
+ )
1024
+
1025
+ validate_series_regression(
1026
+ expected,
1027
+ actual,
1028
+ slug="my-diag",
1029
+ replacements={
1030
+ str(output_dir): "<OUTPUT_DIR>",
1031
+ str(test_data_dir): "<TEST_DATA_DIR>",
1032
+ },
1033
+ )