diff --git a/azure/functions/_durable_functions.py b/azure/functions/_durable_functions.py index aa533679..451018b8 100644 --- a/azure/functions/_durable_functions.py +++ b/azure/functions/_durable_functions.py @@ -1,86 +1,285 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""JSON serialization helpers for Durable Functions custom objects. + +This module exposes a registry-based, symmetric encode/decode pipeline +for round-tripping user-defined classes through JSON, plus back-compat +shims for the historical ``default=`` / ``object_hook=`` helper pair. + +The new pipeline (``to_json_string`` / ``from_json_string``) only +reconstructs classes that have been registered via +``register_durable_serializable_type``; dicts whose shape would collide +with a marker are escaped on encode and restored on decode. The legacy +shims (``_serialize_custom_object`` / ``_deserialize_custom_object``) +preserve the original wire shape for back-compat but resolve target +classes only through the registry or already-loaded modules; no module +is imported during decoding. +""" +from __future__ import annotations + +import json +import logging +import os +import sys +import warnings +from threading import Lock +from typing import Any, Dict, Type, Union -from typing import Union from . import _abc -from importlib import import_module -# Utilities +_OBJ_KEY = "__azfunc_obj__" +_ESC_KEY = "__azfunc_escaped__" +_LEGACY_KEYS = ("__class__", "__module__", "__data__") + +_registered_types: Dict[str, Type] = {} +_registry_lock = Lock() + +_STRICT_LEGACY = os.environ.get( + "AZURE_FUNCTIONS_DURABLE_STRICT_LEGACY_DESERIALIZE", "" +).lower() in ("1", "true", "yes") + +_logger = logging.getLogger("azure.functions.DurableFunctions") + + +def register_durable_serializable_type(cls: Type) -> Type: + """Register ``cls`` as eligible for JSON round-tripping. + + Usable as a decorator. Idempotent for the same class; raises + ``ValueError`` if a different class is already registered under the + same module-qualified name. The class must define both ``to_json`` + and ``from_json``. + """ + if not (hasattr(cls, "to_json") and hasattr(cls, "from_json")): + raise TypeError( + f"{cls!r} must define both `to_json` and `from_json` " + "to be registered as a durable-serializable type." + ) + key = f"{cls.__module__}.{cls.__qualname__}" + with _registry_lock: + prior = _registered_types.get(key) + if prior is not None and prior is not cls: + raise ValueError( + f"A different class is already registered as {key!r}" + ) + _registered_types[key] = cls + return cls + + +def _is_single_key(d: dict, key: str) -> bool: + return len(d) == 1 and next(iter(d)) == key + + +def _is_legacy_marker(d: dict) -> bool: + return all(k in d for k in _LEGACY_KEYS) + + +def _encode(value: Any) -> Any: + """Walk ``value`` and convert registered instances to markers. + + Dicts whose shape would collide with a current or legacy marker are + wrapped under ``_ESC_KEY`` so that decode can restore them as-is. + """ + if isinstance(value, dict): + if (_is_single_key(value, _OBJ_KEY) + or _is_single_key(value, _ESC_KEY) + or _is_legacy_marker(value)): + return {_ESC_KEY: {k: _encode(v) for k, v in value.items()}} + return {k: _encode(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_encode(v) for v in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if hasattr(value, "to_json"): + key = f"{type(value).__module__}.{type(value).__qualname__}" + if key not in _registered_types: + raise TypeError( + f"{type(value)!r} has `to_json` but is not registered " + "as a durable-serializable type. " + "Call register_durable_serializable_type(cls) at app startup." + ) + return {_OBJ_KEY: {"t": key, "d": _encode(type(value).to_json(value))}} + raise TypeError(f"Object of type {type(value)!r} is not JSON serializable") + + +def _resolve_loaded_class(module_name: str, class_name: str): + """Return a class named ``class_name`` from an already-loaded module. + + Returns ``None`` unless every check passes: + + * ``module_name`` is present in ``sys.modules`` (no import is performed), + * the attribute is a real ``type``, + * ``from_json`` is defined directly on the class as a + ``classmethod`` or ``staticmethod`` and is callable, + * ``to_json`` is also defined, + * the class's ``__module__`` matches ``module_name``. + """ + module = sys.modules.get(module_name) + if module is None: + return None + cls = getattr(module, class_name, None) + if cls is None or not isinstance(cls, type): + return None + raw = cls.__dict__.get("from_json") + if not isinstance(raw, (classmethod, staticmethod)): + return None + if not callable(getattr(cls, "from_json", None)): + return None + if not hasattr(cls, "to_json"): + return None + if getattr(cls, "__module__", None) != module_name: + return None + return cls + + +def _legacy_resolve(mod_name: str, cls_name: str): + """Resolve a legacy ``__class__/__module__/__data__`` marker. + + Consults the registry first; falls back to ``_resolve_loaded_class`` + unless strict mode is enabled. Never imports a module. + """ + cls = _registered_types.get(f"{mod_name}.{cls_name}") + if cls is not None: + return cls + if _STRICT_LEGACY: + return None + return _resolve_loaded_class(mod_name, cls_name) + + +def _warn_legacy_hit(mod_name: str, cls_name: str, *, resolved: bool) -> None: + """Emit a log warning and ``DeprecationWarning`` for legacy decode.""" + if resolved: + msg = ( + f"Durable Functions reconstructed {mod_name}.{cls_name} via the " + "legacy custom-object marker. Migrate to " + "azure.functions._durable_functions." + "register_durable_serializable_type(cls); the legacy shape " + "will be removed in the next major version." + ) + else: + msg = ( + f"Durable Functions saw a legacy custom-object marker for " + f"{mod_name}.{cls_name} but could not resolve it. " + "Returning as dict. Either register the class with " + "register_durable_serializable_type(cls), or import the " + "module at app startup and ensure the class defines " + "to_json/from_json (from_json must be a classmethod or " + "staticmethod). The legacy shape will be removed in the next " + "major version." + ) + _logger.warning(msg) + warnings.warn(msg, DeprecationWarning, stacklevel=3) + + +def _decode(value: Any, *, accept_legacy: bool = False) -> Any: + """Inverse of :func:`_encode`. + + When ``accept_legacy`` is true, dicts in the + ``__class__/__module__/__data__`` shape are also recognised and + resolved via :func:`_legacy_resolve`. Unknown markers are returned + as plain dicts. + """ + if isinstance(value, dict): + if _is_single_key(value, _OBJ_KEY): + marker = value[_OBJ_KEY] + if not (isinstance(marker, dict) + and isinstance(marker.get("t"), str) + and "d" in marker): + return value + cls = _registered_types.get(marker["t"]) + decoded_data = _decode(marker["d"], accept_legacy=accept_legacy) + if cls is None: + return {_OBJ_KEY: {"t": marker["t"], "d": decoded_data}} + return cls.from_json(decoded_data) + + if _is_single_key(value, _ESC_KEY): + inner = value[_ESC_KEY] + if isinstance(inner, dict): + return {k: _decode(v, accept_legacy=accept_legacy) + for k, v in inner.items()} + return inner + + if accept_legacy and _is_legacy_marker(value): + cls_name = value.get("__class__") + mod_name = value.get("__module__") + data = value.get("__data__") + if isinstance(cls_name, str) and isinstance(mod_name, str): + cls = _legacy_resolve(mod_name, cls_name) + if cls is not None: + _warn_legacy_hit(mod_name, cls_name, resolved=True) + return cls.from_json( + _decode(data, accept_legacy=accept_legacy) + ) + _warn_legacy_hit(mod_name, cls_name, resolved=False) + return {k: _decode(v, accept_legacy=accept_legacy) + for k, v in value.items()} + + return {k: _decode(v, accept_legacy=accept_legacy) + for k, v in value.items()} + + if isinstance(value, list): + return [_decode(v, accept_legacy=accept_legacy) for v in value] + return value + + +def to_json_string(obj: Any) -> str: + """Serialize ``obj`` to a JSON string using the symmetric pipeline.""" + return json.dumps(_encode(obj)) + + +def from_json_string(s: str, *, accept_legacy: bool = False) -> Any: + """Deserialize a JSON string produced by :func:`to_json_string`. + + Set ``accept_legacy=True`` to also decode values written by older + library versions in the ``__class__/__module__/__data__`` shape. + """ + return _decode(json.loads(s), accept_legacy=accept_legacy) + + def _serialize_custom_object(obj): - """Serialize a user-defined object to JSON. - - This function gets called when `json.dumps` cannot serialize - an object and returns a serializable dictionary containing enough - metadata to recontrust the original object. - - Parameters - ---------- - obj: Object - The object to serialize - - Returns - ------- - dict_obj: A serializable dictionary with enough metadata to reconstruct - `obj` - - Exceptions - ---------- - TypeError: - Raise if `obj` does not contain a `to_json` attribute + """Back-compat ``default=`` callback for ``json.dumps``. + + Emits the legacy ``__class__/__module__/__data__`` shape so older + consumers continue to decode correctly. Only registered classes are + accepted. """ - # 'safety' guard: raise error if object does not - # support serialization + key = f"{type(obj).__module__}.{type(obj).__qualname__}" + if key not in _registered_types: + raise TypeError( + f"{type(obj)!r} is not a registered durable-serializable type." + ) if not hasattr(obj, "to_json"): - raise TypeError(f"class {type(obj)} does not expose a `to_json` " - "function") - # Encode to json using the object's `to_json` - obj_type = type(obj) + raise TypeError(f"{type(obj)!r} does not expose `to_json`.") return { - "__class__": obj.__class__.__name__, - "__module__": obj.__module__, - "__data__": obj_type.to_json(obj) + "__class__": type(obj).__qualname__, + "__module__": type(obj).__module__, + "__data__": type(obj).to_json(obj), } def _deserialize_custom_object(obj: dict) -> object: - """Deserialize a user-defined object from JSON. - - Deserializes a dictionary encoding a custom object, - if it contains class metadata suggesting that it should be - decoded further. - - Parameters: - ---------- - obj: dict - Dictionary object that potentially encodes a custom class - - Returns: - -------- - object - Either the original `obj` dictionary or the custom object it encoded - - Exceptions - ---------- - TypeError - If the decoded object does not contain a `from_json` function + """Back-compat ``object_hook`` callback for ``json.loads``. + + Resolves target classes via :func:`_legacy_resolve` (registry, then + already-loaded modules in non-strict mode). No module is imported. + Unrecognised markers are returned as plain dicts. """ - if ("__class__" in obj) and ("__module__" in obj) and ("__data__" in obj): - class_name = obj.pop("__class__") - module_name = obj.pop("__module__") - obj_data = obj.pop("__data__") - - # Importing the clas - module = import_module(module_name) - class_ = getattr(module, class_name) - - if not hasattr(class_, "from_json"): - raise TypeError(f"class {type(obj)} does not expose a `from_json` " - "function") - - # Initialize the object using its `from_json` deserializer - obj = class_.from_json(obj_data) - return obj + if not all(k in obj for k in _LEGACY_KEYS): + return obj + cls_name = obj.get("__class__") + mod_name = obj.get("__module__") + if not (isinstance(cls_name, str) and isinstance(mod_name, str)): + return obj + cls = _legacy_resolve(mod_name, cls_name) + if cls is None: + _warn_legacy_hit(mod_name, cls_name, resolved=False) + return obj + _warn_legacy_hit(mod_name, cls_name, resolved=True) + data = obj["__data__"] + obj.pop("__class__") + obj.pop("__module__") + obj.pop("__data__") + return cls.from_json(data) class OrchestrationContext(_abc.OrchestrationContext): diff --git a/tests/test_durable_functions.py b/tests/test_durable_functions.py index 043b373d..40f7f37a 100644 --- a/tests/test_durable_functions.py +++ b/tests/test_durable_functions.py @@ -1,8 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import unittest import json +import sys +import threading +import unittest +import warnings from azure.functions.durable_functions import ( OrchestrationTriggerConverter, @@ -14,12 +17,58 @@ OrchestrationContext, EntityContext ) +from azure.functions import _durable_functions as df from azure.functions.meta import Datum CONTEXT_CLASSES = [OrchestrationContext, EntityContext] CONVERTERS = [OrchestrationTriggerConverter, EnitityTriggerConverter] +class City: + """Sample serializable type used by the helper tests.""" + + def __init__(self, name, population): + self.name = name + self.population = population + + def __eq__(self, other): + return (isinstance(other, City) + and self.name == other.name + and self.population == other.population) + + def to_json(self): + return {"name": self.name, "population": self.population} + + @classmethod + def from_json(cls, data): + return cls(data["name"], data["population"]) + + +class PlainFromJsonClass: + """Has from_json as an instance method (must be rejected by resolver).""" + + def to_json(self): + return {} + + def from_json(self, data): # not classmethod/staticmethod + return self + + +class NoToJsonClass: + """Defines from_json but no to_json (must be rejected by resolver).""" + + @classmethod + def from_json(cls, data): + return cls() + + +_NOT_A_CLASS = "i am a string" + + +def _from_json_function(data): + return data + + class TestDurableFunctions(unittest.TestCase): def test_context_string_body(self): body = '{ "name": "great function" }' @@ -344,3 +393,363 @@ def test_durable_client_converter_decode(self): data = Datum(type="weird", value="???") with self.assertRaises(ValueError): DurableClientConverter.decode(data=data, trigger_metadata=None) + + +def _register(cls): + """Register a class and unregister it during teardown.""" + df.register_durable_serializable_type(cls) + + +def _unregister_all(): + df._registered_types.clear() + + +class _NoLazyImports: + """Context manager that asserts decode does not trigger lazy imports. + + Decoding is expected to be a data transformation only. + """ + + def __init__(self, testcase): + self.tc = testcase + self._original = None + + def __enter__(self): + import importlib as _il + self._original = _il.import_module + + def _fail(name, package=None): + raise AssertionError( + "decode triggered a lazy import: " + f"name={name!r} package={package!r}" + ) + + _il.import_module = _fail + return self + + def __exit__(self, *exc): + import importlib as _il + if self._original is not None: + _il.import_module = self._original + + +class TestDurableSerializationRegistry(unittest.TestCase): + + def tearDown(self): + _unregister_all() + + def test_register_requires_to_json_and_from_json(self): + class Bad: + pass + + with self.assertRaises(TypeError): + df.register_durable_serializable_type(Bad) + + def test_register_is_idempotent_for_same_class(self): + df.register_durable_serializable_type(City) + df.register_durable_serializable_type(City) # no error + + def test_register_rejects_conflicting_class(self): + df.register_durable_serializable_type(City) + + Other = type("City", (), { + "__module__": City.__module__, + "__qualname__": City.__qualname__, + "to_json": lambda self: {}, + "from_json": classmethod(lambda cls, d: cls()), + }) + with self.assertRaises(ValueError): + df.register_durable_serializable_type(Other) + + +class TestSymmetricRoundTrip(unittest.TestCase): + + def tearDown(self): + _unregister_all() + + def _round_trip(self, value): + return df.from_json_string(df.to_json_string(value)) + + def test_plain_json_values_round_trip_unchanged(self): + corpus = [ + None, + True, + False, + 0, + -1, + 3.14, + "", + "hello", + [], + [1, 2, 3], + {}, + {"a": 1, "b": [1, 2], "c": {"d": "e"}}, + ] + for value in corpus: + with self.subTest(value=value): + self.assertEqual(self._round_trip(value), value) + + def test_dicts_with_individual_legacy_keys_round_trip_unchanged(self): + corpus = [ + {"__class__": "X"}, + {"__module__": "M"}, + {"__data__": 1}, + {"__class__": "X", "__module__": "M"}, + {"__class__": "X", "__data__": 1}, + {"__module__": "M", "__data__": 1}, + ] + for value in corpus: + with self.subTest(value=value): + self.assertEqual(self._round_trip(value), value) + + def test_dict_with_all_legacy_keys_round_trips_as_dict(self): + forged = {"__class__": "City", "__module__": "antigravity", + "__data__": {}} + self.assertEqual(self._round_trip(forged), forged) + self.assertNotIsInstance(self._round_trip(forged), City) + + def test_nested_collisions_are_escaped_and_restored(self): + value = { + "outer": [ + {"__class__": "X", "__module__": "Y", "__data__": 1}, + {"__azfunc_obj__": {"t": "x", "d": 0}}, + {"__azfunc_escaped__": "x"}, + {"normal": "dict"}, + ], + } + self.assertEqual(self._round_trip(value), value) + + def test_registered_instance_round_trips(self): + df.register_durable_serializable_type(City) + c = City("Seattle", 750000) + self.assertEqual(self._round_trip(c), c) + + def test_registered_instance_nested_in_collection(self): + df.register_durable_serializable_type(City) + value = {"cities": [City("A", 1), City("B", 2)], "count": 2} + self.assertEqual(self._round_trip(value), value) + + def test_unregistered_to_json_class_raises_on_serialize(self): + c = City("Seattle", 1) + with self.assertRaises(TypeError): + df.to_json_string(c) + + def test_non_json_serializable_object_raises(self): + with self.assertRaises(TypeError): + df.to_json_string(object()) + + +class TestDecodeIsPureTransformation(unittest.TestCase): + """Decode is a data operation only.""" + + def tearDown(self): + _unregister_all() + + def test_new_pipeline_with_registered_class(self): + df.register_durable_serializable_type(City) + s = df.to_json_string(City("X", 1)) + with _NoLazyImports(self): + self.assertEqual(df.from_json_string(s), City("X", 1)) + + def test_new_pipeline_with_unknown_marker(self): + s = json.dumps({"__azfunc_obj__": {"t": "no.such.Thing", "d": {}}}) + with _NoLazyImports(self): + result = df.from_json_string(s) + self.assertEqual( + result, {"__azfunc_obj__": {"t": "no.such.Thing", "d": {}}} + ) + + def test_legacy_string_decode_with_unloaded_module(self): + s = json.dumps({"__class__": "Thing", "__module__": "no_such_module_xyz", + "__data__": {}}) + with _NoLazyImports(self), warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result["__class__"], "Thing") + + def test_legacy_object_hook_with_loaded_module(self): + df.register_durable_serializable_type(City) + payload = {"__class__": "City", "__module__": City.__module__, + "__data__": {"name": "X", "population": 1}} + s = json.dumps(payload) + with _NoLazyImports(self), warnings.catch_warnings(): + warnings.simplefilter("ignore") + decoded = json.loads(s, object_hook=df._deserialize_custom_object) + self.assertEqual(decoded, City("X", 1)) + + def test_legacy_object_hook_with_unloaded_module(self): + payload = {"__class__": "Thing", "__module__": "no_such_module_xyz", + "__data__": {}} + with _NoLazyImports(self), warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = df._deserialize_custom_object(dict(payload)) + self.assertEqual(result, payload) + + +class TestLegacyDecodePathFallback(unittest.TestCase): + + def tearDown(self): + _unregister_all() + # Restore default; individual tests may toggle. + df._STRICT_LEGACY = False + + def test_resolver_finds_class_in_loaded_module(self): + # City is defined in this test module (already in sys.modules). + cls = df._resolve_loaded_class(City.__module__, "City") + self.assertIs(cls, City) + + def test_resolver_returns_none_for_unloaded_module(self): + self.assertNotIn("no_such_module_xyz", sys.modules) + self.assertIsNone( + df._resolve_loaded_class("no_such_module_xyz", "Thing") + ) + + def test_resolver_rejects_instance_method_from_json(self): + self.assertIsNone( + df._resolve_loaded_class(__name__, "PlainFromJsonClass") + ) + + def test_resolver_rejects_class_without_to_json(self): + self.assertIsNone( + df._resolve_loaded_class(__name__, "NoToJsonClass") + ) + + def test_resolver_rejects_non_class_attribute(self): + self.assertIsNone(df._resolve_loaded_class(__name__, "_NOT_A_CLASS")) + self.assertIsNone( + df._resolve_loaded_class(__name__, "_from_json_function") + ) + + def test_resolver_rejects_re_export(self): + # City re-exposed under a different module name -> rejected + # because cls.__module__ does not match. + fake_mod_name = "tests.test_durable_functions_fake_export" + fake = type(sys)("fake") + fake.City = City + sys.modules[fake_mod_name] = fake + try: + self.assertIsNone( + df._resolve_loaded_class(fake_mod_name, "City") + ) + finally: + sys.modules.pop(fake_mod_name, None) + + def test_legacy_decode_via_sys_modules_fallback(self): + payload = {"__class__": "City", "__module__": City.__module__, + "__data__": {"name": "Y", "population": 2}} + s = json.dumps(payload) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result, City("Y", 2)) + self.assertTrue(any(issubclass(w.category, DeprecationWarning) + for w in caught)) + + def test_legacy_decode_strict_mode_returns_dict(self): + df._STRICT_LEGACY = True + payload = {"__class__": "City", "__module__": City.__module__, + "__data__": {"name": "Y", "population": 2}} + s = json.dumps(payload) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result, payload) + self.assertTrue(any(issubclass(w.category, DeprecationWarning) + for w in caught)) + + def test_legacy_decode_unloaded_module_returns_dict(self): + payload = {"__class__": "Thing", "__module__": "no_such_module_xyz", + "__data__": {}} + s = json.dumps(payload) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result, payload) + self.assertTrue(any(issubclass(w.category, DeprecationWarning) + for w in caught)) + + def test_legacy_decode_registered_class_takes_precedence(self): + df.register_durable_serializable_type(City) + payload = {"__class__": "City", "__module__": City.__module__, + "__data__": {"name": "Z", "population": 3}} + s = json.dumps(payload) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result, City("Z", 3)) + + def test_legacy_decode_without_accept_legacy_returns_dict(self): + payload = {"__class__": "City", "__module__": City.__module__, + "__data__": {"name": "Q", "population": 4}} + s = json.dumps(payload) + result = df.from_json_string(s, accept_legacy=False) + self.assertEqual(result, payload) + + def test_legacy_decode_unregistered_returns_dict(self): + payload = {"__class__": "Nope", "__module__": "no_such_module_xyz", + "__data__": {}} + s = json.dumps(payload) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = df.from_json_string(s, accept_legacy=True) + self.assertEqual(result, payload) + + +class TestLegacyShimSerialize(unittest.TestCase): + + def tearDown(self): + _unregister_all() + + def test_serialize_unregistered_class_raises(self): + c = City("X", 1) + with self.assertRaises(TypeError): + df._serialize_custom_object(c) + + def test_serialize_registered_class_emits_legacy_shape(self): + df.register_durable_serializable_type(City) + out = df._serialize_custom_object(City("X", 1)) + self.assertEqual(set(out.keys()), + {"__class__", "__module__", "__data__"}) + self.assertEqual(out["__class__"], "City") + self.assertEqual(out["__module__"], City.__module__) + self.assertEqual(out["__data__"], {"name": "X", "population": 1}) + + def test_legacy_shim_round_trip_via_json(self): + df.register_durable_serializable_type(City) + s = json.dumps(City("R", 5), default=df._serialize_custom_object) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + decoded = json.loads(s, object_hook=df._deserialize_custom_object) + self.assertEqual(decoded, City("R", 5)) + + +class TestRegistryConcurrency(unittest.TestCase): + + def tearDown(self): + _unregister_all() + + def test_concurrent_registration_is_safe(self): + errors = [] + + def worker(): + try: + for _ in range(50): + df.register_durable_serializable_type(City) + except Exception as exc: # pragma: no cover - reported via errors + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(errors, []) + key = f"{City.__module__}.{City.__qualname__}" + self.assertIs(df._registered_types[key], City) + + +class TestModuleSurface(unittest.TestCase): + """Module-level imports stay minimal.""" + + def test_module_does_not_expose_import_module(self): + self.assertFalse(hasattr(df, "import_module"))