sonolus.py 0.3.3__py3-none-any.whl → 0.4.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.

Potentially problematic release.


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

Files changed (66) 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 +24 -0
  18. sonolus/backend/visitor.py +260 -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 +27 -24
  25. sonolus/script/array.py +25 -19
  26. sonolus/script/array_like.py +46 -49
  27. sonolus/script/bucket.py +1 -1
  28. sonolus/script/containers.py +22 -26
  29. sonolus/script/debug.py +24 -47
  30. sonolus/script/effect.py +1 -1
  31. sonolus/script/engine.py +2 -2
  32. sonolus/script/globals.py +3 -3
  33. sonolus/script/instruction.py +3 -3
  34. sonolus/script/internal/builtin_impls.py +155 -28
  35. sonolus/script/internal/constant.py +13 -3
  36. sonolus/script/internal/context.py +46 -15
  37. sonolus/script/internal/impl.py +9 -3
  38. sonolus/script/internal/introspection.py +8 -1
  39. sonolus/script/internal/math_impls.py +17 -0
  40. sonolus/script/internal/native.py +5 -5
  41. sonolus/script/internal/range.py +14 -17
  42. sonolus/script/internal/simulation_context.py +1 -1
  43. sonolus/script/internal/transient.py +2 -2
  44. sonolus/script/internal/value.py +42 -4
  45. sonolus/script/interval.py +15 -15
  46. sonolus/script/iterator.py +38 -107
  47. sonolus/script/maybe.py +139 -0
  48. sonolus/script/num.py +30 -15
  49. sonolus/script/options.py +1 -1
  50. sonolus/script/particle.py +1 -1
  51. sonolus/script/pointer.py +1 -1
  52. sonolus/script/project.py +24 -5
  53. sonolus/script/quad.py +15 -15
  54. sonolus/script/record.py +21 -12
  55. sonolus/script/runtime.py +22 -18
  56. sonolus/script/sprite.py +1 -1
  57. sonolus/script/stream.py +69 -85
  58. sonolus/script/transform.py +35 -34
  59. sonolus/script/values.py +10 -10
  60. sonolus/script/vec.py +23 -20
  61. {sonolus_py-0.3.3.dist-info → sonolus_py-0.4.0.dist-info}/METADATA +1 -1
  62. sonolus_py-0.4.0.dist-info/RECORD +93 -0
  63. sonolus_py-0.3.3.dist-info/RECORD +0 -92
  64. {sonolus_py-0.3.3.dist-info → sonolus_py-0.4.0.dist-info}/WHEEL +0 -0
  65. {sonolus_py-0.3.3.dist-info → sonolus_py-0.4.0.dist-info}/entry_points.txt +0 -0
  66. {sonolus_py-0.3.3.dist-info → sonolus_py-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -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,61 @@ 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
+ self.construct_genexpr(generators, elt)
265
+ ctx().scope.set_value("$return", validate_value(None))
227
266
  case _:
228
267
  raise NotImplementedError("Unsupported syntax")
268
+ if has_yield(node) or isinstance(node, ast.GeneratorExp):
269
+ return_ctx = Context.meet([*self.return_ctxs, ctx()])
270
+ result_binding = return_ctx.scope.get_binding("$return")
271
+ if not isinstance(result_binding, ValueBinding):
272
+ raise ValueError("Function has conflicting return values")
273
+ if not result_binding.value._is_py_() and result_binding.value._as_py_() is not None:
274
+ raise ValueError("Generator function return statements must return None")
275
+ with using_ctx(start_ctx):
276
+ state_var = Num._alloc_()
277
+ is_present_var = Num._alloc_()
278
+ with using_ctx(before_ctx):
279
+ state_var._set_(0)
280
+ with using_ctx(return_ctx):
281
+ state_var._set_(len(self.return_ctxs) + 1)
282
+ is_present_var._set_(0)
283
+ del before_ctx.outgoing[None] # Unlink the state machine body from the call site
284
+ entry = before_ctx.new_empty_disconnected()
285
+ entry.test = state_var.ir()
286
+ for i, tgt in enumerate([start_ctx, *self.resume_ctxs]):
287
+ entry.outgoing[i] = tgt
288
+ entry.outgoing[None] = return_ctx
289
+ yield_between_ctxs = []
290
+ for i, out in enumerate(self.yield_ctxs, start=1):
291
+ between = out.branch(None)
292
+ with using_ctx(between):
293
+ state_var._set_(i)
294
+ yield_between_ctxs.append(between)
295
+ if yield_between_ctxs:
296
+ yield_merge_ctx = Context.meet(yield_between_ctxs)
297
+ else:
298
+ yield_merge_ctx = before_ctx.new_empty_disconnected()
299
+ # Making it default to a number for convenience when used with stuff like min, etc.
300
+ yield_merge_ctx.scope.set_value("$yield", validate_value(0))
301
+ yield_binding = yield_merge_ctx.scope.get_binding("$yield")
302
+ if not isinstance(yield_binding, ValueBinding):
303
+ raise ValueError("Function has conflicting yield values")
304
+ with using_ctx(yield_merge_ctx):
305
+ is_present_var._set_(1)
306
+ next_result_ctx = Context.meet([yield_merge_ctx, return_ctx])
307
+ set_ctx(before_ctx)
308
+ return_test = Num._alloc_()
309
+ next_result_ctx.test = return_test.ir()
310
+ return Generator(
311
+ return_test,
312
+ entry,
313
+ next_result_ctx,
314
+ Maybe(present=is_present_var, value=yield_binding.value),
315
+ self.used_parent_binding_values,
316
+ self,
317
+ )
229
318
  after_ctx = Context.meet([*self.return_ctxs, ctx()])
230
319
  self.active_ctx = after_ctx
231
320
  result_binding = after_ctx.scope.get_binding("$return")
@@ -234,6 +323,59 @@ class Visitor(ast.NodeVisitor):
234
323
  set_ctx(after_ctx.branch_with_scope(None, before_ctx.scope.copy()))
235
324
  return result_binding.value
236
325
 
326
+ def construct_genexpr(self, generators: Iterable[ast.comprehension], elt: ast.expr):
327
+ if not generators:
328
+ # Note that there may effectively be multiple yields in an expression since
329
+ # tuples are unrolled.
330
+ value = self.visit(elt)
331
+ ctx().scope.set_value("$yield", validate_value(value))
332
+ self.yield_ctxs.append(ctx())
333
+ resume_ctx = ctx().new_disconnected()
334
+ self.resume_ctxs.append(resume_ctx)
335
+ set_ctx(resume_ctx)
336
+ return
337
+ generator, *others = generators
338
+ iterable = self.visit(generator.iter)
339
+ if isinstance(iterable, TupleImpl):
340
+ for value in iterable.value:
341
+ set_ctx(ctx().branch(None))
342
+ self.handle_assign(generator.target, validate_value(value))
343
+ self.construct_genexpr(others, elt)
344
+ else:
345
+ iterator = self.handle_call(generator.iter, iterable.__iter__)
346
+ if not isinstance(iterator, SonolusIterator):
347
+ raise ValueError("Unsupported iterator")
348
+ header_ctx = ctx().branch(None)
349
+ set_ctx(header_ctx)
350
+ next_value = self.handle_call(generator.iter, iterator.next)
351
+ if not isinstance(next_value, Maybe):
352
+ raise ValueError("Iterator next must return a Maybe")
353
+ if next_value._present._is_py_() and not next_value._present._as_py_():
354
+ # This will never run
355
+ return
356
+ ctx().test = next_value._present.ir()
357
+ body_ctx = ctx().branch(None)
358
+ else_ctx = ctx().branch(0)
359
+ set_ctx(body_ctx)
360
+ self.handle_assign(generator.target, next_value._value)
361
+ for if_expr in generator.ifs:
362
+ test = self.convert_to_boolean_num(if_expr, self.visit(if_expr))
363
+ if test._is_py_():
364
+ if test._as_py_():
365
+ continue
366
+ else:
367
+ ctx().outgoing[None] = header_ctx
368
+ set_ctx(ctx().into_dead())
369
+ else:
370
+ if_then_ctx = ctx().branch(None)
371
+ if_else_ctx = ctx().branch(0)
372
+ ctx().test = test.ir()
373
+ if_else_ctx.outgoing[None] = header_ctx
374
+ set_ctx(if_then_ctx)
375
+ self.construct_genexpr(others, elt)
376
+ ctx().outgoing[None] = header_ctx
377
+ set_ctx(else_ctx)
378
+
237
379
  def visit(self, node):
238
380
  """Visit a node."""
239
381
  # We want this here so this is filtered out of tracebacks
@@ -359,8 +501,10 @@ class Visitor(ast.NodeVisitor):
359
501
  self.loop_head_ctxs.append(header_ctx)
360
502
  self.break_ctxs.append([])
361
503
  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_():
504
+ next_value = self.handle_call(node, iterator.next)
505
+ if not isinstance(next_value, Maybe):
506
+ raise ValueError("Iterator next must return a Maybe")
507
+ if next_value._present._is_py_() and not next_value._present._as_py_():
364
508
  # The loop will never run, continue after evaluating the condition
365
509
  self.loop_head_ctxs.pop()
366
510
  self.break_ctxs.pop()
@@ -369,12 +513,12 @@ class Visitor(ast.NodeVisitor):
369
513
  break
370
514
  self.visit(stmt)
371
515
  return
372
- ctx().test = has_next.ir()
516
+ ctx().test = next_value._present.ir()
373
517
  body_ctx = ctx().branch(None)
374
518
  else_ctx = ctx().branch(0)
375
519
 
376
520
  set_ctx(body_ctx)
377
- self.handle_assign(node.target, self.handle_call(node, iterator.next))
521
+ self.handle_assign(node.target, next_value._value)
378
522
  for stmt in node.body:
379
523
  if not ctx().live:
380
524
  break
@@ -811,16 +955,48 @@ class Visitor(ast.NodeVisitor):
811
955
  raise NotImplementedError("Dict comprehensions are not supported")
812
956
 
813
957
  def visit_GeneratorExp(self, node):
814
- raise NotImplementedError("Generator expressions are not supported")
958
+ self.active_ctx = ctx()
959
+ return Visitor(self.source_file, inspect.Signature([]).bind(), self.globals, self).run(node)
815
960
 
816
961
  def visit_Await(self, node):
817
962
  raise NotImplementedError("Await expressions are not supported")
818
963
 
819
964
  def visit_Yield(self, node):
820
- raise NotImplementedError("Yield expressions are not supported")
965
+ value = self.visit(node.value) if node.value else validate_value(None)
966
+ ctx().scope.set_value("$yield", value)
967
+ self.yield_ctxs.append(ctx())
968
+ resume_ctx = ctx().new_disconnected()
969
+ self.resume_ctxs.append(resume_ctx)
970
+ set_ctx(resume_ctx)
971
+ return validate_value(None) # send() is unsupported, so yield returns None
821
972
 
822
973
  def visit_YieldFrom(self, node):
823
- raise NotImplementedError("Yield from expressions are not supported")
974
+ value = self.visit(node.value)
975
+ if isinstance(value, TupleImpl):
976
+ for entry in value.value:
977
+ ctx().scope.set_value("$yield", validate_value(entry))
978
+ self.yield_ctxs.append(ctx())
979
+ resume_ctx = ctx().new_disconnected()
980
+ self.resume_ctxs.append(resume_ctx)
981
+ set_ctx(resume_ctx)
982
+ return validate_value(None)
983
+ iterator = self.handle_call(node, value.__iter__)
984
+ if not isinstance(iterator, SonolusIterator):
985
+ raise ValueError("Expected a SonolusIterator")
986
+ header = ctx().branch(None)
987
+ set_ctx(header)
988
+ result = self.handle_call(node, iterator.next)
989
+ nothing_branch = ctx().branch(0)
990
+ some_branch = ctx().branch(None)
991
+ ctx().test = result._present.ir()
992
+ set_ctx(some_branch)
993
+ ctx().scope.set_value("$yield", result._value)
994
+ self.yield_ctxs.append(ctx())
995
+ resume_ctx = ctx().new_disconnected()
996
+ self.resume_ctxs.append(resume_ctx)
997
+ resume_ctx.outgoing[None] = header
998
+ set_ctx(nothing_branch)
999
+ return validate_value(None)
824
1000
 
825
1001
  def _has_real_method(self, obj: Value, method_name: str) -> bool:
826
1002
  return hasattr(obj, method_name) and not isinstance(getattr(obj, method_name), MethodWrapperType)
@@ -929,11 +1105,16 @@ class Visitor(ast.NodeVisitor):
929
1105
  raise NotImplementedError("Starred expressions are not supported")
930
1106
 
931
1107
  def visit_Name(self, node):
1108
+ if node.id in self.used_parent_binding_values:
1109
+ return self.used_parent_binding_values[node.id]
932
1110
  self.active_ctx = ctx()
933
1111
  v = self
934
1112
  while v:
935
1113
  if not isinstance(v.active_ctx.scope.get_binding(node.id), EmptyBinding):
936
- return v.active_ctx.scope.get_value(node.id)
1114
+ result = v.active_ctx.scope.get_value(node.id)
1115
+ if v is not self:
1116
+ self.used_parent_binding_values[node.id] = result
1117
+ return result
937
1118
  v = v.parent
938
1119
  if node.id in self.globals:
939
1120
  value = self.globals[node.id]
@@ -1141,6 +1322,9 @@ class Visitor(ast.NodeVisitor):
1141
1322
  if length._is_py_():
1142
1323
  return Num._accept_(length._as_py_() > 0)
1143
1324
  return length > Num._accept_(0)
1325
+ if isinstance(value, Record):
1326
+ return Num._accept_(1)
1327
+ # Not allowing other types to default to truthy for now in case there's any edge cases.
1144
1328
  raise TypeError(f"Converting {type(value).__name__} to bool is not supported")
1145
1329
 
1146
1330
  def arguments_to_signature(self, arguments: ast.arguments) -> inspect.Signature:
@@ -1269,3 +1453,62 @@ class ReportingErrorsAtNode:
1269
1453
 
1270
1454
  if exc_value is not None:
1271
1455
  self.compiler.raise_exception_at_node(self.node, exc_value)
1456
+
1457
+
1458
+ class Generator(TransientValue, SonolusIterator):
1459
+ def __init__(
1460
+ self,
1461
+ return_test: Num,
1462
+ entry: Context,
1463
+ exit_: Context,
1464
+ value: Maybe,
1465
+ used_bindings: dict[str, Value],
1466
+ parent: Visitor,
1467
+ ):
1468
+ self.i = 0
1469
+ self.return_test = return_test
1470
+ self.entry = entry
1471
+ self.exit = exit_
1472
+ self.value = value
1473
+ self.used_bindings = used_bindings
1474
+ self.parent = parent
1475
+
1476
+ @meta_fn
1477
+ def next(self):
1478
+ self._validate_bindings()
1479
+ self.return_test._set_(self.i)
1480
+ after_ctx = ctx().new_disconnected()
1481
+ ctx().outgoing[None] = self.entry
1482
+ self.exit.outgoing[self.i] = after_ctx
1483
+ self.i += 1
1484
+ set_ctx(after_ctx)
1485
+ return self.value
1486
+
1487
+ def _validate_bindings(self):
1488
+ for key, value in self.used_bindings.items():
1489
+ v = self.parent
1490
+ while v:
1491
+ if not isinstance(v.active_ctx.scope.get_binding(key), EmptyBinding):
1492
+ result = v.active_ctx.scope.get_value(key)
1493
+ if result is not value:
1494
+ raise ValueError(f"Binding '{key}' has been modified since the generator was created")
1495
+ v = v.parent
1496
+
1497
+ def __iter__(self):
1498
+ return self
1499
+
1500
+ @classmethod
1501
+ def _accepts_(cls, value: Any) -> bool:
1502
+ return isinstance(value, cls)
1503
+
1504
+ @classmethod
1505
+ def _accept_(cls, value: Any) -> Generator:
1506
+ if not cls._accepts_(value):
1507
+ raise TypeError(f"Cannot accept value of type {type(value).__name__} as {cls.__name__}")
1508
+ return value
1509
+
1510
+ def _is_py_(self) -> bool:
1511
+ return False
1512
+
1513
+ def _as_py_(self) -> Any:
1514
+ 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)
sonolus/build/engine.py CHANGED
@@ -6,7 +6,6 @@ from collections.abc import Callable
6
6
  from concurrent.futures import Executor
