Skip to main content

Python dead code analysis backed by a Rust extension (ty + ruff)

Project description

dead-cst

PyPI Python License: MIT CI codecov Ruff uv

Python dead code analysis backed by a Rust extension built on top of ty's SemanticIndex and libcst (codemod only).

dead-cst builds a full symbol graph of your Python codebase, walks from your entrypoints, and reports (or removes) anything unreachable.

Pre-release software. dead-cst is in early alpha. APIs, CLI flags, and output formats may change without notice, and bugs are expected. dead-cst remove itself is non-destructive — it emits a patch to stdout — but apply the patch on a clean working tree so you can inspect (and easily revert) the result.

Installation

pip install dead-cst

Or with uv:

uv add dead-cst

Quick start

# Find dead code in your project
dead-cst analyze ./src --entrypoint-regex ".*__main__\.py"

# Generate a patch that removes dead code, then apply it
dead-cst remove ./src --entrypoint-regex ".*__main__\.py" | git apply
# Or, outside a git checkout: dead-cst remove ./src ... | patch -p1

# Drop the test suite and every helper it kept alive
dead-cst remove ./src --plugin pytest --query test-only | git apply

# Build the graph once, reuse it for several reachability queries
dead-cst build ./src -o graph.bin --plugin pytest --plugin fastapi
dead-cst analyze ./src --graph graph.bin
dead-cst analyze ./src --graph graph.bin --query test-only
dead-cst remove ./src --graph graph.bin --query test-only -o test-only.patch

CLI reference

The CLI surfaces three commands — build, analyze, remove — plus a --graph flag that lets analyze / remove reuse a graph build already wrote to disk.

dead-cst build

Materialize the project graph and persist it to disk. Subsequent analyze / remove runs can load the saved file with --graph PATH to skip the build step. Useful for CI shapes that run several reachability queries against the same code state. The on-disk format captures only the graph (nodes + edges + a small metadata block); plugins do not round-trip — load a graph and you get the materialized adjacency only.

dead-cst build ROOT -o PATH [OPTIONS]
Option Description
-o, --output Required. Write the graph to this file
-e, --entrypoint Entrypoint as a file path or FQN (repeatable)
--entrypoint-regex Entrypoint as a regex over fqnames / file paths (repeatable)
-p, --path Search path spec: package:dep1,dep2 or package (repeatable)
--resolver Path resolver to run, e.g. uv (mutually exclusive with -p)
--plugin Edge plugin to run, e.g. main_block, pytest, fastapi (repeatable)
--meta Stash key=value metadata in the graph file (repeatable)
-v, --verbose Enable verbose logging

Graph files start with the magic bytes DEADCSTG and a u32 format version; mismatched versions raise on load with no migration path (rebuilding a graph is cheap).

dead-cst analyze

Report dead code for a Python codebase.

dead-cst analyze ROOT [OPTIONS]
Option Description
--graph Load a pre-built graph from disk; mutually exclusive with all build inputs
--query Reachability question: dead (default) or test-only
-e, --entrypoint Entrypoint as a file path or FQN (repeatable)
--entrypoint-regex Entrypoint as a regex (repeatable)
-p, --path Search path spec (repeatable)
--resolver Path resolver to run, e.g. uv
--plugin Edge plugin to run (repeatable)
--format Output format: text or json
--exit-zero Always exit 0, even when dead code is found
-v, --verbose Enable verbose logging

Without --exit-zero, the exit code is 1 if dead code is found, 0 otherwise.

--query test-only runs kept_alive_by_flags_only(NodeFlags.TESTCASE) — the blast-radius diff between "alive with the test suite" and "alive without it" — and reports the set that would go dark if the tests went away. The test functions themselves are included (they are TESTCASE-flagged), so the result is the wholesale "the test suite and everything that backs it" set.

dead-cst remove

Emit a unified diff that removes the dead code. The command never touches source files itself — pipe the patch into git apply (preferred in a git checkout) or POSIX patch -p1 (handy when running from a subdirectory or outside git), or write it to a file with -o and apply later.

