raisemap
Work out which exceptions a Python function can actually raise.
Python has no way to declare what a function raises and no way to check it. The answer lives in docstrings that drift out of date, or in nobody's head at all, and adding an exception to a public function is a breaking change that nothing will tell you about.
raisemap infers the set from the source, checks it against a running program, compares it with what the docstrings claim, and fails CI when the public API's exceptions change.
Installation
pip install raisemap
Requires Python 3.10+. The observe command needs 3.12+ for sys.monitoring.
Quick start
raisemap show src
raises function
---------------------------------- --------------------
ConfigError, TypeError, ValueError mypkg.core.load_config
TypeError, ValueError mypkg.core.parse_port
OSError, json.JSONDecodeError mypkg.io.read
--why shows where each one comes from, which is usually the actual question:
$ raisemap show src --function read --why
mypkg.io.read src/mypkg/io.py:14
OSError
known to raise at line 15 (open())
json.JSONDecodeError
known to raise at line 16 (json.load())
Where the answers come from
| Source | Example |
|---|---|
A raise in the body |
raise ValueError("bad port") |
A bare raise in a handler |
except OSError: raise |
| A call to another function in the project | inferred, then pushed along the call graph |
| A curated table of stdlib calls | int(), open(), json.loads(), subprocess.run() |
Everything is filtered through what the function catches. A body wrapped in
except OSError: return None does not raise OSError, and saying it does would make the report
noise.
Suppression understands the class hierarchy, including your own exception classes. If you define
class ConfigError(ValueError) then except ValueError suppresses it, and raisemap reads that
relationship out of your source rather than importing your package to find it. That works whether
the catch is in the same function as the raise or several calls away, which is why analysis runs
in two passes: one to collect the project's exception classes, one to use them.
Aliases are understood too. IOError and OSError are one class at runtime, not a parent and a
child, so except IOError correctly suppresses a raised OSError.
A handler written with a dotted name is treated as qualified, so except re.error does not
suppress a raised struct.error just because both end in error. An undotted handler is a name
that was imported directly, so except JSONDecodeError does still catch json.JSONDecodeError.
What is deliberately not inferred
The implicit exceptions any expression can produce: AttributeError from an attribute access,
TypeError from arithmetic, KeyError from a subscript. Model those and every function in the
codebase raises everything, and the output stops being worth reading.
The rule for the stdlib table is that the exception is part of the callee's contract and a caller
is expected to handle it. int("x") raising ValueError is a contract. x.y raising
AttributeError is a bug.
Argument-dependent contracts are handled: getattr(x, "y") raises AttributeError,
getattr(x, "y", None) cannot, and the second one is far more common.
Checking the docstrings
raisemap docs src
fail 3 function(s) whose docstrings do not match
problem function
------------------------------------------ --------------------
undocumented: ValueError mypkg.core.load_config
documented but not raised: KeyError mypkg.io.read
no docstring; undocumented: OSError mypkg.io.write
Google, NumPy and Sphinx conventions are all parsed, so nobody has to reformat a codebase to use this:
"""Raises:
ValueError: if the input is empty
"""
"""Raises
------
ValueError
if the input is empty
"""
""":raises ValueError: if the input is empty"""
Checking against a running program
raisemap observe src --command "pytest -q"
observed 41 function(s) raising during the run
1 function(s) raised something the static pass missed
seen but not inferred function
--------------------- -----------------
KeyError mypkg.core.lookup
Static analysis reads what is written. It cannot see an exception from a C extension, from a call it could not resolve, or through dynamic dispatch. Running the suite can. The two disagreeing is the interesting part:
- observed but not inferred means the static pass has a blind spot, and the function raises something nobody has written down.
- inferred but not observed means either a path the tests never take, or an over-eager inference.
This uses sys.monitoring's PY_UNWIND event, which fires exactly when an exception leaves a
Python function. Below 3.12 the pass is skipped rather than approximated with a tracer that would
change the timing of everything it measures.
Every process started under the run inherits the instrumentation and writes its own report, which
are merged afterwards. That means pytest-xdist, multiprocessing, and any subprocess your code
spawns are all covered rather than the last one to exit replacing everything the others saw.
Guarding the public API in CI
raisemap check --update # record what the public API raises today
git add raisemap.lock
[tool.raisemap]
paths = ["src"]
lock = "raisemap.lock"
ignore = ["NotImplementedError"]
$ raisemap check
fail 2 change(s) to what the public API raises
change exceptions function
---------- ----------- --------------------
now raises LookupError mypkg.core.load_config
now raises LookupError mypkg.core.safe_load
Adding an exception to a public function breaks anyone catching around it.
Run 'raisemap check --update' if this is intended.
Only public functions are locked. A private helper gaining an exception is not somebody else's problem. Functions that are new or deleted are not reported as drift, since one has no previous behaviour and the other is a separate conversation.
The lock records every public function the run saw, not just the ones that raise. Without that, a name missing from the list is ambiguous: the function might be new, or it might have existed and started raising, and only the second is a change in behaviour worth failing a build over.
Configuration
Keys under [tool.raisemap] in pyproject.toml. All optional.
| Key | Type | Default | Meaning |
|---|---|---|---|
paths |
list of strings | [] |
Where to look when no paths are given on the command line |
lock |
string | "raisemap.lock" |
Path to the lock file |
require_docstrings |
bool | false |
Fail docs when a raising function has no docstring |
ignore |
list of strings | [] |
Exception names to leave out of docs reports |
Command reference
| Command | What it does |
|---|---|
raisemap show [paths] |
List what each function raises, --why for provenance |
raisemap show --function NAME |
Just one function, by dotted or short name |
raisemap docs [paths] |
Compare docstrings against what is inferred |
raisemap observe [paths] --command "..." |
Run something and reconcile with the static pass |
raisemap check |
Fail when the public API's exceptions change, --update to record |
Every command takes --json, and show/docs take --all to include private functions.
How it compares
| Tool | Infers the set | Follows calls | Understands your exception classes | Checks docstrings | Runtime check | CI guard |
|---|---|---|---|---|---|---|
tryceratops |
no | no | no | no | no | no |
flake8-raise |
no | no | no | no | no | no |
darglint |
no | no | no | yes | no | no |
| raisemap | yes | yes | yes | yes | yes | yes |
tryceratops last released in 2024 and flake8-raise in 2020; both lint how you write raise
and try rather than working out what escapes.
Notes
Calls are resolved by name, not by following imports. A call to helper() matches every project
function called helper, which over-reports when a name is reused across modules. That is the
safer direction: a missed propagation hides a real exception, an extra one is visible and can be
argued with.
Propagation is iterated to a fixed point rather than recursed, so mutual recursion converges instead of needing cycle detection.
Nothing here imports the code being analysed. A tool that has to import your package to describe it is a tool that runs your import side effects.
A file that cannot be parsed, decoded or opened is skipped rather than aborting the run. One module declaring a non-UTF-8 encoding should not cost you the analysis of everything else.
Contributing
Bug reports and pull requests are welcome, especially additions to the stdlib table in
src/raisemap/known.py. The bar for an entry is that the exception is part of the callee's
documented contract. uv sync then uv run pytest to get started.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file raisemap-0.1.0.tar.gz.
File metadata
- Download URL: raisemap-0.1.0.tar.gz
- Upload date:
- Size: 78.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0b7a7ed9c76d2bdb38230ab40c8ad2a24f871192aed1c6a80212cab25f97ae13
|
|
| MD5 |
67741efe3c17c6b8bba7a4655f4bedd1
|
|
| BLAKE2b-256 |
97e83bf12e2e5b322ff7859824d0dac3993a145938305d8d574a68f9e254fafc
|
File details
Details for the file raisemap-0.1.0-py3-none-any.whl.
File metadata
- Download URL: raisemap-0.1.0-py3-none-any.whl
- Upload date:
- Size: 29.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1bfc51ec2046739e4f43f383ee4d4839410d91bd940bf7ead1787abdfd9a6836
|
|
| MD5 |
e36214499d125b291eef5e507520331d
|
|
| BLAKE2b-256 |
7f8fc39e9b392fafa17b2d35904357b20a91ac1cc1bee54c421fd604fad38b8b
|