• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Ouranosinc / xclim / 24155949440

08 Apr 2026 08:00PM UTC coverage: 91.83% (-0.2%) from 92.011%
24155949440

push

github

aulemahal
merge

71 of 93 new or added lines in 6 files covered. (76.34%)

7947 of 8654 relevant lines covered (91.83%)

6.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.28
/src/xclim/testing/utils.py
1
"""
2
Testing and Tutorial Utilities' Module
3
======================================
4
"""
5

6
from __future__ import annotations
7✔
7

8
import importlib.metadata as ilm
7✔
9
import importlib.resources as ilr
7✔
10
import logging
7✔
11
import os
7✔
12
import platform
7✔
13
import re
7✔
14
import sys
7✔
15
import time
7✔
16
import warnings
7✔
17
from collections.abc import Callable, Sequence
7✔
18
from datetime import datetime as dt
7✔
19
from functools import wraps
7✔
20
from importlib.metadata import PackageNotFoundError
7✔
21
from io import StringIO
7✔
22
from pathlib import Path
7✔
23
from shutil import copytree
7✔
24
from typing import IO, Any, TextIO
7✔
25
from urllib.error import HTTPError, URLError
7✔
26
from urllib.parse import urljoin, urlparse
7✔
27
from urllib.request import urlretrieve
7✔
28

29
from filelock import FileLock
7✔
30
from packaging.requirements import Requirement
7✔
31
from packaging.version import Version
7✔
32
from pip._vendor import pkg_resources
7✔
33
from xarray import Dataset
7✔
34
from xarray import open_dataset as _open_dataset
7✔
35

36
import xclim
7✔
37
from xclim import __version__ as __xclim_version__
7✔
38

39
try:
7✔
40
    import pytest
7✔
41
    from pytest_socket import SocketBlockedError
7✔
42
except ImportError:
×
43
    pytest = None
×
44

NEW
45
    class SocketBlockedError(Exception):
×
NEW
46
        pass
×
47

48

49
try:
7✔
50
    import pooch
7✔
51
except ImportError:
×
52
    warnings.warn("The `pooch` library is not installed. The default cache directory for testing data will not be set.")
×
53
    pooch = None
×
54

55

56
logger = logging.getLogger("xclim")
7✔
57

58

59
__all__ = [
7✔
60
    "TESTDATA_BRANCH",
61
    "TESTDATA_CACHE_DIR",
62
    "TESTDATA_REPO_URL",
63
    "audit_url",
64
    "default_testdata_cache",
65
    "default_testdata_repo_url",
66
    "default_testdata_version",
67
    "gather_testing_data",
68
    "list_input_variables",
69
    "nimbus",
70
    "open_dataset",
71
    "populate_testing_data",
72
    "publish_release_notes",
73
    "run_doctests",
74
    "show_versions",
75
    "testing_setup_warnings",
76
]
77

78
default_testdata_version = "v2025.4.29"
7✔
79
"""Default version of the testing data to use when fetching datasets."""
7✔
80

81
default_testdata_repo_url = "https://raw.githubusercontent.com/Ouranosinc/xclim-testdata/"
7✔
82
"""Default URL of the testing data repository to use when fetching datasets."""
7✔
83

84
try:
7✔
85
    default_testdata_cache = Path(pooch.os_cache("xclim-testdata"))
7✔
86
    """Default location for the testing data cache."""
7✔
87
except (AttributeError, TypeError):
×
88
    default_testdata_cache = None
×
89

90
TESTDATA_REPO_URL = str(os.getenv("XCLIM_TESTDATA_REPO_URL", default_testdata_repo_url))
7✔
91
"""
7✔
92
Sets the URL of the testing data repository to use when fetching datasets.
93

94
Notes
95
-----
96
When running tests locally, this can be set for both `pytest` and `tox` by exporting the variable:
97

98
.. code-block:: console
99

100
    $ export XCLIM_TESTDATA_REPO_URL="https://github.com/my_username/xclim-testdata"
101

102
or setting the variable at runtime:
103

104
.. code-block:: console
105

106
    $ env XCLIM_TESTDATA_REPO_URL="https://github.com/my_username/xclim-testdata" pytest
107
"""
108

