cf-xarray 0.10.11__tar.gz → 0.11.1__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 (92) hide show
  1. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/workflows/ci.yaml +2 -2
  2. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/workflows/pypi.yaml +5 -5
  3. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/workflows/testpypi-release.yaml +2 -2
  4. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.pre-commit-config.yaml +2 -2
  5. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/PKG-INFO +1 -1
  6. cf_xarray-0.11.1/cf_xarray/_version.py +1 -0
  7. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/accessor.py +148 -82
  8. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/helpers.py +16 -17
  9. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_accessor.py +50 -4
  10. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/utils.py +8 -5
  11. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray.egg-info/PKG-INFO +1 -1
  12. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/index.rst +1 -1
  13. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/uv.lock +265 -235
  14. cf_xarray-0.10.11/cf_xarray/_version.py +0 -1
  15. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.binder/environment.yml +0 -0
  16. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.deepsource.toml +0 -0
  17. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/dependabot.yml +0 -0
  18. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/release.yml +0 -0
  19. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/workflows/parse_logs.py +0 -0
  20. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.github/workflows/upstream-dev-ci.yaml +0 -0
  21. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.gitignore +0 -0
  22. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.readthedocs.yml +0 -0
  23. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/.tributors +0 -0
  24. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/CITATION.cff +0 -0
  25. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/LICENSE +0 -0
  26. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/README.rst +0 -0
  27. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/__init__.py +0 -0
  28. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/coding.py +0 -0
  29. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/criteria.py +0 -0
  30. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/datasets.py +0 -0
  31. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/formatting.py +0 -0
  32. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/geometry.py +0 -0
  33. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/groupers.py +0 -0
  34. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/options.py +0 -0
  35. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/parametric.py +0 -0
  36. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/py.typed +0 -0
  37. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/scripts/make_doc.py +0 -0
  38. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/scripts/print_versions.py +0 -0
  39. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/sgrid.py +0 -0
  40. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/__init__.py +0 -0
  41. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/conftest.py +0 -0
  42. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_coding.py +0 -0
  43. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_geometry.py +0 -0
  44. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_groupers.py +0 -0
  45. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_helpers.py +0 -0
  46. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_options.py +0 -0
  47. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_parametric.py +0 -0
  48. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_scripts.py +0 -0
  49. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/tests/test_units.py +0 -0
  50. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray/units.py +0 -0
  51. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray.egg-info/SOURCES.txt +0 -0
  52. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray.egg-info/dependency_links.txt +0 -0
  53. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray.egg-info/requires.txt +0 -0
  54. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/cf_xarray.egg-info/top_level.txt +0 -0
  55. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/codecov.yml +0 -0
  56. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/2D_bounds_averaged.png +0 -0
  57. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/2D_bounds_error.png +0 -0
  58. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/2D_bounds_nonunique.png +0 -0
  59. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/Makefile +0 -0
  60. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/dataset-diagram-logo.tex +0 -0
  61. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/full-logo.png +0 -0
  62. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/logo.png +0 -0
  63. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/logo.svg +0 -0
  64. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/rich-repr-example.png +0 -0
  65. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/_static/style.css +0 -0
  66. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/api.rst +0 -0
  67. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/bounds.md +0 -0
  68. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/cartopy_rotated_pole.png +0 -0
  69. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/coding.md +0 -0
  70. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/conf.py +0 -0
  71. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/contributing.rst +0 -0
  72. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/coord_axes.md +0 -0
  73. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/custom-criteria.md +0 -0
  74. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/dsg.md +0 -0
  75. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/examples/introduction.ipynb +0 -0
  76. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/faq.md +0 -0
  77. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/flags.md +0 -0
  78. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/geometry.md +0 -0
  79. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/grid_mappings.md +0 -0
  80. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/howtouse.md +0 -0
  81. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/make.bat +0 -0
  82. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/parametricz.md +0 -0
  83. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/plotting.md +0 -0
  84. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/provenance.md +0 -0
  85. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/quickstart.md +0 -0
  86. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/roadmap.rst +0 -0
  87. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/selecting.md +0 -0
  88. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/sgrid_ugrid.md +0 -0
  89. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/units.md +0 -0
  90. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/doc/whats-new.rst +0 -0
  91. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/pyproject.toml +0 -0
  92. {cf_xarray-0.10.11 → cf_xarray-0.11.1}/setup.cfg +0 -0