7
7
  from concurrent.futures.thread import ThreadPoolExecutor
8
8
  from dataclasses import dataclass
9
- from os import process_cpu_count
10
9
  from pathlib import Path
11
10
 
12
11
  from sonolus.backend.mode import Mode
@@ -74,6 +73,9 @@ def package_engine(
74
73
  rom = ReadOnlyMemory()
75
74
  configuration = build_engine_configuration(engine.options, engine.ui)
76
75
  if no_gil():
76
+ # process_cpu_count is available in Python 3.13+
77
+ from os import process_cpu_count
78
+
77
79
  thread_pool = ThreadPoolExecutor(process_cpu_count() or 1)
78
80
  else:
79
81
  thread_pool = None
@@ -189,6 +191,65 @@ def package_engine(
189
191
  )
190
192
 
191
193
 
194
+ def validate_engine(
195
+ engine: EngineData,
196
+ config: BuildConfig | None = None,
197
+ ):
198
+ config = config or BuildConfig()
199
+ rom = ReadOnlyMemory()
200
+
201
+ play_mode = engine.play if config.build_play else empty_play_mode()
202
+ watch_mode = engine.watch if config.build_watch else empty_watch_mode()
203
+ preview_mode = engine.preview if config.build_preview else empty_preview_mode()
204
+ tutorial_mode = engine.tutorial if config.build_tutorial else empty_tutorial_mode()
205
+
206
+ build_play_mode(
207
+ archetypes=play_mode.archetypes,
208
+ skin=play_mode.skin,
209
+ effects=play_mode.effects,
210
+ particles=play_mode.particles,
211
+ buckets=play_mode.buckets,
212
+ rom=rom,
213
+ config=config,
214
+ thread_pool=None,
215
+ validate_only=True,
216
+ )
217
+ build_watch_mode(
218
+ archetypes=watch_mode.archetypes,
219
+ skin=watch_mode.skin,
220
+ effects=watch_mode.effects,
221
+ particles=watch_mode.particles,
222
+ buckets=watch_mode.buckets,
223
+ rom=rom,
224
+ update_spawn=watch_mode.update_spawn,
225
+ config=config,
226
+ thread_pool=None,
227
+ validate_only=True,
228
+ )
229
+ build_preview_mode(
230
+ archetypes=preview_mode.archetypes,
231
+ skin=preview_mode.skin,
232
+ rom=rom,
233
+ config=config,
234
+ thread_pool=None,
235
+ validate_only=True,
236
+ )
237
+ build_tutorial_mode(
238
+ skin=tutorial_mode.skin,
239
+ effects=tutorial_mode.effects,
240
+ particles=tutorial_mode.particles,
241
+ instructions=tutorial_mode.instructions,
242
+ instruction_icons=tutorial_mode.instruction_icons,
243
+ preprocess=tutorial_mode.preprocess,
244
+ navigate=tutorial_mode.navigate,
245
+ update=tutorial_mode.update,
246
+ rom=rom,
247
+ config=config,
248
+ thread_pool=None,
249
+ validate_only=True,
250
+ )
251
+
252
+
192
253
  def build_engine_configuration(
193
254
  options: Options,
194
255
  ui: UiConfig,
@@ -208,6 +269,7 @@ def build_play_mode(
208
269
  rom: ReadOnlyMemory,
209
270
  config: BuildConfig,
210
271
  thread_pool: Executor | None = None,
272
+ validate_only: bool = False,
211
273
  ):
212
274
  return {
213
275
  **compile_mode(
@@ -217,6 +279,7 @@ def build_play_mode(
217
279
  global_callbacks=None,
218
280
  passes=config.passes,
219
281
  thread_pool=thread_pool,
282
+ validate_only=validate_only,
220
283
  ),
221
284
  "skin": build_skin(skin),
222
285
  "effect": build_effects(effects),
@@ -235,6 +298,7 @@ def build_watch_mode(
235
298
  update_spawn: Callable[[], float],
236
299
  config: BuildConfig,
237
300
  thread_pool: Executor | None = None,
301
+ validate_only: bool = False,
238
302
  ):
239
303
  return {
240
304
  **compile_mode(
@@ -244,6 +308,7 @@ def build_watch_mode(
244
308
  global_callbacks=[(update_spawn_callback, update_spawn)],
245
309
  passes=config.passes,
246
310
  thread_pool=thread_pool,
311
+ validate_only=validate_only,
247
312
  ),
248
313
  "skin": build_skin(skin),
249
314
  "effect": build_effects(effects),
@@ -258,6 +323,7 @@ def build_preview_mode(
258
323
  rom: ReadOnlyMemory,
259
324
  config: BuildConfig,
260
325
  thread_pool: Executor | None = None,
326
+ validate_only: bool = False,
261
327
  ):
262
328
  return {
263
329
  **compile_mode(
@@ -267,6 +333,7 @@ def build_preview_mode(
267
333
  global_callbacks=None,
268
334
  passes=config.passes,
269
335
  thread_pool=thread_pool,
336
+ validate_only=validate_only,
270
337
  ),
271
338
  "skin": build_skin(skin),
272
339
  }
@@ -284,6 +351,7 @@ def build_tutorial_mode(
284
351
  rom: ReadOnlyMemory,
285
352
  config: BuildConfig,
286
353
  thread_pool: Executor | None = None,
354
+ validate_only: bool = False,
287
355
  ):
288
356
  return {
289
357
  **compile_mode(
@@ -297,6 +365,7 @@ def build_tutorial_mode(
297
365
  ],
298
366
  passes=config.passes,
299
367
  thread_pool=thread_pool,
368
+ validate_only=validate_only,
300
369
  ),
301
370
  "skin": build_skin(skin),
302
371
  "effect": build_effects(effects),