sonolus.py 0.3.4__py3-none-any.whl → 0.4.1__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.

Potentially problematic release.


This version of sonolus.py might be problematic. Click here for more details.

Files changed (64) hide show
  1. sonolus/backend/excepthook.py +30 -0
  2. sonolus/backend/finalize.py +15 -1
  3. sonolus/backend/ops.py +4 -0
  4. sonolus/backend/optimize/allocate.py +5 -5
  5. sonolus/backend/optimize/constant_evaluation.py +124 -19
  6. sonolus/backend/optimize/copy_coalesce.py +15 -12
  7. sonolus/backend/optimize/dead_code.py +7 -6
  8. sonolus/backend/optimize/dominance.py +2 -2
  9. sonolus/backend/optimize/flow.py +54 -8
  10. sonolus/backend/optimize/inlining.py +137 -30
  11. sonolus/backend/optimize/liveness.py +2 -2
  12. sonolus/backend/optimize/optimize.py +15 -1
  13. sonolus/backend/optimize/passes.py +11 -3
  14. sonolus/backend/optimize/simplify.py +137 -8
  15. sonolus/backend/optimize/ssa.py +47 -13
  16. sonolus/backend/place.py +5 -4
  17. sonolus/backend/utils.py +44 -16
  18. sonolus/backend/visitor.py +288 -17
  19. sonolus/build/cli.py +47 -19
  20. sonolus/build/compile.py +12 -5
  21. sonolus/build/engine.py +70 -1
  22. sonolus/build/level.py +3 -3
  23. sonolus/build/project.py +2 -2
  24. sonolus/script/archetype.py +12 -9
  25. sonolus/script/array.py +23 -18
  26. sonolus/script/array_like.py +26 -29
  27. sonolus/script/bucket.py +1 -1
  28. sonolus/script/containers.py +22 -26
  29. sonolus/script/debug.py +20 -43
  30. sonolus/script/effect.py +1 -1
  31. sonolus/script/globals.py +3 -3
  32. sonolus/script/instruction.py +2 -2
  33. sonolus/script/internal/builtin_impls.py +155 -28
  34. sonolus/script/internal/constant.py +13 -3
  35. sonolus/script/internal/context.py +46 -15
  36. sonolus/script/internal/impl.py +9 -3
  37. sonolus/script/internal/introspection.py +8 -1
  38. sonolus/script/internal/native.py +2 -2
  39. sonolus/script/internal/range.py +8 -11
  40. sonolus/script/internal/simulation_context.py +1 -1
  41. sonolus/script/internal/transient.py +2 -2
  42. sonolus/script/internal/value.py +41 -3
  43. sonolus/script/interval.py +13 -13
  44. sonolus/script/iterator.py +53 -107
  45. sonolus/script/level.py +2 -2
  46. sonolus/script/maybe.py +241 -0
  47. sonolus/script/num.py +29 -14
  48. sonolus/script/options.py +1 -1
  49. sonolus/script/particle.py +1 -1
  50. sonolus/script/project.py +24 -5
  51. sonolus/script/quad.py +15 -15
  52. sonolus/script/record.py +48 -44
  53. sonolus/script/runtime.py +22 -18
  54. sonolus/script/sprite.py +1 -1
  55. sonolus/script/stream.py +66 -82
  56. sonolus/script/transform.py +35 -34
  57. sonolus/script/values.py +10 -10
  58. sonolus/script/vec.py +21 -18
  59. {sonolus_py-0.3.4.dist-info → sonolus_py-0.4.1.dist-info}/METADATA +1 -1
  60. sonolus_py-0.4.1.dist-info/RECORD +93 -0
  61. sonolus_py-0.3.4.dist-info/RECORD +0 -92
  62. {sonolus_py-0.3.4.dist-info → sonolus_py-0.4.1.dist-info}/WHEEL +0 -0
  63. {sonolus_py-0.3.4.dist-info → sonolus_py-0.4.1.dist-info}/entry_points.txt +0 -0
  64. {sonolus_py-0.3.4.dist-info → sonolus_py-0.4.1.dist-info}/licenses/LICENSE +0 -0
sonolus/backend/utils.py CHANGED
@@ -23,29 +23,33 @@ def get_tree_from_file(file: str | Path) -> ast.Module:
23
23
  class FindFunction(ast.NodeVisitor):
24
24
  def __init__(self, line):
25
25
  self.line = line
26
- self.node: ast.FunctionDef | None = None
26
+ self.results: list[ast.FunctionDef | ast.Lambda] = []
27
27
 
