How it works
gilguard is one module of plain Python, a few hundred lines with no dependencies. You don’t need any of this to use it, but it helps to know what it’s doing when you read a report.
Watching every import
When you import gilguard, it puts a finder at the front of sys.meta_path. That finder doesn’t find anything itself. For each import, it asks the rest of sys.meta_path who can load the module, then wraps that loader’s create_module and exec_module before handing it back.
The wrapped methods check sys._is_gil_enabled() before and after they run. If the GIL was off before and is on after, that module turned it on. gilguard builds the report on the spot, while the import is still on the stack.
That timing is what makes the report specific. While an import is in progress, gilguard keeps track of which modules are loading inside which (the import chain), and it walks the Python stack to find the import statements involved. The outermost one outside the import machinery is “Your code”. The innermost is “Imported by”.
The chain is tracked per thread, so imports running at the same time on different threads each get their own chain.
The error is raised in the thread that ran the import. If that’s a worker thread, only that thread unwinds: the main thread keeps going with the GIL on, and the process can still exit 0. gilguard leaves that alone on purpose, since killing the process from inside an import would skip cleanup and surprise most programs. The FAQ has a threading.excepthook recipe if you want any flip to stop the program.
The backstop
Some code loads modules without going through sys.meta_path. importlib.util.module_from_spec() with a hand-built spec is the common case. Those imports never reach the finder.
CPython always emits its RuntimeWarning at the moment it turns the GIL on, however the module was loaded. gilguard installs its own warnings.showwarning, recognizes that one warning, and reports from there. Every other warning passes through to the original handler untouched.
When an import goes through the finder, the warning still fires, but gilguard ignores it and lets the wrapper raise a moment later. That gives you a cleaner traceback that ends at your import statement.
Why BaseException
GILEnabledError derives from BaseException, the same as KeyboardInterrupt and SystemExit. That’s deliberate. Optional imports in the wild look like this:
try:
import fastjson as json
except ImportError:
import jsonIf the error were an ImportError, or any Exception, that pattern would catch it, fall back, and keep running with the GIL already on. Nothing would be fixed and nothing would be reported. Because GILEnabledError sits outside Exception, only code that names it explicitly can catch it.
First flip only
gilguard reports once per process. Once the GIL is on, nothing turns it off, and no later import can turn it on again, so there’s nothing left to report. It’s also why the scanner needs a separate interpreter for each package.
When it checks the interpreter
At import time, before installing anything, gilguard checks two things. Is this a free-threaded build? If not, it reports gil-build. Is the GIL already on? If so, it reports already-enabled, with sys.flags.gil to show whether it was forced, plus the third-party extension modules already in sys.modules as suspects. Only after both checks pass does it install the finder and the warning hook.