From e12710402c00efc6a8f1c0b8abd484fe598aa7a0 Mon Sep 17 00:00:00 2001 From: Volodymyr Savchenko Date: Sat, 26 Mar 2022 09:59:02 +0100 Subject: [PATCH] extracting features for general test parametrization --- .../heasarc/tests/test_heasarc_remote.py | 2 +- .../heasarc/tests/test_heasarc_remote_isdc.py | 2 +- astroquery/utils/commons.py | 1 + astroquery/utils/testing_parametrization.py | 157 ++++++++++++++++++ docs/testing.rst | 2 +- 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 astroquery/utils/testing_parametrization.py diff --git a/astroquery/heasarc/tests/test_heasarc_remote.py b/astroquery/heasarc/tests/test_heasarc_remote.py index e6f7455762..d57cb96d64 100644 --- a/astroquery/heasarc/tests/test_heasarc_remote.py +++ b/astroquery/heasarc/tests/test_heasarc_remote.py @@ -7,7 +7,7 @@ from ...heasarc import Heasarc from ...utils import commons -from .parametrization import parametrization_local_save_remote, patch_get, MockResponse +from ...utils.testing_parametrization import parametrization_local_save_remote, patch_get, MockResponse @parametrization_local_save_remote diff --git a/astroquery/heasarc/tests/test_heasarc_remote_isdc.py b/astroquery/heasarc/tests/test_heasarc_remote_isdc.py index c43cac094a..dcfe0cfafb 100644 --- a/astroquery/heasarc/tests/test_heasarc_remote_isdc.py +++ b/astroquery/heasarc/tests/test_heasarc_remote_isdc.py @@ -6,7 +6,7 @@ from ...heasarc import Heasarc, Conf from ...utils import commons -from .parametrization import parametrization_local_save_remote, patch_get, MockResponse +from ...utils.testing_parametrization import parametrization_local_save_remote, patch_get, MockResponse @parametrization_local_save_remote diff --git a/astroquery/utils/commons.py b/astroquery/utils/commons.py index 47e6b4e210..223ce8443e 100644 --- a/astroquery/utils/commons.py +++ b/astroquery/utils/commons.py @@ -59,6 +59,7 @@ def FK4CoordGenerator(*args, **kwargs): ASTROPY_LT_4_1 = not minversion('astropy', '4.1') ASTROPY_LT_4_3 = not minversion('astropy', '4.3') ASTROPY_LT_5_0 = not minversion('astropy', '5.0') +ASTROPY_LT_5_1 = not minversion('astropy', '5.1') ASTROPY_LT_5_1 = not minversion('astropy', '5.1dev197') # Update the line above once 5.1 is released diff --git a/astroquery/utils/testing_parametrization.py b/astroquery/utils/testing_parametrization.py new file mode 100644 index 0000000000..c4aacd8944 --- /dev/null +++ b/astroquery/utils/testing_parametrization.py @@ -0,0 +1,157 @@ +import os +import glob +import hashlib +import requests +import traceback +import pytest +from .. import log + +""" +This is an attempt to reduce code duplication between remote and local tests. + +if there is test data: + runs as usual, except all tests have two versions with the same code: remote and local +else + runs remote test patched so that the test data is stored in a temporary directory. + advice is given to copy the newly generated test data into the repository +""" + + +class MockResponse: + def __init__(self, text): + self.text = text + self.content = text.encode() + + +def data_path(filename, output=False): + if output: + data_dir = os.path.join( + os.getenv("TMPDIR", "/tmp"), "astroquery-saved-data" + ) + os.makedirs(data_dir, exist_ok=True) + else: + # the function should be called from module tests. + # we need to find out which one + test_modules = [] + for fs in traceback.extract_stack(): + fs_filename_split = fs.filename.split("/") + if len(fs_filename_split) > 4 and \ + fs_filename_split[-1].startswith("test_") and \ + fs_filename_split[-2] == 'tests' and \ + fs_filename_split[-4] == 'astroquery': + test_modules.append(os.path.dirname(fs.filename)) + + if len(set(test_modules)) == 0: + raise RuntimeError('It seems that "data_path" function was called not from one of the test modules.' + 'Module-specific mock data_path can not be constructed') + elif len(set(test_modules)) > 1: + raise RuntimeError('It seems that "data_path" call stack includes several modules, ' + f'this is probably a programming issue: {test_modules}') + + test_module = test_modules[0] + + data_dir = os.path.join(test_module, "data") + + return os.path.join(data_dir, filename + ".dat") + + +def fileid_for_request(url, params): + return hashlib.md5(str((url, sorted(params.items()))).encode()).hexdigest()[:8] + + +def filename_for_request(url, params, output=False): + fileid = fileid_for_request(url, params) + return data_path(fileid, output=output) + + +def get_mockreturn(session, method, url, params=None, timeout=10, **kwargs): + """ + Finds mock response based on the the request - URL and params (not headers or method) + """ + + filename = filename_for_request(url, params) + try: + content = open(filename, "rt").read() + except FileNotFoundError: + log.error( + f'no stored mock data in {filename} for url="{url}" and params="{params}", ' + f'perhaps you need to clean test data and regenerate it? ' + f'It will be regenerated automatically if cleaned, try `rm -fv astroquery/heasarc/tests/data/* ./build`' + ) + raise + + return MockResponse(content) + + +def save_response_of_get(session, method, url, params=None, timeout=10, **kwargs): + """ + Performs remote requests, and saves the response in a unique filename, constructed from URL and params (not headers or method) + """ + + # _original_request is a monkeypatch-added attribute in patch_get + text = requests.Session._original_request( + session, method, url, params=params, timeout=timeout + ).text + + filename = filename_for_request(url, params, output=True) + + with open(filename, "wt") as f: + log.info(f'saving output to {filename} for url="{url}" and params="{params}"') + # TODO: add doc and a reference to it here + log.warning( + f"you may want to run `cp -fv {os.path.dirname(filename)}/* astroquery/heasarc/tests/data/; rm -rfv build`" + "`git add astroquery/heasarc/tests/data/*`." + ) + f.write(text) + + return MockResponse(text) + + +@pytest.fixture(autouse=True) +def patch_get(request): + """ + If the mode is not remote, patch `requests.Session` to either return saved local data or run save data new local data + TODO: add reference to doc + """ + mode = request.param + mp = request.getfixturevalue("monkeypatch") + + if mode != "remote": + requests.Session._original_request = requests.Session.request + mp.setattr( + requests.Session, + "request", + {"save": save_response_of_get, "local": get_mockreturn}[mode], + ) + + mp.assume_fileid_for_request = lambda patched_fileid_for_request: \ + mp.setattr('astroquery.utils.testing_parametrization.fileid_for_request', patched_fileid_for_request) + + mp.reset_default_fileid_for_request = lambda: \ + mp.delattr('astroquery.utils.testing_parametrization.fileid_for_request') + + return mp + + +def have_mock_data(): + try: + return len(glob.glob(data_path("*"))) > 0 + except RuntimeError: + #TODO: this is questionable + return False + + +parametrization_local_save_remote = pytest.mark.parametrize( + "patch_get", [ + pytest.param("local", marks=[ + pytest.mark.skipif(not have_mock_data(), + reason=f"No test data found in {'/'.join(data_path('').split('/')[-4:])}. If remote_data is allowed, we'll generate some.")]), + pytest.param("save", marks=[ + pytest.mark.remote_data, + pytest.mark.skipif(have_mock_data(), + reason="found some test data: please delete them to save again.")]), + pytest.param("remote", marks=[ + pytest.mark.remote_data, + pytest.mark.skipif(not have_mock_data(), + reason="No test data found, [save] will run remote tests and save data.")])], + indirect=True) diff --git a/docs/testing.rst b/docs/testing.rst index f273544556..45c22273d8 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -67,7 +67,7 @@ object within the ``MockResponse`` class: .. code-block:: python - def get_mockreturn(url, params=None, timeout=10): + def get_mockreturn(session, method, url, params=None, timeout=10, **kwargs): filename = data_path(DATA_FILES['votable']) content = open(filename, 'r').read() return MockResponse(content)