28
28
  def visit_FunctionDef(self, node: ast.FunctionDef):
29
- if node.lineno == self.line or (
30
- node.decorator_list and (node.decorator_list[-1].end_lineno <= self.line <= node.lineno)
31
- ):
32
- self.node = node
33
- else:
34
- self.generic_visit(node)
29
+ self.results.append(node)
30
+ self.generic_visit(node)
35
31
 
36
32
  def visit_Lambda(self, node: ast.Lambda):
37
- if node.lineno == self.line:
38
- if self.node is not None:
39
- raise ValueError("Multiple functions defined on the same line are not supported")
40
- self.node = node
41
- else:
42
- self.generic_visit(node)
33
+ self.results.append(node)
34
+ self.generic_visit(node)
43
35
 
44
36
 
45
- def find_function(tree: ast.Module, line: int):
46
- visitor = FindFunction(line)
37
+ @cache
38
+ def get_functions(tree: ast.Module) -> list[ast.FunctionDef | ast.Lambda]:
39
+ visitor = FindFunction(0)
47
40
  visitor.visit(tree)
48
- return visitor.node
41
+ return visitor.results
42
+
43
+
44
+ def find_function(tree: ast.Module, line: int):
45
+ for node in get_functions(tree):
46
+ if node.lineno == line or (
47
+ isinstance(node, ast.FunctionDef)
48
+ and node.decorator_list
49
+ and (node.decorator_list[-1].end_lineno <= line <= node.lineno)
50
+ ):
51
+ return node
52
+ raise ValueError("Function not found")
49
53
 
50
54
 
51
55
  class ScanWrites(ast.NodeVisitor):
@@ -61,3 +65,27 @@ def scan_writes(node: ast.AST) -> set[str]:
61
65
  visitor = ScanWrites()
62
66
  visitor.visit(node)
63
67
  return set(visitor.writes)
68
+
69
+
70
+ class HasDirectYield(ast.NodeVisitor):
71
+ def __init__(self):
72
+ self.started = False
73
+ self.has_yield = False
74
+
75
+ def visit_Yield(self, node: ast.Yield):
76
+ self.has_yield = True
77
+
78
+ def visit_YieldFrom(self, node: ast.YieldFrom):
79
+ self.has_yield = True
80
+
81
+ def visit_FunctionDef(self, node: ast.FunctionDef):
82
+ if self.started:
83
+ return
84
+ self.started = True
85
+ self.generic_visit(node)
86
+
87
+
88
+ def has_yield(node: ast.AST) -> bool:
89
+ visitor = HasDirectYield()
90
+ visitor.visit(node)
91
+ return visitor.has_yield
@@ -1,26 +1,31 @@
1
1
  # ruff: noqa: N802
2
+ from __future__ import annotations
3
+
2
4
  import ast
3
5
  import builtins
4
6
  import functools
5
7
  import inspect
6
- from collections.abc import Callable, Sequence
8
+ from collections.abc import Callable, Iterable, Sequence
7
9
  from inspect import ismethod
8
10
  from types import FunctionType, MethodType, MethodWrapperType
9
- from typing import Any, Never, Self
11
+ from typing import Any, Never
10
12
 
11
13
  from sonolus.backend.excepthook import install_excepthook
12
- from sonolus.backend.utils import get_function, scan_writes
14
+ from sonolus.backend.utils import get_function, has_yield, scan_writes
13
15
  from sonolus.script.debug import assert_true
14
16
  from sonolus.script.internal.builtin_impls import BUILTIN_IMPLS, _bool, _float, _int, _len
15
17
  from sonolus.script.internal.constant import ConstantValue
16
- from sonolus.script.internal.context import Context, EmptyBinding, Scope, ValueBinding, ctx, set_ctx
18
+ from sonolus.script.internal.context import Context, EmptyBinding, Scope, ValueBinding, ctx, set_ctx, using_ctx
17
19
  from sonolus.script.internal.descriptor import SonolusDescriptor
18
20
  from sonolus.script.internal.error import CompilationError
19
- from sonolus.script.internal.impl import validate_value
21
+ from sonolus.script.internal.impl import meta_fn, validate_value
22
+ from sonolus.script.internal.transient import TransientValue
20
23
  from sonolus.script.internal.tuple_impl import TupleImpl
21
24
  from sonolus.script.internal.value import Value
22
25
  from sonolus.script.iterator import SonolusIterator
26
+ from sonolus.script.maybe import Maybe
23
27
  from sonolus.script.num import Num, _is_num
28
+ from sonolus.script.record import Record
24
29
 
