diff --git a/.cirrus.yml b/.cirrus.yml new file mode 100644 index 000000000..7590017dc --- /dev/null +++ b/.cirrus.yml @@ -0,0 +1,106 @@ +# Reference: +# - https://cirrus-ci.org/guide/writing-tasks/ +# - https://cirrus-ci.org/guide/tips-and-tricks/#sharing-configuration-between-tasks +# - https://cirrus-ci.org/guide/linux/ +# - https://cirrus-ci.org/guide/macOS/ +# - https://cirrus-ci.org/guide/windows/ +# - https://hub.docker.com/_/gcc/ +# - https://hub.docker.com/_/python/ + +# +# Global defaults. +# +container: + image: python:3.8 + cpu: 2 + memory: 4G + +env: + # Maximum cache period (in weeks) before forcing a new cache upload. + CACHE_PERIOD: "2" + # Increment the build number to force new conda cache upload. + CONDA_CACHE_BUILD: "98" + # Increment the build number to force new nox cache upload. + NOX_CACHE_BUILD: "98" + # Increment the build number to force new pip cache upload. + PIP_CACHE_BUILD: "98" + # Pip package to be upgraded/installed. + PIP_CACHE_PACKAGES: "pip setuptools wheel nox pyyaml" + # Base directory for the iris-test-data. + IRIS_TEST_DATA_DIR: ${HOME}/iris-test-data + # Git commit hash for iris test data. + IRIS_TEST_DATA_REF: "2.0.0" + +# +# Linting +# [2021-03-01] jamesp: Currently not black or flake compliant. +# GitHub issues open #251 and #252 +# lint_task: +# auto_cancellation: true +# name: "${CIRRUS_OS}: flake8 and black" +# pip_cache: +# folder: ~/.cache/pip +# fingerprint_script: +# - echo "${CIRRUS_TASK_NAME}" +# - echo "$(date +%Y).$(expr $(date +%U) / ${CACHE_PERIOD}):${PIP_CACHE_BUILD} ${PIP_CACHE_PACKAGES}" +# lint_script: +# - pip list +# - python -m pip install --retries 3 --upgrade ${PIP_CACHE_PACKAGES} +# - pip list +# - nox --session flake8 +# - nox --session black + + +# +# Testing (Linux) +# +linux_task_template: &LINUX_TASK_TEMPLATE + auto_cancellation: true + env: + PATH: ${HOME}/miniconda/bin:${PATH} + + container: + image: gcc:latest + cpu: 6 + memory: 8G + conda_cache: + folder: ${HOME}/miniconda + fingerprint_script: + - wget --quiet https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh + - echo "${CIRRUS_OS} $(sha256sum miniconda.sh)" + - echo "$(date +%Y).$(expr $(date +%U) / ${CACHE_PERIOD}):${CONDA_CACHE_BUILD}" + populate_script: + - bash miniconda.sh -b -p ${HOME}/miniconda + - conda config --set always_yes yes --set changeps1 no + - conda config --set show_channel_urls True + - conda config --add channels conda-forge + - conda update --quiet --name base conda + - conda install --quiet --name base nox pip coverage pyyaml + nox_cache: + folder: ${CIRRUS_WORKING_DIR}/.nox + fingerprint_script: + - echo "${CIRRUS_TASK_NAME}" + - echo "$(date +%Y).$(expr $(date +%U) / ${CACHE_PERIOD}):${NOX_CACHE_BUILD}" + - sha256sum ${CIRRUS_WORKING_DIR}/requirements/ci/py$(echo ${PY_VER} | tr -d ".").yml + - echo "IRIS ${IRIS_SOURCE}" + data_cache: + folder: ${IRIS_TEST_DATA_DIR} + fingerprint_script: + - echo "${IRIS_TEST_DATA_REF}" + populate_script: + - wget --quiet https://github.com/SciTools/iris-test-data/archive/v${IRIS_TEST_DATA_REF}.zip -O iris-test-data.zip + - unzip -q iris-test-data.zip + - mv iris-test-data-$(echo "${IRIS_TEST_DATA_REF}" | sed "s/^v//") ${IRIS_TEST_DATA_DIR} + +tests_task: + matrix: + env: + PY_VER: 3.6 + env: + PY_VER: 3.7 + # env: + # PY_VER: 3.8 + name: "${CIRRUS_OS}: py${PY_VER} tests" + << : *LINUX_TASK_TEMPLATE + tests_script: + - nox --session tests -- --test-data-dir ${IRIS_TEST_DATA_DIR}/test_data diff --git a/.gitignore b/.gitignore index fcae05cd6..2cab3a2fb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ sdist develop-eggs .installed.cfg +# test +.nox/ + # Installer logs pip-log.txt @@ -24,3 +27,4 @@ pip-log.txt \#* \.\#* *.swp +.vscode/ \ No newline at end of file diff --git a/iris_grib/tests/runner/__init__.py b/iris_grib/tests/runner/__init__.py new file mode 100644 index 000000000..c9ef7b895 --- /dev/null +++ b/iris_grib/tests/runner/__init__.py @@ -0,0 +1,9 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Empty file to allow import. + +""" diff --git a/iris_grib/tests/runner/__main__.py b/iris_grib/tests/runner/__main__.py new file mode 100644 index 000000000..cb44fc103 --- /dev/null +++ b/iris_grib/tests/runner/__main__.py @@ -0,0 +1,40 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Provides testing capabilities for installed copies of iris-grib. + +""" + +import argparse + +from ._runner import TestRunner + + +parser = argparse.ArgumentParser( + "iris_grib.tests", description=TestRunner.description +) +for long_opt, short_opt, help_text in TestRunner.user_options: + long_opt = long_opt.strip("=") + if long_opt in TestRunner.boolean_options: + parser.add_argument( + "--" + long_opt, + "-" + short_opt, + action="store_true", + help=help_text, + ) + else: + parser.add_argument("--" + long_opt, "-" + short_opt, help=help_text) +args = parser.parse_args() + +runner = TestRunner() + +runner.initialize_options() +for long_opt, short_opt, help_text in TestRunner.user_options: + arg = long_opt.replace("-", "_").strip("=") + setattr(runner, arg, getattr(args, arg)) +runner.finalize_options() + +runner.run() diff --git a/iris_grib/tests/runner/_runner.py b/iris_grib/tests/runner/_runner.py new file mode 100644 index 000000000..4648c4360 --- /dev/null +++ b/iris_grib/tests/runner/_runner.py @@ -0,0 +1,144 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Provides testing capabilities for installed copies of iris-grib. + +""" + +# Because this file is imported by setup.py, there may be additional runtime +# imports later in the file. +import multiprocessing +import os + + +# NOTE: Do not inherit from object as distutils does not like it. +class TestRunner: + """Run the iris-grib tests under nose and multiprocessor for performance""" + + description = ( + "Run tests under nose and multiprocessor for performance. " + "Specifying one or more test flags will run *only* those " + "tests." + ) + user_options = [ + ( + "no-data", + "n", + "Override the paths to the data repositories so it " + "appears to the tests that it does not exist.", + ), + ("stop", "x", "Stop running tests after the first error or failure."), + ("unit-tests", "s", "Run the limited subset of unit tests."), + ("integration-tests", "i", "Run the integration tests."), + ("default-tests", "d", "Run the default tests."), + ( + "coding-tests", + "c", + "Run the coding standards tests. (These are a " + "subset of the default tests.)", + ), + ( + "num-processors=", + "p", + "The number of processors used for running " "the tests.", + ), + ("create-missing", "m", "Create missing test result files."), + ] + boolean_options = [ + "no-data", + "unit-tests", + "stop", + "default-tests", + "integration-tests", + "create-missing", + ] + + def initialize_options(self): + self.no_data = False + self.stop = False + self.unit_tests = False + self.default_tests = False + self.integration_tests = False + self.num_processors = None + self.create_missing = False + + def finalize_options(self): + # These enviroment variables will be propagated to all the + # processes that nose.run creates. + if self.no_data: + print("Running tests in no-data mode...") + import iris.config + + iris.config.TEST_DATA_DIR = None + if self.create_missing: + os.environ["IRIS_TEST_CREATE_MISSING"] = "true" + + tests = [] + if self.unit_tests: + tests.append("unit") + if self.default_tests: + tests.append("default") + if self.integration_tests: + tests.append("integration") + if not tests: + tests.append("default") + print("Running test suite(s): {}".format(", ".join(tests))) + if self.stop: + print("Stopping tests after the first error or failure") + if self.num_processors is None: + # Choose a magic number that works reasonably well for the default + # number of processes. + self.num_processors = (multiprocessing.cpu_count() + 1) // 4 + 1 + else: + self.num_processors = int(self.num_processors) + + def run(self): + import nose + + if hasattr(self, "distribution") and self.distribution.tests_require: + self.distribution.fetch_build_eggs(self.distribution.tests_require) + + tests = [] + if self.unit_tests: + tests.append("iris_grib.tests.unit") + if self.default_tests: + tests.append("iris_grib.tests") + if self.integration_tests: + tests.append("iris_grib.tests.integration") + + if not tests: + tests.append("iris_grib.tests") + + regexp_pat = r"--match=^([Tt]est(?![Mm]ixin)|[Ss]ystem)" + + n_processors = max(self.num_processors, 1) + + args = [ + "", + None, + "--processes=%s" % n_processors, + "--verbosity=2", + regexp_pat, + "--process-timeout=180", + ] + + if self.stop: + args.append("--stop") + + result = True + for test in tests: + args[1] = test + print() + print( + "Running test discovery on %s with %s processors." + % (test, n_processors) + ) + # run the tests at module level i.e. my_module.tests + # - test must start with test/Test and must not contain the + # word Mixin. + result &= nose.run(argv=args) + if result is False: + exit(1) diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 000000000..e9a029fea --- /dev/null +++ b/noxfile.py @@ -0,0 +1,388 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the LGPL license. +# See COPYING and COPYING.LESSER in the root of the repository for full +# licensing details. +""" +Perform test automation with nox. + +For further details, see https://nox.thea.codes/en/stable/# + +""" + +from contextlib import contextmanager +import hashlib +import os +from pathlib import Path + +import nox +import yaml + +#: Default to reusing any pre-existing nox environments. +nox.options.reuse_existing_virtualenvs = True + +#: Name of the package to test. +PACKAGE = str("iris_grib") + +#: Cirrus-CI environment variable hook. +PY_VER = os.environ.get("PY_VER", ["3.6", "3.7"]) +IRIS_SOURCE = os.environ.get("IRIS_SOURCE", ['source', 'conda-forge']) + +#: Default cartopy cache directory. +CARTOPY_CACHE_DIR = os.environ.get("HOME") / Path(".local/share/cartopy") + + +def venv_cached(session, requirements_file=None): + """ + Determine whether the nox session environment has been cached. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + Returns + ------- + bool + Whether the session has been cached. + + """ + result = False + if not requirements_file is None: + yml = Path(requirements_file) + else: + yml = Path(f"requirements/ci/py{session.python.replace('.', '')}.yml") + tmp_dir = Path(session.create_tmp()) + cache = tmp_dir / yml.name + if cache.is_file(): + with open(yml, "rb") as fi: + expected = hashlib.sha256(fi.read()).hexdigest() + with open(cache, "r") as fi: + actual = fi.read() + result = actual == expected + return result + + +def concat_requirements(primary, *others): + """Join together the dependencies of one or more requirements.yaml. + + Parameters + ---------- + primary: str + filename of the primary requirements.yaml + + others: list[str] + list of additional requirements.yamls + + Returns + ------- + yaml + Dictionary of yaml data: primary with the addition + of others[]['dependencies'] + + """ + with open(primary, 'r') as f: + requirements = yaml.load(f, yaml.FullLoader) + + for o in others: + with open(o, 'r') as f: + oreq = yaml.load(f, yaml.FullLoader) + requirements['dependencies'].extend(oreq['dependencies']) + + return requirements + + +def cache_venv(session, requirements_file=None): + """ + Cache the nox session environment. + + This consists of saving a hexdigest (sha256) of the associated + conda requirements YAML file. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + if requirements_file is None: + yml = Path(f"requirements/ci/py{session.python.replace('.', '')}.yml") + else: + yml = Path(requirements_file) + with open(yml, "rb") as fi: + hexdigest = hashlib.sha256(fi.read()).hexdigest() + tmp_dir = Path(session.create_tmp()) + cache = tmp_dir / yml.name + with open(cache, "w") as fo: + fo.write(hexdigest) + + +def cache_cartopy(session): + """ + Determine whether to cache the cartopy natural earth shapefiles. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + if not CARTOPY_CACHE_DIR.is_dir(): + session.run( + "python", + "-c", + "import cartopy; cartopy.io.shapereader.natural_earth()", + ) + +def write_iris_config(session): + """Add test data dir and libudunits2.so to iris config. + + test data dir is set from session pos args. i.e. can be + configured by passing in on the command line: + nox --session tests -- --test-data-dir $TEST_DATA_DIR/test_data + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + try: + test_data_dir = session.posargs[session.posargs.index('--test-data-dir')+1] + except: + test_data_dir = "" + + iris_config_file = os.path.join(session.virtualenv.location, 'lib', f'python{session.python}', 'site-packages', 'iris', 'etc', 'site.cfg') + iris_config = f""" +[Resources] +test_data_dir = {test_data_dir} +[System] +udunits2_path = {os.path.join(session.virtualenv.location, 'lib', 'libudunits2.so')} +""" + + print("Iris config\n-----------") + print(iris_config) + + with open(iris_config_file, 'w') as f: + f.write(iris_config) + +@contextmanager +def prepare_venv(session, requirements_file=None): + """ + Create and cache the nox session conda environment, and additionally + provide conda environment package details and info. + + Note that, iris-grib is installed into the environment using pip. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + requirements_file: str + Path to the environment requirements file. + Default: requirements/ci/py{PY_VER}.yml + + Notes + ----- + See + - https://github.com/theacodes/nox/issues/346 + - https://github.com/theacodes/nox/issues/260 + + """ + if requirements_file is None: + # Determine the conda requirements yaml file. + requirements_file = f"requirements/ci/py{session.python.replace('.', '')}.yml" + + if not venv_cached(session, requirements_file): + # Back-door approach to force nox to use "conda env update". + command = ( + "conda", + "env", + "update", + f"--prefix={session.virtualenv.location}", + f"--file={requirements_file}", + "--prune", + ) + session._run(*command, silent=True, external="error") + cache_venv(session) + + cache_cartopy(session) + + # Allow the user to do setup things + # like installing iris-grib in development mode + yield session + + # Determine whether verbose diagnostics have been requested + # from the command line. + verbose = "-v" in session.posargs or "--verbose" in session.posargs + + if verbose: + session.run("conda", "info") + session.run("conda", "list", f"--prefix={session.virtualenv.location}") + session.run( + "conda", + "list", + f"--prefix={session.virtualenv.location}", + "--explicit", + ) + + +@nox.session +def flake8(session): + """ + Perform flake8 linting of iris-grib. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + # Pip install the session requirements. + session.install("flake8") + # Execute the flake8 linter on the package. + session.run("flake8", PACKAGE) + # Execute the flake8 linter on this file. + session.run("flake8", __file__) + + +@nox.session +def black(session): + """ + Perform black format checking of iris-grib. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + # Pip install the session requirements. + session.install("black==20.8b1") + # Execute the black format checker on the package. + session.run("black", "--check", PACKAGE) + # Execute the black format checker on this file. + session.run("black", "--check", __file__) + + +@nox.session(python=PY_VER, venv_backend="conda") +@nox.parametrize('iris', IRIS_SOURCE) +def tests(session, iris): + """ + Perform iris-grib tests against release and development versions of iris. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + + if iris == 'source': + # get latest iris + iris_dir = f"{session.create_tmp()}/iris" + + if os.path.exists(iris_dir): + # cached. update by pulling from origin/master + session.run( + "git", + "-C", + iris_dir, + "pull", + "origin", + "master", + external=True # use git from host environment + ) + else: + session.run( + "git", + "clone", + "https://github.com/scitools/iris.git", + iris_dir, + external=True + ) + + # combine iris and iris-grib requirements into one requirement list + requirements = concat_requirements( + f"requirements/ci/py{session.python.replace('.', '')}.yml", + f"{iris_dir}/requirements/ci/py{session.python.replace('.', '')}.yml" + ) + # remove iris dependencies, we'll install these from source + requirements['dependencies'] = [x for x in requirements['dependencies'] + if not x.startswith('iris')] + req_file = f"{session.create_tmp()}/requirements.yaml" + with open(req_file, 'w') as f: + yaml.dump(requirements, f) + else: + req_file = f"requirements/ci/py{session.python.replace('.', '')}.yml" + + with prepare_venv(session, req_file): + if iris == 'source': + session.install(iris_dir, '--no-deps') + session.install("--no-deps", "--editable", ".") + write_iris_config(session) + + session.run("python", "-m", "eccodes", "selfcheck") + + session.run( + "python", + "-m", + "iris_grib.tests.runner", + "--default-tests", + "--unit-tests", + "--integration-tests", + ) + + + +@nox.session(python=PY_VER, venv_backend="conda") +def doctest(session): + """ + Perform iris-grib doc-tests. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + prepare_venv(session) + session.cd("docs") + session.run( + "make", + "clean", + "html", + external=True, + ) + session.run( + "make", + "doctest", + external=True, + ) + + +@nox.session(python=PY_VER, venv_backend="conda") +def linkcheck(session): + """ + Perform iris-grib doc link check. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + prepare_venv(session) + session.cd("docs") + session.run( + "make", + "clean", + "html", + external=True, + ) + session.run( + "make", + "linkcheck", + external=True, + ) \ No newline at end of file diff --git a/requirements/ci/py36.yml b/requirements/ci/py36.yml index e9293f3ca..e51e7c59e 100644 --- a/requirements/ci/py36.yml +++ b/requirements/ci/py36.yml @@ -20,3 +20,5 @@ dependencies: - mock - filelock - pep8 + - requests + - nose diff --git a/requirements/ci/py37.yml b/requirements/ci/py37.yml index 98c7f5c52..fbcc8433b 100644 --- a/requirements/ci/py37.yml +++ b/requirements/ci/py37.yml @@ -20,3 +20,5 @@ dependencies: - mock - filelock - pep8 + - requests + - nose diff --git a/requirements/ci/py38.yml b/requirements/ci/py38.yml new file mode 100644 index 000000000..cc84dcd94 --- /dev/null +++ b/requirements/ci/py38.yml @@ -0,0 +1,24 @@ +name: iris-grib-dev + +channels: + - conda-forge + +dependencies: + - python=3.8 + +# Setup dependencies. + - setuptools + +# Core dependencies. + - iris>=3 + - python-eccodes + +# Optional dependencies. + - mo_pack + +# Test dependencies. + - mock + - filelock + - pep8 + - requests + - nose \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index da738e0bc..9249fdee4 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -3,3 +3,4 @@ mock filelock pep8 +requests diff --git a/setup.py b/setup.py index 272e946e9..2a4afec4b 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,6 @@ import os.path from setuptools import setup - NAME = 'iris_grib' PYPI_NAME = 'iris-grib' PACKAGE_DIR = os.path.abspath(os.path.dirname(__file__)) @@ -93,7 +92,7 @@ def file_walk_relative(top, remove=''): # PyPI, but the user is required to install eccodes itself manually. See # ECMWF ecCodes installation documentation for more information. install_requires=pip_requirements("setup", "core"), - test_suite = f'{NAME}.tests', + test_loader="unittest:TestLoader", extras_require={ "all": pip_requirements("all"), "test": pip_requirements("test"),