Skip to content

feat(typing): pass mypy --strict#2599

Open
0xMattB wants to merge 10 commits intofalconry:masterfrom
0xMattB:issue_2504
Open

feat(typing): pass mypy --strict#2599
0xMattB wants to merge 10 commits intofalconry:masterfrom
0xMattB:issue_2504

Conversation

@0xMattB
Copy link
Copy Markdown
Contributor

@0xMattB 0xMattB commented Jan 21, 2026

Summary of Changes

Updated code to pass mypy --strict type checking.

Related Issues

Issue #2504

Pull Request Checklist

This is just a reminder about the most common mistakes. Please make sure that you tick all appropriate boxes. Reading our contribution guide at least once will save you a few review cycles!

If an item doesn't apply to your pull request, check it anyway to make it apparent that there's nothing to do.

  • Applied changes to both WSGI and ASGI code paths and interfaces (where applicable).
  • Added tests for changed code.
  • Performed automated tests and code quality checks by running tox.
  • Prefixed code comments with GitHub nick and an appropriate prefix.
  • Coding style is consistent with the rest of the framework.
  • Updated documentation for changed code.
    • Added docstrings for any new classes, functions, or modules.
    • Updated docstrings for any modifications to existing code.
    • Updated both WSGI and ASGI docs (where applicable).
    • Added references to new classes, functions, or modules to the relevant RST file under docs/.
    • Updated all relevant supporting documentation files under docs/.
    • A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    • Changed/added classes/methods/functions have appropriate versionadded, versionchanged, or deprecated directives.
  • Changes (and possible deprecations) have towncrier news fragments under docs/_newsfragments/, with the file name format {issue_number}.{fragment_type}.rst. (Run tox -e towncrier, and inspect docs/_build/html/changes/ in the browser to ensure it renders correctly.)
  • LLM output, if any, has been carefully reviewed and tested by a human developer. (See also: Use of LLMs ("AI").)

If you have any questions to any of the points above, just submit and ask! This checklist is here to help you, not to deter you from contributing!

PR template inspired by the attrs project.

@vytas7 vytas7 changed the title Issue 2504 Fix feat(typing): pass mypy --strict Jan 21, 2026
@0xMattB
Copy link
Copy Markdown
Contributor Author

0xMattB commented Jan 21, 2026

Handled errors reported from mypy --strict. Here are some notes on my approach:

  • The strict type checking resulted in 109 errors.
  • I tried to avoid using ignore as much as possible.
  • Many of the type parameter errors were easily fixed by adding the necessary types.
  • Some typed return values were deemed ambiguous:
  • For int and bytes return values, I cast them to be explicit - not sure if this results in a performance hit.
  • For str return values, I used '{}'.format(...) as this satisfied the check while keeping effects on performance to a minimum.

Here are some important observations to be aware of and to consider more carefully:

falcon/http_error.py:164: error: Returning Any from function declared to return "int"  [no-any-return]
falcon/http_status.py:70: error: Returning Any from function declared to return "int"  [no-any-return]
falcon/response.py:202: error: Returning Any from function declared to return "int"  [no-any-return]

These errors refer to http_status_to_code(), defined in misc.py.

This function is wrapped in another function via the _lru_cache_for_simple_logic decorator, which in turn uses the outer function functools.lru_cache, which is from an outside library. For some reason, this results in a return type of Any. I decided to explicitly cast http_status_to_code() as an int.

~

falcon/app.py:272: error: Missing type parameters for generic type "SinkCallable"  [type-arg]
falcon/app.py:272: error: Missing type parameters for generic type "AsgiSinkCallable"  [type-arg]
falcon/app.py:281: error: Missing type parameters for generic type "SinkCallable"  [type-arg]
falcon/app.py:281: error: Missing type parameters for generic type "AsgiSinkCallable"  [type-arg]
falcon/asgi/app.py:1261: error: Non-overlapping container check (element type: "AsgiResponderWsCallable", container item type: "AsgiResponderCallable")  [comparison-overlap]

While I tried to avoid using ignore, I thought it would be best to do that for these errors, as trying to satisfy the type checker would really hurt the readability of the code. However, I am open to fixing these properly if need be (with some assistance).

Note app.py/1261 mentions skipping the warning part for now, so we added ignore to silence the warnings.

~

falcon/testing/helpers.py:158: error: Incompatible types in assignment (expression has type "memoryview[int]", variable has type "str | bytes | None")  [assignment]

Added memoryview to body types, unsure if this is the best approach to address this.
However, this triggers a different error on line 156:

