|
25 | 25 | from lazyimports.core import ( |
26 | 26 | NATIVE_LAZY_IMPORT, |
27 | 27 | SUPPORT_LAZY_IMPORT, |
| 28 | + SETATTR_TARGET, |
28 | 29 | LazyModule, |
29 | 30 | force_load, |
30 | 31 | is_lazy, |
@@ -356,5 +357,274 @@ def test_multiple_proxies_for_same_module(self): |
356 | 357 | self.assertIsNotNone(b._lazy_real) |
357 | 358 |
|
358 | 359 |
|
| 360 | +# License MIT <aiwonderland> in <2026> |
| 361 | +class TestSetattrTarget(unittest.TestCase): |
| 362 | + """Tests for the ``SETATTR_TARGET`` mode switch. |
| 363 | +
|
| 364 | + ``SETATTR_TARGET`` controls whether ``LazyModule.__setattr__`` |
| 365 | + forwards attribute writes to the underlying module (``"module"``, |
| 366 | + the default) or mounts them on the proxy shell only |
| 367 | + (``"proxy"``). |
| 368 | + """ |
| 369 | + |
| 370 | + # Helper used by the mode tests. Save and restore the global so |
| 371 | + # a failure in one test cannot leak into another. |
| 372 | + def setUp(self): |
| 373 | + self._previous_mode = SETATTR_TARGET |
| 374 | + |
| 375 | + def tearDown(self): |
| 376 | + # Reset to the default for every test that runs afterwards. |
| 377 | + lazyimports.core.SETATTR_TARGET = self._previous_mode |
| 378 | + # In case the default ever changes, fall back to "module". |
| 379 | + if lazyimports.core.SETATTR_TARGET not in ("module", "proxy"): |
| 380 | + lazyimports.core.SETATTR_TARGET = "module" |
| 381 | + |
| 382 | + def test_default_is_module(self): |
| 383 | + # The package default must be the backward-compatible mode. |
| 384 | + self.assertEqual(SETATTR_TARGET, "module") |
| 385 | + |
| 386 | + def test_module_mode_forwards_to_underlying_module(self): |
| 387 | + lazyimports.core.SETATTR_TARGET = "module" |
| 388 | + proxy = lazy_import("math") |
| 389 | + sentinel_name = "_setattr_target_module_test_attr" |
| 390 | + try: |
| 391 | + proxy.SCRATCH_ATTR = "via-proxy" |
| 392 | + # The attribute must be visible on the real module too |
| 393 | + # (because the write was forwarded). |
| 394 | + self.assertEqual(math.SCRATCH_ATTR, "via-proxy") |
| 395 | + finally: |
| 396 | + if hasattr(math, sentinel_name): |
| 397 | + delattr(math, sentinel_name) |
| 398 | + |
| 399 | + def test_proxy_mode_does_not_load_module(self): |
| 400 | + lazyimports.core.SETATTR_TARGET = "proxy" |
| 401 | + # Use a sentinel module name guaranteed not to exist; the |
| 402 | + # ``"proxy"`` mode must not even attempt to import it. |
| 403 | + sentinel = "_lazyimports_setattr_proxy_sentinel_xyz" |
| 404 | + proxy = lazy_import(sentinel) |
| 405 | + # No exception yet because we have not touched the module. |
| 406 | + proxy.SCRATCH_ATTR = "scratch" |
| 407 | + # The proxy shell must carry the value. |
| 408 | + self.assertEqual(proxy.SCRATCH_ATTR, "scratch") |
| 409 | + # And the underlying import must NOT have been triggered. |
| 410 | + self.assertIsNone(proxy._lazy_real) |
| 411 | + self.assertIsNone(proxy._lazy_error) |
| 412 | + # Sanity: touching a real attribute now should still raise. |
| 413 | + with self.assertRaises(ImportError): |
| 414 | + _ = proxy.any_real_attribute # noqa: B018 |
| 415 | + |
| 416 | + def test_proxy_mode_is_per_instance(self): |
| 417 | + # Two proxies in proxy mode must not see each other's state |
| 418 | + # because the writes are local to the shell. |
| 419 | + lazyimports.core.SETATTR_TARGET = "proxy" |
| 420 | + sentinel = "_lazyimports_setattr_proxy_isolation_xyz" |
| 421 | + a = lazy_import(sentinel) |
| 422 | + b = lazy_import(sentinel) |
| 423 | + a.ONLY_ON_A = 1 |
| 424 | + # Check ``b``'s instance dict directly so we don't trigger |
| 425 | + # ``__getattr__`` (which would try to import the missing |
| 426 | + # module). The whole point of proxy mode is to keep state |
| 427 | + # local, so verifying the dicts are isolated is enough. |
| 428 | + self.assertNotIn("ONLY_ON_A", b.__dict__) |
| 429 | + self.assertIn("ONLY_ON_A", a.__dict__) |
| 430 | + # ``a`` still has its value. |
| 431 | + self.assertEqual(a.ONLY_ON_A, 1) |
| 432 | + |
| 433 | + def test_internal_lazy_slots_unaffected_by_mode(self): |
| 434 | + # The ``_lazy_*`` slot writes always go through |
| 435 | + # ``object.__setattr__`` regardless of the mode flag. |
| 436 | + for mode in ("module", "proxy"): |
| 437 | + with self.subTest(mode=mode): |
| 438 | + lazyimports.core.SETATTR_TARGET = mode |
| 439 | + proxy = lazy_import("os") |
| 440 | + # Touching internal slots must work in both modes. |
| 441 | + self.assertEqual(proxy._target(), "os") |
| 442 | + self.assertIsNone(proxy._lazy_real) |
| 443 | + self.assertIsNone(proxy._lazy_error) |
| 444 | + |
| 445 | + |
| 446 | +# License MIT <aiwonderland> in <2026> |
| 447 | +class TestImportErrorCaching(unittest.TestCase): |
| 448 | + """``ImportError`` on first resolve must be cached. |
| 449 | +
|
| 450 | + Missing optional dependencies should not be retried on every |
| 451 | + attribute access — that would defeat the whole purpose of the |
| 452 | + backport for conditional dependencies. |
| 453 | + """ |
| 454 | + |
| 455 | + def test_missing_module_caches_error(self): |
| 456 | + sentinel = "_lazyimports_cache_err_does_not_exist_xyz" |
| 457 | + proxy = lazy_import(sentinel) |
| 458 | + # First access fails and caches the exception. |
| 459 | + with self.assertRaises(ImportError): |
| 460 | + proxy.any_attr # noqa: B018 |
| 461 | + cached = proxy._lazy_error |
| 462 | + self.assertIsNotNone(cached) |
| 463 | + self.assertIsInstance(cached, ImportError) |
| 464 | + # The real module must still be unset. |
| 465 | + self.assertIsNone(proxy._lazy_real) |
| 466 | + # Subsequent accesses re-raise the SAME cached exception |
| 467 | + # object (proves we did not retry the import). |
| 468 | + with self.assertRaises(ImportError) as ctx: |
| 469 | + proxy.other_attr # noqa: B018 |
| 470 | + self.assertIs(ctx.exception, cached) |
| 471 | + |
| 472 | + def test_force_load_after_failure_still_raises(self): |
| 473 | + # ``force_load`` must not magically succeed when the module |
| 474 | + # really does not exist; it just re-raises the cached error. |
| 475 | + sentinel = "_lazyimports_force_load_after_err_xyz" |
| 476 | + proxy = lazy_import(sentinel) |
| 477 | + with self.assertRaises(ImportError): |
| 478 | + _ = proxy.any_attr # noqa: B018 |
| 479 | + with self.assertRaises(ImportError): |
| 480 | + force_load(proxy) |
| 481 | + |
| 482 | + def test_repr_after_failure_includes_error(self): |
| 483 | + sentinel = "_lazyimports_repr_after_err_xyz" |
| 484 | + proxy = lazy_import(sentinel) |
| 485 | + with self.assertRaises(ImportError): |
| 486 | + proxy.any_attr # noqa: B018 |
| 487 | + text = repr(proxy) |
| 488 | + # The failure must be visible in the repr so debugging is easy. |
| 489 | + self.assertIn("[failed", text) |
| 490 | + self.assertIn(sentinel, text) |
| 491 | + |
| 492 | + |
| 493 | +# License MIT <aiwonderland> in <2026> |
| 494 | +class TestMagicMethodsNoLoad(unittest.TestCase): |
| 495 | + """Operator dunders must not trigger a lazy import. |
| 496 | +
|
| 497 | + Python looks up operator dunders (``__eq__``, ``__hash__``, ...) |
| 498 | + on the *type*, not on the instance, so they never reach |
| 499 | + ``__getattr__`` and never trigger an import. These tests pin |
| 500 | + that contract down. |
| 501 | + """ |
| 502 | + |
| 503 | + def test_eq_uses_target_name(self): |
| 504 | + a = lazy_import("os") |
| 505 | + b = lazy_import("os") |
| 506 | + c = lazy_import("sys") |
| 507 | + # Equality must be true even before any module is loaded. |
| 508 | + self.assertEqual(a, b) |
| 509 | + self.assertNotEqual(a, c) |
| 510 | + # And no import should have been triggered by the comparison. |
| 511 | + self.assertIsNone(a._lazy_real) |
| 512 | + self.assertIsNone(b._lazy_real) |
| 513 | + self.assertIsNone(c._lazy_real) |
| 514 | + |
| 515 | + def test_ne_uses_target_name(self): |
| 516 | + a = lazy_import("os") |
| 517 | + b = lazy_import("sys") |
| 518 | + self.assertTrue(a != b) |
| 519 | + # ``a != b`` short-circuits on equality, so still no import. |
| 520 | + self.assertIsNone(a._lazy_real) |
| 521 | + self.assertIsNone(b._lazy_real) |
| 522 | + |
| 523 | + def test_eq_with_non_lazy_returns_not_equal(self): |
| 524 | + # A proxy is never equal to an arbitrary non-proxy object. |
| 525 | + proxy = lazy_import("os") |
| 526 | + self.assertNotEqual(proxy, 42) |
| 527 | + self.assertNotEqual(proxy, "os") |
| 528 | + self.assertNotEqual(proxy, None) |
| 529 | + self.assertIsNone(proxy._lazy_real) |
| 530 | + |
| 531 | + def test_hash_consistent_with_eq(self): |
| 532 | + # ``hash(a) == hash(b)`` whenever ``a == b``. This is the |
| 533 | + # invariant Python requires of any object that defines |
| 534 | + # ``__eq__``. |
| 535 | + a = lazy_import("os.path") |
| 536 | + b = lazy_import("os.path") |
| 537 | + self.assertEqual(a, b) |
| 538 | + self.assertEqual(hash(a), hash(b)) |
| 539 | + |
| 540 | + def test_bool_is_true_without_loading(self): |
| 541 | + proxy = lazy_import("os") |
| 542 | + self.assertIs(bool(proxy), True) |
| 543 | + self.assertIsNone(proxy._lazy_real) |
| 544 | + |
| 545 | + |
| 546 | +# License MIT <aiwonderland> in <2026> |
| 547 | +class TestEnhancedDir(unittest.TestCase): |
| 548 | + """``__dir__`` should surface ``__all__`` when the target is |
| 549 | + already in ``sys.modules``.""" |
| 550 | + |
| 551 | + def test_dir_merges_all_when_already_loaded(self): |
| 552 | + # ``os`` is imported very early by Python itself, so it is |
| 553 | + # almost always in ``sys.modules``. The proxy's dir() should |
| 554 | + # merge those exported names. |
| 555 | + proxy = lazy_import("os") |
| 556 | + names = dir(proxy) |
| 557 | + self.assertIn("sep", names) |
| 558 | + self.assertIn("path", names) |
| 559 | + self.assertIn("__class__", names) |
| 560 | + |
| 561 | + def test_dir_static_fallback_when_not_loaded(self): |
| 562 | + # Use a sentinel that is definitely not in sys.modules. |
| 563 | + sentinel = "_lazyimports_dir_sentinel_xyz" |
| 564 | + # Make absolutely sure it is not loaded. |
| 565 | + sys.modules.pop(sentinel, None) |
| 566 | + proxy = lazy_import(sentinel) |
| 567 | + names = dir(proxy) |
| 568 | + # Static baseline still contains the slot names. |
| 569 | + self.assertIsInstance(names, list) |
| 570 | + self.assertIn("__class__", names) |
| 571 | + # And must not have triggered an import. |
| 572 | + self.assertIsNone(proxy._lazy_real) |
| 573 | + self.assertIsNone(proxy._lazy_error) |
| 574 | + |
| 575 | + |
| 576 | +# License MIT <aiwonderland> in <2026> |
| 577 | +class TestSetattrTargetExported(unittest.TestCase): |
| 578 | + """The ``SETATTR_TARGET`` constant must be reachable from the |
| 579 | + package top level so users can read it. |
| 580 | +
|
| 581 | + Note on configuration |
| 582 | + --------------------- |
| 583 | + Python's import semantics mean that ``lazyimports.SETATTR_TARGET`` |
| 584 | + is a **re-exported binding**: it captures the string value at |
| 585 | + package import time. To change the mode at runtime, write to |
| 586 | + ``lazyimports.core.SETATTR_TARGET`` (the canonical location); |
| 587 | + the ``LazyModule.__setattr__`` implementation reads from there |
| 588 | + every time it is invoked. |
| 589 | + """ |
| 590 | + |
| 591 | + def test_exported_in_package_all(self): |
| 592 | + self.assertIn("SETATTR_TARGET", lazyimports.__all__) |
| 593 | + |
| 594 | + def test_exported_attribute_matches_core(self): |
| 595 | + # The re-exported value equals the live core value at import |
| 596 | + # time. (Strings are immutable, so identity equality is fine.) |
| 597 | + self.assertEqual(lazyimports.SETATTR_TARGET, core.SETATTR_TARGET) |
| 598 | + |
| 599 | + def test_configuration_via_core_round_trips(self): |
| 600 | + # Writing through ``lazyimports.core.SETATTR_TARGET`` is the |
| 601 | + # supported configuration path and must take effect |
| 602 | + # immediately for any new ``__setattr__`` call. |
| 603 | + original = core.SETATTR_TARGET |
| 604 | + try: |
| 605 | + core.SETATTR_TARGET = "proxy" |
| 606 | + proxy = lazy_import("os") |
| 607 | + # The proxy's ``__setattr__`` must observe the new mode. |
| 608 | + # We probe by setting an internal-only attribute via the |
| 609 | + # default code path: in proxy mode the underlying module |
| 610 | + # is NOT loaded as a side effect. |
| 611 | + proxy.SCRATCH = 1 |
| 612 | + # The write went to the shell, not to ``os`` itself, so |
| 613 | + # ``os`` must not have been touched (and therefore not |
| 614 | + # loaded by us through the test scenario). |
| 615 | + self.assertNotIn("SCRATCH", os.__dict__) |
| 616 | + core.SETATTR_TARGET = "module" |
| 617 | + finally: |
| 618 | + core.SETATTR_TARGET = original |
| 619 | + # Sanity: default mode behaviour is restored. |
| 620 | + proxy2 = lazy_import("math") |
| 621 | + try: |
| 622 | + proxy2.SCRATCH2 = 2 |
| 623 | + self.assertTrue(hasattr(math, "SCRATCH2")) |
| 624 | + finally: |
| 625 | + if hasattr(math, "SCRATCH2"): |
| 626 | + delattr(math, "SCRATCH2") |
| 627 | + |
| 628 | + |
359 | 629 | if __name__ == "__main__": |
360 | 630 | unittest.main(verbosity=2) |
0 commit comments