109
TESTDATA_BRANCH = str(os.getenv("XCLIM_TESTDATA_BRANCH", default_testdata_version))
7✔
110
"""
7✔
111
Sets the branch of the testing data repository to use when fetching datasets.
112

113
Notes
114
-----
115
When running tests locally, this can be set for both `pytest` and `tox` by exporting the variable:
116

117
.. code-block:: console
118

119
    $ export XCLIM_TESTDATA_BRANCH="my_testing_branch"
120

121
or setting the variable at runtime:
122

123
.. code-block:: console
124

125
    $ env XCLIM_TESTDATA_BRANCH="my_testing_branch" pytest
126
"""
127

128
TESTDATA_CACHE_DIR = os.getenv("XCLIM_TESTDATA_CACHE_DIR", default_testdata_cache)
7✔
129
"""
7✔
130
Sets the directory to store the testing datasets.
131

132
If not set, the default location will be used (based on ``platformdirs``, see :func:`pooch.os_cache`).
133

134
Notes
135
-----
136
When running tests locally, this can be set for both `pytest` and `tox` by exporting the variable:
137

138
.. code-block:: console
139

140
    $ export XCLIM_TESTDATA_CACHE_DIR="/path/to/my/data"
141

142
or setting the variable at runtime:
143

144
.. code-block:: console
145

146
    $ env XCLIM_TESTDATA_CACHE_DIR="/path/to/my/data" pytest
147
"""
148

149

150
def list_input_variables(submodules: Sequence[str] | None = None, realms: Sequence[str] | None = None) -> dict:
7✔
151
    """
152
    List all possible variables names used in xclim's indicators.
153

154
    Made for development purposes. Parses all indicator parameters with the
155
    :py:attr:`xclim.core.utils.InputKind.VARIABLE` or `OPTIONAL_VARIABLE` kinds.
156

157
    Parameters
158
    ----------
159
    submodules : str, optional
160
        Restrict the output to indicators of a list of submodules only. Default None, which parses all indicators.
161
    realms : Sequence of str, optional
162
        Restrict the output to indicators of a list of realms only. Default None, which parses all indicators.
163

164
    Returns
165
    -------
166
    dict
167
        A mapping from variable name to indicator class.
168
    """
169
    from collections import defaultdict  # pylint: disable=import-outside-toplevel
7✔
170

171
    from xclim import indicators  # pylint: disable=import-outside-toplevel
7✔
172
    from xclim.core.indicator import registry  # pylint: disable=import-outside-toplevel
7✔
173
    from xclim.core.utils import InputKind  # pylint: disable=import-outside-toplevel
7✔
174

175
    submodules = submodules or [sub for sub in dir(indicators) if not sub.startswith("__")]
7✔
176
    realms = realms or ["atmos", "ocean", "land", "seaIce"]
7✔
177

178
    variables = defaultdict(list)
7✔
179
    for name, ind in registry.items():
7✔
180
        if "." in name:
7✔
181
            # external submodule, submodule name is prepended to registry key
182
            if name.split(".")[0] not in submodules:
7✔
183
                continue
7✔
184
        elif ind.realm not in submodules:
7✔
185
            # official indicator : realm == submodule
186
            continue
×
187
        if ind.realm not in realms:
7✔
188
            continue
7✔
189

190
        # ok we want this one.
191
        for varname, meta in ind._all_parameters.items():
7✔
192
            if meta.kind in [
7✔
193
                InputKind.VARIABLE,
194
                InputKind.OPTIONAL_VARIABLE,
195
            ]:
196
                var = meta.default or varname
7✔
197
                variables[var].append(ind)
7✔
198

199
    return variables
7✔
200

201

202
# Publishing Tools ###
203

204

205
def publish_release_notes(
7✔
206
    style: str = "md",
207
    file: os.PathLike[str] | StringIO | TextIO | None = None,
208
    changes: str | os.PathLike[str] | None = None,
209
) -> str | None:
210
    """
211
    Format release notes in Markdown or ReStructuredText.
212

213
    Parameters
214
    ----------
215
    style : {"rst", "md"}
216
        Use ReStructuredText formatting or Markdown. Default: Markdown.
217
    file : {os.PathLike, StringIO, TextIO}, optional
218
        If provided, prints to the given file-like object. Otherwise, returns a string.
219
    changes : str or os.PathLike[str], optional
220
        If provided, manually points to the file where the changelog can be found.
221
        Assumes a relative path otherwise.
222

223
    Returns
224
    -------
225
    str, optional
226
        If `file` not provided, the formatted release notes.
227

228
    Notes
229
    -----
230
    This function is used solely for development and packaging purposes.
231
    """
