@@ -32,6 +32,19 @@ use ruff_text_size::{Ranged, TextRange, TextSize};
3232
3333use super :: ast_driver:: { AstPass , PassContext } ;
3434
35+ /// Returns the head identifier of a base-class expression: `A` for `A`, and
36+ /// `A` for the generic form `A[int]`. Other base shapes have no simple name.
37+ fn base_head_name ( base : & Expr ) -> Option < & str > {
38+ match base {
39+ Expr :: Name ( n) => Some ( n. id . as_str ( ) ) ,
40+ Expr :: Subscript ( s) => match s. value . as_ref ( ) {
41+ Expr :: Name ( n) => Some ( n. id . as_str ( ) ) ,
42+ _ => None ,
43+ } ,
44+ _ => None ,
45+ }
46+ }
47+
3548#[ expect( clippy:: struct_excessive_bools) ]
3649pub ( crate ) struct Modifiers < ' src > {
3750 source : & ' src str ,
@@ -50,9 +63,22 @@ pub(crate) struct Modifiers<'src> {
5063 pub ( crate ) exports : Vec < String > ,
5164 /// Module-level names renamed by `private` (original → `_original`).
5265 pub ( crate ) private_renames : Vec < String > ,
66+ /// Module-level classes declared `sealed`, in source order. Each gets a
67+ /// `<name>.__sealed_members__` tuple of its same-module subclasses.
68+ pub ( crate ) sealed_classes : Vec < String > ,
69+ /// Module-level `(class name, base head names, statement end offset)`
70+ /// triples, in source order. Used to resolve the subclasses of each sealed
71+ /// class and to place the `__sealed_members__` assignment after the last one.
72+ pub ( crate ) class_bases : Vec < ( String , Vec < String > , TextSize ) > ,
5373 /// Tracks the current class-nesting depth so visibility modifiers can
5474 /// distinguish module-level declarations from class members.
5575 class_depth : u32 ,
76+
77+ /// Tracks the current function-nesting depth. A class declared inside a
78+ /// function body is not a module-level name, so it must not be recorded as
79+ /// a sealed subclass (the runtime tuple assignment lives at module scope
80+ /// and cannot reference a function-local name).
81+ func_depth : u32 ,
5682}
5783
5884impl < ' src > Modifiers < ' src > {
@@ -70,10 +96,19 @@ impl<'src> Modifiers<'src> {
7096 needs_newtype : false ,
7197 exports : Vec :: new ( ) ,
7298 private_renames : Vec :: new ( ) ,
99+ sealed_classes : Vec :: new ( ) ,
100+ class_bases : Vec :: new ( ) ,
73101 class_depth : 0 ,
102+ func_depth : 0 ,
74103 }
75104 }
76105
106+ /// True when the visitor is directly at module scope — not inside any class
107+ /// or function body.
108+ fn at_module_level ( & self ) -> bool {
109+ self . class_depth == 0 && self . func_depth == 0
110+ }
111+
77112 fn line_indent ( & self , pos : TextSize ) -> & str {
78113 super :: source_util:: line_indent ( self . source , pos)
79114 }
@@ -96,6 +131,19 @@ impl<'src> Modifiers<'src> {
96131 }
97132
98133 fn process_class ( & mut self , class : & StmtClassDef ) {
134+ // Record module-level class → base-head-names so sealed-member tuples can
135+ // be resolved after the whole module has been visited.
136+ if self . at_module_level ( ) {
137+ let bases = class
138+ . arguments
139+ . iter ( )
140+ . flat_map ( |args| args. args . iter ( ) )
141+ . filter_map ( base_head_name)
142+ . map ( str:: to_owned)
143+ . collect ( ) ;
144+ self . class_bases
145+ . push ( ( class. name . as_str ( ) . to_owned ( ) , bases, class. range ( ) . end ( ) ) ) ;
146+ }
99147 for dec in & class. decorator_list {
100148 let Some ( name) = self . synthetic_name ( dec) else {
101149 continue ;
@@ -107,6 +155,15 @@ impl<'src> Modifiers<'src> {
107155 self . edits
108156 . push ( Fix :: safe_edit ( Edit :: range_deletion ( dec. range ( ) ) ) ) ;
109157 }
158+ "sealed" => {
159+ // Remove the modifier prefix; the `__sealed_members__` tuple is
160+ // emitted after the last subclass once they are all known.
161+ self . edits
162+ . push ( Fix :: safe_edit ( Edit :: range_deletion ( dec. range ( ) ) ) ) ;
163+ if self . at_module_level ( ) {
164+ self . sealed_classes . push ( class. name . as_str ( ) . to_owned ( ) ) ;
165+ }
166+ }
110167 "final" => {
111168 self . needs_final = true ;
112169 self . edits . push ( Fix :: safe_edit ( Edit :: range_replacement (
@@ -292,7 +349,7 @@ impl<'src> Modifiers<'src> {
292349 node. range ( ) ,
293350 ) ) ) ;
294351 }
295- "__abstract_annot__" | "__visibility_annot__" => {
352+ "__abstract_annot__" | "__visibility_annot__" | "__modifier_annot__" => {
296353 // erase only the modifier prefix; the rest of the
297354 // statement (`a: int [= v]`) remains in source unchanged
298355 let erase_range =
@@ -380,6 +437,12 @@ impl<'ast> Visitor<'ast> for Modifiers<'_> {
380437 }
381438 Stmt :: FunctionDef ( f) => {
382439 self . process_function ( f) ;
440+ // Walk the body with `func_depth` incremented so a class defined
441+ // inside the function is not treated as a module-level subclass.
442+ self . func_depth += 1 ;
443+ walk_stmt ( self , stmt) ;
444+ self . func_depth -= 1 ;
445+ return ;
383446 }
384447 Stmt :: AnnAssign ( a) => {
385448 self . process_ann_assign ( a) ;
@@ -446,6 +509,8 @@ impl AstPass for ModifiersPass<'_> {
446509 }
447510 let exports = std:: mem:: take ( & mut inner. exports ) ;
448511 let private_renames = std:: mem:: take ( & mut inner. private_renames ) ;
512+ let sealed_classes = std:: mem:: take ( & mut inner. sealed_classes ) ;
513+ let class_bases = std:: mem:: take ( & mut inner. class_bases ) ;
449514
450515 // typing import grouping mirrors lib.rs's preamble logic
451516 let mut typing_imports: Vec < & ' static str > = Vec :: new ( ) ;
@@ -514,6 +579,47 @@ impl AstPass for ModifiersPass<'_> {
514579 . join ( ", " ) ;
515580 ctx. epilogue . push ( format ! ( "__all__ = [{entries}]" ) ) ;
516581 }
582+
583+ for sealed in & sealed_classes {
584+ let subclasses: Vec < & str > = class_bases
585+ . iter ( )
586+ . filter ( |( _, bases, _) | bases. iter ( ) . any ( |base| base == sealed) )
587+ . map ( |( name, _, _) | name. as_str ( ) )
588+ . collect ( ) ;
589+ let tuple = match subclasses. as_slice ( ) {
590+ [ ] => "()" . to_owned ( ) ,
591+ [ single] => format ! ( "({single},)" ) ,
592+ many => format ! ( "({})" , many. join( ", " ) ) ,
593+ } ;
594+
595+ // Place the assignment right after the last subclass (or the sealed
596+ // class itself when it has none), so module-level code that follows
597+ // can read `__sealed_members__`. The module epilogue runs too late.
598+ let anchor_end = class_bases
599+ . iter ( )
600+ . filter ( |( name, bases, _) | {
601+ name == sealed || bases. iter ( ) . any ( |base| base == sealed)
602+ } )
603+ . map ( |( _, _, end) | * end)
604+ . max ( ) ;
605+ let Some ( anchor_end) = anchor_end else {
606+ continue ;
607+ } ;
608+ let anchor_end = usize:: from ( anchor_end) ;
609+
610+ let assignment = format ! ( "{sealed}.__sealed_members__ = {tuple}" ) ;
611+ // Insert at the start of the line after the anchor statement so the
612+ // edit never collides with `empty_declarations`' mid-line `: ...`.
613+ if let Some ( rel) = self . source [ anchor_end..] . find ( '\n' ) {
614+ let pos = TextSize :: try_from ( anchor_end + rel + 1 ) . expect ( "offset fits u32" ) ;
615+ ctx. text_edits
616+ . push ( ( TextRange :: empty ( pos) , format ! ( "{assignment}\n " ) ) ) ;
617+ } else {
618+ let pos = TextSize :: try_from ( self . source . len ( ) ) . expect ( "offset fits u32" ) ;
619+ ctx. text_edits
620+ . push ( ( TextRange :: empty ( pos) , format ! ( "\n {assignment}\n " ) ) ) ;
621+ }
622+ }
517623 }
518624}
519625
@@ -541,6 +647,22 @@ mod tests {
541647 ) ;
542648 }
543649
650+ #[ test]
651+ fn modifier_chain_with_non_leading_final ( ) {
652+ // `final` anywhere in a modifier chain must parse — it was missing from
653+ // the chain-continuation set, so `sealed final class` (final non-leading)
654+ // panicked the parser. modifiers commute, so both orders match.
655+ let expected = indoc ! { "
656+ from typing import final
657+ @final
658+ class A: ...
659+ class B(A): ...
660+ A.__sealed_members__ = (B,)
661+ " } ;
662+ check ( "sealed final class A\n class B(A)\n " , expected) ;
663+ check ( "final sealed class A\n class B(A)\n " , expected) ;
664+ }
665+
544666 #[ test]
545667 fn final_def ( ) {
546668 check (
@@ -909,6 +1031,16 @@ mod tests {
9091031 check ( "final override abstract a = 3\n " , "a = 3\n " ) ;
9101032 }
9111033
1034+ #[ test]
1035+ fn override_annotated_assign ( ) {
1036+ // annotated assignment modifiers must parse and strip just like the
1037+ // unannotated form — previously `override x: T` was a parse error while
1038+ // `override x = v` worked
1039+ check ( "override x: int = 1\n " , "x: int = 1\n " ) ;
1040+ check ( "final override x: int = 1\n " , "x: int = 1\n " ) ;
1041+ check ( "override x: int\n " , "x: int\n " ) ;
1042+ }
1043+
9121044 #[ test]
9131045 fn arbitrary_modifier_chain_def ( ) {
9141046 check (
@@ -1035,6 +1167,71 @@ mod tests {
10351167 ) ;
10361168 }
10371169
1170+ #[ test]
1171+ fn sealed_class_members ( ) {
1172+ check (
1173+ indoc ! { "
1174+ sealed class A
1175+ class B(A)
1176+ class C(A)
1177+ " } ,
1178+ indoc ! { "
1179+ class A: ...
1180+ class B(A): ...
1181+ class C(A): ...
1182+ A.__sealed_members__ = (B, C)
1183+ " } ,
1184+ ) ;
1185+ }
1186+
1187+ #[ test]
1188+ fn sealed_members_exclude_function_local_subclass ( ) {
1189+ // a subclass declared inside a function body is not a module-level name,
1190+ // so it must not appear in the module-level `__sealed_members__` tuple
1191+ // (it would be a `NameError`). matches ty's view of the member set.
1192+ check (
1193+ indoc ! { "
1194+ sealed class A
1195+ def make():
1196+ class C(A): ...
1197+ class B(A)
1198+ " } ,
1199+ indoc ! { "
1200+ class A: ...
1201+ def make():
1202+ class C(A): ...
1203+ class B(A): ...
1204+ A.__sealed_members__ = (B,)
1205+ " } ,
1206+ ) ;
1207+ }
1208+
1209+ #[ test]
1210+ fn sealed_class_single_member ( ) {
1211+ check (
1212+ indoc ! { "
1213+ sealed class A
1214+ class B(A)
1215+ " } ,
1216+ indoc ! { "
1217+ class A: ...
1218+ class B(A): ...
1219+ A.__sealed_members__ = (B,)
1220+ " } ,
1221+ ) ;
1222+ }
1223+
1224+ #[ test]
1225+ fn sealed_class_no_members ( ) {
1226+ check (
1227+ "sealed class A\n " ,
1228+ indoc ! { "
1229+ class A: ...
1230+ A.__sealed_members__ = ()
1231+ " } ,
1232+ ) ;
1233+ }
1234+
10381235 #[ test]
10391236 fn export_and_private_together_in_module ( ) {
10401237 check (
0 commit comments