dead-cst remove ROOT [OPTIONS]
Option Description
--graph Load a pre-built graph from disk; mutually exclusive with all build inputs
--query Reachability question: dead (default) or test-only
-e, --entrypoint Entrypoint as a file path or FQN (repeatable)
--entrypoint-regex Entrypoint as a regex (repeatable)
-p, --path Search path spec (repeatable)
--resolver Path resolver to run, e.g. uv
--plugin Edge plugin to run (repeatable)
-o, --output Write the patch to this file instead of stdout
-v, --verbose Enable verbose logging
dead-cst remove ./src -e mypkg.__main__ | git apply
dead-cst remove ./src -e mypkg.__main__ -o dead.patch && git apply dead.patch

# Outside git, or when running from a sub-directory whose CWD is the patch root:
dead-cst remove . -e mypkg.__main__ | patch -p1

Python API

from pathlib import Path
from dead_cst import Analysis, _native as native
from dead_cst.resolvers import ManualResolver

root = Path("./src")
analysis = Analysis(
    root,
    resolver=ManualResolver(specs=["."]),
    plugins=[
        native.NativePlugin.main_block(),
        # explicit(regexes, str_specs, abs_paths): match __main__.py via regex.
        native.NativePlugin.explicit([r".*__main__\.py"], [], []),
    ],
)

# Whole-project queries: cheap to construct, lazy to materialize.
for node in analysis.dead():
    print(f"dead: {node.fqname} ({node.kind}) at {node.path}")

# "What's only alive because of the tests?" — the same blast-radius
# query the ``dead-cst remove --query test-only`` workflow uses.
from dead_cst.graph import NodeFlags
for node in analysis.kept_alive_by_flags_only(NodeFlags.TESTCASE):
    print(f"test-only: {node.fqname}")

# Rewrite source files to drop every unreachable decl + now-unused import.
from dead_cst.codemod import remove_code
remove_code(list(analysis.dead()), root)

For non-destructive review, dead_cst.codemod.generate_patch(G, root) returns the same removal as a git apply-compatible unified diff. Selection is driven entirely by G.nodes, so you can pass any subgraph slice to review a large codebase as a series of focused patches.

Graph persistence

dead_cst.graph.write_graph(path, graph, meta) and dead_cst.graph.read_graph(path) mirror the dead-cst build / --graph workflow at the library level. The body is a bincode-encoded blob prefixed with magic bytes + a u32 format version; loaders reject mismatched versions outright (no migration logic — rebuilding a graph is cheap). The metadata block records created_at, node / edge / file / line counts, and the user-supplied (key, value) pairs the CLI's --meta flag threads through.

from dead_cst import Analysis
from dead_cst.graph import read_graph, write_graph
from dead_cst.resolvers import ManualResolver

graph = Analysis(root, resolver=ManualResolver(specs=["."])).materialize_all()
write_graph("graph.bin", graph, meta=[("branch", "main"), ("sha", "abc123")])

loaded, meta = read_graph("graph.bin")
print(meta.node_count, meta.edge_count, dict(meta.user_meta))

Analysis(...).materialize_all() returns the live dead_cst._native.ProjectContext; queries on the Analysis (reachable / dead / descendants / ancestors / kept_alive_by_flags_only) delegate to the rust BFS without copying the graph into Python.

Incremental rebuilds

For long-lived Analysis instances (LSP harnesses, watch loops), Analysis.re_materialize(events) rebuilds the graph against the same ProjectContext without tearing down ty's salsa db. The caller supplies the change events — either an explicit list (from an LSP consuming didChangeWatchedFiles, a file-watcher, or a CI script that knows which files it touched), or ctx.detect_changes() to let ty figure it out:

from dead_cst import _native as native

ctx = analysis.materialize_all()
# ... source files change on disk ...

analysis.re_materialize(ctx.detect_changes())              # autodetect via ty
analysis.re_materialize([                                  # or explicit
    native.ChangeEvent.changed("/abs/foo.py"),
    native.ChangeEvent.created("/abs/new.py"),
    native.ChangeEvent.deleted("/abs/gone.py"),
])

