Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
d9f60a4
feat: read both API response shapes; never send an empty webhook_url …
lenzhq Oct 8, 2026
8629e50
fix: match the Node SDK: identical openapi.json text, an empty task_i…
lenzhq Oct 8, 2026
36e40fa
docs(changelog): drop the wrong note about idempotency keys from an e…
lenzhq Oct 8, 2026
7004e8c
fix: parse original-shape bodies exactly as 2.20 did; newer names are…
lenzhq Oct 8, 2026
d90b74b
fix(webhooks): a newer-shape review or citecheck event reads its revi…
lenzhq Oct 8, 2026
d58a49a
feat!: ask for the API's current response shape (2026-10-11); 2.x att…
lenzhq Oct 8, 2026
67cf2d3
fix(errors): a request-schema 422 reads no code, a blank /assess item…
lenzhq Oct 8, 2026
36877b4
fix(verify): a whitespace-only webhook_url is left out; changelog cov…
lenzhq Oct 8, 2026
b458f50
fix: match the Node SDK: 2.x error codes per endpoint, a failed poll'…
lenzhq Oct 8, 2026
1d3b3aa
fix: 2.x empty code for the fallback codes, a batch item's language e…
lenzhq Oct 8, 2026
674ee0e
Merge main (the read-both release, squashed) into the 2026-10-11 branch
lenzhq Oct 8, 2026
f7afb2d
Merge main (2.21.0 release, smoke gate) into the 2026-10-11 branch
lenzhq Oct 9, 2026
e59c1cd
docs: 3.0 keeps the 2.x names, marked deprecated with their replacements
lenzhq Oct 9, 2026
ee61771
fix(errors): credit_balance is restored for the citation check only
lenzhq Oct 9, 2026
9e6004d
feat: the version header goes on every request; an answer in another …
lenzhq Oct 9, 2026
f82fcf5
fix: a verification's failure block spells nothing checkable not_a_cl…
lenzhq Oct 9, 2026
a01efbb
fix: a completed quick check in a review reads the 2.x compound hint
lenzhq Oct 9, 2026
1020320
feat(models): mark every deprecated field in the JSON schema
lenzhq Oct 9, 2026
daf2fd3
test: sync the parity fixtures with the server's recorded cases
lenzhq Oct 9, 2026
0598836
docs: split the 3.0 migration into required and optional, document th…
lenzhq Oct 9, 2026
9e91fc4
fix: LenzApiVersionError suggests what a caller can actually do
lenzhq Oct 9, 2026
1bc6059
fix: a version error is never swallowed by a 404 shortcut or a poll
lenzhq Oct 9, 2026
2c14539
test: freeze the request bodies of every public call before the 3.0 s…
lenzhq Oct 9, 2026
6b0c23b
feat!: verify_batch and ask.send send an automatic Idempotency-Key
lenzhq Oct 9, 2026
2e681c8
feat: LenzNotFoundError, LenzConnectionError, LenzRequestTimeoutError…
lenzhq Oct 9, 2026
de166ef
fix!: waits stop at once on a 401, 403 or 404 instead of polling to a…
lenzhq Oct 9, 2026
7a2bfbd
feat: export Verdict, VerdictLabel, Confidence and Depth Literal aliases
lenzhq Oct 9, 2026
6942413
feat(webhooks): verification events carry .verification, the status e…
lenzhq Oct 9, 2026
25036cb
feat: verifications.iter() and library.iter() walk every page
lenzhq Oct 9, 2026
0798fe7
feat: verify, review_and_wait and citecheck_and_wait list every option
lenzhq Oct 9, 2026
acaa62f
docs: a first call at the top, runnable review and citecheck examples…
lenzhq Oct 9, 2026
37a303e
fix: match the Node SDK on the 404 fix text, retryable and the iterat…
lenzhq Oct 9, 2026
377eeff
fix: retryable is true on a 409 idempotency_conflict or verification_…
lenzhq Oct 9, 2026
9173686
feat: a 409 idempotency_conflict is sent again with the same key, and…
lenzhq Oct 9, 2026
1e7fba0
fix: waits work with timeout=None or an httpx.Timeout, and no poll ru…
lenzhq Oct 9, 2026
94dc2cc
fix: a batch wait fails the one item a version error or 404 answers, …
lenzhq Oct 9, 2026
3d0ca6e
fix: iter() stops at the total, on a page it did not ask for, or with…
lenzhq Oct 9, 2026
4104b7d
fix: every transport failure worth resending is a LenzConnectionError…
lenzhq Oct 9, 2026
3e1bb57
fix: VerificationCompleted.verification.result reads the webhook defa…
lenzhq Oct 9, 2026
faece49
feat: verify_batch and verify_batch_and_wait take any Sequence of claims
lenzhq Oct 9, 2026
8af33ad
fix: a verification.* event's .verification is None when its nested s…
lenzhq Oct 9, 2026
262b059
fix: an answer whose body is not JSON carries the idempotency key, as…
lenzhq Oct 9, 2026
9688e55
docs: resend only with the same key, the 409 resend, the wait deadlin…
lenzhq Oct 9, 2026
0deb6c6
fix: no batch item is polled past the deadline, and a poll keeps ever…
lenzhq Oct 9, 2026
86f22f1
fix: a keyed call whose answer the model refuses carries the idempote…
lenzhq Oct 9, 2026
1ef3647
docs: the zero-timeout read, the kept timeout phases and the key on a…
lenzhq Oct 9, 2026
b4fad7d
fix: a task cancelled elsewhere ends a wait with the failed error, an…
lenzhq Oct 9, 2026
368a995
docs: cancelled tasks and the *.cancelled webhook events
lenzhq Oct 9, 2026
8f08ef4
fix: a malformed verification.cancelled has no needs_input envelope; …
lenzhq Oct 9, 2026
ae34381
feat: cancel, cancel_review and cancel_citecheck stop a run
lenzhq Oct 9, 2026
e85d795
docs: stopping a run with cancel, cancel_review and cancel_citecheck
lenzhq Oct 9, 2026
7cab559
chore: refresh openapi.json (API version 2026-10-11 and the cancel en…
lenzhq Oct 9, 2026
3b395ff
test: the cancelled durable status is {status, task_id}; drop the sta…
lenzhq Oct 9, 2026
ae524d6
fix: an id is one path segment; a cancel keeps the server's code and …
lenzhq Oct 9, 2026
6c72bde
docs: scope the cancel charge wording, say cancelled stays true on a …
lenzhq Oct 9, 2026
42e676d
docs: using Lenz from async code (worker threads, FastAPI, webhooks)
lenzhq Oct 9, 2026
e637d1b
test: freeze what every public call puts on the wire
lenzhq Oct 9, 2026
e2e141b
feat: per-call timeout, max_retries and extra_headers, and with_options
lenzhq Oct 9, 2026
597f32d
docs: per-call options, with_options, and the validation rule
lenzhq Oct 9, 2026
e30d1a8
test: say what the below-the-floor case pins
lenzhq Oct 9, 2026
eca24ad
fix: keep 2.21 overrides working, snapshot options, accept every time…
lenzhq Oct 9, 2026
0133e4c
docs: accepted timeout forms, header syntax, shallow copies, private …
lenzhq Oct 9, 2026
48e3a9f
fix: refuse header values with whitespace at either end
lenzhq Oct 9, 2026
b4875a0
fix: final 3.0 review: empty webhook secret, timeout limit, infinite …
lenzhq Oct 9, 2026
e63650d
fix: a non-finite Retry-After is no stated wait, as in Node
lenzhq Oct 9, 2026
723c31b
docs: failure block values read the server's, booleans refused, trans…
lenzhq Oct 9, 2026
dea1408
fix: a cancelled task status reads the 2.x failure block; clamp the p…
lenzhq Oct 9, 2026
bf322e7
chore: refresh openapi.json (public reference text, 3.0 code samples)
lenzhq Oct 9, 2026
010f40c
style: satisfy ruff 0.17 (None last in unions; README code formatting)
lenzhq Oct 9, 2026
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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ jobs:
ruff format --check .
- name: Type-check
# mypy strict only on hand-written code; generated _generated/ (if added later) is excluded via pyproject.
run: mypy src/lenz_io
# The examples too: they are what people copy, so a renamed field or a
# wrong type there fails CI like one in the SDK.
run: |
mypy src/lenz_io
mypy examples
- name: Test
# `--no-cov` because the coverage gate lives in `addopts` and would
# otherwise run in all eight matrix cells. Coverage does not vary
Expand Down
488 changes: 488 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

597 changes: 480 additions & 117 deletions README.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions examples/core/citecheck_draft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Check a draft's citations on their own: does each source say what the
draft says it does?

Run:
export LENZ_API_KEY=lenz_...
python examples/core/citecheck_draft.py

Send a draft (its links and DOIs are read from the text; keep a link as a
markdown link) or the statement-source pairs yourself. 1 credit per checked
citation; a citation that could not be checked is not charged.
"""

from __future__ import annotations

import os

from lenz_io import CitationPair, Citecheck, CitecheckFailedError, CitecheckTimeoutError, Lenz

DRAFT = (
"Water boils at 100 degrees Celsius at sea level, according to "
"[the encyclopedia entry](https://en.wikipedia.org/wiki/Boiling_point)."
)

PAIRS: list[CitationPair] = [
{
"statement": "Diamond sensors can measure temperature inside a living cell.",
"doi": "10.1038/nature12373",
"cited_year": "2013",
},
]


def report(label: str, check: Citecheck) -> None:
print(f"{label}: {check.outcome}") # clean | issues_found | incomplete | unchecked
for row in check.citations:
finding = row.result.finding if row.result else "pending"
print(f" {finding}: {row.cited_url or row.doi}")
for issue in check.citation_issues: # most serious first
print(f" ISSUE {issue.finding}: {issue.statement}")
if issue.snippet:
print(f" The source says: {issue.snippet}")


def main() -> None:
client = Lenz(api_key=os.environ.get("LENZ_API_KEY"))
try:
report("draft", client.citecheck_and_wait(DRAFT, max_citations=10))
report("pairs", client.citecheck_and_wait(pairs=PAIRS))
except CitecheckTimeoutError as exc:
# The check keeps running: read it later by its id, never resubmit.
print("Still running; read it later with client.get_citecheck:", exc.citecheck_id)
except CitecheckFailedError as exc:
print("The check failed:", exc.error_code, exc.hint)


if __name__ == "__main__":
main()
62 changes: 50 additions & 12 deletions examples/core/fastapi_webhook.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# mypy: allow-untyped-decorators
"""Receive Lenz webhook events in a FastAPI app.

Lenz POSTs HMAC-signed payloads to your ``webhook_url`` when the
verification pipeline terminates. This handler verifies the signature,
Lenz POSTs HMAC-signed payloads to your ``webhook_url`` when a
verification, a review or a citation check ends. This handler verifies the signature,
parses the payload into a typed event, and dispatches per event type.

Run:
Expand All @@ -22,8 +23,11 @@
from fastapi import FastAPI, HTTPException, Request

from lenz_io import (
CitecheckEvent,
LenzWebhooks,
LenzWebhookSignatureError,
ReviewEvent,
VerificationCancelled,
VerificationCompleted,
VerificationFailed,
VerificationNeedsInput,
Expand All @@ -45,23 +49,57 @@ async def lenz_webhook(request: Request) -> dict[str, str]:
logger.warning("Rejected webhook: %s", exc.message)
raise HTTPException(status_code=400, detail=str(exc)) from exc

# A delivery can be retried: deduplicate on event.event_id (stable across
# attempts) before acting on it.
if isinstance(event, VerificationCompleted):
# Verdict block is FLAT on event.result — no nested object.
logger.info(
"Completed: %s -> %s (lenz_score %s, confidence %s)",
event.verification_id,
event.result.get("verdict"),
event.result.get("lenz_score"),
event.result.get("confidence"),
)
# The verification as client.get_status returns it; the verdict is
# under .result.
result = event.verification.result if event.verification else None
if result is not None:
logger.info(
"Completed: %s -> %s (lenz_score %s, confidence %s)",
result.verification_id,
result.verdict,
result.lenz_score,
result.confidence,
)
# TODO: persist verdict + sources to your DB; ping users; etc.
elif isinstance(event, VerificationNeedsInput):
logger.info("Needs input on %s: %s", event.task_id, event.needs_input.get("reason"))
logger.info("Needs input on %s: %s", event.task_id, [c.claim for c in event.claims])
# TODO: surface the candidate claims to the user, then call
# client.select(task_id, claims=[...]) to resolve.
elif isinstance(event, VerificationFailed):
logger.warning("Pipeline failed: %s (%s)", event.task_id, event.error)
failure = event.failure
logger.warning("Verification failed: %s (%s)", event.task_id, failure.code if failure else "unknown")
elif isinstance(event, VerificationCancelled):
# Stopped elsewhere (the website's Stop button, another process).
# Work submitted by an older client reports this as verification.failed
# with failure_class "cancelled" instead.
logger.info("Verification cancelled: %s", event.task_id)
elif isinstance(event, ReviewEvent):
# review.completed / review.failed / review.cancelled: the final review,
# as get_review returns it (event.status says which).
if event.review is not None:
logger.info(
"Review %s %s: outcome %s, %d issue(s)",
event.review_id,
event.status,
event.review.outcome,
len(event.review.issues),
)
elif isinstance(event, CitecheckEvent):
# citecheck.completed / citecheck.failed / citecheck.cancelled: the final
# check (event.status says which).
if event.citecheck is not None:
logger.info(
"Citation check %s %s: outcome %s, %d issue(s)",
event.citecheck_id,
event.status,
event.citecheck.outcome,
len(event.citecheck.citation_issues),
)
else:
# A new event type: acknowledge it and move on.
logger.info("Unhandled webhook event: %s", event.event)

# Always return 2xx fast. Lenz expects an ack within 5s; otherwise the
Expand Down
28 changes: 16 additions & 12 deletions examples/core/quickstart.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Lenz quickstart — the canonical four-primitive integration.
"""Lenz quickstart — the canonical integration: the four-call ladder.

Run:
export LENZ_API_KEY=lenz_...
Expand Down Expand Up @@ -27,32 +27,36 @@ def main() -> None:

# 1. extract — pull verifiable claims out of any text (free)
out = client.extract(text="Sharks don't get cancer. The Eiffel Tower is 330m tall.")
claims = out.identified_claims or [out.claim]
claims = [c.claim for c in out.claims]
print(f"Extracted {len(claims)} claims:")
for c in claims:
print(f" - {c}")
print()

# 2. assess — one call over the extracted claims, one row per claim (sync)
quick = client.assess(claims=claims).claims
for c in quick:
print(f" {c.verdict:<12} conf={c.confidence:<7} {c.claim}")
if c.verdict == "Error":
# No verdict for this item — error_code says why, hint says what to send next.
print(f" {c.error_code}: {c.hint}")
if c.identified_claims:
for row in quick:
print(f" {row.verdict:<12} conf={row.confidence:<7} {row.claim}")
if row.status == "failed" and row.failure:
# No verdict for this item — failure.code says why, failure.hint says what to send next.
print(f" {row.failure.code}: {row.failure.hint}")
if row.more_claims:
# A compound item: only its main claim was assessed.
print(f" also found (not assessed): {c.identified_claims}")
print(f" also found (not assessed): {row.more_claims}")
print()

# 3. verify — escalate the low-confidence rows to the full multi-model panel
# verify_batch_and_wait takes up to 20 claims a call: the first 20 here
doubtful = [{"claim": c.claim} for c in quick if c.verdict != "Error" and c.confidence == "low"][:20]
low = [row for row in quick if row.status == "completed" and row.confidence == "low"]
doubtful = [{"claim": row.claim} for row in low[:20]]
# Fall back to the demo claim so the walkthrough always reaches steps 3
# and 4 even when every row came back confident.
doubtful = doubtful or [{"claim": "Sharks don't get cancer"}]
results = client.verify_batch_and_wait(claims=doubtful)
v = next(r.verification for r in results if r.verification is not None)
v = next((r.verification for r in results if r.verification is not None), None)
if v is None:
print("No verification completed:", [r.status for r in results])
return
print(f"Verdict: {v.verdict} (lenz_score {v.lenz_score}, confidence {v.confidence})")
print(f"Summary: {v.executive_summary}")
print()
Expand All @@ -65,7 +69,7 @@ def main() -> None:
reply = client.ask.send(v.verification_id, message="Which source is strongest?")
print()
print("Q: Which source is strongest?")
print(f"A: {reply.reply}")
print(f"A: {reply.content}")


if __name__ == "__main__":
Expand Down
66 changes: 66 additions & 0 deletions examples/core/review_draft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Review a draft: every claim quick-checked, the doubtful ones deep-checked.

Run:
export LENZ_API_KEY=lenz_...
python examples/core/review_draft.py

One call pulls the claims out of the draft, gives each a quick verdict, sends
the ones that look wrong or uncertain through the deep check (up to five by
default) and returns the issues with suggested rewrites. It takes two to four
minutes; ``on_update`` shows the quick verdicts as soon as they are in.

Credits: 1 per claim assessed, plus 10 per deep check (5 at ``depth="low"``),
plus 1 per checked citation; ``review.credits.charged`` says what it cost.
"""

from __future__ import annotations

import os

from lenz_io import Lenz, LenzError, ReviewFailedError, ReviewFull, ReviewTimeoutError

DRAFT = """
The EU AI Act entered into force on 1 August 2024, and its obligations for
general-purpose models applied from 2 August 2025. Fines for prohibited
practices reach 7% of global annual turnover, according to
[the regulation](https://eur-lex.europa.eu/eli/reg/2024/1689/oj).
"""


def show_progress(review: ReviewFull) -> None:
done = sum(1 for c in review.claims if c.assessment is not None and c.assessment.status != "pending")
print(f" {review.status}: {done}/{len(review.claims)} claims have a quick verdict")


def main() -> None:
client = Lenz(api_key=os.environ.get("LENZ_API_KEY"))
try:
review = client.review_and_wait(
DRAFT,
max_citations=5, # also check the draft's first 5 sources (1 credit each)
suggest_edits=True, # the smallest edits that make the draft say what each rewrite says
on_update=show_progress,
)
except ReviewTimeoutError as exc:
# The review keeps running: read it later by its id, never resubmit.
print("Still running; read it later with client.get_review:", exc.review_id)
return
except ReviewFailedError as exc:
print("The review failed:", exc.error_code, exc.hint)
return
except LenzError as exc:
print("Could not run the review:", exc.message, "(retryable)" if exc.retryable else "")
return

print(f"\nOutcome: {review.outcome}") # clean | issues_found | incomplete | unchecked
for issue in review.issues:
print(f"- {issue.verdict} ({issue.confidence}): {issue.claim}")
if issue.suggested_rewrite:
print(f" Suggested rewrite: {issue.suggested_rewrite}")
for citation in review.citation_issues: # most serious first
print(f"- Source issue {citation.finding}: {citation.cited_url or citation.doi}")
print(f"Credits charged: {review.credits.charged if review.credits else 'unknown'}")


if __name__ == "__main__":
main()
8 changes: 4 additions & 4 deletions examples/core/verify_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ def main() -> None:

for r in results:
if r.status == "completed" and r.verification is not None:
print(f"[completed] {r.claim_text} → {r.verification.verdict} ({r.verification.lenz_score})")
print(f"[completed] {r.claim} → {r.verification.verdict} ({r.verification.lenz_score})")
elif r.status == "failed":
detail = r.status_detail
reason = (detail.error or detail.failure_detail) if detail else "unknown"
print(f"[failed] {r.claim_text} → {reason}")
reason = detail.failure.detail if detail and detail.failure else "unknown"
print(f"[failed] {r.claim} → {reason}")
else:
# needs_input (resolve with client.select) or timeout (poll later)
print(f"[{r.status}] {r.claim_text}")
print(f"[{r.status}] {r.claim}")


if __name__ == "__main__":
Expand Down
25 changes: 13 additions & 12 deletions examples/core/verify_llm_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,39 +30,40 @@ def main() -> None:

# Step 1: extract — pull the verifiable claims out of the answer (free).
out = client.extract(text=LLM_OUTPUT)
claims = out.identified_claims or [out.claim]
claims = [c.claim for c in out.claims]
print(f"Extracted {len(claims)} claims.\n")

# Step 2: assess — one call per 20 extracted claims (extract finds up to
# 100; one assess call takes 20), one row per claim, same order. A row
# with verdict "Error" got no verdict: ``error_code`` says why
# (``upstream_unavailable`` is worth a retry) and ``hint`` says what to
# send next. A compound item is assessed on its main claim and lists the
# rest in ``identified_claims``.
# with ``status == "failed"`` got no verdict: ``failure.code`` says why
# (``upstream_unavailable`` is worth a retry) and ``failure.hint`` says
# what to send next. A compound item is assessed on its main claim and
# lists the rest in ``more_claims``.
quick = [row for i in range(0, len(claims), 20) for row in client.assess(claims=claims[i : i + 20]).claims]
print(f"Assessed {len(quick)} claims:\n")
for c in quick:
print(f" {c.verdict:<12} conf={c.confidence:<7} {c.claim}")
if c.verdict == "Error":
print(f" {c.error_code}: {c.hint}")
elif c.identified_claims:
print(f" also found (not assessed): {c.identified_claims}")
if c.status == "failed" and c.failure:
print(f" {c.failure.code}: {c.failure.hint}")
elif c.more_claims:
print(f" also found (not assessed): {c.more_claims}")
print()

# Step 3: verify — escalate the low-confidence rows to the full pipeline.
# ``assess`` and ``verify`` share a result cache server-side, so a
# claim that already has a deep verification surfaces immediately
# via ``verification_url`` and you can skip the escalation.
# verify_batch_and_wait takes up to 20 claims a call: the first 20 here
doubtful = [{"claim": c.claim} for c in quick if c.verdict != "Error" and c.confidence == "low"][:20]
low = [c for c in quick if c.status == "completed" and c.confidence == "low"]
doubtful = [{"claim": c.claim} for c in low[:20]]
print(f"Escalating {len(doubtful)} low-confidence claims to full verification:\n")
results = client.verify_batch_and_wait(claims=doubtful, timeout=180) if doubtful else []
for r in results:
v = r.verification
if v is None:
print(f"{r.status.upper():<14} {r.claim_text}")
print(f"{r.status.upper():<14} {r.claim}")
continue
print(f"{v.verdict.upper():<14} (lenz_score {v.lenz_score}) {r.claim_text}")
print(f"{v.verdict.upper():<14} (lenz_score {v.lenz_score}) {r.claim}")
if v.verdict.lower() in ("false", "misleading") and v.sources:
print(f" ↳ {v.sources[0].title}")
print(f" {v.sources[0].url}")
Expand Down
2,347 changes: 1,446 additions & 901 deletions openapi.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ dependencies = [
# unknown Field kwarg is swallowed into json_schema_extra with a warning of
# its own and the deprecation never fires, which is worse than the bump.
"pydantic>=2.7",
# `@deprecated(..., category=None)` marks the deprecated property aliases for
# editors and type checkers without a runtime warning. Pydantic needs it too.
"typing_extensions>=4.5",
]

[project.optional-dependencies]
Expand Down
3 changes: 2 additions & 1 deletion scripts/hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# Runs (~10s total on a warm cache):
# 1. ruff check — lint
# 2. ruff format — formatting
# 3. mypy — strict type-check on src/lenz_io
# 3. mypy — strict type-check on src/lenz_io and examples/
# 4. pytest — unit suite (skips live-API smoke; those run in CI / on release)
#
# Skip on a specific commit with `git commit --no-verify`. Don't habituate.
Expand Down Expand Up @@ -43,6 +43,7 @@ done_ok "ruff format"

step "mypy (strict)"
uv run mypy src/lenz_io || fail "mypy"
uv run mypy examples || fail "mypy (examples)"
done_ok "mypy"

step "pytest (unit; skips live-API smoke)"
Expand Down
Loading
Loading