From d296f602e748f8c8c2af293b500f8a3a29fa3686 Mon Sep 17 00:00:00 2001 From: David Peter Date: Wed, 5 Feb 2025 22:26:15 +0100 Subject: [PATCH] [red-knot] Merge Markdown code blocks inside a single section (#15950) ## Summary Allow for literate style in Markdown tests and merge multiple (unnamed) code blocks into a single embedded file. closes #15941 ## Test Plan - Interactively made sure that error-lines were reported correctly in multi-snippet sections. --- Cargo.lock | 1 + .../resources/mdtest/attributes.md | 14 - .../resources/mdtest/comparison/tuples.md | 4 - .../mdtest/exception/control_flow.md | 62 -- .../mdtest/scopes/moduletype_attrs.md | 6 - .../resources/mdtest/shadowing/function.md | 6 - ...ructures_-_Unresolvable_module_import.snap | 4 +- ...ures_-_Unresolvable_submodule_imports.snap | 6 +- ...vable_import_that_does_not_use_`from`.snap | 4 +- ...solvable_module_but_unresolvable_item.snap | 4 +- ...`from`_with_an_unknown_current_module.snap | 4 +- ..._`from`_with_an_unknown_nested_module.snap | 4 +- ...ng_`from`_with_an_unresolvable_module.snap | 4 +- .../mdtest/suppressions/type_ignore.md | 2 - .../resources/mdtest/sys_version_info.md | 6 - .../resources/mdtest/type_api.md | 17 +- .../tuples_containing_never.md | 10 +- crates/red_knot_test/Cargo.toml | 1 + crates/red_knot_test/README.md | 52 +- crates/red_knot_test/src/lib.rs | 38 +- crates/red_knot_test/src/parser.rs | 550 +++++++++++++++--- crates/ruff_python_ast/src/lib.rs | 2 +- 22 files changed, 550 insertions(+), 251 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f7463a7c6..f547718ba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2525,6 +2525,7 @@ dependencies = [ "regex", "ruff_db", "ruff_index", + "ruff_python_ast", "ruff_python_trivia", "ruff_source_file", "ruff_text_size", diff --git a/crates/red_knot_python_semantic/resources/mdtest/attributes.md b/crates/red_knot_python_semantic/resources/mdtest/attributes.md index 9d0bf3bf7a..868c86b807 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/attributes.md +++ b/crates/red_knot_python_semantic/resources/mdtest/attributes.md @@ -849,8 +849,6 @@ 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: -`a.py`: - ```py def f(): ... @@ -860,11 +858,7 @@ reveal_type(f.__kwdefaults__) # revealed: @Todo(generics) | None Some attributes are special-cased, however: -`b.py`: - ```py -def f(): ... - reveal_type(f.__get__) # revealed: @Todo(`__get__` method on functions) reveal_type(f.__call__) # revealed: @Todo(`__call__` method on functions) ``` @@ -874,8 +868,6 @@ 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: -`a.py`: - ```py reveal_type((2).bit_length) # revealed: @Todo(bound method) reveal_type((2).denominator) # revealed: @Todo(@property) @@ -883,8 +875,6 @@ reveal_type((2).denominator) # revealed: @Todo(@property) Some attributes are special-cased, however: -`b.py`: - ```py reveal_type((2).numerator) # revealed: Literal[2] reveal_type((2).real) # revealed: Literal[2] @@ -895,8 +885,6 @@ 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: -`a.py`: - ```py reveal_type(True.__and__) # revealed: @Todo(bound method) reveal_type(False.__or__) # revealed: @Todo(bound method) @@ -904,8 +892,6 @@ reveal_type(False.__or__) # revealed: @Todo(bound method) Some attributes are special-cased, however: -`b.py`: - ```py reveal_type(True.numerator) # revealed: Literal[1] reveal_type(False.real) # revealed: Literal[0] diff --git a/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md b/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md index 805fb39e3a..db0a9fa098 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md +++ b/crates/red_knot_python_semantic/resources/mdtest/comparison/tuples.md @@ -33,8 +33,6 @@ reveal_type(a >= b) # revealed: Literal[False] Even when tuples have different lengths, comparisons should be handled appropriately. -`different_length.py`: - ```py a = (1, 2, 3) b = (1, 2, 3, 4) @@ -104,8 +102,6 @@ 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. -`short_circuit.py`: - ```py a = (1, 2) b = (999999, "hello") diff --git a/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md b/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md index ab49dda658..74f8c2ebd8 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md +++ b/crates/red_knot_python_semantic/resources/mdtest/exception/control_flow.md @@ -29,8 +29,6 @@ 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. -`union_type_inferred.py`: - ```py def could_raise_returns_str() -> str: return "foo" @@ -52,12 +50,7 @@ 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: -`branches_unify_to_non_union_type.py`: - ```py -def could_raise_returns_str() -> str: - return "foo" - x = 1 try: @@ -137,8 +130,6 @@ the `except` suite: - At the end of `else`, `x == 3` - At the end of `except`, `x == 2` -`single_except.py`: - ```py def could_raise_returns_str() -> str: return "foo" @@ -167,9 +158,6 @@ been executed in its entirety, or the `try` suite and the `else` suite must both in their entireties: ```py -def could_raise_returns_str() -> str: - return "foo" - x = 1 try: @@ -198,8 +186,6 @@ 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]`: -`redef_in_finally.py`: - ```py def could_raise_returns_str() -> str: return "foo" @@ -225,12 +211,7 @@ 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.) -`no_redef_in_finally.py`: - ```py -def could_raise_returns_str() -> str: - return "foo" - x = 1 try: @@ -259,8 +240,6 @@ suites: exception raised in the `except` suite to cause us to jump to the `finally` suite before the `except` suite ran to completion -`redef_in_finally.py`: - ```py def could_raise_returns_str() -> str: return "foo" @@ -298,18 +277,7 @@ 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.) -`no_redef_in_finally.py`: - ```py -def could_raise_returns_str() -> str: - return "foo" - -def could_raise_returns_bytes() -> bytes: - return b"foo" - -def could_raise_returns_bool() -> bool: - return True - x = 1 try: @@ -331,18 +299,7 @@ reveal_type(x) # revealed: str | bool An example with multiple `except` branches and a `finally` branch: -`multiple_except_branches.py`: - ```py -def could_raise_returns_str() -> str: - return "foo" - -def could_raise_returns_bytes() -> bytes: - return b"foo" - -def could_raise_returns_bool() -> bool: - return True - def could_raise_returns_memoryview() -> memoryview: return memoryview(b"") @@ -380,8 +337,6 @@ 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*. -`single_except_branch.py`: - ```py def could_raise_returns_str() -> str: return "foo" @@ -425,24 +380,7 @@ reveal_type(x) # revealed: bool | float The same again, this time with multiple `except` branches: -`multiple_except_branches.py`: - ```py -def could_raise_returns_str() -> str: - return "foo" - -def could_raise_returns_bytes() -> bytes: - return b"foo" - -def could_raise_returns_bool() -> bool: - return True - -def could_raise_returns_memoryview() -> memoryview: - return memoryview(b"") - -def could_raise_returns_float() -> float: - return 3.14 - def could_raise_returns_range() -> range: return range(42) diff --git a/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md b/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md index 0bdef5ecb2..b2082bc1dd 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md +++ b/crates/red_knot_python_semantic/resources/mdtest/scopes/moduletype_attrs.md @@ -29,8 +29,6 @@ def foo(): However, three attributes on `types.ModuleType` are not present as implicit module globals; these are excluded: -`unbound_dunders.py`: - ```py # error: [unresolved-reference] # revealed: Unknown @@ -72,11 +70,7 @@ 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: -`__getattr__.py`: - ```py -import typing - # error: [unresolved-attribute] reveal_type(typing.__getattr__) # revealed: Unknown ``` diff --git a/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md b/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md index 806d929c84..1a2b7cbcb5 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md +++ b/crates/red_knot_python_semantic/resources/mdtest/shadowing/function.md @@ -5,8 +5,6 @@ Parameter `x` of type `str` is shadowed and reassigned with a new `int` value inside the function. No diagnostics should be generated. -`a.py`: - ```py def f(x: str): x: int = int(x) @@ -14,8 +12,6 @@ def f(x: str): ## Implicit error -`a.py`: - ```py def f(): ... @@ -24,8 +20,6 @@ f = 1 # error: "Implicit shadowing of function `f`; annotate to make it explici ## Explicit shadowing -`a.py`: - ```py def f(): ... diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_module_import.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_module_import.snap index c363925a97..47bb7fae45 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_module_import.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_module_import.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`" @@ -19,7 +19,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:8 + --> /src/mdtest_snippet.py:1:8 | 1 | import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`" | ^^^^^^^^^^^^^^ Cannot resolve import `zqzqzqzqzqzqzq` diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_submodule_imports.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_submodule_imports.snap index ee1454a3af..bbcb0e3f40 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_submodule_imports.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/basic.md_-_Structures_-_Unresolvable_submodule_imports.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | # Topmost component resolvable, submodule not resolvable: @@ -28,7 +28,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/import/basic.md ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:2:8 + --> /src/mdtest_snippet.py:2:8 | 1 | # Topmost component resolvable, submodule not resolvable: 2 | import a.foo # error: [unresolved-import] "Cannot resolve import `a.foo`" @@ -41,7 +41,7 @@ error: lint:unresolved-import ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:5:8 + --> /src/mdtest_snippet.py:5:8 | 4 | # Topmost component unresolvable: 5 | import b.foo # error: [unresolved-import] "Cannot resolve import `b.foo`" diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_An_unresolvable_import_that_does_not_use_`from`.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_An_unresolvable_import_that_does_not_use_`from`.snap index e975741103..096616ac07 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_An_unresolvable_import_that_does_not_use_`from`.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_An_unresolvable_import_that_does_not_use_`from`.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | import does_not_exist # error: [unresolved-import] @@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:8 + --> /src/mdtest_snippet.py:1:8 | 1 | import does_not_exist # error: [unresolved-import] | ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist` diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_a_resolvable_module_but_unresolvable_item.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_a_resolvable_module_but_unresolvable_item.snap index 2764c497f5..f297f87e8a 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_a_resolvable_module_but_unresolvable_item.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_a_resolvable_module_but_unresolvable_item.snap @@ -16,7 +16,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso 2 | does_exist2 = 2 ``` -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import] @@ -26,7 +26,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:28 + --> /src/mdtest_snippet.py:1:28 | 1 | from a import does_exist1, does_not_exist, does_exist2 # error: [unresolved-import] | ^^^^^^^^^^^^^^ Module `a` has no member `does_not_exist` diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_current_module.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_current_module.snap index 86ee2858eb..88bdb9d791 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_current_module.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_current_module.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | from .does_not_exist import add # error: [unresolved-import] @@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:7 + --> /src/mdtest_snippet.py:1:7 | 1 | from .does_not_exist import add # error: [unresolved-import] | ^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist` diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_nested_module.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_nested_module.snap index 2d5befaad1..5a0c60321e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_nested_module.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unknown_nested_module.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | from .does_not_exist.foo.bar import add # error: [unresolved-import] @@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:7 + --> /src/mdtest_snippet.py:1:7 | 1 | from .does_not_exist.foo.bar import add # error: [unresolved-import] | ^^^^^^^^^^^^^^^^^^^^^^ Cannot resolve import `.does_not_exist.foo.bar` diff --git a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unresolvable_module.snap b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unresolvable_module.snap index cd7a41b2af..e7b2303977 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unresolvable_module.snap +++ b/crates/red_knot_python_semantic/resources/mdtest/snapshots/unresolved_import.md_-_Unresolved_import_diagnostics_-_Using_`from`_with_an_unresolvable_module.snap @@ -9,7 +9,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso # Python source files -## mdtest_snippet__1.py +## mdtest_snippet.py ``` 1 | from does_not_exist import add # error: [unresolved-import] @@ -21,7 +21,7 @@ mdtest path: crates/red_knot_python_semantic/resources/mdtest/diagnostics/unreso ``` error: lint:unresolved-import - --> /src/mdtest_snippet__1.py:1:6 + --> /src/mdtest_snippet.py:1:6 | 1 | from does_not_exist import add # error: [unresolved-import] | ^^^^^^^^^^^^^^ Cannot resolve import `does_not_exist` diff --git a/crates/red_knot_python_semantic/resources/mdtest/suppressions/type_ignore.md b/crates/red_knot_python_semantic/resources/mdtest/suppressions/type_ignore.md index 14d20460fc..2d4b1ff3ca 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/suppressions/type_ignore.md +++ b/crates/red_knot_python_semantic/resources/mdtest/suppressions/type_ignore.md @@ -37,8 +37,6 @@ 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. -`nested.py`: - ```py # fmt: off from typing import cast diff --git a/crates/red_knot_python_semantic/resources/mdtest/sys_version_info.md b/crates/red_knot_python_semantic/resources/mdtest/sys_version_info.md index 64c4e6ccf7..d0144b7c1e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/sys_version_info.md +++ b/crates/red_knot_python_semantic/resources/mdtest/sys_version_info.md @@ -109,8 +109,6 @@ reveal_type(version_info >= (3, 9)) # revealed: bool The fields of `sys.version_info` can be accessed by name: -`a.py`: - ```py import sys @@ -122,11 +120,7 @@ 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: -`b.py`: - ```py -import sys - reveal_type(sys.version_info.micro) # revealed: @Todo(@property) reveal_type(sys.version_info.releaselevel) # revealed: @Todo(@property) reveal_type(sys.version_info.serial) # revealed: @Todo(@property) diff --git a/crates/red_knot_python_semantic/resources/mdtest/type_api.md b/crates/red_knot_python_semantic/resources/mdtest/type_api.md index 9f03e16c0c..3ae113cf2e 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/type_api.md +++ b/crates/red_knot_python_semantic/resources/mdtest/type_api.md @@ -84,8 +84,11 @@ def explicit_unknown(x: Unknown, y: tuple[str, Unknown], z: Unknown = 1) -> None reveal_type(x) # revealed: Unknown reveal_type(y) # revealed: tuple[str, Unknown] reveal_type(z) # revealed: Unknown | Literal[1] +``` -# Unknown can be subclassed, just like Any +`Unknown` can be subclassed, just like `Any`: + +```py class C(Unknown): ... # revealed: tuple[Literal[C], Unknown, Literal[object]] @@ -238,9 +241,12 @@ error_message = "A custom message " error_message += "constructed from multiple string literals" # error: "Static assertion error: A custom message constructed from multiple string literals" static_assert(False, error_message) +``` -# There are limitations to what we can still infer as a string literal. In those cases, -# we simply fall back to the default message. +There are limitations to what we can still infer as a string literal. In those cases, we simply fall +back to the default message: + +```py shouted_message = "A custom message".upper() # error: "Static assertion error: argument evaluates to `False`" static_assert(False, shouted_message) @@ -371,8 +377,11 @@ static_assert(is_subtype_of(TypeOf[str], type[str])) class Base: ... class Derived(Base): ... +``` -# `TypeOf` can be used in annotations: +`TypeOf` can also be used in annotations: + +```py def type_of_annotation() -> None: t1: TypeOf[Base] = Base t2: TypeOf[Base] = Derived # error: [invalid-assignment] diff --git a/crates/red_knot_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md b/crates/red_knot_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md index 027d4943ec..13c36f0c38 100644 --- a/crates/red_knot_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md +++ b/crates/red_knot_python_semantic/resources/mdtest/type_properties/tuples_containing_never.md @@ -19,11 +19,17 @@ static_assert(is_equivalent_to(Never, tuple[int, Never])) static_assert(is_equivalent_to(Never, tuple[int, Never, str])) static_assert(is_equivalent_to(Never, tuple[int, tuple[str, Never]])) static_assert(is_equivalent_to(Never, tuple[tuple[str, Never], int])) +``` -# The empty tuple is *not* equivalent to Never! +The empty `tuple` is *not* equivalent to `Never`! + +```py static_assert(not is_equivalent_to(Never, tuple[()])) +``` -# NoReturn is just a different spelling of Never, so the same is true for NoReturn +`NoReturn` is just a different spelling of `Never`, so the same is true for `NoReturn`: + +```py static_assert(is_equivalent_to(NoReturn, tuple[NoReturn])) static_assert(is_equivalent_to(NoReturn, tuple[NoReturn, int])) static_assert(is_equivalent_to(NoReturn, tuple[int, NoReturn])) diff --git a/crates/red_knot_test/Cargo.toml b/crates/red_knot_test/Cargo.toml index b18c09c178..3736235809 100644 --- a/crates/red_knot_test/Cargo.toml +++ b/crates/red_knot_test/Cargo.toml @@ -18,6 +18,7 @@ ruff_index = { workspace = true } ruff_python_trivia = { workspace = true } ruff_source_file = { workspace = true } ruff_text_size = { workspace = true } +ruff_python_ast = { workspace = true } anyhow = { workspace = true } camino = { workspace = true } diff --git a/crates/red_knot_test/README.md b/crates/red_knot_test/README.md index 3088d7beab..34c1264bcc 100644 --- a/crates/red_knot_test/README.md +++ b/crates/red_knot_test/README.md @@ -20,7 +20,7 @@ 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/mdtest_snippet__1.py`) in its in-memory file system, run a type check on that file, +file path (`/src/mdtest_snippet.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. @@ -126,6 +126,31 @@ Intervening empty lines or non-assertion comments are not allowed; an assertion assertion per line, immediately following each other, with the line immediately following the last assertion as the line of source code on which the matched diagnostics are emitted. +## Literal style + +If multiple code blocks (without an explicit path, see below) are present in a single test, they will +be merged into a single file in the order they appear in the Markdown file. This allows for tests that +interleave code and explanations: + +````markdown +# My literal test + +This first snippet here: + +```py +from typing import Literal + +def f(x: Literal[1]): + pass +``` + +will be merged with this second snippet here, i.e. `f` is defined here: + +```py +f(2) # error: [invalid-argument-type] +``` +```` + ## Diagnostic Snapshotting In addition to inline assertions, one can also snapshot the full diagnostic @@ -156,13 +181,8 @@ snapshotting of specific diagnostics. ## Multi-file tests -Some tests require multiple files, with imports from one file into another. Multiple fenced code -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: +Some tests require multiple files, with imports from one file into another. For this purpose, +tests can specify explicit file paths in a separate line before the code block (`b.py` below): ````markdown ```py @@ -183,8 +203,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/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`. +So the above test creates two files, `/src/mdtest_snippet.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 @@ -398,7 +418,7 @@ This is just an example, not a proposal that red-knot would ever actually output precisely this format: ```output -mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]' +mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]' ``` ```` @@ -406,7 +426,7 @@ We will want to build tooling to automatically capture and update these “full 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 -`/mdtest_snippet__1.py`. An `output` block can be prefixed by a +`/mdtest_snippet.py`. An `output` block can be prefixed by a `<path>`: label as usual, to explicitly specify the Python file for which it asserts diagnostic output. @@ -442,7 +462,7 @@ x = 1 Initial expected output for the unnamed file: ```output -/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[1]' +/src/mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]' ``` Now in our first incremental stage, modify the contents of `b.py`: @@ -457,12 +477,12 @@ x = 2 And this is our updated expected output for the unnamed file at stage 1: ```output stage=1 -/src/mdtest_snippet__1.py, line 1, col 1: revealed type is 'Literal[2]' +/src/mdtest_snippet.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 `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.) +diagnostic assertions for `mdtest_snippet.py` would require specifying new contents for +`mdtest_snippet.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 diff --git a/crates/red_knot_test/src/lib.rs b/crates/red_knot_test/src/lib.rs index 632209be95..49fa475631 100644 --- a/crates/red_knot_test/src/lib.rs +++ b/crates/red_knot_test/src/lib.rs @@ -1,4 +1,5 @@ use crate::config::Log; +use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap}; use camino::Utf8Path; use colored::Colorize; use parser as test_parser; @@ -11,7 +12,6 @@ use ruff_db::parsed::parsed_module; use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; use ruff_db::testing::{setup_logging, setup_logging_with_filter}; use ruff_source_file::{LineIndex, OneIndexed}; -use ruff_text_size::TextSize; use std::fmt::Write; mod assertion; @@ -67,12 +67,14 @@ pub fn run( let md_index = LineIndex::from_source_text(&source); for test_failures in failures { - let backtick_line = md_index.line_index(test_failures.backtick_offset); + let source_map = + EmbeddedFileSourceMap::new(&md_index, test_failures.backtick_offsets); for (relative_line_number, failures) in test_failures.by_line.iter() { + let absolute_line_number = + source_map.to_absolute_line_number(relative_line_number); + for failure in failures { - let absolute_line_number = - backtick_line.checked_add(relative_line_number).unwrap(); let line_info = format!("{relative_fixture_path}:{absolute_line_number}").cyan(); println!(" {line_info} {failure}"); @@ -120,11 +122,7 @@ fn run_test( "Supported file types are: py, pyi, text" ); - let full_path = if embedded.path.starts_with('/') { - SystemPathBuf::from(embedded.path.clone()) - } else { - project_root.join(&embedded.path) - }; + let full_path = embedded.full_path(&project_root); if let Some(ref typeshed_path) = custom_typeshed_path { if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) { @@ -136,7 +134,7 @@ fn run_test( } } - db.write_file(&full_path, embedded.code).unwrap(); + db.write_file(&full_path, &embedded.code).unwrap(); if !full_path.starts_with(&src_path) || embedded.lang == "text" { // These files need to be written to the file system (above), but we don't run any checks on them. @@ -147,7 +145,7 @@ fn run_test( Some(TestFile { file, - backtick_offset: embedded.backtick_offset, + backtick_offsets: embedded.backtick_offsets.clone(), }) }) .collect(); @@ -230,7 +228,7 @@ fn run_test( } by_line.push(OneIndexed::from_zero_indexed(0), messages); return Some(FileFailures { - backtick_offset: test_file.backtick_offset, + backtick_offsets: test_file.backtick_offsets, by_line, }); } @@ -244,7 +242,7 @@ fn run_test( match matcher::match_file(db, test_file.file, diagnostics.iter().map(|d| &**d)) { Ok(()) => None, Err(line_failures) => Some(FileFailures { - backtick_offset: test_file.backtick_offset, + backtick_offsets: test_file.backtick_offsets, by_line: line_failures, }), }; @@ -280,11 +278,11 @@ fn run_test( type Failures = Vec; /// The failures for a single file in a test by line number. -#[derive(Debug)] struct FileFailures { - /// The offset of the backticks that starts the code block in the Markdown file - backtick_offset: TextSize, - /// The failures by lines in the code block. + /// Positional information about the code block(s) to reconstruct absolute line numbers. + backtick_offsets: Vec, + + /// The failures by lines in the file. by_line: matcher::FailuresByLine, } @@ -292,8 +290,8 @@ struct FileFailures { struct TestFile { file: File, - // Offset of the backticks that starts the code block in the Markdown file - backtick_offset: TextSize, + /// Positional information about the code block(s) to reconstruct absolute line numbers. + backtick_offsets: Vec, } fn create_diagnostic_snapshot( @@ -317,7 +315,7 @@ fn create_diagnostic_snapshot( writeln!(snapshot, "# Python source files").unwrap(); writeln!(snapshot).unwrap(); for file in test.files() { - writeln!(snapshot, "## {}", file.path).unwrap(); + writeln!(snapshot, "## {}", file.relative_path()).unwrap(); writeln!(snapshot).unwrap(); // Note that we don't use ```py here because the line numbering // we add makes it invalid Python. This sacrifices syntax diff --git a/crates/red_knot_test/src/parser.rs b/crates/red_knot_test/src/parser.rs index 872abbd5b0..281799b669 100644 --- a/crates/red_knot_test/src/parser.rs +++ b/crates/red_knot_test/src/parser.rs @@ -1,9 +1,13 @@ +use std::{borrow::Cow, collections::hash_map::Entry}; + use anyhow::bail; -use rustc_hash::{FxHashMap, FxHashSet}; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use rustc_hash::FxHashMap; use ruff_index::{newtype_index, IndexVec}; +use ruff_python_ast::PySourceType; use ruff_python_trivia::Cursor; -use ruff_source_file::LineRanges; +use ruff_source_file::{LineIndex, LineRanges, OneIndexed}; use ruff_text_size::{TextLen, TextRange, TextSize}; use crate::config::MarkdownTestConfig; @@ -132,6 +136,112 @@ struct Section<'s> { #[newtype_index] struct EmbeddedFileId; +/// Holds information about the start and the end of a code block in a Markdown file. +/// +/// The start is the offset of the first triple-backtick in the code block, and the end is the +/// offset of the (start of the) closing triple-backtick. +#[derive(Debug, Clone)] +pub(crate) struct BacktickOffsets(TextSize, TextSize); + +/// Holds information about the position and length of all code blocks that are part of +/// a single embedded file in a Markdown file. This is used to reconstruct absolute line +/// numbers (in the Markdown file) from relative line numbers (in the embedded file). +/// +/// If we have a Markdown section with multiple code blocks like this: +/// +/// 01 # Test +/// 02 +/// 03 Part 1: +/// 04 +/// 05 ```py +/// 06 a = 1 # Relative line number: 1 +/// 07 b = 2 # Relative line number: 2 +/// 08 ``` +/// 09 +/// 10 Part 2: +/// 11 +/// 12 ```py +/// 13 c = 3 # Relative line number: 3 +/// 14 ``` +/// +/// We want to reconstruct the absolute line number (left) from relative +/// line numbers. The information we have is the start line and the line +/// count of each code block: +/// +/// - Block 1: (start = 5, count = 2) +/// - Block 2: (start = 12, count = 1) +/// +/// For example, if we see a relative line number of 3, we see that it is +/// larger than the line count of the first block, so we subtract the line +/// count of the first block, and then add the new relative line number (1) +/// to the absolute start line of the second block (12), resulting in an +/// absolute line number of 13. +pub(crate) struct EmbeddedFileSourceMap { + start_line_and_line_count: Vec<(usize, usize)>, +} + +impl EmbeddedFileSourceMap { + pub(crate) fn new( + md_index: &LineIndex, + dimensions: impl IntoIterator, + ) -> EmbeddedFileSourceMap { + EmbeddedFileSourceMap { + start_line_and_line_count: dimensions + .into_iter() + .map(|d| { + let start_line = md_index.line_index(d.0).get(); + let end_line = md_index.line_index(d.1).get(); + let code_line_count = (end_line - start_line) - 1; + (start_line, code_line_count) + }) + .collect(), + } + } + + pub(crate) fn to_absolute_line_number(&self, relative_line_number: OneIndexed) -> OneIndexed { + let mut absolute_line_number = 0; + let mut relative_line_number = relative_line_number.get(); + + for (start_line, line_count) in &self.start_line_and_line_count { + if relative_line_number > *line_count { + relative_line_number -= *line_count; + } else { + absolute_line_number = start_line + relative_line_number; + break; + } + } + + OneIndexed::new(absolute_line_number).expect("Relative line number out of bounds") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum EmbeddedFilePath<'s> { + Autogenerated(PySourceType), + Explicit(&'s str), +} + +impl EmbeddedFilePath<'_> { + pub(crate) fn as_str(&self) -> &str { + match self { + EmbeddedFilePath::Autogenerated(PySourceType::Python) => "mdtest_snippet.py", + EmbeddedFilePath::Autogenerated(PySourceType::Stub) => "mdtest_snippet.pyi", + EmbeddedFilePath::Autogenerated(PySourceType::Ipynb) => "mdtest_snippet.ipynb", + EmbeddedFilePath::Explicit(path) => path, + } + } + + fn is_explicit(&self) -> bool { + matches!(self, EmbeddedFilePath::Explicit(_)) + } + + fn is_allowed_explicit_path(path: &str) -> bool { + [PySourceType::Python, PySourceType::Stub] + .iter() + .all(|source_type| path != EmbeddedFilePath::Autogenerated(*source_type).as_str()) + } +} + /// A single file embedded in a [`Section`] as a fenced code block. /// /// Currently must be a Python file (`py` language), a type stub (`pyi`) or a [typeshed `VERSIONS`] @@ -148,12 +258,45 @@ struct EmbeddedFileId; #[derive(Debug)] pub(crate) struct EmbeddedFile<'s> { section: SectionId, - pub(crate) path: String, + path: EmbeddedFilePath<'s>, pub(crate) lang: &'s str, - pub(crate) code: &'s str, + pub(crate) code: Cow<'s, str>, + pub(crate) backtick_offsets: Vec, +} - /// The offset of the backticks beginning the code block within the markdown file - pub(crate) backtick_offset: TextSize, +impl EmbeddedFile<'_> { + fn append_code(&mut self, backtick_offsets: BacktickOffsets, new_code: &str) { + // Treat empty code blocks as non-existent, instead of creating + // an additional empty line: + if new_code.is_empty() { + return; + } + + self.backtick_offsets.push(backtick_offsets); + + match self.code { + Cow::Borrowed(existing_code) => { + self.code = Cow::Owned(format!("{existing_code}\n{new_code}")); + } + Cow::Owned(ref mut existing_code) => { + existing_code.push('\n'); + existing_code.push_str(new_code); + } + } + } + + pub(crate) fn relative_path(&self) -> &str { + self.path.as_str() + } + + pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf { + let relative_path = self.relative_path(); + if relative_path.starts_with('/') { + SystemPathBuf::from(relative_path) + } else { + project_root.join(relative_path) + } + } } #[derive(Debug)] @@ -195,12 +338,6 @@ struct Parser<'s> { /// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`]. files: IndexVec>, - /// The counts are done by section. This gives each code block a - /// somewhat locally derived name. That is, adding new sections - /// won't change the names of files in other sections. This is - /// important for avoiding snapshot churn. - unnamed_file_count: FxHashMap, - /// The unparsed remainder of the Markdown source. cursor: Cursor<'s>, @@ -217,7 +354,7 @@ struct Parser<'s> { stack: SectionStack, /// Names of embedded files in current active section. - current_section_files: Option>, + current_section_files: FxHashMap, EmbeddedFileId>, /// Whether or not the current section has a config block. current_section_has_config: bool, @@ -237,13 +374,12 @@ impl<'s> Parser<'s> { sections, source, files: IndexVec::default(), - unnamed_file_count: FxHashMap::default(), cursor: Cursor::new(source), preceding_blank_lines: 0, explicit_path: None, source_len: source.text_len(), stack: SectionStack::new(root_section_id), - current_section_files: None, + current_section_files: FxHashMap::default(), current_section_has_config: false, } } @@ -334,7 +470,7 @@ impl<'s> Parser<'s> { if self.cursor.eat_char2('`', '`') { // We saw the triple-backtick beginning of a code block. - let backtick_offset = self.offset() - "```".text_len(); + let backtick_offset_start = self.offset() - "```".text_len(); if self.preceding_blank_lines < 1 && self.explicit_path.is_none() { bail!("Code blocks must start on a new line and be preceded by at least one blank line."); @@ -363,7 +499,13 @@ impl<'s> Parser<'s> { code = &code[..code.len() - '\n'.len_utf8()]; } - self.process_code_block(lang, code, backtick_offset)?; + let backtick_offset_end = self.offset() - "```".text_len(); + + self.process_code_block( + lang, + code, + BacktickOffsets(backtick_offset_start, backtick_offset_end), + )?; } else { let code_block_start = self.cursor.token_len(); let line = self.source.count_lines(TextRange::up_to(code_block_start)); @@ -428,7 +570,7 @@ impl<'s> Parser<'s> { snapshot_diagnostics: self.sections[parent].snapshot_diagnostics, }; - if self.current_section_files.is_some() { + if !self.current_section_files.is_empty() { bail!( "Header '{}' not valid inside a test case; parent '{}' has code files.", section.title, @@ -439,7 +581,7 @@ impl<'s> Parser<'s> { let section_id = self.sections.push(section); self.stack.push(section_id); - self.current_section_files = None; + self.current_section_files.clear(); self.current_section_has_config = false; Ok(()) @@ -449,10 +591,11 @@ impl<'s> Parser<'s> { &mut self, lang: &'s str, code: &'s str, - backtick_offset: TextSize, + backtick_offsets: BacktickOffsets, ) -> anyhow::Result<()> { // We never pop the implicit root section. let section = self.stack.top(); + let test_name = self.sections[section].title; if lang == "toml" { return self.process_config_block(code); @@ -465,52 +608,86 @@ impl<'s> Parser<'s> { && !explicit_path.ends_with(&format!(".{lang}")) { bail!( - "File ending of test file path `{explicit_path}` does not match `lang={lang}` of code block" + "File extension of test file path `{explicit_path}` in test `{test_name}` does not match language specified `{lang}` of code block" ); } } let path = match self.explicit_path { - Some(path) => path.to_string(), - None => { - let unnamed_file_count = self.unnamed_file_count.entry(section).or_default(); - *unnamed_file_count += 1; - - match lang { - "py" | "pyi" => format!("mdtest_snippet__{unnamed_file_count}.{lang}"), - "" => format!("mdtest_snippet__{unnamed_file_count}.py"), - _ => { - bail!( - "Cannot generate name for `lang={}`: Unsupported extension", - lang - ); - } + Some(path) => { + if !EmbeddedFilePath::is_allowed_explicit_path(path) { + bail!( + "The file name `{path}` in test `{test_name}` must not be used explicitly.", + ); } + + EmbeddedFilePath::Explicit(path) } + None => match lang { + "py" => EmbeddedFilePath::Autogenerated(PySourceType::Python), + "pyi" => EmbeddedFilePath::Autogenerated(PySourceType::Stub), + "" => { + bail!("Cannot auto-generate file name for code block with empty language specifier in test `{test_name}`"); + } + _ => { + bail!( + "Cannot auto-generate file name for code block with language `{}` in test `{test_name}`", + lang + ); + } + }, }; - self.files.push(EmbeddedFile { - path: path.clone(), - section, - lang, - code, - backtick_offset, - }); + let has_merged_snippets = self.current_section_has_merged_snippets(); + let has_explicit_file_paths = self.current_section_has_explicit_file_paths(); - if let Some(current_files) = &mut self.current_section_files { - if !current_files.insert(path.clone()) { - bail!( - "Test `{}` has duplicate files named `{path}`.", - self.sections[section].title - ); - }; - } else { - self.current_section_files = Some(FxHashSet::from_iter([path])); + match self.current_section_files.entry(path.clone()) { + Entry::Vacant(entry) => { + if has_merged_snippets { + bail!("Merged snippets in test `{test_name}` are not allowed in the presence of other files."); + } + + let index = self.files.push(EmbeddedFile { + path: path.clone(), + section, + lang, + code: Cow::Borrowed(code), + backtick_offsets: vec![backtick_offsets], + }); + entry.insert(index); + } + Entry::Occupied(entry) => { + if path.is_explicit() { + bail!( + "Test `{test_name}` has duplicate files named `{}`.", + path.as_str(), + ); + }; + + if has_explicit_file_paths { + bail!("Merged snippets in test `{test_name}` are not allowed in the presence of other files."); + } + + let index = *entry.get(); + self.files[index].append_code(backtick_offsets, code); + } } Ok(()) } + fn current_section_has_explicit_file_paths(&self) -> bool { + self.current_section_files + .iter() + .any(|(path, _)| path.is_explicit()) + } + + fn current_section_has_merged_snippets(&self) -> bool { + self.current_section_files + .values() + .any(|id| self.files[*id].backtick_offsets.len() > 1) + } + fn process_config_block(&mut self, code: &str) -> anyhow::Result<()> { if self.current_section_has_config { bail!("Multiple TOML configuration blocks in the same section are not allowed."); @@ -531,7 +708,7 @@ impl<'s> Parser<'s> { everything else (including TOML configuration blocks).", ); } - if self.current_section_files.is_some() { + if !self.current_section_files.is_empty() { bail!( "Section config to enable snapshotting diagnostics must come before \ everything else (including embedded files).", @@ -555,7 +732,7 @@ impl<'s> Parser<'s> { self.stack.pop(); // We would have errored before pushing a child section if there were files, so we know // no parent section can have files. - self.current_section_files = None; + self.current_section_files.clear(); } } @@ -567,8 +744,11 @@ impl<'s> Parser<'s> { #[cfg(test)] mod tests { + use ruff_python_ast::PySourceType; use ruff_python_trivia::textwrap::dedent; + use crate::parser::EmbeddedFilePath; + #[test] fn empty() { let mf = super::parse("file.md", "").unwrap(); @@ -597,7 +777,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.lang, "py"); assert_eq!(file.code, "x = 1"); } @@ -622,7 +805,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.lang, "py"); assert_eq!(file.code, "x = 1"); } @@ -645,10 +831,14 @@ mod tests { # Three + `mod_a.pyi`: + ```pyi a: int ``` + `mod_b.pyi`: + ```pyi b: str ``` @@ -668,7 +858,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.lang, "py"); assert_eq!(file.code, "x = 1"); @@ -676,7 +869,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.lang, "py"); assert_eq!(file.code, "y = 2"); @@ -684,11 +880,11 @@ mod tests { panic!("expected two files"); }; - assert_eq!(file_1.path, "mdtest_snippet__1.pyi"); + assert_eq!(file_1.relative_path(), "mod_a.pyi"); assert_eq!(file_1.lang, "pyi"); assert_eq!(file_1.code, "a: int"); - assert_eq!(file_2.path, "mdtest_snippet__2.pyi"); + assert_eq!(file_2.relative_path(), "mod_b.pyi"); assert_eq!(file_2.lang, "pyi"); assert_eq!(file_2.code, "b: str"); } @@ -731,11 +927,11 @@ mod tests { panic!("expected two files"); }; - assert_eq!(main.path, "main.py"); + assert_eq!(main.relative_path(), "main.py"); assert_eq!(main.lang, "py"); assert_eq!(main.code, "from foo import y"); - assert_eq!(foo.path, "foo.py"); + assert_eq!(foo.relative_path(), "foo.py"); assert_eq!(foo.lang, "py"); assert_eq!(foo.code, "y = 2"); @@ -743,11 +939,157 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.lang, "py"); assert_eq!(file.code, "y = 2"); } + #[test] + fn merged_snippets() { + let source = dedent( + " + # One + + This is the first part of the embedded file: + + ```py + x = 1 + ``` + + And this is the second part: + + ```py + y = 2 + ``` + + And this is the third part: + + ```py + z = 3 + ``` + ", + ); + let mf = super::parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::>()[..] else { + panic!("expected one test"); + }; + + let [file] = test.files().collect::>()[..] else { + panic!("expected one file"); + }; + + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); + assert_eq!(file.lang, "py"); + assert_eq!(file.code, "x = 1\ny = 2\nz = 3"); + } + + #[test] + fn no_merged_snippets_for_explicit_paths() { + let source = dedent( + " + # One + + `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 `One` has duplicate files named `foo.py`." + ); + } + + #[test] + fn disallow_merged_snippets_in_presence_of_explicit_paths() { + for source in [ + // Merged snippets first + " + # One + + ```py + x = 1 + ``` + + ```py + y = 2 + ``` + + `foo.py`: + + ```py + print('hello') + ``` + ", + // Explicit path first + " + # One + + `foo.py`: + + ```py + print('hello') + ``` + + ```py + x = 1 + ``` + + ```py + y = 2 + ``` + ", + ] { + let err = super::parse("file.md", &dedent(source)).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Merged snippets in test `One` are not allowed in the presence of other files." + ); + } + } + + #[test] + fn disallow_pyi_snippets_in_presence_of_merged_py_snippets() { + let source = dedent( + " + # One + + ```py + x = 1 + ``` + + ```py + y = 2 + ``` + + ```pyi + x: int + ``` + ", + ); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Merged snippets in test `One` are not allowed in the presence of other files." + ); + } + #[test] fn custom_file_path() { let source = dedent( @@ -768,7 +1110,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "foo.py"); + assert_eq!(file.relative_path(), "foo.py"); assert_eq!(file.lang, "py"); assert_eq!(file.code, "x = 1"); } @@ -820,28 +1162,27 @@ mod tests { fn no_lang() { let source = dedent( " + # No language specifier + ``` x = 10 ``` ", ); - let mf = super::parse("file.md", &source).unwrap(); - - let [test] = &mf.tests().collect::>()[..] else { - panic!("expected one test"); - }; - let [file] = test.files().collect::>()[..] else { - panic!("expected one file"); - }; - - assert_eq!(file.code, "x = 10"); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!( + err.to_string(), + "Cannot auto-generate file name for code block with empty language specifier in test `No language specifier`" + ); } #[test] fn cannot_generate_name_for_lang() { let source = dedent( " + # JSON test? + ```json {} ``` @@ -850,7 +1191,7 @@ mod tests { 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" + "Cannot auto-generate file name for code block with language `json` in test `JSON test?`" ); } @@ -858,6 +1199,8 @@ mod tests { fn mismatching_lang() { let source = dedent( " + # Accidental stub + `a.py`: ```pyi @@ -868,7 +1211,7 @@ mod tests { 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" + "File extension of test file path `a.py` in test `Accidental stub` does not match language specified `pyi` of code block" ); } @@ -893,7 +1236,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "lorem"); + assert_eq!(file.relative_path(), "lorem"); assert_eq!(file.code, "x = 1"); } @@ -918,7 +1261,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "lorem.yaml"); + assert_eq!(file.relative_path(), "lorem.yaml"); assert_eq!(file.code, "x = 1"); } @@ -940,13 +1283,13 @@ mod tests { " ## A well-fenced block - ``` + ```py y = 2 ``` ## A not-so-well-fenced block - ``` + ```py x = 1 ", ); @@ -987,7 +1330,8 @@ mod tests { ``` ", ); - super::parse("file.md", &source).expect_err("Indented code blocks are not supported."); + let err = super::parse("file.md", &source).expect_err("Should fail to parse"); + assert_eq!(err.to_string(), "Indented code blocks are not supported."); } #[test] @@ -1033,7 +1377,10 @@ mod tests { }; assert_eq!(test.section.title, "file.md"); - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } @@ -1062,7 +1409,10 @@ mod tests { }; assert_eq!(test.section.title, "Foo"); - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } @@ -1091,10 +1441,12 @@ mod tests { } #[test] - fn no_duplicate_name_files_in_test_2() { + fn no_usage_of_autogenerated_name() { let source = dedent( " - `mdtest_snippet__1.py`: + # Name clash + + `mdtest_snippet.py`: ```py x = 1 @@ -1108,7 +1460,7 @@ mod tests { 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`." + "The file name `mdtest_snippet.py` in test `Name clash` must not be used explicitly." ); } @@ -1133,7 +1485,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "foo.py"); + assert_eq!(file.relative_path(), "foo.py"); assert_eq!(file.code, "x = 1"); } @@ -1158,7 +1510,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "foo.py"); + assert_eq!(file.relative_path(), "foo.py"); assert_eq!(file.code, "x = 1"); } @@ -1182,7 +1534,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "foo.py"); + assert_eq!(file.relative_path(), "foo.py"); assert_eq!(file.code, "x = 1"); } @@ -1207,7 +1559,7 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "foo bar.py"); + assert_eq!(file.relative_path(), "foo bar.py"); assert_eq!(file.code, "x = 1"); } @@ -1233,7 +1585,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } @@ -1258,7 +1613,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } @@ -1284,7 +1642,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } @@ -1310,7 +1671,10 @@ mod tests { panic!("expected one file"); }; - assert_eq!(file.path, "mdtest_snippet__1.py"); + assert_eq!( + file.path, + EmbeddedFilePath::Autogenerated(PySourceType::Python) + ); assert_eq!(file.code, "x = 1"); } diff --git a/crates/ruff_python_ast/src/lib.rs b/crates/ruff_python_ast/src/lib.rs index 6f7ab46296..4465ea73e5 100644 --- a/crates/ruff_python_ast/src/lib.rs +++ b/crates/ruff_python_ast/src/lib.rs @@ -69,7 +69,7 @@ pub enum TomlSourceType { Unrecognized, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PySourceType { /// The source is a Python file (`.py`).