25
30
  _compiler_internal_ = True
26
31
 
@@ -31,6 +36,30 @@ def compile_and_call[**P, R](fn: Callable[P, R], /, *args: P.args, **kwargs: P.k
31
36
  return validate_value(generate_fn_impl(fn)(*args, **kwargs))
32
37
 
33
38
 
39
+ def compile_and_call_at_definition[**P, R](fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R:
40
+ if not ctx():
41
+ return fn(*args, **kwargs)
42
+ source_file, node = get_function(fn)
43
+ location_args = {
44
+ "lineno": node.lineno,
45
+ "col_offset": node.col_offset,
46
+ "end_lineno": node.lineno,
47
+ "end_col_offset": node.col_offset + 1,
48
+ }
49
+ expr = ast.Expression(
50
+ body=ast.Call(
51
+ func=ast.Name(id="fn", ctx=ast.Load(), **location_args),
52
+ args=[],
53
+ keywords=[],
54
+ **location_args,
55
+ )
56
+ )
57
+ return eval(
58
+ compile(expr, filename=source_file, mode="eval"),
59
+ {"fn": lambda: compile_and_call(fn, *args, **kwargs), "_filter_traceback_": True, "_traceback_root_": True},
60
+ )
61
+
62
+
34
63
  def generate_fn_impl(fn: Callable):
35
64
  install_excepthook()
36
65
  match fn:
@@ -189,15 +218,18 @@ class Visitor(ast.NodeVisitor):
189
218
  Context | list[Context]
190
219
  ] # Contexts at loop heads, from outer to inner. Contains a list for unrolled (tuple) loops
191
220
  break_ctxs: list[list[Context]] # Contexts at break statements, from outer to inner
192
- active_ctx: Context | None # The active context for use in nested functions=
193
- parent: Self | None # The parent visitor for use in nested functions
221
+ yield_ctxs: list[Context] # Contexts at yield statements, which will branch to the exit
222
+ resume_ctxs: list[Context] # Contexts after yield statements
223
+ active_ctx: Context | None # The active context for use in nested functions
224
+ parent: Visitor | None # The parent visitor for use in nested functions
225
+ used_parent_binding_values: dict[str, Value] # Values of parent bindings used in this function
194
226
 
195
227
  def __init__(
196
228
  self,
197
229
  source_file: str,
198
230
  bound_args: inspect.BoundArguments,
199
231
  global_vars: dict[str, Any],
200
- parent: Self | None = None,
232
+ parent: Visitor | None = None,
201
233
  ):
202
234
  self.source_file = source_file
203
235
  self.globals = global_vars
@@ -206,12 +238,16 @@ class Visitor(ast.NodeVisitor):
206
238
  self.return_ctxs = []
207
239
  self.loop_head_ctxs = []
208
240
  self.break_ctxs = []
241
+ self.yield_ctxs = []
242
+ self.resume_ctxs = []
209
243
  self.active_ctx = None
210
244
  self.parent = parent
245
+ self.used_parent_binding_values = {}
211
246
 
212
247
  def run(self, node):
213
248
  before_ctx = ctx()
214
- set_ctx(before_ctx.branch_with_scope(None, Scope()))
249
+ start_ctx = before_ctx.branch_with_scope(None, Scope())
250
+ set_ctx(start_ctx)
215
251
  for name, value in self.bound_args.arguments.items():
216
252
  ctx().scope.set_value(name, validate_value(value))
217
253
  match node:
@@ -224,8 +260,75 @@ class Visitor(ast.NodeVisitor):
224
260
  case ast.Lambda(body=body):
225
261
  result = self.visit(body)
226
262
  ctx().scope.set_value("$return", result)
263
+ case ast.GeneratorExp(elt=elt, generators=generators):
264
+ first_generator = generators[0]
265
+ iterable = self.visit(first_generator.iter)
266
+ if isinstance(iterable, TupleImpl):
267
+ initial_iterator = iterable
268
+ else:
269
+ if not hasattr(iterable, "__iter__"):
270
+ raise TypeError(f"Object of type '{type(iterable).__name__}' is not iterable")
271
+ initial_iterator = self.handle_call(first_generator.iter, iterable.__iter__)
272
+ if not isinstance(initial_iterator, SonolusIterator):
273
+ raise ValueError("Unsupported iterator")
274
+ # The initial iterator is evaluated eagerly in Python
275
+ before_ctx = ctx().branch_with_scope(None, before_ctx.scope.copy())
276
+ start_ctx = before_ctx.branch_with_scope(None, Scope())
277
+ set_ctx(start_ctx)
278
+ self.construct_genexpr(generators, elt, initial_iterator)
279
+ ctx().scope.set_value("$return", validate_value(None))
227
280
  case _:
228
281
  raise NotImplementedError("Unsupported syntax")
282
+ if has_yield(node) or isinstance(node, ast.GeneratorExp):
283
+ return_ctx = Context.meet([*self.return_ctxs, ctx()])
284
+ result_binding = return_ctx.scope.get_binding("$return")
285
+ if not isinstance(result_binding, ValueBinding):
286
+ raise ValueError("Function has conflicting return values")
287
+ if not result_binding.value._is_py_() and result_binding.value._as_py_() is not None:
288
+ raise ValueError("Generator function return statements must return None")
289
+ with using_ctx(start_ctx):
290
+ state_var = Num._alloc_()
291
+ is_present_var = Num._alloc_()
292
+ with using_ctx(before_ctx):
293
+ state_var._set_(0)
294
+ with using_ctx(return_ctx):
295
+ state_var._set_(len(self.return_ctxs) + 1)
296
+ is_present_var._set_(0)
297
+ del before_ctx.outgoing[None] # Unlink the state machine body from the call site
298
+ entry = before_ctx.new_empty_disconnected()
299
+ entry.test = state_var.ir()
300
+ for i, tgt in enumerate([start_ctx, *self.resume_ctxs]):
301
+ entry.outgoing[i] = tgt
302
+ entry.outgoing[None] = return_ctx
303
+ yield_between_ctxs = []
304
+ for i, out in enumerate(self.yield_ctxs, start=1):
305
+ between = out.branch(None)
306
+ with using_ctx(between):
307
+ state_var._set_(i)
308
+ yield_between_ctxs.append(between)
309
+ if yield_between_ctxs:
310
+ yield_merge_ctx = Context.meet(yield_between_ctxs)
311
+ else:
312
+ yield_merge_ctx = before_ctx.new_empty_disconnected()
313
+ # Making it default to a number for convenience when used with stuff like min, etc.
314
+ yield_merge_ctx.scope.set_value("$yield", validate_value(0))
315
+ yield_binding = yield_merge_ctx.scope.get_binding("$yield")
316
+ if not isinstance(yield_binding, ValueBinding):
317
+ raise ValueError("Function has conflicting yield values")
318
+ with using_ctx(yield_merge_ctx):
319
+ is_present_var._set_(1)
320
+ next_result_ctx = Context.meet([yield_merge_ctx, return_ctx])
321
+ set_ctx(before_ctx)
322
+ return_test = Num._alloc_()
323
+ next_result_ctx.test = return_test.ir()
324
+ return Generator(
325
+ return_test,
326
+ entry,
327
+ next_result_ctx,
328
+ Maybe(present=is_present_var, value=yield_binding.value),
329
+ self.used_parent_binding_values,
330
+ self,
331
+ )
229
332
  after_ctx = Context.meet([*self.return_ctxs, ctx()])
230
333
  self.active_ctx = after_ctx
231
334
  result_binding = after_ctx.scope.get_binding("$return")
@@ -234,6 +337,69 @@ class Visitor(ast.NodeVisitor):
234
337
  set_ctx(after_ctx.branch_with_scope(None, before_ctx.scope.copy()))
235
338
  return result_binding.value
236
339
 
340
+ def construct_genexpr(
341
+ self, generators: Iterable[ast.comprehension], elt: ast.expr, initial_iterator: Value | None = None
342
+ ):
343
+ if not generators:
344
+ # Note that there may effectively be multiple yields in an expression since
345
+ # tuples are unrolled.
346
+ value = self.visit(elt)
347
+ ctx().scope.set_value("$yield", validate_value(value))
348
+ self.yield_ctxs.append(ctx())
349
+ resume_ctx = ctx().new_disconnected()
350
+ self.resume_ctxs.append(resume_ctx)
351
+ set_ctx(resume_ctx)
352
+ return
353
+ generator, *others = generators
354
+ if initial_iterator is not None:
355
+ iterable = initial_iterator
356
+ else:
357
+ iterable = self.visit(generator.iter)
358
+ if isinstance(iterable, TupleImpl):
359
+ for value in iterable.value:
360
+ set_ctx(ctx().branch(None))
361
+ self.handle_assign(generator.target, validate_value(value))
362
+ self.construct_genexpr(others, elt)
363
+ else:
364
+ if initial_iterator is not None:
365
+ iterator = initial_iterator
366
+ else:
367
+ if not hasattr(iterable, "__iter__"):
368
+ raise TypeError(f"Object of type '{type(iterable).__name__}' is not iterable")
369
+ iterator = self.handle_call(generator.iter, iterable.__iter__)
370
+ if not isinstance(iterator, SonolusIterator):
371
+ raise ValueError("Unsupported iterator")
372
+ header_ctx = ctx().branch(None)
373
+ set_ctx(header_ctx)
374
+ next_value = self.handle_call(generator.iter, iterator.next)
375
+ if not isinstance(next_value, Maybe):
376
+ raise ValueError("Iterator next must return a Maybe")
377
+ if next_value._present._is_py_() and not next_value._present._as_py_():
378
+ # This will never run
379
+ return
380
+ ctx().test = next_value._present.ir()
381
+ body_ctx = ctx().branch(None)
382
+ else_ctx = ctx().branch(0)
383
+ set_ctx(body_ctx)
384
+ self.handle_assign(generator.target, next_value._value)
385
+ for if_expr in generator.ifs:
386
+ test = self.convert_to_boolean_num(if_expr, self.visit(if_expr))
387
+ if test._is_py_():
388
+ if test._as_py_():
389
+ continue
390
+ else:
391
+ ctx().outgoing[None] = header_ctx
392
+ set_ctx(ctx().into_dead())
393
+ else:
394
+ if_then_ctx = ctx().branch(None)
395
+ if_else_ctx = ctx().branch(0)
396
+ ctx().test = test.ir()
397
+ if_else_ctx.outgoing[None] = header_ctx
398
+ set_ctx(if_then_ctx)
399
+ self.construct_genexpr(others, elt)
400
+ ctx().outgoing[None] = header_ctx
401
+ set_ctx(else_ctx)
402
+
237
403
  def visit(self, node):
238
404
  """Visit a node."""
239
405
  # We want this here so this is filtered out of tracebacks
@@ -351,6 +517,8 @@ class Visitor(ast.NodeVisitor):
351
517
  if break_ctxs:
352
518
  set_ctx(Context.meet([*break_ctxs, ctx()]))
353
519
  return
520
+ if not hasattr(iterable, "__iter__"):
521
+ raise TypeError(f"Object of type '{type(iterable).__name__}' is not iterable")
354
522
  iterator = self.handle_call(node, iterable.__iter__)
355
523
  if not isinstance(iterator, SonolusIterator):
356
524
  raise ValueError("Unsupported iterator")
@@ -359,8 +527,10 @@ class Visitor(ast.NodeVisitor):
359
527
  self.loop_head_ctxs.append(header_ctx)
360
528
  self.break_ctxs.append([])
361
529
  set_ctx(header_ctx)
362
- has_next = self.convert_to_boolean_num(node, self.handle_call(node, iterator.has_next))
363
- if has_next._is_py_() and not has_next._as_py_():
530
+ next_value = self.handle_call(node, iterator.next)
531
+ if not isinstance(next_value, Maybe):
532
+ raise ValueError("Iterator next must return a Maybe")
533
+ if next_value._present._is_py_() and not next_value._present._as_py_():
364
534
  # The loop will never run, continue after evaluating the condition
365
535
  self.loop_head_ctxs.pop()
366
536
  self.break_ctxs.pop()
@@ -369,12 +539,12 @@ class Visitor(ast.NodeVisitor):
369
539
  break
370
540
  self.visit(stmt)
371
541
  return
372
- ctx().test = has_next.ir()
542
+ ctx().test = next_value._present.ir()
373
543
  body_ctx = ctx().branch(None)
374
544
  else_ctx = ctx().branch(0)
375
545
 
376
546
  set_ctx(body_ctx)
377
- self.handle_assign(node.target, self.handle_call(node, iterator.next))
547
+ self.handle_assign(node.target, next_value._value)
378
548
  for stmt in node.body:
379
549
  if not ctx().live:
380
550
  break
@@ -811,16 +981,50 @@ class Visitor(ast.NodeVisitor):
811
981
  raise NotImplementedError("Dict comprehensions are not supported")
812
982
 
813
983
  def visit_GeneratorExp(self, node):
814
- raise NotImplementedError("Generator expressions are not supported")
984
+ self.active_ctx = ctx()
985
+ return Visitor(self.source_file, inspect.Signature([]).bind(), self.globals, self).run(node)
815
986
 
816
987
  def visit_Await(self, node):
817
988
  raise NotImplementedError("Await expressions are not supported")
818
989
 
819
990
  def visit_Yield(self, node):
820
- raise NotImplementedError("Yield expressions are not supported")
991
+ value = self.visit(node.value) if node.value else validate_value(None)
992
+ ctx().scope.set_value("$yield", value)
993
+ self.yield_ctxs.append(ctx())
994
+ resume_ctx = ctx().new_disconnected()
995
+ self.resume_ctxs.append(resume_ctx)
996
+ set_ctx(resume_ctx)
997
+ return validate_value(None) # send() is unsupported, so yield returns None
821
998
 
822
999
  def visit_YieldFrom(self, node):
823
- raise NotImplementedError("Yield from expressions are not supported")
1000
+ value = self.visit(node.value)
1001
+ if isinstance(value, TupleImpl):
1002
+ for entry in value.value:
1003
+ ctx().scope.set_value("$yield", validate_value(entry))
1004
+ self.yield_ctxs.append(ctx())
1005
+ resume_ctx = ctx().new_disconnected()
1006
+ self.resume_ctxs.append(resume_ctx)
1007
+ set_ctx(resume_ctx)
1008
+ return validate_value(None)
1009
+ if not hasattr(value, "__iter__"):
1010
+ raise TypeError(f"Object of type '{type(value).__name__}' is not iterable")
1011
+ iterator = self.handle_call(node, value.__iter__)
1012
+ if not isinstance(iterator, SonolusIterator):
1013
+ raise ValueError("Expected a SonolusIterator")
1014
+ header = ctx().branch(None)
1015
+ set_ctx(header)
1016
+ result = self.handle_call(node, iterator.next)
1017
+ nothing_branch = ctx().branch(0)
1018
+ some_branch = ctx().branch(None)
1019
+ ctx().test = result._present.ir()
1020
+ set_ctx(some_branch)
1021
+ ctx().scope.set_value("$yield", result._value)
1022
+ self.yield_ctxs.append(ctx())
1023
+ resume_ctx = ctx().new_disconnected()
1024
+ self.resume_ctxs.append(resume_ctx)
1025
+ resume_ctx.outgoing[None] = header
1026
+ set_ctx(nothing_branch)
1027
+ return validate_value(None)
824
1028
 
825
1029
  def _has_real_method(self, obj: Value, method_name: str) -> bool:
826
1030
  return hasattr(obj, method_name) and not isinstance(getattr(obj, method_name), MethodWrapperType)
@@ -929,11 +1133,16 @@ class Visitor(ast.NodeVisitor):
929
1133
  raise NotImplementedError("Starred expressions are not supported")
930
1134
 
931
1135
  def visit_Name(self, node):
1136
+ if node.id in self.used_parent_binding_values:
1137
+ return self.used_parent_binding_values[node.id]
932
1138
  self.active_ctx = ctx()
933
1139
  v = self
934
1140
  while v:
935
1141
  if not isinstance(v.active_ctx.scope.get_binding(node.id), EmptyBinding):
936
- return v.active_ctx.scope.get_value(node.id)
1142
+ result = v.active_ctx.scope.get_value(node.id)
1143
+ if v is not self:
1144
+ self.used_parent_binding_values[node.id] = result
1145
+ return result
937
1146
  v = v.parent
938
1147
  if node.id in self.globals:
939
1148
  value = self.globals[node.id]
@@ -1141,6 +1350,9 @@ class Visitor(ast.NodeVisitor):
1141
1350
  if length._is_py_():
1142
1351
  return Num._accept_(length._as_py_() > 0)
1143
1352
  return length > Num._accept_(0)
1353
+ if isinstance(value, Record):
1354
+ return Num._accept_(1)
1355
+ # Not allowing other types to default to truthy for now in case there's any edge cases.
1144
1356
  raise TypeError(f"Converting {type(value).__name__} to bool is not supported")
1145
1357
 
1146
1358
  def arguments_to_signature(self, arguments: ast.arguments) -> inspect.Signature:
@@ -1269,3 +1481,62 @@ class ReportingErrorsAtNode:
1269
1481
 
1270
1482
  if exc_value is not None:
1271
1483
  self.compiler.raise_exception_at_node(self.node, exc_value)
1484
+
1485
+
1486
+ class Generator(TransientValue, SonolusIterator):
1487
+ def __init__(
1488
+ self,
1489
+ return_test: Num,
1490
+ entry: Context,
1491
+ exit_: Context,
1492
+ value: Maybe,
1493
+ used_bindings: dict[str, Value],
1494
+ parent: Visitor,
1495
+ ):
1496
+ self.i = 0
1497
+ self.return_test = return_test
1498
+ self.entry = entry
1499
+ self.exit = exit_
1500
+ self.value = value
1501
+ self.used_bindings = used_bindings
1502
+ self.parent = parent
1503
+
1504
+ @meta_fn
1505
+ def next(self):
1506
+ self._validate_bindings()
1507
+ self.return_test._set_(self.i)
1508
+ after_ctx = ctx().new_disconnected()
1509
+ ctx().outgoing[None] = self.entry
1510
+ self.exit.outgoing[self.i] = after_ctx
1511
+ self.i += 1
1512
+ set_ctx(after_ctx)
1513
+ return self.value
1514
+
1515
+ def _validate_bindings(self):
1516
+ for key, value in self.used_bindings.items():
1517
+ v = self.parent
1518
+ while v:
1519
+ if not isinstance(v.active_ctx.scope.get_binding(key), EmptyBinding):
1520
+ result = v.active_ctx.scope.get_value(key)
1521
+ if result is not value:
1522
+ raise ValueError(f"Binding '{key}' has been modified since the generator was created")
1523
+ v = v.parent
1524
+
1525
+ def __iter__(self):
1526
+ return self
1527
+
1528
+ @classmethod
1529
+ def _accepts_(cls, value: Any) -> bool:
1530
+ return isinstance(value, cls)
1531
+
1532
+ @classmethod
1533
+ def _accept_(cls, value: Any) -> Generator:
1534
+ if not cls._accepts_(value):
1535
+ raise TypeError(f"Cannot accept value of type {type(value).__name__} as {cls.__name__}")
1536
+ return value
1537
+
1538
+ def _is_py_(self) -> bool:
1539
+ return False
1540
+
1541
+ def _as_py_(self) -> Any:
1542
+ raise NotImplementedError
sonolus/build/cli.py CHANGED
@@ -10,10 +10,12 @@ import sys
10
10
  from pathlib import Path
11
11
  from time import perf_counter
12
12
 
13
+ from sonolus.backend.excepthook import print_simple_traceback
13
14
  from sonolus.backend.optimize.optimize import FAST_PASSES, MINIMAL_PASSES, STANDARD_PASSES
14
- from sonolus.build.engine import no_gil, package_engine
15
+ from sonolus.build.engine import no_gil, package_engine, validate_engine
15
16
  from sonolus.build.level import package_level_data
16
17
  from sonolus.build.project import build_project_to_collection, get_project_schema
18
+ from sonolus.script.internal.error import CompilationError
17
19
  from sonolus.script.project import BuildConfig, Project
18
20
 
19
21
 
@@ -81,7 +83,11 @@ def build_project(project: Project, build_dir: Path, config: BuildConfig):
81
83
  level_path.write_bytes(package_level_data(level.data))
82
84
 
83
85
 
84
- def build_collection(project: Project, build_dir: Path, config: BuildConfig):
86
+ def validate_project(project: Project, config: BuildConfig):
87
+ validate_engine(project.engine.data, config)
88
+
89
+
90
+ def build_collection(project: Project, build_dir: Path, config: BuildConfig | None):
85
91
  site_dir = build_dir / "site"
86
92
  shutil.rmtree(site_dir, ignore_errors=True)
87
93
  site_dir.mkdir(parents=True, exist_ok=True)
@@ -204,6 +210,15 @@ def main():
204
210
  help="Module path (e.g., 'module.name'). If omitted, will auto-detect if only one module exists.",
205
211
  )
