Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
workflow_call:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type == 'branch' }}

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
# Nightly is required by #![feature(extend_one)]. Keep in sync with release.yml.
RUSTUP_TOOLCHAIN: nightly-2026-04-11

jobs:
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust
run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component rustfmt,clippy --no-self-update

- name: Cache Cargo dependencies and build artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ci-${{ runner.os }}-${{ runner.arch }}-${{ env.RUSTUP_TOOLCHAIN }}-${{ hashFiles('Cargo.lock', 'Cargo.toml') }}
restore-keys: |
ci-${{ runner.os }}-${{ runner.arch }}-${{ env.RUSTUP_TOOLCHAIN }}-

- name: Check formatting
if: runner.os == 'Linux'
run: cargo fmt --all -- --check

- name: Lint
if: runner.os == 'Linux'
run: cargo clippy --locked --all-targets --all-features -- -D warnings

- name: Run tests
run: cargo test --locked --all-features
156 changes: 156 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
# Keep in sync with ci.yml.
RUSTUP_TOOLCHAIN: nightly-2026-04-11

jobs:
validate:
name: Validate release version
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Check tag against Cargo.toml
id: version
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python3 - <<'PY'
import os
import tomllib

with open("Cargo.toml", "rb") as manifest:
version = tomllib.load(manifest)["package"]["version"]
tag = os.environ["TAG_NAME"]
if tag != f"v{version}":
raise SystemExit(f"Release tag {tag!r} must match Cargo.toml version v{version}")
prerelease = "-" in version.split("+", 1)[0]
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"prerelease={str(prerelease).lower()}", file=output)
PY

ci:
name: Release checks
needs: validate
uses: ./.github/workflows/ci.yml

