diff --git a/sagemaker-core/src/sagemaker/__init__.py b/sagemaker-core/src/sagemaker/__init__.py index 71038bb89b..34badfccd5 100644 --- a/sagemaker-core/src/sagemaker/__init__.py +++ b/sagemaker-core/src/sagemaker/__init__.py @@ -1,2 +1,16 @@ """Namespace package for SageMaker.""" -__path__ = __import__('pkgutil').extend_path(__path__, __name__) + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) + +# Register the fallback finder that gives actionable migration guidance for v2 +# modules removed in v3. Doing it here (in the namespace package init, which +# runs on any ``import sagemaker.*``) ensures the guidance is active even when a +# removed module is the very first sagemaker import in the process (e.g. +# ``import sagemaker.estimator``), before ``sagemaker.core`` is otherwise loaded. +# Fully guarded so it can never interfere with importing the package. +try: + from sagemaker.core.deprecations import register_removed_module_finder as _register + + _register() +except Exception: # pylint: disable=W0703 # noqa: E722 + pass diff --git a/sagemaker-core/src/sagemaker/core/__init__.py b/sagemaker-core/src/sagemaker/core/__init__.py index 498c85390a..0bd0637152 100644 --- a/sagemaker-core/src/sagemaker/core/__init__.py +++ b/sagemaker-core/src/sagemaker/core/__init__.py @@ -1,9 +1,18 @@ """SageMaker Core package for low-level resource management and SDK foundations.""" + from sagemaker.core.utils.utils import enable_textual_rich_console_and_traceback +from sagemaker.core.deprecations import register_removed_module_finder enable_textual_rich_console_and_traceback() +# Install the meta-path finder that gives actionable migration guidance for v2 +# modules removed in v3. sagemaker-core is the universal dependency of every v3 +# package, so registering here guarantees the finder is active whenever the SDK +# is used (the namespace package __init__ also registers it for the cold +# first-import case). +register_removed_module_finder() + # Job management from sagemaker.core.job import _Job # noqa: F401 from sagemaker.core.processing import ( # noqa: F401 diff --git a/sagemaker-core/src/sagemaker/core/deprecations.py b/sagemaker-core/src/sagemaker/core/deprecations.py index a8ba298082..db5b40fdd9 100644 --- a/sagemaker-core/src/sagemaker/core/deprecations.py +++ b/sagemaker-core/src/sagemaker/core/deprecations.py @@ -13,13 +13,340 @@ """Module for deprecation abstractions.""" from __future__ import absolute_import +import importlib.abc import logging +import sys import warnings logger = logging.getLogger(__name__) V2_URL = "https://sagemaker.readthedocs.io/en/stable/v2.html" +# Migration guide for users moving from the v2 SDK to v3. +V3_MIGRATION_URL = "https://github.com/aws/sagemaker-python-sdk/blob/master/migration.md" + +# Real top-level ``sagemaker.*`` names that ship in v3. The fallback finder must +# never intercept these -- even when a package (e.g. sagemaker-train) is simply +# not installed, its absence should surface the normal error, not a bogus +# "removed in v3" message. +_KNOWN_V3_TOPLEVEL = frozenset({"core", "train", "serve", "mlops", "lineage", "ai_registry"}) + +# Top-level ``sagemaker.`` modules that existed in v2 but were removed in +# v3 (some relocated under ``sagemaker.core.*``). Derived from the v2 top-level +# module surface (the ``master-v2`` branch), minus names that still exist in v3. +# The fallback finder only emits migration guidance for THESE names, so a typo +# or hallucinated import (e.g. ``sagemaker.foobar``) gets a plain +# ``ModuleNotFoundError`` rather than a misleading "was removed" message. V2 is +# in maintenance, so this surface is effectively frozen. +_REMOVED_V2_MODULES = frozenset( + { + "_studio", + "accept_types", + "algorithm", + "amazon", + "amtviz", + "analytics", + "apiutils", + "async_inference", + "automl", + "aws_batch", + "base_deserializers", + "base_predictor", + "base_serializers", + "batch_inference", + "chainer", + "clarify", + "cli", + "collection", + "compute_resource_requirements", + "config", + "container_base_model", + "content_types", + "dataset_definition", + "debugger", + "deprecations", + "deserializers", + "djl_inference", + "drift_check_baselines", + "enums", + "environment_variables", + "estimator", + "exceptions", + "experiments", + "explainer", + "feature_store", + "fw_utils", + "git_utils", + "huggingface", + "hyperparameters", + "image_uri_config", + "image_uris", + "inference_recommender", + "inputs", + "instance_group", + "instance_types", + "instance_types_gpu_info", + "interactive_apps", + "iterators", + "job", + "jumpstart", + "lambda_helper", + "local", + "logs", + "metadata_properties", + "metric_definitions", + "mlflow", + "model", + "model_card", + "model_life_cycle", + "model_metrics", + "model_monitor", + "model_uris", + "modules", + "multidatamodel", + "mxnet", + "network", + "parameter", + "partner_app", + "payloads", + "pipeline", + "predictor", + "predictor_async", + "processing", + "pytorch", + "remote_function", + "resource_requirements", + "rl", + "s3", + "s3_utils", + "script_uris", + "serializer_utils", + "serializers", + "serverless", + "session", + "session_settings", + "sklearn", + "spark", + "sparkml", + "stabilityai", + "telemetry", + "tensorflow", + "training_compiler", + "transformer", + "tuner", + "user_agent", + "utilities", + "utils", + "vpc_utils", + "workflow", + "wrangler", + "xgboost", + } +) + +_DOCS_BASE = "https://sagemaker.readthedocs.io/en/stable/api/generated/" + +# Curated removed v2 modules -> precise v3 guidance: +# (replacement, exact import, docs module path). The finder emits a specific +# message (exact class + copy-pasteable import + docs link) for these names, and +# a generic "was removed" message for every other name in _REMOVED_V2_MODULES. +# Every import target and docs page below is verified against installed v3 and +# readthedocs; the import targets are additionally guarded by a unit test. +_CORE_RES = "sagemaker.core.resources" + + +def _trainer(): + return ( + "`ModelTrainer`", + "from sagemaker.train import ModelTrainer", + "sagemaker.train.model_trainer", + ) + + +def _builder(note=""): + repl = "`ModelBuilder`" + (f" ({note})" if note else "") + return (repl, "from sagemaker.serve import ModelBuilder", "sagemaker.serve.model_builder") + + +def _core_resource(cls): + return (f"the `{cls}` resource", f"from {_CORE_RES} import {cls}", _CORE_RES) + + +def _core_module(mod): + return (f"`{mod}`", f"from sagemaker.core import {mod}", f"sagemaker.core.{mod}") + + +_V3_REPLACEMENTS = { + # Training: estimators (generic + framework) + marketplace algorithms -> ModelTrainer + "estimator": _trainer(), + "algorithm": _trainer(), + "pytorch": _trainer(), + "tensorflow": _trainer(), + "huggingface": _trainer(), + "sklearn": _trainer(), + "xgboost": _trainer(), + # Inference: models / predictors / serverless / async / jumpstart -> ModelBuilder + "model": _builder(), + "multidatamodel": _builder(), + "predictor_async": _builder("async deploy"), + "serverless": _builder("serverless deploy"), + "async_inference": _builder("async deploy"), + "jumpstart": _builder("JumpStart models"), + # sagemaker-core resources + "predictor": _core_resource("Endpoint"), + "base_predictor": _core_resource("Endpoint"), + "transformer": _core_resource("TransformJob"), + "tuner": _core_resource("HyperParameterTuningJob"), + "processing": _core_resource("ProcessingJob"), + "clarify": _core_resource("ProcessingJob"), + "wrangler": _core_resource("ProcessingJob"), + "spark": _core_resource("ProcessingJob"), + "model_monitor": _core_resource("MonitoringSchedule"), + "feature_store": _core_resource("FeatureGroup"), + "experiments": _core_resource("Experiment"), + "model_card": _core_resource("ModelCard"), + "automl": _core_resource("AutoMLJob"), + # Relocated utility modules under sagemaker.core + "image_uris": _core_module("image_uris"), + "s3": _core_module("s3"), + "serializers": _core_module("serializers"), + "deserializers": _core_module("deserializers"), + # Pipelines / workflow -> sagemaker.mlops + "pipeline": ( + "`Pipeline`", + "from sagemaker.mlops.workflow.pipeline import Pipeline", + "sagemaker.mlops.workflow.pipeline", + ), + "workflow": ( + "`Pipeline`", + "from sagemaker.mlops.workflow.pipeline import Pipeline", + "sagemaker.mlops.workflow.pipeline", + ), + # Training inputs -> configs; Session helper + "inputs": ( + "`InputData`", + "from sagemaker.train.configs import InputData", + "sagemaker.train.configs", + ), + "session": ( + "`Session`", + "from sagemaker.core.helper.session_helper import Session", + "sagemaker.core.helper.session_helper", + ), +} + + +def raise_removed_in_v3(module, replacement=None, v3_import=None, v3_docs=None): + """Warn and then raise an actionable error for a v2 module removed in v3. + + The v2 SDK exposed top-level modules (e.g. ``sagemaker.estimator``) that no + longer exist in v3. Importing one would otherwise fail with a bare + ``ModuleNotFoundError: No module named 'sagemaker.estimator'`` that gives the + caller no path forward. This helper is called from ``_RemovedV2ModuleFinder`` + for removed names: it emits a ``DeprecationWarning`` and then raises a + ``ModuleNotFoundError`` whose message names the exact v3 replacement, the + import to copy-paste, and a direct link to that replacement's API docs (plus + the migration guide). + + Args: + module (str): The removed v2 module path, e.g. ``"sagemaker.estimator"``. + replacement (str): Human readable v3 replacement, e.g. ``"ModelTrainer"``. + Optional. + v3_import (str): The exact v3 import statement, e.g. + ``"from sagemaker.train import ModelTrainer"``. Quoted verbatim so the + caller can copy-paste it. Optional. + v3_docs (str): Direct URL to the v3 replacement's API documentation, e.g. + the generated ``sagemaker.train.model_trainer`` page. Optional. + + Raises: + ModuleNotFoundError: always, after emitting the deprecation warning. + """ + msg = f"`{module}` was removed in the SageMaker Python SDK v3." + if replacement: + msg += f" Use {replacement}." + if v3_import: + msg += f" ({v3_import})" + if v3_docs: + msg += f"\nDocs: {v3_docs}" + msg += f"\nSee {V3_MIGRATION_URL} for the migration guide." + + warnings.warn(msg, DeprecationWarning, stacklevel=2) + # The raised ModuleNotFoundError below is the loud, authoritative signal + # (it stops execution and carries the full message). Log at debug only, to + # leave a breadcrumb for log-captured environments without duplicating the + # message at WARNING level. + logger.debug(msg) + raise ModuleNotFoundError(msg, name=module) + + +class _RemovedV2ModuleFinder(importlib.abc.MetaPathFinder): + """Meta-path finder that gives actionable guidance for removed v2 modules. + + A single hook handles all removed top-level ``sagemaker.`` modules: + - names in ``_V3_REPLACEMENTS`` get a **precise** message (exact v3 class, + copy-pasteable import, and API-docs link), + - other names in ``_REMOVED_V2_MODULES`` get a **generic** "was removed" + message, and + - any other name (typo, hallucinated import) falls through to Python's + plain ``ModuleNotFoundError`` -- we never claim something "was removed" + when it never existed. + + It is registered by *appending* to ``sys.meta_path``, so it only runs after + the normal import machinery fails to locate the module. That ordering + guarantees it never shadows a real module: if v3 ever ships a top-level + module whose name matches a removed v2 one, the real module resolves first + and this finder is never consulted for it. + """ + + def find_spec(self, fullname, path=None, target=None): + """Emit guidance only for known removed v2 top-level ``sagemaker`` modules.""" + if not fullname.startswith("sagemaker."): + return None + leaf = fullname[len("sagemaker.") :] + # Only guard top-level names; never touch real v3 subpackages. + if "." in leaf or leaf in _KNOWN_V3_TOPLEVEL: + return None + # Only guard names that were actually v2 modules. Unknown names (typos, + # hallucinated imports) fall through to a plain ModuleNotFoundError so we + # never claim something "was removed" when it never existed. + if leaf not in _REMOVED_V2_MODULES: + return None + + if leaf in _V3_REPLACEMENTS: + # Curated, high-traffic module -> precise guidance. + replacement, v3_import, docs_module = _V3_REPLACEMENTS[leaf] + raise_removed_in_v3( + module=fullname, + replacement=replacement, + v3_import=v3_import, + v3_docs=f"{_DOCS_BASE}{docs_module}.html", + ) + + # Other removed v2 module -> generic guidance. + msg = ( + f"`{fullname}` was removed in the SageMaker Python SDK v3. " + "It may have moved to a new location." + f"\nSee {V3_MIGRATION_URL} for the migration guide." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + # See raise_removed_in_v3: the raised error is the loud signal; log at + # debug to avoid duplicating the message at WARNING level. + logger.debug(msg) + raise ModuleNotFoundError(msg, name=fullname) + + +def register_removed_module_finder(): + """Install the fallback finder for removed v2 modules (idempotent). + + Appends a single ``_RemovedV2ModuleFinder`` to ``sys.meta_path`` so it acts + as a last resort. Safe to call multiple times -- it installs at most one + instance per process. + """ + if any(isinstance(f, _RemovedV2ModuleFinder) for f in sys.meta_path): + return + sys.meta_path.append(_RemovedV2ModuleFinder()) + logger.debug("Registered SageMaker v2 removed-module guidance finder on sys.meta_path.") + def _warn(msg, sdk_version=None): """Generic warning raiser referencing V2 diff --git a/sagemaker-core/tests/unit/test_removed_v2_modules.py b/sagemaker-core/tests/unit/test_removed_v2_modules.py new file mode 100644 index 0000000000..ce34c46be2 --- /dev/null +++ b/sagemaker-core/tests/unit/test_removed_v2_modules.py @@ -0,0 +1,314 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +"""Tests that removed v2 modules raise actionable guidance in v3.""" +from __future__ import absolute_import + +import importlib +import warnings + +import pytest + +from sagemaker.core.deprecations import ( + raise_removed_in_v3, + register_removed_module_finder, + _RemovedV2ModuleFinder, + _REMOVED_V2_MODULES, + _KNOWN_V3_TOPLEVEL, + _V3_REPLACEMENTS, + V3_MIGRATION_URL, +) + +# Curated removed v2 module -> a substring expected in the precise guidance +# message. The values are verified-importable v3 symbols; the message also +# carries the exact import and a docs URL. These are served by the meta-path +# finder via _V3_REPLACEMENTS (no per-module files). +REMOVED_MODULES = { + "sagemaker.estimator": "ModelTrainer", + "sagemaker.model": "ModelBuilder", + "sagemaker.predictor": "Endpoint", + "sagemaker.base_predictor": "Endpoint", + "sagemaker.predictor_async": "ModelBuilder", + "sagemaker.transformer": "TransformJob", + "sagemaker.tuner": "HyperParameterTuningJob", + "sagemaker.processing": "ProcessingJob", + "sagemaker.pipeline": "Pipeline", + "sagemaker.multidatamodel": "ModelBuilder", + "sagemaker.automl": "AutoMLJob", + "sagemaker.algorithm": "ModelTrainer", +} + + +def _fresh_import(module): + """Import a module fresh, bypassing any cached (failed) import state.""" + import sys + + sys.modules.pop(module, None) + return importlib.import_module(module) + + +@pytest.mark.parametrize("module,expected", sorted(REMOVED_MODULES.items())) +def test_removed_module_raises_actionable_error(module, expected): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ModuleNotFoundError) as exc: + _fresh_import(module) + message = str(exc.value) + assert module in message + assert "removed in the SageMaker Python SDK v3" in message + assert expected in message + # Every curated entry points at a copy-pasteable import and a docs URL. + assert "from sagemaker." in message + assert "sagemaker.readthedocs.io/en/stable/api/generated/" in message + assert V3_MIGRATION_URL in message + + +@pytest.mark.parametrize("module", sorted(REMOVED_MODULES)) +def test_removed_module_emits_deprecation_warning(module): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ModuleNotFoundError): + _fresh_import(module) + assert any(issubclass(x.category, DeprecationWarning) for x in w) + + +def test_helper_includes_import_and_docs_when_provided(): + docs = "https://sagemaker.readthedocs.io/en/stable/api/generated/sagemaker.train.model_trainer.html" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ModuleNotFoundError) as exc: + raise_removed_in_v3( + module="sagemaker.estimator", + replacement="`ModelTrainer`", + v3_import="from sagemaker.train import ModelTrainer", + v3_docs=docs, + ) + message = str(exc.value) + assert "from sagemaker.train import ModelTrainer" in message + assert docs in message + # ModuleNotFoundError carries the module name for tooling. + assert exc.value.name == "sagemaker.estimator" + + +def test_helper_without_replacement_still_actionable(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ModuleNotFoundError) as exc: + raise_removed_in_v3(module="sagemaker.something") + message = str(exc.value) + assert "sagemaker.something" in message + assert V3_MIGRATION_URL in message + + +# --- Meta-path finder (handles all removed v2 modules) --- + + +def test_finder_is_registered_on_meta_path(): + import sys + import sagemaker.core # noqa: F401 -- import registers the finder + + assert any(isinstance(f, _RemovedV2ModuleFinder) for f in sys.meta_path) + + +def test_finder_registered_on_bare_sagemaker_import(): + import sys + import importlib + + # The namespace package init registers the finder, so it is active even + # without importing sagemaker.core explicitly. + for name in [m for m in list(sys.modules) if m == "sagemaker" or m.startswith("sagemaker.")]: + pass # (do not evict already-imported real modules; just assert state) + import sagemaker # noqa: F401 -- runs the namespace __init__ + + importlib.import_module("sagemaker") + assert any(isinstance(f, _RemovedV2ModuleFinder) for f in sys.meta_path) + + +def test_removed_module_guidance_on_first_import_in_fresh_process(): + # Regression: a removed module as the VERY FIRST sagemaker import (before + # sagemaker.core is otherwise loaded) must still get guidance, not a bare + # error. Run in a fresh interpreter so import state cannot leak in, and + # capture the message from the exception itself (the console traceback + # formatter does not always write plain text to stderr). + import subprocess + import sys + + code = ( + "import sys\n" + "try:\n" + " import sagemaker.estimator\n" + "except ModuleNotFoundError as e:\n" + " sys.stdout.write(str(e))\n" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert "was removed in the SageMaker Python SDK v3" in proc.stdout + assert "from sagemaker.train import ModelTrainer" in proc.stdout + + +def test_register_is_idempotent(): + import sys + + register_removed_module_finder() + register_removed_module_finder() + finders = [f for f in sys.meta_path if isinstance(f, _RemovedV2ModuleFinder)] + assert len(finders) == 1 + + +def test_finder_is_appended_not_prepended(): + # As a last resort it must sit at the end so real modules resolve first. + # Being in the second half of meta_path is a sufficient proxy. + import sys + + register_removed_module_finder() + idx = next(i for i, f in enumerate(sys.meta_path) if isinstance(f, _RemovedV2ModuleFinder)) + assert idx >= len(sys.meta_path) // 2 + + +@pytest.mark.parametrize("module", ["sagemaker.clarify", "sagemaker.session", "sagemaker.network"]) +def test_uncovered_module_falls_back_to_guidance(module): + import sagemaker.core # noqa: F401 -- ensure finder registered + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ModuleNotFoundError) as exc: + _fresh_import(module) + message = str(exc.value) + assert module in message + assert "was removed in the SageMaker Python SDK v3" in message + assert V3_MIGRATION_URL in message + assert any(issubclass(x.category, DeprecationWarning) for x in w) + + +@pytest.mark.parametrize("module", ["sagemaker.foobar", "sagemaker.not_a_module", "sagemaker.zzz"]) +def test_unknown_name_gets_plain_error_not_guidance(module): + # Names that never existed in v2 (typos, hallucinated imports) must fall + # through to a plain ModuleNotFoundError -- no "was removed" claim, no + # migration-guide link, no deprecation warning. + import sagemaker.core # noqa: F401 -- ensure finder registered + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ModuleNotFoundError) as exc: + _fresh_import(module) + message = str(exc.value) + assert "was removed" not in message + assert V3_MIGRATION_URL not in message + assert not any(issubclass(x.category, DeprecationWarning) for x in w) + + +def test_finder_passes_through_unknown_names(): + import sagemaker.core # noqa: F401 + + finder = _RemovedV2ModuleFinder() + # Known removed v2 module -> guarded (raises); unknown -> pass through (None). + assert finder.find_spec("sagemaker.foobar") is None + assert finder.find_spec("sagemaker.definitely_not_real") is None + + +@pytest.mark.parametrize( + "module", + [ + "sagemaker.core", + "sagemaker.train", + "sagemaker.serve", + "sagemaker.mlops", + "sagemaker.lineage", + ], +) +def test_finder_does_not_shadow_real_v3_packages(module): + import sagemaker.core # noqa: F401 -- ensure finder registered + + # The fallback finder must not intercept real v3 packages: find_spec must + # return None (pass-through), regardless of whether the package happens to + # be installed in the current environment. (The sagemaker-core unit-test job + # installs core only, so sagemaker.train/serve/mlops are not importable + # there -- asserting find_spec pass-through avoids that false dependency.) + finder = _RemovedV2ModuleFinder() + assert finder.find_spec(module) is None + + +def test_finder_ignores_non_sagemaker_and_nested(): + import sagemaker.core # noqa: F401 + + finder = _RemovedV2ModuleFinder() + # Not a sagemaker module -> pass through (None). + assert finder.find_spec("numpy") is None + # Nested path under a (removed) top-level -> only the top-level is guarded. + assert finder.find_spec("sagemaker.workflow.steps") is None + # Real v3 top-level -> never guarded. + assert finder.find_spec("sagemaker.train") is None + + +# --- Name-set invariants (reviewer: what prevents a collision with a new module?) --- + + +def test_removed_set_does_not_overlap_real_v3_packages(): + # A removed-v2 name must never collide with a real v3 top-level package, + # otherwise the finder could claim a live package "was removed". + assert _REMOVED_V2_MODULES.isdisjoint(_KNOWN_V3_TOPLEVEL) + + +def test_all_curated_replacements_are_in_removed_set(): + # Every precisely-guided name must also be in the removed set (the finder + # checks membership there before dispatching to the curated message). + assert set(_V3_REPLACEMENTS).issubset(_REMOVED_V2_MODULES) + + +def test_finder_never_shadows_a_real_module_with_removed_name(monkeypatch): + # Even if a real module exists under a name that is in the removed set, the + # appended finder must not shadow it: normal import machinery resolves the + # real module first, so find_spec is never consulted. Simulate by injecting + # a real module and importing it. + import sys + import types + + import sagemaker.core # noqa: F401 -- ensure finder registered + + name = "sagemaker.inputs" # a name present in _REMOVED_V2_MODULES + assert "inputs" in _REMOVED_V2_MODULES + real = types.ModuleType(name) + real.SENTINEL = object() + monkeypatch.setitem(sys.modules, name, real) + resolved = importlib.import_module(name) + assert resolved is real # real module wins; finder never intercepts it + + +# --- Replacement targets stay valid (guards against silent doc/import rot) --- + + +def _parse_import(stmt): + """Parse ``from import `` into (module, symbol).""" + _, module, _, symbol = stmt.split() + return module, symbol + + +@pytest.mark.parametrize("leaf,entry", sorted(_V3_REPLACEMENTS.items())) +def test_curated_v3_imports_resolve(leaf, entry): + # Recommendation B: every curated v3 import target must actually resolve in + # the installed SDK, so a future rename/move of a replacement fails CI + # instead of shipping a dead import string. The target package may live in a + # sibling distribution (sagemaker-train/serve/mlops) that is not installed in + # the core-only test job -- importorskip skips cleanly there and this runs in + # the all-extras lane. + _replacement, v3_import, docs_module = entry + module, symbol = _parse_import(v3_import) + mod = pytest.importorskip(module) + assert hasattr(mod, symbol), f"{module} has no attribute {symbol} (referenced by {leaf})" + + +def test_curated_docs_url_matches_import_module_root(): + # The docs URL is a derivation of a module path (not independently + # maintained); assert its module segment shares the same top-level package + # as the import so the two cannot silently drift to different subsystems. + for leaf, (_replacement, v3_import, docs_module) in _V3_REPLACEMENTS.items(): + import_module, _symbol = _parse_import(v3_import) + assert docs_module.split(".")[:2] == import_module.split(".")[:2], leaf