feat: Add ty type checker (#38798)

This commit is contained in:
Julian Bez
2025-10-15 11:43:52 +02:00
committed by GitHub
parent 5a5db043c7
commit 411abe0be5
7 changed files with 901 additions and 2 deletions

18
.github/ty-problem-matcher.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"problemMatcher": [
{
"owner": "ty",
"severity": "warning",
"pattern": [
{
"regexp": "^([^:]*):(\\d+):(?:(\\d+):)? (?:error|warning): (.*?)(?: \\[(\\S+)\\])?$",
"file": 1,
"line": 2,
"column": 3,
"message": 4,
"code": 5
}
]
}
]
}

View File

@@ -89,7 +89,21 @@ jobs:
run: |
ruff format --check --diff .
- name: Add Problem Matcher
- name: Add ty Problem Matcher
shell: bash
run: |
echo "::add-matcher::.github/ty-problem-matcher.json"
- name: Check ty type checking (informational)
shell: bash
continue-on-error: true
run: |
echo "👀 Running ty type checker (informational - does not block CI)"
echo "📊 Trial run to evaluate ty vs mypy. Feedback welcome in #team-devex"
echo ""
./bin/ty.py check posthog ee common dags || echo "⚠️ ty found type issues (annotations below)"
- name: Add mypy Problem Matcher
shell: bash
run: |
echo "::add-matcher::.github/mypy-problem-matcher.json"

