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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# attrs

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

[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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Pydantic

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

[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

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

## 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
```

## Regression 1159

```py
from pydantic import BaseModel, Field

def secret_from_env(env_var: str, default: str | None = None) -> bytes | None:
raise NotImplementedError

class BaseChatOpenAI(BaseModel):
model_name: str = Field(default="gpt-3.5-turbo", alias="model")
openai_api_key: bytes | None = Field(alias="api_key", default_factory=secret_from_env("OPENAI_API_KEY", default=None))

# TODO: no error here
# error: [unknown-argument] "Argument `model` does not match any known parameter"
BaseChatOpenAI(model="gpt-4", api_key=b"my_secret_key")
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SQLAlchemy

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

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

## Basic model

```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")

# TODO: SQLAlchemy overrides `__init__` to accepted all combinations of keyword arguments
reveal_type(User.__init__) # revealed: def __init__(self, **kw: Any) -> Unknown

user = User(name="John Doe")
reveal_type(user.id) # revealed: int
reveal_type(user.internal_name) # revealed: str
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SQLModel

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

[project]
dependencies = ["sqlmodel==0.0.27"]
```

## Basic model

```py
from sqlmodel import SQLModel

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

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

# TODO: this should not mention `__pydantic_self__`, and have proper parameters defined by the fields
reveal_type(User.__init__) # revealed: def __init__(__pydantic_self__, **data: Any) -> None

# TODO: this should be an error
User()
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SQLAlchemy

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

[project]
dependencies = ["strawberry-graphql==0.283.3"]
```

## Basic model

```py
import strawberry

@strawberry.type
class User:
id: int
role: str = strawberry.field(default="user")

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

user = User(id=1)
reveal_type(user.id) # revealed: int
reveal_type(user.role) # revealed: str
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# External packages

This document shows how external dependencies can be used in Markdown-based tests, and makes sure
that we can correctly use some common packages. See the mdtest README for more information.

## pydantic

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

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

```py
import pydantic

reveal_type(pydantic.__version__) # revealed: Literal["2.12.2"]
```

## numpy

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

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

```py
import numpy as np

reveal_type(np.float64) # revealed: <class 'float64'>
```

## requests

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

[project]
dependencies = ["requests==2.32.5"]
```

```py
import requests

reveal_type(requests.__version__) # revealed: Literal["2.32.5"]
```

## pytest

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

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

```py
import pytest

reveal_type(pytest.fail) # revealed: _WithException[Unknown, <class 'Failed'>]
```
36 changes: 36 additions & 0 deletions crates/ty_test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,42 @@ To enable logging in an mdtest, set `log = true` at the top level of the TOML bl
See [`MarkdownTestConfig`](https://github.com/astral-sh/ruff/blob/main/crates/ty_test/src/config.rs)
for the full list of supported configuration options.

### Testing with external dependencies

Tests can specify external Python dependencies using a `[project]` section in the TOML configuration.
This allows testing code that uses third-party libraries like `pydantic`, `numpy`, etc.

It is recommended to specify exact versions of packages to ensure reproducibility. The specified
Python version can also be important during package resolution.

````markdown
```toml
[environment]
python-version = "3.13"

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

```py
import pydantic

# use pydantic in the test
```
````

When a test has dependencies:

1. The test framework creates a `pyproject.toml` in a temporary directory.
1. Runs `uv sync` to install the dependencies.
1. Copies the installed packages from the virtual environment's `site-packages` directory into the test's
in-memory filesystem.
1. Configures the type checker to use these packages.

**Note**: This feature requires `uv` to be installed and available in your `PATH`. The dependencies
are installed fresh for each test that specifies them, so tests with many dependencies may be slower
to run.

### Specifying a custom typeshed

Some tests will need to override the default typeshed with custom files. The `[environment]`
Expand Down
24 changes: 24 additions & 0 deletions crates/ty_test/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
//!
//! ```toml
//! log = true # or log = "ty=WARN"
//!
//! [environment]
//! python-version = "3.10"
//!
//! [project]
//! dependencies = ["pydantic==2.12.2"]
//! ```

use anyhow::Context;
Expand All @@ -25,6 +29,9 @@ pub(crate) struct MarkdownTestConfig {
///
/// Defaults to the case-sensitive [`ruff_db::system::InMemorySystem`].
pub(crate) system: Option<SystemKind>,

/// Project configuration for installing external dependencies.
pub(crate) project: Option<Project>,
}

impl MarkdownTestConfig {
Expand All @@ -51,6 +58,10 @@ impl MarkdownTestConfig {
pub(crate) fn python(&self) -> Option<&SystemPath> {
self.environment.as_ref()?.python.as_deref()
}

pub(crate) fn dependencies(&self) -> Option<&[String]> {
self.project.as_ref()?.dependencies.as_deref()
}
}

#[derive(Deserialize, Debug, Default, Clone)]
Expand Down Expand Up @@ -116,3 +127,16 @@ pub(crate) enum SystemKind {
/// This system should only be used when testing system or OS specific behavior.
Os,
}

/// Project configuration for tests that need external dependencies.
#[derive(Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct Project {
/// List of Python package dependencies in `pyproject.toml` format.
///
/// These will be installed using `uv sync` into a temporary virtual environment.
/// The site-packages directory will then be copied into the test's filesystem.
///
/// Example: `dependencies = ["pydantic==2.12.2"]`
pub(crate) dependencies: Option<Vec<String>>,
}
Loading
Loading