Skip to content

Add mypy strict + tighten get_job() to JobInfo | None + py.typed marker#167

Merged
agners merged 3 commits into
home-assistant-libs:mainfrom
heikkih:chore/add-mypy
May 12, 2026
Merged

Add mypy strict + tighten get_job() to JobInfo | None + py.typed marker#167
agners merged 3 commits into
home-assistant-libs:mainfrom
heikkih:chore/add-mypy

Conversation

@heikkih
Copy link
Copy Markdown
Contributor

@heikkih heikkih commented May 9, 2026

Summary

Adds mypy --strict as a CI step, fixes the type-soundness issues it surfaces, and addresses two correctness gaps caught in review.

The library declared correct TypedDict return types on its public methods, but the implementations all returned Any (the result of response.json()). Mypy is lenient with Any → TypedDict so the leak was invisible, but Any → list[Storage] (added in 2.2.0) is strict — that's how the issue surfaced when wiring up the new get_storage() method downstream in Home Assistant core. Every other GET method had the same latent flaw.

Why now

We discovered this while writing the storage-sensors integration in HA. Without fixing it at the source, every downstream consumer would have to cast() results back into the declared types — type debt distributed across consumers. Cleaner to fix once at the boundary.

get_job() signature change (review feedback)

The original code did cast(JobInfo, {}) on the 204 branch, which lies to the type checker — an empty dict does not satisfy JobInfo's required keys. Changed to JobInfo | None, returning None on 204. This matches the existing get_transfer() -> Transfer | None pattern.

This is a minor breaking change at the type level. Runtime impact for sane consumers is nil — anyone reading job["progress"] on the empty-dict return was already crashing today; the new contract just makes the no-job state explicit. HA-side will need a one-line data is not None guard in its base entity's available check; that change rides along with the eventual 2.2.1 bump there.

py.typed marker (review feedback)

Adding pyprusalink/py.typed (PEP 561) so downstream type checkers actually see the TypedDicts. Without it, the strict-typing work in this PR is invisible to consumers — installed distributions would be treated as untyped. Includes:

  • empty pyprusalink/py.typed (full typing marker)
  • [tool.setuptools.package-data] pyprusalink = ["py.typed"] to ensure inclusion in the wheel
  • zip-safe = false per setuptools/mypy guidance for typed packages

Why cast() (and not pydantic / msgspec / TypeGuards)

cast() at each return site is the pragmatic choice for a thin API wrapper:

Approach Decision
cast() ✅ Chosen. Dominant HA-ecosystem pattern, zero runtime cost, minimal change.
pydantic / msgspec ❌ Overkill for a thin wrapper. Pulls in a ~50KB+ C extension and requires refactoring the public types.
Custom TypeGuards ❌ Maintenance burden, easy to drift from the TypedDict definitions.
Suppress with # type: ignore ❌ Defers the debt without solving anything.

Trade-off accepted: cast() does not validate at runtime. If the Prusa API ever changes shape, errors surface as KeyError/AttributeError in consumer code rather than at the library boundary. For a stable upstream API this is acceptable; runtime validation can be layered on later without changing the public types.

Why strict from the start

--strict rather than gradual tightening: with only 10 errors on a small codebase, fixing them in one pass is cheaper than introducing a slow ratchet. Strict mode then prevents future contributions from quietly weakening typing — any new method that leaks Any will fail CI.

Changes

  • pyproject.toml: mypy==2.0.0 in lint extras; [tool.mypy] strict = true; package-data for py.typed; zip-safe = false
  • .github/workflows/test.yml: new mypy step
  • pyprusalink/py.typed: PEP 561 marker (new, empty)
  • pyprusalink/__init__.py: cast(...) at each response.json() return; get_job() -> JobInfo | None returning None on 204
  • pyprusalink/types.py: dict | Nonedict[str, Any] | None on JobFilePrint.meta
  • pyprusalink/client.py: same dict[str, Any] fix on request(..., json_data=...)
  • tests/: updated get_job no-job assertion from == {} to is None

Suggested release

Suggesting 2.2.1 as a patch release once this lands. Runtime is fully backward compatible for sane consumers; the typing contract becomes more honest. HA core will bump to 2.2.1 in a follow-up PR that also adds the small data is not None guard.

Test plan

  • mypy — Success: no issues found in 4 source files
  • pytest tests/ — 22 passed
  • flake8 / black / isort all clean
  • Wheel build verified to include pyprusalink/py.typed

🤖 Generated with Claude Code

Adds mypy in strict mode as a CI step. The library previously had
correct TypedDict declarations on its public methods but the
implementations all returned `Any` (the result of `response.json()`),
so mypy was unable to verify consumer code at the boundary. With
`Any → TypedDict` mypy is lenient and silently accepts the conversion;
with `Any → list[Storage]` (added in 2.2.0) it does not — that is the
first time the leak surfaced for a downstream consumer, even though
every GET method had the same flaw.