@@ -58,7 +58,7 @@ jobs:
58
58
  pytest -n auto --cov=./ --cov-report=xml
59
59
 
60
60
  - name: Upload code coverage to Codecov
61
- uses: codecov/codecov-action@v5.5.2
61
+ uses: codecov/codecov-action@v6.0.0
62
62
  with:
63
63
  file: ./coverage.xml
64
64
  flags: unittests
@@ -98,7 +98,7 @@ jobs:
98
98
  python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report cf_xarray/
99
99
 
100
100
  - name: Upload mypy coverage to Codecov
101
- uses: codecov/codecov-action@v5.5.2
101
+ uses: codecov/codecov-action@v6.0.0
102
102
  with:
103
103
  file: mypy_report/cobertura.xml
104
104
  flags: mypy
@@ -41,7 +41,7 @@ jobs:
41
41
  else
42
42
  echo "✅ Looks good"
43
43
  fi
44
- - uses: actions/upload-artifact@v6
44
+ - uses: actions/upload-artifact@v7
45
45
  with:
46
46
  name: releases
47
47
  path: dist
@@ -54,7 +54,7 @@ jobs:
54
54
  name: Install Python
55
55
  with:
56
56
  python-version: "3.11"
57
- - uses: actions/download-artifact@v7
57
+ - uses: actions/download-artifact@v8
58
58
  with:
59
59
  name: releases
60
60
  path: dist
@@ -72,7 +72,7 @@ jobs:
72
72
 
73
73
  - name: Publish package to TestPyPI
74
74
  if: github.event_name == 'push'
75
- uses: pypa/gh-action-pypi-publish@v1.13.0
75
+ uses: pypa/gh-action-pypi-publish@v1.14.0
76
76
  with:
77
77
  password: ${{ secrets.TESTPYPI_TOKEN }}
78
78
  repository_url: https://test.pypi.org/legacy/
@@ -91,11 +91,11 @@ jobs:
91
91
  id-token: write
92
92
 
93
93
  steps:
94
- - uses: actions/download-artifact@v7
94
+ - uses: actions/download-artifact@v8
95
95
  with:
96
96
  name: releases
97
97
  path: dist
98
98
  - name: Publish package to PyPI
99
- uses: pypa/gh-action-pypi-publish@v1.13.0
99
+ uses: pypa/gh-action-pypi-publish@v1.14.0
100
100
  with:
101
101
  verbose: true
@@ -53,7 +53,7 @@ jobs:
53
53
  echo "✅ Looks good"
54
54
  fi
55
55
 
56
- - uses: actions/upload-artifact@v6
56
+ - uses: actions/upload-artifact@v7
57
57
  with:
58
58
  name: releases
59
59
  path: dist
@@ -66,7 +66,7 @@ jobs:
66
66
  name: Install Python
67
67
  with:
68
68
  python-version: "3.11"
69
- - uses: actions/download-artifact@v7
69
+ - uses: actions/download-artifact@v8
70
70
  with:
71
71
  name: releases
72
72
  path: dist
@@ -10,7 +10,7 @@ repos:
10
10
 
11
11
  - repo: https://github.com/astral-sh/ruff-pre-commit
12
12
  # Ruff version.
13
- rev: 'v0.14.10'
13
+ rev: 'v0.15.9'
14
14
  hooks:
15
15
  - id: ruff
16
16
  args: ["--fix", "--show-fixes"]
@@ -55,7 +55,7 @@ repos:
55
55
  - id: validate-cff
56
56
 
57
57
  - repo: https://github.com/abravalheri/validate-pyproject
58
- rev: v0.24.1
58
+ rev: v0.25
59
59
  hooks:
60
60
  - id: validate-pyproject
61
61
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cf_xarray
3
- Version: 0.10.11
3
+ Version: 0.11.1
4
4
  Summary: A convenience wrapper for using CF attributes on xarray objects
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -0,0 +1 @@
1
+ __version__ = "0.11.1"
@@ -27,6 +27,7 @@ import xarray as xr
27
27
  from xarray import DataArray, Dataset