falcon/testing/helpers.py:156: error: Item "memoryview[int]" of "str | memoryview[int]" has no attribute "encode"  [union-attr]

I was unsure of how to address this without rewriting the function, so I decided to ignore this one for the time being.

~

falcon/testing/client.py:232: error: Argument 1 to "get_encoding_from_headers" has incompatible type "CaseInsensitiveDict"; expected "Mapping[str, str]"  [arg-type]

I was unsure of how to handle this one, so I used ignore for the time being.

~

falcon/testing/client.py:441: error: Missing type parameters for generic type "Task"  [type-arg]
falcon/testing/client.py:1103: error: Missing type parameters for generic type "Task"  [type-arg]
falcon/testing/client.py:2228: error: Missing type parameters for generic type "Task"  [type-arg]

I was unsure of how to handle these without mucking up the imports, so I set the type to Any.

falcon/testing/client.py:1087: error: Missing type parameters for generic type "App"  [type-arg]
falcon/testing/client.py:2089: error: Missing type parameters for generic type "App"  [type-arg]
falcon/testing/test_case.py:53: error: Missing type parameters for generic type "App"  [type-arg]
e2e-tests/server/app.py:15: error: Missing type parameters for generic type "App"  [type-arg]

I was unsure of how to handle this without mucking up the imports, so I used ignore for the time being.

~

falcon/testing/client.py:1149: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1220: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1227: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1234: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1241: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1248: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1255: error: Returning Any from function declared to return "Result"  [no-any-return]
falcon/testing/client.py:1289: error: Returning Any from function declared to return "Result | StreamedResult"  [no-any-return]

I was unsure if we should use a cast on these, so I used ignore for the time being.

~

falcon/testing/test_case.py:34: error: Class cannot subclass "TestCase" (has type "Any")  [misc]

I was unsure of how to fix this, so I used ignore for the time being.

~
Please let me know how everything looks, and if you'd like any of the above points addressed further.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (97a2bfe) to head (22f00c5).

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #2599   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           64        64           
  Lines         7962      7970    +8     
  Branches      1101      1101           
=========================================
+ Hits          7962      7970    +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread e2e-tests/server/app.py Outdated


def create_app() -> falcon.asgi.App:
def create_app() -> falcon.asgi.App: # type: ignore[type-arg]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We shouldn't need the ignore here, we can just parametrize as intended: -> falcon.asgi.App[Request, Response].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done (marking for my own reference).

Comment thread falcon/asgi/request.py Outdated
# that case.
try:
return self.scope['root_path']
return '{}'.format(self.scope['root_path'])
Copy link
Copy Markdown
Member

@vytas7 vytas7 Jan 21, 2026

Choose a reason for hiding this comment

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

