|
| 1 | +""" |
| 2 | +Exploring compile-time metaprogramming options for typed replace() methods. |
| 3 | +
|
| 4 | +Goal: Keep LST source clean and readable, but provide IDE autocomplete for replace(). |
| 5 | +""" |
| 6 | + |
| 7 | +from dataclasses import dataclass, fields, replace |
| 8 | +from typing import Optional, Self, dataclass_transform, TypeVar, get_type_hints |
| 9 | +from uuid import UUID |
| 10 | +import functools |
| 11 | + |
| 12 | + |
| 13 | +# ============================================================================= |
| 14 | +# OPTION 1: Custom decorator that adds a typed replace() method at import time |
| 15 | +# ============================================================================= |
| 16 | +# The decorator inspects fields and dynamically creates a replace() method |
| 17 | +# with the correct signature. BUT - IDEs won't see the generated signature. |
| 18 | + |
| 19 | +def add_replace_method(cls): |
| 20 | + """Decorator that adds a replace() method to a frozen dataclass.""" |
| 21 | + def _replace(self, **changes): |
| 22 | + return replace(self, **changes) |
| 23 | + cls.replace = _replace |
| 24 | + return cls |
| 25 | + |
| 26 | + |
| 27 | +# ============================================================================= |
| 28 | +# OPTION 2: .pyi stub files (RECOMMENDED) |
| 29 | +# ============================================================================= |
| 30 | +# Generate .pyi stub files that declare the typed replace() signature. |
| 31 | +# The runtime code uses plain replace(), but IDEs read the .pyi for types. |
| 32 | +# |
| 33 | +# tree.py (runtime - clean and simple): |
| 34 | +# |
| 35 | +# @dataclass(frozen=True, slots=True) |
| 36 | +# class Await(Py, Expression): |
| 37 | +# id: UUID |
| 38 | +# prefix: Space |
| 39 | +# markers: Markers |
| 40 | +# expression: Expression |
| 41 | +# type: Optional[JavaType] = None |
| 42 | +# |
| 43 | +# tree.pyi (stub file - provides IDE autocomplete): |
| 44 | +# |
| 45 | +# class Await(Py, Expression): |
| 46 | +# id: UUID |
| 47 | +# prefix: Space |
| 48 | +# markers: Markers |
| 49 | +# expression: Expression |
| 50 | +# type: Optional[JavaType] |
| 51 | +# |
| 52 | +# def replace( |
| 53 | +# self, |
| 54 | +# *, |
| 55 | +# id: UUID = ..., |
| 56 | +# prefix: Space = ..., |
| 57 | +# markers: Markers = ..., |
| 58 | +# expression: Expression = ..., |
| 59 | +# type: Optional[JavaType] = ..., |
| 60 | +# ) -> Self: ... |
| 61 | +# |
| 62 | +# The stub generator script reads tree.py and generates tree.pyi automatically. |
| 63 | + |
| 64 | + |
| 65 | +# ============================================================================= |
| 66 | +# OPTION 3: __init_subclass__ with dataclass_transform |
| 67 | +# ============================================================================= |
| 68 | +# Use PEP 681 dataclass_transform to tell type checkers about our pattern. |
| 69 | +# This is how attrs, pydantic, and other libraries get IDE support. |
| 70 | + |
| 71 | +@dataclass_transform(frozen_default=True) |
| 72 | +class LstBase: |
| 73 | + """Base class for all LST types that adds replace() method.""" |
| 74 | + |
| 75 | + def __init_subclass__(cls, **kwargs): |
| 76 | + super().__init_subclass__(**kwargs) |
| 77 | + # Apply dataclass decorator |
| 78 | + dataclass(frozen=True, slots=True, eq=False)(cls) |
| 79 | + |
| 80 | + def replace(self, **changes) -> Self: |
| 81 | + """Create a copy with the specified fields replaced.""" |
| 82 | + return replace(self, **changes) |
| 83 | + |
| 84 | + |
| 85 | +# Usage would be: |
| 86 | +# class Await(LstBase, Py, Expression): |
| 87 | +# id: UUID |
| 88 | +# prefix: Space |
| 89 | +# ... |
| 90 | +# |
| 91 | +# BUT: dataclass_transform alone doesn't give us typed kwargs on replace() |
| 92 | + |
| 93 | + |
| 94 | +# ============================================================================= |
| 95 | +# OPTION 4: Protocol + stub file hybrid |
| 96 | +# ============================================================================= |
| 97 | +# Define a Protocol that declares replace() exists, generate stubs for specifics |
| 98 | + |
| 99 | +from typing import Protocol |
| 100 | + |
| 101 | +class Replaceable(Protocol): |
| 102 | + def replace(self, **kwargs) -> Self: ... |
| 103 | + |
| 104 | + |
| 105 | +# ============================================================================= |
| 106 | +# OPTION 5: Use typing.overload in stub files |
| 107 | +# ============================================================================= |
| 108 | +# In .pyi files, we can use @overload to provide multiple typed signatures. |
| 109 | +# This is the most IDE-friendly approach. |
| 110 | +# |
| 111 | +# tree.pyi: |
| 112 | +# |
| 113 | +# from typing import overload |
| 114 | +# |
| 115 | +# class Await(Py, Expression): |
| 116 | +# @overload |
| 117 | +# def replace(self) -> Await: ... |
| 118 | +# @overload |
| 119 | +# def replace(self, *, id: UUID) -> Await: ... |
| 120 | +# @overload |
| 121 | +# def replace(self, *, prefix: Space) -> Await: ... |
| 122 | +# @overload |
| 123 | +# def replace(self, *, id: UUID, prefix: Space) -> Await: ... |
| 124 | +# # ... combinatorial explosion, not practical |
| 125 | +# |
| 126 | +# Better: Just use keyword-only args with defaults in stub: |
| 127 | +# |
| 128 | +# def replace( |
| 129 | +# self, |
| 130 | +# *, |
| 131 | +# id: UUID = ..., |
| 132 | +# prefix: Space = ..., |
| 133 | +# ) -> Await: ... |
| 134 | + |
| 135 | + |
| 136 | +# ============================================================================= |
| 137 | +# STUB GENERATOR SCRIPT (for Option 2) |
| 138 | +# ============================================================================= |
| 139 | +import ast |
| 140 | +import inspect |
| 141 | +from pathlib import Path |
| 142 | + |
| 143 | + |
| 144 | +def generate_stubs_for_module(source_path: Path) -> str: |
| 145 | + """Generate .pyi stub content for a dataclass module with replace() methods.""" |
| 146 | + with open(source_path) as f: |
| 147 | + source = f.read() |
| 148 | + |
| 149 | + tree = ast.parse(source) |
| 150 | + stub_lines = [] |
| 151 | + |
| 152 | + # Add imports |
| 153 | + stub_lines.append("from typing import Optional, Self") |
| 154 | + stub_lines.append("from uuid import UUID") |
| 155 | + stub_lines.append("# ... other imports") |
| 156 | + stub_lines.append("") |
| 157 | + |
| 158 | + for node in ast.walk(tree): |
| 159 | + if isinstance(node, ast.ClassDef): |
| 160 | + # Check if it's a dataclass (has @dataclass decorator) |
| 161 | + is_dataclass = any( |
| 162 | + (isinstance(d, ast.Name) and d.id == 'dataclass') or |
| 163 | + (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == 'dataclass') |
| 164 | + for d in node.decorator_list |
| 165 | + ) |
| 166 | + |
| 167 | + if is_dataclass: |
| 168 | + # Extract fields (annotated assignments) |
| 169 | + fields_info = [] |
| 170 | + for item in node.body: |
| 171 | + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): |
| 172 | + field_name = item.target.id |
| 173 | + # Get the annotation as string |
| 174 | + field_type = ast.unparse(item.annotation) |
| 175 | + has_default = item.value is not None |
| 176 | + fields_info.append((field_name, field_type, has_default)) |
| 177 | + |
| 178 | + # Generate stub class |
| 179 | + bases = ", ".join(ast.unparse(b) for b in node.bases) |
| 180 | + stub_lines.append(f"class {node.name}({bases}):") |
| 181 | + |
| 182 | + # Add field declarations |
| 183 | + for name, type_str, _ in fields_info: |
| 184 | + stub_lines.append(f" {name}: {type_str}") |
| 185 | + |
| 186 | + stub_lines.append("") |
| 187 | + |
| 188 | + # Add typed replace() method |
| 189 | + stub_lines.append(" def replace(") |
| 190 | + stub_lines.append(" self,") |
| 191 | + stub_lines.append(" *,") |
| 192 | + for name, type_str, _ in fields_info: |
| 193 | + stub_lines.append(f" {name}: {type_str} = ...,") |
| 194 | + stub_lines.append(" ) -> Self: ...") |
| 195 | + stub_lines.append("") |
| 196 | + |
| 197 | + return "\n".join(stub_lines) |
| 198 | + |
| 199 | + |
| 200 | +# ============================================================================= |
| 201 | +# DEMONSTRATION |
| 202 | +# ============================================================================= |
| 203 | + |
| 204 | +if __name__ == "__main__": |
| 205 | + # Example LST class (clean source) |
| 206 | + @dataclass(frozen=True, slots=True, eq=False) |
| 207 | + class Space: |
| 208 | + whitespace: str = "" |
| 209 | + comments: list = None |
| 210 | + |
| 211 | + @dataclass(frozen=True, slots=True, eq=False) |
| 212 | + class Markers: |
| 213 | + id: UUID |
| 214 | + markers: list = None |
| 215 | + |
| 216 | + @dataclass(frozen=True, slots=True, eq=False) |
| 217 | + class Await: |
| 218 | + id: UUID |
| 219 | + prefix: Space |
| 220 | + markers: Markers |
| 221 | + expression: object # Would be Expression |
| 222 | + type: Optional[str] = None |
| 223 | + |
| 224 | + # Simple replace method - runtime works, but IDE doesn't know the kwargs |
| 225 | + def replace(self, **changes) -> "Await": |
| 226 | + return replace(self, **changes) |
| 227 | + |
| 228 | + # Usage |
| 229 | + a = Await( |
| 230 | + id=UUID("12345678-1234-5678-1234-567812345678"), |
| 231 | + prefix=Space(), |
| 232 | + markers=Markers(id=UUID("12345678-1234-5678-1234-567812345678")), |
| 233 | + expression=None |
| 234 | + ) |
| 235 | + |
| 236 | + # This works at runtime, but IDE won't autocomplete 'expression' |
| 237 | + a2 = a.replace(expression="new_expr") |
| 238 | + print(f"Original: {a}") |
| 239 | + print(f"Replaced: {a2}") |
| 240 | + |
| 241 | + # Generate stub for this file |
| 242 | + print("\n--- Generated Stub ---") |
| 243 | + print(generate_stubs_for_module(Path(__file__))) |
0 commit comments