ctx.detect_changes() currently returns a single ChangeEvent.rescan(), which ty's apply handler turns into an mtime-checked Files::sync_all + project file re-walk + metadata rediscovery. Salsa's per-file cache survives across calls, so unchanged files skip parsing / file_to_nodes / file_to_edges / file_to_ref_edges recomputation; cross-file importers invalidate transitively through salsa's read-tracking. The assemble pass and plugin pass run on every call, but they're cheap O(N) walks over the warm cache.

Module-level dunders (__all__, __version__, PEP 562 __getattr__/__dir__, …) and __future__ imports are kept alive by the engine itself — no plugin required — for as long as their module is reachable. An unreachable module's dunders die with it.

Beyond that, entrypoint detection is fully plugin-driven. Builtins:

Plugin Purpose
NativePlugin.main_block() Mark modules containing if __name__ == "__main__": as entrypoints (always on)
NativePlugin.project_scripts() Read pyproject.toml [project.scripts] and mark each target as an entrypoint
NativePlugin.explicit() Match user-supplied file paths / FQNs / regexes (powers -e and --entrypoint-regex)
NativePlugin.pytest() Keep pytest-discovered tests, conftest.py decls, and @pytest.fixture functions alive (--plugin pytest)
NativePlugin.unittest() Keep stdlib unittest.TestCase / IsolatedAsyncioTestCase subclasses and setUpModule / tearDownModule / load_tests hooks alive. Transitive: a class extending a project-local TestCase mixin or a re-exported TestCase is detected (--plugin unittest)
NativePlugin.mock_patch() Resolve string-fqname patch targets so symbols whose only consumers are tests stay alive. Recognizes unittest.mock.patch / mock.patch (decorator and context-manager forms, plus aliased imports), pytest-mock's mocker.patch, and pytest's monkeypatch.setattr("X.Y", v) / monkeypatch.delattr("X.Y") (--plugin mock_patch)
NativePlugin.server_config() Mark Gunicorn / Hypercorn config modules (gunicorn.conf.py, hypercorn.conf.py, and the *_conf.py variants) as entrypoints. The server loads these files by path at startup — nothing imports them statically, so the whole top-level surface would otherwise look dead. Override the filenames tuple for non-standard layouts (--plugin server_config)
NativePlugin.fastapi() Detect top-level FastAPI() / APIRouter() instances; mark FastAPI apps as entrypoints and add instance -> handler edges for every @app.get(...)-style decorator (HTTP methods, websockets, middleware, exception handlers, on_event). Routers stay pass-through, so an APIRouter that's never include_router'd remains dead (--plugin fastapi)
NativePlugin.flask() Detect top-level Flask() / Blueprint() instances; mark Flask apps as entrypoints and add instance -> handler edges for every @app.route(...) / @app.get(...) / lifecycle / errorhandler / template-helper / URL-processor decorator. Blueprints stay pass-through, so a Blueprint that's never register_blueprint'd remains dead (--plugin flask)
NativePlugin.typer() Detect top-level Typer() instances and add instance -> handler edges for every @app.command(...) / @app.callback(...) decorator. Typer apps are pass-through (reach them via [project.scripts] or if __name__ == "__main__": app()), so a sub-typer that's never add_typer'd stays dead (--plugin typer)
NativePlugin.click() Detect top-level Click Group instances (functions decorated @click.group(...) or X = click.Group(...)) and add instance -> handler edges for every @cli.command(...) / @cli.group(...) / @cli.result_callback(...) decorator. Groups are pass-through, so a sub-group that's never add_command'd stays dead (--plugin click)
NativePlugin.cyclopts() Detect top-level cyclopts.App() instances and add instance -> handler edges for every @app.command(...) / @app.default(...) decorator. Apps are pass-through, mirroring the Typer plugin (--plugin cyclopts)
NativePlugin.discordpy() Detect top-level commands.Bot() / discord.Client() (and AutoSharded*) instances, mark them as entrypoints, and add instance -> handler edges for @bot.command() / event / listen() / group() / hybrid_command() / before_invoke / after_invoke / check decorators plus the two-level @bot.tree.command() / @bot.tree.context_menu() slash-command form (--plugin discordpy)
NativePlugin.fastmcp() Detect top-level FastMCP() server instances, mark them as entrypoints, and add instance -> handler edges for every @mcp.tool / @mcp.resource / @mcp.prompt / @mcp.completion decorator. Factory-aware: def create_server() -> FastMCP: ... chains classify across packages (--plugin fastmcp)
NativePlugin.init_subclass() Detect classes that define __init_subclass__ and add parent -> subclass edges for every (transitive) first-party subclass. Parents stay pass-through, so a registry base class only keeps subclasses alive once something else keeps the parent alive (--plugin init_subclass)

