Skip to content

Commit 43109bd

Browse files
committed
Add _check_SETATTR_TARGET_value and tests for it
1 parent 48a41dd commit 43109bd

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

lazyimports/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,13 @@
4040
is_lazy,
4141
force_load,
4242
lazy,
43+
_check_SETATTR_TARGET_value
4344
)
4445

4546

47+
_check_SETATTR_TARGET_value()
48+
del _check_SETATTR_TARGET_value
49+
4650
# License MIT <aiwonderland> in <2026>
4751
__version__ = "0.1.5"
4852
# License MIT <aiwonderland> in <2026>

lazyimports/core.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,81 @@
7777
API surface small. Change at your own risk and reset it before any
7878
code that expects the default behaviour runs."""
7979

80+
# The set of legal values for ``SETATTR_TARGET``. Exposed as a
81+
# module-level constant so users can introspect the contract without
82+
# having to call the validator first. Treated as a tuple (immutable)
83+
# so accidental mutation cannot widen the accepted set.
84+
_VALID_SETATTR_TARGETS = ("module", "proxy")
85+
86+
# License MIT <aiwonderland> in <2026>
87+
def _check_SETATTR_TARGET_value(value=None):
88+
"""Validate that ``value`` (or the current ``SETATTR_TARGET``)
89+
is one of the accepted modes for ``LazyModule.__setattr__``.
90+
91+
This helper is the single source of truth for what counts as a
92+
legal ``SETATTR_TARGET`` value. It is invoked in two places:
93+
94+
1. At import time, via the explicit ``_SETATTR_TARGET_VALIDATED``
95+
flag below, so a corrupted or mutated global is caught as
96+
early as possible.
97+
2. By :func:`set_SETATTR_TARGET`, the supported runtime entry
98+
point for changing the mode.
99+
100+
Parameters
101+
----------
102+
value : str or None, optional
103+
The value to validate. If ``None`` (the default), the
104+
module-level ``SETATTR_TARGET`` constant itself is checked.
105+
106+
Returns
107+
-------
108+
str
109+
The validated value, always one of ``"module"`` or
110+
``"proxy"``. Returning the value (instead of just raising on
111+
failure) lets callers use this function as a normaliser.
112+
113+
Raises
114+
------
115+
TypeError
116+
If ``value`` is provided and is not a ``str``. The mode
117+
names are string literals; any other type is unambiguously
118+
a programming error.
119+
ValueError
120+
If ``value`` is a string but not in
121+
``_VALID_SETATTR_TARGETS``. The message lists every
122+
accepted value and echoes back what was received, which is
123+
enough information to fix the mistake without consulting
124+
the source.
125+
"""
126+
if value is None:
127+
value = SETATTR_TARGET
128+
if not isinstance(value, str):
129+
# Type errors are reported separately from value errors so
130+
# callers (and ``set_SETATTR_TARGET``) can decide which
131+
# exception to surface to the end user.
132+
raise TypeError(
133+
"SETATTR_TARGET must be a str, got {!r} of type {}".format(
134+
value, type(value).__name__,
135+
)
136+
)
137+
if value not in _VALID_SETATTR_TARGETS:
138+
accepted = ", ".join(
139+
"{!r}".format(v) for v in _VALID_SETATTR_TARGETS
140+
)
141+
raise ValueError(
142+
"SETATTR_TARGET must be one of: {}; got {!r}".format(
143+
accepted, value,
144+
)
145+
)
146+
return value
147+
148+
# Run the validator once at import time so a broken module-level
149+
# value fails fast (at ``import lazyimports``) instead of silently
150+
# later. The result is intentionally discarded; we only care about
151+
# the side effect of raising on invalid input.
152+
_SETATTR_TARGET_VALIDATED = _check_SETATTR_TARGET_value()
153+
del _SETATTR_TARGET_VALIDATED
154+
80155
# GNUv3 License, add in <2018>, by <Evan Yang>
81156
class LazyModule(types.ModuleType):
82157
"""Proxy module that defers the real import until attribute access.

test_lazyimports.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
SUPPORT_LAZY_IMPORT,
2828
SETATTR_TARGET,
2929
LazyModule,
30+
_check_SETATTR_TARGET_value,
3031
force_load,
3132
is_lazy,
3233
lazy,
@@ -626,5 +627,151 @@ def test_configuration_via_core_round_trips(self):
626627
delattr(math, "SCRATCH2")
627628

628629

