pyscriptc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. pyscriptc/__init__.py +3 -0
  2. pyscriptc/__main__.py +3 -0
  3. pyscriptc/_posthog_token.py +1 -0
  4. pyscriptc/builtins.py +156 -0
  5. pyscriptc/canonical.py +167 -0
  6. pyscriptc/check.py +49 -0
  7. pyscriptc/cli.py +271 -0
  8. pyscriptc/coverage.py +388 -0
  9. pyscriptc/debug.py +120 -0
  10. pyscriptc/diagnostics.py +179 -0
  11. pyscriptc/errors.py +56 -0
  12. pyscriptc/ir.py +365 -0
  13. pyscriptc/llvm.py +2129 -0
  14. pyscriptc/log.py +127 -0
  15. pyscriptc/lower/__init__.py +1071 -0
  16. pyscriptc/lower/_calls.py +1499 -0
  17. pyscriptc/lower/_classes.py +969 -0
  18. pyscriptc/lower/_core.py +301 -0
  19. pyscriptc/lower/_expr.py +886 -0
  20. pyscriptc/lower/_stdlib.py +491 -0
  21. pyscriptc/lower/_stdlib_shared.py +93 -0
  22. pyscriptc/lower/_stmt.py +1173 -0
  23. pyscriptc/package.py +249 -0
  24. pyscriptc/restlower.py +1008 -0
  25. pyscriptc/runtime/ps_argv.c +28 -0
  26. pyscriptc/runtime/ps_b64.c +168 -0
  27. pyscriptc/runtime/ps_coll.c +133 -0
  28. pyscriptc/runtime/ps_dt.c +388 -0
  29. pyscriptc/runtime/ps_hash.c +411 -0
  30. pyscriptc/runtime/ps_help.c +219 -0
  31. pyscriptc/runtime/ps_hmac.c +120 -0
  32. pyscriptc/runtime/ps_io.c +198 -0
  33. pyscriptc/runtime/ps_island.c +272 -0
  34. pyscriptc/runtime/ps_json.c +402 -0
  35. pyscriptc/runtime/ps_mix.c +47 -0
  36. pyscriptc/runtime/ps_more.c +1089 -0
  37. pyscriptc/runtime/ps_osmore.c +447 -0
  38. pyscriptc/runtime/ps_path.c +547 -0
  39. pyscriptc/runtime/ps_re.c +1102 -0
  40. pyscriptc/runtime/ps_rt.c +1175 -0
  41. pyscriptc/runtime/ps_rt.h +189 -0
  42. pyscriptc/runtime/ps_struct.c +291 -0
  43. pyscriptc/runtime/ps_time.c +207 -0
  44. pyscriptc/runtime/ps_unicode.c +1521 -0
  45. pyscriptc/runtime/ps_url.c +327 -0
  46. pyscriptc/stdlib_queue.py +191 -0
  47. pyscriptc/stdmore.py +1596 -0
  48. pyscriptc/telemetry.py +219 -0
  49. pyscriptc-0.1.0.dist-info/METADATA +187 -0
  50. pyscriptc-0.1.0.dist-info/RECORD +54 -0
  51. pyscriptc-0.1.0.dist-info/WHEEL +5 -0
  52. pyscriptc-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyscriptc-0.1.0.dist-info/licenses/LICENSE +201 -0
  54. pyscriptc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,969 @@