The dispatch-app frameworks (Flask, FastAPI, Typer, Cyclopts, Slack Bolt, FastMCP, Celery) are built-in native plugins too — NativePlugin.flask()NativePlugin.celery(), or --plugin flask--plugin celery on the CLI.

Every built-in is a dead_cst._native.NativePlugin; there is no Python plugin protocol. Write your own as an external native (Rust) plugin that links the dead-cst runtime (see NATIVE_PLUGINS.md). Out-of-tree plugins register under the dead_cst.plugins entry-point group for CLI discovery; the entry point must resolve to (or return) a NativePlugin.

Path resolution is similarly pluggable. PathResolver implementations return a tuple of Package records (path, name, deps) to feed Analysis. Builtins: ManualResolver (explicit package:dep specs from -p) and UvResolver (parses uv.lock to discover workspace members and their inter-member dep edges). Third-party resolvers register under dead_cst.resolvers.

Unreachable code regions (if False:, the else of if True:, statements after an unconditional return / raise / break / continue, ...) are detected by the rust backend and stamped onto the contributing edges with EdgeFlags.DEAD_BRANCH. Default reachability follows those edges so the metadata stays informational; Analysis.kept_alive_by_dead_branches() is the opt-in inverse query that returns the set of nodes only reachable through dead-branch edges.

Graph model

The graph has one node per top-level declaration plus a synthetic module node per file. Edges run from a declaration to each symbol it references, and from every submodule to its parent package so __init__.py stays alive as long as anything in the package does. Entrypoints seed the reachability walk; every node not reached is reported as dead.

A module-level import / from ... import ... is itself a declaration of type "import" in the current module. Uses of the imported name inside the file are wired through that local import node, and the import node in turn points at the upstream module (and, when applicable, at the specific imported symbol). Removing the last local use therefore makes the import itself dead, which is how dead-cst remove knows to drop now-unused import lines.

Imports whose source line carries a ruff/pyflakes # noqa directive that silences F401 (# noqa, # noqa: F401, multi-rule # noqa: E501, F401, case-variant # NOQA) are pinned alive. File-level # ruff: noqa and # flake8: noqa directives (ruff: / flake8: is matched case-sensitively per ruff; noqa is not) pin every import in the file. This matches ruff's own semantics: an import you have explicitly preserved (re-exports, side-effect imports, TYPE_CHECKING shims guarded by F401) is not surfaced as dead and dead-cst remove will not drop it.

Pinned imports are tagged with NodeFlags.NOQA (in addition to NodeFlags.ENTRYPOINT). The opt-in Analysis.kept_alive_by_flags_only(flags) query takes any NodeFlags combination and returns the "blast radius" of dropping every entrypoint with those flag bits. Pass NodeFlags.NOQA to audit stale F401 pins ("if I removed every # noqa: F401, what would actually become dead?"), NodeFlags.TESTCASE for "what would die if you dropped the test suite", or NodeFlags.TESTCASE | NodeFlags.NOQA to combine in one pass.

Scope

dead-cst tracks top-level declarations only -- module-level functions, classes, and variables. Nested definitions (inner functions, methods, nested classes) are deliberately not given their own nodes; references made from inside those nested scopes are attributed to the enclosing top-level declaration. Keeping the containing top-level symbol alive keeps its nested source alive with it.

