Metadata-Version: 2.4
Name: whytrail
Version: 0.3.1
Summary: Python tells you where. whytrail tells you why a value is the way it is.
Project-URL: Homepage, https://github.com/bhouvana/Whytrail
Project-URL: Documentation, https://bhouvana.github.io/Whytrail/
Project-URL: Issues, https://github.com/bhouvana/Whytrail/issues
Author: whytrail contributors
License-Expression: MIT
License-File: LICENSE
Keywords: debugging,exceptions,explainability,provenance,tracing
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Debuggers
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: aiohttp
Requires-Dist: aiohttp>=3.9; extra == 'aiohttp'
Provides-Extra: aiohttp-server
Requires-Dist: aiohttp>=3.9; extra == 'aiohttp-server'
Provides-Extra: alembic
Requires-Dist: alembic>=1.7; extra == 'alembic'
Provides-Extra: algoliasearch
Requires-Dist: algoliasearch>=3.0; extra == 'algoliasearch'
Provides-Extra: all
Requires-Dist: aiohttp>=3.9; extra == 'all'
Requires-Dist: alembic>=1.7; extra == 'all'
Requires-Dist: algoliasearch>=3.0; extra == 'all'
Requires-Dist: anthropic>=0.30; extra == 'all'
Requires-Dist: asana>=5.0; extra == 'all'
Requires-Dist: asyncpg>=0.29; extra == 'all'
Requires-Dist: auth0-python>=4.0; extra == 'all'
Requires-Dist: azure-core>=1.24; extra == 'all'
Requires-Dist: boto3>=1.34; extra == 'all'
Requires-Dist: bugsnag>=4.5; extra == 'all'
Requires-Dist: cassandra-driver>=3.25; extra == 'all'
Requires-Dist: celery>=5.3; extra == 'all'
Requires-Dist: chromadb>=0.5; extra == 'all'
Requires-Dist: clickhouse-connect>=0.6; extra == 'all'
Requires-Dist: cohere>=5.0; extra == 'all'
Requires-Dist: confluent-kafka>=2.0; extra == 'all'
Requires-Dist: dagster>=1.7; extra == 'all'
Requires-Dist: datadog-api-client>=2.20; extra == 'all'
Requires-Dist: ddtrace>=2.0; extra == 'all'
Requires-Dist: discord-py>=2.3; extra == 'all'
Requires-Dist: django>=4.2; extra == 'all'
Requires-Dist: docker>=7.0; extra == 'all'
Requires-Dist: dramatiq>=1.14; extra == 'all'
Requires-Dist: dropbox>=11.36; extra == 'all'
Requires-Dist: elastic-apm>=6.15; extra == 'all'
Requires-Dist: elasticsearch>=8.0; extra == 'all'
Requires-Dist: firebase-admin>=6.0; extra == 'all'
Requires-Dist: flask>=2.3; extra == 'all'
Requires-Dist: google-api-core>=2.15; extra == 'all'
Requires-Dist: google-genai>=1.0; extra == 'all'
Requires-Dist: graphql-core>=3.2; extra == 'all'
Requires-Dist: groq>=0.9; extra == 'all'
Requires-Dist: grpcio>=1.60; extra == 'all'
Requires-Dist: honeybadger>=0.15; extra == 'all'
Requires-Dist: httpx>=0.27; extra == 'all'
Requires-Dist: huggingface-hub>=0.20; extra == 'all'
Requires-Dist: hvac>=2.0; extra == 'all'
Requires-Dist: influxdb-client>=1.30; extra == 'all'
Requires-Dist: jsonschema>=4.0; extra == 'all'
Requires-Dist: kubernetes>=18.20; extra == 'all'
Requires-Dist: langchain-core>=0.2; extra == 'all'
Requires-Dist: loguru>=0.7; extra == 'all'
Requires-Dist: marshmallow>=3.0; extra == 'all'
Requires-Dist: meilisearch>=0.31; extra == 'all'
Requires-Dist: minio>=7.2; extra == 'all'
Requires-Dist: mistralai>=1.0; extra == 'all'
Requires-Dist: mlflow>=2.10; extra == 'all'
Requires-Dist: nats-py>=2.7; extra == 'all'
Requires-Dist: neo4j>=5.24; extra == 'all'
Requires-Dist: newrelic>=9.0; extra == 'all'
Requires-Dist: notion-client>=2.0; extra == 'all'
Requires-Dist: okta>=2.9; extra == 'all'
Requires-Dist: openai>=1.0; extra == 'all'
Requires-Dist: opensearch-py>=2.0; extra == 'all'
Requires-Dist: oracledb>=2.0; extra == 'all'
Requires-Dist: pagerduty>=1.0; extra == 'all'
Requires-Dist: pandas>=2.0; extra == 'all'
Requires-Dist: paramiko>=2.7; extra == 'all'
Requires-Dist: pika>=1.1; extra == 'all'
Requires-Dist: pinecone>=6.0; extra == 'all'
Requires-Dist: plaid-python>=18.0; extra == 'all'
Requires-Dist: polars>=0.20; extra == 'all'
Requires-Dist: postmarker>=1.0; extra == 'all'
Requires-Dist: prefect>=2.14; extra == 'all'
Requires-Dist: psycopg>=3.1; extra == 'all'
Requires-Dist: pydantic>=2.0; extra == 'all'
Requires-Dist: pygithub>=2.1; extra == 'all'
Requires-Dist: pymongo>=4.0; extra == 'all'
Requires-Dist: pymssql>=2.2; extra == 'all'
Requires-Dist: pymysql>=1.0; extra == 'all'
Requires-Dist: pyodbc>=4.0; extra == 'all'
Requires-Dist: pytest>=7.0; extra == 'all'
Requires-Dist: python-arango>=7.9; extra == 'all'
Requires-Dist: pyyaml>=6.0; extra == 'all'
Requires-Dist: pyzmq>=24.0; extra == 'all'
Requires-Dist: qdrant-client>=1.10; extra == 'all'
Requires-Dist: replicate>=1.0; extra == 'all'
Requires-Dist: requests>=2.20; extra == 'all'
Requires-Dist: rollbar>=1.0; extra == 'all'
Requires-Dist: rq>=1.15; extra == 'all'
Requires-Dist: scrapy>=2.8; extra == 'all'
Requires-Dist: sendgrid>=6.0; extra == 'all'
Requires-Dist: sentry-sdk>=2.0; extra == 'all'
Requires-Dist: simple-salesforce>=1.12; extra == 'all'
Requires-Dist: slack-sdk>=3.27; extra == 'all'
Requires-Dist: snowflake-connector-python>=3.0; extra == 'all'
Requires-Dist: sqlalchemy>=2.0; extra == 'all'
Requires-Dist: squareup>=39.0; extra == 'all'
Requires-Dist: starlette>=0.36; extra == 'all'
Requires-Dist: stripe>=7.0; extra == 'all'
Requires-Dist: structlog>=23.1; extra == 'all'
Requires-Dist: supabase>=2.0; extra == 'all'
Requires-Dist: temporalio>=1.7; extra == 'all'
Requires-Dist: tenacity>=8.0; extra == 'all'
Requires-Dist: twilio>=8.0; extra == 'all'
Requires-Dist: wandb>=0.16; extra == 'all'
Requires-Dist: weaviate-client>=4.9; extra == 'all'
Requires-Dist: websockets>=12.0; extra == 'all'
Requires-Dist: zeep>=4.1; extra == 'all'
Requires-Dist: zenpy>=2.0; extra == 'all'
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.30; extra == 'anthropic'
Provides-Extra: arango
Requires-Dist: python-arango>=7.9; extra == 'arango'
Provides-Extra: asana
Requires-Dist: asana>=5.0; extra == 'asana'
Provides-Extra: asyncpg
Requires-Dist: asyncpg>=0.29; extra == 'asyncpg'
Provides-Extra: auth0
Requires-Dist: auth0-python>=4.0; extra == 'auth0'
Provides-Extra: azure-core
Requires-Dist: azure-core>=1.24; extra == 'azure-core'
Provides-Extra: boto3
Requires-Dist: boto3>=1.34; extra == 'boto3'
Provides-Extra: bugsnag
Requires-Dist: bugsnag>=4.5; extra == 'bugsnag'
Provides-Extra: cassandra
Requires-Dist: cassandra-driver>=3.25; extra == 'cassandra'
Provides-Extra: celery
Requires-Dist: celery>=5.3; extra == 'celery'
Provides-Extra: chromadb
Requires-Dist: chromadb>=0.5; extra == 'chromadb'
Provides-Extra: cli
Requires-Dist: rich>=13.0; extra == 'cli'
Provides-Extra: clickhouse
Requires-Dist: clickhouse-connect>=0.6; extra == 'clickhouse'
Provides-Extra: cohere
Requires-Dist: cohere>=5.0; extra == 'cohere'
Provides-Extra: confluent-kafka
Requires-Dist: confluent-kafka>=2.0; extra == 'confluent-kafka'
Provides-Extra: dagster
Requires-Dist: dagster>=1.7; extra == 'dagster'
Provides-Extra: datadog-api-client
Requires-Dist: datadog-api-client>=2.20; extra == 'datadog-api-client'
Provides-Extra: ddtrace
Requires-Dist: ddtrace>=2.0; extra == 'ddtrace'
Provides-Extra: dev
Requires-Dist: click>=8.0; extra == 'dev'
Requires-Dist: hypothesis>=6.100; extra == 'dev'
Requires-Dist: jinja2>=3.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-benchmark>=4.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: requests>=2.25; extra == 'dev'
Requires-Dist: rich>=13.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: discord-py
Requires-Dist: discord-py>=2.3; extra == 'discord-py'
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: docker
Requires-Dist: docker>=7.0; extra == 'docker'
Provides-Extra: docs
Requires-Dist: mkdocs-include-markdown-plugin>=6.0; extra == 'docs'
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
Requires-Dist: mkdocs>=1.6; extra == 'docs'
Provides-Extra: dramatiq
Requires-Dist: dramatiq>=1.14; extra == 'dramatiq'
Provides-Extra: dropbox
Requires-Dist: dropbox>=11.36; extra == 'dropbox'
Provides-Extra: elastic-apm
Requires-Dist: elastic-apm>=6.15; extra == 'elastic-apm'
Provides-Extra: elasticsearch
Requires-Dist: elasticsearch>=8.0; extra == 'elasticsearch'
Provides-Extra: fastapi
Requires-Dist: starlette>=0.36; extra == 'fastapi'
Provides-Extra: firebase-admin
Requires-Dist: firebase-admin>=6.0; extra == 'firebase-admin'
Provides-Extra: flask
Requires-Dist: flask>=2.3; extra == 'flask'
Provides-Extra: github
Requires-Dist: pygithub>=2.1; extra == 'github'
Provides-Extra: google-cloud
Requires-Dist: google-api-core>=2.15; extra == 'google-cloud'
Provides-Extra: google-genai
Requires-Dist: google-genai>=1.0; extra == 'google-genai'
Provides-Extra: graphql-core
Requires-Dist: graphql-core>=3.2; extra == 'graphql-core'
Provides-Extra: groq
Requires-Dist: groq>=0.9; extra == 'groq'
Provides-Extra: grpcio
Requires-Dist: grpcio>=1.60; extra == 'grpcio'
Provides-Extra: honeybadger
Requires-Dist: honeybadger>=0.15; extra == 'honeybadger'
Provides-Extra: httpx
Requires-Dist: httpx>=0.27; extra == 'httpx'
Provides-Extra: huggingface-hub
Requires-Dist: huggingface-hub>=0.20; extra == 'huggingface-hub'
Provides-Extra: hvac
Requires-Dist: hvac>=2.0; extra == 'hvac'
Provides-Extra: influxdb
Requires-Dist: influxdb-client>=1.30; extra == 'influxdb'
Provides-Extra: jsonschema
Requires-Dist: jsonschema>=4.0; extra == 'jsonschema'
Provides-Extra: kubernetes
Requires-Dist: kubernetes>=18.20; extra == 'kubernetes'
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
Provides-Extra: loguru
Requires-Dist: loguru>=0.7; extra == 'loguru'
Provides-Extra: marshmallow
Requires-Dist: marshmallow>=3.0; extra == 'marshmallow'
Provides-Extra: meilisearch
Requires-Dist: meilisearch>=0.31; extra == 'meilisearch'
Provides-Extra: minio
Requires-Dist: minio>=7.2; extra == 'minio'
Provides-Extra: mistralai
Requires-Dist: mistralai>=1.0; extra == 'mistralai'
Provides-Extra: mlflow
Requires-Dist: mlflow>=2.10; extra == 'mlflow'
Provides-Extra: nats-py
Requires-Dist: nats-py>=2.7; extra == 'nats-py'
Provides-Extra: neo4j
Requires-Dist: neo4j>=5.24; extra == 'neo4j'
Provides-Extra: newrelic
Requires-Dist: newrelic>=9.0; extra == 'newrelic'
Provides-Extra: notion-client
Requires-Dist: notion-client>=2.0; extra == 'notion-client'
Provides-Extra: okta
Requires-Dist: okta>=2.9; extra == 'okta'
Provides-Extra: openai
Requires-Dist: openai>=1.0; extra == 'openai'
Provides-Extra: opensearch
Requires-Dist: opensearch-py>=2.0; extra == 'opensearch'
Provides-Extra: oracledb
Requires-Dist: oracledb>=2.0; extra == 'oracledb'
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
Provides-Extra: pagerduty
Requires-Dist: pagerduty>=1.0; extra == 'pagerduty'
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == 'pandas'
Provides-Extra: paramiko
Requires-Dist: paramiko>=2.7; extra == 'paramiko'
Provides-Extra: pika
Requires-Dist: pika>=1.1; extra == 'pika'
Provides-Extra: pinecone
Requires-Dist: pinecone>=6.0; extra == 'pinecone'
Provides-Extra: plaid
Requires-Dist: plaid-python>=18.0; extra == 'plaid'
Provides-Extra: polars
Requires-Dist: polars>=0.20; extra == 'polars'
Provides-Extra: postmarker
Requires-Dist: postmarker>=1.0; extra == 'postmarker'
Provides-Extra: prefect
Requires-Dist: prefect>=2.14; extra == 'prefect'
Provides-Extra: psycopg
Requires-Dist: psycopg>=3.1; extra == 'psycopg'
Provides-Extra: pydantic
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
Provides-Extra: pymongo
Requires-Dist: pymongo>=4.0; extra == 'pymongo'
Provides-Extra: pymssql
Requires-Dist: pymssql>=2.2; extra == 'pymssql'
Provides-Extra: pymysql
Requires-Dist: pymysql>=1.0; extra == 'pymysql'
Provides-Extra: pyodbc
Requires-Dist: pyodbc>=4.0; extra == 'pyodbc'
Provides-Extra: pytest
Requires-Dist: pytest>=7.0; extra == 'pytest'
Provides-Extra: pyyaml
Requires-Dist: pyyaml>=6.0; extra == 'pyyaml'
Provides-Extra: pyzmq
Requires-Dist: pyzmq>=24.0; extra == 'pyzmq'
Provides-Extra: qdrant-client
Requires-Dist: qdrant-client>=1.10; extra == 'qdrant-client'
Provides-Extra: replicate
Requires-Dist: replicate>=1.0; extra == 'replicate'
Provides-Extra: requests
Requires-Dist: requests>=2.20; extra == 'requests'
Provides-Extra: rich
Requires-Dist: rich>=13.0; extra == 'rich'
Provides-Extra: rollbar
Requires-Dist: rollbar>=1.0; extra == 'rollbar'
Provides-Extra: rq
Requires-Dist: rq>=1.15; extra == 'rq'
Provides-Extra: scrapy
Requires-Dist: scrapy>=2.8; extra == 'scrapy'
Provides-Extra: sendgrid
Requires-Dist: sendgrid>=6.0; extra == 'sendgrid'
Provides-Extra: sentry
Requires-Dist: sentry-sdk>=2.0; extra == 'sentry'
Provides-Extra: simple-salesforce
Requires-Dist: simple-salesforce>=1.12; extra == 'simple-salesforce'
Provides-Extra: slack-sdk
Requires-Dist: slack-sdk>=3.27; extra == 'slack-sdk'
Provides-Extra: snowflake
Requires-Dist: snowflake-connector-python>=3.0; extra == 'snowflake'
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
Provides-Extra: square
Requires-Dist: squareup>=39.0; extra == 'square'
Provides-Extra: stripe
Requires-Dist: stripe>=7.0; extra == 'stripe'
Provides-Extra: structlog
Requires-Dist: structlog>=23.1; extra == 'structlog'
Provides-Extra: supabase
Requires-Dist: supabase>=2.0; extra == 'supabase'
Provides-Extra: temporalio
Requires-Dist: temporalio>=1.7; extra == 'temporalio'
Provides-Extra: tenacity
Requires-Dist: tenacity>=8.0; extra == 'tenacity'
Provides-Extra: twilio
Requires-Dist: twilio>=8.0; extra == 'twilio'
Provides-Extra: wandb
Requires-Dist: wandb>=0.16; extra == 'wandb'
Provides-Extra: weaviate-client
Requires-Dist: weaviate-client>=4.9; extra == 'weaviate-client'
Provides-Extra: websockets
Requires-Dist: websockets>=12.0; extra == 'websockets'
Provides-Extra: zeep
Requires-Dist: zeep>=4.1; extra == 'zeep'
Provides-Extra: zenpy
Requires-Dist: zenpy>=2.0; extra == 'zenpy'
Description-Content-Type: text/markdown

