Skip to content

Commit 11580ab

Browse files
committed
Add more tests
1 parent e504f30 commit 11580ab

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

test_lazyimports.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from lazyimports.core import (
2626
NATIVE_LAZY_IMPORT,
2727
SUPPORT_LAZY_IMPORT,
28+
SETATTR_TARGET,
2829
LazyModule,
2930
force_load,
3031
is_lazy,
@@ -356,5 +357,274 @@ def test_multiple_proxies_for_same_module(self):
356357
self.assertIsNotNone(b._lazy_real)
357358

358359

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+
359629
if __name__ == "__main__":
360630
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)