diff --git a/CHANGES.rst b/CHANGES.rst index 10bc7e07f..b81f09d97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,6 +33,7 @@ The ASDF Standard is at v1.6.0 - move asdf.types.format_tag to asdf.testing.helpers.format_tag [#1433] - Deprecate AsdfExtenion, AsdfExtensionList, BuiltinExtension [#1429] - Add AsdfDeprecationWarning to asdf_extensions entry point [#1361] +- Deprecate asdf.tests.helpers [#1440] 2.14.3 (2022-12-15) ------------------- diff --git a/asdf/commands/tests/test_defragment.py b/asdf/commands/tests/test_defragment.py index de72f2c46..64df1708f 100644 --- a/asdf/commands/tests/test_defragment.py +++ b/asdf/commands/tests/test_defragment.py @@ -6,7 +6,7 @@ import asdf from asdf import AsdfFile from asdf.commands import main -from asdf.tests.helpers import assert_tree_match, get_file_sizes +from asdf.tests._helpers import assert_tree_match, get_file_sizes def _test_defragment(tmpdir, codec): diff --git a/asdf/commands/tests/test_diff.py b/asdf/commands/tests/test_diff.py index 2ac65da8f..b6b2025e1 100644 --- a/asdf/commands/tests/test_diff.py +++ b/asdf/commands/tests/test_diff.py @@ -4,7 +4,7 @@ import pytest from asdf.commands import diff, main -from asdf.tests import helpers +from asdf.tests import _helpers as helpers from . import data as test_data diff --git a/asdf/commands/tests/test_exploded.py b/asdf/commands/tests/test_exploded.py index f24a2603d..1a929dbbe 100644 --- a/asdf/commands/tests/test_exploded.py +++ b/asdf/commands/tests/test_exploded.py @@ -5,7 +5,7 @@ import asdf from asdf import AsdfFile from asdf.commands import main -from asdf.tests.helpers import assert_tree_match, get_file_sizes +from asdf.tests._helpers import assert_tree_match, get_file_sizes def test_explode_then_implode(tmpdir): diff --git a/asdf/commands/tests/test_extract.py b/asdf/commands/tests/test_extract.py index 50207693d..7415c9bc6 100644 --- a/asdf/commands/tests/test_extract.py +++ b/asdf/commands/tests/test_extract.py @@ -14,7 +14,7 @@ del sys.modules["asdf.fits_embed"] import asdf.fits_embed -from asdf.tests.helpers import assert_tree_match +from asdf.tests._helpers import assert_tree_match def test_extract(tmpdir): diff --git a/asdf/commands/tests/test_info.py b/asdf/commands/tests/test_info.py index aa05d5a3b..249dd5c19 100644 --- a/asdf/commands/tests/test_info.py +++ b/asdf/commands/tests/test_info.py @@ -3,7 +3,7 @@ import pytest from asdf.commands import main -from asdf.tests import helpers +from asdf.tests import _helpers as helpers from . import data as test_data diff --git a/asdf/commands/tests/test_to_yaml.py b/asdf/commands/tests/test_to_yaml.py index c5e0ea196..a856ea347 100644 --- a/asdf/commands/tests/test_to_yaml.py +++ b/asdf/commands/tests/test_to_yaml.py @@ -5,7 +5,7 @@ import asdf from asdf import AsdfFile from asdf.commands import main -from asdf.tests.helpers import assert_tree_match, get_file_sizes +from asdf.tests._helpers import assert_tree_match, get_file_sizes def test_to_yaml(tmpdir): diff --git a/asdf/conftest.py b/asdf/conftest.py index 2b11722ab..2502fca2f 100644 --- a/asdf/conftest.py +++ b/asdf/conftest.py @@ -7,7 +7,7 @@ from asdf.tests.httpserver import HTTPServer, RangeHTTPServer -collect_ignore = ["asdftypes.py", "fits_embed.py", "resolver.py", "type_index.py", "types.py"] +collect_ignore = ["asdftypes.py", "fits_embed.py", "resolver.py", "type_index.py", "types.py", "tests/helpers.py"] @pytest.fixture() diff --git a/asdf/entry_points.py b/asdf/entry_points.py index 1d8dfadc5..09d543a54 100644 --- a/asdf/entry_points.py +++ b/asdf/entry_points.py @@ -80,6 +80,11 @@ def _handle_error(e): category=AsdfDeprecationWarning, message="BuiltinExtension is deprecated", ) + warnings.filterwarnings( + "ignore", + category=AsdfDeprecationWarning, + message="asdf.tests.helpers is deprecated", + ) elif entry_point.name != "builtin": warnings.warn( f"{package_name} uses the deprecated entry point {LEGACY_EXTENSIONS_GROUP}. " @@ -87,7 +92,6 @@ def _handle_error(e): "https://asdf.readthedocs.io/en/stable/asdf/extending/extensions.html", AsdfDeprecationWarning, ) - elements = entry_point.load()() except Exception as e: # noqa: BLE001 diff --git a/asdf/tags/core/tests/test_complex.py b/asdf/tags/core/tests/test_complex.py index 167d75a69..762bf7dfe 100644 --- a/asdf/tags/core/tests/test_complex.py +++ b/asdf/tags/core/tests/test_complex.py @@ -3,7 +3,7 @@ import pytest import asdf -from asdf.tests import helpers +from asdf.tests import _helpers as helpers def make_complex_asdf(string): diff --git a/asdf/tags/core/tests/test_extension_metadata.py b/asdf/tags/core/tests/test_extension_metadata.py index 33f8926a9..15450426d 100644 --- a/asdf/tags/core/tests/test_extension_metadata.py +++ b/asdf/tags/core/tests/test_extension_metadata.py @@ -1,5 +1,5 @@ import asdf -from asdf.tests import helpers +from asdf.tests import _helpers as helpers def test_extra_properties(): diff --git a/asdf/tags/core/tests/test_external_reference.py b/asdf/tags/core/tests/test_external_reference.py index 0db3d256e..b004d6cab 100644 --- a/asdf/tags/core/tests/test_external_reference.py +++ b/asdf/tags/core/tests/test_external_reference.py @@ -1,5 +1,5 @@ from asdf.tags.core.external_reference import ExternalArrayReference -from asdf.tests import helpers +from asdf.tests import _helpers as helpers def test_roundtrip_external_array(tmpdir): diff --git a/asdf/tags/core/tests/test_history.py b/asdf/tags/core/tests/test_history.py index 7a140101d..9eda0b9df 100644 --- a/asdf/tags/core/tests/test_history.py +++ b/asdf/tags/core/tests/test_history.py @@ -10,8 +10,8 @@ from asdf import util from asdf.exceptions import AsdfDeprecationWarning, AsdfWarning from asdf.tags.core import HistoryEntry -from asdf.tests import helpers -from asdf.tests.helpers import assert_no_warnings, yaml_to_asdf +from asdf.tests import _helpers as helpers +from asdf.tests._helpers import assert_no_warnings, yaml_to_asdf SCHEMA_PATH = os.path.join(os.path.dirname(helpers.__file__), "data") diff --git a/asdf/tags/core/tests/test_integer.py b/asdf/tags/core/tests/test_integer.py index efcc74d6c..c3d6c9b18 100644 --- a/asdf/tags/core/tests/test_integer.py +++ b/asdf/tags/core/tests/test_integer.py @@ -4,7 +4,7 @@ import asdf from asdf import IntegerType -from asdf.tests import helpers +from asdf.tests import _helpers as helpers # Make sure tests are deterministic random.seed(0) diff --git a/asdf/tags/core/tests/test_ndarray.py b/asdf/tags/core/tests/test_ndarray.py index dd18935fa..37c8fc8db 100644 --- a/asdf/tags/core/tests/test_ndarray.py +++ b/asdf/tags/core/tests/test_ndarray.py @@ -14,7 +14,7 @@ from asdf import util from asdf.exceptions import AsdfDeprecationWarning from asdf.tags.core import ndarray -from asdf.tests import helpers +from asdf.tests import _helpers as helpers from asdf.tests.objects import CustomTestType from . import data as test_data diff --git a/asdf/tests/_helpers.py b/asdf/tests/_helpers.py new file mode 100644 index 000000000..50a299079 --- /dev/null +++ b/asdf/tests/_helpers.py @@ -0,0 +1,499 @@ +import io +import os +import warnings +from contextlib import contextmanager +from pathlib import Path + +try: + from astropy.coordinates import ICRS +except ImportError: + ICRS = None + +try: + from astropy.coordinates.representation import CartesianRepresentation +except ImportError: + CartesianRepresentation = None + +try: + from astropy.coordinates.representation import CartesianDifferential +except ImportError: + CartesianDifferential = None + +import yaml + +import asdf +from asdf import generic_io, versioning +from asdf.asdf import AsdfFile, get_asdf_library_info +from asdf.block import Block +from asdf.constants import YAML_TAG_PREFIX +from asdf.exceptions import AsdfConversionWarning, AsdfDeprecationWarning +from asdf.extension import _legacy +from asdf.tags.core import AsdfObject +from asdf.versioning import ( + AsdfVersion, + asdf_standard_development_version, + get_version_map, + split_tag_version, + supported_versions, +) + +from .httpserver import RangeHTTPServer + +try: + from pytest_remotedata.disable_internet import INTERNET_OFF +except ImportError: + INTERNET_OFF = False + + +__all__ = [ + "get_test_data_path", + "assert_tree_match", + "assert_roundtrip_tree", + "yaml_to_asdf", + "get_file_sizes", + "display_warnings", +] + + +def get_test_data_path(name, module=None): + if module is None: + from . import data as test_data + + module = test_data + + module_root = Path(module.__file__).parent + + if name is None or name == "": + return str(module_root) + + return str(module_root / name) + + +def assert_tree_match(old_tree, new_tree, ctx=None, funcname="assert_equal", ignore_keys=None): + """ + Assert that two ASDF trees match. + + Parameters + ---------- + old_tree : ASDF tree + + new_tree : ASDF tree + + ctx : ASDF file context + Used to look up the set of types in effect. + + funcname : `str` or `callable` + The name of a method on members of old_tree and new_tree that + will be used to compare custom objects. The default of + ``assert_equal`` handles Numpy arrays. + + ignore_keys : list of str + List of keys to ignore + """ + seen = set() + + if ignore_keys is None: + ignore_keys = ["asdf_library", "history"] + ignore_keys = set(ignore_keys) + + if ctx is None: + version_string = str(versioning.default_version) + ctx = _legacy.default_extensions.extension_list + else: + version_string = ctx.version_string + + def recurse(old, new): + if id(old) in seen or id(new) in seen: + return + seen.add(id(old)) + seen.add(id(new)) + + old_type = ctx._type_index.from_custom_type(type(old), version_string) + new_type = ctx._type_index.from_custom_type(type(new), version_string) + + if ( + old_type is not None + and new_type is not None + and old_type is new_type + and (callable(funcname) or hasattr(old_type, funcname)) + ): + if callable(funcname): + funcname(old, new) + else: + getattr(old_type, funcname)(old, new) + + elif isinstance(old, dict) and isinstance(new, dict): + assert {x for x in old if x not in ignore_keys} == {x for x in new if x not in ignore_keys} + for key in old: + if key not in ignore_keys: + recurse(old[key], new[key]) + elif isinstance(old, (list, tuple)) and isinstance(new, (list, tuple)): + assert len(old) == len(new) + for a, b in zip(old, new): + recurse(a, b) + # The astropy classes CartesianRepresentation, CartesianDifferential, + # and ICRS do not define equality in a way that is meaningful for unit + # tests. We explicitly compare the fields that we care about in order + # to enable our unit testing. It is possible that in the future it will + # be necessary or useful to account for fields that are not currently + # compared. + elif CartesianRepresentation is not None and isinstance(old, CartesianRepresentation): + assert old.x == new.x + assert old.y == new.y + assert old.z == new.z + elif CartesianDifferential is not None and isinstance(old, CartesianDifferential): + assert old.d_x == new.d_x + assert old.d_y == new.d_y + assert old.d_z == new.d_z + elif ICRS is not None and isinstance(old, ICRS): + assert old.ra == new.ra + assert old.dec == new.dec + else: + assert old == new + + recurse(old_tree, new_tree) + + +def assert_roundtrip_tree(*args, **kwargs): + """ + Assert that a given tree saves to ASDF and, when loaded back, + the tree matches the original tree. + + tree : ASDF tree + + tmp_path : `str` or `pathlib.Path` + Path to temporary directory to save file + + tree_match_func : `str` or `callable` + Passed to `assert_tree_match` and used to compare two objects in the + tree. + + raw_yaml_check_func : callable, optional + Will be called with the raw YAML content as a string to + perform any additional checks. + + asdf_check_func : callable, optional + Will be called with the reloaded ASDF file to perform any + additional checks. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=AsdfConversionWarning) + _assert_roundtrip_tree(*args, **kwargs) + + +def _assert_roundtrip_tree( + tree, + tmp_path, + *, + asdf_check_func=None, + raw_yaml_check_func=None, + write_options=None, + init_options=None, + extensions=None, + tree_match_func="assert_equal", +): + write_options = {} if write_options is None else write_options + init_options = {} if init_options is None else init_options + + fname = os.path.join(str(tmp_path), "test.asdf") + + # First, test writing/reading a BytesIO buffer + buff = io.BytesIO() + AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) + assert not buff.closed + buff.seek(0) + with asdf.open(buff, mode="rw", extensions=extensions) as ff: + assert not buff.closed + assert isinstance(ff.tree, AsdfObject) + assert "asdf_library" in ff.tree + assert ff.tree["asdf_library"] == get_asdf_library_info() + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + + buff.seek(0) + ff = AsdfFile(extensions=extensions, **init_options) + content = AsdfFile._open_impl(ff, buff, mode="r", _get_yaml_content=True) + buff.close() + # We *never* want to get any raw python objects out + assert b"!!python" not in content + assert b"!core/asdf" in content + assert content.startswith(b"%YAML 1.1") + if raw_yaml_check_func: + raw_yaml_check_func(content) + + # Then, test writing/reading to a real file + ff = AsdfFile(tree, extensions=extensions, **init_options) + ff.write_to(fname, **write_options) + with asdf.open(fname, mode="rw", extensions=extensions) as ff: + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + + # Make sure everything works without a block index + write_options["include_block_index"] = False + buff = io.BytesIO() + AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) + assert not buff.closed + buff.seek(0) + with asdf.open(buff, mode="rw", extensions=extensions) as ff: + assert not buff.closed + assert isinstance(ff.tree, AsdfObject) + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + + # Now try everything on an HTTP range server + if not INTERNET_OFF: + server = RangeHTTPServer() + try: + ff = AsdfFile(tree, extensions=extensions, **init_options) + ff.write_to(os.path.join(server.tmpdir, "test.asdf"), **write_options) + with asdf.open(server.url + "test.asdf", mode="r", extensions=extensions) as ff: + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + finally: + server.finalize() + + # Now don't be lazy and check that nothing breaks + with io.BytesIO() as buff: + AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) + buff.seek(0) + ff = asdf.open(buff, extensions=extensions, copy_arrays=True, lazy_load=False) + # Ensure that all the blocks are loaded + for block in ff._blocks._internal_blocks: + assert isinstance(block, Block) + assert block._data is not None + # The underlying file is closed at this time and everything should still work + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + + # Now repeat with copy_arrays=False and a real file to test mmap() + AsdfFile(tree, extensions=extensions, **init_options).write_to(fname, **write_options) + with asdf.open(fname, mode="rw", extensions=extensions, copy_arrays=False, lazy_load=False) as ff: + for block in ff._blocks._internal_blocks: + assert isinstance(block, Block) + assert block._data is not None + assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) + if asdf_check_func: + asdf_check_func(ff) + + +def yaml_to_asdf(yaml_content, yaml_headers=True, standard_version=None): + """ + Given a string of YAML content, adds the extra pre- + and post-amble to make it an ASDF file. + + Parameters + ---------- + yaml_content : string + + yaml_headers : bool, optional + When True (default) add the standard ASDF YAML headers. + + Returns + ------- + buff : io.BytesIO() + A file-like object containing the ASDF-like content. + """ + if isinstance(yaml_content, str): + yaml_content = yaml_content.encode("utf-8") + + buff = io.BytesIO() + + if standard_version is None: + standard_version = versioning.default_version + + standard_version = AsdfVersion(standard_version) + + vm = get_version_map(standard_version) + file_format_version = vm["FILE_FORMAT"] + yaml_version = vm["YAML_VERSION"] + tree_version = vm["tags"]["tag:stsci.edu:asdf/core/asdf"] + + if yaml_headers: + buff.write( + f"""#ASDF {file_format_version} +#ASDF_STANDARD {standard_version} +%YAML {yaml_version} +%TAG ! tag:stsci.edu:asdf/ +--- !core/asdf-{tree_version} +""".encode( + "ascii", + ), + ) + buff.write(yaml_content) + if yaml_headers: + buff.write(b"\n...\n") + + buff.seek(0) + return buff + + +def get_file_sizes(dirname): + """ + Get the file sizes in a directory. + + Parameters + ---------- + dirname : string + Path to a directory + + Returns + ------- + sizes : dict + Dictionary of (file, size) pairs. + """ + files = {} + for filename in os.listdir(dirname): + path = os.path.join(dirname, filename) + if os.path.isfile(path): + files[filename] = os.stat(path).st_size + return files + + +def display_warnings(_warnings): + """ + Return a string that displays a list of unexpected warnings + + Parameters + ---------- + _warnings : iterable + List of warnings to be displayed + + Returns + ------- + msg : str + String containing the warning messages to be displayed + """ + if len(_warnings) == 0: + return "No warnings occurred (was one expected?)" + + msg = "Unexpected warning(s) occurred:\n" + for warning in _warnings: + msg += f"{warning.filename}:{warning.lineno}: {warning.category.__name__}: {warning.message}\n" + return msg + + +@contextmanager +def assert_no_warnings(warning_class=None): + """ + Assert that no warnings were emitted within the context. + Requires that pytest be installed. + + Parameters + ---------- + warning_class : type, optional + Assert only that no warnings of the specified class were + emitted. + """ + import pytest + + if warning_class is None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + + yield + else: + with pytest.warns(Warning) as recorded_warnings: + yield + + assert not any(isinstance(w.message, warning_class) for w in recorded_warnings), display_warnings( + recorded_warnings, + ) + + +def assert_extension_correctness(extension): + """ + Assert that an ASDF extension's types are all correctly formed and + that the extension provides all of the required schemas. + + Parameters + ---------- + extension : asdf.AsdfExtension + The extension to validate + """ + __tracebackhide__ = True + + # locally import the deprecated Resolver and ResolverChain to avoid + # exposing it as asdf.tests.helpers.Resolver/ResolverChain + from asdf._resolver import Resolver, ResolverChain + + warnings.warn( + "assert_extension_correctness is deprecated and depends " + "on the deprecated type system. Please use the new " + "extension API: " + "https://asdf.readthedocs.io/en/stable/asdf/extending/converters.html", + AsdfDeprecationWarning, + ) + + resolver = ResolverChain( + Resolver(extension.tag_mapping, "tag"), + Resolver(extension.url_mapping, "url"), + ) + + for extension_type in extension.types: + _assert_extension_type_correctness(extension, extension_type, resolver) + + +def _assert_extension_type_correctness(extension, extension_type, resolver): + __tracebackhide__ = True + + if extension_type.yaml_tag is not None and extension_type.yaml_tag.startswith(YAML_TAG_PREFIX): + return + + if extension_type == asdf.stream.Stream: + # Stream is a special case. It was implemented as a subclass of NDArrayType, + # but shares a tag with that class, so it isn't really a distinct type. + return + + assert extension_type.name is not None, f"{extension_type.__name__} must set the 'name' class attribute" + + # Currently ExtensionType sets a default version of 1.0.0, + # but we want to encourage an explicit version on the subclass. + assert "version" in extension_type.__dict__, "{} must set the 'version' class attribute".format( + extension_type.__name__, + ) + + # check the default version + types_to_check = [extension_type] + + # Adding or updating a schema/type version might involve updating multiple + # packages. This can result in types without schema and schema without types + # for the development version of the asdf-standard. To account for this, + # don't include versioned siblings of types with versions that are not + # in one of the asdf-standard versions in supported_versions (excluding the + # current development version). + asdf_standard_versions = supported_versions.copy() + if asdf_standard_development_version in asdf_standard_versions: + asdf_standard_versions.remove(asdf_standard_development_version) + for sibling in extension_type.versioned_siblings: + tag_base, version = split_tag_version(sibling.yaml_tag) + for asdf_standard_version in asdf_standard_versions: + vm = get_version_map(asdf_standard_version) + if tag_base in vm["tags"] and AsdfVersion(vm["tags"][tag_base]) == version: + types_to_check.append(sibling) + break + + for check_type in types_to_check: + schema_location = resolver(check_type.yaml_tag) + + assert schema_location is not None, ( + f"{extension_type.__name__} supports tag, {check_type.yaml_tag}, " + "but tag does not resolve. Check the tag_mapping and uri_mapping " + f"properties on the related extension ({extension_type.__name__})." + ) + + if schema_location not in asdf.get_config().resource_manager: + try: + with generic_io.get_file(schema_location) as f: + yaml.safe_load(f.read()) + except Exception as err: # noqa: BLE001 + msg = ( + f"{extension_type.__name__} supports tag, {check_type.yaml_tag}, " + f"which resolves to schema at {schema_location}, but " + "schema cannot be read." + ) + raise AssertionError(msg) from err diff --git a/asdf/tests/helpers.py b/asdf/tests/helpers.py index 3d685f129..a4afc4b72 100644 --- a/asdf/tests/helpers.py +++ b/asdf/tests/helpers.py @@ -1,498 +1,26 @@ -import io -import os +""" +This module is deprecated. Please see `asdf.testing.helpers` +""" import warnings -from contextlib import contextmanager -from pathlib import Path -try: - from astropy.coordinates import ICRS -except ImportError: - ICRS = None +from asdf.exceptions import AsdfDeprecationWarning -try: - from astropy.coordinates.representation import CartesianRepresentation -except ImportError: - CartesianRepresentation = None +from . import _helpers -try: - from astropy.coordinates.representation import CartesianDifferential -except ImportError: - CartesianDifferential = None - -import yaml - -import asdf -from asdf import generic_io, versioning -from asdf.asdf import AsdfFile, get_asdf_library_info -from asdf.block import Block -from asdf.constants import YAML_TAG_PREFIX -from asdf.exceptions import AsdfConversionWarning, AsdfDeprecationWarning -from asdf.extension import _legacy -from asdf.tags.core import AsdfObject -from asdf.versioning import ( - AsdfVersion, - asdf_standard_development_version, - get_version_map, - split_tag_version, - supported_versions, +warnings.warn( + "asdf.tests.helpers is deprecated. Please see asdf.testing.helpers", + AsdfDeprecationWarning, ) -from .httpserver import RangeHTTPServer - -try: - from pytest_remotedata.disable_internet import INTERNET_OFF -except ImportError: - INTERNET_OFF = False - - -__all__ = [ - "get_test_data_path", - "assert_tree_match", - "assert_roundtrip_tree", - "yaml_to_asdf", - "get_file_sizes", - "display_warnings", -] - - -def get_test_data_path(name, module=None): - if module is None: - from . import data as test_data - - module = test_data - - module_root = Path(module.__file__).parent - - if name is None or name == "": - return str(module_root) - - return str(module_root / name) - - -def assert_tree_match(old_tree, new_tree, ctx=None, funcname="assert_equal", ignore_keys=None): - """ - Assert that two ASDF trees match. - - Parameters - ---------- - old_tree : ASDF tree - - new_tree : ASDF tree - - ctx : ASDF file context - Used to look up the set of types in effect. - - funcname : `str` or `callable` - The name of a method on members of old_tree and new_tree that - will be used to compare custom objects. The default of - ``assert_equal`` handles Numpy arrays. - - ignore_keys : list of str - List of keys to ignore - """ - seen = set() - - if ignore_keys is None: - ignore_keys = ["asdf_library", "history"] - ignore_keys = set(ignore_keys) - - if ctx is None: - version_string = str(versioning.default_version) - ctx = _legacy.default_extensions.extension_list - else: - version_string = ctx.version_string - - def recurse(old, new): - if id(old) in seen or id(new) in seen: - return - seen.add(id(old)) - seen.add(id(new)) - - old_type = ctx._type_index.from_custom_type(type(old), version_string) - new_type = ctx._type_index.from_custom_type(type(new), version_string) - - if ( - old_type is not None - and new_type is not None - and old_type is new_type - and (callable(funcname) or hasattr(old_type, funcname)) - ): - if callable(funcname): - funcname(old, new) - else: - getattr(old_type, funcname)(old, new) - - elif isinstance(old, dict) and isinstance(new, dict): - assert {x for x in old if x not in ignore_keys} == {x for x in new if x not in ignore_keys} - for key in old: - if key not in ignore_keys: - recurse(old[key], new[key]) - elif isinstance(old, (list, tuple)) and isinstance(new, (list, tuple)): - assert len(old) == len(new) - for a, b in zip(old, new): - recurse(a, b) - # The astropy classes CartesianRepresentation, CartesianDifferential, - # and ICRS do not define equality in a way that is meaningful for unit - # tests. We explicitly compare the fields that we care about in order - # to enable our unit testing. It is possible that in the future it will - # be necessary or useful to account for fields that are not currently - # compared. - elif CartesianRepresentation is not None and isinstance(old, CartesianRepresentation): - assert old.x == new.x - assert old.y == new.y - assert old.z == new.z - elif CartesianDifferential is not None and isinstance(old, CartesianDifferential): - assert old.d_x == new.d_x - assert old.d_y == new.d_y - assert old.d_z == new.d_z - elif ICRS is not None and isinstance(old, ICRS): - assert old.ra == new.ra - assert old.dec == new.dec - else: - assert old == new - - recurse(old_tree, new_tree) - - -def assert_roundtrip_tree(*args, **kwargs): - """ - Assert that a given tree saves to ASDF and, when loaded back, - the tree matches the original tree. - - tree : ASDF tree - - tmp_path : `str` or `pathlib.Path` - Path to temporary directory to save file - - tree_match_func : `str` or `callable` - Passed to `assert_tree_match` and used to compare two objects in the - tree. - - raw_yaml_check_func : callable, optional - Will be called with the raw YAML content as a string to - perform any additional checks. - - asdf_check_func : callable, optional - Will be called with the reloaded ASDF file to perform any - additional checks. - """ - with warnings.catch_warnings(): - warnings.filterwarnings("error", category=AsdfConversionWarning) - _assert_roundtrip_tree(*args, **kwargs) - - -def _assert_roundtrip_tree( - tree, - tmp_path, - *, - asdf_check_func=None, - raw_yaml_check_func=None, - write_options=None, - init_options=None, - extensions=None, - tree_match_func="assert_equal", -): - write_options = {} if write_options is None else write_options - init_options = {} if init_options is None else init_options - - fname = os.path.join(str(tmp_path), "test.asdf") - - # First, test writing/reading a BytesIO buffer - buff = io.BytesIO() - AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) - assert not buff.closed - buff.seek(0) - with asdf.open(buff, mode="rw", extensions=extensions) as ff: - assert not buff.closed - assert isinstance(ff.tree, AsdfObject) - assert "asdf_library" in ff.tree - assert ff.tree["asdf_library"] == get_asdf_library_info() - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) - - buff.seek(0) - ff = AsdfFile(extensions=extensions, **init_options) - content = AsdfFile._open_impl(ff, buff, mode="r", _get_yaml_content=True) - buff.close() - # We *never* want to get any raw python objects out - assert b"!!python" not in content - assert b"!core/asdf" in content - assert content.startswith(b"%YAML 1.1") - if raw_yaml_check_func: - raw_yaml_check_func(content) - - # Then, test writing/reading to a real file - ff = AsdfFile(tree, extensions=extensions, **init_options) - ff.write_to(fname, **write_options) - with asdf.open(fname, mode="rw", extensions=extensions) as ff: - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) - - # Make sure everything works without a block index - write_options["include_block_index"] = False - buff = io.BytesIO() - AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) - assert not buff.closed - buff.seek(0) - with asdf.open(buff, mode="rw", extensions=extensions) as ff: - assert not buff.closed - assert isinstance(ff.tree, AsdfObject) - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) - - # Now try everything on an HTTP range server - if not INTERNET_OFF: - server = RangeHTTPServer() - try: - ff = AsdfFile(tree, extensions=extensions, **init_options) - ff.write_to(os.path.join(server.tmpdir, "test.asdf"), **write_options) - with asdf.open(server.url + "test.asdf", mode="r", extensions=extensions) as ff: - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) - finally: - server.finalize() - - # Now don't be lazy and check that nothing breaks - with io.BytesIO() as buff: - AsdfFile(tree, extensions=extensions, **init_options).write_to(buff, **write_options) - buff.seek(0) - ff = asdf.open(buff, extensions=extensions, copy_arrays=True, lazy_load=False) - # Ensure that all the blocks are loaded - for block in ff._blocks._internal_blocks: - assert isinstance(block, Block) - assert block._data is not None - # The underlying file is closed at this time and everything should still work - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) - - # Now repeat with copy_arrays=False and a real file to test mmap() - AsdfFile(tree, extensions=extensions, **init_options).write_to(fname, **write_options) - with asdf.open(fname, mode="rw", extensions=extensions, copy_arrays=False, lazy_load=False) as ff: - for block in ff._blocks._internal_blocks: - assert isinstance(block, Block) - assert block._data is not None - assert_tree_match(tree, ff.tree, ff, funcname=tree_match_func) - if asdf_check_func: - asdf_check_func(ff) +__all__ = _helpers.__all__ # noqa: PLE0605 -def yaml_to_asdf(yaml_content, yaml_headers=True, standard_version=None): - """ - Given a string of YAML content, adds the extra pre- - and post-amble to make it an ASDF file. - - Parameters - ---------- - yaml_content : string - - yaml_headers : bool, optional - When True (default) add the standard ASDF YAML headers. - - Returns - ------- - buff : io.BytesIO() - A file-like object containing the ASDF-like content. - """ - if isinstance(yaml_content, str): - yaml_content = yaml_content.encode("utf-8") - - buff = io.BytesIO() - - if standard_version is None: - standard_version = versioning.default_version - - standard_version = AsdfVersion(standard_version) - - vm = get_version_map(standard_version) - file_format_version = vm["FILE_FORMAT"] - yaml_version = vm["YAML_VERSION"] - tree_version = vm["tags"]["tag:stsci.edu:asdf/core/asdf"] - - if yaml_headers: - buff.write( - f"""#ASDF {file_format_version} -#ASDF_STANDARD {standard_version} -%YAML {yaml_version} -%TAG ! tag:stsci.edu:asdf/ ---- !core/asdf-{tree_version} -""".encode( - "ascii", - ), - ) - buff.write(yaml_content) - if yaml_headers: - buff.write(b"\n...\n") - - buff.seek(0) - return buff - - -def get_file_sizes(dirname): - """ - Get the file sizes in a directory. - - Parameters - ---------- - dirname : string - Path to a directory - - Returns - ------- - sizes : dict - Dictionary of (file, size) pairs. - """ - files = {} - for filename in os.listdir(dirname): - path = os.path.join(dirname, filename) - if os.path.isfile(path): - files[filename] = os.stat(path).st_size - return files - - -def display_warnings(_warnings): - """ - Return a string that displays a list of unexpected warnings - - Parameters - ---------- - _warnings : iterable - List of warnings to be displayed - - Returns - ------- - msg : str - String containing the warning messages to be displayed - """ - if len(_warnings) == 0: - return "No warnings occurred (was one expected?)" - - msg = "Unexpected warning(s) occurred:\n" - for warning in _warnings: - msg += f"{warning.filename}:{warning.lineno}: {warning.category.__name__}: {warning.message}\n" - return msg - - -@contextmanager -def assert_no_warnings(warning_class=None): - """ - Assert that no warnings were emitted within the context. - Requires that pytest be installed. - - Parameters - ---------- - warning_class : type, optional - Assert only that no warnings of the specified class were - emitted. - """ - import pytest - - if warning_class is None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - yield - else: - with pytest.warns(Warning) as recorded_warnings: - yield - - assert not any(isinstance(w.message, warning_class) for w in recorded_warnings), display_warnings( - recorded_warnings, - ) - - -def assert_extension_correctness(extension): - """ - Assert that an ASDF extension's types are all correctly formed and - that the extension provides all of the required schemas. - - Parameters - ---------- - extension : asdf.AsdfExtension - The extension to validate - """ - __tracebackhide__ = True - - # locally import the deprecated Resolver and ResolverChain to avoid - # exposing it as asdf.tests.helpers.Resolver/ResolverChain - from asdf._resolver import Resolver, ResolverChain +def __getattr__(name): warnings.warn( - "assert_extension_correctness is deprecated and depends " - "on the deprecated type system. Please use the new " - "extension API: " - "https://asdf.readthedocs.io/en/stable/asdf/extending/converters.html", + "asdf.tests.helpers is deprecated. Please see asdf.testing.helpers", AsdfDeprecationWarning, ) - - resolver = ResolverChain( - Resolver(extension.tag_mapping, "tag"), - Resolver(extension.url_mapping, "url"), - ) - - for extension_type in extension.types: - _assert_extension_type_correctness(extension, extension_type, resolver) - - -def _assert_extension_type_correctness(extension, extension_type, resolver): - __tracebackhide__ = True - - if extension_type.yaml_tag is not None and extension_type.yaml_tag.startswith(YAML_TAG_PREFIX): - return - - if extension_type == asdf.stream.Stream: - # Stream is a special case. It was implemented as a subclass of NDArrayType, - # but shares a tag with that class, so it isn't really a distinct type. - return - - assert extension_type.name is not None, f"{extension_type.__name__} must set the 'name' class attribute" - - # Currently ExtensionType sets a default version of 1.0.0, - # but we want to encourage an explicit version on the subclass. - assert "version" in extension_type.__dict__, "{} must set the 'version' class attribute".format( - extension_type.__name__, - ) - - # check the default version - types_to_check = [extension_type] - - # Adding or updating a schema/type version might involve updating multiple - # packages. This can result in types without schema and schema without types - # for the development version of the asdf-standard. To account for this, - # don't include versioned siblings of types with versions that are not - # in one of the asdf-standard versions in supported_versions (excluding the - # current development version). - asdf_standard_versions = supported_versions.copy() - if asdf_standard_development_version in asdf_standard_versions: - asdf_standard_versions.remove(asdf_standard_development_version) - for sibling in extension_type.versioned_siblings: - tag_base, version = split_tag_version(sibling.yaml_tag) - for asdf_standard_version in asdf_standard_versions: - vm = get_version_map(asdf_standard_version) - if tag_base in vm["tags"] and AsdfVersion(vm["tags"][tag_base]) == version: - types_to_check.append(sibling) - break - - for check_type in types_to_check: - schema_location = resolver(check_type.yaml_tag) - - assert schema_location is not None, ( - f"{extension_type.__name__} supports tag, {check_type.yaml_tag}, " - "but tag does not resolve. Check the tag_mapping and uri_mapping " - f"properties on the related extension ({extension_type.__name__})." - ) - - if schema_location not in asdf.get_config().resource_manager: - try: - with generic_io.get_file(schema_location) as f: - yaml.safe_load(f.read()) - except Exception as err: # noqa: BLE001 - msg = ( - f"{extension_type.__name__} supports tag, {check_type.yaml_tag}, " - f"which resolves to schema at {schema_location}, but " - "schema cannot be read." - ) - raise AssertionError(msg) from err + attr = getattr(_helpers, name) + attr.__module__ = __name__ # make automodapi think this is local + return attr diff --git a/asdf/tests/objects.py b/asdf/tests/objects.py index 0a3fb0448..ba0e63245 100644 --- a/asdf/tests/objects.py +++ b/asdf/tests/objects.py @@ -3,7 +3,7 @@ from asdf import CustomType, util from asdf.exceptions import AsdfDeprecationWarning -from .helpers import get_test_data_path +from ._helpers import get_test_data_path with pytest.warns(AsdfDeprecationWarning, match=".*subclasses the deprecated CustomType.*"): diff --git a/asdf/tests/test_api.py b/asdf/tests/test_api.py index 76be4f6fa..06074c970 100644 --- a/asdf/tests/test_api.py +++ b/asdf/tests/test_api.py @@ -16,7 +16,7 @@ from asdf.exceptions import AsdfDeprecationWarning, AsdfWarning from asdf.extension import ExtensionProxy -from .helpers import assert_no_warnings, assert_roundtrip_tree, assert_tree_match, yaml_to_asdf +from ._helpers import assert_no_warnings, assert_roundtrip_tree, assert_tree_match, yaml_to_asdf RNG = np.random.default_rng(97) diff --git a/asdf/tests/test_asdf.py b/asdf/tests/test_asdf.py index 517cfa3ec..1b9b10c71 100644 --- a/asdf/tests/test_asdf.py +++ b/asdf/tests/test_asdf.py @@ -9,7 +9,7 @@ from asdf.exceptions import AsdfWarning from asdf.extension import ExtensionManager, ExtensionProxy from asdf.extension._legacy import AsdfExtensionList -from asdf.tests.helpers import assert_no_warnings, assert_tree_match, yaml_to_asdf +from asdf.tests._helpers import assert_no_warnings, assert_tree_match, yaml_to_asdf from asdf.versioning import AsdfVersion diff --git a/asdf/tests/test_compression.py b/asdf/tests/test_compression.py index d32b9b7d3..75dece7c6 100644 --- a/asdf/tests/test_compression.py +++ b/asdf/tests/test_compression.py @@ -8,7 +8,7 @@ import asdf from asdf import compression, config_context, generic_io from asdf.extension import Compressor, Extension -from asdf.tests import helpers +from asdf.tests import _helpers as helpers RNG = np.random.default_rng(0) diff --git a/asdf/tests/test_deprecated.py b/asdf/tests/test_deprecated.py index 052fc05d4..231ab92c0 100644 --- a/asdf/tests/test_deprecated.py +++ b/asdf/tests/test_deprecated.py @@ -9,7 +9,7 @@ from asdf import entry_points from asdf._types import CustomType from asdf.exceptions import AsdfDeprecationWarning -from asdf.tests.helpers import assert_extension_correctness +from asdf.tests._helpers import assert_extension_correctness from asdf.tests.objects import CustomExtension from .test_entry_points import _monkeypatch_entry_points, mock_entry_points # noqa: F401 @@ -112,3 +112,15 @@ def test_deprecated_entry_point(mock_entry_points): # noqa: F811 mock_entry_points.append(("asdf_extensions", "legacy", "asdf.tests.test_entry_points:LegacyExtension")) with pytest.warns(AsdfDeprecationWarning, match=".* uses the deprecated entry point asdf_extensions"): entry_points.get_extensions() + + +def test_asdf_tests_helpers_deprecation(): + with pytest.warns(AsdfDeprecationWarning, match="asdf.tests.helpers is deprecated"): + if "asdf.tests.helpers" in sys.modules: + del sys.modules["asdf.tests.helpers"] + import asdf.tests.helpers + from asdf.tests import _helpers + + for attr in _helpers.__all__: + with pytest.warns(AsdfDeprecationWarning, match="asdf.tests.helpers is deprecated"): + getattr(asdf.tests.helpers, attr) diff --git a/asdf/tests/test_extension.py b/asdf/tests/test_extension.py index 0b170d7ae..048d212a3 100644 --- a/asdf/tests/test_extension.py +++ b/asdf/tests/test_extension.py @@ -18,7 +18,7 @@ get_cached_extension_manager, ) from asdf.extension._legacy import AsdfExtension, BuiltinExtension -from asdf.tests.helpers import assert_extension_correctness +from asdf.tests._helpers import assert_extension_correctness def test_builtin_extension(): diff --git a/asdf/tests/test_fits_embed.py b/asdf/tests/test_fits_embed.py index 6e0b3114c..3cc77c153 100644 --- a/asdf/tests/test_fits_embed.py +++ b/asdf/tests/test_fits_embed.py @@ -18,7 +18,7 @@ del sys.modules["asdf.fits_embed"] import asdf.fits_embed -from .helpers import assert_no_warnings, assert_tree_match, get_test_data_path, yaml_to_asdf +from ._helpers import assert_no_warnings, assert_tree_match, get_test_data_path, yaml_to_asdf TEST_DTYPES = ["f8", "u4", "i4"] diff --git a/asdf/tests/test_generic_io.py b/asdf/tests/test_generic_io.py index 5448190bf..4c1c22343 100644 --- a/asdf/tests/test_generic_io.py +++ b/asdf/tests/test_generic_io.py @@ -12,7 +12,8 @@ from asdf import exceptions, generic_io, util from asdf.config import config_context -from . import create_large_tree, create_small_tree, helpers +from . import _helpers as helpers +from . import create_large_tree, create_small_tree @pytest.fixture(params=[create_small_tree, create_large_tree]) diff --git a/asdf/tests/test_helpers.py b/asdf/tests/test_helpers.py index 9a9d463e2..3a5ae0933 100644 --- a/asdf/tests/test_helpers.py +++ b/asdf/tests/test_helpers.py @@ -2,7 +2,7 @@ from asdf import _types as types from asdf.exceptions import AsdfConversionWarning, AsdfDeprecationWarning, AsdfWarning -from asdf.tests.helpers import assert_roundtrip_tree +from asdf.tests._helpers import assert_roundtrip_tree def test_conversion_error(tmp_path): diff --git a/asdf/tests/test_reference.py b/asdf/tests/test_reference.py index 810c5ec70..93586580f 100644 --- a/asdf/tests/test_reference.py +++ b/asdf/tests/test_reference.py @@ -9,7 +9,7 @@ from asdf import reference, util from asdf.tags.core import ndarray -from .helpers import assert_tree_match +from ._helpers import assert_tree_match def test_external_reference(tmp_path): diff --git a/asdf/tests/test_reference_files.py b/asdf/tests/test_reference_files.py index 0086dfa84..b1d96e6a2 100644 --- a/asdf/tests/test_reference_files.py +++ b/asdf/tests/test_reference_files.py @@ -6,7 +6,7 @@ from asdf import open as asdf_open from asdf import versioning -from .helpers import assert_tree_match +from ._helpers import assert_tree_match _REFFILE_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "asdf-standard", "reference_files") diff --git a/asdf/tests/test_schema.py b/asdf/tests/test_schema.py index 2ac8d5ea7..1a7da5e89 100644 --- a/asdf/tests/test_schema.py +++ b/asdf/tests/test_schema.py @@ -12,7 +12,7 @@ from asdf import _types as types from asdf import config_context, constants, extension, get_config, schema, tagged, util, yamlutil from asdf.exceptions import AsdfConversionWarning, AsdfDeprecationWarning, AsdfWarning -from asdf.tests import helpers +from asdf.tests import _helpers as helpers from asdf.tests.objects import CustomExtension with pytest.warns(AsdfDeprecationWarning, match=".*subclasses the deprecated CustomType.*"): diff --git a/asdf/tests/test_types.py b/asdf/tests/test_types.py index 150fad36d..a9ab369a6 100644 --- a/asdf/tests/test_types.py +++ b/asdf/tests/test_types.py @@ -9,7 +9,7 @@ from asdf.exceptions import AsdfConversionWarning, AsdfDeprecationWarning, AsdfWarning from asdf.extension import _legacy -from . import helpers +from . import _helpers as helpers from .objects import CustomExtension, CustomTestType TEST_DATA_PATH = str(helpers.get_test_data_path("")) diff --git a/asdf/tests/test_yaml.py b/asdf/tests/test_yaml.py index 2471fb987..c1f69f768 100644 --- a/asdf/tests/test_yaml.py +++ b/asdf/tests/test_yaml.py @@ -10,7 +10,7 @@ from asdf import tagged, treeutil, yamlutil from asdf.exceptions import AsdfWarning -from . import helpers +from . import _helpers as helpers def test_ordered_dict(tmp_path): diff --git a/docs/asdf/developer_api.rst b/docs/asdf/developer_api.rst index 540e07721..b85c8da50 100644 --- a/docs/asdf/developer_api.rst +++ b/docs/asdf/developer_api.rst @@ -27,4 +27,6 @@ to create their own custom ASDF types and extensions. :skip: ExternalArrayReference :skip: IntegerType +.. automodapi:: asdf.testing.helpers + .. automodapi:: asdf.tests.helpers diff --git a/pyproject.toml b/pyproject.toml index 7796bee88..255a440f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,7 +195,7 @@ extend-exclude = ["asdf/extern/*", "docs/*"] [tool.ruff.per-file-ignores] "test_*.py" = ["S101"] -"asdf/tests/helpers.py" = ["S101"] +"asdf/tests/_helpers.py" = ["S101"] "compatibility_tests/common.py" = ["S101"] [tool.flynt] diff --git a/pytest_asdf/plugin.py b/pytest_asdf/plugin.py index 226dd9323..387c4e7da 100644 --- a/pytest_asdf/plugin.py +++ b/pytest_asdf/plugin.py @@ -219,7 +219,7 @@ def from_parent( def runtest(self): from asdf import AsdfFile, block, util - from asdf.tests import helpers + from asdf.tests import _helpers as helpers # Make sure that the examples in the schema files (and thus the # ASDF standard document) are valid. diff --git a/tox.ini b/tox.ini index 61318e196..ec815bee1 100644 --- a/tox.ini +++ b/tox.ini @@ -127,7 +127,8 @@ commands_pre = pip install -r {env_tmp_dir}/requirements.txt pip freeze commands= - pytest astropy/astropy/io/misc/asdf --open-files --run-slow --remote-data + pytest astropy/astropy/io/misc/asdf --open-files --run-slow --remote-data \ + -W "ignore::asdf.exceptions.AsdfDeprecationWarning:asdf.tests.helpers" [testenv:asdf-astropy] change_dir = {env_tmp_dir} @@ -142,7 +143,8 @@ commands_pre = pip install -r {env_tmp_dir}/requirements.txt pip freeze commands = - pytest asdf-astropy + pytest asdf-astropy \ + -W "ignore::asdf.exceptions.AsdfDeprecationWarning:asdf.tests.helpers" [testenv:specutils] change_dir = {env_tmp_dir} @@ -158,7 +160,7 @@ commands_pre = pip freeze commands = pytest specutils \ - -W "ignore::asdf.exceptions.AsdfDeprecationWarning:asdf.entry_points" + -W "ignore::asdf.exceptions.AsdfDeprecationWarning:asdf.tests.helpers" [testenv:gwcs] change_dir = {env_tmp_dir}