# whytrail

[![PyPI](https://img.shields.io/pypi/v/whytrail.svg)](https://pypi.org/project/whytrail/)
[![CI](https://github.com/bhouvana/Whytrail/actions/workflows/ci.yml/badge.svg)](https://github.com/bhouvana/Whytrail/actions/workflows/ci.yml)
[![Python versions](https://img.shields.io/pypi/pyversions/whytrail.svg)](https://pypi.org/project/whytrail/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Python tells you *where* something happened. `whytrail` tells you *why
a value has the value it has.*

The same way a traceback already answers "where did this crash," `why()`
answers "why does this specific value look like this" -- for an
exception (zero setup) or anything else you deliberately track. See
[`docs/adr/0010-positioning-why-not-where.md`](docs/adr/0010-positioning-why-not-where.md)
for the full reasoning, and why exceptions below are the fastest thing
to show, not the whole story (see "Not just exceptions" further down).

## See it in 10 seconds, zero code

```
$ pip install whytrail
$ whytrail demo
```

```
A real exception, explained with zero setup -- this is why(exc):

why(KeyError: 'SUMMER'):
  [explicit] ValueError: discount code table missing region 'EU'  [<whytrail demo>:4, in load_codes]
      locals: region='EU', table={}
  [explicit] which explicitly caused KeyError: 'SUMMER'  [<whytrail demo>:11, in apply_discount]
      locals: price=12.5, code='SUMMER'

That's a Tier 1 answer: zero config, reconstructed entirely from data
CPython already retains for every exception (__traceback__, __cause__,
__context__). Add this near the top of your own program and every
uncaught exception shows this automatically, not just this demo:

    import whytrail
    whytrail.install()
```

(Real output -- `whytrail demo` actually raises the exception shown and
runs `why()` on it; with `pip install whytrail[rich]` it renders as a
panel/tree instead of plain text, the same rendering `.rich()` uses
everywhere else.) No script to write, no exception to cause on
purpose -- this is the fastest way to see what every uncaught exception
in your own program looks like once you add the two lines below.

## Install once. See it on every crash.

```python
import whytrail
whytrail.install()
```

Two lines, anywhere near the top of your program. From then on, every
uncaught exception -- main thread, a background thread, the interactive
REPL -- prints the causal chain first, then the traceback you already
know, unchanged:

```
$ python crash.py
why(KeyError: 'SUMMER'):
  [explicit] ValueError: discount code table missing region 'EU'  [crash.py:7, in load_codes]
  [explicit] which explicitly caused KeyError: 'SUMMER'  [crash.py:14, in apply_discount]

Traceback (most recent call last):
  File "crash.py", line 12, in apply_discount
    load_codes("EU")
  File "crash.py", line 7, in load_codes
    raise ValueError(f"discount code table missing region {region!r}")
ValueError: discount code table missing region 'EU'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "crash.py", line 16, in <module>
    apply_discount(12.5, "SUMMER")
  File "crash.py", line 14, in apply_discount
    raise KeyError(code) from exc
KeyError: 'SUMMER'
```

Nothing is removed -- the full traceback still prints, exactly as
before. What's new is the two lines above it: root cause first
(`ValueError` at the actual `load_codes` call), then what it caused
(`KeyError`, three frames later), reconstructed entirely from
`__traceback__`/`__cause__`/`__context__` -- data CPython already
retains for every exception, whether or not `whytrail.install()` was
ever called. `install()` doesn't add new capture, it just makes that
zero-config answer automatic instead of something you have to remember
to ask for.

Same mechanism as `rich.traceback.install()`, checked against two real
gaps most naive versions of this miss: it also hooks
`threading.excepthook` (an uncaught exception in a worker thread never
reaches `sys.excepthook` at all -- confirmed directly, not assumed),
and locals are redacted by default (`whytrail.install(log_locals=True)`
to opt in) since this hook's output often ends up somewhere off-box
(journald, a container's stdout capture, a CI log) that whytrail
doesn't control.

## Two tiers, one function

**Tier 1 -- zero configuration.** `why(some_exception)` reassembles a causal
chain from data CPython already retains: `__traceback__`, `__cause__`,
`__context__`, and the locals of the frame where it actually originated.
No setup, no tracing engine, no overhead when unused.

**Tier 2 -- opt-in, scoped.** `why(some_tracked_value)` walks a small
provenance graph built only for values you deliberately watched:

```python
import whytrail

with whytrail.trace():
    raw = whytrail.track(fetch_row(), label="raw CSV row")
    price = whytrail.track(float(raw["price"]), derived_from=raw)

print(whytrail.why(price))
```

Something that was never tracked gets an honest answer, not a guess:

```
why(3.14): unknown -- no provenance captured.
  This value was never tracked. Wrap it with whytrail.track(),
  @whytrail.tracked, or raise it as an exception to get an answer.
```

This is the whole design in one line: **provenance-first debugging --
explicit capture, honest confidence, and it never fabricates an answer
it isn't sure of.** Most observability tools infer (a profiler samples,
a tracer instruments everything reachable); whytrail asks you to mark
what matters and, in return, answers questions inference can't answer
reliably. See
[`docs/adr/0001-whytrail-architecture.md`](docs/adr/0001-whytrail-architecture.md)
for the full reasoning behind that design choice, and why a fully automatic
`why(anything)` isn't possible in the first place.

`whytrail.why(price).graph()` renders that same chain as an actual
diagram (real output, from the example above, not a mockup):

```mermaid
graph TD
    N2["value: 12.5"]
    N1["value: raw CSV row"]
    N1 -->|derived_from| N2
```

## Not just exceptions

Exceptions are the fastest thing to demo (zero setup, `whytrail.install()`
and you're done) -- they aren't the definition of what whytrail
explains. Underneath both tiers is one general causal-explanation
engine: a typed provenance graph (`Node`/`Edge`) plus a type-keyed
resolution order
(`__why__` protocol, then the plugin registry, then the graph). Nothing
in that core assumes its subject is an exception or a `track()`ed
value specifically -- those are its first two consumers, not its
limit. `whytrail.config` is a second one, shipped, not hypothetical:
`env()` resolves a setting from the process environment, a parsed
`.env` mapping, or a default, and records *which one it actually used*
into the same graph `track()` writes to:

```python
import whytrail
import whytrail.config

with whytrail.trace():
    timeout = whytrail.config.env("TIMEOUT", 30, cast=int)

print(whytrail.why(timeout))
```

```
why(30):
  [explicit] external: default value for 'TIMEOUT' (checked the environment, not found)
  [explicit] value: TIMEOUT=30
```

A missing key with no default raises `whytrail.config.ConfigError` --
a normal exception, so Tier 1 already explains *that* for free, no
separate explainer needed. See
[`docs/adr/0007-explanation-engine-reframe.md`](docs/adr/0007-explanation-engine-reframe.md)
for the reasoning, and
[`docs/plugin-guide.md`](docs/plugin-guide.md) for writing a plugin
that explains a type of your own the same way.

## Plain-English output

`whytrail.why(exc).text` is terse and technical, by design. For someone
who doesn't read tracebacks for a living, `.plain_text` renders the exact
same facts as prose, with general guidance for common exception types --
paraphrase, not new information, same honesty guarantee as `.text`:

```
Here's what happened, from the root cause to the final result:

1. ValueError -- got a value that didn't make sense for what it was doing (discount code table missing region 'EU') -- in load_codes(), line 12 of pricing.py
   At that point: region was 'EU', table was {}.
   How to avoid this: validate the value before using it, or check what produced it further up this chain.
2. KeyError -- tried to look up something that wasn't there ('SUMMER') -- in apply_discount(), line 31 of pricing.py
   At that point: price was 12.5, code was 'SUMMER'.
   How to avoid this: check the key exists before accessing it (`if key in d`), or use `d.get(key, default)` instead of `d[key]`.
```

## Install

```bash
pip install whytrail            # core, zero dependencies
pip install whytrail[rich]      # + Explanation.rich() tree rendering
pip install whytrail[cli]       # + the `whytrail` CLI
pip install whytrail[requests]  # + auto-explain requests.RequestException, etc.
pip install whytrail[all]       # every integration below, one install
```

One package, one version -- all integrations below are extras of
`whytrail` itself, not separate PyPI packages (ADR 0006). `pip install
whytrail[X]` pulls in exactly the library `X` explains, nothing else;
`why()` picks it up automatically the moment it's installed, no further
setup. `whytrail plugins` (needs the `cli` extra) lists all 102 and
whether each is actually active in your current environment:

```bash
$ whytrail plugins
Auto-registering (explainer-shaped), active in this environment: 27/81
  [x] requests
  [x] httpx
  [ ] stripe
  ...
Integration-shaped (need explicit install()/wiring in your code): 21/21 importable
  [x] fastapi
  [x] django
  ...
```

`whytrail run script.py` runs a script and, on an uncaught exception,
prints `why()` instead of a bare traceback:

```bash
whytrail run --json script.py    # machine-readable output
whytrail run --graph script.py   # also print the Mermaid provenance graph
```

**Flags go before the script path, not after.** `script_args` has to
swallow everything after `script` so a script's own flags reach it
unmolested (`whytrail run script.py --verbose` should pass `--verbose`
to *your* script) -- which means `whytrail run script.py --json` silently
does nothing: `--json` becomes part of your script's own arguments
instead of whytrail's. The CLI warns on stderr when this happens
rather than failing silently, but the fix is just word order.

## Why not just use a debugger / logging / OpenTelemetry?

Each answers a different question:

| Tool | Answers |
|---|---|
| `pdb` / IDE debugger | What is the state *right now*, interactively |
| `logging` | Whatever you decided in advance to record |
| `traceback` | *Where* it crashed |
| OpenTelemetry | Cross-service request flow |
| `whytrail` | What produced *this specific value*, on demand, after the fact |

## Performance

Real numbers, not estimates -- from
[`benchmarks/test_overhead.py`](benchmarks/test_overhead.py) (`pytest
benchmarks/ --benchmark-only`) and a direct `-X importtime` measurement,
both on CPython 3.13:

| What | Cost |
|---|---|
| A plain, untracked function call | ~77ns (baseline) |
| `@tracked` function call, no `trace()` scope open | ~265ns -- still sub-microsecond |
| `@tracked` function call, actively capturing inside `trace()` | ~8.8us |
| `why()` on an untracked object (the honest-unknown path) | ~4.0us |
| `import whytrail`'s own cumulative cost | ~50ms |

The "no overhead when unused" claim above is specifically about
`@tracked`/`track()` outside an open `trace()` scope -- the state
almost all code is in almost all the time -- not "tracing is free while
active." Measuring `import whytrail` honestly (via `-X importtime`) is
also how a real, fixable cost was found and cut during this
measurement: `registry.py` imported `importlib.metadata` eagerly at
module load even though it's only needed the first time a plugin's
entry point is actually resolved, costing ~30ms of every `import
whytrail` for code that may never hit that path. Made lazy; cumulative
import cost dropped from ~77ms to ~50ms as a direct result -- see
`CHANGELOG.md`.

## Public API

Five verbs (`why`, `track`, `tracked`, `trace`, `register`) plus two
persistence helpers (`snapshot`, `restore`). Domain-specific integrations
are extras of this same package, not new verbs -- see
`docs/plugin-guide.md`.

## Ecosystem

102 integrations today -- 63 reached the previous resting point (60 from
the original ecosystem push, plus `logging`/`structlog`/`loguru`), and a
0.3.1 push spanning vector databases, newer LLM SDKs, SaaS/commerce
APIs, orchestration/messaging, and identity/observability tooling added
39 more, each earning its place by clearing one of three bars
(structured error data, a security-sensitive boundary, or a
non-standard capture mechanism) rather than existing just to exist --
see `docs/adr/0003-ecosystem-scale-triage.md` for the full reasoning,
the next candidates, and the much longer list of libraries deliberately
*not* wrapped, because generic `track()`/`@tracked` already covers them
with zero extra code, or because they were checked directly and found
to carry no structured data beyond what tier 1 already shows for free.
Full table with what each one adds: `docs/plugin-guide.md`.

| | | |
|---|---|---|
| `whytrail[requests]` | `whytrail[httpx]` | `whytrail[aiohttp]` |
| `whytrail[huggingface-hub]` | `whytrail[openai]` | `whytrail[anthropic]` |
| `whytrail[boto3]` | `whytrail[google-cloud]` | `whytrail[sqlalchemy]` |
| `whytrail[asyncpg]` | `whytrail[pymongo]` | `whytrail[grpcio]` |
| `whytrail[pydantic]` | `whytrail[marshmallow]` | `whytrail[jsonschema]` |
| `whytrail[pyyaml]` | `whytrail[pandas]` | `whytrail[polars]` |
| `whytrail[stripe]` | `whytrail[alembic]` | `whytrail[paramiko]` |
| `whytrail[elasticsearch]` | `whytrail[pika]` | `whytrail[kubernetes]` |
| `whytrail[azure-core]` | `whytrail[sendgrid]` | `whytrail[websockets]` |
| `whytrail[opensearch]` | `whytrail[pyodbc]` | `whytrail[google-genai]` |
| `whytrail[oracledb]` | `whytrail[confluent-kafka]` | `whytrail[pymysql]` |
| `whytrail[pymssql]` | `whytrail[clickhouse]` | `whytrail[snowflake]` |
| `whytrail[graphql-core]` | `whytrail[tenacity]` | `whytrail[psycopg]` |
| `whytrail[cassandra]` | `whytrail[influxdb]` | `whytrail[pyzmq]` |
| `whytrail[zeep]` | `whytrail[sentry]` | `whytrail[ddtrace]` |
| `whytrail[celery]` | `whytrail[rq]` | `whytrail[dramatiq]` |
| `whytrail[prefect]` | `whytrail[scrapy]` | `whytrail[pytest]` |
| `whytrail[fastapi]` | `whytrail[django]` | `whytrail[flask]` |
| `whytrail[langchain]` | `whytrail[newrelic]` | `whytrail[rollbar]` |
| `whytrail[honeybadger]` | `whytrail[elastic-apm]` | `whytrail[bugsnag]` |
| `whytrail[structlog]` | `whytrail[loguru]` | `logging` (stdlib, no extra) |
| `whytrail[pinecone]` | `whytrail[weaviate-client]` | `whytrail[qdrant-client]` |
| `whytrail[neo4j]` | `whytrail[cohere]` | `whytrail[mistralai]` |
| `whytrail[twilio]` | `whytrail[slack-sdk]` | `whytrail[plaid]` |
| `whytrail[docker]` | `whytrail[hvac]` | `whytrail[square]` |
| `whytrail[temporalio]` | `whytrail[dagster]` | `whytrail[discord-py]` |
| `whytrail[nats-py]` | `whytrail[aiohttp-server]` | `whytrail[firebase-admin]` |
| `whytrail[minio]` | `whytrail[arango]` | `whytrail[supabase]` |
| `whytrail[auth0]` | `whytrail[pagerduty]` | `whytrail[algoliasearch]` |
| `whytrail[mlflow]` | `whytrail[meilisearch]` | `whytrail[github]` |
| `whytrail[okta]` | `whytrail[chromadb]` | `whytrail[wandb]` |
| `whytrail[datadog-api-client]` | `whytrail[postmarker]` | `whytrail[simple-salesforce]` |
| `whytrail[zenpy]` | `whytrail[notion-client]` | `whytrail[dropbox]` |
| `whytrail[asana]` | `whytrail[groq]` | `whytrail[replicate]` |

All of the above in one install: `pip install whytrail[all]`. Want to publish your
own, outside this repo, for a library not on this list?
`python scripts/new_plugin.py <library> --kind explainer|integration`
scaffolds that (ADR 0006 -- the entry-point extensibility mechanism the
bundled 30 used to use is still there for exactly this). `.github/actions
/whytrail-run` packages the CLI as a GitHub Action for CI.

**Real, runnable examples** for the frameworks above:
[`examples/ex_fastapi.py`](examples/ex_fastapi.py),
[`examples/ex_flask.py`](examples/ex_flask.py),
[`examples/ex_django.py`](examples/ex_django.py) (each shows the
safe-by-default production response next to the `debug=True` one, and
proves the secret local never leaks either way), and
[`examples/ex_pytest_fixtures.py`](examples/ex_pytest_fixtures.py)
(`pytest examples/ex_pytest_fixtures.py -v` -- a fixture-chain failure
explained automatically, zero whytrail-specific code in the test
itself). Every one of these is executed for real, not just written to
look plausible -- see their own docstrings for exact run commands.

**On test coverage:** every integration above is verified against a real
object from the real library, not a mock, and every one's stated minimum
dependency version is confirmed to actually install and work on the
newest supported Python -- both caught real bugs, twenty of them
version-compatibility gaps invisible from the version number alone,
eight of those found only once this project's CI actually ran on real
Linux for the first time rather than the Windows sandbox it was built in
(see `CHANGELOG.md`). It is not the same claim as "battle-tested in
every condition": see `docs/testing-maturity.md` for exactly what is and
isn't covered (the full Python 3.10-3.13 matrix, concurrency beyond the
three web frameworks, and full exception-surface breadth are the current
gaps).

## Status

Pre-1.0. The public API may still change between minor versions -- see
`docs/api-stability.md` for what's actually stable in practice versus
still moving. See `CHANGELOG.md` for what's shipped at each version and
`docs/adr/` for the architecture this was built from:

- [`0001`](docs/adr/0001-whytrail-architecture.md) -- feasibility and the
  original two-tier architecture.
- [`0002`](docs/adr/0002-category-strategy.md) -- category positioning
  and the pre-1.0 API fixes it drove.
- [`0003`](docs/adr/0003-ecosystem-scale-triage.md) -- how the plugin
  ecosystem scales (and how it doesn't).
- [`0004`](docs/adr/0004-rename-to-whytrail.md) -- why this project is
  called `whytrail` and not `butwhy`.
- [`0005`](docs/adr/0005-vscode-extension-scope.md) -- VS Code extension
  scope assessment (not started, and why).
- [`0006`](docs/adr/0006-unify-plugins-into-extras.md) -- why the 30
  integrations became extras of one package instead of 30 separate
  PyPI distributions.

Full documentation site (same content, easier to browse):
https://bhouvana.github.io/Whytrail/
