Skip to content
Merged
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 .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ env:
PACKAGE_NAME: ruff
PYTHON_VERSION: "3.14"
NEXTEST_PROFILE: ci
# Enable mdtests that require external dependencies
MDTEST_EXTERNAL: "1"

jobs:
determine_changes:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 additions & 2 deletions crates/ty_python_semantic/mdtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ class MDTestRunner:
mdtest_executable: Path | None
console: Console
filters: list[str]
enable_external: bool

def __init__(self, filters: list[str] | None = None) -> None:
def __init__(self, filters: list[str] | None, enable_external: bool) -> None:
self.mdtest_executable = None
self.console = Console()
self.filters = [
f.removesuffix(".md").replace("/", "_").replace("-", "_")
for f in (filters or [])
]
self.enable_external = enable_external

def _run_cargo_test(self, *, message_format: Literal["human", "json"]) -> str:
return subprocess.check_output(
Expand Down Expand Up @@ -120,6 +122,7 @@ def _run_mdtest(
CLICOLOR_FORCE="1",
INSTA_FORCE_PASS="1",
INSTA_OUTPUT="none",
MDTEST_EXTERNAL="1" if self.enable_external else "0",
),
capture_output=capture_output,
text=True,
Expand Down Expand Up @@ -266,11 +269,19 @@ def main() -> None:
nargs="*",
help="Partial paths or mangled names, e.g., 'loops/for.md' or 'loops_for'",
)
parser.add_argument(
"--enable-external",
"-e",
action="store_true",
help="Enable tests with external dependencies",
)

args = parser.parse_args()

try:
runner = MDTestRunner(filters=args.filters)
runner = MDTestRunner(
filters=args.filters, enable_external=args.enable_external
)
runner.watch()
except KeyboardInterrupt:
print()
Expand Down
4 changes: 4 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# mdtests with external dependencies

This directory contains mdtests that make use of external packages. See the mdtest `README.md` for
more information.
78 changes: 78 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/attrs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# attrs

```toml
[environment]
python-version = "3.13"
python-platform = "linux"

[project]
dependencies = ["attrs==25.4.0"]
```

## Basic class (`attr`)

```py
import attr

@attr.s
class User:
id: int = attr.ib()
name: str = attr.ib()

user = User(id=1, name="John Doe")

reveal_type(user.id) # revealed: int
reveal_type(user.name) # revealed: str
```

## Basic class (`define`)

```py
from attrs import define, field

@define
class User:
id: int = field()
internal_name: str = field(alias="name")

user = User(id=1, name="John Doe")
reveal_type(user.id) # revealed: int
reveal_type(user.internal_name) # revealed: str
```

## Usage of `field` parameters

```py
from attrs import define, field

@define
class Product:
id: int = field(init=False)
name: str = field()
price_cent: int = field(kw_only=True)

reveal_type(Product.__init__) # revealed: (self: Product, name: str, *, price_cent: int) -> None
```

## Dedicated support for the `default` decorator?

We currently do not support this:

```py
from attrs import define, field

@define
class Person:
id: int = field()
name: str = field()

# error: [call-non-callable] "Object of type `_MISSING_TYPE` is not callable"
@id.default
def _default_id(self) -> int:
raise NotImplementedError

# error: [missing-argument] "No argument provided for required parameter `id`"
person = Person(name="Alice")
reveal_type(person.id) # revealed: int
reveal_type(person.name) # revealed: str
```
23 changes: 23 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/numpy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# numpy

```toml
[environment]
python-version = "3.13"
python-platform = "linux"

[project]
dependencies = ["numpy==2.3.0"]
```

## Basic usage

```py
import numpy as np

xs = np.array([1, 2, 3])
reveal_type(xs) # revealed: ndarray[tuple[Any, ...], dtype[Any]]

xs = np.array([1.0, 2.0, 3.0], dtype=np.float64)
# TODO: should be `ndarray[tuple[Any, ...], dtype[float64]]`
reveal_type(xs) # revealed: ndarray[tuple[Any, ...], dtype[Unknown]]
```
48 changes: 48 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/pydantic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Pydantic

```toml
[environment]
python-version = "3.12"
python-platform = "linux"

[project]
dependencies = ["pydantic==2.12.2"]
```

## Basic model