206
212
 
213
+ check_parser = subparsers.add_parser("check")
214
+ check_parser.add_argument(
215
+ "module",
216
+ type=str,
217
+ nargs="?",
218
+ help="Module path (e.g., 'module.name'). If omitted, will auto-detect if only one module exists.",
219
+ )
220
+ add_common_arguments(check_parser)
221
+
207
222
  args = parser.parse_args()
208
223
 
209
224
  if not args.module:
@@ -220,24 +235,37 @@ def main():
220
235
  if hasattr(sys, "_jit") and sys._jit.is_enabled():
221
236
  print("Python JIT is enabled")
222
237
 
238
+ start_time = perf_counter()
223
239
  project = import_project(args.module)
240
+ end_time = perf_counter()
224
241
  if project is None:
225
242
  sys.exit(1)
243
+ print(f"Project imported in {end_time - start_time:.2f}s")
226
244
 
227
- if args.command == "build":
228
- build_dir = Path(args.build_dir)
229
- start_time = perf_counter()
230
- config = get_config(args)
231
- build_project(project, build_dir, config)
232
- end_time = perf_counter()
233
- print(f"Project built successfully to '{build_dir.resolve()}' in {end_time - start_time:.2f}s")
234
- elif args.command == "dev":
235
- build_dir = Path(args.build_dir)
236
- start_time = perf_counter()
237
- config = get_config(args)
238
- build_collection(project, build_dir, config)
239
- end_time = perf_counter()
240
- print(f"Build finished in {end_time - start_time:.2f}s")
241
- run_server(build_dir / "site", port=args.port)
242
- elif args.command == "schema":
243
- print(json.dumps(get_project_schema(project), indent=2))
245
+ try:
246
+ if args.command == "build":
247
+ build_dir = Path(args.build_dir)
248
+ start_time = perf_counter()
249
+ config = get_config(args)
250
+ build_project(project, build_dir, config)
251
+ end_time = perf_counter()
252
+ print(f"Project built successfully to '{build_dir.resolve()}' in {end_time - start_time:.2f}s")
253
+ elif args.command == "dev":
254
+ build_dir = Path(args.build_dir)
255
+ start_time = perf_counter()
256
+ config = get_config(args)
257
+ build_collection(project, build_dir, config)
258
+ end_time = perf_counter()
259
+ print(f"Build finished in {end_time - start_time:.2f}s")
260
+ run_server(build_dir / "site", port=args.port)
261
+ elif args.command == "schema":
262
+ print(json.dumps(get_project_schema(project), indent=2))
263
+ elif args.command == "check":
264
+ start_time = perf_counter()
265
+ config = get_config(args)
266
+ validate_project(project, config)
267
+ end_time = perf_counter()
268
+ print(f"Project validation completed successfully in {end_time - start_time:.2f}s")
269
+ except CompilationError:
270
+ exc_info = sys.exc_info()
271
+ print_simple_traceback(*exc_info)
sonolus/build/compile.py CHANGED
@@ -7,8 +7,8 @@ from sonolus.backend.mode import Mode
7
7
  from sonolus.backend.ops import Op
