diff --git a/.github/workflows/test_package.yml b/.github/workflows/test_package.yml index 4655d8c..9fbbfa5 100644 --- a/.github/workflows/test_package.yml +++ b/.github/workflows/test_package.yml @@ -41,7 +41,10 @@ jobs: cd .. pip install . cd .. - pip install . + pip install '.[tests]' + + - name: Test offline unit and CLI behavior + run: python -m pytest -q -rs tests -m 'not network and not integration' # this just check the scripts are accessible after the install # the scripts are not run or checked for correct behaviour diff --git a/.github/workflows/test_processing.yml b/.github/workflows/test_processing.yml index 43902c9..6380592 100644 --- a/.github/workflows/test_processing.yml +++ b/.github/workflows/test_processing.yml @@ -1,49 +1,39 @@ name: test_convert_scripts -on: +on: workflow_dispatch: - pull_request: - branches: - - main - paths-ignore: - - "**.md" - - "**.txt" - - "**.cff" - - 'docs/**' - - '.gitignore' - push: - branches: - - main + inputs: + script: + description: Script recipe to run (downloads and processes large datasets) + required: true + type: choice + options: + - convert_fendl + - convert_jeff32 + - convert_jeff33 + - convert_endf + - convert_tendl + - convert_tendl_rand + - download_endf + - download_tendl + - download_chain + - add_branching_ratios + - reduce_chain jobs: run_scripts: runs-on: ubuntu-latest + timeout-minutes: 360 steps: - uses: actions/checkout@v5 - with: - fetch-depth: 0 # OR "2" -> To retrieve the preceding commit. - - - name: Get specific changed files - id: changed-files-specific - uses: tj-actions/changed-files@v47 - with: - files: | - src/openmc_data/convert/convert_*.py - src/openmc_data/other/convert_tendl_rand.py - src/openmc_data/download/download_*.py - src/openmc_data/depletion/add_branching_ratios.py - src/openmc_data/depletion/reduce_chain.py - src/openmc_data/utils.py - name: Set up Python - if: steps.changed-files-specific.outputs.any_changed == 'true' uses: actions/setup-python@v6 with: python-version: 3.12 - name: Install testing dependencies - if: steps.changed-files-specific.outputs.any_changed == 'true' run: | sudo apt-get update sudo apt-get install libhdf5-serial-dev @@ -59,66 +49,57 @@ jobs: cd .. pip install . - # convert scripts that require mcnp nuclear data are not tested when the - # contents change as the CI doesn't contain that nuclear data. + # Convert scripts that require MCNP nuclear data are not tested because + # the CI doesn't contain that nuclear data. # convert_lib80x.py convert_mcnp70.py convert_mcnp71.py files are not tested here - name: test default convert_fendl - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/convert/convert_fendl.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_fendl' run: | - echo "convert_fendl.py file has been modified." convert_fendl --cleanup # this running convert_jeff32 in with default arguments produces 49GB of # data. Hence the use of arguments to process just one temperature - name: test default convert_jeff32 - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/convert/convert_jeff32.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_jeff32' run: | - echo "convert_jeff32.py file has been modified." convert_jeff32 -r 3.2 -t 293 --cleanup - name: test default convert_jeff33 - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/convert/convert_jeff33.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_jeff33' run: | - echo "convert_jeff33.py file has been modified." convert_jeff33 -r 3.3 -t 293 --cleanup - name: test default convert_endf - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/convert/convert_endf.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_endf' run: | - echo "convert_endf.py file has been modified." convert_endf --cleanup - name: test default convert_tendl - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/convert/convert_tendl.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_tendl' run: | - echo "convert_tendl.py file has been modified." convert_tendl --cleanup - name: test default convert_tendl_rand - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/other/convert_tendl_rand.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'convert_tendl_rand' run: | - echo "convert_tendl_rand.py file has been modified." convert_tendl_rand --nuclides O16 --cleanup # download scripts - name: test default download_endf - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/download/download_endf.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'download_endf' run: | - echo "download_endf.py file has been modified." download_endf - name: test default download_tendl - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/download/download_tendl.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'download_tendl' run: | - echo "download_tendl.py file has been modified." download_tendl -r 2019 download_tendl -r 2021 - name: test default download_chain - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/download/download_chain.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'download_chain' run: | - echo "download_chain.py file has been modified." download_chain -l endf -r b7.1 -b None download_chain -l endf -r b8.0 -b None download_chain -l endf -r b7.1 -b SFR @@ -128,16 +109,14 @@ jobs: download_chain -l endf -r b8.0 -b PWR - name: test default add_branching_ratios - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/depletion/add_branching_ratios.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'add_branching_ratios' run: | - echo "add_branching_ratios.py file has been modified." download_chain -l endf -r b8.0 add_branching_ratios -i chain-endf-b8.0.xml -o chain_endf_b8.0_sfr.xml -b sfr add_branching_ratios -i chain-endf-b8.0.xml -o chain_endf_b8.0_pwr.xml -b pwr - name: test default reduce_chain - if: contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/depletion/reduce_chain.py') || contains(steps.changed-files-specific.outputs.modified_files, 'src/openmc_data/utils.py') + if: inputs.script == 'reduce_chain' run: | - echo "reduce_chain.py file has been modified." download_chain -l endf -r b8.0 reduce_chain -i chain-endf-b8.0.xml -o chain_endf_b8.0_sfr.xml -hl 1e15 diff --git a/.github/workflows/test_urls.yml b/.github/workflows/test_urls.yml index c66703b..9b801e6 100644 --- a/.github/workflows/test_urls.yml +++ b/.github/workflows/test_urls.yml @@ -30,23 +30,26 @@ jobs: python-version: 3.12 - name: Install testing dependencies - run: | - sudo apt-get update - sudo apt-get install libhdf5-serial-dev - git clone --single-branch --branch develop --depth 1 https://github.com/openmc-dev/openmc.git - cd openmc - mkdir build - cd build - cmake .. - make -j2 - sudo make -j2 install - cd .. - pip install . - cd .. - pip install .[tests] - - # the -s allows normal print statements in the url testing to be seen in - # the pytest output - - name: test urls - run: | - pytest -v -s tests/test_urls.py + run: python -m pip install '.[tests]' + + - name: Test URL registry and HTTP helpers offline + run: python -m pytest -q -rs tests/test_urls.py -m 'not network and not integration' + + live_urls: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.12 + + - name: Install testing dependencies + run: python -m pip install '.[tests]' + + # External failures remain failures; this health check is not a PR gate. + - name: Test live URLs with explicit skip reasons + run: python -m pytest -v -rs tests/test_urls.py -m network diff --git a/.gitignore b/.gitignore index 58f02d3..b2e26f6 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.pytest-tmp/ # Translations *.mo diff --git a/README.md b/README.md index 26b6a9a..609d98d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,34 @@ The package is distributed on [PYPI](https://pypi.org/project/openmc-data/) and pip install openmc_data ``` +# Testing + +Install the test dependencies with `python -m pip install '.[tests]'`, then run +the offline suite with `python -m pytest -q -rs tests -m 'not network and not integration'`. +Tests stay in the flat [tests](tests) directory: [test_utils.py](tests/test_utils.py) +covers shared utilities, [test_urls.py](tests/test_urls.py) covers registry +validation, HTTP helper regressions and marked live URL checks, and script-specific +tests belong in files named test_<script>.py alongside them. Use pytest's +`tmp_path` for generated test artifacts rather than writing into the repository. + +The `network` marker identifies live external URL checks; `integration` identifies +data download and processing tests. Pull requests and pushes run offline tests +and CLI help checks in [test_package.yml](.github/workflows/test_package.yml), and +offline URL syntax and repository-file checks in +[test_urls.yml](.github/workflows/test_urls.yml), without compiling OpenMC for the +URL jobs. Live URL checks run nightly or manually in a separate job of that same +workflow. Full, multi-GB processing runs only through the required script choice +in the manual [test_processing.yml](.github/workflows/test_processing.yml) workflow. + +Exactly one URL is explicitly **not tested over HTTP** because of its Cloudflare +challenge: +. +Its syntax is still checked offline. Other HTTP errors, including IAEA and +Cloudflare 403 responses, remain failures, not skips or expected failures. +Nightly/manual external health checks may remain red until blocked IAEA and +obsolete OECD URLs are restored; passing offline checks does not establish that +downloads are available. + # Usage Once installed several scripts are available in your terminal that are able to diff --git a/pyproject.toml b/pyproject.toml index 429c9fc..942fab3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,13 @@ tests = [ "requests" ] +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "network: live external URL checks (scheduled or manual, not a pull request gate)", + "integration: data download and processing tests (manual only)", +] + [project.urls] "Homepage" = "https://github.com/fusion-energy/openmc_data" "Bug Tracker" = "https://github.com/fusion-energy/openmc_data/issues" diff --git a/tests/test_urls.py b/tests/test_urls.py index 1c08b67..25002de 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -1,18 +1,42 @@ import time from pathlib import Path +from unittest.mock import MagicMock +from urllib.parse import urlsplit +import pytest import requests -from openmc_data import all_release_details, all_h5_release_details, all_chain_release_details +from openmc_data import ( + all_release_details, + all_h5_release_details, + all_chain_release_details, + all_decay_release_details, +) # urls that serve a file kept in this repository, see check_url REPO_RAW_PREFIX = "https://github.com/openmc-data-storage/openmc_data/raw/main/" REPO_ROOT = Path(__file__).resolve().parent.parent +UNTESTED_URL = "https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip" -def check_url(url, attempts=4): +def validate_url(url): + """Check registry syntax and repository files without making HTTP requests.""" + assert isinstance(url, str) and url, f"Expected a nonempty URL, got {url!r}" + assert not any(char.isspace() for char in url), f"{url!r} contains whitespace" + parsed = urlsplit(url) + assert parsed.scheme in ('http', 'https'), f"{url!r} must use HTTP or HTTPS" + assert parsed.hostname, f"{url!r} has no hostname" + assert parsed.path and not parsed.path.endswith('/'), f"{url!r} has no file path" + + if url.startswith(REPO_RAW_PREFIX): + relative_path = parsed.path[len(urlsplit(REPO_RAW_PREFIX).path):] + local_path = REPO_ROOT / relative_path + assert local_path.is_file(), f"{url} points at {local_path} which is missing" + + +def check_url(url, attempts=4, as_browser=False): """Asserts that a url is usable, retrying transient failures. Files that are stored in this repository are checked on disk rather than @@ -21,63 +45,228 @@ def check_url(url, attempts=4): Looking for the file on disk tests the same thing, that the url in the registry corresponds to a real file. - Every other url is fetched. GitHub and some of the data hosts answer 429 or - an occasional 404 when this suite requests hundreds of files in one job, so - a failure is retried with a growing wait before it is treated as real. + Only UNTESTED_URL is skipped because of its Cloudflare challenge. Every + other URL is fetched with a bounded, streamed GET; HTTP failures, including + other Cloudflare challenges, fail. Each URL is a separate test so a blocked + host cannot prevent the other libraries from being checked. """ + if url == UNTESTED_URL: + pytest.skip(f"{url}: Cloudflare challenge blocks the automated check; this URL is not tested") + if url.startswith(REPO_RAW_PREFIX): - local_path = REPO_ROOT / url[len(REPO_RAW_PREFIX):] - assert local_path.is_file(), f"{url} points at {local_path} which is missing" + validate_url(url) return status_code = None + error = None for attempt in range(attempts): if attempt: time.sleep(2**attempt) - status_code = requests.get(url, stream=True).status_code - if status_code == 200: - return - assert status_code == 200, ( - f"{url} returned {status_code} on {attempts} attempts" - ) - + headers = {'User-Agent': 'Mozilla/5.0'} if as_browser else {} + try: + with requests.get(url, stream=True, headers=headers, timeout=(10, 30)) as response: + status_code = response.status_code + if status_code in (200, 206): + return + error = f"HTTP {status_code}" + except requests.RequestException as exc: + error = str(exc) + pytest.fail(f"{url} returned {error} on {attempts} attempts") -def test_convert_urls(): - """Cycles through all the urls in each nuclear data library and checks - that they return a status 200 code (success)""" +def convert_urls(): for library, releases in all_release_details.items(): for release, particles in releases.items(): for particle, file_types in particles.items(): - for file_types, value in file_types.items(): + for file_type, value in file_types.items(): for file in value['compressed_files']: - url = value['base_url'] + file - # printing output so that in the event of a failure the - # failing url can be identified - print(library, release, particle, url) - check_url(url) + url = value['base_url'] + file + value.get('url_suffix', '') + yield pytest.param(url, value.get('as_browser', False), + id=f"{library}-{release}-{particle}-{file_type}-{file}") + +@pytest.mark.network +@pytest.mark.parametrize('url,as_browser', list(convert_urls())) +def test_convert_urls(url, as_browser): + check_url(url, as_browser=as_browser) -def test_h5_urls(): - """Cycles through all the h5 urls in each nuclear data library and checks - that they return a status 200 code (success)""" +def h5_urls(): for library, releases in all_h5_release_details.items(): for release, particles in releases.items(): for particle, value in particles.items(): for file in value['compressed_files']: url = value['base_url'] + file - print(library, release, particle, url) - check_url(url) + yield pytest.param(url, id=f"{library}-{release}-{particle}-{file}") -def test_xml_urls(): - """Cycles through all the xml urls in each nuclear data library and checks - that they return a status 200 code (success)""" +@pytest.mark.network +@pytest.mark.parametrize('url', list(h5_urls())) +def test_h5_urls(url): + check_url(url) + +def xml_urls(): for library, releases in all_chain_release_details.items(): for release, branching_ratios in releases.items(): for branching_ratio in branching_ratios: url = all_chain_release_details[library][release][branching_ratio]['chain']['url'] - print(library, release, branching_ratio, url) - check_url(url) + yield pytest.param(url, id=f"{library}-{release}-{branching_ratio}") + + +@pytest.mark.network +@pytest.mark.parametrize('url', list(xml_urls())) +def test_xml_urls(url): + check_url(url) + + +def decay_urls(): + for library, releases in all_decay_release_details.items(): + for release, particles in releases.items(): + for particle, value in particles.items(): + base_urls = value['base_url'] + if isinstance(base_urls, str): + base_urls = [base_urls] + for base_url in base_urls: + for file in value['compressed_files']: + yield pytest.param(base_url + file, + id=f"{library}-{release}-{particle}-{file}") + + +@pytest.mark.network +@pytest.mark.parametrize('url', list(decay_urls())) +def test_decay_urls(url): + check_url(url) + + +# Validate every registry entry offline, including the URL not tested over HTTP. +@pytest.mark.parametrize('url,as_browser', list(convert_urls())) +def test_convert_url_registry(url, as_browser): + validate_url(url) + + +@pytest.mark.parametrize('url', list(h5_urls()) + list(xml_urls()) + list(decay_urls())) +def test_url_registry(url): + validate_url(url) + + +# Offline regressions for the URL checker stay unmarked. +@pytest.fixture +def http_response(monkeypatch): + response = MagicMock(status_code=200, headers={}) + response.__enter__.return_value = response + get = MagicMock(return_value=response) + monkeypatch.setattr(requests, 'get', get) + monkeypatch.setattr(time, 'sleep', lambda _: None) + return get, response + + +@pytest.mark.parametrize('url', [ + 'http://example.org/data.zip', + 'https://example.org/data.zip?download=1', + UNTESTED_URL, +]) +def test_valid_url_syntax_does_not_request_http(http_response, url): + get, _ = http_response + validate_url(url) + get.assert_not_called() + + +@pytest.mark.parametrize('url', [ + '', + 'data.zip', + 'ftp://example.org/data.zip', + 'https:///data.zip', + 'https://', + 'https://example.org', + 'https://example.org/', + 'https://example.org/data/', + ' https://example.org/data.zip', + 'https://exam ple.org/data.zip', + 'https://example.org/data\t.zip', + 'https://example.org/data.zip\n', +]) +def test_malformed_registry_url_fails(url): + with pytest.raises(AssertionError): + validate_url(url) + + +@pytest.mark.parametrize('checker', [validate_url, check_url]) +@pytest.mark.parametrize('suffix', ['', '?download=1']) +def test_repository_file_is_checked_locally(monkeypatch, tmp_path, http_response, checker, suffix): + get, _ = http_response + monkeypatch.setitem(globals(), 'REPO_ROOT', tmp_path) + (tmp_path / 'chain.xml').write_text('') + checker(REPO_RAW_PREFIX + 'chain.xml' + suffix) + get.assert_not_called() + + +@pytest.mark.parametrize('checker', [validate_url, check_url]) +def test_missing_repository_file_fails(monkeypatch, tmp_path, http_response, checker): + get, _ = http_response + monkeypatch.setitem(globals(), 'REPO_ROOT', tmp_path) + with pytest.raises(AssertionError, match='missing.xml.*missing'): + checker(REPO_RAW_PREFIX + 'missing.xml') + get.assert_not_called() + + +@pytest.mark.parametrize('status', [200, 206]) +def test_success_closes_response(http_response, status): + get, response = http_response + response.status_code = status + check_url('https://example.org/data', as_browser=True) + get.assert_called_once_with('https://example.org/data', stream=True, + headers={'User-Agent': 'Mozilla/5.0'}, timeout=(10, 30)) + response.__exit__.assert_called_once() + + +def test_only_exact_fendl_url_is_not_tested(http_response): + get, _ = http_response + with pytest.raises(pytest.skip.Exception, match='Cloudflare challenge.*not tested'): + check_url('https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip') + get.assert_not_called() + + +@pytest.mark.parametrize('url', [ + 'https://example.org/data', + 'https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2b-neutron-endf.zip', + 'https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-ace.zip', + 'https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip?download=1', + 'https://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip/', + 'https://www-nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip', + 'http://nds.iaea.org/fendl/data/neutron/fendl-FENDL-3.2c-neutron-endf.zip', + 'https://nds.iaea.org/public/download-endf/TENDL-2019/n/n-C013.zip', +]) +def test_other_cloudflare_challenges_fail(http_response, url): + get, response = http_response + response.status_code = 403 + response.headers = {'cf-mitigated': 'challenge'} + with pytest.raises(pytest.fail.Exception, match='HTTP 403'): + check_url(url, attempts=2) + assert get.call_count == 2 + assert response.__exit__.call_count == 2 + + +@pytest.mark.parametrize('status', [403, 404, 429, 500]) +def test_ordinary_http_errors_fail(http_response, status): + get, response = http_response + response.status_code = status + with pytest.raises(pytest.fail.Exception, match=f'HTTP {status}'): + check_url('https://example.org/data', attempts=2) + assert get.call_count == 2 + assert response.__exit__.call_count == 2 + + +def test_network_errors_fail(http_response): + get, _ = http_response + get.side_effect = requests.Timeout('timed out') + with pytest.raises(pytest.fail.Exception, match='timed out'): + check_url('https://example.org/data', attempts=2) + assert get.call_count == 2 + + +def test_transient_failure_is_retried(http_response): + get, response = http_response + get.side_effect = [requests.Timeout('timed out'), response] + check_url('https://example.org/data') + assert get.call_count == 2