Skip to content

Commit 00bda33

Browse files
committed
compose visibility modifiers with enum class and protocol
1 parent 5811072 commit 00bda33

7 files changed

Lines changed: 236 additions & 18 deletions

File tree

crates/by_transforms/src/transforms/enums.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,19 @@ fn has_marker(class: &StmtClassDef, source: &str, marker: &str) -> bool {
193193
})
194194
}
195195

196+
/// The visibility keyword prefix (`"private "` / `"export "`) to emit ahead of
197+
/// the enum's synthesized `class` line, or `""` for the default. `public` is
198+
/// normalized to the `export` marker by the parser.
199+
fn enum_visibility_prefix(class: &StmtClassDef, source: &str) -> &'static str {
200+
if has_marker(class, source, "private") {
201+
"private "
202+
} else if has_marker(class, source, "export") {
203+
"export "
204+
} else {
205+
""
206+
}
207+
}
208+
196209
fn check_no_nested_enums(body: &[Stmt], source: &str, nested: bool, errors: &mut Vec<String>) {
197210
for stmt in body {
198211
match stmt {
@@ -302,12 +315,20 @@ fn emit_enum(
302315
let name = class.name.as_str();
303316
let is_generic = class.type_params.is_some();
304317

318+
// a `private`/`export` modifier on the enum (`private enum class E:`) rides
319+
// through as a keyword prefix on the synthesized base class: phase-1's
320+
// `modifiers` pass then renames every module-level reference to the private
321+
// name (variant bases `class _E_V(E)`, attachments `E.V`) or registers the
322+
// export in `__all__` — the enum owns no visibility logic of its own.
323+
// each emitter derives the prefix from the class it already has
324+
305325
// `is_all_unit_enum` is the shared predicate with ty (it models such enums
306326
// as idiomatic `Enum`s): all variants unit AND no assignment members —
307327
// python's `Enum` would turn a class-body constant into a *member*,
308328
// silently diverging from the constant semantics the checker models
309329
if class.is_all_unit_enum() && !is_generic {
310-
emit_plain_enum(out, source, name, &variants, &members, imports);
330+
let vis = enum_visibility_prefix(class, source);
331+
emit_plain_enum(out, source, name, vis, &variants, &members, imports);
311332
} else {
312333
emit_sealed_hierarchy(
313334
out,
@@ -331,14 +352,15 @@ fn emit_plain_enum(
331352
out: &mut Out,
332353
source: &str,
333354
name: &str,
355+
vis: &str,
334356
variants: &[Variant],
335357
members: &[&Stmt],
336358
imports: &mut ImportSet,
337359
) {
338360
imports.add("enum", "Enum");
339361
imports.add("enum", "auto");
340362

341-
out.push_gen(&format!("class {name}(Enum):\n"));
363+
out.push_gen(&format!("{vis}class {name}(Enum):\n"));
342364
for variant in variants {
343365
out.push_gen(&format!("{}{} = auto()\n", variant.indent, variant.name));
344366
}
@@ -369,6 +391,9 @@ fn emit_sealed_hierarchy(
369391
imports: &mut ImportSet,
370392
min_version: PythonVersion,
371393
) {
394+
// visibility prefix derived from the class (see `lower`): `private`/`export`
395+
// ride through to phase-1's `modifiers` pass on the synthesized base line
396+
let vis = enum_visibility_prefix(class, source);
372397
// payload variants lower to `@final` frozen dataclasses; unit variants are
373398
// plain singleton values needing neither import
374399
if variants.iter().any(|v| v.kind != VariantKind::Unit) {
@@ -385,7 +410,7 @@ fn emit_sealed_hierarchy(
385410
.map(|tp| slice(source, tp.range()).to_string())
386411
.unwrap_or_default();
387412

388-
out.push_gen(&format!("class {name}{params}:\n"));
413+
out.push_gen(&format!("{vis}class {name}{params}:\n"));
389414
// ordinary members (methods, classmethods, constants) — copied verbatim,
390415
// already indented under the enum in the source. they may refer to variants
391416
// (`A.Foo`) freely: the references resolve lazily at call time, by which
@@ -620,6 +645,54 @@ mod tests {
620645
);
621646
}
622647

648+
#[test]
649+
fn export_enum_registers_in_all() {
650+
// `export enum class` rides through as an `export class` prefix on the
651+
// synthesized enum, so phase-1's modifiers pass registers it in `__all__`
652+
check(
653+
indoc! {"
654+
export enum class Color:
655+
case Red, Green
656+
"},
657+
indoc! {"
658+
from __future__ import annotations
659+
from enum import Enum, auto
660+
class Color(Enum):
661+
Red = auto()
662+
Green = auto()
663+
__all__ = [\"Color\"]
664+
"},
665+
);
666+
}
667+
668+
#[test]
669+
fn private_enum_renames_whole_hierarchy() {
670+
// `private enum class` becomes a `private class` prefix; modifiers renames
671+
// the declaration *and* every module-level reference (the variant bases and
672+
// attachments the enum synthesized), so the private name stays consistent
673+
check(
674+
indoc! {"
675+
private enum class Shape:
676+
case Circle(radius: int)
677+
"},
678+
indoc! {"
679+
from __future__ import annotations
680+
from dataclasses import dataclass
681+
from typing import final
682+
class _Shape:
683+
pass
684+
685+
@final
686+
@dataclass(frozen=True, slots=True)
687+
class _Shape_Circle(_Shape):
688+
radius: int
689+
_Shape_Circle.__name__ = \"Circle\"
690+
_Shape_Circle.__qualname__ = \"Shape.Circle\"
691+
_Shape.Circle = _Shape_Circle
692+
"},
693+
);
694+
}
695+
623696
#[test]
624697
fn all_unit_with_constant_avoids_enum_form() {
625698
// python's `Enum` turns every class-body assignment into a *member* —

crates/by_transforms/src/transforms/modifiers.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,31 @@ mod tests {
823823
);
824824
}
825825

826+
#[test]
827+
fn private_protocol() {
828+
// a visibility modifier composes with the `protocol` introducer: both the
829+
// `private` rename and the `protocol_class` rewrite lower by disjoint edits
830+
check(
831+
"private protocol Foo: ...\n",
832+
indoc! {"
833+
from typing import Protocol
834+
class _Foo(Protocol): ...
835+
"},
836+
);
837+
}
838+
839+
#[test]
840+
fn export_protocol() {
841+
check(
842+
"export protocol Foo: ...\n",
843+
indoc! {"
844+
from typing import Protocol
845+
class Foo(Protocol): ...
846+
__all__ = [\"Foo\"]
847+
"},
848+
);
849+
}
850+
826851
#[test]
827852
fn let_decl() {
828853
check(

crates/ruff_python_parser/src/parser/statement.rs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ impl<'src> Parser<'src> {
247247
self.error_if_not_basedpython(
248248
"`protocol` class syntax is not valid in .py files".to_string(),
249249
);
250-
return Some(self.parse_protocol_def(start));
250+
return Some(self.parse_protocol_def(start, DecoratorList::new()));
251251
}
252252
// `enum class E:` / `enum class E[T]:` — a "based enum" (an algebraic
253253
// sum type when its body has payload variants, an idiomatic `Enum` when
@@ -258,7 +258,7 @@ impl<'src> Parser<'src> {
258258
self.error_if_not_basedpython(
259259
"`enum class` declarations are not valid in .py files".to_string(),
260260
);
261-
return Some(self.parse_enum_def(start));
261+
return Some(self.parse_enum_def(start, DecoratorList::new()));
262262
}
263263
// a bare `enum E:` (no `class`) is not valid — based enums are written
264264
// `enum class E:`. report it but still parse the body for recovery
@@ -269,7 +269,7 @@ impl<'src> Parser<'src> {
269269
),
270270
self.current_token_range(),
271271
);
272-
return Some(self.parse_enum_def(start));
272+
return Some(self.parse_enum_def(start, DecoratorList::new()));
273273
}
274274
if kw == "decorator" && self.peek() == TokenKind::Def {
275275
self.error_if_not_basedpython(
@@ -358,6 +358,18 @@ impl<'src> Parser<'src> {
358358
} else {
359359
self.peek_nth(idx).0
360360
};
361+
// an introducer keyword (`enum class`, `protocol`) after a
362+
// modifier chain — `private enum class E:`, `export protocol P:`.
363+
// dispatch through the modifier path so the chain is carried
364+
// as decorators on the introduced class
365+
if (text == "protocol" && following == TokenKind::Name)
366+
|| (text == "enum" && following == TokenKind::Class)
367+
{
368+
self.error_if_not_basedpython(format!(
369+
"`{kw}` is a basedpython modifier and is not valid in .py files"
370+
));
371+
return Some(self.parse_with_modifier(start, DecoratorList::new()));
372+
}
361373
return match following {
362374
TokenKind::Equal if idx > 0 => {
363375
self.error_if_not_basedpython(format!(
@@ -524,7 +536,8 @@ impl<'src> Parser<'src> {
524536
if self.at(TokenKind::Def) || self.at(TokenKind::Class) || self.at(TokenKind::Async) {
525537
break;
526538
}
527-
// another modifier keyword follows — keep looping
539+
// another modifier keyword follows — keep looping. an `enum class` /
540+
// `protocol` introducer also ends the chain (handled after the loop)
528541
if self.at(TokenKind::Name) {
529542
let kw = self.src_text(self.current_token_range());
530543
// any modifier keyword may follow another, in any order — reuse
@@ -548,6 +561,20 @@ impl<'src> Parser<'src> {
548561
})
549562
} else if self.at(TokenKind::Def) {
550563
Stmt::FunctionDef(self.parse_function_definition(decorators, start))
564+
} else if self.at(TokenKind::Name)
565+
&& self.src_text(self.current_token_range()) == "protocol"
566+
&& self.peek() == TokenKind::Name
567+
{
568+
// `private protocol P:` — the modifier decorators ride alongside the
569+
// synthetic `protocol_class` marker; both lower by disjoint edits
570+
self.parse_protocol_def(start, decorators)
571+
} else if self.at(TokenKind::Name)
572+
&& self.src_text(self.current_token_range()) == "enum"
573+
&& self.peek() == TokenKind::Class
574+
{
575+
// `private enum class E:` — the enum lowering reads the visibility
576+
// markers to prefix its synthesized `class` line
577+
self.parse_enum_def(start, decorators)
551578
} else {
552579
Stmt::ClassDef(self.parse_class_definition(decorators, start))
553580
}
@@ -853,13 +880,16 @@ impl<'src> Parser<'src> {
853880
/// requiring an explicit `class` keyword. Produces a `ClassDef` with a synthetic
854881
/// `Decorator` (name `"protocol_class"`) that the `modifiers` transform rewrites
855882
/// to `class Foo(Protocol):`.
856-
fn parse_protocol_def(&mut self, start: TextSize) -> Stmt {
883+
fn parse_protocol_def(&mut self, start: TextSize, mut decorators: DecoratorList) -> Stmt {
857884
let protocol_start = self.current_token_range().start();
858885
self.bump(TokenKind::Name); // consume "protocol"
859886
let class_name_start = self.current_token_range().start();
860887
let decorator_range = TextRange::new(protocol_start, class_name_start);
861888

862-
let decorator = ast::Decorator {
889+
// the synthetic `protocol_class` marker follows any modifier decorators
890+
// (`private protocol P:`); the `modifiers` transform consumes each by a
891+
// disjoint range edit, so order between them does not matter
892+
decorators.push(ast::Decorator {
863893
expression: Expr::Name(ast::ExprName {
864894
id: Name::new_static("protocol_class"),
865895
ctx: ExprContext::Invalid,
@@ -868,7 +898,7 @@ impl<'src> Parser<'src> {
868898
}),
869899
range: decorator_range,
870900
node_index: AtomicNodeIndex::NONE,
871-
};
901+
});
872902

873903
let name = self.parse_identifier();
874904
let type_params = self.try_parse_type_params();
@@ -884,7 +914,7 @@ impl<'src> Parser<'src> {
884914

885915
Stmt::ClassDef(ast::StmtClassDef {
886916
range: self.node_range(start),
887-
decorator_list: vec![decorator].into(),
917+
decorator_list: decorators,
888918
name,
889919
type_params: type_params.map(Box::new),
890920
arguments,
@@ -903,7 +933,7 @@ impl<'src> Parser<'src> {
903933
///
904934
/// [`ClassDef`]: ast::StmtClassDef
905935
/// [`AnnAssign`]: ast::StmtAnnAssign
906-
fn parse_enum_def(&mut self, start: TextSize) -> Stmt {
936+
fn parse_enum_def(&mut self, start: TextSize, mut decorators: DecoratorList) -> Stmt {
907937
let enum_start = self.current_token_range().start();
908938
self.bump(TokenKind::Name); // consume "enum"
909939
// the canonical surface is `enum class E:`; the `class` keyword is part
@@ -913,7 +943,11 @@ impl<'src> Parser<'src> {
913943
let name_start = self.current_token_range().start();
914944
let decorator_range = TextRange::new(enum_start, name_start);
915945

916-
let decorator = ast::Decorator {
946+
// the synthetic `enum_def` marker follows any modifier decorators
947+
// (`private enum class E:`); the enum lowering re-emits the enum from
948+
// scratch and reads the visibility markers to prefix the synthesized
949+
// `class` line, so the standard `private`/`export` class path applies
950+
decorators.push(ast::Decorator {
917951
expression: Expr::Name(ast::ExprName {
918952
id: Name::new_static("enum_def"),
919953
ctx: ExprContext::Invalid,
@@ -922,7 +956,7 @@ impl<'src> Parser<'src> {
922956
}),
923957
range: decorator_range,
924958
node_index: AtomicNodeIndex::NONE,
925-
};
959+
});
926960

927961
let name = self.parse_identifier();
928962
let type_params = self.try_parse_type_params();
@@ -952,7 +986,7 @@ impl<'src> Parser<'src> {
952986

953987
Stmt::ClassDef(ast::StmtClassDef {
954988
range: self.node_range(start),
955-
decorator_list: vec![decorator].into(),
989+
decorator_list: decorators,
956990
name,
957991
type_params: type_params.map(Box::new),
958992
arguments: None,

crates/ty_python_semantic/resources/mdtest/basedpython_enums.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,27 @@ def f(e: WithConst) -> str:
272272
return "b"
273273
```
274274

275+
## visibility modifiers compose with `enum class`
276+
277+
a `private` or `export` modifier may precede `enum class`. `export` registers the enum in the
278+
module's `__all__`; `private` renames the enum — and every reference the lowering synthesizes (the
279+
variant subclasses, their attachments) — to an underscore-prefixed module-private name, so the
280+
public surface stays consistent. the variants are still reached qualified through the declared name.
281+
282+
```by
283+
export enum class Color:
284+
case Red, Green
285+
286+
private enum class Shape:
287+
case Circle(radius: float)
288+
case Square(side: float)
289+
290+
reveal_type(Color.Red) # revealed: Color.Red
291+
c = Shape.Circle(2.0)
292+
reveal_type(c.radius) # revealed: float
293+
reveal_type(Shape.Square(1.0)) # revealed: Square
294+
```
295+
275296
## variants require `case`
276297

277298
a bare name in an `enum class` body is a no-op statement, almost certainly a variant missing its
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# basedpython: visibility modifiers
2+
3+
`private` and `export`/`public` are transpile-time visibility modifiers: `export`/`public` add the
4+
symbol to the module's generated `__all__`, and `private` renames it with an underscore prefix (or,
5+
inside a class body, name-mangles it with `__`). they carry no type-level effect — the decorated
6+
class or function keeps its ordinary type rather than being erased to `Unknown`.
7+
8+
## a private class keeps its type
9+
10+
```by
11+
private class Box:
12+
value: int = 0
13+
14+
b = Box()
15+
reveal_type(b) # revealed: Box
16+
reveal_type(b.value) # revealed: int
17+
```
18+
19+
## an exported function keeps its signature
20+
21+
```by
22+
export def make(n: int) -> int:
23+
return n * 2
24+
25+
reveal_type(make) # revealed: def make(n: int) -> int
26+
reveal_type(make(3)) # revealed: int
27+
```
28+
29+
## `open` is a no-op modifier on the type
30+
31+
`open` marks a class freely subclassable (the default in Python); it only suppresses the closed-by-
32+
default checks, so the class type is unchanged.
33+
34+
```by
35+
open class Base:
36+
tag: str = "b"
37+
38+
reveal_type(Base().tag) # revealed: str
39+
```
40+
41+
## modifiers compose with each other
42+
43+
a chain of modifiers still resolves to the underlying declaration's type.
44+
45+
```by
46+
private final class Sealed:
47+
n: int = 1
48+
49+
reveal_type(Sealed().n) # revealed: int
50+
```

0 commit comments

Comments
 (0)