232
    if isinstance(changes, str | Path):
7✔
233
        changes_file = Path(changes).absolute()
7✔
234
    else:
235
        changes_file = Path(__file__).absolute().parents[3].joinpath("CHANGELOG.rst")
×
236

237
    if not changes_file.exists():
7✔
238
        raise FileNotFoundError("Changelog file not found in xclim folder tree.")
7✔
239

240
    with open(changes_file, encoding="utf-8") as hf:
7✔
241
        changes = hf.read()
7✔
242

243
    if style == "rst":
7✔
244
        hyperlink_replacements = {
7✔
245
            r":issue:`([0-9]+)`": r"`GH/\1 <https://github.com/Ouranosinc/xclim/issues/\1>`_",
246
            r":pull:`([0-9]+)`": r"`PR/\1 <https://github.com/Ouranosinc/xclim/pull/\>`_",
247
            r":user:`([a-zA-Z0-9_.-]+)`": r"`@\1 <https://github.com/\1>`_",
248
        }
249
    elif style == "md":
7✔
250
        hyperlink_replacements = {
7✔
251
            r":issue:`([0-9]+)`": r"[GH/\1](https://github.com/Ouranosinc/xclim/issues/\1)",
252
            r":pull:`([0-9]+)`": r"[PR/\1](https://github.com/Ouranosinc/xclim/pull/\1)",
253
            r":user:`([a-zA-Z0-9_.-]+)`": r"[@\1](https://github.com/\1)",
254
        }
255
    else:
256
        msg = f"Formatting style not supported: {style}"
7✔
257
        raise NotImplementedError(msg)
7✔
258

259
    for search, replacement in hyperlink_replacements.items():
7✔
260
        changes = re.sub(search, replacement, changes)
7✔
261

262
    if style == "md":
7✔
263
        changes = changes.replace("=========\nChangelog\n=========", "# Changelog")
7✔
264

265
        titles = {r"\n(.*?)\n([\-]{1,})": "-", r"\n(.*?)\n([\^]{1,})": "^"}
7✔
266
        for title_expression, level in titles.items():
7✔
267
            found = re.findall(title_expression, changes)
7✔
268
            for grouping in found:
7✔
269
                fixed_grouping = str(grouping[0]).replace("(", r"\(").replace(")", r"\)")
7✔
270
                search = rf"({fixed_grouping})\n([\{level}]{'{' + str(len(grouping[1])) + '}'})"
7✔
271
                replacement = f"{'##' if level == '-' else '###'} {grouping[0]}"
7✔
272
                changes = re.sub(search, replacement, changes)
7✔
273

274
        link_expressions = r"[\`]{1}([\w\s]+)\s<(.+)>`\_"
7✔
275
        found = re.findall(link_expressions, changes)
7✔
276
        for grouping in found:
7✔
277
            search = rf"`{grouping[0]} <.+>`\_"
7✔
278
            replacement = f"[{str(grouping[0]).strip()}]({grouping[1]})"
7✔
279
            changes = re.sub(search, replacement, changes)
7✔
280

281
    if not file:
7✔
282
        return changes
7✔
283
    if isinstance(file, Path | os.PathLike):
7✔
284
        with open(file, "w", encoding="utf-8") as f:
7✔
285
            print(changes, file=f)
7✔
286
    else:
287
        print(changes, file=file)
×
288
    return None
7✔
289

290

291
def show_versions(
7✔
292
    file: os.PathLike | StringIO | TextIO | None = None,
293
    deps: list[str] | None = None,
294
) -> str | None:
295
    """
296
    Print the versions of xclim and its dependencies.
297

298
    Parameters
299
    ----------
300
    file : {os.PathLike, StringIO, TextIO}, optional
301
        If provided, prints to the given file-like object. Otherwise, returns a string.
302
    deps : list of str, optional
303
        A list of dependencies to gather and print version information from.
304
        Otherwise, prints `xclim` dependencies.
305

306
    Returns
307
    -------
308
    str or None
309
        If `file` not provided, the versions of xclim and its dependencies.
310
    """
311
    dependencies: list[str]
312

313
    def _find_dependencies(package_name):
7✔
314
        package = pkg_resources.working_set.by_key[package_name]
