sonolus.py 0.1.9__py3-none-any.whl → 0.2.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 (42) hide show
  1. sonolus/backend/optimize/constant_evaluation.py +2 -2
  2. sonolus/backend/optimize/optimize.py +12 -4
  3. sonolus/backend/optimize/passes.py +2 -1
  4. sonolus/backend/place.py +95 -14
  5. sonolus/backend/visitor.py +51 -3
  6. sonolus/build/cli.py +60 -9
  7. sonolus/build/collection.py +68 -26
  8. sonolus/build/compile.py +85 -27
  9. sonolus/build/engine.py +166 -40
  10. sonolus/build/node.py +8 -1
  11. sonolus/build/project.py +30 -11
  12. sonolus/script/archetype.py +110 -26
  13. sonolus/script/array.py +11 -0
  14. sonolus/script/debug.py +2 -2
  15. sonolus/script/effect.py +2 -2
  16. sonolus/script/engine.py +123 -15
  17. sonolus/script/internal/builtin_impls.py +21 -2
  18. sonolus/script/internal/constant.py +5 -1
  19. sonolus/script/internal/context.py +30 -25
  20. sonolus/script/internal/math_impls.py +2 -1
  21. sonolus/script/internal/transient.py +4 -0
  22. sonolus/script/internal/value.py +6 -0
  23. sonolus/script/interval.py +16 -0
  24. sonolus/script/iterator.py +17 -0
  25. sonolus/script/level.py +113 -10
  26. sonolus/script/metadata.py +32 -0
  27. sonolus/script/num.py +9 -0
  28. sonolus/script/options.py +5 -3
  29. sonolus/script/pointer.py +2 -0
  30. sonolus/script/project.py +41 -5
  31. sonolus/script/record.py +7 -2
  32. sonolus/script/runtime.py +61 -10
  33. sonolus/script/sprite.py +18 -1
  34. sonolus/script/ui.py +7 -3
  35. sonolus/script/values.py +8 -5
  36. sonolus/script/vec.py +28 -0
  37. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/METADATA +3 -2
  38. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/RECORD +42 -41
  39. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/WHEEL +1 -1
  40. /sonolus/script/{print.py → printing.py} +0 -0
  41. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/entry_points.txt +0 -0
  42. {sonolus_py-0.1.9.dist-info → sonolus_py-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -315,8 +315,8 @@ class SparseConditionalConstantPropagation(CompilerPass):
315
315
  return 1
316
316
  return functools.reduce(operator.pow, args)
317
317
  case Op.Log:
318
- assert len(args) == 2
319
- return math.log(args[0], args[1])
318
+ assert len(args) == 1
319
+ return math.log(args[0])
320
320
  case Op.Ceil:
321
321
  assert len(args) == 1
322
322
  return math.ceil(args[0])
@@ -12,13 +12,21 @@ from sonolus.backend.optimize.passes import run_passes
12
12
  from sonolus.backend.optimize.simplify import CoalesceFlow, NormalizeSwitch, RewriteToSwitch
13
13
  from sonolus.backend.optimize.ssa import FromSSA, ToSSA
14
14
 
15
- MINIMAL_PASSES = [
15
+ MINIMAL_PASSES = (
16
16
  CoalesceFlow(),
17
17
  UnreachableCodeElimination(),
18
18
  AllocateBasic(),
19
- ]
19
+ )
20
20
 
21
- STANDARD_PASSES = [
21
+ FAST_PASSES = (
22
+ CoalesceFlow(),
23
+ UnreachableCodeElimination(),
24
+ AdvancedDeadCodeElimination(),
25
+ CoalesceFlow(),
26
+ Allocate(),
27
+ )
28
+
29
+ STANDARD_PASSES = (
22
30
  CoalesceFlow(),
23
31
  UnreachableCodeElimination(),
24
32
  DeadCodeElimination(),
@@ -37,7 +45,7 @@ STANDARD_PASSES = [
37
45
  CoalesceFlow(),
38
46
  NormalizeSwitch(),
39
47
  Allocate(),
40
- ]
48
+ )
41
49
 
42
50
 
43
51
  def optimize_and_allocate(cfg: BasicBlock):
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from collections import deque
5
+ from collections.abc import Sequence
5
6
 
6
7
  from sonolus.backend.optimize.flow import BasicBlock
7
8
 
@@ -35,7 +36,7 @@ class CompilerPass(ABC):
35
36
  pass
36
37
 
37
38
 
38
- def run_passes(entry: BasicBlock, passes: list[CompilerPass]) -> BasicBlock:
39
+ def run_passes(entry: BasicBlock, passes: Sequence[CompilerPass]) -> BasicBlock:
39
40
  active_passes = set()
40
41
  queue = deque(passes)
41
42
  while queue:
sonolus/backend/place.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Iterator
2
- from typing import NamedTuple, Self
2
+ from typing import Self
3
3
 
4
4
  from sonolus.backend.blocks import Block
5
5
 
@@ -8,9 +8,13 @@ type BlockValue = Block | int | TempBlock | Place
8
8
  type IndexValue = int | Place
9
9
 
10
10
 
11
- class TempBlock(NamedTuple):
12
- name: str
13
- size: int = 1
11
+ class TempBlock:
12
+ __slots__ = ("__hash", "name", "size")
13
+
14
+ def __init__(self, name: str, size: int = 1):
15
+ self.name = name
16
+ self.size = size
17
+ self.__hash = hash(name) # Precompute hash based on name alone
14
18
 
15
19
  def __repr__(self):
16
20
  return f"TempBlock(name={self.name!r}, size={self.size!r})"
@@ -28,14 +32,38 @@ class TempBlock(NamedTuple):
28
32
  def __eq__(self, other):
29
33
  return isinstance(other, TempBlock) and self.name == other.name and self.size == other.size
30
34
 
35
+ def __lt__(self, other):
36
+ if not isinstance(other, TempBlock):
37
+ return NotImplemented
38
+ return str(self) < str(other)
39
+
40
+ def __le__(self, other):
41
+ if not isinstance(other, TempBlock):
42
+ return NotImplemented
43
+ return str(self) <= str(other)
44
+
45
+ def __gt__(self, other):
46
+ if not isinstance(other, TempBlock):
47
+ return NotImplemented
48
+ return str(self) > str(other)
49
+
50
+ def __ge__(self, other):
51
+ if not isinstance(other, TempBlock):
52
+ return NotImplemented
53
+ return str(self) >= str(other)
54
+
31
55
  def __hash__(self):
32
- return hash(self.name) # Typically will be unique by name alone
56
+ return self.__hash
33
57
 
34
58
 
35
- class BlockPlace(NamedTuple):
36
- block: BlockValue
37
- index: IndexValue = 0
38
- offset: int = 0
59
+ class BlockPlace:
60
+ __slots__ = ("__hash", "block", "index", "offset")
61
+
62
+ def __init__(self, block: BlockValue, index: IndexValue = 0, offset: int = 0):
63
+ self.block = block
64
+ self.index = index
65
+ self.offset = offset
66
+ self.__hash = hash((block, index, offset))
39
67
 
40
68
  def __repr__(self):
41
69
  return f"BlockPlace(block={self.block!r}, index={self.index!r}, offset={self.offset!r})"
@@ -61,13 +89,42 @@ class BlockPlace(NamedTuple):
61
89
  and self.offset == other.offset
62
90
  )
63
91
 
92
+ def __lt__(self, other):
93
+ if not isinstance(other, BlockPlace):
94
+ return NotImplemented
95
+ return str(self) < str(other)
96
+
97
+ def __le__(self, other):
98
+ if not isinstance(other, BlockPlace):
99
+ return NotImplemented
100
+ return str(self) <= str(other)
101
+
102
+ def __gt__(self, other):
103
+ if not isinstance(other, BlockPlace):
104
+ return NotImplemented
105
+ return str(self) > str(other)
106
+
107
+ def __ge__(self, other):
108
+ if not isinstance(other, BlockPlace):
109
+ return NotImplemented
110
+ return str(self) >= str(other)
111
+
64
112
  def __hash__(self):
65
- return hash((self.block, self.index, self.offset))
113
+ return self.__hash
114
+
115
+ def __iter__(self):
116
+ yield self.block
117
+ yield self.index
118
+ yield self.offset
66
119
 
67
120
 
68
- class SSAPlace(NamedTuple):
69
- name: str
70
- num: int
121
+ class SSAPlace:
122
+ __slots__ = ("__hash", "name", "num")
123
+
124
+ def __init__(self, name: str, num: int):
125
+ self.name = name
126
+ self.num = num
127
+ self.__hash = hash((name, num))
71
128
 
72
129
  def __repr__(self):
73
130
  return f"SSAPlace(name={self.name!r}, num={self.num!r})"
@@ -78,5 +135,29 @@ class SSAPlace(NamedTuple):
78
135
  def __eq__(self, other):
79
136
  return isinstance(other, SSAPlace) and self.name == other.name and self.num == other.num
80
137
 
138
+ def __lt__(self, other):
139
+ if not isinstance(other, SSAPlace):
140
+ return NotImplemented
141
+ return str(self) < str(other)
142
+
143
+ def __le__(self, other):
144
+ if not isinstance(other, SSAPlace):
145
+ return NotImplemented
146
+ return str(self) <= str(other)
147
+
148
+ def __gt__(self, other):
149
+ if not isinstance(other, SSAPlace):
150
+ return NotImplemented
151
+ return str(self) > str(other)
152
+
153
+ def __ge__(self, other):
154
+ if not isinstance(other, SSAPlace):
155
+ return NotImplemented
156
+ return str(self) >= str(other)
157
+
81
158
  def __hash__(self):
82
- return hash((self.name, self.num))
159
+ return self.__hash
160
+
161
+ def __iter__(self):
162
+ yield self.name
163
+ yield self.num
@@ -172,7 +172,9 @@ class Visitor(ast.NodeVisitor):
172
172
  bound_args: inspect.BoundArguments
173
173
  used_names: dict[str, int]
174
174
  return_ctxs: list[Context] # Contexts at return statements, which will branch to the exit
175
- loop_head_ctxs: list[Context] # Contexts at loop heads, from outer to inner
175
+ loop_head_ctxs: list[
176
+ Context | list[Context]
177
+ ] # Contexts at loop heads, from outer to inner. Contains a list for unrolled (tuple) loops
176
178
  break_ctxs: list[list[Context]] # Contexts at break statements, from outer to inner
177
179
  active_ctx: Context | None # The active context for use in nested functions=
178
180
  parent: Self | None # The parent visitor for use in nested functions
@@ -203,6 +205,8 @@ class Visitor(ast.NodeVisitor):
203
205
  case ast.FunctionDef(body=body):
204
206
  ctx().scope.set_value("$return", validate_value(None))
205
207
  for stmt in body:
208
+ if not ctx().live:
209
+ break
206
210
  self.visit(stmt)
207
211
  case ast.Lambda(body=body):
208
212
  result = self.visit(body)
@@ -318,11 +322,21 @@ class Visitor(ast.NodeVisitor):
318
322
  iterable = self.visit(node.iter)
319
323
  if isinstance(iterable, TupleImpl):
320
324
  # Unroll the loop
325
+ break_ctxs = []
321
326
  for value in iterable.value:
322
327
  set_ctx(ctx().branch(None))
328
+ self.loop_head_ctxs.append([])
329
+ self.break_ctxs.append([])
323
330
  self.handle_assign(node.target, validate_value(value))
324
331
  for stmt in node.body:
332
+ if not ctx().live:
333
+ break
325
334
  self.visit(stmt)
335
+ continue_ctxs = [*self.loop_head_ctxs.pop(), ctx()]
336
+ break_ctxs.extend(self.break_ctxs.pop())
337
+ set_ctx(Context.meet(continue_ctxs))
338
+ if break_ctxs:
339
+ set_ctx(Context.meet([*break_ctxs, ctx()]))
326
340
  return
327
341
  iterator = self.handle_call(node, iterable.__iter__)
328
342
  if not isinstance(iterator, SonolusIterator):
@@ -338,6 +352,8 @@ class Visitor(ast.NodeVisitor):
338
352
  self.loop_head_ctxs.pop()
339
353
  self.break_ctxs.pop()
340
354
  for stmt in node.orelse:
355
+ if not ctx().live:
356
+ break
341
357
  self.visit(stmt)
342
358
  return
343
359
  ctx().test = has_next.ir()
@@ -347,6 +363,8 @@ class Visitor(ast.NodeVisitor):
347
363
  set_ctx(body_ctx)
348
364
  self.handle_assign(node.target, self.handle_call(node, iterator.next))
349
365
  for stmt in node.body:
366
+ if not ctx().live:
367
+ break
350
368
  self.visit(stmt)
351
369
  ctx().branch_to_loop_header(header_ctx)
352
370
 
@@ -355,6 +373,8 @@ class Visitor(ast.NodeVisitor):
355
373
 
356
374
  set_ctx(else_ctx)
357
375
  for stmt in node.orelse:
376
+ if not ctx().live:
377
+ break
358
378
  self.visit(stmt)
359
379
  else_end_ctx = ctx()
360
380
 
@@ -374,6 +394,8 @@ class Visitor(ast.NodeVisitor):
374
394
  body_ctx = ctx().branch(None)
375
395
  set_ctx(body_ctx)
376
396
  for stmt in node.body:
397
+ if not ctx().live:
398
+ break
377
399
  self.visit(stmt)
378
400
  ctx().branch_to_loop_header(header_ctx)
379
401
 
@@ -390,6 +412,8 @@ class Visitor(ast.NodeVisitor):
390
412
  self.loop_head_ctxs.pop()
391
413
  self.break_ctxs.pop()
392
414
  for stmt in node.orelse:
415
+ if not ctx().live:
416
+ break
393
417
  self.visit(stmt)
394
418
  return
395
419
  ctx().test = test.ir()
@@ -398,6 +422,8 @@ class Visitor(ast.NodeVisitor):
398
422
 
399
423
  set_ctx(body_ctx)
400
424
  for stmt in node.body:
425
+ if not ctx().live:
426
+ break
401
427
  self.visit(stmt)
402
428
  ctx().branch_to_loop_header(header_ctx)
403
429
 
@@ -406,6 +432,8 @@ class Visitor(ast.NodeVisitor):
406
432
 
407
433
  set_ctx(else_ctx)
408
434
  for stmt in node.orelse:
435
+ if not ctx().live:
436
+ break
409
437
  self.visit(stmt)
410
438
  else_end_ctx = ctx()
411
439
 
@@ -418,9 +446,13 @@ class Visitor(ast.NodeVisitor):
418
446
  if test._is_py_():
419
447
  if test._as_py_():
420
448
  for stmt in node.body:
449
+ if not ctx().live:
450
+ break
421
451
  self.visit(stmt)
422
452
  else:
423
453
  for stmt in node.orelse:
454
+ if not ctx().live:
455
+ break
424
456
  self.visit(stmt)
425
457
  return
426
458
 
@@ -431,11 +463,15 @@ class Visitor(ast.NodeVisitor):
431
463
 
432
464
  set_ctx(true_ctx)
433
465
  for stmt in node.body:
466
+ if not ctx().live:
467
+ break
434
468
  self.visit(stmt)
435
469
  true_end_ctx = ctx()
436
470
 
437
471
  set_ctx(false_ctx)
438
472
  for stmt in node.orelse:
473
+ if not ctx().live:
474
+ break
439
475
  self.visit(stmt)
440
476
  false_end_ctx = ctx()
441
477
 
@@ -462,6 +498,8 @@ class Visitor(ast.NodeVisitor):
462
498
  if guard._is_py_():
463
499
  if guard._as_py_():
464
500
  for stmt in case.body:
501
+ if not ctx().live:
502
+ break
465
503
  self.visit(stmt)
466
504
  end_ctxs.append(ctx())
467
505
  else:
@@ -473,6 +511,8 @@ class Visitor(ast.NodeVisitor):
473
511
  guard_false_ctx = ctx().branch(0)
474
512
  set_ctx(guard_true_ctx)
475
513
  for stmt in case.body:
514
+ if not ctx().live:
515
+ break
476
516
  self.visit(stmt)
477
517
  end_ctxs.append(ctx())
478
518
  false_ctx = Context.meet([false_ctx, guard_false_ctx])
@@ -633,7 +673,11 @@ class Visitor(ast.NodeVisitor):
633
673
  set_ctx(ctx().into_dead())
634
674
 
635
675
  def visit_Continue(self, node):
636
- ctx().branch_to_loop_header(self.loop_head_ctxs[-1])
676
+ loop_head = self.loop_head_ctxs[-1]
677
+ if isinstance(loop_head, list):
678
+ loop_head.append(ctx())
679
+ else:
680
+ ctx().branch_to_loop_header(loop_head)
637
681
  set_ctx(ctx().into_dead())
638
682
 
639
683
  def visit_BoolOp(self, node) -> Value:
@@ -982,7 +1026,11 @@ class Visitor(ast.NodeVisitor):
982
1026
  if isinstance(target, ConstantValue):
983
1027
  # Unwrap so we can access fields
984
1028
  target = target._as_py_()
985
- descriptor = type(target).__dict__.get(key)
1029
+ descriptor = None
1030
+ for cls in type.mro(type(target)):
1031
+ descriptor = cls.__dict__.get(key, None)
1032
+ if descriptor is not None:
1033
+ break
986
1034
  match descriptor:
987
1035
  case property(fget=getter):
988
1036
  return self.handle_call(node, getter, target)
sonolus/build/cli.py CHANGED
@@ -10,10 +10,11 @@ import sys
10
10
  from pathlib import Path
11
11
  from time import perf_counter
12
12
 
13
+ from sonolus.backend.optimize.optimize import FAST_PASSES, MINIMAL_PASSES, STANDARD_PASSES
13
14
  from sonolus.build.engine import package_engine
14
15
  from sonolus.build.level import package_level_data
15
16
  from sonolus.build.project import build_project_to_collection, get_project_schema
16
- from sonolus.script.project import Project
17
+ from sonolus.script.project import BuildConfig, Project
17
18
 
18
19
 
19
20
  def find_default_module() -> str | None:
@@ -63,29 +64,29 @@ def import_project(module_path: str) -> Project | None:
63
64
  return project
64
65
  except Exception as e:
65
66
  print(f"Error: Failed to import project: {e}")
66
- return None
67
+ raise e from None
67
68
 
68
69
 
69
- def build_project(project: Project, build_dir: Path):
70
+ def build_project(project: Project, build_dir: Path, config: BuildConfig):
70
71
  dist_dir = build_dir / "dist"
71
72
  levels_dir = dist_dir / "levels"
72
73
  shutil.rmtree(dist_dir, ignore_errors=True)
73
74
  dist_dir.mkdir(parents=True, exist_ok=True)
74
75
  levels_dir.mkdir(parents=True, exist_ok=True)
75
76
 
76
- package_engine(project.engine.data).write(dist_dir / "engine")
77
+ package_engine(project.engine.data, config).write(dist_dir / "engine")
77
78
 
78
79
  for level in project.levels:
79
80
  level_path = levels_dir / level.name
80
81
  level_path.write_bytes(package_level_data(level.data))
81
82
 
82
83
 
83
- def build_collection(project: Project, build_dir: Path):
84
+ def build_collection(project: Project, build_dir: Path, config: BuildConfig):
84
85
  site_dir = build_dir / "site"
85
86
  shutil.rmtree(site_dir, ignore_errors=True)
86
87
  site_dir.mkdir(parents=True, exist_ok=True)
87
88
 
88
- collection = build_project_to_collection(project)
89
+ collection = build_project_to_collection(project, config)
89
90
  collection.write(site_dir)
90
91
 
91
92
 
@@ -125,10 +126,55 @@ def run_server(base_dir: Path, port: int = 8000):
125
126
  httpd.shutdown()
126
127
 
127
128
 
129
+ def get_config(args: argparse.Namespace) -> BuildConfig:
130
+ if hasattr(args, "optimize_minimal") and args.optimize_minimal:
131
+ optimization_passes = MINIMAL_PASSES
132
+ elif hasattr(args, "optimize_fast") and args.optimize_fast:
133
+ optimization_passes = FAST_PASSES
134
+ elif hasattr(args, "optimize_standard") and args.optimize_standard:
135
+ optimization_passes = STANDARD_PASSES
136
+ else:
137
+ optimization_passes = FAST_PASSES if args.command == "dev" else STANDARD_PASSES
138
+
139
+ if any(hasattr(args, attr) and getattr(args, attr) for attr in ["play", "watch", "preview", "tutorial"]):
140
+ build_play = hasattr(args, "play") and args.play
141
+ build_watch = hasattr(args, "watch") and args.watch
142
+ build_preview = hasattr(args, "preview") and args.preview
143
+ build_tutorial = hasattr(args, "tutorial") and args.tutorial
144
+ else:
145
+ build_play = build_watch = build_preview = build_tutorial = True
146
+
147
+ return BuildConfig(
148
+ passes=optimization_passes,
149
+ build_play=build_play,
150
+ build_watch=build_watch,
151
+ build_preview=build_preview,
152
+ build_tutorial=build_tutorial,
153
+ )
154
+
155
+
128
156
  def main():
129
157
  parser = argparse.ArgumentParser(description="Sonolus project build and development tools")
130
158
  subparsers = parser.add_subparsers(dest="command", required=True)
131
159
 
160
+ def add_common_arguments(parser):
161
+ optimization_group = parser.add_mutually_exclusive_group()
162
+ optimization_group.add_argument(
163
+ "-o0", "--optimize-minimal", action="store_true", help="Use minimal optimization passes"
164
+ )
165
+ optimization_group.add_argument(
166
+ "-o1", "--optimize-fast", action="store_true", help="Use fast optimization passes"
167
+ )
168
+ optimization_group.add_argument(
169
+ "-o2", "--optimize-standard", action="store_true", help="Use standard optimization passes"
170
+ )
171
+
172
+ build_components = parser.add_argument_group("build components")
173
+ build_components.add_argument("--play", action="store_true", help="Build play component")
174
+ build_components.add_argument("--watch", action="store_true", help="Build watch component")
175
+ build_components.add_argument("--preview", action="store_true", help="Build preview component")
176
+ build_components.add_argument("--tutorial", action="store_true", help="Build tutorial component")
177
+
132
178
  build_parser = subparsers.add_parser("build")
133
179
  build_parser.add_argument(
134
180
  "module",
@@ -137,6 +183,7 @@ def main():
137
183
  help="Module path (e.g., 'module.name'). If omitted, will auto-detect if only one module exists.",
138
184
  )
139
185
  build_parser.add_argument("--build-dir", type=str, default="./build")
186
+ add_common_arguments(build_parser)
140
187
 
141
188
  dev_parser = subparsers.add_parser("dev")
142
189
  dev_parser.add_argument(
@@ -147,6 +194,7 @@ def main():
147
194
  )
148
195
  dev_parser.add_argument("--build-dir", type=str, default="./build")
149
196
  dev_parser.add_argument("--port", type=int, default=8000)
197
+ add_common_arguments(dev_parser)
150
198
 
151
199
  schema_parser = subparsers.add_parser("schema")
152
200
  schema_parser.add_argument(
@@ -161,7 +209,8 @@ def main():
161
209
  if not args.module:
162
210
  default_module = find_default_module()
163
211
  if default_module:
164
- print(f"Using auto-detected module: {default_module}")
212
+ if args.command != "schema":
213
+ print(f"Using auto-detected module: {default_module}")
165
214
  args.module = default_module
166
215
  else:
167
216
  parser.error("Module argument is required when multiple or no modules are found")
@@ -173,13 +222,15 @@ def main():
173
222
  if args.command == "build":
174
223
  build_dir = Path(args.build_dir)
175
224
  start_time = perf_counter()
176
- build_project(project, build_dir)
225
+ config = get_config(args)
226
+ build_project(project, build_dir, config)
177
227
  end_time = perf_counter()
178
228
  print(f"Project built successfully to '{build_dir.resolve()}' in {end_time - start_time:.2f}s")
179
229
  elif args.command == "dev":
180
230
  build_dir = Path(args.build_dir)
181
231
  start_time = perf_counter()
182
- build_collection(project, build_dir)
232
+ config = get_config(args)
233
+ build_collection(project, build_dir, config)
183
234
  end_time = perf_counter()
184
235
  print(f"Build finished in {end_time - start_time:.2f}s")
185
236
  run_server(build_dir / "site", port=args.port)
@@ -36,7 +36,7 @@ SINGULAR_CATEGORY_NAMES: dict[Category, str] = {
36
36
  }
37
37
  BASE_PATH = "/sonolus/"
38
38
  RESERVED_FILENAMES = {"info", "list"}
39
- LOCALIZED_KEYS = {"title", "subtitle", "author", "description"}
39
+ LOCALIZED_KEYS = {"title", "subtitle", "author", "description", "artists"}
40
40
  CATEGORY_SORT_ORDER = {
41
41
  "levels": 0,
42
42
  "engines": 1,
@@ -69,10 +69,10 @@ class Collection:
69
69
  def add_item(self, category: Category, name: str, item: Any) -> None:
70
70
  self.categories.setdefault(category, {})[name] = self._make_item_details(item)
71
71
 
72
- @staticmethod
73
- def _make_item_details(item: dict[str, Any]) -> dict[str, Any]:
72
+ @classmethod
73
+ def _make_item_details(cls, item: dict[str, Any]) -> dict[str, Any]:
74
74
  return {
75
- "item": item,
75
+ "item": cls._localize_item(item),
76
76
  "actions": [],
77
77
  "hasCommunity": False,
78
78
  "leaderboards": [],
@@ -81,16 +81,7 @@ class Collection:
81
81
 
82
82
  @staticmethod
83
83
  def _load_data(value: Asset) -> bytes:
84
- match value:
85
- case str() if value.startswith(("http://", "https://")):
86
- with urllib.request.urlopen(value) as response:
87
- return response.read()
88
- case PathLike():
89
- return Path(value).read_bytes()
90
- case bytes():
91
- return value
92
- case _:
93
- raise TypeError("value must be a URL, a path, or bytes")
84
+ return load_asset(value)
94
85
 
95
86
  def add_asset(self, value: Asset, /) -> Srl:
96
87
  data = self._load_data(value)
@@ -123,7 +114,7 @@ class Collection:
123
114
  continue
124
115
 
125
116
  try:
126
- item_data = json.loads(item_json_path.read_text())
117
+ item_data = json.loads(item_json_path.read_text(encoding="utf-8"))
127
118
  except json.JSONDecodeError:
128
119
  continue
129
120
 
@@ -149,19 +140,32 @@ class Collection:
149
140
 
150
141
  self.add_item(category_name, item_dir.name, item_data)
151
142
 
152
- @staticmethod
153
- def _localize_item(item: dict[str, Any]) -> dict[str, Any]:
143
+ @classmethod
144
+ def _localize_item(cls, item: dict[str, Any]) -> dict[str, Any]:
154
145
  localized_item = item.copy()
155
146
  for key in LOCALIZED_KEYS:
156
- match localized_item.get(key):
157
- case {"en": localized_value}:
158
- localized_item[key] = localized_value
159
- case {**other_languages} if other_languages:
160
- localized_item[key] = localized_item[key][min(other_languages)]
161
- case _:
162
- localized_item[key] = ""
147
+ if key not in localized_item:
148
+ continue
149
+ localized_item[key] = cls._localize_text(localized_item[key])
150
+ if "tags" in localized_item:
151
+ localized_item["tags"] = [
152
+ {**tag, "title": cls._localize_text(tag["title"])} for tag in localized_item["tags"]
153
+ ]
154
+ localized_item.pop("meta", None)
163
155
  return localized_item
164
156
 
157
+ @staticmethod
158
+ def _localize_text(text: str | dict[str, str]) -> str:
159
+ match text:
160
+ case str():
161
+ return text
162
+ case {"en": localized_text}:
163
+ return localized_text
164
+ case {**other_languages} if other_languages:
165
+ return text[min(other_languages)]
166
+ case _:
167
+ return ""
168
+
165
169
  def _group_zip_entries_by_directory(self, file_list: list[zipfile.ZipInfo]) -> dict[str, list[zipfile.ZipInfo]]:
166
170
  files_by_dir: dict[str, list[zipfile.ZipInfo]] = {}
167
171
 
@@ -207,7 +211,7 @@ class Collection:
207
211
  ) -> None:
208
212
  for zip_entry in zip_entries:
209
213
  try:
210
- item_details = json.loads(zf.read(zip_entry))
214
+ item_details = json.loads(zf.read(zip_entry).decode("utf-8"))
211
215
  except json.JSONDecodeError:
212
216
  continue
213
217
 
@@ -220,11 +224,28 @@ class Collection:
220
224
  self.categories[dir_name][item_name] = item_details
221
225
 
222
226
  def write(self, path: Asset) -> None:
227
+ self.link()
223
228
  base_dir = self._create_base_directory(path)
224
229
  self._write_main_info(base_dir)
225
230
  self._write_category_items(base_dir)
226
231
  self._write_repository_items(base_dir)
227
232
 
233
+ def link(self):
234
+ for level_details in self.categories.get("levels", {}).values():
235
+ level = level_details["item"]
236
+ if isinstance(level["engine"], str):
237
+ level["engine"] = self.get_item("engines", level["engine"])
238
+ for key, category in (
239
+ ("useSkin", "skins"),
240
+ ("useBackground", "backgrounds"),
241
+ ("useEffect", "effects"),
242
+ ("useParticle", "particles"),
243
+ ):
244
+ use_item = level[key]
245
+ if "item" not in use_item:
246
+ continue
247
+ use_item["item"] = self.get_item(category, use_item["item"])
248
+
228
249
  def _create_base_directory(self, path: Asset) -> Path:
229
250
  base_dir = Path(path) / BASE_PATH.strip("/")
230
251
  base_dir.mkdir(parents=True, exist_ok=True)
@@ -280,7 +301,7 @@ class Collection:
280
301
 
281
302
  @staticmethod
282
303
  def _write_json(path: Path, content: Any) -> None:
283
- path.write_text(json.dumps(content))
304
+ path.write_text(json.dumps(content), encoding="utf-8")
284
305
 
285
306
  def update(self, other: Collection) -> None:
286
307
  self.repository.update(other.repository)
@@ -291,3 +312,24 @@ class Collection:
291
312
  class Srl(TypedDict):
292
313
  hash: str
293
314
  url: str
315
+
316
+
317
+ def load_asset(value: Asset) -> bytes:
318
+ match value:
319
+ case str() if value.startswith(("http://", "https://")):
320
+ headers = {
321
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
322
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
323
+ "Accept": "*/*",
324
+ "Accept-Language": "en-US,en;q=0.9",
325
+ "Connection": "keep-alive",
326
+ }
327
+ request = urllib.request.Request(value, headers=headers)
328
+ with urllib.request.urlopen(request) as response:
329
+ return response.read()
330
+ case PathLike():
331
+ return Path(value).read_bytes()
332
+ case bytes():
333
+ return value
334
+ case _:
335
+ raise TypeError("value must be a URL, a path, or bytes")