Agent Rules / Names and imports
Why do fallback imports make agents edit the wrong code?
Rule AR-05
No conditional imports that bind the same name to different objects (try/except ImportError fallbacks get a typed shim).
When a file binds dumps from a fast library in a try block and from a pure-Python version in the except block, the name has two possible owners and which one runs is decided on each machine at start-up. An agent that reads the file sees both and has to guess which one the call reaches. Put the choice in one small shim module with one declared signature, and every other file imports from the shim.
Why it happens
Python. An import statement is an ordinary statement. It runs when control reaches it, and a failure to find the module raises ModuleNotFoundError (a subclass of ImportError). So try: import a / except ImportError: import b executes only one branch at runtime, and the name is bound to whichever module succeeded. Nothing in the file says which. The choice depends on what's installed.
Static tools have to decide for you:
- Pyright reports
reportMissingImports(error in basic, standard and strict) for an import with no source or stub file, andreportMissingModuleSource(warning by default) for stubs without source. The configuration docs don't describe a setting for try/except fallbacks. - Pyright's
reportPossiblyUnboundVariable(off in basic, error in standard and strict) fires when a name is bound only on some paths. - Mypy documents that it understands
sys.version_infoandsys.platformchecks inif/elif/else, so definitions in those branches are type-checked per version. It does not document the same for try/except fallbacks, and for two incompatible bindings of one name, mypy 2.4.1 with--strictreportsfallback.py:4: error: Incompatible import of "dumps" ... [assignment]. A caller that imports the name from the fallback module also getscaller.py:1: error: Module "fallback" does not explicitly export attribute "dumps" [attr-defined], which is the--strictimplicit re-export check.
TypeScript and JavaScript. Static import declarations are resolved before the module runs: the spec's InitializeEnvironment creates import bindings during linking, and an unresolvable import is a SyntaxError at that stage. That is why static imports are traceable and can't be conditional. The conditional forms are require() inside an if, and dynamic import(), which Node documents as an asynchronous way to load modules and which returns a Promise. A specifier built at runtime (import(./plugins/${name})) has no fixed target for a tool to follow.
In both languages, the cost is the same. The module a call reaches is a runtime fact, and tools that read source can only list the candidates.
Bad and good
# bad: billing/serialize.py
try:
from orjson import dumps # returns bytes
except ImportError:
from json import dumps # standard library, str keys only# good: billing/_json.py is the only place that chooses
import json
from importlib.util import find_spec
HAS_ORJSON = find_spec("orjson") is not None
def dumps(obj: object) -> str:
if HAS_ORJSON:
import orjson
return orjson.dumps(obj).decode("utf-8")
return json.dumps(obj)# good: billing/serialize.py
from billing._json import dumpsThe shim has one dumps and one signature. A type checker checks the body against that signature, a reader sees the choice in one place, and the fallback can have its own test.
// bad: src/store.ts
const driver = process.env.DB === "sqlite" ? require("./sqlite") : require("./postgres");
export const save = driver.save;// good: src/store.ts
import type { Driver } from "./driver";
import * as sqlite from "./sqlite";
import * as postgres from "./postgres";
const driver: Driver = process.env.DB === "sqlite" ? sqlite : postgres;
export const save = (row: Row) => driver.save(row);In the good TypeScript version both modules are imported statically, both are visible to tools, and Driver is the contract they must meet.
Trade-offs and when a conditional import is acceptable
- Optional dependencies are a real need: a library that works with or without an accelerator. The shim above keeps the choice and still gives callers one name.
- Platform and version splits (
sys.version_info >= (3, 11)) are the best-supported form. Mypy documents that it understands these checks. Pyright 1.1.414 handles them too: withpythonVersionset to Python 3.10 the Python 3.11 branch was unreachable and gave 0 errors, and with Python 3.12 it reportedreportReturnTypefor the bad return inside the Python 3.11 branch (check.py:5:16) and nothing in the other branch. - **
importlib.util.find_spec** returnsNonewhen a module isn't found, without importing the module. For a dotted name, the parent package is imported. Ruff'sF401docs recommend it for existence checks over a try/except. - Heavy imports inside functions to delay start-up cost are a different pattern: the name has one owner and the import is only late. Ruff's
PLC0415flags them, and moving them to the top is its suggested fix, so allow this on purpose where start-up time matters. - **Dynamic
import()for code splitting** is normal in front-end code. Keep the specifier a string literal so tools can follow it. - Shims cost a file. For two or three conditional names, one
_compat.pyorcompat.tscosts less than the confusion does.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | F401 unused-import | an import kept only for an availability check; the docs recommend importlib.util.find_spec | 2026-10-30 |
| ruff | PLC0415 import-outside-top-level | imports inside functions or classes (ruff 0.12.0 and later) | 2026-10-30 |
| pyright | reportPossiblyUnboundVariable (none in basic, error in standard and strict) | a name bound on only some branches | 2026-10-30 |
| pyright | reportMissingImports (error), reportMissingModuleSource (warning) | optional dependencies that aren't installed in the checking environment | 2026-10-30 |
| mypy | sys.version_info / sys.platform checks | conditional definitions it can evaluate, per target version | 2026-10-30 |
| typescript-eslint | no-require-imports (in recommended) | require() calls and import x = require() (the latter allowed with allowAsImport) | 2026-10-30 |
There's no linter setting in the docs checked that bans try/except import fallbacks directly. A code-review rule plus a shim module is the control.
In pyproject.toml:
[tool.ruff.lint]
extend-select = ["F401", "PLC0415"]Rule block for AGENTS.md / CLAUDE.md
- Don't write `try: import a / except ImportError: import b` in feature code. Put the choice in
one shim module with a typed public function, and import from the shim everywhere else.
- Use `importlib.util.find_spec("pkg")` to test for an optional package.
- Don't build module specifiers from variables in `import()` or `importlib.import_module()`.
If a plugin list is needed, keep a table of literal imports.
- When you edit code behind a shim, change the declared signature first and update both branches.Edge cases
- **
if TYPE_CHECKING:blocks** import names for annotations only, for type checkers. In a test, mypy 2.4.1 with--strictreported no issues, and at runtimehasattr(api, 'Invoice')wasFalsewith noNameError(Python 3.14.7). They do not bind two objects to one name, so they are fine. A runtime import of the same name elsewhere is the problem. - Same name, two meanings:
try: import tomllib / except ImportError: import tomli as tomllibgives callers one name, which is the right goal, but the aliasing and the fallback are still in feature code. Move it to the shim. - **
Nonefallbacks** (except ImportError: orjson = None) make every later use a possible attribute error onNone. Pyright'sreportOptionalMemberAccess(error in all modes) catches the use sites. - **
importlib.import_module(name)** with a variable name has no static target. See AR-06 for registries filled this way. - Testing: both branches need a test. The shim makes that a two-line monkeypatch of
HAS_ORJSON.
What LLMGRAPH shows
When a name is bound by try/except to two definitions, LLMGRAPH lists both and prints the call as maybe_more (open: type unknown) under each. After a shim module, the call prints certain.
References
- Python Language Reference, "The import statement" (module initialization when the statement runs;
ImportError): https://docs.python.org/3/reference/simple_stmts.html#the-import-statement - Python Language Reference, "The import system": https://docs.python.org/3/reference/import.html
importlib.util.find_spec: https://docs.python.org/3/library/importlib.html- Pyright configuration: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
- Mypy, "Common issues" (version and platform checks): https://mypy.readthedocs.io/en/stable/common_issues.html
- Ruff F401 and PLC0415: https://docs.astral.sh/ruff/rules/unused-import/ · https://docs.astral.sh/ruff/rules/import-outside-top-level/
- ECMAScript spec, module linking (
InitializeEnvironment): https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html - Node.js ESM docs (dynamic
import()): https://nodejs.org/api/esm.html - typescript-eslint
no-require-imports: https://typescript-eslint.io/rules/no-require-imports/ - TypeScript handbook, modules reference (
import(),require): https://www.typescriptlang.org/docs/handbook/modules/reference.html
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: tool checks resolved (tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1, pyright 1.1.414)
- 2026-10-30: copy-gate wording fixes
Related
- AR-03 What happens when two functions share a name?
- AR-06 Why does my agent not see what importing this module does?
- AR-04 Do import aliases confuse coding agents?
Last verified