Skip to content

Commit 848c573

Browse files
committed
Merge remote-tracking branch 'origin/main' into dcreager/smoosh-reachability
* origin/main: [ty] Expansion of enums into unions of literals (#19382) [ty] Avoid rechecking the entire project when changing the opened files (#19463) [ty] Add warning for unknown `TY_MEMORY_REPORT` value (#19465)
2 parents 7090b7e + dc66019 commit 848c573

File tree

21 files changed

+802
-133
lines changed

21 files changed

+802
-133
lines changed

crates/ty/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,12 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
156156
Ok("short") => write!(stdout, "{}", db.salsa_memory_dump().display_short())?,
157157
Ok("mypy_primer") => write!(stdout, "{}", db.salsa_memory_dump().display_mypy_primer())?,
158158
Ok("full") => write!(stdout, "{}", db.salsa_memory_dump().display_full())?,
159-
_ => {}
159+
Ok(other) => {
160+
tracing::warn!(
161+
"Unknown value for `TY_MEMORY_REPORT`: `{other}`. Valid values are `short`, `mypy_primer`, and `full`."
162+
);
163+
}
164+
Err(_) => {}
160165
}
161166

162167
std::mem::forget(db);

crates/ty_project/src/lib.rs

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ impl Project {
252252
.map(IOErrorDiagnostic::to_diagnostic),
253253
);
254254

255+
let open_files = self.open_files(db);
255256
let check_start = ruff_db::Instant::now();
256257
let file_diagnostics = std::sync::Mutex::new(vec![]);
257258

@@ -269,11 +270,30 @@ impl Project {
269270
tracing::debug_span!(parent: project_span, "check_file", ?file);
270271
let _entered = check_file_span.entered();
271272

272-
let result = check_file_impl(&db, file);
273-
file_diagnostics
274-
.lock()
275-
.unwrap()
276-
.extend(result.iter().map(Clone::clone));
273+
match check_file_impl(&db, file) {
274+
Ok(diagnostics) => {
275+
file_diagnostics
276+
.lock()
277+
.unwrap()
278+
.extend(diagnostics.iter().map(Clone::clone));
279+
280+
// This is outside `check_file_impl` to avoid that opening or closing
281+
// a file invalidates the `check_file_impl` query of every file!
282+
if !open_files.contains(&file) {
283+
// The module has already been parsed by `check_file_impl`.
284+
// We only retrieve it here so that we can call `clear` on it.
285+
let parsed = parsed_module(&db, file);
286+
287+
// Drop the AST now that we are done checking this file. It is not currently open,
288+
// so it is unlikely to be accessed again soon. If any queries need to access the AST
289+
// from across files, it will be re-parsed.
290+
parsed.clear();
291+
}
292+
}
293+
Err(io_error) => {
294+
file_diagnostics.lock().unwrap().push(io_error.clone());
295+
}
296+
}
277297

278298
reporter.report_file(&file);
279299
});
@@ -300,7 +320,10 @@ impl Project {
300320
return Vec::new();
301321
}
302322

303-
check_file_impl(db, file).iter().map(Clone::clone).collect()
323+
match check_file_impl(db, file) {
324+
Ok(diagnostics) => diagnostics.to_vec(),
325+
Err(diagnostic) => vec![diagnostic.clone()],
326+
}
304327
}
305328

306329
/// Opens a file in the project.
@@ -484,22 +507,19 @@ impl Project {
484507
}
485508
}
486509

