From 1a363f0ccc43b8b73120cb1a6601ed46568e1948 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Mon, 16 Sep 2024 14:25:26 -0600 Subject: [PATCH] Test infrastructure for external spice kernels (#842) * Add downloader for de440s.bsp kernel Ignore all flavors of the de440 SPK Add external_kernel pytest marker for tests that require kernels to download from NAIF Parameterize the use_test_metekernel deocorator to enable using alternate metakernel templates Add metekernel template that includes de440s.bsp * Add requests to optional test requirements * Try modifying github action to only run external_kernel tests on ubuntu python3.9 * fix test.yml syntax * fix test.yml syntax - trying again * reorder if statement in kernel download logic * Fix typo Co-authored-by: Greg Lucas * fix typo --------- Co-authored-by: Greg Lucas --- .github/workflows/test.yml | 8 +- .gitignore | 4 + imap_processing/tests/conftest.py | 223 +++++++++++++----- .../imap_ena_sim_metakernel.template | 4 + ...mplate => imap_simple_metakernel.template} | 0 imap_processing/tests/spice/test_geometry.py | 8 + poetry.lock | 6 +- pyproject.toml | 6 +- 8 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 imap_processing/tests/spice/test_data/imap_ena_sim_metakernel.template rename imap_processing/tests/spice/test_data/{imap_test_metakernel.template => imap_simple_metakernel.template} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1aee0dff1..2545afc2a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,9 +39,15 @@ jobs: - name: Testing id: test + env: + RUN_EXTERNAL_KERNEL_MARK: ${{ contains(matrix.os, 'ubuntu') && matrix.python-version == '3.9' }} run: | # Ignore the network marks from the remote test environment - poetry run pytest --color=yes --cov --cov-report=xml + if [ "$RUN_EXTERNAL_KERNEL_MARK" = "true" ]; then + poetry run pytest --color=yes --cov --cov-report=xml + else + poetry run pytest --color=yes --cov --cov-report=xml -m "not external_kernel" + fi - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index 118b08b41..eccb14f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,7 @@ cython_debug/ # Data that is downloaded data/ + +# Ignore specific SPICE kernels that get downloaded from NAIF automatically for tests +# marked with @pytest.mark.external_kernel +**/de440*.bsp diff --git a/imap_processing/tests/conftest.py b/imap_processing/tests/conftest.py index c2e2cb57e..3e00a8358 100644 --- a/imap_processing/tests/conftest.py +++ b/imap_processing/tests/conftest.py @@ -1,11 +1,14 @@ """Global pytest configuration for the package.""" +import logging import os import re +import time import imap_data_access import numpy as np import pytest +import requests import spiceypy as spice from imap_processing import imap_module_directory @@ -36,6 +39,60 @@ def _autoclear_spice(): spice.kclear() +@pytest.fixture(scope="session") +def _download_de440s(spice_test_data_path): + """This fixture downloads the de440s.bsp kernel into the + tests/spice/test_data directory if it does not already exist there. The + fixture is not intended to be used directly. It is automatically added to + tests marked with "external_kernel" in the hook below.""" + logger = logging.getLogger(__name__) + kernel_url = ( + "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp" + ) + kernel_name = kernel_url.split("/")[-1] + local_filepath = spice_test_data_path / kernel_name + + if local_filepath.exists(): + return + allowed_attempts = 3 + for attempt_number in range(allowed_attempts): + try: + with requests.get(kernel_url, stream=True, timeout=30) as r: + r.raise_for_status() + with open(local_filepath, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + logger.info("Cached kernel file to %s", local_filepath) + break + except requests.exceptions.RequestException as error: + logger.info(f"Request failed. {error}") + if attempt_number < allowed_attempts: + logger.info( + f"Trying again, retries left " + f"{allowed_attempts - attempt_number}, " + f"Exception: {error}" + ) + time.sleep(1) + else: + logger.error( + f"Failed to download file after {allowed_attempts} " + f"attempts, Final Error: {error}" + ) + raise + + +def pytest_collection_modifyitems(items): + """ + The use of this hook allows modification of test `Items` after tests have + been collected. In this case, it automatically adds the _download_de440s + fixture to any test marked with the `external_kernel`. + https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_collection_modifyitems + """ + for item in items: + if item.get_closest_marker("external_kernel") is not None: + item.fixturenames.append("_download_de440s") + + @pytest.fixture(scope="session") def spice_test_data_path(imap_tests_path): return imap_tests_path / "spice/test_data" @@ -71,8 +128,63 @@ def monkeypatch_session(): m.undo() +def make_metakernel_from_kernels(metakernel, kernels): + """Helper function that writes a test metakernel from a list of filenames""" + with open(metakernel, "w") as mk: + mk.writelines( + [ + "\n", + "\\begintext\n", + "\n", + "This is a temporary metakernel for imap_processing" + " unit and integration testing.\n", + "\n", + "\\begindata\n", + "\n", + "KERNELS_TO_LOAD = (\n", + ] + ) + # Put single quotes around every kernel name + kernels_with_quotes = [" '" + kern + "'" for kern in kernels] + # Add a comma and EOL to the end of each kernel path except the last. + formatted_kernels = [kern + ",\n" for kern in kernels_with_quotes[0:-1]] + # Add ')' to the last kernel + formatted_kernels.append(kernels_with_quotes[-1] + "\n)\n\n") + mk.writelines(formatted_kernels) + + +def get_test_kernels_to_load(template_path, kernel_dir_path): + """ + Helper function for grabbing a list of kernel filenames from the test + metakernel template. This is necessary in order to get absolute paths on + any system. Formats the absolute paths using the test data path fixture + value. + """ + kernels_to_load = [] + max_line_length = 80 + with open(template_path) as mk: + for k in mk: + kernel = k.rstrip("\n").format( + **{"SPICE_TEST_DATA_PATH": str(kernel_dir_path.absolute())} + ) + while len(kernel) > 0: + if len(kernel) <= max_line_length: + kernels_to_load.append(kernel) + break + else: + slash_positions = np.array( + [m.start() for m in re.finditer("/", kernel)] + ) + stop_idx = ( + slash_positions[slash_positions < max_line_length - 1].max() + 1 + ) + kernels_to_load.append(kernel[0:stop_idx] + "+") + kernel = kernel[stop_idx:] + return kernels_to_load + + @pytest.fixture(scope="session", autouse=True) -def use_test_metakernel(monkeypatch_session, tmpdir_factory, spice_test_data_path): +def session_test_metakernel(monkeypatch_session, tmpdir_factory, spice_test_data_path): """Generate a metakernel from the template metakernel by injecting the local path into the metakernel and set the SPICE_METAKERNEL environment variable. @@ -86,70 +198,65 @@ def use_test_metakernel(monkeypatch_session, tmpdir_factory, spice_test_data_pat variable as needed. Use the `metakernel_path_not_set` fixture in tests that need to override the environment variable. """ - - def make_metakernel_from_kernels(metakernel, kernels): - """Helper function that writes a test metakernel from a list of filenames""" - with open(metakernel, "w") as mk: - mk.writelines( - [ - "\n", - "\\begintext\n", - "\n", - "This is a temporary metakernel for imap_processing" - " unit and integration testing.\n", - "\n", - "\\begindata\n", - "\n", - "KERNELS_TO_LOAD = (\n", - ] - ) - # Put single quotes around every kernel name - kernels_with_quotes = [" '" + kern + "'" for kern in kernels] - # Add a comma and EOL to the end of each kernel path except the last. - formated_kernels = [kern + ",\n" for kern in kernels_with_quotes[0:-1]] - # Add ')' to the last kernel - formated_kernels.append(kernels_with_quotes[-1] + "\n)\n\n") - mk.writelines(formated_kernels) - - def get_test_kernels_to_load(): - """ - Helper function for grabbing a list of kernel filenames from the test - metakernel template. This is necessary in order to get absolute paths on - any system. Formats the absolute paths using the test data path fixture - value. - """ - test_metakernel = spice_test_data_path / "imap_test_metakernel.template" - kernels_to_load = [] - max_line_length = 80 - with open(test_metakernel) as mk: - for k in mk: - kernel = k.rstrip("\n").format( - **{"SPICE_TEST_DATA_PATH": str(spice_test_data_path.absolute())} - ) - while len(kernel) > 0: - if len(kernel) <= max_line_length: - kernels_to_load.append(kernel) - break - else: - slash_positions = np.array( - [m.start() for m in re.finditer("/", kernel)] - ) - stop_idx = ( - slash_positions[slash_positions < max_line_length - 1].max() - + 1 - ) - kernels_to_load.append(kernel[0:stop_idx] + "+") - kernel = kernel[stop_idx:] - return kernels_to_load - + template_path = spice_test_data_path / "imap_simple_metakernel.template" + kernels_to_load = get_test_kernels_to_load(template_path, spice_test_data_path) metakernel_path = tmpdir_factory.mktemp("spice") / "imap_2024_v001.tm" - kernels_to_load = get_test_kernels_to_load() make_metakernel_from_kernels(metakernel_path, kernels_to_load) monkeypatch_session.setenv("SPICE_METAKERNEL", str(metakernel_path)) yield str(metakernel_path) spice.kclear() +@pytest.fixture() +def use_test_metakernel( + request, monkeypatch, spice_test_data_path, session_test_metakernel +): + """ + Generate a metakernel and set SPICE_METAKERNEL environment variable. + + This fixture generates a metakernel in the directory pointed to by + `imap_data_access.config["DATA_DIR"]` and sets the SPICE_METAKERNEL + environment variable to point to it for use by the `@ensure_spice` decorator. + The default metekernel is named "imap_simple_metakernel.template". Other + metakerels can be specified by marking the test with metakernel. See + examples below. + + Parameters + ---------- + request : fixture + monkeypatch : fixture + spice_test_data_path : fixture + session_test_metakernel : fixture + + Yields + ------ + metakernel_path : Path + + Examples + -------- + 1. Use the default metakernel template + >>> def test_my_spicey_func(use_test_metakernel): + ... pass + + 2. Specify a different metakernel template + >>> @pytest.mark.metakernel("other_template_mk.template") + ... def test_my_spicey_func(use_test_metakernel): + ... pass + """ + marker = request.node.get_closest_marker("metakernel") + if marker is None: + yield session_test_metakernel + else: + template_name = marker.args[0] + template_path = spice_test_data_path / template_name + metakernel_path = imap_data_access.config["DATA_DIR"] / "imap_2024_v001.tm" + kernels_to_load = get_test_kernels_to_load(template_path, spice_test_data_path) + make_metakernel_from_kernels(metakernel_path, kernels_to_load) + monkeypatch.setenv("SPICE_METAKERNEL", str(metakernel_path)) + yield str(metakernel_path) + spice.kclear() + + @pytest.fixture() def _unset_metakernel_path(monkeypatch): """Temporarily unsets the SPICE_METAKERNEL environment variable""" diff --git a/imap_processing/tests/spice/test_data/imap_ena_sim_metakernel.template b/imap_processing/tests/spice/test_data/imap_ena_sim_metakernel.template new file mode 100644 index 000000000..4a6ca3d7e --- /dev/null +++ b/imap_processing/tests/spice/test_data/imap_ena_sim_metakernel.template @@ -0,0 +1,4 @@ +{SPICE_TEST_DATA_PATH}/imap_sclk_0000.tsc +{SPICE_TEST_DATA_PATH}/naif0012.tls +{SPICE_TEST_DATA_PATH}/imap_spk_demo.bsp +{SPICE_TEST_DATA_PATH}/de440s.bsp \ No newline at end of file diff --git a/imap_processing/tests/spice/test_data/imap_test_metakernel.template b/imap_processing/tests/spice/test_data/imap_simple_metakernel.template similarity index 100% rename from imap_processing/tests/spice/test_data/imap_test_metakernel.template rename to imap_processing/tests/spice/test_data/imap_simple_metakernel.template diff --git a/imap_processing/tests/spice/test_geometry.py b/imap_processing/tests/spice/test_geometry.py index f1a0ae448..5dc3e145b 100644 --- a/imap_processing/tests/spice/test_geometry.py +++ b/imap_processing/tests/spice/test_geometry.py @@ -23,3 +23,11 @@ def test_imap_state(et, use_test_metakernel): np.testing.assert_array_equal(state.shape, (len(et), 6)) else: assert state.shape == (6,) + + +@pytest.mark.external_kernel() +@pytest.mark.metakernel("imap_ena_sim_metakernel.template") +def test_imap_state_ecliptic(use_test_metakernel): + """Tests retrieving IMAP state in the ECLIPJ2000 frame""" + state = imap_state(798033670) + assert state.shape == (6,) diff --git a/poetry.lock b/poetry.lock index 83c5479d1..b5a5ccce5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -1741,10 +1741,10 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [extras] dev = ["mypy", "pre-commit", "ruff"] doc = ["numpydoc", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-openapi"] -test = ["openpyxl", "pytest", "pytest-cov"] +test = ["openpyxl", "pytest", "pytest-cov", "requests"] tools = ["openpyxl", "pandas"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "f750033025b765826c827adb1142fd59444e13a985755c2a6efc3a207348a959" +content-hash = "132db1b838a111d90a1db0c336b4a4cd37d389795ea0ebea534a839eec582e2a" diff --git a/pyproject.toml b/pyproject.toml index d56531883..1ed07471e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,11 +52,12 @@ ruff = {version="==0.2.1", optional=true} sphinx = {version="*", optional=true} sphinxcontrib-openapi = {version="^0.8.3", optional=true} mypy = {version="^1.10.1", optional=true} +requests = {version = "^2.32.3", optional = true} [tool.poetry.extras] dev = ["pre-commit", "ruff", "mypy"] doc = ["numpydoc", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-openapi"] -test = ["openpyxl", "pytest", "pytest-cov"] +test = ["openpyxl", "pytest", "pytest-cov", "requests"] tools= ["openpyxl", "pandas"] [project.urls] @@ -72,6 +73,9 @@ filterwarnings = [ "ignore:Converting non-nanosecond:UserWarning:cdflib", "ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:cdflib", ] +markers = [ + "external_kernel: marks tests as requiring external SPICE kernels (deselect with '-m \"not external_kernel\"')", +] [tool.ruff]