build:
name: Build (${{ matrix.target }})
needs: [validate, ci]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- os: macos-15-intel
target: x86_64-apple-darwin
- os: macos-15
target: aarch64-apple-darwin
defaults:
run:
shell: bash
env:
TARGET: ${{ matrix.target }}
TAG_NAME: ${{ github.ref_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust
run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --target "$TARGET" --no-self-update

- name: Cache Cargo dependencies and build artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: release-${{ matrix.os }}-${{ matrix.target }}-${{ env.RUSTUP_TOOLCHAIN }}-${{ hashFiles('Cargo.lock', 'Cargo.toml') }}
restore-keys: |
release-${{ matrix.os }}-${{ matrix.target }}-${{ env.RUSTUP_TOOLCHAIN }}-

- name: Build release binary
run: cargo build --locked --release --bin py-checked-exceptions --target "$TARGET"

- name: Smoke test release binary
run: '"target/$TARGET/release/py-checked-exceptions" --help'

- name: Stage archive contents
run: |
archive="py-checked-exceptions-$TAG_NAME-$TARGET"
mkdir -p "dist/$archive"
cp "target/$TARGET/release/py-checked-exceptions" "dist/$archive/"
cp README.md LICENSE "dist/$archive/"

- name: Package binary
run: |
archive="py-checked-exceptions-$TAG_NAME-$TARGET"
tar -czf "dist/$archive.tar.gz" -C dist "$archive"

- name: Upload release archive
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binary-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
compression-level: 0
retention-days: 7

publish:
name: Publish GitHub release
needs: [validate, build]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download release archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binary-*
path: dist
merge-multiple: true

- name: Generate checksums
working-directory: dist
run: sha256sum -- *.tar.gz > SHA256SUMS

- name: Create release with binaries
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.ref_name }}
PRERELEASE: ${{ needs.validate.outputs.prerelease }}
run: |
flags=()
if [[ "$PRERELEASE" == 'true' ]]; then
flags+=(--prerelease)
fi
gh release create "$TAG_NAME" dist/* \
--verify-tag --generate-notes --title "$TAG_NAME" "${flags[@]}"
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,24 @@ A static analyzer that enforces exception documentation in Python code. It verif

## Installation

Download the archive for your platform from [GitHub Releases](https://github.com/cstruct/py-checked-exceptions/releases),
extract it, and put `py-checked-exceptions` on your `PATH`.

| Platform | Archive target |
| --- | --- |
| Linux x64 | `x86_64-unknown-linux-gnu` |
| Linux ARM64 | `aarch64-unknown-linux-gnu` |
| macOS Intel | `x86_64-apple-darwin` |
| macOS Apple Silicon | `aarch64-apple-darwin` |

Linux binaries are built on Ubuntu 22.04 and require glibc 2.35 or newer.
Each release includes `SHA256SUMS` to verify the downloaded archives.

To build from source, use nightly Rust:

```bash
cargo install --git https://github.com/cstruct/py-checked-exceptions.git
rustup toolchain install nightly-2026-04-11 --profile minimal
cargo +nightly-2026-04-11 install --locked --git https://github.com/cstruct/py-checked-exceptions.git
```

## Usage
Expand Down Expand Up @@ -163,6 +179,24 @@ Enable FastAPI-specific analysis with `--extension fastapi`. The extension:

FastAPI behavior is disabled unless the extension is explicitly enabled.

To emit exception-documentation diagnostics only for FastAPI route handlers, select the
extension-provided entrypoint:

```bash
py-checked-exceptions check --extension fastapi --entrypoint fastapi:route
```

The same setting can be configured in `pyproject.toml`:

```toml
[tool.py-checked-exceptions]
extensions = ["fastapi"]
entrypoints = ["fastapi:route"]
```

Entrypoints restrict which functions start a check. Calls made by a selected route are still
followed transitively.

## Known Limitations

Use `--show-analysis-gaps` to print a summary of code the analyzer could not fully model, or
Expand Down Expand Up @@ -192,6 +226,30 @@ hk install

This ensures all commits pass linting and tests.

GitHub Actions runs tests on Linux and macOS for pull requests and pushes to `main`.
The Linux job also checks formatting and runs Clippy with warnings treated as errors.
Windows CI and release binaries are currently disabled because several integration tests fail
on that platform.
CI and release builds use `nightly-2026-04-11`, since the project requires nightly Rust.
Keep the toolchain versions in both workflows in sync when updating them.

## Releasing

1. Update the package version in `Cargo.toml` and regenerate `Cargo.lock` with `cargo check`.
2. Commit and push the version change and wait for CI to pass.
3. Create and push a tag matching the package version, for example:

```bash
git tag v0.1.0
git push origin v0.1.0
```

The release workflow verifies the tag, reruns CI, and builds and smoke-tests binaries for all
platforms listed above. Once every job succeeds, it publishes a GitHub release with generated
release notes, `.tar.gz` archives for Linux/macOS, and `SHA256SUMS`.
Each archive contains the executable, README, and license. Versions such as `0.2.0-rc.1` are
published as prereleases. The workflow uses the built-in `GITHUB_TOKEN`; no extra secret is needed.

## LLM disclosure

Large language models (LLMs) are used in the development of commits after the commit titled
Expand Down
22 changes: 20 additions & 2 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use ty_project::metadata::{
};

use crate::logging::Verbosity;
use py_checked_exceptions::AnalysisExtension;
use py_checked_exceptions::{AnalysisExtension, Entrypoint};

#[derive(Debug, Parser)]
#[command(
Expand Down Expand Up @@ -54,6 +54,11 @@ pub(crate) struct CheckCommand {
#[arg(long = "extension", value_name = "EXTENSION")]
pub(crate) extensions: Vec<AnalysisExtension>,

/// Restrict diagnostics to selected analysis entrypoints (can be passed multiple times).
/// Transitive calls from selected entrypoints are still analyzed.
#[arg(long = "entrypoint", value_name = "SELECTOR")]
pub(crate) entrypoints: Vec<Entrypoint>,

/// Path to the Python environment.
///
/// py-checked-exceptions uses the Python environment to resolve type information and third-party dependencies.
Expand Down Expand Up @@ -246,7 +251,7 @@ pub(crate) enum TerminalColor {
mod tests {
use clap::Parser;

use super::{AnalysisExtension, AnalysisGapOutput, Cli, Command};
use super::{AnalysisExtension, AnalysisGapOutput, Cli, Command, Entrypoint};

#[test]
fn analysis_gap_output_is_opt_in() {
Expand Down Expand Up @@ -282,4 +287,17 @@ mod tests {
let Command::Check(check) = cli.command;
assert_eq!(check.extensions, vec![AnalysisExtension::Fastapi]);
}

#[test]
fn accepts_fastapi_route_entrypoint() {
let cli = Cli::try_parse_from([
"py-checked-exceptions",
"check",
"--entrypoint",
"fastapi:route",
])
.unwrap();
let Command::Check(check) = cli.command;
assert_eq!(check.entrypoints, vec![Entrypoint::FastapiRoute]);
}
}
5 changes: 4 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{io::ErrorKind, sync::Arc};

use anyhow::{Context, Result};
use py_checked_exceptions::{AnalysisExtension, ContextManagerEffectRule};
use py_checked_exceptions::{AnalysisExtension, ContextManagerEffectRule, Entrypoint};
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_ranged_value::ValueSource;
use serde::Deserialize;
Expand All @@ -14,6 +14,7 @@ use crate::args::{AnalysisGapOutput, OutputFormat, TerminalColor};
pub(crate) struct ProjectConfig {
pub(crate) target_exceptions: Option<Vec<String>>,
pub(crate) extensions: Option<Vec<AnalysisExtension>>,
pub(crate) entrypoints: Option<Vec<Entrypoint>>,
pub(crate) context_manager_effects: Option<Vec<ContextManagerEffectRule>>,
pub(crate) python: Option<String>,
pub(crate) typeshed: Option<String>,
Expand Down Expand Up @@ -145,6 +146,7 @@ mod tests {
[tool.py-checked-exceptions]
target-exceptions = ["example.BaseError"]
extensions = ["fastapi"]
entrypoints = ["fastapi:route"]
python = ".venv"
typeshed = "typings/typeshed"
extra-search-paths = ["packages/one", "packages/two"]
Expand Down Expand Up @@ -173,6 +175,7 @@ mod tests {
Some(vec!["example.BaseError".into()])
);
assert_eq!(config.extensions, Some(vec![AnalysisExtension::Fastapi]));
assert_eq!(config.entrypoints, Some(vec![Entrypoint::FastapiRoute]));
let effects = config.context_manager_effects.unwrap();
assert_eq!(effects.len(), 2);
assert_eq!(effects[0].function, "example.suppress_error");
Expand Down
9 changes: 9 additions & 0 deletions src/extension/fastapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ use crate::{
},
};

pub(crate) fn is_route(function: &StmtFunctionDef) -> bool {
function.decorator_list.iter().any(|decorator| {
decorator
.expression
.as_call_expr()
.is_some_and(|call| is_route_decorator(&call.func))
})
}

pub(crate) fn documented_exceptions(
db: &dyn Db,
file: File,
Expand Down
Loading