241
bin/ty.py Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Run ty with mypy-baseline integration.
This helper mirrors the workflow provided by mypy-baseline's CLI helpers. It
supports filtering diagnostics during pre-commit runs and updating the
``mypy-baseline.txt`` file from ty's output when new violations are added or
removed.
"""
from __future__ import annotations
import re
import sys
import argparse
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import cast
REPO_ROOT = Path(__file__).resolve().parent.parent
TY_BASELINE_PATH = REPO_ROOT / "ty-baseline.txt"
PYPROJECT_PATH = REPO_ROOT / "pyproject.toml"
# ty prints lines like ``path:line[:column]: error[rule] message``.
# Normalizing them makes mypy-baseline treat ty the same way as mypy.
_TY_PATTERN = re.compile(
r"^(?P<prefix>.+:\d+(?::\d+)?): (?P<severity>error|warn|warning)\[(?P<rule>[^\]]+)\] (?P<message>.*)$"
)
@dataclass(frozen=True)
class TyResult:
returncode: int
output: str
def _load_baseline_paths(baseline_path: Path) -> set[str]:
if not baseline_path.exists():
return set()
paths: set[str] = set()
for raw_line in baseline_path.read_text(encoding="utf-8").splitlines():
if not raw_line or raw_line.startswith("#"):
continue
path, *_rest = raw_line.split(":", 1)
paths.add(path)
return paths
def _should_skip(path: str, *, baseline_paths: set[str]) -> bool:
candidate = Path(path)
if not candidate.is_absolute():
candidate = (Path.cwd() / candidate).resolve()
try:
relative = candidate.relative_to(REPO_ROOT)
except ValueError:
relative = candidate
return relative.as_posix() in baseline_paths
def _normalize_ty_output(raw_output: str) -> str:
normalized_lines: list[str] = []
for raw_line in raw_output.splitlines():
line = raw_line.rstrip("\n")
if not line:
continue
if line.startswith("WARN ty is pre-release"):
continue
if line.startswith("Checking "):
continue
if line.startswith("Found ") and "diagnostic" in line:
continue
if line.startswith("info:"):
continue
if line.startswith("All checks passed"):
continue
match = _TY_PATTERN.match(line)
if match:
severity = match.group("severity")
if severity == "warn":
severity = "warning"
normalized_lines.append(
f"{match.group('prefix')}: {severity}: {match.group('message')} [{match.group('rule')}]"
)
continue
normalized_lines.append(line)
return "\n".join(normalized_lines)
def _run_ty(targets: Sequence[str]) -> TyResult:
if not targets:
return TyResult(returncode=0, output="")
proc = subprocess.run(
["uv", "run", "ty", "check", "--output-format", "concise", *targets],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
return TyResult(returncode=proc.returncode, output=proc.stdout)
def _run_mypy_baseline(
subcommand: str,
*,
input_text: str,
extra_args: Sequence[str] = (),
) -> TyResult:
proc = subprocess.run(
[
"uv",
"run",
"mypy-baseline",
subcommand,
"--config",
str(PYPROJECT_PATH),
*extra_args,
],
cwd=REPO_ROOT,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
return TyResult(returncode=proc.returncode, output=proc.stdout)
def _check(paths: Sequence[str], *, from_hook: bool = False) -> int:
# Check all files - mypy-baseline filter handles line-level filtering
filtered_targets = list(paths)
if not filtered_targets:
return 0
ty_result = _run_ty(filtered_targets)
normalized = _normalize_ty_output(ty_result.output)
# If ty found no issues, don't even run mypy-baseline filter
if ty_result.returncode == 0 and not normalized.strip():
return 0
baseline_result = _run_mypy_baseline(
"filter",
input_text=normalized,
extra_args=("--hide-stats", "--baseline-path", str(TY_BASELINE_PATH)),
)
if baseline_result.output:
sys.stdout.write(baseline_result.output)
if not baseline_result.output.endswith("\n"):
sys.stdout.write("\n")
# If there are filtered errors (baseline_result has output), that means new errors
if baseline_result.output:
sys.stderr.write(
"\n💡 ty found type errors (fast preflight check). For authoritative results, run mypy locally or check CI.\n"
)
return 1
if ty_result.returncode != 0 and "error[" not in normalized:
# ty failed for a reason unrelated to diagnostics (e.g. crash).
sys.stdout.write(ty_result.output)
if not ty_result.output.endswith("\n"):
sys.stdout.write("\n")
return ty_result.returncode
# All good - no new errors
return 0
def _sync(paths: Sequence[str]) -> int:
# Check all Python directories if no paths specified
targets = list(paths) if paths else ["posthog", "ee", "common", "dags"]
ty_result = _run_ty(targets)
normalized = _normalize_ty_output(ty_result.output)
sync_result = _run_mypy_baseline(
"sync",
input_text=normalized,
extra_args=("--hide-stats", "--baseline-path", str(TY_BASELINE_PATH)),
)
if sync_result.output:
sys.stdout.write(sync_result.output)
if not sync_result.output.endswith("\n"):
sys.stdout.write("\n")
if sync_result.returncode != 0:
return sync_result.returncode
if ty_result.returncode != 0 and "error[" not in normalized:
# Preserve failures unrelated to diagnostics so they aren't hidden.
sys.stdout.write(ty_result.output)
if not ty_result.output.endswith("\n"):
sys.stdout.write("\n")
return ty_result.returncode
return 0
def _parse_args(argv: Sequence[str]) -> tuple[str, list[str], bool]:
parser = argparse.ArgumentParser(
description="Run ty with mypy-baseline integration.",
)
subparsers = parser.add_subparsers(dest="command")
check_parser = subparsers.add_parser(
"check",
help="Run ty on the given paths, filtering diagnostics using mypy-baseline.",
)
check_parser.add_argument("paths", nargs="*")
sync_parser = subparsers.add_parser(
"sync",
help="Update mypy-baseline.txt by re-running ty on the provided paths.",
)
sync_parser.add_argument("paths", nargs="*")
# ``bin/ty.py <files>`` should behave like ``bin/ty.py check <files>`` for lint-staged.
# When called this way (no subcommand), it's from a hook
from_hook: bool = bool(argv and not argv[0].startswith("-") and argv[0] not in {"check", "sync"})
if from_hook:
argv = ["check", *argv]
args = parser.parse_args(argv)
command: str = cast(str, args.command or "check")
paths: list[str] = cast(list[str], getattr(args, "paths", []) or [])
return command, paths, from_hook
def main(argv: Sequence[str] | None = None) -> int:
command, paths, from_hook = _parse_args(list(argv) if argv is not None else sys.argv[1:])
if command == "check":
return _check(paths, from_hook=from_hook)
if command == "sync":
return _sync(paths)
raise AssertionError(f"Unknown command: {command}")
if __name__ == "__main__": # pragma: no cover - CLI entry point
sys.exit(main())

75
docs/ty-baseline.md Normal file
View File

@@ -0,0 +1,75 @@
# Ty Type Checking
ty runs in CI as an informational check to evaluate its usefulness vs mypy. If you see ty annotations in your PR:
- 👀 Review the feedback - ty often catches real type issues
- 📊 Share your experience in #team-devex
- ⚠️ ty warnings are informational and don't block CI
The baseline system filters out 567 pre-existing errors so you only see new issues introduced by your changes.
## Manual Usage
```bash
./bin/ty.py check path/to/file.py # Check specific files
./bin/ty.py check posthog ee # Check directories
```
## Ty vs mypy: Fast trial vs authoritative checking
**ty** is currently in trial mode:
- ⚡ Extremely fast (~10-100x faster than mypy)
- 🧪 Alpha software (v0.0.1a22 - expect edge cases)
- 📢 Runs in CI only (non-blocking) to gather feedback
- 🚨 Uses GitHub problem matcher to show warnings inline
**mypy** remains the **authoritative type checker**:
- 🎯 More mature and comprehensive
- 🐌 Slower but thorough
- ✅ Final source of truth (runs in CI and blocks on errors)
- 📍 Runs in CI and recommended for local deep checks
This trial helps us evaluate whether ty should become a blocking check in the future.
## Baseline Management
### Two Separate Baselines
- **`mypy-baseline.txt`** - Maintained by mypy (~1287 errors)
- **`ty-baseline.txt`** - Maintained by ty (567 diagnostics)
ty maintains its own baseline because it reports different errors than mypy. The baseline contains pre-existing errors that won't trigger warnings in CI.
### Baseline Contents
The ty-baseline.txt contains:
- 91 redundant-cast (easy cleanup opportunities)
- 96 possibly-unbound-attribute (null safety checks)
- 119 missing-argument (real bugs requiring investigation)
- 52 deprecated warnings
- Plus other categories
About 50% are real bugs, 25% safety improvements, 25% trivial cleanups.
### Updating ty-baseline
When you fix ty errors across the codebase, update the baseline:
```bash
./bin/ty.py sync
git add ty-baseline.txt
git commit -m "chore: update ty baseline after fixing type errors"
```
The sync command runs ty on all Python directories and updates `ty-baseline.txt` with the current state.
### How Filtering Works in CI
1. CI runs ty on your changed files
2. ty finds errors (both old and new)
3. Baseline filter removes errors already in `ty-baseline.txt`
4. Only new errors you introduced are shown as warnings
5. CI always passes (ty is informational only)

View File

@@ -195,6 +195,7 @@ dev = [
"mypy~=1.18.2",
"mypy-baseline~=0.7.3",
"mypy-extensions~=1.1.0",
"ty==0.0.1a22",
# openapi-spec-validator needed for prance as a validation backend
"openapi-spec-validator==0.7.1",
"orjson==3.10.15",
@@ -341,3 +342,24 @@ python_version = "3.12"
[tool.mypy-baseline]
sort_baseline = true
ignore_categories = ["note"]
[tool.ty.environment]
python-version = "3.12"
[tool.ty.src]
exclude = [
"bin/**",
"gunicorn.config.py",
"manage.py",
"posthog/hogql/grammar/**",
]
[tool.ty.rules]
# Import resolution - ty can't always find all modules/stubs
unresolved-import = "ignore"
# Django-related rules - ty doesn't support Django's metaprogramming yet
# See: https://github.com/astral-sh/ty/issues/291
unresolved-attribute = "ignore" # Django model fields, relationships, auto-generated attributes
invalid-argument-type = "ignore" # Django querysets, managers, form fields
invalid-base = "ignore" # Django model metaclasses (e.g., models.Model)

502
ty-baseline.txt Normal file

File diff suppressed because one or more lines are too long

29
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = "==3.12.11"
[[package]]
@@ -4312,6 +4312,7 @@ dev = [
{ name = "stpyv8", marker = "(platform_machine != 'aarch64' and platform_machine != 'arm64') or sys_platform != 'linux'" },
{ name = "syrupy" },
{ name = "tach" },
{ name = "ty" },
{ name = "types-aioboto3", extra = ["s3"] },
{ name = "types-aiobotocore" },
{ name = "types-boto3", extra = ["essential"] },
@@ -4548,6 +4549,7 @@ dev = [
{ name = "stpyv8", marker = "(platform_machine != 'aarch64' and platform_machine != 'arm64') or sys_platform != 'linux'", specifier = "==13.1.201.22" },
{ name = "syrupy", specifier = "~=4.9.1" },
{ name = "tach", specifier = "~=0.20.0" },
{ name = "ty", specifier = "==0.0.1a22" },
{ name = "types-aioboto3", extras = ["s3"], specifier = ">=14.3.0" },
{ name = "types-aiobotocore", specifier = ">=2.22.0" },
{ name = "types-boto3", extras = ["essential"], specifier = "==1.37.6" },
@@ -6443,6 +6445,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" },
]
[[package]]
name = "ty"
version = "0.0.1a22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/87/eab73cdc990d1141b60237379975efc0e913bfa0d19083daab0f497444a6/ty-0.0.1a22.tar.gz", hash = "sha256:b20ec5362830a1e9e05654c15e88607fdbb45325ec130a9a364c6dd412ecbf55", size = 4312182, upload-time = "2025-10-10T13:07:15.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/30/83e2dbfbc70de8a1932b19daf05ce803d7d76cdc6251de1519a49cf1c27d/ty-0.0.1a22-py3-none-linux_armv6l.whl", hash = "sha256:6efba0c777881d2d072fa7375a64ad20357e825eff2a0b6ff9ec80399a04253b", size = 8581795, upload-time = "2025-10-10T13:06:44.396Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8c/5193534fc4a3569f517408828d077b26d6280fe8c2dd0bdc63db4403dcdb/ty-0.0.1a22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2ada020eebe1b44403affdf45cd5c8d3fb8312c3e80469d795690093c0921f55", size = 8682602, upload-time = "2025-10-10T13:06:46.44Z" },
{ url = "https://files.pythonhosted.org/packages/22/4a/7ba53493bf37b61d3e0dfe6df910e6bc74c40d16c3effd84e15c0863d34e/ty-0.0.1a22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ed4f11f1a5824ea10d3e46b1990d092c3f341b1d492c357d23bed2ac347fd253", size = 8278839, upload-time = "2025-10-10T13:06:48.688Z" },
{ url = "https://files.pythonhosted.org/packages/52/0a/d9862c41b9615de56d2158bfbb5177dbf5a65e94922d3dd13855f48cb91b/ty-0.0.1a22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56f48d8f94292909d596dbeb56ff7f9f070bd316aa628b45c02ca2b2f5797f31", size = 8421483, upload-time = "2025-10-10T13:06:50.75Z" },
{ url = "https://files.pythonhosted.org/packages/a5/cb/3ebe0e45b80724d4c2f849fdf304179727fd06df7fee7cd12fe6c3efe49d/ty-0.0.1a22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:733e9ac22885b6574de26bdbae439c960a06acc825a938d3780c9d498bb65339", size = 8419225, upload-time = "2025-10-10T13:06:52.533Z" },
{ url = "https://files.pythonhosted.org/packages/4f/b5/da65f3f8ad31d881ca9987a3f6f26069a0cc649c9354adb7453ca62116bb/ty-0.0.1a22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5135d662484e56809c77b3343614005585caadaa5c1cf643ed6a09303497652b", size = 9352336, upload-time = "2025-10-10T13:06:54.476Z" },
{ url = "https://files.pythonhosted.org/packages/a3/24/9c46f2eb16734ab0fcf3291486b1c5c528a1569f94541dc1f19f97dd2a5b/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:87f297f99a98154d33a3f21991979418c65d8bf480f6a1bad1e54d46d2dc7df7", size = 9857840, upload-time = "2025-10-10T13:06:56.514Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ae/930c94bbbe5c049eae5355a197c39522844f55c7ab7fccd0ba061f618541/ty-0.0.1a22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3310217eaa4dccf20b7336fcbeb072097addc6fde0c9d3f791dea437af0aa6dc", size = 9452611, upload-time = "2025-10-10T13:06:58.154Z" },
{ url = "https://files.pythonhosted.org/packages/a2/80/d8f594438465c352cf0ebd4072f5ca3be2871153a3cd279ed2f35ecd487c/ty-0.0.1a22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12b032e81012bf5228fd65f01b50e29eb409534b6aac28ee5c48ee3b7b860ddf", size = 9214875, upload-time = "2025-10-10T13:06:59.861Z" },
{ url = "https://files.pythonhosted.org/packages/fd/07/f852fb20ac27707de495c39a02aeb056e3368833b7e12888d43b1f61594d/ty-0.0.1a22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3ffda8149cab0000a21e7a078142073e27a1a9ac03b9a0837aa2f53d1fbebcb", size = 8906715, upload-time = "2025-10-10T13:07:01.926Z" },
{ url = "https://files.pythonhosted.org/packages/40/4d/0e0b85b4179891cc3067a6e717f5161921c07873a4f545963fdf1dd3619c/ty-0.0.1a22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:afa512e7dc78f0cf0b55f87394968ba59c46993c67bc0ef295962144fea85b12", size = 8350873, upload-time = "2025-10-10T13:07:03.999Z" },
{ url = "https://files.pythonhosted.org/packages/a1/1f/e70c63e12b4a0d97d4fd6f872dd199113666ad1b236e18838fa5e5d5502d/ty-0.0.1a22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:069cdbbea6025f7ebbb5e9043c8d0daf760358df46df8304ef5ca5bb3e320aef", size = 8442568, upload-time = "2025-10-10T13:07:05.745Z" },
{ url = "https://files.pythonhosted.org/packages/de/3b/55518906cb3598f2b99ff1e86c838d77d006cab70cdd2a0a625d02ccb52c/ty-0.0.1a22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:67d31d902e6fd67a4b3523604f635e71d2ec55acfb9118f984600584bfe0ff2a", size = 8896775, upload-time = "2025-10-10T13:07:08.02Z" },
{ url = "https://files.pythonhosted.org/packages/c3/ea/60c654c27931bf84fa9cb463a4c4c49e8869c052fa607a6e930be717b619/ty-0.0.1a22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f9e154f262162e6f76b01f318e469ac6c22ffce22b010c396ed34e81d8369821", size = 9054544, upload-time = "2025-10-10T13:07:09.675Z" },
{ url = "https://files.pythonhosted.org/packages/6c/60/9a6d5530d6829ccf656e6ae0fb13d70a4e2514f4fb8910266ebd54286620/ty-0.0.1a22-py3-none-win32.whl", hash = "sha256:37525433ca7b02a8fca4b8fa9dcde818bf3a413b539b9dbc8f7b39d124eb7c49", size = 8165703, upload-time = "2025-10-10T13:07:11.378Z" },
{ url = "https://files.pythonhosted.org/packages/14/9c/ac08c832643850d4e18cbc959abc69cd51d531fe11bdb691098b3cf2f562/ty-0.0.1a22-py3-none-win_amd64.whl", hash = "sha256:75d21cdeba8bcef247af89518d7ce98079cac4a55c4160cb76682ea40a18b92c", size = 8828319, upload-time = "2025-10-10T13:07:12.815Z" },
{ url = "https://files.pythonhosted.org/packages/22/df/38068fc44e3cfb455aeb41d0ff1850a4d3c9988010466d4a8d19860b8b9a/ty-0.0.1a22-py3-none-win_arm64.whl", hash = "sha256:1c7f040fe311e9696917417434c2a0e58402235be842c508002c6a2eff1398b0", size = 8367136, upload-time = "2025-10-10T13:07:14.518Z" },
]
[[package]]
name = "typer"
version = "0.16.0"