Skip to content

feat: add fail and require_not_none - #306

Open
msto wants to merge 1 commit into
mainfrom
feat/require-not-none-and-fail
Open

feat: add fail and require_not_none#306
msto wants to merge 1 commit into
mainfrom
feat/require-not-none-and-fail

Conversation

@msto

@msto msto commented Jul 29, 2026

Copy link
Copy Markdown

Note

Authorship: the content below was drafted by an AI coding agent and filed via gh under @msto's account. It reflects the agent's analysis, not a statement authored by @msto

Summary

require cannot narrow types, and never will be able to. By the time it is called, x is not None has already collapsed to a bool, and no type checker tracks that bool's provenance back to x. I verified this is unanimous across mypy 1.0.1, mypy 2.3, ty, pyrefly, and basedpyright — including that a Literal[False] -> NoReturn overload does not rescue it, since x is not None types as plain bool.

That makes require a quiet footgun for its main use case: anyone mechanically rewriting assert x is not None into require(x is not None) loses narrowing, and the natural next step is a cast() — trading an assertion stripped under -O for one that is never checked at all.

This adds the two functions that do narrow, and documents the gap on require itself.

fail(message) -> NoReturn — a type checker treats any branch calling it as terminating, so a guard narrows the checked expression. It composes with every narrowing form assert supports, because the if does the narrowing and fail only replaces the raise. I tested 15 forms across all four checkers; fail matched assert on every one, with zero disagreements:

is not None · isinstance · truthiness · type() is · enum identity · literal equality · tagged union · callable() · issubclass · literal membership · tuple length · TypedDict key · hasattr · user TypeIs · is True

require_not_none(value) -> ValueType — narrows via the return type, so it works in expression position where a guard does not fit:

query_names = [require_not_none(rec.query_name) for rec in records]

A guard plus fail expresses the same check, but only by repeating the subexpression — verbose, and a correctness hazard when the expression is not pure.

Why only this one unwrapper

is not None is the largest assert category in fgpyo itself (26 of 78, 33%), and isinstance/issubclass/callable/hasattr asserts number zero. It is also the only form where the check yields a value to bind rather than a predicate about a value — tagged unions, tuple length, and TypedDict keys cannot be unwrappers at all. So no require_isinstance here; it would be YAGNI with a messy typing story (tuple-of-classes overloads, and generic erasure means it could never verify list[int]).

No behavior change to require. It is reimplemented over fail so message handling lives in one place; its signature, semantics, and lazy-message evaluation are unchanged.

Test plan

Narrowing is pinned with typing.assert_type, which mypy enforces via poe check-typing (files = ["./"] covers tests). I mutation-checked those three assertions by flipping the expected types — mypy caught all three, so they have teeth rather than passing vacuously. Also adds error-path and lazy-message coverage for fail and require_not_none, a falsey-value test ("", 0, [], False are returned, only None raises), and the previously-untested contract that require does not evaluate a callable message when satisfied. poe check-all is green: lock, format, lint, mypy, 855 tests, 20 doctests.

Follow-up

all_not_none in fgpyo/util/types.py is a TypeGuard, which narrows only the positive branch; TypeIs narrows both and is strictly better. fgpyo also calls it internally as assert all_not_none(...), which has the same -O problem. Happy to send that as a separate PR on top.

🤖 Generated with Claude Code

`require` cannot narrow types. By the time it is called, `x is not None` has
already been evaluated to a `bool`, and no type checker tracks that bool's
provenance back to `x`. Anyone mechanically rewriting `assert x is not None`
as `require(x is not None)` silently loses narrowing, and the natural next
step is a `cast()` — trading an assertion that is stripped under `-O` for one
that is never checked at all.

Add two functions that do narrow:

- `fail(message)` returns `NoReturn`, so a type checker treats any branch that
  calls it as terminating. Used in a guard it narrows the checked expression,
  and it composes with every narrowing form `assert` supports — isinstance,
  truthiness, tagged unions, tuple length, TypedDict keys, TypeIs predicates,
  and the rest — because the narrowing is performed by the `if` statement and
  `fail` only replaces the `raise`.

- `require_not_none(value)` narrows via its return type, so it can be used in
  expression position (e.g. a comprehension) where a statement-level guard
  does not fit. A guard plus `fail` can express the same check, but only by
  repeating the subexpression, which is both verbose and a correctness hazard
  when the expression is not pure.

`is not None` is the only narrowing form worth a dedicated unwrapper: it is
the most common form by a wide margin, and it is the only one where the check
yields a value to bind rather than a predicate about a value. Forms such as
tagged unions and tuple length cannot be expressed as unwrappers at all.

`require` is reimplemented over `fail` so the message handling lives in one
place, and its docstring now notes the lack of narrowing.

The narrowing behavior is pinned with `typing.assert_type` in the tests, which
mypy enforces via `poe check-typing` since `files = ["./"]` covers tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@msto
msto requested review from clintval, nh13 and tfenne as code owners July 29, 2026 17:23
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d712efab-a5d1-4077-b389-2f2a9d9933af

📥 Commits

Reviewing files that changed from the base of the PR and between 3a07662 and df6be17.

📒 Files selected for processing (3)
  • fgpyo/__init__.py
  • fgpyo/_requirements.py
  • tests/fgpyo/test_requirements.py

📝 Walkthrough

Walkthrough

Added and exported fail and require_not_none. Requirement failures now use the centralized fail helper, while require_not_none returns narrowed non-None values. Tests cover messages, lazy evaluation, falsey values, exceptions, and type narrowing in assignments and expressions.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the two new helpers added by the change.
Description check ✅ Passed The description matches the changeset and explains the new narrowing helpers and require behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/require-not-none-and-fail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

4 participants