fix(sim): break the transponders<->sim circular import via lazy re-exports

Importing a transponder model first (e.g. iso15693.type5, or iso14443a via the fuzzer) triggered pm3py.sim's __init__, which eagerly re-imported that same transponder before it finished initialising — ImportError on a partially-initialised module. Masked in the full suite by import order; failed whenever a transponder was the first import.

Move sim/__init__'s ~90 transponder re-exports into sim/_models.py and load them lazily via a PEP 562 __getattr__ (public API unchanged: attribute access, from-import, __all__, dir(), and 'import *' all still work). Defer fuzzer.py's iso14443a.base import into the one method that uses it. Add tests/test_import_hygiene.py — subprocess fresh-import guards per transponder family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 21:23:12 -07:00
parent d59a3c017b
commit 0586e2a41a
4 changed files with 141 additions and 63 deletions

View File

@@ -16,68 +16,27 @@ from .mcu_bridge import McuBridge
from .dual_session import DualInterfaceSession
# --- Transponder models (canonical home: pm3py.transponders) ---
from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import (
MifareClassicTag, MifareClassicReader, MifareAccessConditions,
)
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
from pm3py.transponders.hf.iso14443a.nxp.type2 import NxpType2Tag, Type2Config
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21xConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21,
UL_C_DEFAULT_KEY, UltralightEV1Config, UltralightCConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import (
NtagI2C, NT3H2111, NT3H2211, NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C,
)
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K
from pm3py.transponders.hf.iso14443a.infineon.optiga_nbt import OptigaAuthenticateNBT
from pm3py.transponders.hf.iso14443a.st.st25ta import (
ST25TA, ST25TA512B, ST25TA02KB, ST25TA16K, ST25TA64K,
)
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag, IcodeFeatureFlags
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.auth_aes import NxpKeyPrivileges
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import (
Ntag5PlatformTag, Ntag5StatusReg, Ntag5ConfigReg,
)
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
from pm3py.transponders.hf.iso15693.st.st25tv import ST25TV, ST25TV512C, ST25TV02KC
from pm3py.transponders.hf.iso15693.st.st25dv import (
ST25DV, ST25DV04K, ST25DV16K, ST25DV64K, St25dvRfAreaSS,
)
from pm3py.transponders.hf.iso15693.infineon.myd_vicinity import (
MydVicinity, SRF55V02P, SRF55V10P,
)
from pm3py.transponders.hf.iso14443b.base import Tag14443B, StateB, Tag14443B_4, StateB4
from pm3py.transponders.hf.iso14443b.st.st25tb import (
ST25TB, ST25TB512AC, ST25TB512AT, ST25TB02K, ST25TB04K,
)
from pm3py.transponders.hf.iso14443b.ti.rf430cl330h import RF430CL330H
from pm3py.transponders.hf.iso15693.ti.tagit import TagItHFIPlus
from pm3py.transponders.hf.iso15693.ti.rf430frl import (
RF430FRL15xH, RF430FRL152H, RF430FRL153H, RF430FRL154H,
)
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.bitfield import BitField, Register
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
from pm3py.transponders.lf.nxp.hitag2 import Hitag2Tag, Hitag2Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag
# Loaded lazily (PEP 562) from ._models. Those models import back into pm3py.sim for the
# Transponder/Reader/RFFrame/Medium primitives, so importing them at package-init time would
# create a transponders<->sim circular import (breaking e.g. `from pm3py.transponders...` as a
# first import). On first access they load from ._models and cache into this module's globals.
def __getattr__(name):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name) # don't trigger a model import for dunder probes
import importlib
models = importlib.import_module(f"{__name__}._models")
try:
value = getattr(models, name)
except AttributeError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
globals()[name] = value # cache: subsequent access is a direct lookup
return value
def __dir__():
return sorted(set(globals()) | set(__all__))
__all__ = [
"RFFrame",

71
pm3py/sim/_models.py Normal file
View File

@@ -0,0 +1,71 @@
"""Transponder-model re-exports for :mod:`pm3py.sim`.
Loaded lazily by the package's PEP 562 ``__getattr__`` — these models import back into
``pm3py.sim`` (for the ``Transponder`` / ``Reader`` / ``RFFrame`` / ``Medium`` primitives), so
importing them at ``sim`` package-init time creates a circular import. Their canonical home is
``pm3py.transponders``; this module just re-exposes them under ``pm3py.sim``.
"""
from __future__ import annotations
from pm3py.transponders.hf.iso14443a.base import (
Tag14443A, Tag14443A_3, Tag14443A_4, Reader14443A, State14443A,
)
from pm3py.transponders.hf.iso14443a.nxp.crypto1 import Crypto1
from pm3py.transponders.hf.iso14443a.nxp.mifare_classic import (
MifareClassicTag, MifareClassicReader, MifareAccessConditions,
)
from pm3py.transponders.hf.iso14443a.nxp.desfire import DesfireTag, DesfireReader
from pm3py.transponders.hf.iso14443a.ndef import NfcType2Tag, NfcType4Tag
from pm3py.transponders.hf.iso14443a.nxp.type2 import NxpType2Tag, Type2Config
from pm3py.transponders.hf.iso14443a.nxp.ntag21x import (
Ntag21x, NTAG210, NTAG212, NTAG213, NTAG215, NTAG216, Ntag21xConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ultralight import (
MifareUltralight, MifareUltralightC, MifareUltralightEV1, MF0UL11, MF0UL21,
UL_C_DEFAULT_KEY, UltralightEV1Config, UltralightCConfig,
)
from pm3py.transponders.hf.iso14443a.nxp.ntag_i2c import (
NtagI2C, NT3H2111, NT3H2211, NtagI2CConfig, NtagI2CSession, NtagI2CPtI2C,
)
from pm3py.transponders.hf.iso14443a.st.st25tn import ST25TN, ST25TN512, ST25TN01K
from pm3py.transponders.hf.iso14443a.infineon.optiga_nbt import OptigaAuthenticateNBT
from pm3py.transponders.hf.iso14443a.st.st25ta import (
ST25TA, ST25TA512B, ST25TA02KB, ST25TA16K, ST25TA64K,
)
from pm3py.transponders.hf.iso15693.base import Tag15693, Reader15693, State15693
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag, ndef_text, ndef_uri, ndef_mime
from pm3py.transponders.hf.iso15693.nxp.nxp_icode import NxpIcodeTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix import IcodeSlixTag
from pm3py.transponders.hf.iso15693.nxp.icode_slix2 import IcodeSlix2Tag
from pm3py.transponders.hf.iso15693.nxp.icode3 import Icode3Tag, IcodeFeatureFlags
from pm3py.transponders.hf.iso15693.nxp.icode_dna import IcodeDnaTag
from pm3py.transponders.hf.iso15693.nxp.auth_aes import NxpKeyPrivileges
from pm3py.transponders.hf.iso15693.nxp.ntag5_platform import (
Ntag5PlatformTag, Ntag5StatusReg, Ntag5ConfigReg,
)
from pm3py.transponders.hf.iso15693.nxp.ntag5_switch import Ntag5SwitchTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_link import Ntag5LinkTag
from pm3py.transponders.hf.iso15693.nxp.ntag5_boost import Ntag5BoostTag
from pm3py.transponders.hf.iso15693.st.st25tv import ST25TV, ST25TV512C, ST25TV02KC
from pm3py.transponders.hf.iso15693.st.st25dv import (
ST25DV, ST25DV04K, ST25DV16K, ST25DV64K, St25dvRfAreaSS,
)
from pm3py.transponders.hf.iso15693.infineon.myd_vicinity import (
MydVicinity, SRF55V02P, SRF55V10P,
)
from pm3py.transponders.hf.iso14443b.base import Tag14443B, StateB, Tag14443B_4, StateB4
from pm3py.transponders.hf.iso14443b.st.st25tb import (
ST25TB, ST25TB512AC, ST25TB512AT, ST25TB02K, ST25TB04K,
)
from pm3py.transponders.hf.iso14443b.ti.rf430cl330h import RF430CL330H
from pm3py.transponders.hf.iso15693.ti.tagit import TagItHFIPlus
from pm3py.transponders.hf.iso15693.ti.rf430frl import (
RF430FRL15xH, RF430FRL152H, RF430FRL153H, RF430FRL154H,
)
from pm3py.transponders.lf.base import TagLF, ReaderLF, Modulation
from pm3py.transponders.lf.em.em4100 import EM4100Tag, EM4100Reader, EM4100Code
from pm3py.transponders.lf.hid.hid import HIDProxTag, HIDReader
from pm3py.transponders.bitfield import BitField, Register
from pm3py.transponders.lf.atmel.t5577 import T5577Tag, T5577Reader, T5577Config
from pm3py.transponders.lf.nxp.hitag2 import Hitag2Tag, Hitag2Reader
from pm3py.transponders.implants import xEM, xNT, xM1, FlexDF, NExT, MagicMifareClassicTag

View File

@@ -6,7 +6,6 @@ from typing import Iterator
from bitarray import bitarray
from .frame import RFFrame
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
class MutationFuzzer:
@@ -76,6 +75,9 @@ class GrammarFuzzer:
return None
def _gen_14443a(self, command: str, **overrides) -> RFFrame | None:
# deferred: iso14443a.base imports back into pm3py.sim, so a module-level import here would
# create a transponders<->sim circular import when 14443a is the first thing imported
from pm3py.transponders.hf.iso14443a.base import CL1, NVB_SELECT, _compute_bcc
match command:
case "REQA":
return RFFrame.from_bytes(b"\x26")

View File

@@ -0,0 +1,46 @@
"""Guards against the transponders<->sim circular import.
Transponder models import ``pm3py.sim`` primitives (Transponder/Reader/RFFrame/Medium), so
``pm3py.sim`` must NOT eagerly re-import transponder models at package-init time — its model
re-exports are lazy (see ``pm3py/sim/_models.py`` + the package ``__getattr__``), and ``fuzzer.py``
defers its ``iso14443a.base`` import. Each family must therefore import as a *fresh* first import.
These run in subprocesses because import-order bugs only show in a clean interpreter.
"""
import subprocess
import sys
import pytest
# each of these, imported first in a clean interpreter, used to trigger a circular import
_FRESH_IMPORTS = [
"pm3py.transponders.hf.iso15693.type5", # the reported failure
"pm3py.transponders.hf.iso14443a.base", # the fuzzer-side failure
"pm3py.transponders.hf.iso14443b.base",
"pm3py.transponders.lf.atmel.t5577",
"pm3py.transponders.implants",
"pm3py.sim.fuzzer",
"pm3py.sim",
]
@pytest.mark.parametrize("module", _FRESH_IMPORTS)
def test_fresh_import_has_no_cycle(module):
r = subprocess.run([sys.executable, "-c", f"import {module}"],
capture_output=True, text=True)
assert r.returncode == 0, f"importing {module} first failed:\n{r.stderr}"
def test_sim_lazy_reexports_resolve():
import pm3py.sim as sim
from pm3py.transponders.hf.iso15693.type5 import NfcType5Tag
assert sim.NfcType5Tag is NfcType5Tag # lazy re-export == canonical class
assert "NfcType5Tag" in dir(sim)
assert all(getattr(sim, n, None) is not None for n in sim.__all__)
def test_fuzzer_deferred_import_works():
from pm3py.sim.fuzzer import GrammarFuzzer
frame = GrammarFuzzer("14443a").generate("SELECT", uid=b"\x04\x11\x22\x33")
assert frame.data.hex() == "93700411223304" # CL1, NVB_SELECT, uid, bcc