487-
#[salsa::tracked(returns(deref), heap_size=get_size2::GetSize::get_heap_size)]
488-
pub(crate) fn check_file_impl(db: &dyn Db, file: File) -> Box<[Diagnostic]> {
510+
#[salsa::tracked(returns(ref), heap_size=get_size2::GetSize::get_heap_size)]
511+
pub(crate) fn check_file_impl(db: &dyn Db, file: File) -> Result<Box<[Diagnostic]>, Diagnostic> {
489512
let mut diagnostics: Vec<Diagnostic> = Vec::new();
490513

491514
// Abort checking if there are IO errors.
492515
let source = source_text(db, file);
493516

494517
if let Some(read_error) = source.read_error() {
495-
diagnostics.push(
496-
IOErrorDiagnostic {
497-
file: Some(file),
498-
error: read_error.clone().into(),
499-
}
500-
.to_diagnostic(),
501-
);
502-
return diagnostics.into_boxed_slice();
518+
return Err(IOErrorDiagnostic {
519+
file: Some(file),
520+
error: read_error.clone().into(),
521+
}
522+
.to_diagnostic());
503523
}
504524

505525
let parsed = parsed_module(db, file);
@@ -529,13 +549,6 @@ pub(crate) fn check_file_impl(db: &dyn Db, file: File) -> Box<[Diagnostic]> {
529549
}
530550
}
531551

532-
if !db.project().open_fileset(db).contains(&file) {
533-
// Drop the AST now that we are done checking this file. It is not currently open,
534-
// so it is unlikely to be accessed again soon. If any queries need to access the AST
535-
// from across files, it will be re-parsed.
536-
parsed.clear();
537-
}
538-
539552
diagnostics.sort_unstable_by_key(|diagnostic| {
540553
diagnostic
541554
.primary_span()
@@ -544,7 +557,7 @@ pub(crate) fn check_file_impl(db: &dyn Db, file: File) -> Box<[Diagnostic]> {
544557
.start()
545558
});
546559

547-
diagnostics.into_boxed_slice()
560+
Ok(diagnostics.into_boxed_slice())
548561
}
549562

550563
#[derive(Debug)]
@@ -762,10 +775,11 @@ mod tests {
762775
assert_eq!(source_text(&db, file).as_str(), "");
763776
assert_eq!(
764777
check_file_impl(&db, file)
765-
.iter()
766-
.map(|diagnostic| diagnostic.primary_message().to_string())
767-
.collect::<Vec<_>>(),
768-
vec!["Failed to read file: No such file or directory".to_string()]
778+
.as_ref()
779+
.unwrap_err()
780+
.primary_message()
781+
.to_string(),
782+
"Failed to read file: No such file or directory".to_string()
769783
);
770784

771785
let events = db.take_salsa_events();
@@ -778,6 +792,8 @@ mod tests {
778792
assert_eq!(source_text(&db, file).as_str(), "");
779793
assert_eq!(
780794
check_file_impl(&db, file)
795+
.as_ref()
796+
.unwrap()
781797
.iter()
782798
.map(|diagnostic| diagnostic.primary_message().to_string())
783799
.collect::<Vec<_>>(),

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,12 +2355,13 @@ import enum
23552355

23562356
reveal_type(enum.Enum.__members__) # revealed: MappingProxyType[str, Unknown]
23572357

2358-
class Foo(enum.Enum):
2359-
BAR = 1
2358+
class Answer(enum.Enum):
2359+
NO = 0
2360+
YES = 1
23602361

2361-
reveal_type(Foo.BAR) # revealed: Literal[Foo.BAR]
2362-
reveal_type(Foo.BAR.value) # revealed: Any
2363-
reveal_type(Foo.__members__) # revealed: MappingProxyType[str, Unknown]
2362+
reveal_type(Answer.NO) # revealed: Literal[Answer.NO]
2363+
reveal_type(Answer.NO.value) # revealed: Any
2364+
reveal_type(Answer.__members__) # revealed: MappingProxyType[str, Unknown]
23642365
```
23652366

23662367
## References

crates/ty_python_semantic/resources/mdtest/call/overloads.md

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ def _(x: type[A | B]):
369369

370370
### Expanding enums
371371

372+
#### Basic
373+
372374
`overloaded.pyi`:
373375

374376
```pyi
@@ -394,15 +396,106 @@ def f(x: Literal[SomeEnum.C]) -> C: ...
394396
```
395397

396398
```py
399+
from typing import Literal
397400
from overloaded import SomeEnum, A, B, C, f
398401

399-
def _(x: SomeEnum):
402+
def _(x: SomeEnum, y: Literal[SomeEnum.A, SomeEnum.C]):
400403
reveal_type(f(SomeEnum.A)) # revealed: A
401404
reveal_type(f(SomeEnum.B)) # revealed: B
402405
reveal_type(f(SomeEnum.C)) # revealed: C
403-
# TODO: This should not be an error. The return type should be `A | B | C` once enums are expanded
404-
# error: [no-matching-overload]
405-
reveal_type(f(x)) # revealed: Unknown
406+
reveal_type(f(x)) # revealed: A | B | C
407+
reveal_type(f(y)) # revealed: A | C
408+
```
409+
410+
#### Enum with single member
411+
412+
This pattern appears in typeshed. Here, it is used to represent two optional, mutually exclusive
413+
keyword parameters:
414+
415+
`overloaded.pyi`:
416+
417+
```pyi
418+
from enum import Enum, auto
419+
from typing import overload, Literal
420+
421+
class Missing(Enum):
422+
Value = auto()
423+
424+
class OnlyASpecified: ...
425+
class OnlyBSpecified: ...
426+
class BothMissing: ...
427+
428+
@overload
429+
def f(*, a: int, b: Literal[Missing.Value] = ...) -> OnlyASpecified: ...
430+
@overload
431+
def f(*, a: Literal[Missing.Value] = ..., b: int) -> OnlyBSpecified: ...
432+
@overload
433+
def f(*, a: Literal[Missing.Value] = ..., b: Literal[Missing.Value] = ...) -> BothMissing: ...
434+
```
435+
436+
```py
437+
from typing import Literal
438+
from overloaded import f, Missing
439+
440+
reveal_type(f()) # revealed: BothMissing
441+
reveal_type(f(a=0)) # revealed: OnlyASpecified
442+
reveal_type(f(b=0)) # revealed: OnlyBSpecified
443+
444+
f(a=0, b=0) # error: [no-matching-overload]
445+
446+
def _(missing: Literal[Missing.Value], missing_or_present: Literal[Missing.Value] | int):
447+
reveal_type(f(a=missing, b=missing)) # revealed: BothMissing
448+
reveal_type(f(a=missing)) # revealed: BothMissing
449+
reveal_type(f(b=missing)) # revealed: BothMissing
450+
reveal_type(f(a=0, b=missing)) # revealed: OnlyASpecified
451+
reveal_type(f(a=missing, b=0)) # revealed: OnlyBSpecified
452+
453+
reveal_type(f(a=missing_or_present)) # revealed: BothMissing | OnlyASpecified
454+
reveal_type(f(b=missing_or_present)) # revealed: BothMissing | OnlyBSpecified
455+
456+
# Here, both could be present, so this should be an error
457+
f(a=missing_or_present, b=missing_or_present) # error: [no-matching-overload]
458+
```
459+
460+
#### Enum subclass without members
461+
462+
An `Enum` subclass without members should *not* be expanded:
463+
464+
`overloaded.pyi`:
465+
466+
```pyi
467+
from enum import Enum
468+
from typing import overload, Literal
469+
470+
class MyEnumSubclass(Enum):
471+
pass
472+
473+
class ActualEnum(MyEnumSubclass):
474+
A = 1
475+
B = 2
476+
477+
class OnlyA: ...
478+
class OnlyB: ...
479+
class Both: ...
480+
481+
@overload
482+
def f(x: Literal[ActualEnum.A]) -> OnlyA: ...
483+
@overload
484+
def f(x: Literal[ActualEnum.B]) -> OnlyB: ...
485+
@overload
486+
def f(x: ActualEnum) -> Both: ...
487+
@overload
488+
def f(x: MyEnumSubclass) -> MyEnumSubclass: ...
489+
```
490+
491+
```py
492+
from overloaded import MyEnumSubclass, ActualEnum, f
493+
494+
def _(actual_enum: ActualEnum, my_enum_instance: MyEnumSubclass):
495+
reveal_type(f(actual_enum)) # revealed: Both
496+
reveal_type(f(ActualEnum.A)) # revealed: OnlyA
497+
reveal_type(f(ActualEnum.B)) # revealed: OnlyB
498+
reveal_type(f(my_enum_instance)) # revealed: MyEnumSubclass
406499
```
407500

408501
### No matching overloads

crates/ty_python_semantic/resources/mdtest/enums.md

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,111 @@ To do: <https://typing.python.org/en/latest/spec/enums.html#enum-definition>
570570

571571
## Exhaustiveness checking
572572

573-
To do
573+
## `if` statements
574+
575+
```py
576+
from enum import Enum
577+
from typing_extensions import assert_never
578+
579+
class Color(Enum):
580+
RED = 1
581+
GREEN = 2
582+
BLUE = 3
583+
584+
def color_name(color: Color) -> str:
585+
if color is Color.RED:
586+
return "Red"
587+
elif color is Color.GREEN:
588+
return "Green"
589+
elif color is Color.BLUE:
590+
return "Blue"
591+
else:
592+
assert_never(color)
593+
594+
# No `invalid-return-type` error here because the implicit `else` branch is detected as unreachable:
595+
def color_name_without_assertion(color: Color) -> str:
596+
if color is Color.RED:
597+
return "Red"
598+
elif color is Color.GREEN:
599+
return "Green"
600+
elif color is Color.BLUE:
601+
return "Blue"
602+
603+
def color_name_misses_one_variant(color: Color) -> str:
604+
if color is Color.RED:
605+
return "Red"
606+
elif color is Color.GREEN:
607+
return "Green"
608+
else:
609+
assert_never(color) # error: [type-assertion-failure] "Argument does not have asserted type `Never`"
610+
611+
class Singleton(Enum):
612+
VALUE = 1
613+
614+
def singleton_check(value: Singleton) -> str:
615+
if value is Singleton.VALUE:
616+
return "Singleton value"
617+
else:
618+
assert_never(value)
619+
```
620+
621+
## `match` statements
622+
623+
```toml
624+
[environment]
625+
python-version = "3.10"
626+
```
627+
628+
```py
629+
from enum import Enum
630+
from typing_extensions import assert_never
631+
632+
class Color(Enum):
633+
RED = 1
634+
GREEN = 2
635+
BLUE = 3
636+
637+
def color_name(color: Color) -> str:
638+
match color:
639+
case Color.RED:
640+
return "Red"
641+
case Color.GREEN:
642+
return "Green"
643+
case Color.BLUE:
644+
return "Blue"
645+
case _:
646+
assert_never(color)
647+
648+
# TODO: this should not be an error, see https://github.com/astral-sh/ty/issues/99#issuecomment-2983054488
649+
# error: [invalid-return-type] "Function can implicitly return `None`, which is not assignable to return type `str`"
650+
def color_name_without_assertion(color: Color) -> str:
651+
match color:
652+
case Color.RED:
653+
return "Red"
654+
case Color.GREEN:
655+
return "Green"
656+
case Color.BLUE:
657+
return "Blue"
658+
659+
def color_name_misses_one_variant(color: Color) -> str:
660+
match color:
661+
case Color.RED:
662+
return "Red"
663+
case Color.GREEN:
664+
return "Green"
665+
case _:
666+
assert_never(color) # error: [type-assertion-failure] "Argument does not have asserted type `Never`"
667+
668+
class Singleton(Enum):
669+
VALUE = 1
670+
671+
def singleton_check(value: Singleton) -> str:
672+
match value:
673+
case Singleton.VALUE:
674+
return "Singleton value"
675+
case _:
676+
assert_never(value)
677+
```
574678

575679
## References
576680

0 commit comments

Comments
 (0)