Architectural choice: `cast()` at each return site rather than
runtime validation (pydantic / msgspec / hand-rolled TypeGuards).
Reasoning:

- pyprusalink is a thin API wrapper, not a data-modelling library.
  Pulling in a validation library (~50KB+ C extension) is overkill
  for a wrapper that calls a stable, well-defined Prusa API.
- `cast()` is the dominant pattern in the Home Assistant ecosystem
  for this exact situation (TypedDict from JSON) and matches what
  HA core does internally.
- Trade-off accepted: `cast()` does not validate at runtime; if the
  Prusa API ever changes shape, errors surface as `KeyError` /
  `AttributeError` in consumer code rather than at the library
  boundary. For a stable upstream API this is acceptable; if it
  becomes a problem, runtime validation can be added later without
  changing the public types.

Strict mode is enabled so future contributions cannot quietly weaken
this — any new method that leaks `Any` will fail CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces strict static type checking to the project’s CI via mypy --strict and addresses the resulting type-soundness gaps by tightening API response typing (primarily around response.json() returning Any).

Changes:

  • Add mypy --strict configuration and run it in GitHub Actions CI.
  • Use typing.cast(...) at response.json() call sites to prevent Any from leaking into declared TypedDict return types.
  • Refine a few overly-broad dict annotations to dict[str, Any] for better strict-mode compatibility.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pyproject.toml Adds mypy to lint extras and enables strict mypy configuration.
.github/workflows/test.yml Runs mypy as part of CI alongside existing linters/tests.
pyprusalink/__init__.py Adds cast(...) to typed API wrapper return paths to avoid Any leakage.
pyprusalink/types.py Tightens JobFilePrint.meta typing to `dict[str, Any]
pyprusalink/client.py Tightens request JSON payload typing to `dict[str, Any]

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pyprusalink/__init__.py Outdated
Comment thread pyprusalink/__init__.py Outdated
Comment thread pyproject.toml
@heikkih heikkih marked this pull request as draft May 9, 2026 15:57
- Change get_job() return type to JobInfo | None and return None on 204
  instead of casting an empty dict to JobInfo. Callers can now check for
  None explicitly instead of guessing whether a TypedDict has its
  required keys.
- Add PEP 561 py.typed marker so downstream type checkers actually see
  the TypedDicts. Set zip-safe = false (per mypy/setuptools guidance for
  typed packages) and include py.typed via setuptools package-data.
- Drop the misspelled 204 comment; the new docstring makes the
  no-job case explicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heikkih heikkih changed the title Add mypy with strict type checking to CI Add mypy strict + tighten get_job() to JobInfo | None + py.typed marker May 9, 2026
@heikkih heikkih marked this pull request as ready for review May 9, 2026 18:42
Copy link
Copy Markdown
Contributor

@agners agners left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So with returning None now we essentially change the APU. According to semantic versioning this would mean the next release needs to be a major version bump. I am fine with it, just saying... If we have other breaking API changes in mind, let's introduce them now too.

Comment thread pyproject.toml Outdated
Per @agners on home-assistant-libs#167: `zip-safe` is a setuptools-egg-era flag and
modern wheel-based installs ignore it. The original change was based
on mypy/setuptools docs that still mention `zip-safe = false` for
typed packages, but in practice py.typed is reliably accessible from
extracted wheels regardless of this flag. Cleaner to drop it entirely.

Wheel build verified to still include `pyprusalink/py.typed`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heikkih
Copy link
Copy Markdown
Contributor Author

heikkih commented May 11, 2026

So with returning None now we essentially change the APU. According to semantic versioning this would mean the next release needs to be a major version bump. I am fine with it, just saying... If we have other breaking API changes in mind, let's introduce them now too.

Thanks. Agree we should do a major-bump. I did a thorough check if there is anything else that could be in a bump.

Already in this PR (the actual driver for 3.0.0):

  • mypy --strict in CI + cast() at JSON boundaries
  • get_job() -> JobInfo | None returning None on 204
  • py.typed marker so downstream type checkers see the TypedDicts

Worth considering:

PrinterInfo: migrate T | None → NotRequired[T] to match VersionInfo. The API actually omits absent fields rather than returning None, so the current typing is dishonest. HA-side already accesses PrinterInfo defensively via .get() in most places — the migration makes the contract match reality without forcing real changes downstream. Twelve fields, but straightforward.

Potential, want your take:

jobId → job_id rename on the four job-control methods, for PEP 8 and consistency with cancel_transfer(transfer_id). HA-core calls them positionally so HA doesn't benefit, but the inconsistency is a wart. Cheap to fold in if you'd like it cleaned up; otherwise happy to leave it.

Otherwise no other breaking changes on my roadmap right now.

#155 should warrant a new major version in the future, but not ready for that.