28
28
  from xarray.core.groupby import GroupBy
29
29
  from xarray.core.resample import Resample
30
+ from xarray.core.utils import Frozen
30
31
 
31
32
  try:
32
33
  from xarray.core.rolling import ( # type:ignore[import-not-found,no-redef,unused-ignore]
@@ -48,6 +49,11 @@ except ImportError:
48
49
  Weighted,
49
50
  )
50
51
 
52
+ try:
53
+ from regex import match as regex_match
54
+ except ImportError:
55
+ from re import match as regex_match # type: ignore[no-redef]
56
+
51
57
 
52
58
  from . import parametric, sgrid
53
59
  from .criteria import (
@@ -305,12 +311,6 @@ def _get_custom_criteria(
305
311
  List[str]
306
312
  Variable name(s) in parent xarray object that matches axis, coordinate, or custom ``key``
307
313
  """
308
-
309
- try:
310
- from regex import match as regex_match
311
- except ImportError:
312
- from re import match as regex_match # type: ignore[no-redef]
313
-
314
314
  if criteria is None:
315
315
  if not OPTIONS["custom_criteria"]:
316
316
  return []
@@ -496,7 +496,10 @@ def _get_bounds(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]:
496
496
  return list(results)
497
497
 
498
498
 
499
- def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hashable]]:
499
+ @functools.lru_cache(maxsize=256)
500
+ def _parse_grid_mapping_attribute(
501
+ grid_mapping_attr: str,
502
+ ) -> Mapping[str, list[Hashable]]:
500
503
  """
501
504
  Parse a grid_mapping attribute that may contain multiple grid mappings.
502
505
 
@@ -508,11 +511,12 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
508
511
  - Multiple: "spatial_ref: crs_4326: latitude longitude crs_27700: x27700 y27700"
509
512
  -> {"spatial_ref": [], "crs_4326": ["latitude", "longitude"], "crs_27700": ["x27700", "y27700"]}
510
513
 
511
- Returns a dictionary mapping grid mapping variable names to their associated coordinate variables.
514
+ Returns a read-only mapping from grid mapping variable name to its associated
515
+ coordinate variables. The result is memoized, so callers must not mutate it.
512
516
  """
513
517
  # Check if there are colons indicating multiple mappings
514
518
  if ":" not in grid_mapping_attr:
515
- return {grid_mapping_attr.strip(): []}
519
+ return Frozen({grid_mapping_attr.strip(): []})
516
520
 
517
521
  # Use regex to parse the format
518
522
  # First, find all grid mapping variables (words before colons)
@@ -520,7 +524,7 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
520
524
  grid_mappings = re.findall(grid_pattern, grid_mapping_attr)
521
525
 
522
526
  if not grid_mappings:
523
- return {grid_mapping_attr.strip(): []}
527
+ return Frozen({grid_mapping_attr.strip(): []})
524
528
 
525
529
  result: dict[str, list[Hashable]] = {}
526
530
 
@@ -549,13 +553,13 @@ def _parse_grid_mapping_attribute(grid_mapping_attr: str) -> dict[str, list[Hash
549
553
  else:
550
554
  result[gm] = []
551
555
 
552
- return result
556
+ return Frozen(result)
553
557
 
554
558
 
555
559
  def _create_grid_mapping(
556
560
  var_name: str,
557
561
  ds: Dataset,
558
- grid_mapping_dict: dict[str, list[Hashable]],
562
+ grid_mapping_dict: Mapping[str, list[Hashable]],
559
563
  ) -> GridMapping:
560
564
  """
561
565
  Create a GridMapping dataclass instance from a grid mapping variable.
@@ -1008,7 +1012,11 @@ def _getattr(
1008
1012
  newmap.update(dict.fromkeys(inverted[key], value))
1009
1013
  newmap.update({key: attribute[key] for key in unused_keys})
1010
1014
 
1011
- skip: dict[str, list[Literal["coords", "measures"]] | None] = {
1015
+ skip: dict[
1016
+ str,
1017
+ list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1018
+ | None,
1019
+ ] = {
1012
1020
  "data_vars": ["coords"],
1013
1021
  "coords": None,
1014
1022
  }
@@ -1049,7 +1057,8 @@ def _getattr(
1049
1057
  def _getitem(
1050
1058
  accessor: CFAccessor,
1051
1059
  key: Hashable,
1052
- skip: list[Literal["coords", "measures"]] | None = None,
1060
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1061
+ | None = None,
1053
1062
  ) -> DataArray: ...
1054
1063
 
1055
1064
 
@@ -1057,14 +1066,16 @@ def _getitem(
1057
1066
  def _getitem(
1058
1067
  accessor: CFAccessor,
1059
1068
  key: Iterable[Hashable],
1060
- skip: list[Literal["coords", "measures"]] | None = None,
1069
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1070
+ | None = None,
1061
1071
  ) -> Dataset: ...
1062
1072
 
1063
1073
 
1064
1074
  def _getitem(
1065
1075
  accessor: CFAccessor,
1066
1076
  key: Hashable | Iterable[Hashable],
1067
- skip: list[Literal["coords", "measures"]] | None = None,
1077
+ skip: list[Literal["coords", "measures", "grid_mapping_names", "geometries"]]
1078
+ | None = None,
1068
1079
  ):
1069
1080
  """
1070
1081
  Index into obj using key. Attaches CF associated variables.
@@ -1078,9 +1089,14 @@ def _getitem(
1078
1089
  """
1079
1090
 
1080
1091
  obj = accessor._obj
1081
- all_bounds = obj.cf.bounds if isinstance(obj, Dataset) else {}
1082
1092
  kind = str(type(obj).__name__)
1083
1093
  scalar_key = isinstance(key, Hashable)
1094
+ # obj.cf.bounds is expensive; only compute it when scalar lookup on a
1095
+ # Dataset actually needs to drop bounds variables.
1096
+ if not isinstance(obj, DataArray) and scalar_key:
1097
+ all_bounds = obj.cf.bounds
1098
+ else:
1099
+ all_bounds = {}
1084
1100
 
1085
1101
  key_iter: Iterable[Hashable]
1086
1102
  if isinstance(key, Hashable): # using scalar_key breaks mypy type narrowing
@@ -1128,60 +1144,90 @@ def _getitem(
1128
1144
 
1129
1145
  custom_criteria = ChainMap(*OPTIONS["custom_criteria"])
1130
1146
 
1131
- varnames: list[Hashable] = []
1132
- coords: list[Hashable] = []
1133
- successful = dict.fromkeys(key_iter, False)
1134
- for k in key_iter:
1135
- if "coords" not in skip and k in _AXIS_NAMES + _COORD_NAMES:
1136
- names = _get_all(obj, k)
1137
- names = drop_bounds(names)
1138
- check_results(names, k)
1139
- successful[k] = bool(names)
1140
- coords.extend(names)
1141
- elif "measures" not in skip and k in measures:
1142
- measure = _get_all(obj, k)
1143
- check_results(measure, k)
1144
- successful[k] = bool(measure)
1145
- if measure:
1146
- varnames.extend(measure)
1147
- elif "grid_mapping_names" not in skip and k in grid_mapping_names:
1148
- grid_mapping = _get_all(obj, k)
1149
- check_results(grid_mapping, k)
1150
- successful[k] = bool(grid_mapping)
1151
- if grid_mapping:
1152
- varnames.extend(grid_mapping)
1153
- elif "geometries" not in skip and (k == "geometry" or k in _GEOMETRY_TYPES):
1154
- geometries = _get_all(obj, k)
1155
- if geometries and k in _GEOMETRY_TYPES:
1156
- new = itertools.chain(
1157
- _parse_related_geometry_vars(
1158
- ChainMap(obj[g].attrs, obj[g].encoding)
1147
+ # Fast path: when every key is just a plain variable in the Dataset (no CF
1148
+ # special meaning), skip the per-key classification loop below. Test the
1149
+ # cheap predicates first and only build the reserved-name set / consult
1150
+ # accessor.standard_names (a full attrs scan) if those could pass — the
1151
+ # common ds.cf["X"] / ds.cf["longitude"] paths must not pay that cost.
1152
+ fast_path = (
1153
+ isinstance(obj, Dataset)
1154
+ and not skip
1155
+ and all(k in obj._variables for k in key_iter)
1156
+ )
1157
+ if fast_path:
1158
+ reserved: set[Hashable] = set(_AXIS_NAMES).union(
1159
+ _COORD_NAMES,
1160
+ _GEOMETRY_TYPES,
1161
+ ("geometry",),
1162
+ measures,
1163
+ grid_mapping_names,
1164
+ custom_criteria,
1165
+ cf_role_criteria,
1166
+ )
1167
+ standard_names = accessor.standard_names
1168
+ fast_path = all(k not in reserved and k not in standard_names for k in key_iter)
1169
+
1170
+ varnames: list[Hashable]
1171
+ coords: list[Hashable]
1172
+ if fast_path:
1173
+ varnames = list(key_iter)
1174
+ coords = []
1175
+ successful = dict.fromkeys(key_iter, True)
1176
+ else:
1177
+ varnames = []
1178
+ coords = []
1179
+ successful = dict.fromkeys(key_iter, False)
1180
+ for k in key_iter:
1181
+ if "coords" not in skip and k in _AXIS_NAMES + _COORD_NAMES:
1182
+ names = _get_all(obj, k)
1183
+ names = drop_bounds(names)
1184
+ check_results(names, k)
1185
+ successful[k] = bool(names)
1186
+ coords.extend(names)
1187
+ elif "measures" not in skip and k in measures:
1188
+ measure = _get_all(obj, k)
1189
+ check_results(measure, k)
1190
+ successful[k] = bool(measure)
1191
+ if measure:
1192
+ varnames.extend(measure)
1193
+ elif "grid_mapping_names" not in skip and k in grid_mapping_names:
1194
+ grid_mapping = _get_all(obj, k)
1195
+ check_results(grid_mapping, k)
1196
+ successful[k] = bool(grid_mapping)
1197
+ if grid_mapping:
1198
+ varnames.extend(grid_mapping)
1199
+ elif "geometries" not in skip and (k == "geometry" or k in _GEOMETRY_TYPES):
1200
+ geometries = _get_all(obj, k)
1201
+ if geometries and k in _GEOMETRY_TYPES:
1202
+ new = itertools.chain(
1203
+ _parse_related_geometry_vars(
1204
+ ChainMap(obj[g].attrs, obj[g].encoding)
1205
+ )
1206
+ for g in geometries
1159
1207
  )
1160
- for g in geometries
1161
- )
1162
- geometries.extend(*new)
1163
- if len(geometries) > 1 and scalar_key:
1164
- raise ValueError(
1165
- f"CF geometries must be represented by an Xarray Dataset. To request a Dataset in return please pass `[{k!r}]` instead."
1166
- )
1167
- successful[k] = bool(geometries)
1168
- if geometries:
1169
- varnames.extend(geometries)
1170
- elif k in custom_criteria or k in cf_role_criteria:
1171
- names = _get_all(obj, k)
1172
- check_results(names, k)
1173
- successful[k] = bool(names)
1174
- varnames.extend(names)
1175
- else:
1176
- stdnames = set(_get_with_standard_name(obj, k))
1177
- objcoords = set(obj.coords)
1178
- stdnames = drop_bounds(stdnames)
1179
- if "coords" in skip:
1180
- stdnames -= objcoords
1181
- check_results(stdnames, k)
1182
- successful[k] = bool(stdnames)
1183
- varnames.extend(stdnames - objcoords)
1184
- coords.extend(stdnames & objcoords)
1208
+ geometries.extend(*new)
1209
+ if len(geometries) > 1 and scalar_key:
1210
+ raise ValueError(
1211
+ f"CF geometries must be represented by an Xarray Dataset. To request a Dataset in return please pass `[{k!r}]` instead."
1212
+ )
1213
+ successful[k] = bool(geometries)
1214
+ if geometries:
1215
+ varnames.extend(geometries)
1216
+ elif k in custom_criteria or k in cf_role_criteria:
1217
+ names = _get_all(obj, k)
1218
+ check_results(names, k)
1219
+ successful[k] = bool(names)
1220
+ varnames.extend(names)
1221
+ else:
1222
+ stdnames = set(_get_with_standard_name(obj, k))
1223
+ objcoords = set(obj.coords)
1224
+ stdnames = drop_bounds(stdnames)
1225
+ if "coords" in skip:
1226
+ stdnames -= objcoords
1227
+ check_results(stdnames, k)
1228
+ successful[k] = bool(stdnames)
1229
+ varnames.extend(stdnames - objcoords)
1230
+ coords.extend(stdnames & objcoords)
1185
1231
 
1186
1232
  # these are not special names but could be variable names in underlying object
1187
1233
  # we allow this so that we can return variables with appropriate CF auxiliary variables
@@ -1814,10 +1860,6 @@ class CFAccessor:
1814
1860
  for k, v in value.items()
1815
1861
  )
1816
1862
  )
1817
-
1818
- elif value is Ellipsis:
1819
- pass
1820
-
1821
1863
  else:
1822
1864
  # things like sum which have dim
1823
1865
  newvalue = [
@@ -2047,7 +2089,7 @@ class CFAccessor:
2047
2089
  ]
2048
2090
  as_dataset = self._maybe_to_dataset().reset_coords()
2049
2091
 
2050
- keys = {}
2092
+ keys: dict[str, str] = {}
2051
2093
  for attr in set(all_attrs):
2052
2094
  try:
2053
2095
  keys.update(parse_cell_methods_attr(attr))
@@ -2840,14 +2882,38 @@ class CFDatasetAccessor(CFAccessor):
2840
2882
  """
2841
2883
 
2842
2884
  obj = self._obj
2843
- keys = self.keys() | set(obj.variables)
2885
+ variables = obj._variables
2886
+
2887
+ # Single linear scan to discover which variables in the dataset have
2888
+ # a ``bounds`` attribute pointing at another existing variable. The
2889
+ # naive implementation called ``apply_mapper(_get_bounds, ...)`` for
2890
+ # every key in ``self.keys() | set(variables)``, which itself fans
2891
+ # out through ``_get_all`` and runs the regex criteria against every
2892
+ # variable's attrs. That is O(num_keys × num_vars × num_criteria)
2893
+ # for an answer that is structurally a single attribute lookup.
2894
+ var_to_bounds: dict[Hashable, Hashable] = {}
2895
+ for name, var in variables.items():
2896
+ attrs_or_encoding = ChainMap(var.attrs, var.encoding)
2897
+ bounds_name = attrs_or_encoding.get("bounds")
2898
+ if bounds_name is not None and bounds_name in variables:
2899
+ var_to_bounds[name] = bounds_name
2900
+
2901
+ if not var_to_bounds:
2902
+ return {}
2844
2903
 
2845
- vardict = {
2846
- key: self._drop_missing_variables(
2847
- apply_mapper(_get_bounds, obj, key, error=False)
2848
- )
2849
- for key in keys
2850
- }
2904
+ vardict: dict[Hashable, set[Hashable]] = {}
2905
+ # Direct variable-name keys: O(1) lookup, no mapper machinery.
2906
+ for name, bounds_name in var_to_bounds.items():
2907
+ vardict[name] = {bounds_name}
2908
+
2909
+ # CF keys (axes, coordinate names, custom criteria, ...): resolve to
2910
+ # their target variable names once, then read the precomputed
2911
+ # ``var_to_bounds`` map. ``_get_bounds`` is no longer invoked.
2912
+ for key in self.keys() - set(variables):
2913
+ target_vars = apply_mapper(_get_all, obj, key, error=False, default=[])
2914
+ bounds_vars = {var_to_bounds[v] for v in target_vars if v in var_to_bounds}
2915
+ if bounds_vars:
2916
+ vardict[key] = bounds_vars
2851
2917
 
2852
2918
  return {k: sort_maybe_hashable(v) for k, v in vardict.items() if v}
2853
2919
 
@@ -17,24 +17,23 @@ def _guess_bounds_1d(da, dim):
17
17
  """
18
18
  if dim not in da.dims:
19
19
  (dim,) = da.cf.axes[dim]
20
- ADDED_INDEX = False
21
- if dim not in da.coords:
22
- # For proper alignment in the lines below, we need an index on dim.
23
- da = da.assign_coords({dim: da[dim]})
24
- ADDED_INDEX = True
25
-
26
- diff = da.diff(dim)
27
- lower = da - diff / 2
28
- upper = da + diff / 2
29
- bounds = xr.concat([lower, upper], dim="bounds")
30
-
31
- first = (bounds.isel({dim: 0}) - diff.isel({dim: 0})).assign_coords(
32
- {dim: da[dim][0]}
20
+
21
+ bound_position = 0.5
22
+
23
+ diff = da.diff(dim).pad({dim: (1, 1)}, mode="edge")
24
+ lower = da.copy(
25
+ deep=False,
26
+ data=da.data - bound_position * diff.isel({dim: slice(0, -1)}).data,
27
+ )
28
+ upper = da.copy(
29
+ deep=False,
30
+ data=da.data + bound_position * diff.isel({dim: slice(1, None)}).data,
31
+ )
32
+ return (
33
+ xr.concat([lower, upper], dim="bounds")
34
+ .transpose(..., "bounds")
35
+ .drop_attrs(deep=False)
33
36
  )
34
- result = xr.concat([first, bounds], dim=dim).transpose(..., "bounds")
35
- if ADDED_INDEX:
36
- result = result.drop_vars(dim)
37
- return result.drop_attrs(deep=False)
38
37
 
39
38
 
40
39
  def _guess_bounds_2d(da, dims):
@@ -3,6 +3,7 @@ import pickle
3
3
  import warnings
4
4
  from textwrap import dedent
5
5
 
6
+ import dask.array
6
7
  import matplotlib as mpl
7
8
  import numpy as np
8
9
  import pandas as pd
@@ -839,6 +840,51 @@ def test_add_bounds(dims):
839
840
  _check_unchanged(original, ds)
840
841
 
841
842
 
843
+ def test_add_bounds_preserves_array_type() -> None:
844
+ # Test that the array type of the bounds variable is the same as the original variable.
845
+ ds = airds
846
+ original = ds.copy(deep=True)
847
+ ds = ds.drop_indexes("lat").rename_dims(lat="x")
848
+ ds["lat"] = ds.lat.copy(data=dask.array.asarray(ds.lat.data))
849
+ added = ds.cf.add_bounds("lat")
850
+
851
+ assert isinstance(added.lat.data, dask.array.Array)
852
+ assert isinstance(added.lat_bounds.data, dask.array.Array)
853
+
854
+ assert isinstance(ds.lat.data, dask.array.Array)
855
+ _check_unchanged(original, ds)
856
+
857
+
858
+ def test_add_irregularly_spaced_bounds_do_not_overlap() -> None:
859
+ # Test that added bounds with irregular spacing do not overlap.
860
+ ds = airds
861
+ original = ds.copy(deep=True)
862
+ ds["time"] = ds["time"].copy(data=pd.date_range("2013-01", "2013-04", freq="MS"))
863
+ expected = xr.DataArray(
864
+ data=[
865
+ pd.to_datetime(t)
866
+ for t in [
867
+ ["2012-12-16T12", "2013-01-16T12"],
868
+ ["2013-01-16T12", "2013-02-15T00"],
869
+ ["2013-02-15T00", "2013-03-16T12"],
870
+ ["2013-03-16T12", "2013-04-16T12"],
871
+ ]
872
+ ],
873
+ dims=["time", "bounds"],
874
+ name="time_bounds",
875
+ coords={"time": ds.time.data},
876
+ )
877
+ added = ds.copy(deep=False)
878
+ added = added.cf.add_bounds("time")
879
+
880
+ name = "time_bounds"
881
+ assert name in added.coords
882
+ assert added["time"].attrs["bounds"] == name
883
+ assert_allclose(added[name].reset_coords(drop=True), expected)
884
+
885
+ _check_unchanged(original, ds)
886
+
887
+
842
888
  def test_add_bounds_multiple() -> None:
843
889
  # Test multiple dimensions
844
890
  assert not {"x1_bounds", "x2_bounds"} <= set(multiple.variables)
@@ -853,7 +899,7 @@ def test_add_bounds_nd_variable() -> None:
853
899
 
854
900
  # 2D
855
901
  expected = (
856
- vertices_to_bounds( # type: ignore[misc]
902
+ vertices_to_bounds(
857
903
  xr.DataArray(
858
904
  np.arange(0, 13, 3).reshape(5, 1) + np.arange(-2, 2).reshape(1, 4),
859
905
  dims=("x", "y"),
@@ -861,7 +907,7 @@ def test_add_bounds_nd_variable() -> None:
861
907
  out_dims=("bounds", "x", "y"),
862
908
  )
863
909
  .rename("z_bounds")
864
- .assign_coords(**ds.coords)
910
+ .assign_coords(**ds.coords) # type: ignore[arg-type]
865
911
  )
866
912
  actual = ds.cf.add_bounds("z").z_bounds.reset_coords(drop=True)
867
913
  xr.testing.assert_identical(actual, expected)
@@ -882,11 +928,11 @@ def test_add_bounds_nd_variable() -> None:
882
928
  expected = (
883
929
  xr.concat([ds.z - 1.5, ds.z + 1.5], dim="bounds")
884
930
  .rename("z_bounds")
885
- .transpose("bounds", "y", "x")
931
+ .transpose(..., "bounds")
886
932
  )
887
933
 
888
934
  actual = ds.cf.add_bounds("z", dim="x").z_bounds.reset_coords(drop=True)
889
- xr.testing.assert_identical(expected.transpose(..., "bounds"), actual)
935
+ xr.testing.assert_identical(expected, actual)
890
936
 
891
937
  # Requesting bounds on a non-variable dimension
892
938
  with pytest.raises(ValueError, match="are dimensions with no index."):
@@ -1,13 +1,15 @@
1
+ import functools
1
2
  import inspect
2
3
  import os
3
4
  import warnings
4
5
  from collections import defaultdict
5
- from collections.abc import Iterable
6
+ from collections.abc import Iterable, Mapping
6
7
  from typing import Any
7
8
  from xml.etree import ElementTree
8
9
 
9
10
  import numpy as np
10
11
  from xarray import DataArray
12
+ from xarray.core.utils import Frozen
11
13
 
12
14
  try:
13
15
  import cftime
@@ -64,7 +66,8 @@ def _is_datetime_like(da: DataArray) -> bool:
64
66
  return False
65
67
 
66
68
 
67
- def parse_cell_methods_attr(attr: str) -> dict[str, str]:
69
+ @functools.lru_cache(maxsize=256)
70
+ def parse_cell_methods_attr(attr: str) -> Mapping[str, str]:
68
71
  """
69
72
  Parse cell_methods attributes (format is 'measure: name').
70
73
 
@@ -75,14 +78,14 @@ def parse_cell_methods_attr(attr: str) -> dict[str, str]:
75
78
 
76
79
  Returns
77
80
  -------
78
- Dictionary mapping measure to name
81
+ Read-only mapping from measure to name.
79
82
  """
80
83
  strings = [s for scolons in attr.split(":") for s in scolons.split()]
81
84
  if len(strings) % 2 != 0:
82
85
  raise ValueError(f"attrs['cell_measures'] = {attr!r} is malformed.")
83
86
 
84
- return dict(
85
- zip(strings[slice(0, None, 2)], strings[slice(1, None, 2)], strict=False)
87
+ return Frozen(
88
+ dict(zip(strings[slice(0, None, 2)], strings[slice(1, None, 2)], strict=False))
86
89
  )
87
90
 
88
91
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cf_xarray
3
- Version: 0.10.11
3
+ Version: 0.11.1
4
4
  Summary: A convenience wrapper for using CF attributes on xarray objects
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -35,7 +35,7 @@ or using ``conda``
35
35
  examples/introduction
36
36
  EarthCube 2021 demo <https://earthcube2021.github.io/ec21_book/notebooks/ec21_cherian_etal/DC_01_cf-xarray.html>
37
37
  CESM ocean model demo <https://pop-tools.readthedocs.io/en/latest/examples/cesm-cmip-cf-xarray.html>
38
- COSIMA ocean-sea ice model demo <https://cosima-recipes.readthedocs.io/en/latest/Tutorials/Model_Agnostic_Analysis.html>
38
+ COSIMA ocean-sea ice model demo <https://cosima-recipes.readthedocs.io/en/latest/01-Cooking-Lessons-101/02-Advanced/Model_Agnostic_Analysis.html>
39
39
 
40
40
  .. toctree::
41
41
  :maxdepth: 2