.pyi stub files are ingested for the compiled-extension layout: a binary like mypkg/_native.so shipping next to mypkg/_native.pyi. With no matching .py, the stub is the only Python-discoverable description of those names, so the analyzer parses it under its natural module FQN (mypkg._native) and from mypkg._native import compute resolves to the stub's declaration through the normal import path. A .pyi shipped alongside a real .py is dropped during file enumeration -- the runtime always wins, and the peer stub is invisible to dead-cst.

Jupyter notebooks (.ipynb) are ingested too: code cells are concatenated in document order into a single libcst-parseable module, IPython line / cell magics, shell escapes (!ls), and trailing-help forms (obj?, obj.attr??) are neutralized line-for-line into pass # <line> comments, and every emitted SymbolNode is flagged NodeFlags.NOTEBOOK | NodeFlags.ENTRYPOINT. Notebooks aren't importable, so their decls stay out of the cross-module lookup trie — but .py code a notebook imports stays alive, which makes notebooks effective reachability seeds. dead-cst remove skips .ipynb paths (cell-aware writeback into the notebook JSON envelope is out of scope today).

@typing.overload-decorated functions in .py files are flagged so a dead implementation drags its overloads along during dead-cst remove instead of leaving them behind as orphans.

Limitations

  • from X import * is treated pessimistically (every top-level declaration in the target stays alive). Cross-module from <importer> import <name> still resolves through to <name>'s real source — even across chained __init__.py star re-exports — via ty's name resolution, and bare-name uses inside function bodies (from x import *; def a(): g()) edge to the upstream decl. When two stars in the same module export the same name, "first writer wins"; Python's runtime "last star wins" semantics is not implemented.
  • __import__('pkg.mod') and importlib.import_module('pkg.mod') with string-literal arguments emit EdgeFlags.DYNAMIC_IMPORT edges to the resolved module / decls; non-literal arguments fall back to opaque external nodes. NativePlugin.dynamic_import_fallback() opts into per-name fan-out for the dynamic-import case.
  • Dynamic attribute access (getattr) and runtime-generated symbols are invisible to static analysis.
  • Only first-party code is analysed; third-party dependencies appear as synthetic [external dist] <name> / [external file] <name> nodes in the graph (and are the targets of every import that resolves outside the project).
  • __all__ is followed only when assigned a list/tuple of string literals; dynamic mutation (__all__.append, comprehensions, etc.) is not tracked.
  • PEP 572 walrus targets inside a comprehension ([last := n for n in nums]) don't surface as top-level decls and module-level uses of the name go unresolved. ty has a // TODO walrus in comprehensions is implicitly nonlocal, so the last binding stays scoped to the comprehension instead of leaking to the enclosing module per PEP 572 (see tests/test_limitations.py::comprehension-walrus-doesnt-leak-to-enclosing-scope).

Native plugins (experimental)

Every built-in plugin is native and ships in the wheel. Out-of-tree extensions are also native (Rust) plugins: a separately-compiled plugin that links the dead-cst runtime and runs against a live ProjectContext — including its own salsa-cached queries over ty's database. The external compile-and-load flow below is still a preview.

pip install dead-cst[build-plugin]            # pulls the rlib compile closure
PLUGIN=$(dead-cst build-plugin my_plugin.rs)  # compile against the shared runtime
from dead_cst import Analysis, _native as native
plugins = native.load_native_plugins(PLUGIN)
Analysis(root, plugins=plugins).materialize_all()

The shipped macOS/Linux wheel runs the runtime as a shared dylib, so the built plugin loads with no extra steps. This is a preview (macOS and Linux today, recompile-per-release). See NATIVE_PLUGINS.md for the full guide and the worked examples/main_block_plugin/.

Development

git clone https://github.com/lpetre/dead-cst
cd dead-cst
uv sync
uv run pytest
uv run prek run --all-files

