[red-knot] Enforce specifying paths for mdtest code blocks in a separate preceding line (#15890)

## Summary

Resolves #15695, rework of #15704.

This change modifies the Mdtests framework so that:

* Paths must now be specified in a separate preceding line:

	`````markdown
	`a.py`:

	```py
	x = 1
	```
	`````

If the path of a file conflicts with its `lang`, an error will be
thrown.

* Configs are no longer accepted. The pattern still take them into
account, however, to avoid "Unterminated code block" errors.
* Unnamed files are now assigned unique, `lang`-respecting paths
automatically.

Additionally, all legacy usages have been updated.

## Test Plan

Unit tests and Markdown tests.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
InSync 2025-02-04 14:27:17 +07:00 committed by GitHub
parent 0529ad67d7
commit 11cfe2ea8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 967 additions and 286 deletions

View file

@ -2,7 +2,9 @@
## Deferred annotations in stubs always resolve
```pyi path=mod.pyi
`mod.pyi`:
```pyi
def get_foo() -> Foo: ...
class Foo: ...
```

View file

@ -116,7 +116,9 @@ def union_example(
Only Literal that is defined in typing and typing_extension modules is detected as the special
Literal.
```pyi path=other.pyi
`other.pyi`:
```pyi
from typing import _SpecialForm
Literal: _SpecialForm

View file

@ -25,7 +25,9 @@ x = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]` is not
## Tuple annotations are understood
```py path=module.py
`module.py`:
```py
from typing_extensions import Unpack
a: tuple[()] = ()
@ -40,7 +42,9 @@ i: tuple[str | int, str | int] = (42, 42)
j: tuple[str | int] = (42,)
```
```py path=script.py
`script.py`:
```py
from module import a, b, c, d, e, f, g, h, i, j
reveal_type(a) # revealed: tuple[()]
@ -114,7 +118,7 @@ reveal_type(x) # revealed: Foo
## Annotations in stub files are deferred
```pyi path=main.pyi
```pyi
x: Foo
class Foo: ...
@ -125,7 +129,7 @@ reveal_type(x) # revealed: Foo
## Annotated assignments in stub files are inferred correctly
```pyi path=main.pyi
```pyi
x: int = 1
reveal_type(x) # revealed: Literal[1]
```

View file

@ -703,7 +703,9 @@ reveal_type(Foo.__class__) # revealed: Literal[type]
## Module attributes
```py path=mod.py
`mod.py`:
```py
global_symbol: str = "a"
```
@ -737,13 +739,19 @@ for mod.global_symbol in IntIterable():
## Nested attributes
```py path=outer/__init__.py
`outer/__init__.py`:
```py
```
```py path=outer/nested/__init__.py
`outer/nested/__init__.py`:
```py
```
```py path=outer/nested/inner.py
`outer/nested/inner.py`:
```py
class Outer:
class Nested:
class Inner:
@ -766,7 +774,9 @@ outer.nested.inner.Outer.Nested.Inner.attr = "a"
Most attribute accesses on function-literal types are delegated to `types.FunctionType`, since all
functions are instances of that class:
```py path=a.py
`a.py`:
```py
def f(): ...
reveal_type(f.__defaults__) # revealed: @Todo(full tuple[...] support) | None
@ -775,7 +785,9 @@ reveal_type(f.__kwdefaults__) # revealed: @Todo(generics) | None
Some attributes are special-cased, however:
```py path=b.py
`b.py`:
```py
def f(): ...
reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions)
@ -787,14 +799,18 @@ reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions)
Most attribute accesses on int-literal types are delegated to `builtins.int`, since all literal
integers are instances of that class:
```py path=a.py
`a.py`:
```py
reveal_type((2).bit_length) # revealed: @Todo(bound method)
reveal_type((2).denominator) # revealed: @Todo(@property)
```
Some attributes are special-cased, however:
```py path=b.py
`b.py`:
```py
reveal_type((2).numerator) # revealed: Literal[2]
reveal_type((2).real) # revealed: Literal[2]
```
@ -804,14 +820,18 @@ reveal_type((2).real) # revealed: Literal[2]
Most attribute accesses on bool-literal types are delegated to `builtins.bool`, since all literal
bols are instances of that class:
```py path=a.py
`a.py`:
```py
reveal_type(True.__and__) # revealed: @Todo(bound method)
reveal_type(False.__or__) # revealed: @Todo(bound method)
```
Some attributes are special-cased, however:
```py path=b.py
`b.py`:
```py
reveal_type(True.numerator) # revealed: Literal[1]
reveal_type(False.real) # revealed: Literal[0]
```

View file

@ -36,7 +36,9 @@ In particular, we should raise errors in the "possibly-undeclared-and-unbound" a
If a symbol has a declared type (`int`), we use that even if there is a more precise inferred type
(`Literal[1]`), or a conflicting inferred type (`str` vs. `Literal[2]` below):
```py path=mod.py
`mod.py`:
```py
from typing import Any
def any() -> Any: ...
@ -61,7 +63,9 @@ reveal_type(d) # revealed: int
If a symbol is declared and *possibly* unbound, we trust that other module and use the declared type
without raising an error.
```py path=mod.py
`mod.py`:
```py
from typing import Any
def any() -> Any: ...
@ -93,7 +97,9 @@ reveal_type(d) # revealed: int
Similarly, if a symbol is declared but unbound, we do not raise an error. We trust that this symbol
is available somehow and simply use the declared type.
```py path=mod.py
`mod.py`:
```py
from typing import Any
a: int
@ -114,7 +120,9 @@ reveal_type(b) # revealed: Any
If a symbol is possibly undeclared but definitely bound, we use the union of the declared and
inferred types:
```py path=mod.py
`mod.py`:
```py
from typing import Any
def any() -> Any: ...
@ -151,7 +159,9 @@ inferred types. This case is interesting because the "possibly declared" definit
same as the "possibly bound" definition (symbol `b`). Note that we raise a `possibly-unbound-import`
error for both `a` and `b`:
```py path=mod.py
`mod.py`:
```py
from typing import Any
def flag() -> bool: ...
@ -181,7 +191,9 @@ b = None
If a symbol is possibly undeclared and definitely unbound, we currently do not raise an error. This
seems inconsistent when compared to the case just above.
```py path=mod.py
`mod.py`:
```py
def flag() -> bool: ...
if flag():
@ -208,7 +220,9 @@ If a symbol is *undeclared*, we use the union of `Unknown` with the inferred typ
treat this case differently from the case where a symbol is implicitly declared with `Unknown`,
possibly due to the usage of an unknown name in the annotation:
```py path=mod.py
`mod.py`:
```py
# Undeclared:
a = 1
@ -231,7 +245,9 @@ a = None
If a symbol is undeclared and *possibly* unbound, we currently do not raise an error. This seems
inconsistent when compared to the "possibly-undeclared-and-possibly-unbound" case.
```py path=mod.py
`mod.py`:
```py
def flag() -> bool: ...
if flag:
@ -255,7 +271,9 @@ a = None
If a symbol is undeclared *and* unbound, we infer `Unknown` and raise an error.
```py path=mod.py
`mod.py`:
```py
if False:
a: int = 1
```

View file

@ -33,7 +33,9 @@ reveal_type(a >= b) # revealed: Literal[False]
Even when tuples have different lengths, comparisons should be handled appropriately.
```py path=different_length.py
`different_length.py`:
```py
a = (1, 2, 3)
b = (1, 2, 3, 4)
@ -102,7 +104,9 @@ reveal_type(a >= b) # revealed: bool
However, if the lexicographic comparison completes without reaching a point where str and int are
compared, Python will still produce a result based on the prior elements.
```py path=short_circuit.py
`short_circuit.py`:
```py
a = (1, 2)
b = (999999, "hello")

View file

@ -29,7 +29,9 @@ completing. The type of `x` at the beginning of the `except` suite in this examp
`x = could_raise_returns_str()` redefinition, but we *also* could have jumped to the `except` suite
*after* that redefinition.
```py path=union_type_inferred.py
`union_type_inferred.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -50,7 +52,9 @@ reveal_type(x) # revealed: str | Literal[2]
If `x` has the same type at the end of both branches, however, the branches unify and `x` is not
inferred as having a union type following the `try`/`except` block:
```py path=branches_unify_to_non_union_type.py
`branches_unify_to_non_union_type.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -133,7 +137,9 @@ the `except` suite:
- At the end of `else`, `x == 3`
- At the end of `except`, `x == 2`
```py path=single_except.py
`single_except.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -192,7 +198,9 @@ A `finally` suite is *always* executed. As such, if we reach the `reveal_type` c
this example, we know that `x` *must* have been reassigned to `2` during the `finally` suite. The
type of `x` at the end of the example is therefore `Literal[2]`:
```py path=redef_in_finally.py
`redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -217,7 +225,9 @@ at this point than there were when we were inside the `finally` block.
(Our current model does *not* correctly infer the types *inside* `finally` suites, however; this is
still a TODO item for us.)
```py path=no_redef_in_finally.py
`no_redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -249,7 +259,9 @@ suites:
exception raised in the `except` suite to cause us to jump to the `finally` suite before the
`except` suite ran to completion
```py path=redef_in_finally.py
`redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -286,7 +298,9 @@ itself. (In some control-flow possibilities, some exceptions were merely *suspen
`finally` suite; these lead to the scope's termination following the conclusion of the `finally`
suite.)
```py path=no_redef_in_finally.py
`no_redef_in_finally.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -317,7 +331,9 @@ reveal_type(x) # revealed: str | bool
An example with multiple `except` branches and a `finally` branch:
```py path=multiple_except_branches.py
`multiple_except_branches.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -364,7 +380,9 @@ If the exception handler has an `else` branch, we must also take into account th
control flow could have jumped to the `finally` suite from partway through the `else` suite due to
an exception raised *there*.
```py path=single_except_branch.py
`single_except_branch.py`:
```py
def could_raise_returns_str() -> str:
return "foo"
@ -407,7 +425,9 @@ reveal_type(x) # revealed: bool | float
The same again, this time with multiple `except` branches:
```py path=multiple_except_branches.py
`multiple_except_branches.py`:
```py
def could_raise_returns_str() -> str:
return "foo"

View file

@ -54,7 +54,9 @@ reveal_type("x" or "y" and "") # revealed: Literal["x"]
## Evaluates to builtin
```py path=a.py
`a.py`:
```py
redefined_builtin_bool: type[bool] = bool
def my_bool(x) -> bool:

View file

@ -51,7 +51,7 @@ In type stubs, classes can reference themselves in their base class definitions.
This should hold true even with generics at play.
```py path=a.pyi
```pyi
class Seq[T]: ...
# TODO not error on the subscripting

View file

@ -9,7 +9,9 @@ E = D
reveal_type(E) # revealed: Literal[C]
```
```py path=b.py
`b.py`:
```py
class C: ...
```
@ -22,7 +24,9 @@ D = b.C
reveal_type(D) # revealed: Literal[C]
```
```py path=b.py
`b.py`:
```py
class C: ...
```
@ -34,10 +38,14 @@ import a.b
reveal_type(a.b.C) # revealed: Literal[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
class C: ...
```
@ -49,13 +57,19 @@ import a.b.c
reveal_type(a.b.c.C) # revealed: Literal[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b/__init__.py
`a/b/__init__.py`:
```py
```
```py path=a/b/c.py
`a/b/c.py`:
```py
class C: ...
```
@ -67,10 +81,14 @@ import a.b as b
reveal_type(b.C) # revealed: Literal[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
class C: ...
```
@ -82,13 +100,19 @@ import a.b.c as c
reveal_type(c.C) # revealed: Literal[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b/__init__.py
`a/b/__init__.py`:
```py
```
```py path=a/b/c.py
`a/b/c.py`:
```py
class C: ...
```
@ -102,5 +126,7 @@ import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`"
import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`"
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```

View file

@ -29,13 +29,17 @@ builtins from the "actual" vendored typeshed:
typeshed = "/typeshed"
```
```pyi path=/typeshed/stdlib/builtins.pyi
`/typeshed/stdlib/builtins.pyi`:
```pyi
class Custom: ...
custom_builtin: Custom
```
```pyi path=/typeshed/stdlib/typing_extensions.pyi
`/typeshed/stdlib/typing_extensions.pyi`:
```pyi
def reveal_type(obj, /): ...
```
@ -56,12 +60,16 @@ that point:
typeshed = "/typeshed"
```
```pyi path=/typeshed/stdlib/builtins.pyi
`/typeshed/stdlib/builtins.pyi`:
```pyi
foo = bar
bar = 1
```
```pyi path=/typeshed/stdlib/typing_extensions.pyi
`/typeshed/stdlib/typing_extensions.pyi`:
```pyi
def reveal_type(obj, /): ...
```

View file

@ -2,7 +2,9 @@
## Maybe unbound
```py path=maybe_unbound.py
`maybe_unbound.py`:
```py
def coinflip() -> bool:
return True
@ -29,7 +31,9 @@ reveal_type(y) # revealed: Unknown | Literal[3]
## Maybe unbound annotated
```py path=maybe_unbound_annotated.py
`maybe_unbound_annotated.py`:
```py
def coinflip() -> bool:
return True
@ -60,7 +64,9 @@ reveal_type(y) # revealed: int
Importing a possibly undeclared name still gives us its declared type:
```py path=maybe_undeclared.py
`maybe_undeclared.py`:
```py
def coinflip() -> bool:
return True
@ -76,11 +82,15 @@ reveal_type(x) # revealed: int
## Reimport
```py path=c.py
`c.py`:
```py
def f(): ...
```
```py path=b.py
`b.py`:
```py
def coinflip() -> bool:
return True
@ -102,11 +112,15 @@ reveal_type(f) # revealed: Literal[f, f]
When we have a declared type in one path and only an inferred-from-definition type in the other, we
should still be able to unify those:
```py path=c.pyi
`c.pyi`:
```pyi
x: int
```
```py path=b.py
`b.py`:
```py
def coinflip() -> bool:
return True

View file

@ -8,11 +8,15 @@ import a.b
reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
`a/__init__.py`:
```py
b: int = 42
```
```py path=a/b.py
`a/b.py`:
```py
```
## Via from/import
@ -23,11 +27,15 @@ from a import b
reveal_type(b) # revealed: int
```
```py path=a/__init__.py
`a/__init__.py`:
```py
b: int = 42
```
```py path=a/b.py
`a/b.py`:
```py
```
## Via both
@ -40,11 +48,15 @@ reveal_type(b) # revealed: <module 'a.b'>
reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
`a/__init__.py`:
```py
b: int = 42
```
```py path=a/b.py
`a/b.py`:
```py
```
## Via both (backwards)
@ -65,11 +77,15 @@ reveal_type(b) # revealed: <module 'a.b'>
reveal_type(a.b) # revealed: <module 'a.b'>
```
```py path=a/__init__.py
`a/__init__.py`:
```py
b: int = 42
```
```py path=a/b.py
`a/b.py`:
```py
```
[from-import]: https://docs.python.org/3/reference/simple_stmts.html#the-import-statement

View file

@ -18,7 +18,9 @@ reveal_type(baz) # revealed: Unknown
## Unresolved import from resolved module
```py path=a.py
`a.py`:
```py
```
```py
@ -29,7 +31,9 @@ reveal_type(thing) # revealed: Unknown
## Resolved import of symbol from unresolved import
```py path=a.py
`a.py`:
```py
import foo as foo # error: "Cannot resolve import `foo`"
reveal_type(foo) # revealed: Unknown
@ -46,7 +50,9 @@ reveal_type(foo) # revealed: Unknown
## No implicit shadowing
```py path=b.py
`b.py`:
```py
x: int
```
@ -58,7 +64,9 @@ x = "foo" # error: [invalid-assignment] "Object of type `Literal["foo"]"
## Import cycle
```py path=a.py
`a.py`:
```py
class A: ...
reveal_type(A.__mro__) # revealed: tuple[Literal[A], Literal[object]]
@ -69,7 +77,9 @@ class C(b.B): ...
reveal_type(C.__mro__) # revealed: tuple[Literal[C], Literal[B], Literal[A], Literal[object]]
```
```py path=b.py
`b.py`:
```py
from a import A
class B(A): ...

View file

@ -23,9 +23,13 @@ reveal_type(b) # revealed: <module 'a.b'>
reveal_type(b.c) # revealed: int
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
c: int = 1
```

View file

@ -2,10 +2,14 @@
## Non-existent
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/bar.py
`package/bar.py`:
```py
from .foo import X # error: [unresolved-import]
reveal_type(X) # revealed: Unknown
@ -13,14 +17,20 @@ reveal_type(X) # revealed: Unknown
## Simple
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo.py
`package/foo.py`:
```py
X: int = 42
```
```py path=package/bar.py
`package/bar.py`:
```py
from .foo import X
reveal_type(X) # revealed: int
@ -28,14 +38,20 @@ reveal_type(X) # revealed: int
## Dotted
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo/bar/baz.py
`package/foo/bar/baz.py`:
```py
X: int = 42
```
```py path=package/bar.py
`package/bar.py`:
```py
from .foo.bar.baz import X
reveal_type(X) # revealed: int
@ -43,11 +59,15 @@ reveal_type(X) # revealed: int
## Bare to package
```py path=package/__init__.py
`package/__init__.py`:
```py
X: int = 42
```
```py path=package/bar.py
`package/bar.py`:
```py
from . import X
reveal_type(X) # revealed: int
@ -55,7 +75,9 @@ reveal_type(X) # revealed: int
## Non-existent + bare to package
```py path=package/bar.py
`package/bar.py`:
```py
from . import X # error: [unresolved-import]
reveal_type(X) # revealed: Unknown
@ -63,19 +85,25 @@ reveal_type(X) # revealed: Unknown
## Dunder init
```py path=package/__init__.py
`package/__init__.py`:
```py
from .foo import X
reveal_type(X) # revealed: int
```
```py path=package/foo.py
`package/foo.py`:
```py
X: int = 42
```
## Non-existent + dunder init
```py path=package/__init__.py
`package/__init__.py`:
```py
from .foo import X # error: [unresolved-import]
reveal_type(X) # revealed: Unknown
@ -83,14 +111,20 @@ reveal_type(X) # revealed: Unknown
## Long relative import
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo.py
`package/foo.py`:
```py
X: int = 42
```
```py path=package/subpackage/subsubpackage/bar.py
`package/subpackage/subsubpackage/bar.py`:
```py
from ...foo import X
reveal_type(X) # revealed: int
@ -98,14 +132,20 @@ reveal_type(X) # revealed: int
## Unbound symbol
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo.py
`package/foo.py`:
```py
x # error: [unresolved-reference]
```
```py path=package/bar.py
`package/bar.py`:
```py
from .foo import x # error: [unresolved-import]
reveal_type(x) # revealed: Unknown
@ -113,14 +153,20 @@ reveal_type(x) # revealed: Unknown
## Bare to module
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo.py
`package/foo.py`:
```py
X: int = 42
```
```py path=package/bar.py
`package/bar.py`:
```py
from . import foo
reveal_type(foo.X) # revealed: int
@ -131,10 +177,14 @@ reveal_type(foo.X) # revealed: int
This test verifies that we emit an error when we try to import a symbol that is neither a submodule
nor an attribute of `package`.
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/bar.py
`package/bar.py`:
```py
from . import foo # error: [unresolved-import]
reveal_type(foo) # revealed: Unknown
@ -148,14 +198,20 @@ submodule when that submodule name appears in the `imported_modules` set. That m
that are imported via `from...import` are not visible to our type inference if you also access that
submodule via the attribute on its parent package.
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/foo.py
`package/foo.py`:
```py
X: int = 42
```
```py path=package/bar.py
`package/bar.py`:
```py
from . import foo
import package

View file

@ -9,7 +9,9 @@ y = x
reveal_type(y) # revealed: int
```
```py path=b.pyi
`b.pyi`:
```pyi
x: int
```
@ -22,6 +24,8 @@ y = x
reveal_type(y) # revealed: int
```
```py path=b.py
`b.py`:
```py
x: int = 1
```

View file

@ -32,10 +32,14 @@ reveal_type(a.b.C) # revealed: Literal[C]
import a.b
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
class C: ...
```
@ -55,14 +59,20 @@ reveal_type(a.b) # revealed: <module 'a.b'>
reveal_type(a.b.C) # revealed: Literal[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
class C: ...
```
```py path=q.py
`q.py`:
```py
import a as a
import a.b as b
```
@ -83,18 +93,26 @@ reveal_type(sub.b) # revealed: <module 'sub.b'>
reveal_type(attr.b) # revealed: <module 'attr.b'>
```
```py path=sub/__init__.py
`sub/__init__.py`:
```py
b = 1
```
```py path=sub/b.py
`sub/b.py`:
```py
```
```py path=attr/__init__.py
`attr/__init__.py`:
```py
from . import b as _
b = 1
```
```py path=attr/b.py
`attr/b.py`:
```py
```

View file

@ -31,7 +31,9 @@ reveal_type(TC) # revealed: Literal[True]
Make sure we only use our special handling for `typing.TYPE_CHECKING` and not for other constants
with the same name:
```py path=constants.py
`constants.py`:
```py
TYPE_CHECKING: bool = False
```

View file

@ -19,13 +19,17 @@ typeshed = "/typeshed"
We can then place custom stub files in `/typeshed/stdlib`, for example:
```pyi path=/typeshed/stdlib/builtins.pyi
`/typeshed/stdlib/builtins.pyi`:
```pyi
class BuiltinClass: ...
builtin_symbol: BuiltinClass
```
```pyi path=/typeshed/stdlib/sys/__init__.pyi
`/typeshed/stdlib/sys/__init__.pyi`:
```pyi
version = "my custom Python"
```
@ -54,15 +58,21 @@ python-version = "3.10"
typeshed = "/typeshed"
```
```pyi path=/typeshed/stdlib/old_module.pyi
`/typeshed/stdlib/old_module.pyi`:
```pyi
class OldClass: ...
```
```pyi path=/typeshed/stdlib/new_module.pyi
`/typeshed/stdlib/new_module.pyi`:
```pyi
class NewClass: ...
```
```text path=/typeshed/stdlib/VERSIONS
`/typeshed/stdlib/VERSIONS`:
```text
old_module: 3.0-
new_module: 3.11-
```
@ -86,7 +96,9 @@ simple untyped definition is enough to make `reveal_type` work in tests:
typeshed = "/typeshed"
```
```pyi path=/typeshed/stdlib/typing_extensions.pyi
`/typeshed/stdlib/typing_extensions.pyi`:
```pyi
def reveal_type(obj, /): ...
```

View file

@ -205,7 +205,7 @@ reveal_type(D.__class__) # revealed: Literal[SignatureMismatch]
Retrieving the metaclass of a cyclically defined class should not cause an infinite loop.
```py path=a.pyi
```pyi
class A(B): ... # error: [cyclic-class-definition]
class B(C): ... # error: [cyclic-class-definition]
class C(A): ... # error: [cyclic-class-definition]

View file

@ -347,7 +347,7 @@ reveal_type(unknown_object.__mro__) # revealed: Unknown
These are invalid, but we need to be able to handle them gracefully without panicking.
```py path=a.pyi
```pyi
class Foo(Foo): ... # error: [cyclic-class-definition]
reveal_type(Foo) # revealed: Literal[Foo]
@ -365,7 +365,7 @@ reveal_type(Boz.__mro__) # revealed: tuple[Literal[Boz], Unknown, Literal[objec
These are similarly unlikely, but we still shouldn't crash:
```py path=a.pyi
```pyi
class Foo(Bar): ... # error: [cyclic-class-definition]
class Bar(Baz): ... # error: [cyclic-class-definition]
class Baz(Foo): ... # error: [cyclic-class-definition]
@ -377,7 +377,7 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec
## Classes with cycles in their MROs, and multiple inheritance
```py path=a.pyi
```pyi
class Spam: ...
class Foo(Bar): ... # error: [cyclic-class-definition]
class Bar(Baz): ... # error: [cyclic-class-definition]
@ -390,7 +390,7 @@ reveal_type(Baz.__mro__) # revealed: tuple[Literal[Baz], Unknown, Literal[objec
## Classes with cycles in their MRO, and a sub-graph
```py path=a.pyi
```pyi
class FooCycle(BarCycle): ... # error: [cyclic-class-definition]
class Foo: ...
class BarCycle(FooCycle): ... # error: [cyclic-class-definition]

View file

@ -2,12 +2,16 @@
Regression test for [this issue](https://github.com/astral-sh/ruff/issues/14334).
```py path=base.py
`base.py`:
```py
# error: [invalid-base]
class Base(2): ...
```
```py path=a.py
`a.py`:
```py
# No error here
from base import Base
```

View file

@ -29,7 +29,9 @@ def foo():
However, three attributes on `types.ModuleType` are not present as implicit module globals; these
are excluded:
```py path=unbound_dunders.py
`unbound_dunders.py`:
```py
# error: [unresolved-reference]
# revealed: Unknown
reveal_type(__getattr__)
@ -70,7 +72,9 @@ Typeshed includes a fake `__getattr__` method in the stub for `types.ModuleType`
dynamic imports; but we ignore that for module-literal types where we know exactly which module
we're dealing with:
```py path=__getattr__.py
`__getattr__.py`:
```py
import typing
# error: [unresolved-attribute]
@ -83,13 +87,17 @@ It's impossible to override the `__dict__` attribute of `types.ModuleType` insta
module; we should prioritise the attribute in the `types.ModuleType` stub over a variable named
`__dict__` in the module's global namespace:
```py path=foo.py
`foo.py`:
```py
__dict__ = "foo"
reveal_type(__dict__) # revealed: Literal["foo"]
```
```py path=bar.py
`bar.py`:
```py
import foo
from foo import __dict__ as foo_dict

View file

@ -5,14 +5,18 @@
Parameter `x` of type `str` is shadowed and reassigned with a new `int` value inside the function.
No diagnostics should be generated.
```py path=a.py
`a.py`:
```py
def f(x: str):
x: int = int(x)
```
## Implicit error
```py path=a.py
`a.py`:
```py
def f(): ...
f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explicit if this is intentional"
@ -20,7 +24,9 @@ f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explici
## Explicit shadowing
```py path=a.py
`a.py`:
```py
def f(): ...
f: int = 1

View file

@ -7,7 +7,9 @@ branches whose conditions we can statically determine to be always true or alway
useful for `sys.version_info` branches, which can make new features available based on the Python
version:
```py path=module1.py
`module1.py`:
```py
import sys
if sys.version_info >= (3, 9):
@ -17,7 +19,9 @@ if sys.version_info >= (3, 9):
If we can statically determine that the condition is always true, then we can also understand that
`SomeFeature` is always bound, without raising any errors:
```py path=test1.py
`test1.py`:
```py
from module1 import SomeFeature
# SomeFeature is unconditionally available here, because we are on Python 3.9 or newer:
@ -27,11 +31,15 @@ reveal_type(SomeFeature) # revealed: str
Another scenario where this is useful is for `typing.TYPE_CHECKING` branches, which are often used
for conditional imports:
```py path=module2.py
`module2.py`:
```py
class SomeType: ...
```
```py path=test2.py
`test2.py`:
```py
import typing
if typing.TYPE_CHECKING:
@ -167,7 +175,9 @@ statically known conditions, but here, we show that the results are truly based
not some special handling of specific conditions in semantic index building. We use two modules to
demonstrate this, since semantic index building is inherently single-module:
```py path=module.py
`module.py`:
```py
from typing import Literal
class AlwaysTrue:
@ -1426,7 +1436,9 @@ def f():
#### Always false, unbound
```py path=module.py
`module.py`:
```py
if False:
symbol = 1
```
@ -1438,7 +1450,9 @@ from module import symbol
#### Always true, bound
```py path=module.py
`module.py`:
```py
if True:
symbol = 1
```
@ -1450,7 +1464,9 @@ from module import symbol
#### Ambiguous, possibly unbound
```py path=module.py
`module.py`:
```py
def flag() -> bool:
return True
@ -1465,7 +1481,9 @@ from module import symbol
#### Always false, undeclared
```py path=module.py
`module.py`:
```py
if False:
symbol: int
```
@ -1479,7 +1497,9 @@ reveal_type(symbol) # revealed: Unknown
#### Always true, declared
```py path=module.py
`module.py`:
```py
if True:
symbol: int
```

View file

@ -5,7 +5,7 @@
In type stubs, classes can reference themselves in their base class definitions. For example, in
`typeshed`, we have `class str(Sequence[str]): ...`.
```py path=a.pyi
```pyi
class Foo[T]: ...
# TODO: actually is subscriptable

View file

@ -5,7 +5,7 @@
The ellipsis literal `...` can be used as a placeholder default value for a function parameter, in a
stub file only, regardless of the type of the parameter.
```py path=test.pyi
```pyi
def f(x: int = ...) -> None:
reveal_type(x) # revealed: int
@ -18,7 +18,7 @@ def f2(x: str = ...) -> None:
The ellipsis literal can be assigned to a class or module symbol, regardless of its declared type,
in a stub file only.
```py path=test.pyi
```pyi
y: bytes = ...
reveal_type(y) # revealed: bytes
x = ...
@ -35,7 +35,7 @@ reveal_type(Foo.y) # revealed: int
No diagnostic is emitted if an ellipsis literal is "unpacked" in a stub file as part of an
assignment statement:
```py path=test.pyi
```pyi
x, y = ...
reveal_type(x) # revealed: Unknown
reveal_type(y) # revealed: Unknown
@ -46,7 +46,7 @@ reveal_type(y) # revealed: Unknown
Iterating over an ellipsis literal as part of a `for` loop in a stub is invalid, however, and
results in a diagnostic:
```py path=test.pyi
```pyi
# error: [not-iterable] "Object of type `ellipsis` is not iterable"
for a, b in ...:
reveal_type(a) # revealed: Unknown
@ -72,7 +72,7 @@ reveal_type(b) # revealed: ellipsis
There is no special treatment of the builtin name `Ellipsis` in stubs, only of `...` literals.
```py path=test.pyi
```pyi
# error: 7 [invalid-parameter-default] "Default value of type `ellipsis` is not assignable to annotated parameter type `int`"
def f(x: int = Ellipsis) -> None: ...
```

View file

@ -37,7 +37,9 @@ child expression now suppresses errors in the outer expression.
For example, the `type: ignore` comment in this example suppresses the error of adding `2` to
`"test"` and adding `"other"` to the result of the cast.
```py path=nested.py
`nested.py`:
```py
# fmt: off
from typing import cast

View file

@ -86,14 +86,20 @@ reveal_type(bar >= (3, 9)) # revealed: Literal[True]
Only comparisons with the symbol `version_info` from the `sys` module produce literal types:
```py path=package/__init__.py
`package/__init__.py`:
```py
```
```py path=package/sys.py
`package/sys.py`:
```py
version_info: tuple[int, int] = (4, 2)
```
```py path=package/script.py
`package/script.py`:
```py
from .sys import version_info
reveal_type(version_info >= (3, 9)) # revealed: bool
@ -103,7 +109,9 @@ reveal_type(version_info >= (3, 9)) # revealed: bool
The fields of `sys.version_info` can be accessed by name:
```py path=a.py
`a.py`:
```py
import sys
reveal_type(sys.version_info.major >= 3) # revealed: Literal[True]
@ -114,7 +122,9 @@ reveal_type(sys.version_info.minor >= 10) # revealed: Literal[False]
But the `micro`, `releaselevel` and `serial` fields are inferred as `@Todo` until we support
properties on instance types:
```py path=b.py
`b.py`:
```py
import sys
reveal_type(sys.version_info.micro) # revealed: @Todo(@property)

View file

@ -39,7 +39,9 @@ def f(c: type[A]):
reveal_type(c) # revealed: type[A]
```
```py path=a.py
`a.py`:
```py
class A: ...
```
@ -52,23 +54,31 @@ def f(c: type[a.B]):
reveal_type(c) # revealed: type[B]
```
```py path=a.py
`a.py`:
```py
class B: ...
```
## Deeply qualified class literal from another module
```py path=a/test.py
`a/test.py`:
```py
import a.b
def f(c: type[a.b.C]):
reveal_type(c) # revealed: type[C]
```
```py path=a/__init__.py
`a/__init__.py`:
```py
```
```py path=a/b.py
`a/b.py`:
```py
class C: ...
```

View file

@ -28,7 +28,9 @@ reveal_type(not b) # revealed: Literal[False]
reveal_type(not warnings) # revealed: Literal[False]
```
```py path=b.py
`b.py`:
```py
y = 1
```

View file

@ -20,10 +20,10 @@ reveal_type(1) # revealed: Literal[1]
````
When running this test, the mdtest framework will write a file with these contents to the default
file path (`/src/test.py`) in its in-memory file system, run a type check on that file, and then
match the resulting diagnostics with the assertions in the test. Assertions are in the form of
Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise, it
fails.
file path (`/src/mdtest_snippet__1.py`) in its in-memory file system, run a type check on that file,
and then match the resulting diagnostics with the assertions in the test. Assertions are in the form
of Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise,
it fails.
<!---
(If you are reading this document in raw Markdown source rather than rendered Markdown, note that
@ -129,8 +129,12 @@ assertion as the line of source code on which the matched diagnostics are emitte
## Multi-file tests
Some tests require multiple files, with imports from one file into another. Multiple fenced code
blocks represent multiple embedded files. Since files must have unique names, at most one file can
use the default name of `/src/test.py`. Other files must explicitly specify their file name:
blocks represent multiple embedded files. If there are multiple unnamed files, mdtest will name them
according to the numbered scheme `/src/mdtest_snippet__1.py`, `/src/mdtest_snippet__2.py`, etc. (If
they are `pyi` files, they will be named with a `pyi` extension instead.)
Tests should not rely on these default names. If a test must import from a file, then it should
explicitly specify the file name:
````markdown
```py
@ -138,7 +142,9 @@ from b import C
reveal_type(C) # revealed: Literal[C]
```
```py path=b.py
`b.py`:
```py
class C: pass
```
````
@ -149,8 +155,8 @@ is, the equivalent of a runtime entry on `sys.path`).
The default workspace root is `/src/`. Currently it is not possible to customize this in a test, but
this is a feature we will want to add in the future.
So the above test creates two files, `/src/test.py` and `/src/b.py`, and sets the workspace root to
`/src/`, allowing `test.py` to import from `b.py` using the module name `b`.
So the above test creates two files, `/src/mdtest_snippet__1.py` and `/src/b.py`, and sets the
workspace root to `/src/`, allowing imports from `b.py` using the module name `b`.
## Multi-test suites
@ -171,7 +177,9 @@ from b import y
x: int = y # error: [invalid-assignment]
```
```py path=b.py
`b.py`:
```py
y = "foo"
```
````
@ -357,17 +365,17 @@ This is just an example, not a proposal that red-knot would ever actually output
precisely this format:
```output
test.py, line 1, col 1: revealed type is 'Literal[1]'
mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]'
```
````
We will want to build tooling to automatically capture and update these “full diagnostic output”
blocks, when tests are run in an update-output mode (probably specified by an environment variable.)
By default, an `output` block will specify diagnostic output for the file `<workspace-root>/test.py`.
An `output` block can have a `path=` option, to explicitly specify the Python file for which it
asserts diagnostic output, and a `stage=` option, to specify which stage of an incremental test it
specifies diagnostic output at. (See “incremental tests” below.)
By default, an `output` block will specify diagnostic output for the file
`<workspace-root>/mdtest_snippet__1.py`. An `output` block can be prefixed by a
<code>`&lt;path>`:</code> label as usual, to explicitly specify the Python file for which it asserts
diagnostic output.
It is an error for an `output` block to exist, if there is no `py` or `python` block in the same
test for the same file path.
@ -385,39 +393,43 @@ fenced code blocks in the test:
## modify a file
Initial version of `test.py` and `b.py`:
Initial file contents:
```py
from b import x
reveal_type(x)
```
```py path=b.py
`b.py`:
```py
x = 1
```
Initial expected output for `test.py`:
Initial expected output for the unnamed file:
```output
/src/test.py, line 1, col 1: revealed type is 'Literal[1]'
/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]'
```
Now in our first incremental stage, modify the contents of `b.py`:
```py path=b.py stage=1
`b.py`:
```py stage=1
# b.py
x = 2
```
And this is our updated expected output for `test.py` at stage 1:
And this is our updated expected output for the unnamed file at stage 1:
```output stage=1
/src/test.py, line 1, col 1: revealed type is 'Literal[2]'
/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[2]'
```
(One reason to use full-diagnostic-output blocks in this test is that updating
inline-comment diagnostic assertions for `test.py` would require specifying new
contents for `test.py` in stage 1, which we don't want to do in this test.)
(One reason to use full-diagnostic-output blocks in this test is that updating inline-comment
diagnostic assertions for `mdtest_snippet__1.py` would require specifying new contents for
`mdtest_snippet__1.py` in stage 1, which we don't want to do in this test.)
````
It will be possible to provide any number of stages in an incremental test. If a stage re-specifies

View file

@ -109,9 +109,9 @@ fn run_test(db: &mut db::Db, test: &parser::MarkdownTest) -> Result<(), Failures
);
let full_path = if embedded.path.starts_with('/') {
SystemPathBuf::from(embedded.path)
SystemPathBuf::from(embedded.path.clone())
} else {
project_root.join(embedded.path)
project_root.join(&embedded.path)
};
if let Some(ref typeshed_path) = custom_typeshed_path {

View file

@ -3,7 +3,7 @@ use std::sync::LazyLock;
use anyhow::bail;
use memchr::memchr2;
use regex::{Captures, Match, Regex};
use rustc_hash::{FxHashMap, FxHashSet};
use rustc_hash::FxHashSet;
use ruff_index::{newtype_index, IndexVec};
use ruff_python_trivia::Cursor;
@ -147,7 +147,7 @@ struct EmbeddedFileId;
#[derive(Debug)]
pub(crate) struct EmbeddedFile<'s> {
section: SectionId,
pub(crate) path: &'s str,
pub(crate) path: String,
pub(crate) lang: &'s str,
pub(crate) code: &'s str,
@ -157,16 +157,20 @@ pub(crate) struct EmbeddedFile<'s> {
/// Matches a sequence of `#` characters, followed by a title heading, followed by a newline.
static HEADER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?<level>#+)\s+(?<title>.+)\s*\n").unwrap());
LazyLock::new(|| Regex::new(r"^(?<level>#+)[^\S\n]+(?<title>.+)[^\S\n]*\n").unwrap());
/// Matches a code block fenced by triple backticks, possibly with language and `key=val`
/// configuration items following the opening backticks (in the "tag string" of the code block).
static CODE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
^```(?<lang>(?-u:\w)+)?(?<config>(?:\x20+\S+)*)\s*\n
(?<code>(?:.|\n)*?)\n?
(?<end>```|\z)
^(?:
`(?<path>[^`\n]+)`[^\S\n]*:[^\S\n]*\n
\n?
)?
```(?<lang>(?-u:\w)+)?\x20*(?<config>\S.*)?\n
(?<code>[\s\S]*?)\n?
(?<end>```\n?|\z)
",
)
.unwrap()
@ -210,6 +214,7 @@ struct Parser<'s> {
/// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`].
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
unnamed_file_count: usize,
/// The unparsed remainder of the Markdown source.
cursor: Cursor<'s>,
@ -221,7 +226,7 @@ struct Parser<'s> {
stack: SectionStack,
/// Names of embedded files in current active section.
current_section_files: Option<FxHashSet<&'s str>>,
current_section_files: Option<FxHashSet<String>>,
/// Whether or not the current section has a config block.
current_section_has_config: bool,
@ -240,6 +245,7 @@ impl<'s> Parser<'s> {
sections,
source,
files: IndexVec::default(),
unnamed_file_count: 0,
cursor: Cursor::new(source),
source_len: source.text_len(),
stack: SectionStack::new(root_section_id),
@ -265,10 +271,14 @@ impl<'s> Parser<'s> {
fn parse_impl(&mut self) -> anyhow::Result<()> {
while let Some(position) = memchr2(b'`', b'#', self.cursor.as_bytes()) {
self.cursor.skip_bytes(position.saturating_sub(1));
self.cursor.skip_bytes(position.saturating_sub(2));
// code blocks and headers must start on a new line.
if position == 0 || self.cursor.eat_char('\n') {
// Code blocks and headers must start on a new line
// and preceded by at least one blank line.
if position == 0 && self.cursor.first() == '#'
|| position == 1 && self.cursor.eat_char('\n')
|| self.cursor.eat_char('\n') && self.cursor.eat_char('\n')
{
match self.cursor.first() {
'#' => {
if let Some(find) = HEADER_RE.find(self.cursor.as_str()) {
@ -290,7 +300,7 @@ impl<'s> Parser<'s> {
// Skip to the end of the line
if let Some(position) = memchr::memchr(b'\n', self.cursor.as_bytes()) {
self.cursor.skip_bytes(position);
self.cursor.skip_bytes(position + 1);
} else {
break;
}
@ -349,26 +359,10 @@ impl<'s> Parser<'s> {
return Err(anyhow::anyhow!("Unterminated code block at line {line}."));
}
let mut config: FxHashMap<&'s str, &'s str> = FxHashMap::default();
if let Some(config_match) = captures.name("config") {
for item in config_match.as_str().split_whitespace() {
let mut parts = item.split('=');
let key = parts.next().unwrap();
let Some(val) = parts.next() else {
return Err(anyhow::anyhow!("Invalid config item `{}`.", item));
};
if parts.next().is_some() {
return Err(anyhow::anyhow!("Invalid config item `{}`.", item));
}
if config.insert(key, val).is_some() {
return Err(anyhow::anyhow!("Duplicate config item `{}`.", item));
}
}
if captures.name("config").is_some() {
return Err(anyhow::anyhow!("Trailing code-block metadata is not supported. Only the code block language can be specified."));
}
let path = config.get("path").copied().unwrap_or("test.py");
// CODE_RE can't match without matches for 'lang' and 'code'.
let lang = captures
.name("lang")
@ -381,26 +375,48 @@ impl<'s> Parser<'s> {
return self.parse_config(code);
}
let explicit_path = captures.name("path").map(|it| it.as_str());
if let Some(explicit_path) = explicit_path {
if !lang.is_empty()
&& lang != "text"
&& explicit_path.contains('.')
&& !explicit_path.ends_with(&format!(".{lang}"))
{
return Err(anyhow::anyhow!(
"File ending of test file path `{explicit_path}` does not match `lang={lang}` of code block"
));
}
}
let path = match explicit_path {
Some(path) => path.to_string(),
None => {
self.unnamed_file_count += 1;
match lang {
"py" | "pyi" => format!("mdtest_snippet__{}.{lang}", self.unnamed_file_count),
"" => format!("mdtest_snippet__{}.py", self.unnamed_file_count),
_ => {
return Err(anyhow::anyhow!(
"Cannot generate name for `lang={}`: Unsupported extension",
lang
))
}
}
}
};
self.files.push(EmbeddedFile {
path,
path: path.clone(),
section,
lang,
code,
md_offset: self.offset(),
});
if let Some(current_files) = &mut self.current_section_files {
if !current_files.insert(path) {
if path == "test.py" {
return Err(anyhow::anyhow!(
"Test `{}` has duplicate files named `{path}`. \
(This is the default filename; \
consider giving some files an explicit name with `path=...`.)",
self.sections[section].title
));
}
if !current_files.insert(path.clone()) {
return Err(anyhow::anyhow!(
"Test `{}` has duplicate files named `{path}`.",
self.sections[section].title
@ -473,7 +489,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
@ -498,7 +514,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
}
@ -518,22 +534,33 @@ mod tests {
```py
y = 2
```
# Three
```pyi
a: int
```
```pyi
b: str
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test1, test2] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected two tests");
let [test1, test2, test3] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected three tests");
};
assert_eq!(test1.name(), "file.md - One");
assert_eq!(test2.name(), "file.md - Two");
assert_eq!(test3.name(), "file.md - Three");
let [file] = test1.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "x = 1");
@ -541,9 +568,21 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.path, "mdtest_snippet__2.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "y = 2");
let [file_1, file_2] = test3.files().collect::<Vec<_>>()[..] else {
panic!("expected two files");
};
assert_eq!(file_1.path, "mdtest_snippet__3.pyi");
assert_eq!(file_1.lang, "pyi");
assert_eq!(file_1.code, "a: int");
assert_eq!(file_2.path, "mdtest_snippet__4.pyi");
assert_eq!(file_2.lang, "pyi");
assert_eq!(file_2.code, "b: str");
}
#[test]
@ -552,11 +591,15 @@ mod tests {
"
# One
```py path=main.py
`main.py`:
```py
from foo import y
```
```py path=foo.py
`foo.py`:
```py
y = 2
```
@ -592,7 +635,7 @@ mod tests {
panic!("expected one file");
};
assert_eq!(file.path, "test.py");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.lang, "py");
assert_eq!(file.code, "y = 2");
}
@ -601,7 +644,9 @@ mod tests {
fn custom_file_path() {
let source = dedent(
"
```py path=foo.py
`foo.py`:
```py
x = 1
```
",
@ -685,6 +730,90 @@ mod tests {
assert_eq!(file.code, "x = 10");
}
#[test]
fn cannot_generate_name_for_lang() {
let source = dedent(
"
```json
{}
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Cannot generate name for `lang=json`: Unsupported extension"
);
}
#[test]
fn mismatching_lang() {
let source = dedent(
"
`a.py`:
```pyi
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"File ending of test file path `a.py` does not match `lang=pyi` of code block"
);
}
#[test]
fn files_with_no_extension_can_have_any_lang() {
let source = dedent(
"
`lorem`:
```foo
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "lorem");
assert_eq!(file.code, "x = 1");
}
#[test]
fn files_with_lang_text_can_have_any_paths() {
let source = dedent(
"
`lorem.yaml`:
```text
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "lorem.yaml");
assert_eq!(file.code, "x = 1");
}
#[test]
fn unterminated_code_block_1() {
let source = dedent(
@ -738,83 +867,319 @@ mod tests {
}
#[test]
fn invalid_config_item_no_equals() {
fn line_break_in_header_1() {
let source = dedent(
"
```py foo
#
Foo
```py
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Invalid config item `foo`.");
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(test.section.title, "file.md");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn invalid_config_item_too_many_equals() {
fn line_break_in_header_2() {
let source = dedent(
"
```py foo=bar=baz
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Invalid config item `foo=bar=baz`.");
}
# Foo
#[test]
fn invalid_config_item_duplicate() {
let source = dedent(
"
```py foo=bar foo=baz
##
Lorem
```py
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Duplicate config item `foo=baz`.");
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(test.section.title, "Foo");
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn no_duplicate_name_files_in_test() {
let source = dedent(
"
`foo.py`:
```py
x = 1
```
`foo.py`:
```py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `test.py`. \
(This is the default filename; consider giving some files an explicit name \
with `path=...`.)"
);
}
#[test]
fn no_duplicate_name_files_in_test_non_default() {
let source = dedent(
"
```py path=foo.py
x = 1
```
```py path=foo.py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `foo.py`."
);
}
#[test]
fn no_duplicate_name_files_in_test_2() {
let source = dedent(
"
`mdtest_snippet__1.py`:
```py
x = 1
```
```py
y = 2
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `mdtest_snippet__1.py`."
);
}
#[test]
fn separate_path() {
let source = dedent(
"
`foo.py`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn separate_path_whitespace_1() {
let source = dedent(
"
`foo.py` :
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn separate_path_whitespace_2() {
let source = dedent(
"
`foo.py`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "foo.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn path_with_space() {
let source = dedent(
"
`foo bar.py`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "foo bar.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn path_with_line_break() {
let source = dedent(
"
`foo
.py`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn path_with_backtick() {
let source = dedent(
"
`foo`bar.py`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn path_colon_on_next_line() {
let source = dedent(
"
`foo.py`
:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn random_trailing_backtick_quoted() {
let source = dedent(
"
A long sentence that forces a line break
`int`:
```py
x = 1
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
};
let [file] = test.files().collect::<Vec<_>>()[..] else {
panic!("expected one file");
};
assert_eq!(file.path, "mdtest_snippet__1.py");
assert_eq!(file.code, "x = 1");
}
#[test]
fn config_no_longer_allowed() {
let source = dedent(
"
```py foo=bar
x = 1
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Trailing code-block metadata is not supported. Only the code block language can be specified.");
}
}