7✔
315
        full_deps = [str(dependency) for dependency in package.requires()]
7✔
316
        dep_names = [Requirement(dep).name for dep in full_deps]
7✔
317
        return dep_names
7✔
318

319
    _xclim_deps = _find_dependencies("xclim")
7✔
320
    _xclim_deps.extend(["flox", "lmoments3", "matplotlib", "numbagg", "pymannkendall", "xclim", "xsdba"])
7✔
321

322
    if deps is None:
7✔
323
        dependencies = _xclim_deps
7✔
324
    else:
325
        dependencies = deps
×
326

327
    dependency_versions = {}
7✔
328
    for d in dependencies:
7✔
329
        try:
7✔
330
            _version = ilm.version(d)
7✔
331
        except PackageNotFoundError:
3✔
332
            _version = None
3✔
333
        dependency_versions[d] = _version
7✔
334

335
    modules_versions = "\n".join([f"{k}: {stat}" for k, stat in sorted(dependency_versions.items())])
7✔
336

337
    installed_versions = [
7✔
338
        "INSTALLED VERSIONS",
339
        "------------------",
340
        f"python: {platform.python_version()}",
341
        f"{modules_versions}",
342
        f"Anaconda-based environment: {'yes' if Path(sys.base_prefix).joinpath('conda-meta').exists() else 'no'}",
343
    ]
344

345
    message = "\n".join(installed_versions)
7✔
346

347
    if not file:
7✔
348
        return message
7✔
349
    if isinstance(file, Path | os.PathLike):
7✔
350
        with open(file, "w", encoding="utf-8") as f:
7✔
351
            print(message, file=f)
7✔
352
    else:
353
        print(message, file=file)
×
354
    return None
7✔
355

356

357
# Test Data Utilities ###
358

359

360
def run_doctests():
7✔
361
    """Run the doctests for the module."""
362
    if pytest is None:
×
363
        raise ImportError(
×
364
            "The `pytest` package is required to run the doctests. "
365
            "You can install it with `pip install pytest` or `pip install xclim[dev]`."
366
        )
367

368
    cmd = [
×
369
        f"--rootdir={Path(__file__).absolute().parent}",
370
        "--numprocesses=0",
371
        "--xdoctest",
372
        f"{Path(__file__).absolute().parents[1]}",
373
    ]
374

375
    sys.exit(pytest.main(cmd))
×
376

377

378
def testing_setup_warnings():
7✔
379
    """Warn users about potential incompatibilities between xclim and xclim-testdata versions."""
380
    if re.match(r"^\d+\.\d+\.\d+$", __xclim_version__) and TESTDATA_BRANCH != default_testdata_version:
7✔
381
        # This does not need to be emitted on GitHub Workflows and ReadTheDocs
382
        if not os.getenv("CI") and not os.getenv("READTHEDOCS"):
×
383
            warnings.warn(
×
384
                f"`xclim` stable ({__xclim_version__}) is running tests against a non-default "
385
                f"branch of the testing data. It is possible that changes to the testing data may "
386
                f"be incompatible with some assertions in this version. "
387
                f"Please be sure to check {TESTDATA_REPO_URL} for more information.",
388
            )
389

390
    if re.match(r"^v\d+\.\d+\.\d+", TESTDATA_BRANCH):
7✔
391
        # Find the date of last modification of xclim source files to generate a calendar version
392
        install_date = dt.strptime(
7✔
393
            time.ctime(os.path.getmtime(xclim.__file__)),
394
            "%a %b %d %H:%M:%S %Y",
395
        )
396
        install_calendar_version = f"{install_date.year}.{install_date.month}.{install_date.day}"
7✔
397

398
        if Version(TESTDATA_BRANCH) > Version(install_calendar_version):
7✔
399
            warnings.warn(
×
400
                f"The installation date of `xclim` ({install_date.ctime()}) "
401
                f"predates the last release of testing data ({TESTDATA_BRANCH}). "
402
                "It is very likely that the testing data is incompatible with this build of `xclim`.",
403
            )
404

405

406
def load_registry(branch: str = TESTDATA_BRANCH, repo: str = TESTDATA_REPO_URL) -> dict[str, str]:
7✔
407
    """
408
    Load the registry file for the test data.
409

410
    Parameters
411
    ----------
412
    branch : str
413
        Branch of the repository to use when fetching testing datasets.
414
    repo : str
415
        URL of the repository to use when fetching testing datasets.
416

417
    Returns
418
    -------
419
    dict
420
        Dictionary of filenames and hashes.
421
    """
