diff --git a/docs/releases/development.rst b/docs/releases/development.rst index 1370206e..1f4c55c6 100644 --- a/docs/releases/development.rst +++ b/docs/releases/development.rst @@ -21,3 +21,7 @@ Next release (in development) (:pr:`219`). * Defer ShocSimple coordinate detection to the CFGrid2D base class (:issue:`217`, :pr:`218`). +* Split `tests.utils` in to multiple `tests.helpers` submodules + (:pr:`220`). +* Split `tests.test_utils` in to multiple `tests.utils.test_component` submodules + (:pr:`220`). diff --git a/tests/conventions/test_cfgrid1d.py b/tests/conventions/test_cfgrid1d.py index 8eed4ba0..057d0707 100644 --- a/tests/conventions/test_cfgrid1d.py +++ b/tests/conventions/test_cfgrid1d.py @@ -17,9 +17,10 @@ CFGrid1D, CFGrid1DTopology, CFGridKind, CFGridTopology ) from emsarray.operations import geometry -from tests.utils import ( - assert_property_not_cached, box, mask_from_strings, track_peak_memory_usage -) +from tests.helpers.array import mask_from_strings +from tests.helpers.functools import assert_property_not_cached +from tests.helpers.geometry import box +from tests.helpers.memory import track_peak_memory_usage logger = logging.getLogger(__name__) diff --git a/tests/conventions/test_cfgrid2d.py b/tests/conventions/test_cfgrid2d.py index 726e97a1..bc35f89b 100644 --- a/tests/conventions/test_cfgrid2d.py +++ b/tests/conventions/test_cfgrid2d.py @@ -26,11 +26,13 @@ from emsarray.conventions.shoc import ShocSimple from emsarray.exceptions import NoSuchCoordinateError from emsarray.operations import geometry -from tests.utils import ( +from tests.helpers.datasets import ( AxisAlignedShocGrid, DiagonalShocGrid, ShocGridGenerator, - ShocLayerGenerator, assert_property_not_cached, plot_geometry, - track_peak_memory_usage + ShocLayerGenerator ) +from tests.helpers.functools import assert_property_not_cached +from tests.helpers.geometry import plot_geometry +from tests.helpers.memory import track_peak_memory_usage logger = logging.getLogger(__name__) diff --git a/tests/conventions/test_shoc_standard.py b/tests/conventions/test_shoc_standard.py index 56863fe6..a4717973 100644 --- a/tests/conventions/test_shoc_standard.py +++ b/tests/conventions/test_shoc_standard.py @@ -19,10 +19,11 @@ ) from emsarray.conventions.shoc import ShocStandard from emsarray.operations import geometry -from tests.utils import ( - DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator, mask_from_strings, - track_peak_memory_usage +from tests.helpers.array import mask_from_strings +from tests.helpers.datasets import ( + DiagonalShocGrid, ShocGridGenerator, ShocLayerGenerator ) +from tests.helpers.memory import track_peak_memory_usage logger = logging.getLogger(__name__) diff --git a/tests/conventions/test_ugrid.py b/tests/conventions/test_ugrid.py index 7b415fed..04edc654 100644 --- a/tests/conventions/test_ugrid.py +++ b/tests/conventions/test_ugrid.py @@ -23,9 +23,9 @@ ConventionViolationError, ConventionViolationWarning ) from emsarray.operations import geometry -from tests.utils import ( - assert_property_not_cached, filter_warning, track_peak_memory_usage -) +from tests.helpers.functools import assert_property_not_cached +from tests.helpers.memory import track_peak_memory_usage +from tests.helpers.warnings import filter_warning logger = logging.getLogger(__name__) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/helpers/array.py b/tests/helpers/array.py new file mode 100644 index 00000000..f90dd15b --- /dev/null +++ b/tests/helpers/array.py @@ -0,0 +1,34 @@ +import itertools + +import numpy + + +def reduce_axes(arr: numpy.ndarray, axes: tuple[bool, ...] | None = None) -> numpy.ndarray: + """ + Reduce the size of an array by one on an axis-by-axis basis. If an axis is + reduced, neigbouring values are averaged together + + :param arr: The array to reduce. + :param axes: A tuple of booleans indicating which axes should be reduced. Optional, defaults to reducing along all axes. + :returns: A new array with the same number of axes, but one size smaller in each axis that was reduced. + """ + if axes is None: + axes = tuple(True for _ in arr.shape) + axes_slices = [[numpy.s_[+1:], numpy.s_[:-1]] if axis else [numpy.s_[:]] for axis in axes] + return numpy.mean([arr[tuple(p)] for p in itertools.product(*axes_slices)], axis=0) # type: ignore + + +def mask_from_strings(mask_strings: list[str]) -> numpy.ndarray: + """ + Make a boolean mask array from a list of strings: + + >>> mask_from_strings([ + ... "101", + ... "010", + ... "111", + ... ]) + array([[ True, False, True], + [False, True, False], + [ True, True, True]]) + """ + return numpy.array([list(map(int, line)) for line in mask_strings]).astype(bool) diff --git a/tests/utils.py b/tests/helpers/datasets.py similarity index 64% rename from tests/utils.py rename to tests/helpers/datasets.py index fa85ac60..c1af1da1 100644 --- a/tests/utils.py +++ b/tests/helpers/datasets.py @@ -1,81 +1,15 @@ import abc -import contextlib -import importlib.metadata -import itertools -import tracemalloc -import warnings -from collections.abc import Hashable from functools import cached_property -from types import TracebackType -from typing import Any, Type +from typing import Hashable -import matplotlib.pyplot as plt import numpy -import pytest -import shapely import xarray -from cartopy.mpl.geoaxes import GeoAxes -from packaging.requirements import Requirement from emsarray.conventions.arakawa_c import ( ArakawaCGridKind, c_mask_from_centres ) -from emsarray.types import Bounds, Pathish - - -@contextlib.contextmanager -def filter_warning(*args, record: bool = False, **kwargs): - """ - A shortcut wrapper around warnings.catch_warning() - and warnings.filterwarnings() - """ - with warnings.catch_warnings(record=record) as context: - warnings.filterwarnings(*args, **kwargs) - yield context - - -def box(minx, miny, maxx, maxy) -> shapely.Polygon: - """ - Make a box, with coordinates going counterclockwise - starting at (minx miny). - """ - return shapely.Polygon([ - (minx, miny), - (maxx, miny), - (maxx, maxy), - (minx, maxy), - ]) - - -def reduce_axes(arr: numpy.ndarray, axes: tuple[bool, ...] | None = None) -> numpy.ndarray: - """ - Reduce the size of an array by one on an axis-by-axis basis. If an axis is - reduced, neigbouring values are averaged together - - :param arr: The array to reduce. - :param axes: A tuple of booleans indicating which axes should be reduced. Optional, defaults to reducing along all axes. - :returns: A new array with the same number of axes, but one size smaller in each axis that was reduced. - """ - if axes is None: - axes = tuple(True for _ in arr.shape) - axes_slices = [[numpy.s_[+1:], numpy.s_[:-1]] if axis else [numpy.s_[:]] for axis in axes] - return numpy.mean([arr[tuple(p)] for p in itertools.product(*axes_slices)], axis=0) # type: ignore - - -def mask_from_strings(mask_strings: list[str]) -> numpy.ndarray: - """ - Make a boolean mask array from a list of strings: - - >>> mask_from_strings([ - ... "101", - ... "010", - ... "111", - ... ]) - array([[ True, False, True], - [False, True, False], - [ True, True, True]]) - """ - return numpy.array([list(map(int, line)) for line in mask_strings]).astype(bool) + +from .array import reduce_axes class ShocLayerGenerator(abc.ABC): @@ -132,7 +66,7 @@ def z_centre(self) -> numpy.ndarray: class ShocGridGenerator(abc.ABC): - dimensions = { + dimensions: dict[ArakawaCGridKind, tuple[Hashable, Hashable]] = { ArakawaCGridKind.face: ('j_centre', 'i_centre'), ArakawaCGridKind.back: ('j_back', 'i_back'), ArakawaCGridKind.left: ('j_left', 'i_left'), @@ -376,130 +310,3 @@ def make_x_grid(self, j: numpy.ndarray, i: numpy.ndarray) -> numpy.ndarray: def make_y_grid(self, j: numpy.ndarray, i: numpy.ndarray) -> numpy.ndarray: return 0.1 * (5 + j) * numpy.sin(numpy.pi - i * numpy.pi / (self.i_size)) # type: ignore - - -def assert_property_not_cached( - instance: Any, - prop_name: str, - /, -) -> None: - __tracebackhide__ = True # noqa - cls = type(instance) - prop = getattr(cls, prop_name) - assert isinstance(prop, cached_property), \ - "{instance!r}.{prop_name} is not a cached_property" - - cache = instance.__dict__ - assert prop.attrname not in cache, \ - f"{instance!r}.{prop_name} was cached!" - - -def skip_versions(*requirements: str): - """ - Skips a test function if any of the version specifiers match. - """ - invalid_versions = [] - for requirement in map(Requirement, requirements): - assert not requirement.extras - assert requirement.url is None - assert requirement.marker is None - - try: - version = importlib.metadata.version(requirement.name) - except importlib.metadata.PackageNotFoundError: - # The package is not installed, so an invalid version isn't installed - continue - - if version in requirement.specifier: - invalid_versions.append( - f'{requirement.name}=={version} matches skipped version specifier {requirement}') - - return pytest.mark.skipif(len(invalid_versions) > 0, reason='\n'.join(invalid_versions)) - - -def only_versions(*requirements: str): - """ - Runs a test function only if all of the version specifiers match. - """ - invalid_versions = [] - for requirement in map(Requirement, requirements): - assert not requirement.extras - assert requirement.url is None - assert requirement.marker is None - - try: - version = importlib.metadata.version(requirement.name) - except importlib.metadata.PackageNotFoundError: - # The package is not installed, so a required version is not installed - invalid_versions.append(f'{requirement.name} is not installed') - continue - - if version not in requirement.specifier: - invalid_versions.append( - f'{requirement.name}=={version} does not satisfy {requirement}') - - return pytest.mark.skipif(len(invalid_versions) > 0, reason='\n'.join(invalid_versions)) - - -def plot_geometry( - dataset: xarray.Dataset, - out: Pathish, - *, - figsize: tuple[float, float] = (10, 10), - extent: Bounds | None = None, - title: str | None = None -) -> None: - figure = plt.figure(layout='constrained', figsize=figsize) - axes: GeoAxes = figure.add_subplot(projection=dataset.ems.data_crs) - axes.set_aspect(aspect='equal', adjustable='datalim') - axes.gridlines(draw_labels=['left', 'bottom'], linestyle='dashed') - - dataset.ems.plot_geometry(axes) - grid = dataset.ems.default_grid - x, y = grid.centroid_coordinates.T - axes.scatter(x, y, c='red') - - if title is not None: - axes.set_title(title) - if extent is not None: - axes.set_extent(extent) - - figure.savefig(out) - - -class TracemallocTracker: - _finished = False - _usage = None - - def __enter__(self): - tracemalloc.start() - return self - - @property - def current(self): - if not self._finished: - raise RuntimeError("Context manager has not exited yet") - return self._usage[0] - - @property - def peak(self): - if not self._finished: - raise RuntimeError("Context manager has not exited yet") - return self._usage[1] - - def __exit__( - self, - exc_type: Type[Exception] | None, - exc_value: Exception | None, - exc_traceback: TracebackType | None, - ) -> bool | None: - self._finished = True - self._usage = tracemalloc.get_traced_memory() - - tracemalloc.stop() - - return None - - -def track_peak_memory_usage(): - return TracemallocTracker() diff --git a/tests/helpers/functools.py b/tests/helpers/functools.py new file mode 100644 index 00000000..b969c271 --- /dev/null +++ b/tests/helpers/functools.py @@ -0,0 +1,18 @@ +from functools import cached_property +from typing import Any + + +def assert_property_not_cached( + instance: Any, + prop_name: str, + /, +) -> None: + __tracebackhide__ = True # noqa + cls = type(instance) + prop = getattr(cls, prop_name) + assert isinstance(prop, cached_property), \ + "{instance!r}.{prop_name} is not a cached_property" + + cache = instance.__dict__ + assert prop.attrname not in cache, \ + f"{instance!r}.{prop_name} was cached!" diff --git a/tests/helpers/geometry.py b/tests/helpers/geometry.py new file mode 100644 index 00000000..9c6e2f6c --- /dev/null +++ b/tests/helpers/geometry.py @@ -0,0 +1,45 @@ +import shapely +import xarray +from cartopy.mpl.geoaxes import GeoAxes +from matplotlib import pyplot as plt + +from emsarray.types import Bounds, Pathish + + +def box(minx, miny, maxx, maxy) -> shapely.Polygon: + """ + Make a box, with coordinates going counterclockwise + starting at (minx miny). + """ + return shapely.Polygon([ + (minx, miny), + (maxx, miny), + (maxx, maxy), + (minx, maxy), + ]) + + +def plot_geometry( + dataset: xarray.Dataset, + out: Pathish, + *, + figsize: tuple[float, float] = (10, 10), + extent: Bounds | None = None, + title: str | None = None +) -> None: + figure = plt.figure(layout='constrained', figsize=figsize) + axes: GeoAxes = figure.add_subplot(projection=dataset.ems.data_crs) + axes.set_aspect(aspect='equal', adjustable='datalim') + axes.gridlines(draw_labels=['left', 'bottom'], linestyle='dashed') + + dataset.ems.plot_geometry(axes) + grid = dataset.ems.default_grid + x, y = grid.centroid_coordinates.T + axes.scatter(x, y, c='red') + + if title is not None: + axes.set_title(title) + if extent is not None: + axes.set_extent(extent) + + figure.savefig(out) diff --git a/tests/helpers/memory.py b/tests/helpers/memory.py new file mode 100644 index 00000000..97009b7f --- /dev/null +++ b/tests/helpers/memory.py @@ -0,0 +1,41 @@ +import tracemalloc +from types import TracebackType +from typing import Type + + +class TracemallocTracker: + _finished = False + _usage = None + + def __enter__(self): + tracemalloc.start() + return self + + @property + def current(self): + if not self._finished: + raise RuntimeError("Context manager has not exited yet") + return self._usage[0] + + @property + def peak(self): + if not self._finished: + raise RuntimeError("Context manager has not exited yet") + return self._usage[1] + + def __exit__( + self, + exc_type: Type[Exception] | None, + exc_value: Exception | None, + exc_traceback: TracebackType | None, + ) -> bool | None: + self._finished = True + self._usage = tracemalloc.get_traced_memory() + + tracemalloc.stop() + + return None + + +def track_peak_memory_usage(): + return TracemallocTracker() diff --git a/tests/helpers/versions.py b/tests/helpers/versions.py new file mode 100644 index 00000000..54f5ccee --- /dev/null +++ b/tests/helpers/versions.py @@ -0,0 +1,51 @@ +import importlib.metadata + +import pytest +from packaging.requirements import Requirement + + +def skip_versions(*requirements: str): + """ + Skips a test function if any of the version specifiers match. + """ + invalid_versions = [] + for requirement in map(Requirement, requirements): + assert not requirement.extras + assert requirement.url is None + assert requirement.marker is None + + try: + version = importlib.metadata.version(requirement.name) + except importlib.metadata.PackageNotFoundError: + # The package is not installed, so an invalid version isn't installed + continue + + if version in requirement.specifier: + invalid_versions.append( + f'{requirement.name}=={version} matches skipped version specifier {requirement}') + + return pytest.mark.skipif(len(invalid_versions) > 0, reason='\n'.join(invalid_versions)) + + +def only_versions(*requirements: str): + """ + Runs a test function only if all of the version specifiers match. + """ + invalid_versions = [] + for requirement in map(Requirement, requirements): + assert not requirement.extras + assert requirement.url is None + assert requirement.marker is None + + try: + version = importlib.metadata.version(requirement.name) + except importlib.metadata.PackageNotFoundError: + # The package is not installed, so a required version is not installed + invalid_versions.append(f'{requirement.name} is not installed') + continue + + if version not in requirement.specifier: + invalid_versions.append( + f'{requirement.name}=={version} does not satisfy {requirement}') + + return pytest.mark.skipif(len(invalid_versions) > 0, reason='\n'.join(invalid_versions)) diff --git a/tests/helpers/warnings.py b/tests/helpers/warnings.py new file mode 100644 index 00000000..0cb4eda5 --- /dev/null +++ b/tests/helpers/warnings.py @@ -0,0 +1,13 @@ +import contextlib +import warnings + + +@contextlib.contextmanager +def filter_warning(*args, record: bool = False, **kwargs): + """ + A shortcut wrapper around warnings.catch_warning() + and warnings.filterwarnings() + """ + with warnings.catch_warnings(record=record) as context: + warnings.filterwarnings(*args, **kwargs) + yield context diff --git a/tests/masking/test_mask_dataset.py b/tests/masking/test_mask_dataset.py index 62b9eacf..5c43c086 100644 --- a/tests/masking/test_mask_dataset.py +++ b/tests/masking/test_mask_dataset.py @@ -12,7 +12,8 @@ from emsarray.conventions.arakawa_c import ( ArakawaCGridKind, c_mask_from_centres ) -from tests.utils import DiagonalShocGrid, ShocLayerGenerator, mask_from_strings +from tests.helpers.array import mask_from_strings +from tests.helpers.datasets import DiagonalShocGrid, ShocLayerGenerator def test_standard_mask_from_centres(): diff --git a/tests/masking/test_utils.py b/tests/masking/test_utils.py index 4ef8937e..0e68470a 100644 --- a/tests/masking/test_utils.py +++ b/tests/masking/test_utils.py @@ -8,7 +8,8 @@ from emsarray import masking from emsarray.utils import to_netcdf_with_fixes -from tests.utils import filter_warning, mask_from_strings +from tests.helpers.array import mask_from_strings +from tests.helpers.warnings import filter_warning def assert_raw_values( diff --git a/tests/misc/test_xarray_timedelta_units.py b/tests/misc/test_xarray_timedelta_units.py index 36af2fa6..624216fd 100644 --- a/tests/misc/test_xarray_timedelta_units.py +++ b/tests/misc/test_xarray_timedelta_units.py @@ -11,7 +11,7 @@ import pytest import xarray -from tests.utils import only_versions +from tests.helpers.versions import only_versions def make_dataset(): diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/test_datetime_from_np_time.py b/tests/utils/test_datetime_from_np_time.py new file mode 100644 index 00000000..c0530aff --- /dev/null +++ b/tests/utils/test_datetime_from_np_time.py @@ -0,0 +1,29 @@ +import datetime + +import freezegun +import numpy +import pytest + +from emsarray import utils + + +@pytest.mark.parametrize('tz_offset', [0, 10, -4]) +def test_datetime_from_np_time(tz_offset: int): + # Change the system timezone to `tz_offset` + with freezegun.freeze_time(tz_offset=tz_offset): + np_time = numpy.datetime64('2025-08-18T12:05:00.123456') + + # Test that converting works using the UTC default timezone, + # regardless of system timezone + py_time_utc = utils.datetime_from_np_time(np_time) + assert py_time_utc == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=datetime.UTC) + + # Test that converting works when interpreted in the system timezone. + py_tz_system = datetime.timezone(datetime.timedelta(hours=tz_offset)) + py_time_local = utils.datetime_from_np_time(np_time, tz=py_tz_system) + assert py_time_local == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_system) + + # Test that converting works when using some other arbitrary timezone. + py_tz_eucla = datetime.timezone(datetime.timedelta(hours=8, minutes=45)) + py_time_eucla = utils.datetime_from_np_time(np_time, tz=py_tz_eucla) + assert py_time_eucla == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_eucla) diff --git a/tests/utils/test_ems_time_units.py b/tests/utils/test_ems_time_units.py new file mode 100644 index 00000000..06f39580 --- /dev/null +++ b/tests/utils/test_ems_time_units.py @@ -0,0 +1,52 @@ +import datetime +import pathlib + +import netCDF4 +import pytest +import xarray + +from emsarray import utils + + +@pytest.mark.parametrize( + ('existing', 'new'), + [ + ('days since 1990-01-01T00:00:00+10:00', 'days since 1990-01-01 00:00:00 +10:00'), + ('days since 1990-1-1 0:00:00 +10', 'days since 1990-01-01 00:00:00 +10:00'), + ('hours since 2021-11-16T12:00:00+11:00', 'hours since 2021-11-16 12:00:00 +11:00'), + ], +) +def test_format_time_units_for_ems(existing, new): + assert new == utils.format_time_units_for_ems(existing) + + +def test_fix_time_units_for_ems(tmp_path: pathlib.Path): + dataset_path = tmp_path / "dataset.nc" + # Make a minimal dataset, with a record dimension and time data + xr_dataset = xarray.Dataset( + data_vars={ + 't': xarray.DataArray( + data=[datetime.datetime(2021, 11, 16, hour) for hour in range(24)], + dims=["record"], + attrs={'coordinate': 'time', 'long_name': 'Time'}, + ), + }, + ) + # Set a nice, EMS compatible time unit string + xr_dataset.data_vars['t'].encoding.update({ + 'units': 'hours since 2021-11-01 00:00:00 +10:00', + 'calendar': 'proleptic_gregorian', + }) + xr_dataset.encoding['unlimited_dims'] = {'record'} + xr_dataset.to_netcdf(dataset_path) + + with netCDF4.Dataset(dataset_path, "r") as nc_dataset: + # xarray will have reformatted the time units to ISO8601 format, + # with a 'T' separator and no space before the timezone + assert nc_dataset.variables['t'].getncattr('units') == 'hours since 2021-11-01T00:00:00+10:00' + + utils.fix_time_units_for_ems(dataset_path, "t") + + with netCDF4.Dataset(dataset_path, "r+") as nc_dataset: + # The time units should now be in an EMS-compatible format + assert nc_dataset.variables['t'].getncattr('units') == 'hours since 2021-11-01 00:00:00 +10:00' diff --git a/tests/test_utils.py b/tests/utils/test_xarray.py similarity index 86% rename from tests/test_utils.py rename to tests/utils/test_xarray.py index 8a727139..cbe986f2 100644 --- a/tests/test_utils.py +++ b/tests/utils/test_xarray.py @@ -1,9 +1,7 @@ -import datetime import logging import pathlib from importlib.metadata import version -import freezegun import netCDF4 import numpy import numpy.testing @@ -14,7 +12,7 @@ from packaging.version import parse from emsarray import utils -from tests.utils import filter_warning +from tests.helpers.warnings import filter_warning logger = logging.getLogger(__name__) @@ -22,50 +20,6 @@ xarray_version = parse(version('xarray')) -@pytest.mark.parametrize( - ('existing', 'new'), - [ - ('days since 1990-01-01T00:00:00+10:00', 'days since 1990-01-01 00:00:00 +10:00'), - ('days since 1990-1-1 0:00:00 +10', 'days since 1990-01-01 00:00:00 +10:00'), - ('hours since 2021-11-16T12:00:00+11:00', 'hours since 2021-11-16 12:00:00 +11:00'), - ], -) -def test_format_time_units_for_ems(existing, new): - assert new == utils.format_time_units_for_ems(existing) - - -def test_fix_time_units_for_ems(tmp_path: pathlib.Path): - dataset_path = tmp_path / "dataset.nc" - # Make a minimal dataset, with a record dimension and time data - xr_dataset = xarray.Dataset( - data_vars={ - 't': xarray.DataArray( - data=[datetime.datetime(2021, 11, 16, hour) for hour in range(24)], - dims=["record"], - attrs={'coordinate': 'time', 'long_name': 'Time'}, - ), - }, - ) - # Set a nice, EMS compatible time unit string - xr_dataset.data_vars['t'].encoding.update({ - 'units': 'hours since 2021-11-01 00:00:00 +10:00', - 'calendar': 'proleptic_gregorian', - }) - xr_dataset.encoding['unlimited_dims'] = {'record'} - xr_dataset.to_netcdf(dataset_path) - - with netCDF4.Dataset(dataset_path, "r") as nc_dataset: - # xarray will have reformatted the time units to ISO8601 format, - # with a 'T' separator and no space before the timezone - assert nc_dataset.variables['t'].getncattr('units') == 'hours since 2021-11-01T00:00:00+10:00' - - utils.fix_time_units_for_ems(dataset_path, "t") - - with netCDF4.Dataset(dataset_path, "r+") as nc_dataset: - # The time units should now be in an EMS-compatible format - assert nc_dataset.variables['t'].getncattr('units') == 'hours since 2021-11-01 00:00:00 +10:00' - - def test_disable_default_fill_value(tmp_path: pathlib.Path): int_var = xarray.DataArray( data=numpy.arange(35, dtype=int).reshape(5, 7), @@ -570,25 +524,3 @@ def test_wind_dimension_renamed(): ) wound = utils.wind_dimension(data_array, ['y', 'x'], [5, 4], linear_dimension='ix') xarray.testing.assert_equal(wound, expected) - - -@pytest.mark.parametrize('tz_offset', [0, 10, -4]) -def test_datetime_from_np_time(tz_offset: int): - # Change the system timezone to `tz_offset` - with freezegun.freeze_time(tz_offset=tz_offset): - np_time = numpy.datetime64('2025-08-18T12:05:00.123456') - - # Test that converting works using the UTC default timezone, - # regardless of system timezone - py_time_utc = utils.datetime_from_np_time(np_time) - assert py_time_utc == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=datetime.UTC) - - # Test that converting works when interpreted in the system timezone. - py_tz_system = datetime.timezone(datetime.timedelta(hours=tz_offset)) - py_time_local = utils.datetime_from_np_time(np_time, tz=py_tz_system) - assert py_time_local == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_system) - - # Test that converting works when using some other arbitrary timezone. - py_tz_eucla = datetime.timezone(datetime.timedelta(hours=8, minutes=45)) - py_time_eucla = utils.datetime_from_np_time(np_time, tz=py_tz_eucla) - assert py_time_eucla == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_eucla)