See ARCHITECTURE.md for a walkthrough of the analyzer pipeline, CONTRIBUTING.md for the full dev guide, CHANGELOG.md for release notes, and ROADMAP.md for the stack-ranked plan toward 1.0.

TODO

  • Host API documentation on Read the Docs.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dead_cst-0.14.0.tar.gz (5.3 MB view details)

Uploaded Source

Built Distributions

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

dead_cst-0.14.0-cp311-abi3-win_amd64.whl (9.5 MB view details)

Uploaded CPython 3.11+Windows x86-64

dead_cst-0.14.0-cp311-abi3-manylinux_2_28_x86_64.whl (16.1 MB view details)

Uploaded CPython 3.11+manylinux: glibc 2.28+ x86-64

dead_cst-0.14.0-cp311-abi3-manylinux_2_28_aarch64.whl (16.5 MB view details)

Uploaded CPython 3.11+manylinux: glibc 2.28+ ARM64

dead_cst-0.14.0-cp311-abi3-macosx_11_0_arm64.whl (13.6 MB view details)

Uploaded CPython 3.11+macOS 11.0+ ARM64

File details

Details for the file dead_cst-0.14.0.tar.gz.

File metadata

  • Download URL: dead_cst-0.14.0.tar.gz
  • Upload date:
  • Size: 5.3 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dead_cst-0.14.0.tar.gz
Algorithm Hash digest
SHA256 4641c7f2c11aad3a09ec017cf5eb4635b00bf61436e75766549fc7691fe9ebf4
MD5 f299c57e4ce9c35c2483a2b60dcc3f97
BLAKE2b-256 87238414f5229774f8c920a1843fbb6c81694fc52eaf493a0694cf271261a386

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.14.0.tar.gz:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dead_cst-0.14.0-cp311-abi3-win_amd64.whl.

File metadata

  • Download URL: dead_cst-0.14.0-cp311-abi3-win_amd64.whl
  • Upload date:
  • Size: 9.5 MB
  • Tags: CPython 3.11+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for dead_cst-0.14.0-cp311-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 b672c4283f6ea4dc175b96afe6687835969afa3880f458b85c9996a85c4315d4
MD5 7d37e49a35ca87346b417bee5f52e4ac
BLAKE2b-256 924b5364e9fef6ca7df4854b2dc7842ab24907dfaa49f9d97abc695c4f03dc2f

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.14.0-cp311-abi3-win_amd64.whl:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dead_cst-0.14.0-cp311-abi3-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for dead_cst-0.14.0-cp311-abi3-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 c8f72c07f1a5509a0f162b880bef4e10133801da2834348d0c531ae2a5bd2e23
MD5 a056a3b5471d33cb6232f070db38d8e6
BLAKE2b-256 ba750b7b12396bd2d08bff78072629a6f43f029c8a2de9e0836eb65a27426f7b

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.14.0-cp311-abi3-manylinux_2_28_x86_64.whl:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dead_cst-0.14.0-cp311-abi3-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for dead_cst-0.14.0-cp311-abi3-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 d6b8164a9cff2076ff28c1279ca7d70e2377b4a78e7cc8c397e08157ef91bd0f
MD5 ae54374756d65c37bbaccd4e11439325
BLAKE2b-256 764749ef1cc9eb57a0c86a06da19a20e4dbcff6b69979094f05bf11e719e38cb

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.14.0-cp311-abi3-manylinux_2_28_aarch64.whl:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file dead_cst-0.14.0-cp311-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for dead_cst-0.14.0-cp311-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 faf026a861fa39212251d96ac568bc4f2ce9a5b7adeb7af191618739517da6ec
MD5 1f1fe8127cd274e281e611004408bb8f
BLAKE2b-256 54e9f2e4952458f3c3a50f480a2ad5dda79e63a8be8d4d6052cc517f7901831a

See more details on using hashes here.

Provenance

The following attestation bundles were made for dead_cst-0.14.0-cp311-abi3-macosx_11_0_arm64.whl:

Publisher: publish.yml on lpetre/dead-cst

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

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