422
    if not repo.endswith("/"):
7✔
423
        repo = f"{repo}/"
×
424
    remote_registry = audit_url(
7✔
425
        urljoin(
426
            urljoin(repo, branch if branch.endswith("/") else f"{branch}/"),
427
            "data/registry.txt",
428
        )
429
    )
430

431
    if repo != default_testdata_repo_url:
7✔
432
        external_repo_name = urlparse(repo).path.split("/")[-2]
×
433
        external_branch_name = branch.split("/")[-1]
×
434
        registry_file = Path(
×
435
            str(ilr.files("xclim").joinpath(f"testing/registry.{external_repo_name}.{external_branch_name}.txt"))
436
        )
437
        urlretrieve(remote_registry, registry_file)  # noqa: S310
×
438

439
    elif branch != default_testdata_version:
7✔
440
        custom_registry_folder = Path(str(ilr.files("xclim").joinpath(f"testing/{branch}")))
×
441
        custom_registry_folder.mkdir(parents=True, exist_ok=True)
×
442
        registry_file = custom_registry_folder.joinpath("registry.txt")
×
443
        urlretrieve(remote_registry, registry_file)  # noqa: S310
×
444

445
    else:
446
        registry_file = Path(str(ilr.files("xclim").joinpath("testing/registry.txt")))
7✔
447

448
    if not registry_file.exists():
7✔
449
        raise FileNotFoundError(f"Registry file not found: {registry_file}")
×
450

451
    # Load the registry file
452
    with registry_file.open(encoding="utf-8") as f:
7✔
453
        registry = {line.split()[0]: line.split()[1] for line in f}
7✔
454
    return registry
7✔
455

456

457
def nimbus(
7✔
458
    repo: str = TESTDATA_REPO_URL,
459
    branch: str = TESTDATA_BRANCH,
460
    cache_dir: str | Path = TESTDATA_CACHE_DIR,
461
    allow_updates: bool = True,
462
):
463
    """
464
    Pooch registry instance for xclim test data.
465

466
    Parameters
467
    ----------
468
    repo : str
469
        URL of the repository to use when fetching testing datasets.
470
    branch : str
471
        Branch of repository to use when fetching testing datasets.
472
    cache_dir : str or Path
473
        The path to the directory where the data files are stored.
474
    allow_updates : bool
475
        If True, allow updates to the data files. Default is True.
476

477
    Returns
478
    -------
479
    pooch.Pooch
480
        The Pooch instance for accessing the xclim testing data.
481

482
    Notes
483
    -----
484
    There are three environment variables that can be used to control the behaviour of this registry:
485
        - ``XCLIM_TESTDATA_CACHE_DIR``: If this environment variable is set, it will be used as the
486
          base directory to store the data files.
487
          The directory should be an absolute path (i.e., it should start with ``/``).
488
          Otherwise, the default location will be used (based on ``platformdirs``, see :py:func:`pooch.os_cache`).
489
        - ``XCLIM_TESTDATA_REPO_URL``: If this environment variable is set, it will be used as the URL of
490
          the repository to use when fetching datasets. Otherwise, the default repository will be used.
491
        - ``XCLIM_TESTDATA_BRANCH``: If this environment variable is set, it will be used as the branch of
492
          the repository to use when fetching datasets. Otherwise, the default branch will be used.
493

494
    Examples
495
    --------
496
    Using the registry to download a file:
497

498
    .. code-block:: python
499

500
        import xarray as xr
501
        from xclim.testing.helpers import nimbus
502

503
        example_file = nimbus().fetch("example.nc")
504
        data = xr.open_dataset(example_file)
505
    """
506
    if pooch is None:
7✔
507
        raise ImportError(
×
508
            "The `pooch` package is required to fetch the xclim testing data. "
509
            "You can install it with `pip install pooch` or `pip install xclim[dev]`."
510
        )
511
    if not repo.endswith("/"):
7✔
512
        repo = f"{repo}/"
×
513
    remote = audit_url(urljoin(urljoin(repo, branch if branch.endswith("/") else f"{branch}/"), "data"))
7✔
514

