|
| 1 | +# Copyright (c) Meta Platforms, Inc. and affiliates. |
| 2 | +# All rights reserved. |
| 3 | +# |
| 4 | +# This source code is licensed under the BSD-style license found in the |
| 5 | +# LICENSE file in the root directory of this source tree. |
| 6 | + |
| 7 | +"""Unit tests that the Core ML availability rules agree. |
| 8 | +
|
| 9 | +Where coremltools can be installed is written down three times: as a dependency marker in |
| 10 | +setup.py, as a condition in conftest.py that drops the Core ML tests from collection, and |
| 11 | +as `is_supported_platform_for_coreml_lowering` in export/utils.py, which callers use as an |
| 12 | +import guard. They are written in different languages, PEP 508 and Python, so nothing but a |
| 13 | +comment keeps them together. When they drift, the wheel declares a package on a platform |
| 14 | +the test run assumes is absent, or the other way round, and pytest fails at collection with |
| 15 | +a bare ModuleNotFoundError. |
| 16 | +
|
| 17 | +The rules are read out of the three files rather than restated here, so a change to any one |
| 18 | +of them is exercised by this test instead of being duplicated a fourth time. |
| 19 | +""" |
| 20 | + |
| 21 | +import ast |
| 22 | +import fnmatch |
| 23 | +import re |
| 24 | +from pathlib import Path |
| 25 | +from types import SimpleNamespace |
| 26 | +from typing import NamedTuple |
| 27 | + |
| 28 | +import pytest |
| 29 | +from packaging.markers import Marker |
| 30 | + |
| 31 | +REPO_ROOT = Path(__file__).resolve().parents[3] |
| 32 | + |
| 33 | +# platform_system, platform_machine, sys_platform |
| 34 | +PLATFORMS = [ |
| 35 | + ("Darwin", "arm64", "darwin"), |
| 36 | + ("Darwin", "x86_64", "darwin"), |
| 37 | + ("Linux", "x86_64", "linux"), |
| 38 | + ("Linux", "aarch64", "linux"), |
| 39 | + ("Linux", "armv7l", "linux"), |
| 40 | + ("Linux", "ppc64le", "linux"), |
| 41 | + ("Linux", "s390x", "linux"), |
| 42 | + ("Windows", "AMD64", "win32"), |
| 43 | + ("Windows", "ARM64", "win32"), |
| 44 | +] |
| 45 | + |
| 46 | +# Every version in requires-python, plus one past the end. |
| 47 | +PYTHONS = [(3, 10), (3, 11), (3, 12), (3, 13), (3, 14), (3, 15)] |
| 48 | + |
| 49 | + |
| 50 | +class _VersionInfo(NamedTuple): |
| 51 | + """Stands in for sys.version_info: compares as a tuple, has .major/.minor.""" |
| 52 | + |
| 53 | + major: int |
| 54 | + minor: int |
| 55 | + |
| 56 | + |
| 57 | +def _normalise(name: str) -> str: |
| 58 | + # PEP 503: runs of -, _ and . collapse to a single -, then casefold. |
| 59 | + return re.sub(r"[-_.]+", "-", name).lower() |
| 60 | + |
| 61 | + |
| 62 | +def _project_name(requirement: str) -> str: |
| 63 | + # Strip everything a PEP 508 requirement can carry after the project name: an extras |
| 64 | + # list, a version specifier, an environment marker, or a URL. |
| 65 | + head = re.split(r"[\[<>=!~;@\s]", requirement.strip(), maxsplit=1)[0] |
| 66 | + return _normalise(head) |
| 67 | + |
| 68 | + |
| 69 | +def _base_dependency(name: str) -> str: |
| 70 | + """Return the requirement string for `name` from setup.py's _base_dependencies().""" |
| 71 | + # Compare PEP 503 normalised project names rather than using startswith, which would |
| 72 | + # match a longer name that merely begins with this one (scikit-learn against |
| 73 | + # scikit-learn-intelex) and would also match the function's own docstring, since that |
| 74 | + # is just another string constant in the body. |
| 75 | + wanted = _normalise(name) |
| 76 | + tree = ast.parse((REPO_ROOT / "setup.py").read_text()) |
| 77 | + for node in ast.walk(tree): |
| 78 | + if not ( |
| 79 | + isinstance(node, ast.FunctionDef) and node.name == "_base_dependencies" |
| 80 | + ): |
| 81 | + continue |
| 82 | + for constant in ast.walk(node): |
| 83 | + if not ( |
| 84 | + isinstance(constant, ast.Constant) and isinstance(constant.value, str) |
| 85 | + ): |
| 86 | + continue |
| 87 | + if _project_name(constant.value) == wanted: |
| 88 | + return constant.value |
| 89 | + raise AssertionError(f"no {name} requirement in setup.py _base_dependencies()") |
| 90 | + |
| 91 | + |
| 92 | +def _conftest_condition() -> str: |
| 93 | + """Return the source of conftest.py's _coremltools_is_declared expression.""" |
| 94 | + tree = ast.parse((REPO_ROOT / "conftest.py").read_text()) |
| 95 | + for node in ast.walk(tree): |
| 96 | + if isinstance(node, ast.Assign) and any( |
| 97 | + isinstance(target, ast.Name) and target.id == "_coremltools_is_declared" |
| 98 | + for target in node.targets |
| 99 | + ): |
| 100 | + return ast.unparse(node.value) |
| 101 | + raise AssertionError("no _coremltools_is_declared assignment in conftest.py") |
| 102 | + |
| 103 | + |
| 104 | +def _coreml_ignore_globs() -> list[str]: |
| 105 | + """Return the globs conftest.py adds when _coremltools_is_declared is false. |
| 106 | +
|
| 107 | + Reads the `if not _coremltools_is_declared:` statement rather than the module's |
| 108 | + resulting state, because importing conftest.py here would evaluate the condition |
| 109 | + for the interpreter running this test instead of for the platforms under test. |
| 110 | + """ |
| 111 | + tree = ast.parse((REPO_ROOT / "conftest.py").read_text()) |
| 112 | + for node in ast.walk(tree): |
| 113 | + if not isinstance(node, ast.If): |
| 114 | + continue |
| 115 | + test = node.test |
| 116 | + # `if not _coremltools_is_declared:` |
| 117 | + if not ( |
| 118 | + isinstance(test, ast.UnaryOp) |
| 119 | + and isinstance(test.op, ast.Not) |
| 120 | + and isinstance(test.operand, ast.Name) |
| 121 | + and test.operand.id == "_coremltools_is_declared" |
| 122 | + ): |
| 123 | + continue |
| 124 | + globs: list[str] = [] |
| 125 | + for stmt in node.body: |
| 126 | + # `collect_ignore_glob += [...]` or `collect_ignore_glob = [...]` |
| 127 | + targets = ( |
| 128 | + [stmt.target] |
| 129 | + if isinstance(stmt, ast.AugAssign) |
| 130 | + else stmt.targets if isinstance(stmt, ast.Assign) else [] |
| 131 | + ) |
| 132 | + if not any( |
| 133 | + isinstance(t, ast.Name) and t.id == "collect_ignore_glob" |
| 134 | + for t in targets |
| 135 | + ): |
| 136 | + continue |
| 137 | + for element in ast.walk(stmt.value): |
| 138 | + if isinstance(element, ast.Constant) and isinstance(element.value, str): |
| 139 | + globs.append(element.value) |
| 140 | + if globs: |
| 141 | + return globs |
| 142 | + raise AssertionError( |
| 143 | + "conftest.py does not gate collect_ignore_glob on `not _coremltools_is_declared`, " |
| 144 | + "so the Core ML tests are collected even where coremltools is not installed" |
| 145 | + ) |
| 146 | + |
| 147 | + |
| 148 | +def _marker_says_installed( |
| 149 | + requirement: str, system, machine, sys_platform, python |
| 150 | +) -> bool: |
| 151 | + _, _, marker = requirement.partition(";") |
| 152 | + assert marker.strip(), f"{requirement} has no environment marker" |
| 153 | + return Marker(marker).evaluate( |
| 154 | + { |
| 155 | + "platform_system": system, |
| 156 | + "platform_machine": machine, |
| 157 | + "sys_platform": sys_platform, |
| 158 | + "python_version": f"{python[0]}.{python[1]}", |
| 159 | + "python_full_version": f"{python[0]}.{python[1]}.0", |
| 160 | + } |
| 161 | + ) |
| 162 | + |
| 163 | + |
| 164 | +def _conftest_says_installed(machine, sys_platform, python) -> bool: |
| 165 | + return bool( |
| 166 | + eval( # the expression is read out of conftest.py, not taken from input |
| 167 | + compile(_conftest_condition(), "<conftest.py>", "eval"), |
| 168 | + { |
| 169 | + "sys": SimpleNamespace(platform=sys_platform, version_info=python), |
| 170 | + "platform": SimpleNamespace(machine=lambda: machine), |
| 171 | + }, |
| 172 | + ) |
| 173 | + ) |
| 174 | + |
| 175 | + |
| 176 | +def _support_helper_says_supported(system, machine, python) -> bool: |
| 177 | + """Evaluate export/utils.py's is_supported_platform_for_coreml_lowering(). |
| 178 | +
|
| 179 | + The function is extracted and run against stub platform/sys modules rather than |
| 180 | + imported, because importing export.utils pulls in torch and would answer for the |
| 181 | + interpreter running this test instead of for the platform under test. |
| 182 | + """ |
| 183 | + tree = ast.parse((REPO_ROOT / "export" / "utils.py").read_text()) |
| 184 | + for node in tree.body: |
| 185 | + if ( |
| 186 | + isinstance(node, ast.FunctionDef) |
| 187 | + and node.name == "is_supported_platform_for_coreml_lowering" |
| 188 | + ): |
| 189 | + namespace = { |
| 190 | + "platform": SimpleNamespace( |
| 191 | + system=lambda: system, machine=lambda: machine |
| 192 | + ), |
| 193 | + # A namedtuple-like value so both `>= (3, 14)` and `.major`/`.minor` |
| 194 | + # work, matching what sys.version_info supports. |
| 195 | + "sys": SimpleNamespace(version_info=_VersionInfo(*python)), |
| 196 | + "logging": SimpleNamespace(info=lambda *a, **k: None), |
| 197 | + } |
| 198 | + module = ast.Module(body=[node], type_ignores=[]) |
| 199 | + exec(compile(module, "<export/utils.py>", "exec"), namespace) |
| 200 | + return bool(namespace["is_supported_platform_for_coreml_lowering"]()) |
| 201 | + raise AssertionError( |
| 202 | + "no is_supported_platform_for_coreml_lowering in export/utils.py" |
| 203 | + ) |
| 204 | + |
| 205 | + |
| 206 | +@pytest.mark.parametrize("system,machine,sys_platform", PLATFORMS) |
| 207 | +@pytest.mark.parametrize("python", PYTHONS) |
| 208 | +def test_conftest_matches_the_coremltools_marker(system, machine, sys_platform, python): |
| 209 | + declared = _marker_says_installed( |
| 210 | + _base_dependency("coremltools"), system, machine, sys_platform, python |
| 211 | + ) |
| 212 | + collected = _conftest_says_installed(machine, sys_platform, python) |
| 213 | + assert declared == collected, ( |
| 214 | + f"on {system}/{machine} with Python {python[0]}.{python[1]} setup.py " |
| 215 | + f"{'declares' if declared else 'does not declare'} coremltools while conftest.py " |
| 216 | + f"{'collects' if collected else 'skips'} the Core ML tests, so pytest will either fail " |
| 217 | + "at collection on a missing import or silently skip tests that could have run" |
| 218 | + ) |
| 219 | + |
| 220 | + |
| 221 | +@pytest.mark.parametrize("system,machine,sys_platform", PLATFORMS) |
| 222 | +@pytest.mark.parametrize("python", PYTHONS) |
| 223 | +def test_scikit_learn_follows_coremltools(system, machine, sys_platform, python): |
| 224 | + # scikit-learn is there for coremltools' palettization, so it is only useful where |
| 225 | + # coremltools is. Nothing else in the wheel imports scikit-learn itself; scipy, which |
| 226 | + # used to arrive as its transitive dependency, is declared in requirements-examples.txt |
| 227 | + # for the code that does import it. |
| 228 | + assert _marker_says_installed( |
| 229 | + _base_dependency("scikit-learn"), system, machine, sys_platform, python |
| 230 | + ) == _marker_says_installed( |
| 231 | + _base_dependency("coremltools"), system, machine, sys_platform, python |
| 232 | + ) |
| 233 | + |
| 234 | + |
| 235 | +def test_the_platforms_core_ml_is_used_on_are_still_covered(): |
| 236 | + # A guard on the parametrized tests above: they would pass just as happily if both |
| 237 | + # rules said "never", which would quietly stop running the Core ML tests everywhere. |
| 238 | + requirement = _base_dependency("coremltools") |
| 239 | + assert _marker_says_installed(requirement, "Darwin", "arm64", "darwin", (3, 12)) |
| 240 | + assert _marker_says_installed(requirement, "Linux", "x86_64", "linux", (3, 12)) |
| 241 | + assert not _marker_says_installed(requirement, "Linux", "aarch64", "linux", (3, 12)) |
| 242 | + assert not _marker_says_installed(requirement, "Windows", "AMD64", "win32", (3, 12)) |
| 243 | + |
| 244 | + |
| 245 | +def test_conftest_acts_on_the_condition(): |
| 246 | + # The tests above only compare the two rules as expressions. They would still pass if |
| 247 | + # conftest.py computed _coremltools_is_declared and then never used it, which is the |
| 248 | + # one failure that reaches contributors as a ModuleNotFoundError at collection. |
| 249 | + # |
| 250 | + # Membership, not equality: adding a second correct glob is a legitimate change, and |
| 251 | + # test_the_ignore_globs_cover_every_core_ml_test_module below already catches a glob |
| 252 | + # that stops matching. |
| 253 | + assert "backends/apple/coreml/**" in _coreml_ignore_globs() |
| 254 | + |
| 255 | + |
| 256 | +@pytest.mark.parametrize("system,machine,sys_platform", PLATFORMS) |
| 257 | +@pytest.mark.parametrize("python", PYTHONS) |
| 258 | +def test_export_support_helper_never_claims_more_than_the_marker( |
| 259 | + system, machine, sys_platform, python |
| 260 | +): |
| 261 | + # export/utils.py is the third place this rule is written down. Callers use it as an |
| 262 | + # import guard (export/target_recipes.py raises ValueError when it says no), so it must |
| 263 | + # never claim support where coremltools is not declared, or the guard is bypassed and |
| 264 | + # the import raises ModuleNotFoundError instead. |
| 265 | + # |
| 266 | + # The reverse is allowed: the helper is deliberately narrower than the marker, because |
| 267 | + # coremltools installs on Darwin x86_64 but lowering is only supported on Apple silicon. |
| 268 | + declared = _marker_says_installed( |
| 269 | + _base_dependency("coremltools"), system, machine, sys_platform, python |
| 270 | + ) |
| 271 | + supported = _support_helper_says_supported(system, machine, python) |
| 272 | + assert not (supported and not declared), ( |
| 273 | + f"on {system}/{machine} with Python {python[0]}.{python[1]} " |
| 274 | + "is_supported_platform_for_coreml_lowering() reports support while setup.py does " |
| 275 | + "not declare coremltools, so the import guard in export/target_recipes.py is " |
| 276 | + "bypassed and importing coremltools raises ModuleNotFoundError" |
| 277 | + ) |
| 278 | + |
| 279 | + |
| 280 | +def test_the_support_helper_still_says_yes_where_core_ml_is_used(): |
| 281 | + # A guard on the one-directional test above, which a helper that always returned False |
| 282 | + # would satisfy trivially. |
| 283 | + assert _support_helper_says_supported("Darwin", "arm64", (3, 12)) |
| 284 | + assert _support_helper_says_supported("Linux", "x86_64", (3, 12)) |
| 285 | + assert not _support_helper_says_supported("Windows", "AMD64", (3, 12)) |
| 286 | + |
| 287 | + |
| 288 | +def test_the_support_helper_tracks_the_marker_on_python_version(): |
| 289 | + # The one-directional test above is deliberately loose on architecture, because |
| 290 | + # coremltools installs on Darwin x86_64 while lowering needs Apple silicon. It must not |
| 291 | + # be loose on the Python version too: if the marker starts declaring coremltools on a |
| 292 | + # version the helper still refuses, every caller keeps taking the "not supported" branch |
| 293 | + # on a platform where Core ML now works, and nothing else in this file notices. |
| 294 | + # |
| 295 | + # So pin the helper to the marker's own bound rather than to a literal, and read the |
| 296 | + # bound out of setup.py so widening the marker fails here until the helper follows. |
| 297 | + marker = _base_dependency("coremltools") |
| 298 | + for python in PYTHONS: |
| 299 | + declared = _marker_says_installed(marker, "Darwin", "arm64", "darwin", python) |
| 300 | + supported = _support_helper_says_supported("Darwin", "arm64", python) |
| 301 | + assert supported == declared, ( |
| 302 | + f"on Darwin/arm64 with Python {python[0]}.{python[1]} setup.py " |
| 303 | + f"{'declares' if declared else 'does not declare'} coremltools but " |
| 304 | + f"is_supported_platform_for_coreml_lowering() reports " |
| 305 | + f"{'support' if supported else 'no support'}; the version bound in " |
| 306 | + "export/utils.py has drifted from the marker in setup.py" |
| 307 | + ) |
| 308 | + |
| 309 | + |
| 310 | +def test_the_ignore_globs_cover_every_core_ml_test_module(): |
| 311 | + # A glob that no longer matches would silence nothing. Checked against the files on |
| 312 | + # disk so that moving the Core ML tests without updating conftest.py fails here. |
| 313 | + coreml_tests = sorted( |
| 314 | + path.relative_to(REPO_ROOT).as_posix() |
| 315 | + for path in (REPO_ROOT / "backends/apple/coreml").rglob("test_*.py") |
| 316 | + ) |
| 317 | + assert coreml_tests, "no Core ML test modules found, so this guard proves nothing" |
| 318 | + globs = _coreml_ignore_globs() |
| 319 | + for test in coreml_tests: |
| 320 | + assert any( |
| 321 | + fnmatch.fnmatch(test, glob) for glob in globs |
| 322 | + ), f"{test} is not covered by conftest.py's ignore globs {globs}" |
0 commit comments