630+
# License MIT <aiwonderland> in <2026>
631+
class TestCheckSetattrTargetValue(unittest.TestCase):
632+
"""Tests for ``_check_SETATTR_TARGET_value``.
633+
634+
The validator is the single source of truth for what counts as a
635+
legal ``SETATTR_TARGET`` value. It must:
636+
637+
* return the validated value (acting as a normaliser);
638+
* accept both ``"module"`` and ``"proxy"``;
639+
* reject unknown strings with ``ValueError``;
640+
* reject non-string arguments with ``TypeError``;
641+
* treat ``value=None`` as a request to re-check the current
642+
module-level ``SETATTR_TARGET``.
643+
"""
644+
645+
def setUp(self):
646+
# Save and restore the global so an exception-raising test
647+
# does not leave the package in a broken state for the
648+
# tests that follow.
649+
self._previous_mode = core.SETATTR_TARGET
650+
651+
def tearDown(self):
652+
core.SETATTR_TARGET = self._previous_mode
653+
if core.SETATTR_TARGET not in ("module", "proxy"):
654+
core.SETATTR_TARGET = "module"
655+
656+
# ------------------------------------------------------------------
657+
# Happy path
658+
# ------------------------------------------------------------------
659+
def test_default_module_value_passes(self):
660+
# The shipped default must validate without raising. We
661+
# call without an argument so the function checks the
662+
# current ``SETATTR_TARGET``.
663+
result = _check_SETATTR_TARGET_value()
664+
self.assertEqual(result, SETATTR_TARGET)
665+
self.assertEqual(result, "module")
666+
667+
def test_explicit_module_string(self):
668+
result = _check_SETATTR_TARGET_value("module")
669+
self.assertEqual(result, "module")
670+
671+
def test_explicit_proxy_string(self):
672+
result = _check_SETATTR_TARGET_value("proxy")
673+
self.assertEqual(result, "proxy")
674+
675+
def test_none_argument_validates_current_value(self):
676+
# ``None`` is documented as ``"check the current value"``,
677+
# not as "missing argument". This is a behaviour the tests
678+
# lock down so a future refactor cannot quietly change it.
679+
core.SETATTR_TARGET = "proxy"
680+
result = _check_SETATTR_TARGET_value(None)
681+
self.assertEqual(result, "proxy")
682+
core.SETATTR_TARGET = "module"
683+
result = _check_SETATTR_TARGET_value(None)
684+
self.assertEqual(result, "module")
685+
686+
def test_returns_valid_value_for_use_as_normaliser(self):
687+
# The return value must equal the input when it is valid,
688+
# which lets callers use the function as a one-stop
689+
# ``str -> Literal[...]`` converter.
690+
self.assertEqual(_check_SETATTR_TARGET_value("module"), "module")
691+
self.assertEqual(_check_SETATTR_TARGET_value("proxy"), "proxy")
692+
693+
# ------------------------------------------------------------------
694+
# Value errors
695+
# ------------------------------------------------------------------
696+
def test_unknown_string_raises_value_error(self):
697+
for bad in ("MODULE", "Module", "Module ", "", "modules", "prxy"):
698+
with self.subTest(value=bad):
699+
with self.assertRaises(ValueError):
700+
_check_SETATTR_TARGET_value(bad)
701+
702+
def test_value_error_message_lists_accepted_values(self):
703+
# The message is part of the API surface: callers (and
704+
# automated tooling) rely on it being informative without
705+
# having to consult the source.
706+
try:
707+
_check_SETATTR_TARGET_value("nope")
708+
except ValueError as exc:
709+
message = str(exc)
710+
else:
711+
self.fail("ValueError not raised")
712+
# Both accepted values must appear in the message.
713+
self.assertIn("'module'", message)
714+
self.assertIn("'proxy'", message)
715+
# And the offending value must be echoed back so the
716+
# user can see what they sent.
717+
self.assertIn("'nope'", message)
718+
719+
def test_value_error_with_none_checks_current(self):
720+
# If the *current* ``SETATTR_TARGET`` has been corrupted
721+
# before the test runs, the validator must still catch it.
722+
core.SETATTR_TARGET = "garbage"
723+
with self.assertRaises(ValueError):
724+
_check_SETATTR_TARGET_value(None)
725+
726+
# ------------------------------------------------------------------
727+
# Type errors
728+
# ------------------------------------------------------------------
729+
def test_non_string_raises_type_error(self):
730+
for bad in (0, 1, 1.5, True, None, [], {}, (), b"module", object()):
731+
with self.subTest(value=bad, type=type(bad).__name__):
732+
# ``None`` is special-cased (means "check current
733+
# value") and should NOT raise ``TypeError``. Skip
734+
# it explicitly here.
735+
if bad is None:
736+
continue
737+
with self.assertRaises(TypeError):
738+
_check_SETATTR_TARGET_value(bad)
739+
740+
def test_type_error_message_mentions_str(self):
741+
try:
742+
_check_SETATTR_TARGET_value(42)
743+
except TypeError as exc:
744+
message = str(exc)
745+
else:
746+
self.fail("TypeError not raised")
747+
# The error must point at ``str`` as the expected type so
748+
# users immediately know what to pass instead.
749+
self.assertIn("str", message)
750+
751+
def test_subclass_of_str_is_accepted(self):
752+
# ``str`` subclasses are valid: this is standard Python
753+
# duck-typing and avoids surprising users who build their
754+
# own string-like types.
755+
class MyStr(str):
756+
pass
757+
self.assertEqual(_check_SETATTR_TARGET_value(MyStr("module")), "module")
758+
self.assertEqual(_check_SETATTR_TARGET_value(MyStr("proxy")), "proxy")
759+
760+
# ------------------------------------------------------------------
761+
# Integration with the package import-time check
762+
# ------------------------------------------------------------------
763+
def test_package_loaded_successfully(self):
764+
# ``__init__.py`` calls ``_check_SETATTR_TARGET_value()``
765+
# once at import time. The mere fact that we have reached
766+
# this test class proves the call succeeded; if the
767+
# default value were invalid the import would have raised
768+
# before any test could run.
769+
#
770+
# We additionally assert the global is still in a valid
771+
# state after all the previous tests have potentially
772+
# poked at it.
773+
_check_SETATTR_TARGET_value() # must not raise
774+
775+
629776
if __name__ == "__main__":
630777
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)