515
    _nimbus = pooch.create(
7✔
516
        path=cache_dir,
517
        base_url=remote,
518
        version=default_testdata_version,
519
        version_dev=branch,
520
        allow_updates=allow_updates,
521
        registry=load_registry(branch=branch, repo=repo),
522
    )
523

524
    # Add a custom fetch method to the Pooch instance
525
    # Needed to address: https://github.com/readthedocs/readthedocs.org/issues/11763
526
    # Fix inspired by @bjlittle (https://github.com/bjlittle/geovista/pull/1202)
527
    _nimbus.fetch_diversion = _nimbus.fetch
7✔
528

529
    # Overload the fetch method to add user-agent headers
530
    @wraps(_nimbus.fetch_diversion)
7✔
531
    def _fetch(*args, **kwargs: bool | Callable) -> str:  # numpydoc ignore=GL08  # *args: str
7✔
532
        def _downloader(
7✔
533
            url: str,
534
            output_file: str | IO,
535
            poocher: pooch.Pooch,
536
            check_only: bool | None = False,
537
        ) -> None:
538
            """Download the file from the URL and save it to the save_path."""
539
            headers = {"User-Agent": f"xclim ({__xclim_version__})"}
7✔
540
            downloader = pooch.HTTPDownloader(headers=headers)
7✔
541
            return downloader(url, output_file, poocher, check_only=check_only)
7✔
542

543
        # default to our http/s downloader with user-agent headers
544
        kwargs.setdefault("downloader", _downloader)
7✔
545
        try:
7✔
546
            return _nimbus.fetch_diversion(*args, **kwargs)
7✔
547
        except SocketBlockedError as err:
×
548
            raise FileNotFoundError(
×
549
                "File was not found in the testing data cache and remote socket connections are disabled. "
550
                "You may need to download the testing data using `xclim prefetch_testing_data`."
551
            ) from err
552

553
    # Replace the fetch method with the custom fetch method
554
    _nimbus.fetch = _fetch
7✔
555

556
    return _nimbus
7✔
557

558

559
def open_dataset(name: str, nimbus_kwargs: dict[str, Path | str | bool] | None = None, **xr_kwargs: Any) -> Dataset:
7✔
560
    r"""
561
    Convenience function to open a dataset from the xclim testing data using the `nimbus` class.
562

563
    This is a thin wrapper around the `nimbus` class to make it easier to open xclim testing datasets.
564

565
    Parameters
566
    ----------
567
    name : str
568
        Name of the file containing the dataset.
569
    nimbus_kwargs : dict
570
        Keyword arguments passed to the nimbus function.
571
    **xr_kwargs : Any
572
        Keyword arguments passed to xarray.open_dataset.
573

574
    Returns
575
    -------
576
    xarray.Dataset
577
        The dataset.
578

579
    See Also
580
    --------
581
    xarray.open_dataset : Open and read a dataset from a file or file-like object.
582
    nimbus : Pooch wrapper for accessing the xclim testing data.
583

584
    Notes
585
    -----
586
    As of `xclim` v0.57.0, this function no longer supports the `dap_url` parameter. For OPeNDAP datasets, use
587
    `xarray.open_dataset` directly using the OPeNDAP URL with an appropriate backend installed (netCDF4, pydap, etc.).
588
    """
589
    if nimbus_kwargs is None:
7✔
590
        nimbus_kwargs = {}
×
591
    return _open_dataset(nimbus(**nimbus_kwargs).fetch(name), **xr_kwargs)
7✔
592

593

594
def populate_testing_data(
7✔
595
    temp_folder: Path | None = None,
596
    repo: str = TESTDATA_REPO_URL,
597
    branch: str = TESTDATA_BRANCH,
598
    local_cache: Path = TESTDATA_CACHE_DIR,
599
) -> None:
600
    """
601
    Populate the local cache with the testing data.
602

603
    Parameters
604
    ----------
605
    temp_folder : Path, optional
606
        Path to a temporary folder to use as the local cache. If not provided, the default location will be used.
607
    repo : str, optional
608
        URL of the repository to use when fetching testing datasets.
609
    branch : str, optional
610
        Branch of xclim-testdata to use when fetching testing datasets.
611
    local_cache : Path
612
        The path to the local cache. Defaults to the location set by the platformdirs library.
613
        The testing data will be downloaded to this local cache.
614
    """
615
    # Create the Pooch instance