```py
from pydantic import BaseModel

class User(BaseModel):
id: int
name: str

reveal_type(User.__init__) # revealed: (self: User, *, id: int, name: str) -> None

user = User(id=1, name="John Doe")
reveal_type(user.id) # revealed: int
reveal_type(user.name) # revealed: str

# error: [missing-argument] "No argument provided for required parameter `name`"
invalid_user = User(id=2)
```

## Usage of `Field`

```py
from pydantic import BaseModel, Field

class Product(BaseModel):
id: int = Field(init=False)
name: str = Field(..., kw_only=False, min_length=1)
internal_price_cent: int = Field(..., gt=0, alias="price_cent")

reveal_type(Product.__init__) # revealed: (self: Product, name: str = Any, *, price_cent: int = Any) -> None

product = Product("Laptop", price_cent=999_00)

reveal_type(product.id) # revealed: int
reveal_type(product.name) # revealed: str
reveal_type(product.internal_price_cent) # revealed: int
```
27 changes: 27 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/pytest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# pytest

```toml
[environment]
python-version = "3.13"
python-platform = "linux"

[project]
dependencies = ["pytest==9.0.1"]
```

## `pytest.fail`

Make sure that we recognize `pytest.fail` calls as terminal:

```py
import pytest

def some_runtime_condition() -> bool:
return True

def test_something():
if not some_runtime_condition():
pytest.fail("Runtime condition failed")

no_error_here_this_is_unreachable
```
124 changes: 124 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/external/sqlalchemy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# SQLAlchemy

```toml
[environment]
python-version = "3.13"
python-platform = "linux"

[project]
dependencies = ["SQLAlchemy==2.0.44"]
```

## Basic model

Here, we mostly make sure that ty understands SQLAlchemy's dataclass-transformer setup:

```py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
pass

class User(Base):
__tablename__ = "user"

id: Mapped[int] = mapped_column(primary_key=True, init=False)
internal_name: Mapped[str] = mapped_column(alias="name")

user = User(name="John Doe")
reveal_type(user.id) # revealed: int
reveal_type(user.internal_name) # revealed: str
```

Unfortunately, SQLAlchemy overrides `__init__` and explicitly accepts all combinations of keyword
arguments. This is why we currently cannot flag invalid constructor calls:

```py
reveal_type(User.__init__) # revealed: def __init__(self, **kw: Any) -> Unknown

# TODO: this should ideally be an error
invalid_user = User(invalid_arg=42)
```

## Queries

First, the basic setup:

```py
from datetime import datetime

from sqlalchemy import select, Integer, Text, Boolean, DateTime
from sqlalchemy.orm import Session
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import create_engine

engine = create_engine("sqlite://example.db")
session = Session(engine)
```

Now we can declare a simple model:

```py
class Base(DeclarativeBase):
pass

class User(Base):
__tablename__ = "users"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
```

And perform simple queries:

```py
stmt = select(User)
reveal_type(stmt) # revealed: Select[tuple[User]]

users = session.scalars(stmt).all()
reveal_type(users) # revealed: Sequence[User]

for row in session.execute(stmt):
reveal_type(row) # revealed: Row[tuple[User]]

stmt = select(User).where(User.name == "Alice")
alice = session.scalars(stmt).first()
reveal_type(alice) # revealed: User | None

stmt = select(User).where(User.is_admin == True).order_by(User.name).limit(10)
admin_users = session.scalars(stmt).all()
reveal_type(admin_users) # revealed: Sequence[User]
```

This also works with the legacy `query` API:

```py
users_legacy = session.query(User).all()
reveal_type(users_legacy) # revealed: list[User]
```

We can also specify particular columns to select:

```py
stmt = select(User.id, User.name)
# TODO: should be `Select[tuple[int, str]]`
reveal_type(stmt) # revealed: Select[tuple[Unknown, Unknown]]

for row in session.execute(stmt):
# TODO: should be `Row[Tuple[int, str]]`
reveal_type(row) # revealed: Row[tuple[Unknown, Unknown]]
```

And similarly with the legacy `query` API:

```py
query = session.query(User.id, User.name)
# TODO: should be `RowReturningQuery[tuple[int, str]]`
reveal_type(query) # revealed: RowReturningQuery[tuple[Unknown, Unknown]]

for row in query.all():
# TODO: should be `Row[Tuple[int, str]]`
reveal_type(row) # revealed: Row[tuple[Unknown, Unknown]]
```
Loading
Loading