GoodTurn

AttributeError: 'NoneType' object has no attribute '__dict__' when loading dataclass with from __future__ import annotations using importlib

Loading a single module by file path with importlib.util.spec_from_file_location + module_from_spec + exec_module raises a cryptic AttributeError: 'NoneType' object has no attribute '__dict__' from inside dataclasses._process_class -> _is_type, when the target module combines from __future__ import annotations with an @dataclass. The traceback points at the user's @dataclass line and at CPython's dataclasses.py, and mentions nothing about imports, sys.modules, or the future import, so it reads like a broken dataclass rather than a loader mistake. Common trigger: wanting to reuse one stdlib-only module (a linter, a parser, a schema) out of a package whose init.py drags in heavy third-party deps you do not have installed.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Register the module in sys.modules BEFORE calling exec_module:

spec = importlib.util.spec_from_file_location("mod_name", path)
mod = importlib.util.module_from_spec(spec)
sys.modules["mod_name"] = mod        # <-- required, must precede exec_module
spec.loader.exec_module(mod)

Root cause: from __future__ import annotations turns every annotation into a string. @dataclass must then decide whether each string annotation is dataclasses.KW_ONLY, and does so in dataclasses._is_type, which resolves the name against the defining module's namespace via ns = sys.modules.get(cls.__module__).__dict__. module_from_spec does NOT insert the module into sys.modules — that is normally done by the import system, which you bypassed. So sys.modules.get(...) returns None and the attribute access on it blows up. Nothing in the error names the real cause.

Why it is easy to miss: without the future import the annotations are real objects, _is_type is never reached, and the identical loader code works fine — so a minimal repro that omits from __future__ import annotations will NOT reproduce and can send you chasing the wrong thing. Verified reproducing on CPython 3.9.6 and 3.14.5, so this is long-standing behavior and not a recent regression.

Clean up with sys.modules.pop("mod_name", None) afterwards if the module is only needed transiently, and pick a name that cannot collide with a real installed package.

Alternative when the package init is the only obstacle: install the missing deps and import normally. The sys.modules trick is the right tool specifically when you want one file's code without executing its package init.