Skip to content

Commit e3100f6

Browse files
committed
Add ty type checking alongside mypy
ty and mypy use incompatible suppression comments: ty ignores any `# type: ignore[...]` that carries error codes in brackets, and mypy only recognizes `# type: ignore` when it is the first comment on a line. Lines that both checkers flag now carry a mypy comment followed by a ty one, which each tool parses as its own. A bare `# type: ignore` is honored by both, so the sites that already use one are left alone. Add ty to the pep8test dependency group and run it in the `flake` and `local` nox sessions. ty resolves its target version from `project.requires-python` rather than the interpreter it runs on, so pass `--python-version` to keep it in step with mypy; otherwise the two disagree about which `sys.version_info` branch is reachable, and ty demands `tomli` even on interpreters where the fallback import is dead. Skip ty on Python 3.9. ty binds away the first parameter of a `__call__` typed through a Callable-bounded TypeVar, which is how pytest 8's `_WithException` protocol declares it, so every `pytest.skip("...")` in the suite is rejected. pytest 9 dropped that pattern but requires Python 3.10+. Disable ty's `unused-type-ignore-comment`. One arm of a `sys.version_info` pair is unreachable at any given version, and ty reports the blanket ignore mypy needs there as unused. mypy's `warn_unused_ignores` already covers those comments. `unused-ignore-comment` (for `# ty: ignore`) stays on, since mypy cannot check those. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FfdhCmHmddyxdZACBwjaeg
1 parent fa81394 commit e3100f6

18 files changed

Lines changed: 87 additions & 22 deletions

File tree

ci-constraints-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ tomli==2.4.1 ; python_full_version <= '3.11'
248248
# nox
249249
# pytest
250250
# sphinx
251+
ty==0.0.69
252+
# via cryptography (pyproject.toml:dev)
251253
typing-extensions==4.16.0
252254
# via
253255
# cryptography (pyproject.toml)

docs/development/submitting-patches.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ running ``ruff`` against it. If you've installed the development requirements
2424
this will automatically use our configuration. You can also run the ``nox``
2525
job with ``nox -e flake``.
2626

27+
We type check with both ``mypy`` and ``ty``. The two use different suppression
28+
comments, so a line that needs suppressing in both must carry both, with the
29+
``mypy`` one first (``mypy`` only recognizes ``# type: ignore`` when it is the
30+
first comment on the line):
31+
32+
.. code-block:: python
33+
34+
key1 < key2 # type: ignore[operator] # ty: ignore[unsupported-operator]
35+
36+
``ty`` does not understand ``mypy``'s error codes (see `ty#3127`_) and ignores
37+
a ``# type: ignore[...]`` comment that has any codes in brackets, so a
38+
``mypy``-only suppression needs nothing extra. A bare ``# type: ignore``, on
39+
the other hand, is honored by both, and needs no ``ty`` comment.
40+
41+
Don't add a ``# ty: ignore`` that isn't needed: ``ty`` reports unused ones.
42+
2743
`Write comments as complete sentences.`_
2844

2945
Class names which contains acronyms or initialisms should always be
@@ -148,3 +164,4 @@ So, specifically:
148164
.. _`syntax`: https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#info-field-lists
149165
.. _`Studies have shown`: https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/
150166
.. _`our mailing list`: https://mail.python.org/mailman/listinfo/cryptography-dev
167+
.. _`ty#3127`: https://github.com/astral-sh/ty/issues/3127

noxfile.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ def load_pyproject_toml() -> dict:
5454
return tomllib.load(f)
5555

5656

57+
def run_ty(session: nox.Session) -> None:
58+
# ty binds away the first parameter of a `__call__` typed through a
59+
# Callable-bounded TypeVar (https://github.com/astral-sh/ty/issues/3981),
60+
# which is how pytest 8's `_WithException` protocol declares it, so it
61+
# rejects every `pytest.skip("...")` in the test suite
62+
# (https://github.com/astral-sh/ty/issues/2797). pytest 9 dropped that
63+
# pattern but requires Python 3.10+, so skip ty on 3.9.
64+
if sys.version_info < (3, 10):
65+
session.log("Skipping ty: needs pytest >= 9, which needs Python 3.10+")
66+
return
67+
68+
session.run(
69+
"ty",
70+
"check",
71+
# ty takes its target version from `project.requires-python` (3.9)
72+
# rather than the interpreter it runs on. Pin it so ty and mypy agree
73+
# on which `sys.version_info` branches are reachable. No session pins a
74+
# python, so this is the version the session venv uses.
75+
f"--python-version={sys.version_info[0]}.{sys.version_info[1]}",
76+
"src/cryptography/",
77+
"vectors/cryptography_vectors/",
78+
"tests/",
79+
"release.py",
80+
"noxfile.py",
81+
)
82+
83+
5784
@nox.session
5885
@nox.session(name="tests-ssh")
5986
@nox.session(name="tests-randomorder")
@@ -232,6 +259,7 @@ def flake(session: nox.Session) -> None:
232259
"release.py",
233260
"noxfile.py",
234261
)
262+
run_ty(session)
235263
session.run("check-sdist", "--no-isolation")
236264

237265

@@ -332,6 +360,8 @@ def local(session: nox.Session):
332360
"noxfile.py",
333361
)
334362

363+
run_ty(session)
364+
335365
session.run(
336366
"maturin",
337367
"develop",

pyproject.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ sdist = ["build >=1.0.0"]
100100
pep8test = [
101101
"ruff >=0.11.11",
102102
"mypy >=1.14",
103+
"ty >=0.0.69",
103104
"check-sdist",
104105
"click >=8.0.1",
105106
]
@@ -159,6 +160,21 @@ warn_unused_configs = true
159160
strict_equality = true
160161
strict_bytes = true
161162

163+
# ty does not understand mypy's error codes, so a line both checkers flag
164+
# carries a `# type: ignore[...]` followed by a `# ty: ignore[...]`. If ty
165+
# grows support for mypy's names (https://github.com/astral-sh/ty/issues/3127)
166+
# the `ty:` halves can be dropped.
167+
[tool.ty.rules]
168+
# In `sys.version_info`-gated code one branch is always unreachable, and ty
169+
# reports the blanket ignore there as unused while mypy stays quiet. Whichever
170+
# version we check, one arm of such a pair gets flagged. mypy's
171+
# `warn_unused_ignores` already covers `# type: ignore` comments, so turn this
172+
# off. `unused-ignore-comment` (for `# ty: ignore`) stays enabled, since mypy
173+
# can't check those.
174+
# Tracked upstream as https://github.com/astral-sh/ty/issues/2681; this can be
175+
# re-enabled once ty stops reporting suppressions in unreachable code.
176+
unused-type-ignore-comment = "ignore"
177+
162178
[[tool.mypy.overrides]]
163179
module = ["pretend"]
164180
ignore_missing_imports = true

src/cryptography/hazmat/bindings/openssl/binding.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def build_conditional_library(
3636
conditional_names: Mapping[str, Callable[[], list[str]]],
3737
) -> typing.Any:
3838
conditional_lib = types.ModuleType("lib")
39-
conditional_lib._original_lib = lib # type: ignore[attr-defined]
39+
conditional_lib._original_lib = lib # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
4040
excluded_names = set()
4141
for condition, names_cb in conditional_names.items():
4242
if not getattr(lib, condition):

src/cryptography/x509/extensions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2510,7 +2510,7 @@ def __init__(self, oid: ObjectIdentifier, value: bytes) -> None:
25102510
self._value = value
25112511

25122512
@property
2513-
def oid(self) -> ObjectIdentifier: # type: ignore[override]
2513+
def oid(self) -> ObjectIdentifier: # type: ignore[override] # ty: ignore[invalid-attribute-override]
25142514
return self._oid
25152515

25162516
@property

tests/hazmat/asn1/test_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ class Example:
225225
with pytest.raises(
226226
TypeError, match="got an unexpected keyword argument 'bar'"
227227
):
228-
Example(bar=3) # type: ignore[call-arg]
228+
Example(bar=3) # type: ignore[call-arg] # ty: ignore[missing-argument, unknown-argument]
229229

230230
def test_fail_init_missing_field_name(self) -> None:
231231
@asn1.sequence
@@ -239,7 +239,7 @@ class Example:
239239
)
240240

241241
with pytest.raises(TypeError, match=expected_err):
242-
Example() # type: ignore[call-arg]
242+
Example() # type: ignore[call-arg] # ty: ignore[missing-argument]
243243

244244
def test_fail_positional_field_initialization(self) -> None:
245245
@asn1.sequence
@@ -475,7 +475,7 @@ def test_fail_choice_with_non_literal_tag(self) -> None:
475475
class Example:
476476
foo: typing.Union[
477477
Annotated[
478-
asn1.Variant[int, str],
478+
asn1.Variant[int, str], # ty: ignore[invalid-type-arguments]
479479
asn1.Implicit(0),
480480
],
481481
Annotated[
@@ -542,7 +542,7 @@ class Example:
542542
with pytest.raises(
543543
TypeError, match="got an unexpected keyword argument 'bar'"
544544
):
545-
Example(bar=3) # type: ignore[call-arg]
545+
Example(bar=3) # type: ignore[call-arg] # ty: ignore[missing-argument, unknown-argument]
546546

547547
def test_fail_init_missing_field_name(self) -> None:
548548
@asn1.set
@@ -556,7 +556,7 @@ class Example:
556556
)
557557

558558
with pytest.raises(TypeError, match=expected_err):
559-
Example() # type: ignore[call-arg]
559+
Example() # type: ignore[call-arg] # ty: ignore[missing-argument]
560560

561561
def test_fail_positional_field_initialization(self) -> None:
562562
@asn1.set

tests/hazmat/primitives/test_dh.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ def test_public_key_equality(self):
506506
assert key1 != object()
507507

508508
with pytest.raises(TypeError):
509-
key1 < key2 # type: ignore[operator]
509+
key1 < key2 # type: ignore[operator] # ty: ignore[unsupported-operator]
510510

511511
def test_public_key_copy(self):
512512
key_bytes = load_vectors_from_file(

tests/hazmat/primitives/test_dsa.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ def test_public_key_equality(self):
397397
assert key1 != key3
398398
assert key1 != object()
399399
with pytest.raises(TypeError):
400-
key1 < key2 # type: ignore[operator]
400+
key1 < key2 # type: ignore[operator] # ty: ignore[unsupported-operator]
401401

402402
def test_public_key_copy(self):
403403
key_bytes = load_vectors_from_file(

tests/hazmat/primitives/test_ec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ def test_public_key_equality(self, backend):
763763
assert key1 != key3
764764
assert key1 != object()
765765
with pytest.raises(TypeError):
766-
key1 < key2 # type: ignore[operator]
766+
key1 < key2 # type: ignore[operator] # ty: ignore[unsupported-operator]
767767

768768
def test_public_key_copy(self, backend):
769769
_skip_curve_unsupported(backend, ec.SECP256R1())

0 commit comments

Comments
 (0)