browser-py 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.
browser_source.py ADDED
@@ -0,0 +1,598 @@
1
+ from ast import (
2
+ AST,
3
+ Add,
4
+ And,
5
+ AnnAssign,
6
+ Assign,
7
+ AsyncFunctionDef,
8
+ Attribute,
9
+ AugAssign,
10
+ Await,
11
+ BinOp,
12
+ BitAnd,
13
+ BitOr,
14
+ BitXor,
15
+ BoolOp,
16
+ Break,
17
+ Call,
18
+ ClassDef,
19
+ Compare,
20
+ Constant,
21
+ Continue,
22
+ Dict,
23
+ Div,
24
+ Eq,
25
+ ExceptHandler,
26
+ Expr,
27
+ For,
28
+ FunctionDef,
29
+ Gt,
30
+ GtE,
31
+ If,
32
+ IfExp,
33
+ ImportFrom,
34
+ Is,
35
+ IsNot,
36
+ List,
37
+ Lt,
38
+ LtE,
39
+ Mult,
40
+ Name,
41
+ Not,
42
+ NotEq,
43
+ Pass,
44
+ Raise,
45
+ Return,
46
+ Store,
47
+ Sub,
48
+ Subscript,
49
+ Try,
50
+ Tuple,
51
+ UAdd,
52
+ UnaryOp,
53
+ USub,
54
+ While,
55
+ expr,
56
+ parse,
57
+ stmt,
58
+ walk,
59
+ )
60
+ from dataclasses import dataclass
61
+ from json import dumps
62
+ from pathlib import Path
63
+
64
+ from numeric_source import require_acyclic
65
+
66
+
67
+ @dataclass
68
+ class Emission:
69
+ node: stmt | None
70
+ text: str
71
+ depth: int
72
+ exception: str
73
+
74
+
75
+ @dataclass
76
+ class BrowserScope:
77
+ names: set[str]
78
+ arities: dict[str, int]
79
+ constructors: set[str]
80
+ arrays: set[str]
81
+ native: set[str]
82
+ asynchronous: bool
83
+
84
+
85
+ def annotation_shape(node: expr | None, constructors: set[str]) -> str:
86
+ if node is None:
87
+ return "native"
88
+ if isinstance(node, Constant) and node.value is None:
89
+ return "void"
90
+ if isinstance(node, Name):
91
+ if node.id in ("int", "float", "str", "bool"):
92
+ return "value"
93
+ if node.id in constructors:
94
+ return "record"
95
+ if isinstance(node, Subscript) and isinstance(node.value, Name):
96
+ element = node.slice
97
+ if node.value.id == "tuple" and isinstance(element, Tuple) and len(element.elts) == 2:
98
+ if isinstance(element.elts[1], Constant) and element.elts[1].value is Ellipsis:
99
+ element = element.elts[0]
100
+ else:
101
+ raise NotImplementedError("Browser tuple annotations require one repeated element type")
102
+ if node.value.id in ("list", "tuple") and isinstance(element, Name):
103
+ if element.id in ("int", "float", "str", "bool") or element.id in constructors:
104
+ return "array"
105
+ raise NotImplementedError("Unsupported browser annotation")
106
+
107
+
108
+ def operator_text(node: BinOp | AugAssign | Compare | UnaryOp) -> str:
109
+ operator = node.ops[0] if isinstance(node, Compare) else node.op
110
+ if isinstance(operator, Add):
111
+ return "+"
112
+ if isinstance(operator, Sub):
113
+ return "-"
114
+ if isinstance(operator, Mult):
115
+ return "*"
116
+ if isinstance(operator, Div):
117
+ return "/"
118
+ if isinstance(operator, BitAnd):
119
+ return "&"
120
+ if isinstance(operator, BitOr):
121
+ return "|"
122
+ if isinstance(operator, BitXor):
123
+ return "^"
124
+ if isinstance(operator, (Eq, Is)):
125
+ return "==="
126
+ if isinstance(operator, (NotEq, IsNot)):
127
+ return "!=="
128
+ if isinstance(operator, Lt):
129
+ return "<"
130
+ if isinstance(operator, LtE):
131
+ return "<="
132
+ if isinstance(operator, Gt):
133
+ return ">"
134
+ if isinstance(operator, GtE):
135
+ return ">="
136
+ if isinstance(operator, Not):
137
+ return "!"
138
+ if isinstance(operator, USub):
139
+ return "-"
140
+ if isinstance(operator, UAdd):
141
+ return "+"
142
+ raise NotImplementedError("Unsupported browser operator: " + type(operator).__name__)
143
+
144
+
145
+ def expression_text(value: expr, scope: BrowserScope) -> str:
146
+ text: dict[expr, str] = {}
147
+ shapes: dict[expr, str] = {}
148
+ nodes = list(walk(value))
149
+ targets = {item.func for item in nodes if isinstance(item, Call)}
150
+ for node in reversed(nodes):
151
+ if not isinstance(node, expr):
152
+ continue
153
+ shape = "value"
154
+ if isinstance(node, Constant):
155
+ if node.value is None or isinstance(node.value, (str, bool, int, float)):
156
+ result = dumps(node.value, ensure_ascii=True, allow_nan=False)
157
+ else:
158
+ raise NotImplementedError("Unsupported browser literal")
159
+ elif isinstance(node, Name):
160
+ if node.id in scope.names:
161
+ result = node.id if node.id in ("window", "document") else "p_" + node.id
162
+ if node.id in scope.arrays:
163
+ shape = "array"
164
+ elif node.id in scope.native:
165
+ shape = "native"
166
+ elif node.id in ("len", "int", "str", "ValueError") and node in targets:
167
+ result = node.id
168
+ else:
169
+ raise NotImplementedError("Unknown browser name: " + node.id)
170
+ if node.id in scope.arities and node not in targets:
171
+ raise NotImplementedError("Browser functions must be called directly")
172
+ elif isinstance(node, (List, Tuple)):
173
+ result = "[" + ", ".join(text[item] for item in node.elts) + "]"
174
+ shape = "array"
175
+ elif isinstance(node, Dict):
176
+ fields = []
177
+ for i in range(len(node.keys)):
178
+ key = node.keys[i]
179
+ if isinstance(key, Constant) and isinstance(key.value, str):
180
+ fields.append(text[key] + ": " + text[node.values[i]])
181
+ else:
182
+ raise NotImplementedError("Browser descriptors require literal string keys")
183
+ result = "({" + ", ".join(fields) + "})"
184
+ shape = "native"
185
+ elif isinstance(node, Attribute):
186
+ if node.attr.startswith("_") and node.attr != "__new__":
187
+ raise NotImplementedError("Private browser attributes are unsupported")
188
+ if node.attr == "append":
189
+ if shapes[node.value] == "array" and node in targets:
190
+ result = text[node.value] + ".push"
191
+ else:
192
+ raise NotImplementedError("append requires a known array and a direct call")
193
+ else:
194
+ result = "(" + text[node.value] + ")." + node.attr
195
+ shape = "native"
196
+ elif isinstance(node, Subscript):
197
+ result = "(" + text[node.value] + ")[" + text[node.slice] + "]"
198
+ shape = "native"
199
+ elif isinstance(node, BinOp):
200
+ if shapes[node.left] == "array" or shapes[node.right] == "array":
201
+ raise NotImplementedError("Browser arrays do not support arithmetic")
202
+ result = "(" + text[node.left] + " " + operator_text(node) + " " + text[node.right] + ")"
203
+ elif isinstance(node, UnaryOp):
204
+ result = "(" + operator_text(node) + text[node.operand] + ")"
205
+ elif isinstance(node, BoolOp):
206
+ symbol = " && " if isinstance(node.op, And) else " || "
207
+ result = "(" + symbol.join(text[item] for item in node.values) + ")"
208
+ elif isinstance(node, Compare) and len(node.ops) == 1:
209
+ result = "(" + text[node.left] + " " + operator_text(node) + " " + text[node.comparators[0]] + ")"
210
+ elif isinstance(node, IfExp):
211
+ result = "(" + text[node.test] + " ? " + text[node.body] + " : " + text[node.orelse] + ")"
212
+ if shapes[node.body] == shapes[node.orelse]:
213
+ shape = shapes[node.body]
214
+ elif isinstance(node, Await) and scope.asynchronous:
215
+ result = "(await " + text[node.value] + ")"
216
+ shape = "native"
217
+ elif isinstance(node, Call):
218
+ if node.keywords:
219
+ raise NotImplementedError("Browser calls require positional arguments")
220
+ args = ", ".join(text[item] for item in node.args)
221
+ if isinstance(node.func, Name):
222
+ name = node.func.id
223
+ if name in ("len", "int", "str", "ValueError") and len(node.args) == 1:
224
+ if name == "len":
225
+ result = "(" + args + ").length"
226
+ elif name == "int":
227
+ result = "Math.trunc(" + args + ")"
228
+ elif name == "str":
229
+ result = "String(" + args + ")"
230
+ else:
231
+ result = "new Error(" + args + ")"
232
+ elif name in scope.arities and len(node.args) == scope.arities[name]:
233
+ prefix = "new " if name in scope.constructors else ""
234
+ result = prefix + text[node.func] + "(" + args + ")"
235
+ if name in scope.arrays:
236
+ shape = "array"
237
+ elif name in scope.native:
238
+ shape = "native"
239
+ else:
240
+ raise NotImplementedError("Unsupported browser call or arity: " + name)
241
+ elif isinstance(node.func, Attribute) and node.func.attr == "__new__":
242
+ if shapes[node.func.value] == "native":
243
+ result = "new (" + text[node.func.value] + ")(" + args + ")"
244
+ shape = "native"
245
+ else:
246
+ raise NotImplementedError("Native constructor required")
247
+ elif isinstance(node.func, (Attribute, Subscript)):
248
+ result = text[node.func] + "(" + args + ")"
249
+ shape = "native"
250
+ else:
251
+ raise NotImplementedError("Browser call must name a function or native method")
252
+ else:
253
+ raise NotImplementedError("Unsupported browser expression: " + type(node).__name__)
254
+ text[node] = result
255
+ shapes[node] = shape
256
+ return text[value]
257
+
258
+
259
+ def emit_statements(body: list[stmt], scope: BrowserScope) -> str:
260
+ stack = [Emission(node, "", 0, "") for node in reversed(body)]
261
+ lines = []
262
+ serial = 0
263
+ while stack:
264
+ task = stack.pop()
265
+ node = task.node
266
+ depth = task.depth
267
+ exception = task.exception
268
+ if node is None:
269
+ lines.append(" " * depth + task.text)
270
+ continue
271
+ text = ""
272
+ if isinstance(node, (Assign, AnnAssign)):
273
+ target = node.targets[0] if isinstance(node, Assign) and len(node.targets) == 1 else None
274
+ if isinstance(node, AnnAssign):
275
+ target = node.target
276
+ if isinstance(target, (Name, Attribute, Subscript)) and node.value is not None:
277
+ text = expression_text(target, scope) + " = " + expression_text(node.value, scope) + ";"
278
+ else:
279
+ raise NotImplementedError("Browser assignments require one explicit target/value")
280
+ elif isinstance(node, AugAssign) and isinstance(node.target, (Name, Attribute, Subscript)):
281
+ text = (
282
+ expression_text(node.target, scope)
283
+ + " "
284
+ + operator_text(node)
285
+ + "= "
286
+ + expression_text(node.value, scope)
287
+ + ";"
288
+ )
289
+ elif isinstance(node, Expr):
290
+ if isinstance(node.value, Constant) and isinstance(node.value.value, str):
291
+ continue
292
+ if isinstance(node.value, (Call, Await)):
293
+ text = expression_text(node.value, scope) + ";"
294
+ else:
295
+ raise NotImplementedError("Browser expression statements must be calls")
296
+ elif isinstance(node, Return):
297
+ text = (
298
+ "return " + (expression_text(node.value, scope) if node.value is not None else "undefined") + ";"
299
+ )
300
+ elif isinstance(node, Raise):
301
+ if node.cause is not None:
302
+ raise NotImplementedError("Browser exception causes are unsupported")
303
+ if node.exc is not None:
304
+ text = "throw " + expression_text(node.exc, scope) + ";"
305
+ elif exception:
306
+ text = "throw " + exception + ";"
307
+ else:
308
+ raise NotImplementedError("Bare raise requires a catch scope")
309
+ elif isinstance(node, If):
310
+ text = "if (" + expression_text(node.test, scope) + ") {"
311
+ stack.append(Emission(None, "}", depth, exception))
312
+ if node.orelse:
313
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.orelse))
314
+ stack.append(Emission(None, "} else {", depth, exception))
315
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.body))
316
+ elif isinstance(node, (For, While)):
317
+ if node.orelse:
318
+ raise NotImplementedError("Browser loop else clauses are unsupported")
319
+ if isinstance(node, While):
320
+ text = "while (" + expression_text(node.test, scope) + ") {"
321
+ elif isinstance(node.target, Name):
322
+ variable = expression_text(node.target, scope)
323
+ iterator = node.iter
324
+ if isinstance(iterator, Call) and isinstance(iterator.func, Name) and iterator.func.id == "range":
325
+ if iterator.keywords or len(iterator.args) not in (1, 2, 3):
326
+ raise NotImplementedError("Expected range with one to three positional arguments")
327
+ args = [expression_text(item, scope) for item in iterator.args]
328
+ start = args[0] if len(args) > 1 else "0"
329
+ stop = args[1] if len(args) > 1 else args[0]
330
+ step = args[2] if len(args) == 3 else "1"
331
+ if len(args) == 3 and not (
332
+ isinstance(iterator.args[2], Constant)
333
+ and type(iterator.args[2].value) is int
334
+ and iterator.args[2].value > 0
335
+ ):
336
+ raise NotImplementedError("Browser ranges require a positive literal step")
337
+ serial += 1
338
+ initial = "bp_start_" + str(serial)
339
+ limit = "bp_limit_" + str(serial)
340
+ index = "bp_index_" + str(serial)
341
+ lines.append(" " * depth + "const " + initial + " = " + start + ";")
342
+ lines.append(" " * depth + "const " + limit + " = " + stop + ";")
343
+ text = f"for (let {index} = {initial}; {index} < {limit}; {index} += {step}) {{"
344
+ stack.append(Emission(None, "}", depth, exception))
345
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.body))
346
+ stack.append(Emission(None, variable + " = " + index + ";", depth + 1, exception))
347
+ lines.append(" " * depth + text)
348
+ continue
349
+ elif isinstance(iterator, (List, Tuple)) or (
350
+ isinstance(iterator, Name) and iterator.id in scope.arrays
351
+ ):
352
+ text = "for (" + variable + " of " + expression_text(iterator, scope) + ") {"
353
+ else:
354
+ raise NotImplementedError("Browser iteration requires range or a known array")
355
+ else:
356
+ raise NotImplementedError("Browser loops require a single named target")
357
+ stack.append(Emission(None, "}", depth, exception))
358
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.body))
359
+ elif isinstance(node, Try):
360
+ if node.orelse or len(node.handlers) > 1:
361
+ raise NotImplementedError("Browser try supports one native catch and/or finally")
362
+ text = "try {"
363
+ stack.append(Emission(None, "}", depth, exception))
364
+ if node.finalbody:
365
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.finalbody))
366
+ stack.append(Emission(None, "} finally {", depth, exception))
367
+ if node.handlers:
368
+ handler = node.handlers[0]
369
+ if handler.type is not None and not (
370
+ isinstance(handler.type, Name) and handler.type.id == "Exception"
371
+ ):
372
+ raise NotImplementedError(
373
+ "Browser catches handle native exceptions without Python type dispatch"
374
+ )
375
+ serial += 1
376
+ caught = "bp_error_" + str(serial)
377
+ stack.extend(Emission(item, "", depth + 1, caught) for item in reversed(handler.body))
378
+ if handler.name is not None:
379
+ stack.append(
380
+ Emission(None, "const p_" + handler.name + " = " + caught + ";", depth + 1, caught)
381
+ )
382
+ stack.append(Emission(None, "} catch (" + caught + ") {", depth, caught))
383
+ stack.extend(Emission(item, "", depth + 1, exception) for item in reversed(node.body))
384
+ elif isinstance(node, Pass):
385
+ continue
386
+ elif isinstance(node, Break):
387
+ text = "break;"
388
+ elif isinstance(node, Continue):
389
+ text = "continue;"
390
+ else:
391
+ raise NotImplementedError("Unsupported browser statement: " + type(node).__name__)
392
+ lines.append(" " * depth + text)
393
+ return "\n".join(lines)
394
+
395
+
396
+ def compile_browser_source(source: Path) -> str:
397
+ tree = parse(source.read_text())
398
+ names = set()
399
+ arities: dict[str, int] = {}
400
+ constructors = set()
401
+ arrays = set()
402
+ native = {"window", "document"}
403
+ functions: list[FunctionDef | AsyncFunctionDef] = []
404
+ lines = []
405
+ exports = []
406
+ entry: list[stmt] = []
407
+ dataclass_imported = False
408
+ imports_open = True
409
+ model_names = {item.name for item in tree.body if isinstance(item, ClassDef)}
410
+ for node in tree.body:
411
+ if isinstance(node, Expr) and isinstance(node.value, Constant) and isinstance(node.value.value, str):
412
+ continue
413
+ if isinstance(node, ImportFrom) and imports_open:
414
+ if node.level or node.module is None or not node.module.isidentifier():
415
+ raise NotImplementedError("Browser imports require direct module names")
416
+ symbols = [item.name for item in node.names]
417
+ if any(
418
+ item.asname is not None
419
+ or item.name == "*"
420
+ or item.name in names
421
+ or item.name in ("len", "str", "int", "range", "ValueError")
422
+ for item in node.names
423
+ ):
424
+ raise NotImplementedError("Browser imports require unique unaliased symbols")
425
+ names.update(symbols)
426
+ if node.module == "browser":
427
+ if set(symbols) <= {"window", "document"}:
428
+ continue
429
+ raise NotImplementedError("Browser imports expose only native document and window")
430
+ if node.module == "dataclasses":
431
+ if symbols == ["dataclass"]:
432
+ dataclass_imported = True
433
+ continue
434
+ raise NotImplementedError("Browser dataclass imports expose only the decorator")
435
+ if node.module.startswith("__"):
436
+ raise NotImplementedError("Browser entry modules cannot be imported")
437
+ target = source.parent / (node.module + ".py")
438
+ if not target.is_file():
439
+ raise NotImplementedError("Browser imports must name local source modules: " + node.module)
440
+ definitions = {
441
+ item.name: item for item in parse(target.read_text()).body if isinstance(item, FunctionDef)
442
+ }
443
+ for symbol in symbols:
444
+ if symbol not in definitions:
445
+ raise NotImplementedError("Browser imports must name directly defined functions: " + symbol)
446
+ definition = definitions[symbol]
447
+ arities[symbol] = len(definition.args.args)
448
+ shape = annotation_shape(definition.returns, set())
449
+ if shape == "array":
450
+ arrays.add(symbol)
451
+ elif shape == "native":
452
+ native.add(symbol)
453
+ bindings = ", ".join(symbol + " as p_" + symbol for symbol in symbols)
454
+ lines.append("import { " + bindings + " } from './" + node.module + ".js';")
455
+ continue
456
+ imports_open = False
457
+ if isinstance(node, (FunctionDef, AsyncFunctionDef, ClassDef)):
458
+ if (
459
+ node.name in names
460
+ or node.name in native
461
+ or node.name in ("len", "str", "int", "range", "ValueError")
462
+ ):
463
+ raise NotImplementedError("Duplicate/reserved browser name: " + node.name)
464
+ names.add(node.name)
465
+ exports.append("p_" + node.name + " as " + node.name)
466
+ if isinstance(node, (FunctionDef, AsyncFunctionDef)):
467
+ args = node.args
468
+ if (
469
+ node.decorator_list
470
+ or node.type_params
471
+ or args.defaults
472
+ or args.kw_defaults
473
+ or args.vararg
474
+ or args.kwarg
475
+ or args.kwonlyargs
476
+ or args.posonlyargs
477
+ ):
478
+ raise NotImplementedError("Browser functions require plain positional parameters")
479
+ arities[node.name] = len(args.args)
480
+ for parameter in args.args:
481
+ annotation_shape(parameter.annotation, model_names)
482
+ shape = annotation_shape(node.returns, model_names)
483
+ if shape == "array":
484
+ arrays.add(node.name)
485
+ elif shape == "native":
486
+ native.add(node.name)
487
+ functions.append(node)
488
+ elif isinstance(node, ClassDef):
489
+ if not (
490
+ dataclass_imported
491
+ and len(node.decorator_list) == 1
492
+ and isinstance(node.decorator_list[0], Name)
493
+ and node.decorator_list[0].id == "dataclass"
494
+ and not node.bases
495
+ and not node.keywords
496
+ and not node.type_params
497
+ ):
498
+ raise NotImplementedError("Browser classes must be plain required-field dataclasses")
499
+ fields = []
500
+ for field in node.body:
501
+ if isinstance(field, AnnAssign) and isinstance(field.target, Name) and field.value is None:
502
+ if field.target.id in fields:
503
+ raise NotImplementedError("Duplicate browser dataclass field")
504
+ if annotation_shape(field.annotation, model_names) in ("value", "record", "array"):
505
+ fields.append(field.target.id)
506
+ else:
507
+ raise NotImplementedError("Browser dataclass fields require explicit value annotations")
508
+ else:
509
+ raise NotImplementedError("Browser dataclasses require annotation-only fields")
510
+ constructors.add(node.name)
511
+ arities[node.name] = len(fields)
512
+ lines.append(
513
+ "class p_" + node.name + " {\nconstructor(" + ", ".join("p_" + item for item in fields) + ") {"
514
+ )
515
+ for field_name in fields:
516
+ lines.append("this." + field_name + " = p_" + field_name + ";")
517
+ lines.append("}\n}")
518
+ elif isinstance(node, (Expr, Try)):
519
+ entry.append(node)
520
+ else:
521
+ raise NotImplementedError("Browser module scope permits imports, definitions, and entry calls only")
522
+ calls = {}
523
+ function_names = {item.name for item in functions}
524
+ for function in functions:
525
+ calls[function.name] = {
526
+ item.func.id
527
+ for statement in function.body
528
+ for item in walk(statement)
529
+ if isinstance(item, Call) and isinstance(item.func, Name) and item.func.id in function_names
530
+ }
531
+ require_acyclic(calls, "Browser calls")
532
+ for function in functions:
533
+ parameters = {item.arg for item in function.args.args}
534
+ if len(parameters) != len(function.args.args):
535
+ raise NotImplementedError("Browser parameters must have unique names")
536
+ locals_ = set(parameters)
537
+ local_arrays = set(arrays)
538
+ local_native = set(native)
539
+ nodes: list[AST] = []
540
+ for parameter in function.args.args:
541
+ shape = annotation_shape(parameter.annotation, model_names)
542
+ if shape == "array":
543
+ local_arrays.add(parameter.arg)
544
+ elif shape == "native":
545
+ local_native.add(parameter.arg)
546
+ for statement in function.body:
547
+ nodes.extend(walk(statement))
548
+ for item in nodes:
549
+ if isinstance(item, (FunctionDef, AsyncFunctionDef, ClassDef, ImportFrom)):
550
+ raise NotImplementedError("Browser functions cannot contain nested definitions/imports")
551
+ if isinstance(item, Name) and isinstance(item.ctx, Store):
552
+ locals_.add(item.id)
553
+ if isinstance(item, AnnAssign):
554
+ annotation_shape(item.annotation, model_names)
555
+ if isinstance(item, ExceptHandler) and item.name is not None:
556
+ locals_.add(item.name)
557
+ if isinstance(item, Assign) and len(item.targets) == 1 and isinstance(item.targets[0], Name):
558
+ if isinstance(item.value, (List, Tuple)) or (
559
+ isinstance(item.value, IfExp)
560
+ and isinstance(item.value.body, (List, Tuple))
561
+ and isinstance(item.value.orelse, (List, Tuple))
562
+ ):
563
+ local_arrays.add(item.targets[0].id)
564
+ else:
565
+ local_native.add(item.targets[0].id)
566
+ if locals_ & (set(arities) | {"window", "document", "len", "str", "int", "range", "ValueError"}):
567
+ raise NotImplementedError("Browser locals cannot shadow callables or native globals")
568
+ scope = BrowserScope(
569
+ names | locals_,
570
+ arities,
571
+ constructors,
572
+ local_arrays,
573
+ local_native,
574
+ isinstance(function, AsyncFunctionDef),
575
+ )
576
+ declaration = "async function " if scope.asynchronous else "function "
577
+ signature = ", ".join("p_" + item.arg for item in function.args.args)
578
+ lines.append(declaration + "p_" + function.name + "(" + signature + ") {")
579
+ declared = sorted(locals_ - parameters)
580
+ if declared:
581
+ lines.append("let " + ", ".join("p_" + item for item in declared) + ";")
582
+ lines.append(emit_statements(function.body, scope))
583
+ lines.append("}")
584
+ if entry:
585
+ for statement in entry:
586
+ for item in walk(statement):
587
+ if isinstance(item, Name) and isinstance(item.ctx, Store):
588
+ raise NotImplementedError("Browser entry state belongs inside a function")
589
+ caught_names = {
590
+ item.name
591
+ for statement in entry
592
+ for item in walk(statement)
593
+ if isinstance(item, ExceptHandler) and item.name is not None
594
+ }
595
+ scope = BrowserScope(names | caught_names, arities, constructors, arrays, native, True)
596
+ lines.append(emit_statements(entry, scope))
597
+ lines.append("export { " + ", ".join(exports) + " };\n")
598
+ return "\n".join(lines)
compiler_command.py ADDED
@@ -0,0 +1,53 @@
1
+ from argparse import ArgumentParser
2
+ from os import environ
3
+ from pathlib import Path
4
+ from subprocess import run
5
+ from sys import executable
6
+ from tempfile import mkdtemp
7
+
8
+ from browser_compile import compile_modules
9
+
10
+
11
+ def main() -> None:
12
+ parser = ArgumentParser(prog="browser-py", description="Compile Python for the browser")
13
+ commands = parser.add_subparsers(dest="command", required=True)
14
+ compile_command = commands.add_parser(
15
+ "compile", help="Compile a typed numeric Python module and its imports"
16
+ )
17
+ compile_command.add_argument("source", type=Path)
18
+ compile_command.add_argument(
19
+ "--unminified", action="store_true", help="Keep generated modules uncompressed"
20
+ )
21
+ compile_command.add_argument("--output", type=Path, help="Write compiled files to this directory")
22
+ commands.add_parser("validate", help="Run synchronous regression checks from a source checkout")
23
+ args = parser.parse_args()
24
+ root = Path(__file__).resolve().parents[1]
25
+ if args.command == "compile":
26
+ output = args.output
27
+ if output is None:
28
+ output = Path(mkdtemp(prefix="browser-py-compile-", dir="/tmp"))
29
+ entry = compile_modules(args.source, output, not args.unminified)
30
+ print(f"Compiled {entry}", flush=True)
31
+ print("Orchestration: command -> source admission -> mypyc IR -> browser-py emission -> ES modules")
32
+ return
33
+ if args.command == "validate":
34
+ if not (root / "pyproject.toml").is_file() or not (root / "tests").is_dir():
35
+ parser.error("validate requires a source checkout; run python -B browser_py.py validate there")
36
+ environment = {**environ, "PYTHONPATH": str(root / "source"), "PYTHONDONTWRITEBYTECODE": "1"}
37
+ run(
38
+ [executable, "-B", "-m", "ruff", "check", str(root / "source"), str(root / "tests")],
39
+ env=environment,
40
+ check=True,
41
+ )
42
+ run(
43
+ [executable, "-B", "-m", "ruff", "format", "--check", str(root / "source"), str(root / "tests")],
44
+ env=environment,
45
+ check=True,
46
+ )
47
+ run(
48
+ [executable, "-B", "-m", "mypy", str(root / "source"), str(root / "tests")],
49
+ env=environment,
50
+ check=True,
51
+ )
52
+ run([executable, "-B", "-m", "pytest", str(root / "tests")], env=environment, check=True)
53
+ print("Orchestration: command -> Ruff -> mypy -> regression tests")
javascript_output.py ADDED
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ from rjsmin import jsmin
4
+
5
+
6
+ def write_javascript(javascript: str, target: Path, minified: bool) -> None:
7
+ if minified:
8
+ javascript = jsmin(javascript)
9
+ target.write_text(javascript)