Skip to main content

raisemap

Work out which exceptions a Python function can actually raise.

PyPI Python CI License

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

raisemap-0.1.0.tar.gz (78.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

raisemap-0.1.0-py3-none-any.whl (29.1 kB view details)

Uploaded Python 3

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

Hashes for raisemap-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0b7a7ed9c76d2bdb38230ab40c8ad2a24f871192aed1c6a80212cab25f97ae13
MD5 67741efe3c17c6b8bba7a4655f4bedd1
BLAKE2b-256 97e83bf12e2e5b322ff7859824d0dac3993a145938305d8d574a68f9e254fafc

See more details on using hashes here.

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

Hashes for raisemap-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1bfc51ec2046739e4f43f383ee4d4839410d91bd940bf7ead1787abdfd9a6836
MD5 e36214499d125b291eef5e507520331d
BLAKE2b-256 7f8fc39e9b392fafa17b2d35904357b20a91ac1cc1bee54c421fd604fad38b8b

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page