Lean: ship 3.0.0 with what's in this PR + the PrinterInfo migration, and add the jobId rename if you want it.

@agners
Copy link
Copy Markdown
Contributor

agners commented May 11, 2026

Lean: ship 3.0.0 with what's in this PR + the PrinterInfo migration, and add the jobId rename if you want it.

Yeah let's do PrinterInfo and jobId rename as well.

@heikkih
Copy link
Copy Markdown
Contributor Author

heikkih commented May 12, 2026

Lean: ship 3.0.0 with what's in this PR + the PrinterInfo migration, and add the jobId rename if you want it.

Yeah let's do PrinterInfo and jobId rename as well.

Great. I've added #169 and #170 now 👍

@agners agners merged commit 85bd215 into home-assistant-libs:main May 12, 2026
2 checks passed
agners pushed a commit that referenced this pull request May 12, 2026
The /api/v1/info endpoint actually omits absent fields rather than
returning None — the original `T | None` typing was a lie that misled
consumers into expecting `info["x"]` to always work. NotRequired makes
the contract honest: fields may be absent, and consumers must use
.get() or membership checks.

Older Buddy firmware versions and edge configurations (e.g. printers
not in farm mode) omit several fields. Documented in the docstring.

Verified all 12 field types against Prusa-Link-Web's authoritative
OpenAPI spec (no `required` list, so all properties are optional).
Style matches the existing `NotRequired[T]` on `VersionInfo`.

Breaking change for strict-typed consumers that index missing fields;
they need to switch to `.get()`. Targeted at the 3.0.0 release (see
#167).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
agners pushed a commit that referenced this pull request May 12, 2026
Brings the four job-control methods (cancel_job, pause_job,
resume_job, continue_job) in line with PEP 8 and with
cancel_transfer(transfer_id) which already uses snake_case.

Breaking change for callers using keyword arguments
(cancel_job(jobId=42)). Home Assistant calls these methods positionally
via `lambda api: api.cancel_job` in the button entity descriptions, so
no HA-side changes are needed. Targeted at the 3.0.0 release (see
#167).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
agners pushed a commit that referenced this pull request May 12, 2026
* Rewrite README for the 3.0 release

The old three-line README didn't say much beyond the package name.
The new one covers what someone landing on the PyPI page actually
needs:

- positioning: thin async wrapper, primary consumer is HA
- requirements and async-only / httpx caveat
- quickstart with credential note
- public API table by endpoint
- exception hierarchy and example
- type contract: NotRequired vs T | None, why we use cast() instead
  of pydantic/msgspec
- semver policy (TypedDict shape changes = breaking)
- development setup and the opt-in integration test invocation

Some of what's described — mypy strict, the cast() pattern, get_job()
returning None, get_transfer() returning None — is landing as part of
3.0.0 in #167/#169/#170, so this README is sized to match that state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reframe the README intro per agners' review

- Drop the "intentionally a thin wrapper" framing. As agners pointed
  out, that codifies a present-state property rather than a design
  principle — the library shape should be whatever serves the HA
  integration best, and may evolve.
- Replace with "API shape decisions are weighted toward serving the
  HA integration", and qualify the no-validation/no-retry note as a
  current state ("Today... but the shape may evolve").
- Fix "PrusaLink v2 API" — the API isn't versioned that way; replace
  with "PrusaLink HTTP API" and explicitly note the legacy paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
agners pushed a commit that referenced this pull request May 12, 2026
* Add Copilot code review instructions

Captures the conventions Copilot's PR reviewer should follow when
reviewing this repo: review-style rules at the top (avoid commenting
on lint/formatting since CI already enforces it), public API
conventions (TypedDicts + cast at JSON boundaries is intentional,
NotRequired over T | None, T | None reserved for "no data" return
paths), test layout (respx + optional integration marker), and
semver expectations for TypedDict shape changes.

Standalone — no generator script like home-assistant/core has; the
file is small enough to maintain by hand.

Some of the conventions described (mypy strict, cast() pattern,
PrinterInfo NotRequired migration, get_job() -> JobInfo | None) are
landing as part of the upcoming 3.0.0 release in #167 / #169 / #170.
The file describes the end state; once 3.0.0 ships, everything in
here will be true on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review on Copilot instructions

- Drop "PrusaLink v2 API" framing — agners pointed out that the
  endpoints aren't versioned that way and the library also covers a
  few legacy paths (/api/version, /api/printer). Replace with
  "PrusaLink HTTP API" and explicitly mention both endpoint families.
- Drop the blanket "suggest fixes at the library level" rule. As
  agners noted, that's case-by-case rather than a general principle.
- Drop "thin wrapper" framing in favour of "the shape is weighted
  toward what serves the HA integration best".
- Add concrete examples of helpful vs unhelpful Copilot feedback so
  the rule list isn't just abstract dos/don'ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heikkih heikkih deleted the chore/add-mypy branch May 13, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants