Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ for family, grp in itertools.groupby(collected.checks.items(), key=lambda x: x[1
- [`MY104`](https://learn.scientific-python.org/development/guides/style#MY104): MyPy enables ignore-without-code
- [`MY105`](https://learn.scientific-python.org/development/guides/style#MY105): MyPy enables redundant-expr
- [`MY106`](https://learn.scientific-python.org/development/guides/style#MY106): MyPy enables truthy-bool
- [`MY107`](https://learn.scientific-python.org/development/guides/style#MY107): ty fails on warnings
- [`MY108`](https://learn.scientific-python.org/development/guides/style#MY108): ty enables blanket-ignore-comment

### Nox

Expand Down
18 changes: 15 additions & 3 deletions docs/guides/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,9 @@ ignore-missing-imports = ["numpy"]

Pyrefly checks all your code by default, rather than only annotated functions,
so you can start from a looser `preset` and tighten as you go. Set
`preset = "strict"` (the equivalent of MyPy's `strict = true`) once you are
ready {rr}`MY101`, and toggle individual error codes in a
`preset = "strict"` (the equivalent of MyPy's `strict = true`) or
`preset = "all"` once you are ready {rr}`MY101`. The strict preset also reports
unused `# pyrefly: ignore` comments. You can toggle individual error codes in a
`[tool.pyrefly.errors]` table, for example:

```toml
Expand Down Expand Up @@ -778,12 +779,23 @@ exclude = ["**/tests"]

[tool.ty.rules]
# Each rule can be set to "ignore", "warn", or "error"
blanket-ignore-comment = "error"
unresolved-import = "ignore"

[tool.ty.terminal]
error-on-warning = true
```

Like Pyrefly, ty checks all your code rather than only annotated functions, and
it has no single `strict` switch -- you tighten or relax individual rules in
`[tool.ty.rules]`. To silence missing imports for specific modules (the
`[tool.ty.rules]` {rr}`MY101`. Set `all = "error"` there to turn on every rule.

ty exits successfully on warnings by default, so CI does not fail on rules such
as `unused-ignore-comment`. Set `error-on-warning` {rr}`MY107` to change this.
The `blanket-ignore-comment` rule {rr}`MY108` forces every `# ty: ignore` to
list the rules it ignores, like MyPy's `ignore-without-code`.

To silence missing imports for specific modules (the
equivalent of MyPy's per-module `ignore_missing_imports`) rather than turning
the rule off everywhere, treat their imports as `Any` in the
`[tool.ty.analysis]` table:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ noxfile = "sp_repo_review.checks.noxfile:repo_review_checks"
dependabot = "sp_repo_review.checks.github:dependabot"
noxfile = "sp_repo_review.checks.noxfile:noxfile"
precommit = "sp_repo_review.checks.precommit:precommit"
pyrefly = "sp_repo_review.checks.mypy:pyrefly"
pytest = "sp_repo_review.checks.pyproject:pytest"
readthedocs = "sp_repo_review.checks.readthedocs:readthedocs"
ruff = "sp_repo_review.checks.ruff:ruff"
setupcfg = "sp_repo_review.checks.setupcfg:setupcfg"
ty = "sp_repo_review.checks.mypy:ty"
workflows = "sp_repo_review.checks.github:workflows"

[project.entry-points."repo_review.families"]
Expand Down
138 changes: 116 additions & 22 deletions src/sp_repo_review/checks/mypy.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
from __future__ import annotations

__lazy_modules__ = ["tomllib"]

import tomllib
from typing import TYPE_CHECKING, Any

from . import mk_url

if TYPE_CHECKING:
from importlib.resources.abc import Traversable

MY100_CONFIG_FILES = ("ty.toml", "pyrefly.toml")

def _load_config(
pyproject: dict[str, Any], package: Traversable, name: str
) -> dict[str, Any] | None:
path = package.joinpath(f"{name}.toml")
if path.is_file():
with path.open("rb") as f:
return tomllib.load(f)
config: dict[str, Any] | None = pyproject.get("tool", {}).get(name)
return config


def ty(pyproject: dict[str, Any], package: Traversable) -> dict[str, Any] | None:
"""
Returns the ty configuration, or None if ty is not configured. A
``ty.toml`` file takes precedence over ``pyproject.toml``.
"""
return _load_config(pyproject, package, "ty")


def pyrefly(pyproject: dict[str, Any], package: Traversable) -> dict[str, Any] | None:
"""
Returns the Pyrefly configuration, or None if Pyrefly is not configured.
A ``pyrefly.toml`` file takes precedence over ``pyproject.toml``.
"""
return _load_config(pyproject, package, "pyrefly")


class MyPy:
Expand All @@ -21,23 +49,21 @@
url = mk_url("style")

@staticmethod
def check(pyproject: dict[str, Any], package: Traversable) -> bool:
def check(
pyproject: dict[str, Any],
ty: dict[str, Any] | None,
pyrefly: dict[str, Any] | None,
) -> bool:
"""
Must have a `tool.mypy`, `tool.pyrefly`, or `tool.ty` section in
`pyproject.toml`, or a `ty.toml` or `pyrefly.toml` file. The other checks in this family only read
`pyproject.toml`.
`pyproject.toml`, or a `ty.toml` or `pyrefly.toml` file.
"""

if any(package.joinpath(fn).is_file() for fn in MY100_CONFIG_FILES):
return True

match pyproject:
case {
"tool": {"mypy": object()} | {"pyrefly": object()} | {"ty": object()}
}:
case {"tool": {"mypy": object()}}:
return True
case _:
return False
return ty is not None or pyrefly is not None


class MY101(MyPy):
Expand All @@ -47,28 +73,37 @@
url = mk_url("style")

@staticmethod
def check(pyproject: dict[str, Any]) -> bool | None:
def check(
pyproject: dict[str, Any],
ty: dict[str, Any] | None,
pyrefly: dict[str, Any] | None,
) -> bool | None:
"""
Should opt into strict checking. For MyPy, set `strict` (true/false).
For Pyrefly, set a `preset` (such as `"strict"`). ty has no strict
switch -- you tighten individual rules in `[tool.ty.rules]` -- so this
check is skipped when only ty is configured.
For Pyrefly, set `preset` to `"strict"` or `"all"`. ty has no strict
switch, so set the levels of rules in `[tool.ty.rules]`. Passes if any
configured type checker is strict.

```toml
[tool.mypy]
strict = true
```
"""

match pyproject:
case {"tool": {"mypy": {"strict": bool()}}}:
mypy = pyproject.get("tool", {}).get("mypy")
if mypy is None and ty is None and pyrefly is None:
return None

match mypy:
case {"strict": bool()}:
return True
case {"tool": {"pyrefly": {"preset": str()}}}:
match pyrefly:
case {"preset": "strict" | "all"}:
return True
case {"tool": {"mypy": object()} | {"pyrefly": object()}}:
return False
case _:
return None
match ty:
case {"rules": dict()}:
return True
return False


class MY102(MyPy):
Expand Down Expand Up @@ -207,5 +242,64 @@
return None


class MY107(MyPy):
"ty fails on warnings"

requires = {"MY100"}
url = mk_url("style")

@staticmethod
def check(ty: dict[str, Any] | None) -> bool | None:
"""
Must set `error-on-warning` (true/false) in `[tool.ty.terminal]`. ty
exits successfully on warnings by default, so CI does not fail on
rules such as `unused-ignore-comment`. Skipped if ty is not configured.

```toml
[tool.ty.terminal]
error-on-warning = true
```
"""

match ty:
case None:
return None
case {"terminal": {"error-on-warning": bool()}}:
return True
case _:
return False


class MY108(MyPy):
"ty enables blanket-ignore-comment"

requires = {"MY100"}
url = mk_url("style")

@staticmethod
def check(ty: dict[str, Any] | None) -> bool | None:
"""
Must enable the `blanket-ignore-comment` rule (or set `all` to a
level other than `"ignore"`). This forces every `# ty: ignore` to list
the rules it ignores, like MyPy's `ignore-without-code`. Skipped if ty
is not configured.

```toml
[tool.ty.rules]
blanket-ignore-comment = "error"
```
"""

match ty:
case None:
return None
case {"rules": {"blanket-ignore-comment": str(level)}}:

Check notice on line 296 in src/sp_repo_review/checks/mypy.py

View workflow job for this annotation

GitHub Actions / rr-tests / Run pylint

R1905

Use 'str() as level' instead
return level != "ignore"
case {"rules": {"all": str(level)}}:

Check notice on line 298 in src/sp_repo_review/checks/mypy.py

View workflow job for this annotation

GitHub Actions / rr-tests / Run pylint

R1905

Use 'str() as level' instead
return level != "ignore"
case _:
return False


def repo_review_checks() -> dict[str, MyPy]:
return {p.__name__: p() for p in MyPy.__subclasses__()}
Loading
Loading