diff --git a/tests/corekit/test_sentinel_exports_unit.py b/tests/corekit/test_sentinel_exports_unit.py index b2550bc9f4..3255df529d 100644 --- a/tests/corekit/test_sentinel_exports_unit.py +++ b/tests/corekit/test_sentinel_exports_unit.py @@ -117,14 +117,18 @@ """ from __future__ import annotations +import importlib import pathlib import re import unittest -from pcapkit.corekit.enum import NO_DEFAULT, NoDefaultType -from pcapkit.corekit.fields.field import NO_VALUE, NoValueType -from pcapkit.corekit.module import NULL, NullType -from pcapkit.protocols.protocol import ABSENT, AbsentType +from tests._support import reimport_once_per_class + +# ``pcapkit`` is deliberately not imported at module load. GitHub issue #1332: the +# plain-unittest ordering legs leave a freshly re-imported ``pcapkit`` in +# ``sys.modules``, so a sentinel bound here would be the *old* import's object and +# every ``assertIs`` against the canonical one would fail. The sentinels are read +# through :func:`_sentinels` after :func:`~tests._support.reimport_once_per_class`. #: Repository root, for the two tests that read a file rather than import it. ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -134,8 +138,6 @@ #: below used to name a different module here (``pcapkit.corekit.module``, #: ``pcapkit.corekit.fields.field``, ``pcapkit.corekit.enum`` and #: ``pcapkit.protocols.protocol`` respectively); all four now report this one. -import pcapkit.corekit.sentinels as sentinels - CANONICAL_MODULE = 'pcapkit.corekit.sentinels' #: Every sentinel in the tree that follows the house ``Type`` convention, @@ -145,12 +147,7 @@ #: All four now share :data:`CANONICAL_MODULE` as their defining module, which is #: why a per-entry module column is no longer part of this tuple -- see #: :data:`PUBLIC_SENTINELS` below for the (still distinct) *shim* locations. -SENTINELS = ( - ('NULL', NULL, NullType), - ('NO_VALUE', NO_VALUE, NoValueType), - ('NO_DEFAULT', NO_DEFAULT, NoDefaultType), - ('ABSENT', ABSENT, AbsentType), -) +#: Built by :func:`_sentinels` from the live import, not at module load. #: The public three of :data:`SENTINELS`, as ``(module, object name, type name)``. #: ``ABSENT`` is absent from it deliberately: it is private, so it is exported @@ -163,6 +160,27 @@ ) +def _sentinels() -> 'tuple[tuple[str, object, type], ...]': + """:data:`SENTINELS`, read from the ``pcapkit`` import that is live now.""" + enum = importlib.import_module('pcapkit.corekit.enum') + field = importlib.import_module('pcapkit.corekit.fields.field') + module = importlib.import_module('pcapkit.corekit.module') + protocol = importlib.import_module('pcapkit.protocols.protocol') + return ( + ('NULL', module.NULL, module.NullType), + ('NO_VALUE', field.NO_VALUE, field.NoValueType), + ('NO_DEFAULT', enum.NO_DEFAULT, enum.NoDefaultType), + ('ABSENT', protocol.ABSENT, protocol.AbsentType), + ) + + +class _ReimportCase(unittest.TestCase): + """Base: give each test class one fresh ``pcapkit`` import (GitHub issue #1332).""" + + def setUp(self) -> 'None': + reimport_once_per_class(self) + + def _expected_type_name(instance_name: 'str') -> 'str': """The type name the house convention derives from an instance name. @@ -214,7 +232,7 @@ def _sentinel_section() -> 'str': return path.read_text(encoding='utf-8') -class SentinelExportTests(unittest.TestCase): +class SentinelExportTests(_ReimportCase): """``__all__`` names the object and not the type, per module.""" def test_module_exports_the_object_and_not_the_type(self) -> 'None': @@ -279,15 +297,14 @@ def test_star_import_hands_back_the_canonical_object(self) -> 'None': test above and break every ``is`` check the sentinel exists for. """ + canonical = importlib.import_module(CANONICAL_MODULE) for module, obj, _ in PUBLIC_SENTINELS: namespace = _star_import(module) with self.subTest(module=module): - # Matched on the instance name alone: every entry in ``SENTINELS`` - # now shares :data:`CANONICAL_MODULE`, so a ``where == module`` - # filter against the *shim* location would no longer distinguish - # them -- the instance names themselves already do. - expected = next(instance for name, instance, _ in SENTINELS if name == obj) - self.assertIs(namespace[obj], expected) + # Taken from the defining module, never from the shim being star-imported: + # both sides of the comparison would otherwise read the same shim attribute + # and the check could not fail if a shim defined its own object. + self.assertIs(namespace[obj], getattr(canonical, obj)) def test_every_sentinel_type_is_still_importable_by_name(self) -> 'None': """The exact extent of the breaking change: ``import *`` narrows, nothing else. @@ -297,7 +314,7 @@ def test_every_sentinel_type_is_still_importable_by_name(self) -> 'None': relies on that -- this module's own imports are the demonstration. """ - for name, instance, type_ in SENTINELS: + for name, instance, type_ in _sentinels(): with self.subTest(sentinel=name): self.assertIs(type(instance), type_) self.assertEqual(type_.__module__, CANONICAL_MODULE) @@ -323,7 +340,7 @@ def test_the_private_sentinel_is_exported_neither_way(self) -> 'None': self.assertNotIn('ABSENT', _star_import('pcapkit.protocols.protocol')) -class SentinelPopulationTests(unittest.TestCase): +class SentinelPopulationTests(_ReimportCase): """There are four, they follow the naming rule, and the docs say so.""" def test_every_sentinel_follows_the_naming_convention(self) -> 'None': @@ -332,7 +349,7 @@ def test_every_sentinel_follows_the_naming_convention(self) -> 'None': The house convention set under GitHub issue #857, kept uniform so that a future maintainer adding a sentinel can derive the type's name mechanically. """ - for name, _, type_ in SENTINELS: + for name, _, type_ in _sentinels(): with self.subTest(sentinel=name): self.assertEqual(type_.__name__, _expected_type_name(name)) @@ -363,7 +380,7 @@ def test_every_sentinel_object_is_screaming_snake(self) -> 'None': """ pattern = re.compile(r'^[A-Z][A-Z0-9_]*$') - for name, _, _ in SENTINELS: + for name, _, _ in _sentinels(): with self.subTest(sentinel=name): self.assertRegex(name, pattern) @@ -380,7 +397,7 @@ def test_conventions_doc_lists_every_sentinel_in_the_tree(self) -> 'None': """ section = _sentinel_section() - for name, _, type_ in SENTINELS: + for name, _, type_ in _sentinels(): with self.subTest(sentinel=name): self.assertIn(f'``{name}``', section) self.assertIn(f'``{type_.__name__}``', section) @@ -428,7 +445,7 @@ def test_conventions_doc_records_that_only_the_object_is_exported(self) -> 'None # The ruling itself, against the module it governs rather than against prose: # every public sentinel's instance is exported and its type is not. - exported = set(sentinels.__all__) + exported = set(importlib.import_module(CANONICAL_MODULE).__all__) for _, instance_name, type_name in PUBLIC_SENTINELS: with self.subTest(sentinel=instance_name): self.assertIn(instance_name, exported, @@ -466,10 +483,10 @@ def test_the_sentinel_table_has_a_row_per_sentinel_and_no_more(self) -> 'None': table = table[:table.index('\n\n', table.index('- Defined in'))] rows = re.findall(r'^ \* - (\S+)$', table, re.MULTILINE) - self.assertEqual(rows, ['Instance'] + [f'``{name}``' for name, _, _ in SENTINELS]) + self.assertEqual(rows, ['Instance'] + [f'``{name}``' for name, _, _ in _sentinels()]) -class SentinelBehaviourTests(unittest.TestCase): +class SentinelBehaviourTests(_ReimportCase): """The per-sentinel differences :file:`docs/source/contributing/conventions/sentinel-convention.rst` documents. @@ -482,7 +499,8 @@ class SentinelBehaviourTests(unittest.TestCase): def test_the_absent_value_sentinels_are_falsy(self) -> 'None': """``NULL``, ``NO_VALUE`` and ``ABSENT`` each stand for an absent value.""" - for sentinel in (NULL, NO_VALUE, ABSENT): + live = {name: obj for name, obj, _ in _sentinels()} + for sentinel in (live['NULL'], live['NO_VALUE'], live['ABSENT']): with self.subTest(sentinel=repr(sentinel)): self.assertFalse(sentinel) @@ -494,7 +512,7 @@ def test_no_default_is_deliberately_truthy(self) -> 'None': prevent. """ - self.assertTrue(NO_DEFAULT) + self.assertTrue(_sentinels()[2][1]) def test_the_private_sentinel_reprs_as_its_own_name(self) -> 'None': """````, not ````. @@ -504,12 +522,14 @@ def test_the_private_sentinel_reprs_as_its_own_name(self) -> 'None': does not. """ - self.assertEqual(repr(ABSENT), '') - self.assertNotIn('0x', repr(ABSENT)) - self.assertNotIn('__repr__', vars(NoValueType)) + live = _sentinels() + absent, no_value_type = live[3][1], live[1][2] + self.assertEqual(repr(absent), '') + self.assertNotIn('0x', repr(absent)) + self.assertNotIn('__repr__', vars(no_value_type)) -class NoValueIsTheDocumentedFieldDefaultTests(unittest.TestCase): +class NoValueIsTheDocumentedFieldDefaultTests(_ReimportCase): """Why the ruling reaches ``NO_VALUE`` at all. ``NO_VALUE``'s own comment in :mod:`pcapkit.corekit.fields.field` reads *"Default @@ -530,6 +550,7 @@ class NoValueIsTheDocumentedFieldDefaultTests(unittest.TestCase): def test_an_undefaulted_field_reports_the_sentinel(self) -> 'None': """``is``, not ``==`` -- the whole point of a sentinel.""" + from pcapkit.corekit.fields.field import NO_VALUE from pcapkit.corekit.fields.strings import BytesField self.assertIs(BytesField(length=4).default, NO_VALUE) @@ -542,6 +563,7 @@ def test_setting_and_deleting_a_default_round_trips_through_the_sentinel(self) - exactly the confusion the sentinel exists to prevent. """ + from pcapkit.corekit.fields.field import NO_VALUE from pcapkit.corekit.fields.strings import BytesField field = BytesField(length=4)