For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

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, and reportMissingModuleSource (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_info and sys.platform checks in if/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 --strict reports fallback.py:4: error: Incompatible import of "dumps" ... [assignment]. A caller that imports the name from the fallback module also gets caller.py:1: error: Module "fallback" does not explicitly export attribute "dumps" [attr-defined], which is the --strict implicit 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

python# bad: billing/serialize.py try: from orjson import dumps # returns bytes except ImportError: from json import dumps # standard library, str keys only
python# 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)
python# good: billing/serialize.py from billing._json import dumps

The 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.

ts// bad: src/store.ts const driver = process.env.DB === "sqlite" ? require("./sqlite") : require("./postgres"); export const save = driver.save;
ts// 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: with pythonVersion set to Python 3.10 the Python 3.11 branch was unreachable and gave 0 errors, and with Python 3.12 it reported reportReturnType for the bad return inside the Python 3.11 branch (check.py:5:16) and nothing in the other branch.
  • **importlib.util.find_spec** returns None when a module isn't found, without importing the module. For a dotted name, the parent package is imported. Ruff's F401 docs 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 PLC0415 flags 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.py or compat.ts costs less than the confusion does.

Enforce it

ToolSettingWhat it catchesChecked
ruffF401 unused-importan import kept only for an availability check; the docs recommend importlib.util.find_spec2026-10-30
ruffPLC0415 import-outside-top-levelimports inside functions or classes (ruff 0.12.0 and later)2026-10-30
pyrightreportPossiblyUnboundVariable (none in basic, error in standard and strict)a name bound on only some branches2026-10-30
pyrightreportMissingImports (error), reportMissingModuleSource (warning)optional dependencies that aren't installed in the checking environment2026-10-30
mypysys.version_info / sys.platform checksconditional definitions it can evaluate, per target version2026-10-30
typescript-eslintno-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:

toml[tool.ruff.lint] extend-select = ["F401", "PLC0415"]

Rule block for AGENTS.md / CLAUDE.md

markdown- 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 --strict reported no issues, and at runtime hasattr(api, 'Invoice') was False with no NameError (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 tomllib gives callers one name, which is the right goal, but the aliasing and the fallback are still in feature code. Move it to the shim.
  • **None fallbacks** (except ImportError: orjson = None) make every later use a possible attribute error on None. Pyright's reportOptionalMemberAccess (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
  • 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

For agentsthis page as Markdown/agent-readable/ar-05.mdllms.txt