1
+ """Class lowering and typing-annotation machinery.
2
+
3
+ `ClassesMixin` collects `class` statements (dataclasses, enums, synthetic
4
+ tuple/deque/OrderedDict shapes), lowers method signatures, and resolves
5
+ typing annotations. Moved verbatim from `lower.py` via `lower/__init__.py`;
6
+ no signature or behavior change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import copy
13
+ from typing import Any, NoReturn
14
+
15
+ from pyscriptc.builtins import EXCEPTIONS
16
+ from pyscriptc.errors import span_of
17
+ from pyscriptc.ir import type_key, types_equal
18
+ from pyscriptc.lower._core import (
19
+ _DICT_FORMS,
20
+ _LIST_FORMS,
21
+ _SET_FORMS,
22
+ Fence,
23
+ _name,
24
+ _show,
25
+ )
26
+ from pyscriptc.lower._stmt import _always_returns, _is_docstring
27
+ from pyscriptc.restlower import alias_name, path_attr
28
+ from pyscriptc.restlower import method_spec as synth_method
29
+ from pyscriptc.stdmore import ALWAYS_CLASSES, CLASS_FIELDS
30
+
31
+ _UNWRAP_FORMS = frozenset({"Final", "ClassVar", "Annotated", "Required", "NotRequired"})
32
+ _DYNAMIC_ANNOTATIONS = frozenset({"object", "Any", "Protocol", "TypeVar", "Generic", "Callable", "ABC"})
33
+ _IO_HANDLES = {"BinaryIO": "BytesIO", "TextIO": "TextIO"}
34
+
35
+
36
+ class ClassesMixin:
37
+ def _install_class_export(self, local: str, item: dict[str, Any], stmt: ast.AST, fences: list[Fence]) -> None:
38
+ name = str(item["name"])
39
+ if name in self.classes and not self.classes[name].get("imported"):
40
+ fences.append(Fence("PS2002", f"{local} is already bound", span_of(stmt, self.filename), None))
41
+ return
42
+ info = copy.deepcopy(item["info"])
43
+ info["imported"] = True
44
+ self.classes[name] = info
45
+ self.class_aliases[local] = name
46
+ self.known_classes.add(local)
47
+ self.known_classes.add(name)
48
+ self.declared.add(local)
49
+ self.declared.add(name)
50
+
51
+ def _install_synthetics(self, class_nodes: list[ast.ClassDef], fences: list[Fence]) -> None:
52
+ self.synthetics.update(ALWAYS_CLASSES)
53
+ specs: dict[str, list[dict[str, Any]]] = {}
54
+ if "Pattern" in self.synthetics:
55
+ specs["Pattern"] = [{"name": "pattern", "type": {"kind": "str"}}]
56
+ if "SplitResult" in self.synthetics:
57
+ specs["SplitResult"] = [
58
+ {"name": name, "type": {"kind": "str"}} for name in ("scheme", "netloc", "path", "query", "fragment")
59
+ ]
60
+ for name in self.synthetics:
61
+ if name in CLASS_FIELDS:
62
+ specs[name] = CLASS_FIELDS[name]
63
+ user_names = {node.name for node in class_nodes}
64
+ for name, fields in specs.items():
65
+ if name in user_names or name in self.declared:
66
+ clash = next((node for node in class_nodes if node.name == name), None)
67
+ fences.append(
68
+ Fence(
69
+ "PS2002",
70
+ f"{name} clashes with a lowered stdlib type",
71
+ span_of(clash if clash is not None else ast.Pass(), self.filename),
72
+ None,
73
+ )
74
+ )
75
+ continue
76
+ self.declared.add(name)
77
+ self.classes[name] = {
78
+ "fields": fields,
79
+ "own_fields": list(fields),
80
+ "methods": {},
81
+ "method_kind": {},
82
+ "method_owner": {},
83
+ "synthetic": True,
84
+ "base": None,
85
+ }
86
+
87
+ def _collect_class(self, node: ast.ClassDef) -> None:
88
+ if node.keywords:
89
+ self.fail(node, "PS1203", "class keywords are not lowered")
90
+ if len(node.bases) > 1:
91
+ self.fail(node, "PS1203", "multiple inheritance is not lowered")
92
+ if node.name in self.bindings or node.name in self.sigs or node.name in self.declared:
93
+ self.fail(node, "PS2002", f"{node.name} is already bound")
94
+ deco = self._class_decorator(node)
95
+ # class C(object) and class C(object,) are the same class as class C.
96
+ # object.__init__ is not emitted. Two or more bases already fenced above.
97
+ base = None
98
+ if node.bases and not self._is_builtin_object(node.bases[0]):
99
+ base = self._resolve_base(node.bases[0])
100
+ if deco == "dataclass" and base is not None:
101
+ self.fail(node, "PS2002", "a dataclass has no base")
102
+ if base is not None and base["kind"] == "enum":
103
+ self._collect_enum(node, str(base["name"]))
104
+ return
105
+ self.declared.add(node.name)
106
+ parent_name = base["name"] if base is not None else None
107
+ parent = self.classes.get(parent_name) if parent_name and base and base["kind"] == "class" else None
108
+ inherited = list(parent["fields"]) if parent is not None else []
109
+ inherited_names = {field["name"] for field in inherited}
110
+ fields: list[dict[str, Any]] = []
111
+ methods: list[tuple[ast.FunctionDef, str]] = []
112
+ seen: set[str] = set()
113
+ seen_default = False
114
+ for stmt in node.body:
115
+ if _is_docstring(stmt) or isinstance(stmt, ast.Pass):
116
+ continue
117
+ try:
118
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
119
+ if stmt.value is not None and deco != "dataclass":
120
+ self.fail(stmt, "PS1204", "class fields are declared with an annotation and no value")
121
+ if stmt.target.id in seen or stmt.target.id in inherited_names:
122
+ self.fail(stmt, "PS2002", f"duplicate field {stmt.target.id}")
123
+ typ = self._annotation(stmt.annotation)
124
+ if typ["kind"] == "bool":
125
+ self.fail(stmt, "PS2002", "bool fields are not lowered")
126
+ field: dict[str, Any] = {"name": stmt.target.id, "type": typ}
127
+ if stmt.value is not None:
128
+ saved_prelude = self.prelude
129
+ self.prelude = []
130
+ try:
131
+ default = self._expr(stmt.value, typ)
132
+ finally:
133
+ self.prelude = saved_prelude
134
+ self._expect(stmt, default["type"], typ, stmt.target.id)
135
+ if default["kind"] != "const":
136
+ self.fail(stmt, "PS2002", "a dataclass default must be a constant")
137
+ field["default"] = default
138
+ seen_default = True
139
+ elif seen_default:
140
+ self.fail(stmt, "PS2002", "a non-default field follows a default")
141
+ seen.add(stmt.target.id)
142
+ fields.append(field)
143
+ continue
144
+ if isinstance(stmt, ast.FunctionDef):
145
+ kind = self._method_decorator(stmt)
146
+ if stmt.name == "__init__" and kind != "method":
147
+ self.fail(stmt, "PS1201", "decorators are not lowered")
148
+ methods.append((stmt, kind))
149
+ continue
150
+ self.fail(stmt, "PS1204", f"{type(stmt).__name__} in a class body is not lowered")
151
+ except Fence as exc:
152
+ self.caught.append(exc)
153
+ method_sigs: dict[str, tuple[list[dict[str, Any]], dict[str, Any]]] = {}
154
+ kinds: dict[str, str] = {}
155
+ owners: dict[str, str] = {}
156
+ if parent is not None:
157
+ method_sigs.update(parent["methods"])
158
+ kinds.update(parent["method_kind"])
159
+ owners.update(parent["method_owner"])
160
+ wrote_init = False
161
+ # A field fence leaves the field set unknown; a method signature fence
162
+ # only drops that one method. Count each kind so the catalog pass can
163
+ # tell them apart.
164
+ body_fences = len(self.caught)
165
+ sig_fences = 0
166
+ for method, kind in methods:
167
+ try:
168
+ method_sigs[method.name] = self._signature(method, node.name, kind)
169
+ kinds[method.name] = kind
170
+ owners[method.name] = node.name
171
+ if method.name == "__init__":
172
+ wrote_init = True
173
+ except Fence as exc:
174
+ self.caught.append(exc)
175
+ sig_fences += 1
176
+ # The catalog pass still exports the shape when only method signatures
177
+ # fenced: the fenced methods drop out and the rest resolves, the same
178
+ # rule a fenced module function follows (it does not export, its
179
+ # siblings still do). Without this, a mixin or base class with one
180
+ # unlowerable annotation is invisible to every module that subclasses
181
+ # it, and the import fences as "not exported by this package". The
182
+ # lowering pass keeps the strict abort so an emitted class stays
183
+ # all-or-nothing.
184
+ if len(self.caught) > body_fences + sig_fences or (sig_fences and not self.cataloguing):
185
+ return
186
+ generated: str | None = None
187
+ if deco == "dataclass":
188
+ if wrote_init:
189
+ self.fail(node, "PS2002", "a dataclass does not take a written __init__")
190
+ params = [{"name": "self", "type": {"kind": "class", "name": node.name}}]
191
+ for field in fields:
192
+ param: dict[str, Any] = {"name": field["name"], "type": field["type"]}
193
+ if "default" in field:
194
+ param["default"] = field["default"]
195
+ params.append(param)
196
+ method_sigs["__init__"] = (params, {"kind": "none"})
197
+ kinds["__init__"] = "method"
198
+ owners["__init__"] = node.name
199
+ generated = "dataclass"
200
+ elif parent is not None and not wrote_init and "__init__" in parent["methods"]:
201
+ if fields:
202
+ self.fail(node, "PS1001", f"{node.name} needs __init__")
203
+ parent_params = parent["methods"]["__init__"][0]
204
+ params = [{"name": "self", "type": {"kind": "class", "name": node.name}}, *parent_params[1:]]
205
+ method_sigs["__init__"] = (params, {"kind": "none"})
206
+ kinds["__init__"] = "method"
207
+ owners["__init__"] = node.name
208
+ generated = "forward"
209
+ elif parent is not None and fields and not wrote_init:
210
+ self.fail(node, "PS1001", f"{node.name} needs __init__")
211
+ exception = bool(base and (base["kind"] == "exception" or (parent and parent.get("exception"))))
212
+ info: dict[str, Any] = {
213
+ "fields": [*inherited, *fields],
214
+ "own_fields": fields,
215
+ "methods": method_sigs,
216
+ "method_kind": kinds,
217
+ "method_owner": owners,
218
+ "base": parent_name,
219
+ "node": node,
220
+ }
221
+ if generated is not None:
222
+ info["generated_init"] = generated
223
+ if exception:
224
+ info["exception"] = True
225
+ info["exc_code"] = self.exc_n
226
+ self.exc_n += 1
227
+ self.classes[node.name] = info
228
+
229
+ def _class_decorator(self, node: ast.ClassDef) -> str | None:
230
+ if not node.decorator_list:
231
+ return None
232
+ if len(node.decorator_list) != 1 or not self._is_dataclass_deco(node.decorator_list[0]):
233
+ self.fail(node, "PS1201", "decorators are not lowered")
234
+ return "dataclass"
235
+
236
+ def _is_dataclass_deco(self, dec: ast.expr) -> bool:
237
+ target = dec
238
+ if isinstance(dec, ast.Call):
239
+ if dec.args or dec.keywords:
240
+ return False
241
+ target = dec.func
242
+ if isinstance(target, ast.Name):
243
+ binding = self.bindings.get(target.id)
244
+ return binding is not None and binding.module == "dataclasses" and binding.attr == "dataclass"
245
+ if isinstance(target, ast.Attribute):
246
+ resolved = self._resolve_module(target)
247
+ return resolved is not None and resolved[1] == "dataclasses" and resolved[2] == "dataclass"
248
+ return False
249
+
250
+ def _method_decorator(self, fn: ast.FunctionDef) -> str:
251
+ if not fn.decorator_list:
252
+ return "method"
253
+ if len(fn.decorator_list) != 1:
254
+ self.fail(fn, "PS1201", "decorators are not lowered")
255
+ dec = fn.decorator_list[0]
256
+ target = dec
257
+ if isinstance(dec, ast.Call):
258
+ if dec.args or dec.keywords:
259
+ self.fail(fn, "PS1201", "decorators are not lowered")
260
+ target = dec.func
261
+ if isinstance(target, ast.Name) and target.id in {"property", "staticmethod", "classmethod"}:
262
+ return {"property": "property", "staticmethod": "static", "classmethod": "class"}[target.id]
263
+ self.fail(fn, "PS1201", "decorators are not lowered")
264
+ raise AssertionError("unreachable")
265
+
266
+ def _is_builtin_object(self, node: ast.expr) -> bool:
267
+ if not isinstance(node, ast.Name) or node.id != "object":
268
+ return False
269
+ if node.id in self.classes or node.id in self.bindings or node.id in self.sigs or node.id in self.constants:
270
+ return False
271
+ if node.id in self.declared:
272
+ return False
273
+ return True
274
+
275
+ def _resolve_base(self, node: ast.expr) -> dict[str, str]:
276
+ enum = self._enum_base_name(node)
277
+ if enum is not None:
278
+ return {"kind": "enum", "name": enum}
279
+ if isinstance(node, ast.Name) and node.id in EXCEPTIONS:
280
+ return {"kind": "exception", "name": node.id}
281
+ if isinstance(node, ast.Name) and node.id in self.classes:
282
+ info = self.classes[node.id]
283
+ if info.get("synthetic"):
284
+ self.fail(node, "PS2002", "subclassing a stdlib type is not lowered")
285
+ if info.get("enum"):
286
+ self.fail(node, "PS2002", "an enum is not a base")
287
+ return {"kind": "class", "name": node.id}
288
+ self.fail(node, "PS2002", "this class base is not lowered")
289
+ raise AssertionError("unreachable")
290
+
291
+ def _enum_base_name(self, node: ast.expr) -> str | None:
292
+ if isinstance(node, ast.Name):
293
+ binding = self.bindings.get(node.id)
294
+ if binding is not None and binding.module == "enum" and binding.attr in {"Enum", "IntEnum"}:
295
+ return binding.attr
296
+ return None
297
+ if isinstance(node, ast.Attribute):
298
+ resolved = self._resolve_module(node)
299
+ if resolved is not None and resolved[1] == "enum" and resolved[2] in {"Enum", "IntEnum"}:
300
+ return resolved[2]
301
+ return None
302
+
303
+ def _collect_enum(self, node: ast.ClassDef, which: str) -> None:
304
+ self.declared.add(node.name)
305
+ members: dict[str, int] = {}
306
+ for stmt in node.body:
307
+ if _is_docstring(stmt) or isinstance(stmt, ast.Pass):
308
+ continue
309
+ try:
310
+ target, value = self._enum_assign(stmt)
311
+ number = _const_int_index(value)
312
+ if number is None:
313
+ self.fail(stmt, "PS2002", "an enum member is an integer constant")
314
+ if target in members:
315
+ self.fail(stmt, "PS2002", f"duplicate enum member {target}")
316
+ members[target] = number
317
+ except Fence as exc:
318
+ self.caught.append(exc)
319
+ if self.caught:
320
+ return
321
+ if not members:
322
+ self.fail(node, "PS2002", "an enum needs a member")
323
+ self.classes[node.name] = {
324
+ "fields": [],
325
+ "own_fields": [],
326
+ "methods": {},
327
+ "method_kind": {},
328
+ "method_owner": {},
329
+ "base": which,
330
+ "enum": True,
331
+ "members": members,
332
+ "node": node,
333
+ }
334
+
335
+ def _enum_assign(self, stmt: ast.stmt) -> tuple[str, ast.expr]:
336
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
337
+ return stmt.targets[0].id, stmt.value
338
+ if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
339
+ return stmt.target.id, stmt.value
340
+ self.fail(stmt, "PS2002", "an enum body is integer constants")
341
+ raise AssertionError("unreachable")
342
+
343
+ def _lower_class(self, node: ast.ClassDef) -> dict[str, Any] | None:
344
+ info = self.classes[node.name]
345
+ if info.get("enum"):
346
+ return None
347
+ own_names = {field["name"] for field in info.get("own_fields", info["fields"])}
348
+ methods: list[dict[str, Any]] = []
349
+ for stmt in node.body:
350
+ if not isinstance(stmt, ast.FunctionDef):
351
+ continue
352
+ if info["method_owner"].get(stmt.name) != node.name:
353
+ continue
354
+ self.function = f"{node.name}.{stmt.name}"
355
+ self.temp_n = 0
356
+ self.prelude = []
357
+ self.fn_globals = self._globals_for(stmt.body)
358
+ params, ret = info["methods"][stmt.name]
359
+ kind = info["method_kind"].get(stmt.name, "method")
360
+ self.locals = {param["name"]: param["type"] for param in params}
361
+ self.frozen_names = set()
362
+ self.fn_body = list(stmt.body)
363
+ if kind in {"method", "property"}:
364
+ self.locals["self"] = {"kind": "class", "name": node.name}
365
+ body = self._body(list(stmt.body))
366
+ if self.caught:
367
+ continue
368
+ if stmt.name == "__init__":
369
+ assigned = _assigned_fields(stmt.body)
370
+ missing = sorted(own_names - assigned)
371
+ if missing:
372
+ self.fail(stmt, "PS1001", f"__init__ does not assign {', '.join(missing)}")
373
+ if ret["kind"] != "none" and not _always_returns(body):
374
+ self.fail(stmt, "PS1004", f"{stmt.name} can fall off the end and return None")
375
+ methods.append(self._method_ir(stmt.name, params, ret, body, span_of(stmt, self.filename)))
376
+ if self.caught:
377
+ return None
378
+ generated = self._generated_method(node, info)
379
+ if generated is not None:
380
+ methods.append(generated)
381
+ return {
382
+ "name": node.name,
383
+ "fields": info["fields"],
384
+ "methods": methods,
385
+ "span": span_of(node, self.filename),
386
+ }
387
+
388
+ def _method_ir(
389
+ self,
390
+ name: str,
391
+ params: list[dict[str, Any]],
392
+ ret: dict[str, Any],
393
+ body: list[dict[str, Any]],
394
+ span: dict[str, Any],
395
+ ) -> dict[str, Any]:
396
+ return {
397
+ "name": name,
398
+ "params": params,
399
+ "returnType": ret,
400
+ "locals": [{"name": local, "type": typ} for local, typ in self.locals.items()]
401
+ if self.locals
402
+ else [{"name": param["name"], "type": param["type"]} for param in params],
403
+ "body": body,
404
+ "span": span,
405
+ }
406
+
407
+ def _generated_method(self, node: ast.ClassDef, info: dict[str, Any]) -> dict[str, Any] | None:
408
+ mode = info.get("generated_init")
409
+ if mode is None:
410
+ return None
411
+ params, ret = info["methods"]["__init__"]
412
+ span = span_of(node, self.filename)
413
+ self_type = {"kind": "class", "name": node.name}
414
+ if mode == "dataclass":
415
+ body = [
416
+ {
417
+ "kind": "setattr",
418
+ "object": _name("self", self_type, span),
419
+ "name": field["name"],
420
+ "value": _name(field["name"], field["type"], span),
421
+ "span": span,
422
+ }
423
+ for field in info["own_fields"]
424
+ ]
425
+ else:
426
+ parent = info["base"]
427
+ args = [_name(param["name"], param["type"], span) for param in params]
428
+ body = [
429
+ {
430
+ "kind": "expr",
431
+ "expr": {
432
+ "kind": "call",
433
+ "callee": {"kind": "function", "name": f"cls.{parent}.__init__"},
434
+ "args": args,
435
+ "type": {"kind": "none"},
436
+ "span": span,
437
+ },
438
+ "span": span,
439
+ }
440
+ ]
441
+ self.locals = {param["name"]: param["type"] for param in params}
442
+ return self._method_ir("__init__", params, ret, body, span)
443
+
444
+ def _signature(
445
+ self, fn: ast.FunctionDef, class_name: str | None, kind: str = "method"
446
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
447
+ if fn.args.kwarg or fn.args.kwonlyargs or fn.args.posonlyargs:
448
+ self.fail(fn, "PS1202", f"{fn.name} uses a parameter form that is not lowered")
449
+ args = list(fn.args.args)
450
+ params: list[dict[str, Any]] = []
451
+ if kind == "property":
452
+ if fn.args.vararg is not None or len(args) != 1 or args[0].arg != "self":
453
+ self.fail(fn, "PS2002", "a property's only parameter is self")
454
+ if args[0].annotation is not None:
455
+ self.fail(args[0], "PS2002", "self is not annotated")
456
+ if class_name is None:
457
+ self.fail(fn, "PS1201", "decorators are not lowered")
458
+ params.append({"name": "self", "type": {"kind": "class", "name": class_name}})
459
+ args = []
460
+ elif kind == "class":
461
+ if not args or args[0].arg != "cls":
462
+ self.fail(fn, "PS2002", "a classmethod's first parameter must be cls")
463
+ if args[0].annotation is not None:
464
+ self.fail(args[0], "PS2002", "cls is not annotated")
465
+ # cls is not a value. Class.method(...) does not pass the class object.
466
+ args = args[1:]
467
+ elif kind == "static":
468
+ pass
469
+ elif class_name is not None:
470
+ if not args or args[0].arg != "self":
471
+ self.fail(fn, "PS2002", "a method's first parameter must be self")
472
+ if args[0].annotation is not None:
473
+ self.fail(args[0], "PS2002", "self is not annotated")
474
+ params.append({"name": "self", "type": {"kind": "class", "name": class_name}})
475
+ args = args[1:]
476
+ defaults = list(fn.args.defaults)
477
+ missing = len(args) - len(defaults)
478
+ for index, arg in enumerate(args):
479
+ param = _param(self, arg)
480
+ if index >= missing:
481
+ default_node = defaults[index - missing]
482
+ value = self._expr(default_node, param["type"])
483
+ self._expect(default_node, value["type"], param["type"], arg.arg)
484
+ if value["kind"] != "const":
485
+ self.fail(default_node, "PS2002", "a default must be a constant")
486
+ param["default"] = value
487
+ params.append(param)
488
+ if fn.args.vararg is not None:
489
+ vararg = fn.args.vararg
490
+ if vararg.annotation is None:
491
+ self.fail(vararg, "PS1001", f"parameter {vararg.arg} needs an annotation")
492
+ elem = self._annotation(vararg.annotation)
493
+ if elem["kind"] == "bool":
494
+ self.fail(vararg, "PS2002", "list[bool] is not lowered")
495
+ params.append({"name": vararg.arg, "type": {"kind": "list", "elem": elem}, "vararg": True})
496
+ if fn.returns is None:
497
+ self.fail(fn, "PS1001", f"{fn.name} needs a return annotation")
498
+ ret = self._annotation(fn.returns)
499
+ if class_name is not None and fn.name == "__init__" and ret["kind"] != "none":
500
+ self.fail(fn, "PS1003", "__init__ returns None")
501
+ return params, ret
502
+
503
+ def _construct(self, node: ast.Call, span: dict[str, Any], name: str) -> dict[str, Any]:
504
+ info = self.classes[name]
505
+ init = info["methods"].get("__init__")
506
+ if init is None:
507
+ if node.args or node.keywords:
508
+ self.fail(node, "PS2003", f"{name} takes no arguments")
509
+ return {
510
+ "kind": "call",
511
+ "callee": {"kind": "class", "name": name},
512
+ "args": [],
513
+ "type": {"kind": "class", "name": name},
514
+ "span": span,
515
+ }
516
+ params = [param for param in init[0] if param["name"] != "self"]
517
+ args = self._fill_args(node, params, name)
518
+ return {
519
+ "kind": "call",
520
+ "callee": {"kind": "class", "name": name},
521
+ "args": args,
522
+ "type": {"kind": "class", "name": name},
523
+ "span": span,
524
+ }
525
+
526
+ def _reject_banned_type(self, node: ast.AST, typ: dict[str, Any]) -> None:
527
+ if typ["kind"] == "list" and typ["elem"]["kind"] == "bool":
528
+ self.fail(node, "PS2002", "list[bool] is not lowered")
529
+ if typ["kind"] == "dict":
530
+ if typ["key"]["kind"] not in {"int", "str"}:
531
+ self.fail(node, "PS2002", "dict keys are int or str")
532
+ if typ["value"]["kind"] == "bool":
533
+ self.fail(node, "PS2002", "dict values of bool are not lowered")
534
+
535
+ def _annotation(self, node: ast.AST) -> dict[str, Any]:
536
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
537
+ self.fail(node, "PS1101", "union types are not in the IR")
538
+ formed = self._typing_annotation(node)
539
+ if formed is not None:
540
+ return formed
541
+ if isinstance(node, ast.Name) and node.id in {"int", "float", "bool", "str", "bytes"}:
542
+ return {"kind": node.id}
543
+ if isinstance(node, ast.Constant) and node.value is None:
544
+ return {"kind": "none"}
545
+ if isinstance(node, ast.Name) and (node.id in self.declared or node.id in self.known_classes):
546
+ return {"kind": "class", "name": self.class_aliases.get(node.id, node.id)}
547
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == "list":
548
+ typ = {"kind": "list", "elem": self._annotation(node.slice)}
549
+ self._reject_banned_type(node, typ)
550
+ return typ
551
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == "set":
552
+ elem = self._annotation(node.slice)
553
+ if elem["kind"] not in {"int", "str"}:
554
+ self.fail(node, "PS2002", "set elements are int or str")
555
+ return {"kind": "list", "elem": elem, "set": True}
556
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == "tuple":
557
+ return self._tuple_annotation(node)
558
+ aliased = self._aliased_annotation(node)
559
+ if aliased is not None:
560
+ return aliased
561
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == "dict":
562
+ if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != 2:
563
+ self.fail(node, "PS1001", "dict annotations are dict[key, value]")
564
+ typ = {
565
+ "kind": "dict",
566
+ "key": self._annotation(node.slice.elts[0]),
567
+ "value": self._annotation(node.slice.elts[1]),
568
+ }
569
+ self._reject_banned_type(node, typ)
570
+ return typ
571
+ named = self._annotation_class(node)
572
+ if named is not None:
573
+ return {"kind": "class", "name": named}
574
+ self._fail_annotation(node)
575
+
576
+ def _fail_annotation(self, node: ast.AST) -> NoReturn:
577
+ """Fence an annotation the subset does not lower, with the code that names why."""
578
+ text = ast.unparse(node)
579
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
580
+ self.fail(node, "PS1105", f"the forward reference {text} is not lowered")
581
+ if isinstance(node, ast.Subscript):
582
+ self.fail(node, "PS1103", f"the generic annotation {text} is not lowered")
583
+ tail = text.rsplit(".", 1)[-1]
584
+ if tail in _DYNAMIC_ANNOTATIONS:
585
+ self.fail(node, "PS1104", f"{text} is a dynamic or structural type and is not lowered")
586
+ if isinstance(node, (ast.Name, ast.Attribute)):
587
+ self.fail(node, "PS1106", f"the annotation {text} names a type that is not lowered")
588
+ self.fail(node, "PS1001", "this annotation is not in the lowered subset")
589
+
590
+ def _class_binary(
591
+ self, node: ast.BinOp, left: dict[str, Any], right: dict[str, Any], span: dict[str, Any]
592
+ ) -> dict[str, Any]:
593
+ op = type(node.op)
594
+ left_name = left["type"].get("name")
595
+ right_name = right["type"].get("name")
596
+ if op in {ast.Add, ast.Sub}:
597
+ sign = "+" if op is ast.Add else "-"
598
+ if left_name in {"Date", "DateTime"} and right_name == "TimeDelta":
599
+ return {"kind": "binary", "op": sign, "left": left, "right": right, "type": left["type"], "span": span}
600
+ if left_name == "TimeDelta" and right_name == "TimeDelta":
601
+ return {"kind": "binary", "op": sign, "left": left, "right": right, "type": left["type"], "span": span}
602
+ if op is ast.Sub and left_name == right_name and left_name in {"Date", "DateTime"}:
603
+ return {
604
+ "kind": "binary",
605
+ "op": "-",
606
+ "left": left,
607
+ "right": right,
608
+ "type": {"kind": "class", "name": "TimeDelta"},
609
+ "span": span,
610
+ }
611
+ self.fail(node, "PS2004", f"{op.__name__} on {left_name} and {right_name} is not lowered")
612
+ raise AssertionError("unreachable")
613
+
614
+ def _lookup_attr(self, obj: dict[str, Any], name: str, span: dict[str, Any], node: ast.AST) -> dict[str, Any]:
615
+ if obj["type"]["kind"] != "class":
616
+ self.fail(node, "PS2001", f"attribute access is not lowered ({name})")
617
+ class_name = obj["type"]["name"]
618
+ info = self.classes.get(class_name)
619
+ if info is None:
620
+ self.fail(node, "PS2001", f"{class_name} has no fields")
621
+ for field in info["fields"]:
622
+ if field["name"] == name:
623
+ return {"kind": "attr", "value": obj, "name": name, "type": field["type"], "span": span}
624
+ if info.get("method_kind", {}).get(name) == "property":
625
+ sig = info["methods"][name]
626
+ owner = info.get("method_owner", {}).get(name, class_name)
627
+ return {
628
+ "kind": "call",
629
+ "callee": {"kind": "function", "name": f"cls.{owner}.{name}"},
630
+ "args": [obj],
631
+ "type": sig[1],
632
+ "span": span,
633
+ }
634
+ attr = path_attr(name) if class_name == "Path" else None
635
+ if attr is not None:
636
+ typ, symbol = attr
637
+ return self._abi_call("pathlib", name, [obj], typ, symbol, span)
638
+ if synth_method(class_name, name) is not None:
639
+ self.fail(node, "PS2002", f"{class_name}.{name} is a function and is not a value")
640
+ self.fail(node, "PS2001", f"{class_name}.{name} is not a field")
641
+ raise AssertionError("unreachable")
642
+
643
+ def _has_attr(self, obj: dict[str, Any], name: str) -> bool:
644
+ if obj["type"]["kind"] != "class":
645
+ return False
646
+ class_name = obj["type"]["name"]
647
+ info = self.classes.get(class_name)
648
+ if info is None:
649
+ return False
650
+ if any(field["name"] == name for field in info["fields"]):
651
+ return True
652
+ if info.get("method_kind", {}).get(name) == "property":
653
+ return True
654
+ if class_name == "Path" and path_attr(name) is not None:
655
+ return True
656
+ return synth_method(class_name, name) is not None
657
+
658
+ def _isinstance_type(self, node: ast.expr) -> dict[str, Any]:
659
+ if isinstance(node, ast.Name) and node.id in {"int", "float", "bool", "str", "bytes"}:
660
+ return {"kind": node.id}
661
+ if isinstance(node, ast.Name) and node.id in {"list", "dict"}:
662
+ return {"kind": node.id, "bare": True}
663
+ if isinstance(node, ast.Name) and (
664
+ node.id in self.classes or node.id in self.class_aliases or node.id in EXCEPTIONS
665
+ ):
666
+ return {"kind": "class", "name": self.class_aliases.get(node.id, node.id)}
667
+ if isinstance(node, ast.Name):
668
+ binding = self.bindings.get(node.id)
669
+ if binding is not None:
670
+ alias = alias_name(binding.module, binding.attr)
671
+ if alias is not None:
672
+ return {"kind": "class", "name": alias}
673
+ if isinstance(node, ast.Attribute):
674
+ resolved = self._resolve_module(node)
675
+ if resolved is not None:
676
+ alias = alias_name(resolved[1], resolved[2])
677
+ if alias is not None:
678
+ return {"kind": "class", "name": alias}
679
+ self.fail(node, "PS2001", "isinstance type is not in the lowered subset")
680
+ raise AssertionError("unreachable")
681
+
682
+ def _isinstance_holds(self, got: dict[str, Any], want: dict[str, Any]) -> bool:
683
+ if want.get("bare") and got["kind"] == want["kind"]:
684
+ return True
685
+ if want["kind"] == "int" and got["kind"] == "bool":
686
+ return True
687
+ if want["kind"] == "class" and got["kind"] == "class":
688
+ got_name = got.get("name")
689
+ want_name = want.get("name")
690
+ if isinstance(got_name, str) and isinstance(want_name, str) and self._is_subclass_name(got_name, want_name):
691
+ return True
692
+ return types_equal(got, want)
693
+
694
+ def _is_subclass_name(self, got: str, want: str) -> bool:
695
+ current: str | None = got
696
+ seen: set[str] = set()
697
+ while current and current not in seen:
698
+ if current == want:
699
+ return True
700
+ seen.add(current)
701
+ info = self.classes.get(current)
702
+ if info is None:
703
+ return False
704
+ parent = info.get("base")
705
+ current = parent if isinstance(parent, str) else None
706
+ return False
707
+
708
+ def _exception_codes(self, name: str) -> list[int]:
709
+ found: list[int] = []
710
+ if name in EXCEPTIONS:
711
+ found.append(EXCEPTIONS[name])
712
+ else:
713
+ info = self.classes.get(name)
714
+ code = info.get("exc_code") if info is not None else None
715
+ if isinstance(code, int):
716
+ found.append(code)
717
+ for class_name, info in self.classes.items():
718
+ code = info.get("exc_code")
719
+ if class_name == name or not isinstance(code, int):
720
+ continue
721
+ if self._is_subclass_name(class_name, name):
722
+ found.append(code)
723
+ return found
724
+
725
+ def _aliased_annotation(self, node: ast.AST) -> dict[str, Any] | None:
726
+ if not isinstance(node, ast.Subscript):
727
+ return None
728
+ alias = self._annotation_alias(node.value)
729
+ if alias == "Deque":
730
+ elem = self._annotation(node.slice)
731
+ if elem["kind"] == "bool":
732
+ self.fail(node, "PS2002", "list[bool] is not lowered")
733
+ return {"kind": "class", "name": "Deque", "elem": elem}
734
+ if alias == "OrderedDict":
735
+ if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != 2:
736
+ self.fail(node, "PS1001", "OrderedDict annotations are OrderedDict[key, value]")
737
+ typ = {
738
+ "kind": "dict",
739
+ "key": self._annotation(node.slice.elts[0]),
740
+ "value": self._annotation(node.slice.elts[1]),
741
+ }
742
+ self._reject_banned_type(node, typ)
743
+ return typ
744
+ return None
745
+
746
+ def _annotation_class(self, node: ast.AST) -> str | None:
747
+ if isinstance(node, ast.Name):
748
+ binding = self.bindings.get(node.id)
749
+ if binding is None:
750
+ return None
751
+ return alias_name(binding.module, binding.attr)
752
+ if isinstance(node, ast.Attribute):
753
+ resolved = self._resolve_module(node)
754
+ if resolved is None:
755
+ return None
756
+ if resolved[3] == "erased":
757
+ return _IO_HANDLES.get(resolved[2])
758
+ return alias_name(resolved[1], resolved[2])
759
+ return None
760
+
761
+ def _annotation_alias(self, node: ast.AST) -> str | None:
762
+ if isinstance(node, ast.Name):
763
+ binding = self.bindings.get(node.id)
764
+ if binding is None:
765
+ return None
766
+ if binding.module == "collections" and binding.attr == "deque":
767
+ return "Deque"
768
+ if binding.module == "collections" and binding.attr == "OrderedDict":
769
+ return "OrderedDict"
770
+ return alias_name(binding.module, binding.attr)
771
+ if isinstance(node, ast.Attribute):
772
+ resolved = self._resolve_module(node)
773
+ if resolved is None or resolved[3] != "lowered":
774
+ return None
775
+ if resolved[1] == "collections" and resolved[2] == "deque":
776
+ return "Deque"
777
+ if resolved[1] == "collections" and resolved[2] == "OrderedDict":
778
+ return "OrderedDict"
779
+ return None
780
+
781
+ def _typing_annotation(self, node: ast.AST) -> dict[str, Any] | None:
782
+ if isinstance(node, ast.Name) and node.id in self.type_aliases:
783
+ alias = self.type_aliases[node.id]
784
+ handle = _IO_HANDLES.get(alias)
785
+ if handle is not None:
786
+ return {"kind": "class", "name": handle}
787
+ code = "PS1104" if alias in _DYNAMIC_ANNOTATIONS else "PS1103"
788
+ self.fail(node, code, f"{alias} is not a value type in the IR")
789
+ if not isinstance(node, ast.Subscript):
790
+ return None
791
+ alias = self._typing_alias(node.value)
792
+ if alias is None:
793
+ return None
794
+ if alias == "IO":
795
+ handle = _io_parameter(node.slice)
796
+ if handle is None:
797
+ self.fail(node, "PS1001", "IO is not a value type in the IR")
798
+ return {"kind": "class", "name": handle}
799
+ if alias in _LIST_FORMS:
800
+ typ = {"kind": "list", "elem": self._annotation(node.slice)}
801
+ self._reject_banned_type(node, typ)
802
+ return typ
803
+ if alias in _SET_FORMS:
804
+ elem = self._annotation(node.slice)
805
+ if elem["kind"] not in {"int", "str"}:
806
+ self.fail(node, "PS2002", "set elements are int or str")
807
+ return {"kind": "list", "elem": elem, "set": True}
808
+ if alias in _DICT_FORMS:
809
+ if not isinstance(node.slice, ast.Tuple) or len(node.slice.elts) != 2:
810
+ self.fail(node, "PS1001", "Dict annotations are Dict[key, value]")
811
+ typ = {
812
+ "kind": "dict",
813
+ "key": self._annotation(node.slice.elts[0]),
814
+ "value": self._annotation(node.slice.elts[1]),
815
+ }
816
+ self._reject_banned_type(node, typ)
817
+ return typ
818
+ if alias == "Tuple":
819
+ return self._tuple_annotation(node)
820
+ if alias in _UNWRAP_FORMS:
821
+ inner = node.slice
822
+ if alias == "Annotated":
823
+ if isinstance(inner, ast.Tuple) and inner.elts:
824
+ inner = inner.elts[0]
825
+ else:
826
+ self.fail(node, "PS1001", "Annotated needs a type")
827
+ return self._annotation(inner)
828
+ if alias == "Optional":
829
+ self.fail(node, "PS1102", "optional types are not in the IR")
830
+ if alias == "Union":
831
+ self.fail(node, "PS1101", "union types are not in the IR")
832
+ self.fail(node, "PS1103", f"{alias} is not a value type in the IR")
833
+
834
+ def _typing_alias(self, node: ast.AST) -> str | None:
835
+ if isinstance(node, ast.Name) and node.id in self.type_aliases:
836
+ return self.type_aliases[node.id]
837
+ if isinstance(node, ast.Attribute):
838
+ resolved = self._resolve_module(node)
839
+ if resolved is not None and resolved[3] == "erased":
840
+ return resolved[2]
841
+ return None
842
+
843
+ def _synthetic_classes(self, seen: set[str]) -> list[dict[str, Any]]:
844
+ found: list[dict[str, Any]] = []
845
+ for name, info in self.classes.items():
846
+ if name in seen or not info.get("synthetic"):
847
+ continue
848
+ found.append(
849
+ {
850
+ "name": name,
851
+ "fields": info["fields"],
852
+ "methods": [],
853
+ "span": {"file": self.filename, "line": 1, "col": 1, "endLine": 1, "endCol": 1},
854
+ }
855
+ )
856
+ return found
857
+
858
+ def _tuple_annotation(self, node: ast.Subscript) -> dict[str, Any]:
859
+ elts = list(node.slice.elts) if isinstance(node.slice, ast.Tuple) else [node.slice]
860
+ if any(_ellipsis_const(elt) for elt in elts):
861
+ # tuple[T, ...] is a homogeneous sequence of zero or more T. That is the
862
+ # list[T] struct.unpack already returns. A synthetic class would be a new
863
+ # shape. Mixed or nested ellipsis stays the current fence.
864
+ if len(elts) == 2 and _ellipsis_const(elts[1]) and not _node_has_ellipsis(elts[0]):
865
+ elem = self._annotation(elts[0])
866
+ typ = {"kind": "list", "elem": elem}
867
+ self._reject_banned_type(node, typ)
868
+ return typ
869
+ self.fail(node, "PS1001", "tuple[T, ...] is not in the IR")
870
+ if len(elts) < 2:
871
+ self.fail(node, "PS1001", "tuples need at least two fields")
872
+ return self._tuple_type([self._annotation(elt) for elt in elts], node)
873
+
874
+ def _tuple_type(self, fields: list[dict[str, Any]], node: ast.AST) -> dict[str, Any]:
875
+ label = "tuple[" + ", ".join(_show(field) for field in fields) + "]"
876
+ name = "tuple_" + str(len(fields)) + "_" + "_".join(_type_slug(field) for field in fields)
877
+ existing = self.classes.get(name)
878
+ if existing is None:
879
+ if name in self.declared:
880
+ self.fail(node, "PS2002", f"{name} clashes with a tuple type")
881
+ self.classes[name] = {
882
+ "fields": [{"name": f"_{index}", "type": field} for index, field in enumerate(fields)],
883
+ "methods": {},
884
+ "synthetic": True,
885
+ "tuple": True,
886
+ }
887
+ self.declared.add(name)
888
+ elif not existing.get("tuple"):
889
+ self.fail(node, "PS2002", f"{name} clashes with a tuple type")
890
+ return {"kind": "class", "name": name, "label": label}
891
+
892
+ def _tuple_expr(self, node: ast.Tuple, span: dict[str, Any]) -> dict[str, Any]:
893
+ if len(node.elts) < 2:
894
+ self.fail(node, "PS2002", "tuples need at least two fields")
895
+ elems = [self._expr(elt) for elt in node.elts]
896
+ return self._materialize_tuple(elems, span, node)
897
+
898
+ def _tuple_subscript(self, node: ast.Subscript, value: dict[str, Any], span: dict[str, Any]) -> dict[str, Any]:
899
+ index = _const_int_index(node.slice)
900
+ if index is None:
901
+ self.fail(node, "PS2002", "a tuple index must be a constant")
902
+ fields = self.classes[value["type"]["name"]]["fields"]
903
+ if index < 0:
904
+ index += len(fields)
905
+ if index < 0 or index >= len(fields):
906
+ self.fail(node, "PS2002", "tuple index is out of range")
907
+ field = fields[index]
908
+ return {"kind": "attr", "value": value, "name": field["name"], "type": field["type"], "span": span}
909
+
910
+
911
+ def _param(lowerer: Any, arg: ast.arg) -> dict[str, Any]:
912
+ if arg.annotation is None:
913
+ lowerer.fail(arg, "PS1001", f"parameter {arg.arg} needs an annotation")
914
+ return {"name": arg.arg, "type": lowerer._annotation(arg.annotation)}
915
+
916
+
917
+ def _ellipsis_const(node: ast.AST) -> bool:
918
+ return isinstance(node, ast.Constant) and node.value is Ellipsis
919
+
920
+
921
+ def _node_has_ellipsis(node: ast.AST) -> bool:
922
+ return any(_ellipsis_const(child) for child in ast.walk(node))
923
+
924
+
925
+ def _io_parameter(node: ast.AST) -> str | None:
926
+ """IO[bytes] and IO[str] are the handles open() already returns. Any other parameter stays fenced."""
927
+
928
+ if isinstance(node, ast.Name) and node.id == "bytes":
929
+ return "BytesIO"
930
+ if isinstance(node, ast.Name) and node.id == "str":
931
+ return "TextIO"
932
+ return None
933
+
934
+
935
+ def _assigned_fields(body: list[ast.stmt]) -> set[str]:
936
+ found: set[str] = set()
937
+ for stmt in body:
938
+ target = None
939
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
940
+ target = stmt.targets[0]
941
+ elif isinstance(stmt, ast.AugAssign):
942
+ target = stmt.target
943
+ elif isinstance(stmt, ast.AnnAssign):
944
+ target = stmt.target
945
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self":
946
+ found.add(target.attr)
947
+ for child in ast.iter_child_nodes(stmt):
948
+ if isinstance(child, ast.stmt):
949
+ found |= _assigned_fields([child])
950
+ return found
951
+
952
+
953
+ def _const_int_index(node: ast.expr) -> int | None:
954
+ if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool):
955
+ return node.value
956
+ if (
957
+ isinstance(node, ast.UnaryOp)
958
+ and isinstance(node.op, ast.USub)
959
+ and isinstance(node.operand, ast.Constant)
960
+ and isinstance(node.operand.value, int)
961
+ and not isinstance(node.operand.value, bool)
962
+ ):
963
+ return -node.operand.value
964
+ return None
965
+
966
+
967
+ def _type_slug(typ: dict[str, Any]) -> str:
968
+ raw = type_key(typ)
969
+ return "".join(ch if ch.isalnum() else "_" for ch in raw).strip("_")