These are affecting performance for no good reason in a hot code path.
If in doubt, we simply need to ignore, or use a more advanced scope typing where scope[root_path']` is typed as string.

Edit: we can probably just copy these or implement something similar: https://github.com/django/asgiref/blob/main/asgiref/typing.py#L66.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

How would you feel about using ignore at the moment, and marking a better long-term solution as a new issue?
This way, the broader PR can be pushed through, then we can give this specific item a proper, focused consideration?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That works for me. We can create a follow-up issue, and comment these lines with TODO(0xMattB): (explain what will be improved <...>).

Comment thread falcon/asgi/ws.py Outdated
raise errors.PayloadTypeError('Missing TEXT (0x01) payload')

return text
return '{}'.format(text)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again, undo these in a hot code path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done (marking for my own reference).

Comment thread falcon/asgi/ws.py Outdated
raise errors.PayloadTypeError('Missing BINARY (0x02) payload')

return data
return bytes(data)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done (marking for my own reference).

Comment thread falcon/http_status.py Outdated
def status_code(self) -> int:
"""HTTP status code normalized from :attr:`status`."""
return http_status_to_code(self.status)
return int(http_status_to_code(self.status))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These shouldn't be needed either. Does http_status_to_code always return an int?

Copy link
Copy Markdown
Contributor Author

@0xMattB 0xMattB Jan 21, 2026

Choose a reason for hiding this comment

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

It appears so from the code, but I believe something with the decorator is throwing this off.

falcon/http_status.py:70: error: Returning Any from function declared to return "int"  [no-any-return]

See first observation in my above post.

Edit: Or maybe it's too ambiguous for mypy --strict, I'm honestly not exactly sure.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah, right, it's coming from these LRU cache decorators... I'll take a look tomorrow... err later today 💤

@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Jan 22, 2026

Thanks a lot for this effort so far, it looks great overall! 💯

Copy link
Copy Markdown
Member

@vytas7 vytas7 left a comment

Choose a reason for hiding this comment

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

I took a quick look at the things LRU, it could potentially be improved slightly.

Comment thread falcon/util/misc.py
# the nocover pragma here.
def _lru_cache_nop(maxsize: int) -> Callable[[Callable], Callable]: # pragma: nocover
def decorator(func: Callable) -> Callable:
def _lru_cache_nop(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Probably we could use ParamSpec to improve typing of this?
I still had to use one cast(...), because it seems lru_cache is, itself, not fully typing-friendly.

But that is done once here, not in the places where the function is called.

For me, the below snippet passes with --strict:

import functools
from typing import cast, Callable, ParamSpec, TypeVar

_P = ParamSpec('_P')
_T = TypeVar('_T')


def my_cache(func: Callable[_P, _T]) -> Callable[_P, _T]:
    if func.__name__.startswith('fast'):
        return func
    cache = functools.lru_cache(maxsize=8)
    return cast(Callable[_P, _T], cache(func))


@my_cache
def parse_int(number: str) -> int:
    if number == 'zero':
        return 0
    if number == 'one':
        return 1
    if number == 'two':
        return 2
    if number == 'three':
        return 3
    return int(number)


def test() -> None:
    assert parse_int('two') == 2
    assert parse_int('3') == 3


if __name__ == '__main__':
    test()

@0xMattB
Copy link
Copy Markdown
Contributor Author

0xMattB commented Jan 23, 2026

Update 01/23/26: I had saved the list of original errors, so I was able to methodically address the proposed changes in neat groups.

falcon/util/misc.py:501: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/request.py:647: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/request.py:672: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/request.py:1265: error: Returning Any from function declared to return "str | None"  [no-any-return]
falcon/request.py:1274: error: Returning Any from function declared to return "str | None"  [no-any-return]
falcon/asgi/ws.py:420: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/asgi/request.py:348: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/asgi/request.py:382: error: Returning Any from function declared to return "str"  [no-any-return]
falcon/inspect.py:581: error: Returning Any from function declared to return "str"  [no-any-return]

Per comment, restored to original code, and added ignore and a TODO referencing this PR.
This should be flagged as a new specific issue.

falcon/http_error.py:164: error: Returning Any from function declared to return "int"  [no-any-return]
falcon/http_status.py:70: error: Returning Any from function declared to return "int"  [no-any-return]
falcon/response.py:202: error: Returning Any from function declared to return "int"  [no-any-return]

Per comment, restored to original code, and added ignore and a TODO referencing this PR.
This should be flagged as a new specific issue.

-falcon/http_error.py:237: error: Returning Any from function declared to return "bytes"  [no-any-return]
-falcon/asgi/ws.py:444: error: Returning Any from function declared to return "bytes"  [no-any-return]

Per comment, restored to original code, and added ignore.

~
IMPORTANT: For some reason this time around, tox produced an error from the mypy tests.

While analyzing this, I found that the mypy tests --allow-untyped-defs test in tox.ini was reporting unused-ignore errors on the files I modified (a side-effect of running without --strict). I'm not sure why running this in tests/ was causing it to check the files in falcon/.

I was able to get tox to pass by ignore these errors (mypy tests --allow-untyped-defs --disable-error-code "unused-ignore"), but I'm not sure if this is a valid solution or merely a band-aid.

Any thoughts?

@vytas7

@vytas7 vytas7 requested a review from CaselIT February 3, 2026 22:49
@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Feb 3, 2026

Thanks a ton for your extensive work on this so far @0xMattB!
If I understand the problem correctly, running with or without --strict triggers different errors, and causes that unused ignore error in one of the scenarios.

We already have the same situation across different CPython versions too. Perhaps unintuitive, but I found by trial and error that you can add unused-ignore, itself, to the list of ignored errors.
E.g., here: https://github.com/falconry/falcon/blob/master/falcon/asgi/app.py#L1267

I know it's ugly, but maybe it could do as a workaround?

@0xMattB
Copy link
Copy Markdown
Contributor Author

0xMattB commented Feb 6, 2026

@vytas7 No problem!

Thanks a ton for your extensive work on this so far @0xMattB! If I understand the problem correctly, running with or without --strict triggers different errors, and causes that unused ignore error in one of the scenarios.

Pretty much, but the latest errors were not from the /falcon directory, but the /test directory. Adding the disable-error-code flag to the test command seemed to do the trick.

Copy link
Copy Markdown
Member

@CaselIT CaselIT left a comment

Choose a reason for hiding this comment

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

Looks nice, but please avoid code changes in a typing related PR.

The code change may make sense but they should go to a separate PR so that they can be tracked better

Comment thread falcon/testing/helpers.py Outdated
Comment thread falcon/testing/helpers.py Outdated
Comment thread falcon/util/__init__.py Outdated
Comment thread falcon/app.py Outdated
Comment thread falcon/hooks.py Outdated
Comment thread falcon/hooks.py Outdated
@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Mar 7, 2026

In retrospect, maybe it was a somewhat vague goal to support --strict, which itself is not strictly defined.
Maybe we should check which flags --strict implies, and create separate PRs for each of them (edit: I meant not for each trivial flag, but if needed, for the larger ones)?

Edit: the meaning of --strict at the time of this writing:

@CaselIT
Copy link
Copy Markdown
Member

CaselIT commented Mar 10, 2026

Maybe we should check which flags --strict implies, and create separate PRs for each of them?

seems a lot more effort for not much gain. this pr is not too big

@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Mar 10, 2026

seems a lot more effort for not much gain. this pr is not too big

Yes, I edited my comment. I meant it might be good to break out a couple of hardest ones into separate PRs, if needed.

@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Apr 18, 2026

Hi again @0xMattB, just checking if you're still working on this?

vytas7 and others added 3 commits April 19, 2026 12:19
Add the remaining `--strict` options to `[tool.mypy]`
(`check_untyped_defs`, `disallow_any_generics`, `implicit_reexport = false`,
`warn_redundant_casts`, `warn_return_any`) so the strict checklist lives
in config rather than depending on the `--strict` CLI flag.

Parametrize the `App` annotations in the testing module with `[Any, Any]`
(required by `disallow_any_generics`), since these clients accept any
ASGI/WSGI app — including Falcon apps with custom Request/Response
subclasses. Leave a NOTE pointing at making the test clients generic as
future work (facilitated by TypeVar defaults on Python 3.13+).

Also pass `--no-check-untyped-defs` to `mypy tests` in tox, since the
suite is deliberately left untyped (`--allow-untyped-defs`) and checking
the bodies of those fixtures surfaces noise that the project's typing
strategy doesn't intend to address here.
Revert changes that were unrelated to making `mypy --strict` pass, per
@CaselIT's review comments:

* `falcon/hooks.py`: restore the `decorate_on_request` wrapping block
  (and the `cast(_R, ...)` returns) in both `_before` and `_after`
  that had been deleted. Setting `falcon.hooks.decorate_on_request`
  had become a no-op.
* `falcon/testing/helpers.py`: drop the unnecessary `'{}'.format(text)`
  / `bytes(data)` wraps in `receive_text` / `receive_data`; silence the
  underlying `no-any-return` (originating from `AsgiEvent = Mapping[str,
  Any]`) with a `# type: ignore` matching the style used elsewhere in
  this branch.
* `falcon/util/__init__.py`: remove `'sys'` from `__all__`. Use the
  `import sys as sys` explicit re-export idiom so that
  `from falcon.util import sys` (in `falcon/__init__.py`) still works
  under `implicit_reexport = false` without exposing a stdlib module
  name through `__all__`.
* `falcon/app.py`: drop the underscored TypeVars `_ReqT` / `_RespT`
  from `__all__`; move their import in `falcon/inspect.py` to come
  from their real home `falcon._typing`. Align the resulting
  `__all__` with the `falcon.asgi.app` shape (`App`, `Request`,
  `Response`).
@vytas7
Copy link
Copy Markdown
Member

vytas7 commented Apr 19, 2026

I have addressed most of the concerns with "AI" + myself, but I see some __all__ s are still a bit wonky. I'll check.

@vytas7 vytas7 requested a review from CaselIT April 19, 2026 17:29
@vytas7 vytas7 self-requested a review April 19, 2026 17:29
Comment thread falcon/asgi/app.py Outdated

__all__ = (
'App',
'Request',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need to officially re-export Request and Response from this app module? 🤔

CaselIT
CaselIT previously approved these changes Apr 20, 2026
Copy link
Copy Markdown
Member

@CaselIT CaselIT left a comment

Choose a reason for hiding this comment

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

looks mostly ok now.

Comment thread falcon/asgi/app.py Outdated
Co-authored-by: Federico Caselli <cfederico87@gmail.com>
Comment thread falcon/util/__init__.py

from http import cookies as http_cookies
import sys
import sys as sys # re-export for ``from falcon.util import sys`` consumers
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There should be no such "consumers"... I'll take a look at this.

Comment thread falcon/app.py

__all__ = (
'App',
'Request',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same issue w/ reexporting

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