8
8
  from sonolus.backend.optimize.flow import BasicBlock
9
9
  from sonolus.backend.optimize.optimize import STANDARD_PASSES
10
- from sonolus.backend.optimize.passes import CompilerPass, run_passes
11
- from sonolus.backend.visitor import compile_and_call
10
+ from sonolus.backend.optimize.passes import CompilerPass, OptimizerConfig, run_passes
11
+ from sonolus.backend.visitor import compile_and_call_at_definition
12
12
  from sonolus.build.node import OutputNodeGenerator
13
13
  from sonolus.script.archetype import _BaseArchetype
14
14
  from sonolus.script.internal.callbacks import CallbackInfo
@@ -31,6 +31,7 @@ def compile_mode(
31
31
  global_callbacks: list[tuple[CallbackInfo, Callable]] | None,
32
32
  passes: Sequence[CompilerPass] | None = None,
33
33
  thread_pool: Executor | None = None,
34
+ validate_only: bool = False,
34
35
  ) -> dict:
35
36
  if passes is None:
36
37
  passes = STANDARD_PASSES
@@ -55,7 +56,13 @@ def compile_mode(
55
56
  - (cb_info.name, {"index": node_index, "order": cb_order}) for archetype callbacks.
56
57
  """
57
58
  cfg = callback_to_cfg(global_state, cb, cb_info.name, arch)
58
- cfg = run_passes(cfg, passes)
59
+ if validate_only:
60
+ if arch is not None:
61
+ cb_order = getattr(cb, "_callback_order_", 0)
62
+ return cb_info.name, {"index": 0, "order": cb_order}
63
+ else:
64
+ return cb_info.name, 0
65
+ cfg = run_passes(cfg, passes, OptimizerConfig(mode=mode, callback=cb_info.name))
59
66
  node = cfg_to_engine_node(cfg)
60
67
  node_index = nodes.add(node)
61
68
 
@@ -137,9 +144,9 @@ def callback_to_cfg(
137
144
  context = Context(global_state, callback_state)
138
145
  with using_ctx(context):
139
146
  if archetype is not None:
140
- result = compile_and_call(callback, archetype._for_compilation())
147
+ result = compile_and_call_at_definition(callback, archetype._for_compilation())
141
148
  else:
142
- result = compile_and_call(callback)
149
+ result = compile_and_call_at_definition(callback)
143
150
  if _is_num(result):
144
151
  ctx().add_statements(IRInstr(Op.Break, [IRConst(1), result.ir()]))
145
152
  return context_to_cfg(context)