616
    n = nimbus(repo=repo, branch=branch, cache_dir=temp_folder or local_cache)
7✔
617

618
    # Download the files
619
    errored_files = []
7✔
620
    for file in load_registry():
7✔
621
        try:
7✔
622
            n.fetch(file)
7✔
623
        except HTTPError:
×
624
            msg = f"File `{file}` not accessible in remote repository."
×
625
            logging.error(msg)
×
626
            errored_files.append(file)
×
627
        except SocketBlockedError as err:
×
628
            msg = (
×
629
                "Unable to access registry file online. Testing suite is being run with `--disable-socket`. "
630
                "If you intend to run tests with this option enabled, please download the file beforehand with the "
631
                "following console command: `$ xclim prefetch_testing_data`."
632
            )
633
            raise SocketBlockedError(msg) from err
×
634
        else:
635
            logging.info("Files were downloaded successfully.")
7✔
636

637
    if errored_files:
7✔
638
        logging.error(
×
639
            "The following files were unable to be downloaded: %s",
640
            errored_files,
641
        )
642

643

644
def gather_testing_data(
7✔
645
    worker_cache_dir: str | os.PathLike[str] | Path,
646
    worker_id: str,
647
    _cache_dir: str | os.PathLike[str] | None = TESTDATA_CACHE_DIR,
648
) -> None:
649
    """
650
    Gather testing data across workers.
651

652
    Parameters
653
    ----------
654
    worker_cache_dir : str or Path
655
        The directory to store the testing data.
656
    worker_id : str
657
        The worker ID.
658
    _cache_dir : str or Path, optional
659
        The directory to store the testing data. Default is None.
660

661
    Raises
662
    ------
663
    ValueError
664
        If the cache directory is not set.
665
    FileNotFoundError
666
        If the testing data is not found.
667
    """
668
    if _cache_dir is None:
7✔
669
        raise ValueError(
×
670
            "The cache directory must be set. "
671
            "Please set the `cache_dir` parameter or the `XCLIM_DATA_DIR` environment variable."
672
        )
673
    cache_dir = Path(_cache_dir)
7✔
674

675
    if worker_id == "master":
7✔
676
        populate_testing_data(branch=TESTDATA_BRANCH)
×
677
    else:
678
        if platform.system() == "Windows":
7✔
679
            if not cache_dir.joinpath(default_testdata_version).exists():
×
680
                raise FileNotFoundError(
×
681
                    "Testing data not found and UNIX-style file-locking is not supported on Windows. "
682
                    "Consider running `$ xclim prefetch_testing_data` to download testing data beforehand."
683
                )
684
        else:
685
            cache_dir.mkdir(exist_ok=True, parents=True)
7✔
686
            lockfile = cache_dir.joinpath(".lock")
7✔
687
            test_data_being_written = FileLock(lockfile)
7✔
688
            with test_data_being_written:
7✔
689
                # This flag prevents multiple calls from re-attempting to download testing data in the same pytest run
690
                populate_testing_data(branch=TESTDATA_BRANCH)
7✔
691
                cache_dir.joinpath(".data_written").touch()
7✔
692
            with test_data_being_written.acquire():
7✔
693
                if lockfile.exists():
7✔
694
                    lockfile.unlink()
7✔
695
        copytree(cache_dir.joinpath(default_testdata_version), worker_cache_dir)
7✔
696

697

698
# Testing Utilities ###
699

700

701
def audit_url(url: str, context: str | None = None) -> str:
7✔
702
    """
703
    Check if the URL is well-formed.
704

705
    Parameters
706
    ----------
707
    url : str
708
        The URL to check.
709
    context : str, optional
710
        Additional context to include in the error message. Default is None.
711

712
    Returns
713
    -------
714
    str
715
        The URL if it is well-formed.
716

717
    Raises
718
    ------
719
    URLError
720
        If the URL is not well-formed.
721
    """
722
    msg = ""
7✔
723
    result = urlparse(url)
7✔
724
    if result.scheme == "http":
7✔
725
        msg = f"{context if context else ''} URL is not using secure HTTP: '{url}'".strip()
×
726
    if not all([result.scheme, result.netloc]):
7✔
727
        msg = f"{context if context else ''} URL is not well-formed: '{url}'".strip()
×
728

729
    if msg:
7✔
730
        logger.error(msg)
×
731
        raise URLError(msg)
×
732
    return url
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc