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

Ouranosinc / xclim / 25683017529

11 May 2026 04:27PM UTC coverage: 91.788% (-0.04%) from 91.823%
25683017529

push

github

web-flow
Bump urllib3 from 2.6.3 to 2.7.0 in /CI (#2354)

Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.7.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Security</h2>
<p>Addressed high-severity security issues. Impact was limited to
specific use cases detailed in the accompanying advisories; overall user
exposure was estimated to be marginal.</p>
<ul>
<li>
<p>Decompression-bomb safeguards of the streaming API were bypassed:</p>
<ol>
<li>When <code>HTTPResponse.drain_conn()</code> was called after the
response had been read and decompressed partially. (Reported by <a
href="https://github.com/Cycloctane"><code>@​Cycloctane</code></a>)</li>
<li>During the second <code>HTTPResponse.read(amt=N)</code> or
<code>HTTPResponse.stream(amt=N)</code> call when the response was
decompressed using the official <a
href="https://pypi.org/project/brotli/">Brotli</a> library. (Reported by
<a
href="https://github.com/kimkou2024"><code>@​kimkou2024</code></a>)</li>
</ol>
<p>See GHSA-mf9v-mfxr-j63j for details.</p>
</li>
<li>
<p>HTTP pools created using
<code>ProxyManager.connection_from_url</code> did not strip sensitive
headers specified in <code>Retry.remove_headers_on_redirect</... (continued)

7936 of 8646 relevant lines covered (91.79%)

6.26 hits per line

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

77.37
/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, Iterable, 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.version import Version
7✔
31
from xarray import Dataset
7✔
32
from xarray import open_dataset as _open_dataset
7✔
33

34
import xclim
7✔
35
from xclim import __version__ as __xclim_version__
7✔
36

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

43
    class SocketBlockedError(Exception):
×
44
        pass
×
45

46

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

53

54
logger = logging.getLogger("xclim")
7✔
55

56

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

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

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

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

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

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

96
.. code-block:: console
97

98
    $ export XCLIM_TESTDATA_REPO_URL="https://github.com/my_username/xclim-testdata"
99

100
or setting the variable at runtime:
101

102
.. code-block:: console
103

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

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

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

115
.. code-block:: console
116

117
    $ export XCLIM_TESTDATA_BRANCH="my_testing_branch"
118

119
or setting the variable at runtime:
120

121
.. code-block:: console
122

123
    $ env XCLIM_TESTDATA_BRANCH="my_testing_branch" pytest
124
"""
125

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

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

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

136
.. code-block:: console
137

138
    $ export XCLIM_TESTDATA_CACHE_DIR="/path/to/my/data"
139

140
or setting the variable at runtime:
141

142
.. code-block:: console
143

144
    $ env XCLIM_TESTDATA_CACHE_DIR="/path/to/my/data" pytest
145
"""
146

147

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

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

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

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

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

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

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

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

197
    return variables
7✔
198

199

200
# Publishing Tools ###
201

202

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

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

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

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

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

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

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

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

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

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

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

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

288

289
_xclim_deps = [
7✔
290
    "xclim",
291
    "xarray",
292
    "statsmodels",
293
    "scikit-learn",
294
    "scipy",
295
    "pint",
296
    "pandas",
297
    "numpy",
298
    "numba",
299
    "lmoments3",
300
    "jsonpickle",
301
    "flox",
302
    "dask",
303
    "cf_xarray",
304
    "cftime",
305
    "clisops",
306
    "click",
307
    "bottleneck",
308
    "boltons",
309
]
310

311

312
def show_versions(
7✔
313
    file: os.PathLike | StringIO | TextIO | None = None,
314
    deps: Iterable[str] | None = None,
315
) -> str | None:
316
    """
317
    Print the versions of xclim and its dependencies.
318

319
    Parameters
320
    ----------
321
    file : {os.PathLike, StringIO, TextIO}, optional
322
        If provided, prints to the given file-like object. Otherwise, returns a string.
323
    deps : iterable of str, optional
324
        An iterable of dependencies to gather and print version information from.
325
        Otherwise, prints `xclim` dependencies.
326

327
    Returns
328
    -------
329
    str or None
330
        If `file` not provided, the versions of xclim and its dependencies.
331
    """
332
    dependencies: list[str]
333
    if deps is None:
7✔
334
        dependencies = _xclim_deps
7✔
335
    else:
336
        dependencies = deps
×
337

338
    dependency_versions = {}
7✔
339
    for d in dependencies:
7✔
340
        try:
7✔
341
            _version = ilm.version(d)
7✔
342
        except PackageNotFoundError:
7✔
343
            _version = None
7✔
344
        dependency_versions[d] = _version
7✔
345

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

348
    installed_versions = [
7✔
349
        "INSTALLED VERSIONS",
350
        "------------------",
351
        f"python: {platform.python_version()}",
352
        f"{modules_versions}",
353
        f"Anaconda-based environment: {'yes' if Path(sys.base_prefix).joinpath('conda-meta').exists() else 'no'}",
354
    ]
355

356
    message = "\n".join(installed_versions)
7✔
357

358
    if not file:
7✔
359
        return message
7✔
360
    if isinstance(file, Path | os.PathLike):
7✔
361
        with open(file, "w", encoding="utf-8") as f:
7✔
362
            print(message, file=f)
7✔
363
    else:
364
        print(message, file=file)
×
365
    return None
7✔
366

367

368
# Test Data Utilities ###
369

370

371
def run_doctests():
7✔
372
    """Run the doctests for the module."""
373
    if pytest is None:
×
374
        raise ImportError(
×
375
            "The `pytest` package is required to run the doctests. "
376
            "You can install it with `pip install pytest` or `pip install xclim[dev]`."
377
        )
378

379
    cmd = [
×
380
        f"--rootdir={Path(__file__).absolute().parent}",
381
        "--numprocesses=0",
382
        "--xdoctest",
383
        f"{Path(__file__).absolute().parents[1]}",
384
    ]
385

386
    sys.exit(pytest.main(cmd))
×
387

388

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

401
    if re.match(r"^v\d+\.\d+\.\d+", TESTDATA_BRANCH):
7✔
402
        # Find the date of last modification of xclim source files to generate a calendar version
403
        install_date = dt.strptime(
7✔
404
            time.ctime(os.path.getmtime(xclim.__file__)),
405
            "%a %b %d %H:%M:%S %Y",
406
        )
407
        install_calendar_version = f"{install_date.year}.{install_date.month}.{install_date.day}"
7✔
408

409
        if Version(TESTDATA_BRANCH) > Version(install_calendar_version):
7✔
410
            warnings.warn(
×
411
                f"The installation date of `xclim` ({install_date.ctime()}) "
412
                f"predates the last release of testing data ({TESTDATA_BRANCH}). "
413
                "It is very likely that the testing data is incompatible with this build of `xclim`.",
414
            )
415

416

417
def load_registry(branch: str = TESTDATA_BRANCH, repo: str = TESTDATA_REPO_URL) -> dict[str, str]:
7✔
418
    """
419
    Load the registry file for the test data.
420

421
    Parameters
422
    ----------
423
    branch : str
424
        Branch of the repository to use when fetching testing datasets.
425
    repo : str
426
        URL of the repository to use when fetching testing datasets.
427

428
    Returns
429
    -------
430
    dict
431
        Dictionary of filenames and hashes.
432
    """
433
    if not repo.endswith("/"):
7✔
434
        repo = f"{repo}/"
×
435
    remote_registry = audit_url(
7✔
436
        urljoin(
437
            urljoin(repo, branch if branch.endswith("/") else f"{branch}/"),
438
            "data/registry.txt",
439
        )
440
    )
441

442
    if repo != default_testdata_repo_url:
7✔
443
        external_repo_name = urlparse(repo).path.split("/")[-2]
×
444
        external_branch_name = branch.split("/")[-1]
×
445
        registry_file = Path(
×
446
            str(ilr.files("xclim").joinpath(f"testing/registry.{external_repo_name}.{external_branch_name}.txt"))
447
        )
448
        urlretrieve(remote_registry, registry_file)  # noqa: S310
×
449

450
    elif branch != default_testdata_version:
7✔
451
        custom_registry_folder = Path(str(ilr.files("xclim").joinpath(f"testing/{branch}")))
×
452
        custom_registry_folder.mkdir(parents=True, exist_ok=True)
×
453
        registry_file = custom_registry_folder.joinpath("registry.txt")
×
454
        urlretrieve(remote_registry, registry_file)  # noqa: S310
×
455

456
    else:
457
        registry_file = Path(str(ilr.files("xclim").joinpath("testing/registry.txt")))
7✔
458

459
    if not registry_file.exists():
7✔
460
        raise FileNotFoundError(f"Registry file not found: {registry_file}")
×
461

462
    # Load the registry file
463
    with registry_file.open(encoding="utf-8") as f:
7✔
464
        registry = {line.split()[0]: line.split()[1] for line in f}
7✔
465
    return registry
7✔
466

467

468
def nimbus(
7✔
469
    repo: str = TESTDATA_REPO_URL,
470
    branch: str = TESTDATA_BRANCH,
471
    cache_dir: str | Path = TESTDATA_CACHE_DIR,
472
    allow_updates: bool = True,
473
):
474
    """
475
    Pooch registry instance for xclim test data.
476

477
    Parameters
478
    ----------
479
    repo : str
480
        URL of the repository to use when fetching testing datasets.
481
    branch : str
482
        Branch of repository to use when fetching testing datasets.
483
    cache_dir : str or Path
484
        The path to the directory where the data files are stored.
485
    allow_updates : bool
486
        If True, allow updates to the data files. Default is True.
487

488
    Returns
489
    -------
490
    pooch.Pooch
491
        The Pooch instance for accessing the xclim testing data.
492

493
    Notes
494
    -----
495
    There are three environment variables that can be used to control the behaviour of this registry:
496
        - ``XCLIM_TESTDATA_CACHE_DIR``: If this environment variable is set, it will be used as the
497
          base directory to store the data files.
498
          The directory should be an absolute path (i.e., it should start with ``/``).
499
          Otherwise, the default location will be used (based on ``platformdirs``, see :py:func:`pooch.os_cache`).
500
        - ``XCLIM_TESTDATA_REPO_URL``: If this environment variable is set, it will be used as the URL of
501
          the repository to use when fetching datasets. Otherwise, the default repository will be used.
502
        - ``XCLIM_TESTDATA_BRANCH``: If this environment variable is set, it will be used as the branch of
503
          the repository to use when fetching datasets. Otherwise, the default branch will be used.
504

505
    Examples
506
    --------
507
    Using the registry to download a file:
508

509
    .. code-block:: python
510

511
        import xarray as xr
512
        from xclim.testing.helpers import nimbus
513

514
        example_file = nimbus().fetch("example.nc")
515
        data = xr.open_dataset(example_file)
516
    """
517
    if pooch is None:
7✔
518
        raise ImportError(
×
519
            "The `pooch` package is required to fetch the xclim testing data. "
520
            "You can install it with `pip install pooch` or `pip install xclim[dev]`."
521
        )
522
    if not repo.endswith("/"):
7✔
523
        repo = f"{repo}/"
×
524
    remote = audit_url(urljoin(urljoin(repo, branch if branch.endswith("/") else f"{branch}/"), "data"))
7✔
525

526
    _nimbus = pooch.create(
7✔
527
        path=cache_dir,
528
        base_url=remote,
529
        version=default_testdata_version,
530
        version_dev=branch,
531
        allow_updates=allow_updates,
532
        registry=load_registry(branch=branch, repo=repo),
533
    )
534

535
    # Add a custom fetch method to the Pooch instance
536
    # Needed to address: https://github.com/readthedocs/readthedocs.org/issues/11763
537
    # Fix inspired by @bjlittle (https://github.com/bjlittle/geovista/pull/1202)
538
    _nimbus.fetch_diversion = _nimbus.fetch
7✔
539

540
    # Overload the fetch method to add user-agent headers
541
    @wraps(_nimbus.fetch_diversion)
7✔
542
    def _fetch(*args, **kwargs: bool | Callable) -> str:  # numpydoc ignore=GL08  # *args: str
7✔
543
        def _downloader(
7✔
544
            url: str,
545
            output_file: str | IO,
546
            poocher: pooch.Pooch,
547
            check_only: bool | None = False,
548
        ) -> None:
549
            """Download the file from the URL and save it to the save_path."""
550
            headers = {"User-Agent": f"xclim ({__xclim_version__})"}
×
551
            downloader = pooch.HTTPDownloader(headers=headers)
×
552
            return downloader(url, output_file, poocher, check_only=check_only)
×
553

554
        # default to our http/s downloader with user-agent headers
555
        kwargs.setdefault("downloader", _downloader)
7✔
556
        try:
7✔
557
            return _nimbus.fetch_diversion(*args, **kwargs)
7✔
558
        except SocketBlockedError as err:
×
559
            raise FileNotFoundError(
×
560
                "File was not found in the testing data cache and remote socket connections are disabled. "
561
                "You may need to download the testing data using `xclim prefetch_testing_data`."
562
            ) from err
563

564
    # Replace the fetch method with the custom fetch method
565
    _nimbus.fetch = _fetch
7✔
566

567
    return _nimbus
7✔
568

569

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

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

576
    Parameters
577
    ----------
578
    name : str
579
        Name of the file containing the dataset.
580
    nimbus_kwargs : dict
581
        Keyword arguments passed to the nimbus function.
582
    **xr_kwargs : Any
583
        Keyword arguments passed to xarray.open_dataset.
584

585
    Returns
586
    -------
587
    xarray.Dataset
588
        The dataset.
589

590
    See Also
591
    --------
592
    xarray.open_dataset : Open and read a dataset from a file or file-like object.
593
    nimbus : Pooch wrapper for accessing the xclim testing data.
594

595
    Notes
596
    -----
597
    As of `xclim` v0.57.0, this function no longer supports the `dap_url` parameter. For OPeNDAP datasets, use
598
    `xarray.open_dataset` directly using the OPeNDAP URL with an appropriate backend installed (netCDF4, pydap, etc.).
599
    """
600
    if nimbus_kwargs is None:
7✔
601
        nimbus_kwargs = {}
×
602
    return _open_dataset(nimbus(**nimbus_kwargs).fetch(name), **xr_kwargs)
7✔
603

604

605
def populate_testing_data(
7✔
606
    temp_folder: Path | None = None,
607
    repo: str = TESTDATA_REPO_URL,
608
    branch: str = TESTDATA_BRANCH,
609
    local_cache: Path = TESTDATA_CACHE_DIR,
610
) -> None:
611
    """
612
    Populate the local cache with the testing data.
613

614
    Parameters
615
    ----------
616
    temp_folder : Path, optional
617
        Path to a temporary folder to use as the local cache. If not provided, the default location will be used.
618
    repo : str, optional
619
        URL of the repository to use when fetching testing datasets.
620
    branch : str, optional
621
        Branch of xclim-testdata to use when fetching testing datasets.
622
    local_cache : Path
623
        The path to the local cache. Defaults to the location set by the platformdirs library.
624
        The testing data will be downloaded to this local cache.
625
    """
626
    # Create the Pooch instance
627
    n = nimbus(repo=repo, branch=branch, cache_dir=temp_folder or local_cache)
7✔
628

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

648
    if errored_files:
7✔
649
        logging.error(
×
650
            "The following files were unable to be downloaded: %s",
651
            errored_files,
652
        )
653

654

655
def gather_testing_data(
7✔
656
    worker_cache_dir: str | os.PathLike[str] | Path,
657
    worker_id: str,
658
    _cache_dir: str | os.PathLike[str] | None = TESTDATA_CACHE_DIR,
659
) -> None:
660
    """
661
    Gather testing data across workers.
662

663
    Parameters
664
    ----------
665
    worker_cache_dir : str or Path
666
        The directory to store the testing data.
667
    worker_id : str
668
        The worker ID.
669
    _cache_dir : str or Path, optional
670
        The directory to store the testing data. Default is None.
671

672
    Raises
673
    ------
674
    ValueError
675
        If the cache directory is not set.
676
    FileNotFoundError
677
        If the testing data is not found.
678
    """
679
    if _cache_dir is None:
7✔
680
        raise ValueError(
×
681
            "The cache directory must be set. "
682
            "Please set the `cache_dir` parameter or the `XCLIM_DATA_DIR` environment variable."
683
        )
684
    cache_dir = Path(_cache_dir)
7✔
685

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

708

709
# Testing Utilities ###
710

711

712
def audit_url(url: str, context: str | None = None) -> str:
7✔
713
    """
714
    Check if the URL is well-formed.
715

716
    Parameters
717
    ----------
718
    url : str
719
        The URL to check.
720
    context : str, optional
721
        Additional context to include in the error message. Default is None.
722

723
    Returns
724
    -------
725
    str
726
        The URL if it is well-formed.
727

728
    Raises
729
    ------
730
    URLError
731
        If the URL is not well-formed.
732
    """
733
    msg = ""
7✔
734
    result = urlparse(url)
7✔
735
    if result.scheme == "http":
7✔
736
        msg = f"{context if context else ''} URL is not using secure HTTP: '{url}'".strip()
×
737
    if not all([result.scheme, result.netloc]):
7✔
738
        msg = f"{context if context else ''} URL is not well-formed: '{url}'".strip()
×
739

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

© 2026 Coveralls, Inc