pygodide 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.
pygodide/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Bundle Pygame apps for the browser with Pyodide."""
2
+
3
+ from importlib import metadata
4
+
5
+ from pygodide.builder import BuildPlan, build_app
6
+
7
+ try:
8
+ __version__ = metadata.version("pygodide")
9
+ except metadata.PackageNotFoundError:
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = [
13
+ "BuildPlan",
14
+ "build_app",
15
+ "__version__",
16
+ ]
pygodide/asyncify.py ADDED
@@ -0,0 +1,603 @@
1
+ """Automatic conversion of simple sync Pygame loops to async-friendly form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pygodide.builder.plan import BuildPlan
11
+ from pygodide.project_config import load_pygodide_project_config
12
+
13
+ AsyncifyStatus = Literal["already-async", "changed", "skipped"]
14
+ DiagnosticStatus = Literal["already-async", "would-change", "would-skip", "disabled"]
15
+ # Pyodide SDL guidance uses await asyncio.sleep(1 / fps) so the browser can
16
+ # paint. sleep(0) yields the event loop but often starves paints (esp. Firefox).
17
+ # Sleep half a frame budget (1 / (fps * 2)) so frame work + yield can still hit
18
+ # the desired FPS instead of always landing below it.
19
+ DEFAULT_TARGET_FPS = 60
20
+ FRAME_YIELD_HINT = f"await asyncio.sleep(1 / ({DEFAULT_TARGET_FPS} * 2))"
21
+ MANUAL_ASYNC_GUIDANCE = (
22
+ f"Make the entrypoint async and add {FRAME_YIELD_HINT} once per frame "
23
+ "(see https://pyodide.org/en/stable/usage/sdl.html)."
24
+ )
25
+ PYGAME_FRAME_SIGNALS = frozenset(
26
+ {
27
+ "pygame.event.get",
28
+ "pygame.event.pump",
29
+ "pygame.event.wait",
30
+ "pygame.display.update",
31
+ "pygame.display.flip",
32
+ }
33
+ )
34
+ BLOCKING_CALLS = frozenset(
35
+ {
36
+ "time.sleep",
37
+ "pygame.time.wait",
38
+ "pygame.time.delay",
39
+ }
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AsyncifyResult:
45
+ changed: bool
46
+ status: AsyncifyStatus
47
+ message: str
48
+ relative_path: str | None = None
49
+ warnings: tuple[str, ...] = ()
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class AsyncifyDiagnostic:
54
+ status: DiagnosticStatus
55
+ message: str
56
+ warnings: tuple[str, ...] = ()
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TransformTarget:
61
+ function: ast.FunctionDef
62
+ game_loop: ast.While | None = None
63
+ await_calls_to: frozenset[str] = frozenset()
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class EntrypointAnalysis:
68
+ relative_path: str
69
+ module: ast.Module | None = None
70
+ transform_targets: list[TransformTarget] | None = None
71
+ already_async: bool = False
72
+ skip_message: str | None = None
73
+ warnings: tuple[str, ...] = ()
74
+
75
+
76
+ def resolve_auto_async(
77
+ source_dir: str | Path,
78
+ *,
79
+ cli_auto_async: bool | None = None,
80
+ manifest_auto_async: bool | None = None,
81
+ ) -> tuple[bool, str]:
82
+ if cli_auto_async is not None:
83
+ return (
84
+ cli_auto_async,
85
+ "CLI --auto-async" if cli_auto_async else "CLI --no-auto-async",
86
+ )
87
+
88
+ if manifest_auto_async is not None:
89
+ return manifest_auto_async, "testing_manifest.yaml build.auto-async"
90
+
91
+ project_config = load_pygodide_project_config(source_dir)
92
+ if project_config is not None and project_config.auto_async is not None:
93
+ return project_config.auto_async, "[tool.pygodide].auto-async"
94
+
95
+ return True, "default"
96
+
97
+
98
+ def dotted_name(node: ast.AST) -> str | None:
99
+ if isinstance(node, ast.Name):
100
+ return node.id
101
+ if isinstance(node, ast.Attribute):
102
+ parent_name = dotted_name(node.value)
103
+ if parent_name is None:
104
+ return node.attr
105
+ return f"{parent_name}.{node.attr}"
106
+ return None
107
+
108
+
109
+ def is_pygame_frame_signal(node: ast.AST) -> bool:
110
+ if not isinstance(node, ast.Call):
111
+ return False
112
+
113
+ call_name = dotted_name(node.func)
114
+ if call_name in PYGAME_FRAME_SIGNALS:
115
+ return True
116
+ return call_name is not None and call_name.endswith(".tick")
117
+
118
+
119
+ def loop_has_pygame_frame_signal(loop: ast.While) -> bool:
120
+ return any(
121
+ is_pygame_frame_signal(node)
122
+ for statement in loop.body
123
+ for node in ast.walk(statement)
124
+ )
125
+
126
+
127
+ def iter_game_loops(
128
+ function: ast.AsyncFunctionDef | ast.FunctionDef,
129
+ ) -> list[ast.While]:
130
+ return [
131
+ node
132
+ for node in ast.walk(function)
133
+ if isinstance(node, ast.While) and loop_has_pygame_frame_signal(node)
134
+ ]
135
+
136
+
137
+ def format_smoke_async_warnings(
138
+ diagnostic: AsyncifyDiagnostic,
139
+ *,
140
+ auto_async_enabled: bool,
141
+ ) -> list[str]:
142
+ warnings: list[str] = []
143
+
144
+ if not auto_async_enabled:
145
+ if diagnostic.status == "would-change":
146
+ warnings.append(
147
+ "Smoke warning: auto-async is disabled but the entrypoint looks "
148
+ "like a synchronous Pygame loop. Rebuild with auto-async enabled "
149
+ "or asyncify the entrypoint manually."
150
+ )
151
+ warnings.extend(diagnostic.warnings)
152
+ return warnings
153
+
154
+ if diagnostic.status == "would-skip":
155
+ warnings.append(
156
+ "Smoke warning: auto-async cannot safely transform this entrypoint. "
157
+ f"{diagnostic.message} {MANUAL_ASYNC_GUIDANCE}"
158
+ )
159
+
160
+ warnings.extend(diagnostic.warnings)
161
+ return warnings
162
+
163
+
164
+ def module_import_warnings(module: ast.Module) -> tuple[str, ...]:
165
+ warnings: list[str] = []
166
+ for statement in module.body:
167
+ if not isinstance(statement, ast.Expr):
168
+ continue
169
+ if not isinstance(statement.value, ast.Call):
170
+ continue
171
+ call_name = dotted_name(statement.value.func)
172
+ if call_name == "asyncio.run":
173
+ warnings.append(
174
+ "Pygodide warning: module-level asyncio.run() executes during "
175
+ "import in the browser and breaks startup. Move it under "
176
+ 'if __name__ == "__main__": for local runs only.'
177
+ )
178
+ return tuple(warnings)
179
+
180
+
181
+ def merge_warnings(*warning_groups: tuple[str, ...]) -> tuple[str, ...]:
182
+ merged: list[str] = []
183
+ for warning_group in warning_groups:
184
+ for warning in warning_group:
185
+ if warning not in merged:
186
+ merged.append(warning)
187
+ return tuple(merged)
188
+
189
+
190
+ def blocking_call_warnings(
191
+ module: ast.Module, function: ast.AsyncFunctionDef | ast.FunctionDef
192
+ ) -> tuple[str, ...]:
193
+ del module # reserved for future cross-module analysis
194
+ warnings: list[str] = []
195
+ for loop in iter_game_loops(function):
196
+ for node in ast.walk(loop):
197
+ if not isinstance(node, ast.Call):
198
+ continue
199
+ call_name = dotted_name(node.func)
200
+ if call_name in BLOCKING_CALLS:
201
+ warnings.append(
202
+ "Auto async warning: game loop calls "
203
+ f"{call_name}(), which can block the browser event loop."
204
+ )
205
+ return tuple(dict.fromkeys(warnings))
206
+
207
+
208
+ def blocking_call_warnings_for_targets(
209
+ module: ast.Module, transform_targets: list[TransformTarget] | None
210
+ ) -> tuple[str, ...]:
211
+ del module
212
+ if transform_targets is None:
213
+ return ()
214
+
215
+ warnings: list[str] = []
216
+ for transform_target in transform_targets:
217
+ if transform_target.game_loop is None:
218
+ continue
219
+ for node in ast.walk(transform_target.game_loop):
220
+ if not isinstance(node, ast.Call):
221
+ continue
222
+ call_name = dotted_name(node.func)
223
+ if call_name in BLOCKING_CALLS:
224
+ warnings.append(
225
+ "Auto async warning: game loop calls "
226
+ f"{call_name}(), which can block the browser event loop."
227
+ )
228
+ return tuple(dict.fromkeys(warnings))
229
+
230
+
231
+ def diagnose_entrypoint(
232
+ build_plan: BuildPlan, source_dir: str | Path
233
+ ) -> AsyncifyDiagnostic:
234
+ resolved_source_dir = Path(source_dir).resolve()
235
+ entrypoint_path = resolved_source_dir / entrypoint_relative_path(build_plan)
236
+ analysis = analyze_entrypoint(build_plan, entrypoint_path)
237
+
238
+ if analysis.already_async:
239
+ return AsyncifyDiagnostic(
240
+ status="already-async",
241
+ message=analysis.skip_message or "",
242
+ warnings=analysis.warnings,
243
+ )
244
+
245
+ if analysis.skip_message is not None:
246
+ return AsyncifyDiagnostic(
247
+ status="would-skip",
248
+ message=analysis.skip_message,
249
+ warnings=analysis.warnings,
250
+ )
251
+
252
+ return AsyncifyDiagnostic(
253
+ status="would-change",
254
+ message=(
255
+ f"Auto async would transform {analysis.relative_path}, "
256
+ f"inserting {FRAME_YIELD_HINT}"
257
+ ),
258
+ warnings=analysis.warnings,
259
+ )
260
+
261
+
262
+ def analyze_entrypoint(
263
+ build_plan: BuildPlan, entrypoint_path: Path
264
+ ) -> EntrypointAnalysis:
265
+ relative_path = entrypoint_relative_path(build_plan)
266
+
267
+ if not entrypoint_path.is_file():
268
+ return EntrypointAnalysis(
269
+ relative_path=relative_path,
270
+ skip_message=(
271
+ f"Auto async: skipped {relative_path}, entrypoint module file "
272
+ f"was not found. {MANUAL_ASYNC_GUIDANCE}"
273
+ ),
274
+ )
275
+
276
+ if relative_path not in build_plan.package_files:
277
+ return EntrypointAnalysis(
278
+ relative_path=relative_path,
279
+ skip_message=(
280
+ f"Auto async: skipped {relative_path}, entrypoint module file "
281
+ f"is not included in the build package "
282
+ f"(check [tool.pygodide].include if set). {MANUAL_ASYNC_GUIDANCE}"
283
+ ),
284
+ )
285
+
286
+ source = entrypoint_path.read_text(encoding="utf-8")
287
+ try:
288
+ module = ast.parse(source, filename=str(entrypoint_path))
289
+ except SyntaxError as exc:
290
+ return EntrypointAnalysis(
291
+ relative_path=relative_path,
292
+ skip_message=(
293
+ f"Auto async: skipped {relative_path}, could not parse Python "
294
+ f"source: {exc}. {MANUAL_ASYNC_GUIDANCE}"
295
+ ),
296
+ )
297
+
298
+ import_warnings = module_import_warnings(module)
299
+
300
+ target_function = find_function_by_name(module, build_plan.entry_function)
301
+ if target_function is None:
302
+ return EntrypointAnalysis(
303
+ relative_path=relative_path,
304
+ module=module,
305
+ skip_message=(
306
+ f"Auto async: skipped {relative_path}, entrypoint function "
307
+ f"{build_plan.entry_function!r} was not found. {MANUAL_ASYNC_GUIDANCE}"
308
+ ),
309
+ warnings=import_warnings,
310
+ )
311
+
312
+ if isinstance(target_function, ast.AsyncFunctionDef):
313
+ warnings = merge_warnings(
314
+ import_warnings,
315
+ blocking_call_warnings(module, target_function),
316
+ )
317
+ return EntrypointAnalysis(
318
+ relative_path=relative_path,
319
+ module=module,
320
+ already_async=True,
321
+ skip_message=(
322
+ "Auto async: already async "
323
+ f"({build_plan.entry_module}:{build_plan.entry_function})"
324
+ ),
325
+ warnings=warnings,
326
+ )
327
+
328
+ transform_targets, skip_message = resolve_transform_targets(
329
+ module,
330
+ target_function,
331
+ relative_path=relative_path,
332
+ )
333
+ warnings = merge_warnings(
334
+ import_warnings,
335
+ blocking_call_warnings_for_targets(module, transform_targets),
336
+ )
337
+
338
+ if transform_targets is None:
339
+ return EntrypointAnalysis(
340
+ relative_path=relative_path,
341
+ module=module,
342
+ skip_message=skip_message,
343
+ warnings=warnings,
344
+ )
345
+
346
+ return EntrypointAnalysis(
347
+ relative_path=relative_path,
348
+ module=module,
349
+ transform_targets=transform_targets,
350
+ warnings=warnings,
351
+ )
352
+
353
+
354
+ def entrypoint_relative_path(build_plan: BuildPlan) -> str:
355
+ return f"{build_plan.entry_module.replace('.', '/')}.py"
356
+
357
+
358
+ def find_function_by_name(
359
+ module: ast.Module, function_name: str
360
+ ) -> ast.AsyncFunctionDef | ast.FunctionDef | None:
361
+ for statement in module.body:
362
+ if isinstance(statement, ast.AsyncFunctionDef | ast.FunctionDef):
363
+ if statement.name == function_name:
364
+ return statement
365
+ return None
366
+
367
+
368
+ def resolve_transform_targets(
369
+ module: ast.Module,
370
+ entrypoint: ast.FunctionDef,
371
+ *,
372
+ relative_path: str,
373
+ ) -> tuple[list[TransformTarget] | None, str | None]:
374
+ entrypoint_loop = find_candidate_game_loop(entrypoint)
375
+ if entrypoint_loop is not None:
376
+ return [TransformTarget(function=entrypoint, game_loop=entrypoint_loop)], None
377
+
378
+ helper_targets = find_helper_loop_targets(module, entrypoint)
379
+ if len(helper_targets) > 1:
380
+ helper_names = ", ".join(function.name for function, _ in helper_targets)
381
+ return None, (
382
+ f"Auto async: skipped {relative_path}, multiple helper game loops "
383
+ f"found ({helper_names}). {MANUAL_ASYNC_GUIDANCE}"
384
+ )
385
+
386
+ if len(helper_targets) == 1:
387
+ helper_function, helper_loop = helper_targets[0]
388
+ return [
389
+ TransformTarget(
390
+ function=entrypoint,
391
+ await_calls_to=frozenset({helper_function.name}),
392
+ ),
393
+ TransformTarget(function=helper_function, game_loop=helper_loop),
394
+ ], None
395
+
396
+ if direct_module_function_calls(module, entrypoint):
397
+ return None, (
398
+ f"Auto async: skipped {relative_path}, helper functions were called but "
399
+ f"none contain a recognizable Pygame loop. {MANUAL_ASYNC_GUIDANCE}"
400
+ )
401
+
402
+ return None, (
403
+ f"Auto async: skipped {relative_path}, no safe game loop found in the "
404
+ f"entrypoint. {MANUAL_ASYNC_GUIDANCE}"
405
+ )
406
+
407
+
408
+ def find_helper_loop_targets(
409
+ module: ast.Module, entrypoint: ast.FunctionDef
410
+ ) -> list[tuple[ast.FunctionDef, ast.While]]:
411
+ called_names = direct_module_function_calls(module, entrypoint)
412
+ helper_targets: list[tuple[ast.FunctionDef, ast.While]] = []
413
+
414
+ for function_name in called_names:
415
+ helper_function = find_function_by_name(module, function_name)
416
+ if helper_function is None or isinstance(helper_function, ast.AsyncFunctionDef):
417
+ continue
418
+
419
+ helper_loop = find_candidate_game_loop(helper_function)
420
+ if helper_loop is not None:
421
+ helper_targets.append((helper_function, helper_loop))
422
+
423
+ return helper_targets
424
+
425
+
426
+ def module_function_names(module: ast.Module) -> set[str]:
427
+ return {
428
+ statement.name
429
+ for statement in module.body
430
+ if isinstance(statement, ast.AsyncFunctionDef | ast.FunctionDef)
431
+ }
432
+
433
+
434
+ def direct_module_function_calls(
435
+ module: ast.Module, function: ast.FunctionDef
436
+ ) -> list[str]:
437
+ defined_names = module_function_names(module)
438
+ called_names: list[str] = []
439
+ for statement in function.body:
440
+ for node in ast.walk(statement):
441
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
442
+ if node.func.id in defined_names:
443
+ called_names.append(node.func.id)
444
+ return called_names
445
+
446
+
447
+ def find_candidate_game_loop(function: ast.FunctionDef) -> ast.While | None:
448
+ for node in ast.walk(function):
449
+ if isinstance(node, ast.While) and loop_has_pygame_frame_signal(node):
450
+ return node
451
+ return None
452
+
453
+
454
+ def asyncify_entrypoint(build_plan: BuildPlan, output_dir: Path) -> AsyncifyResult:
455
+ relative_path = entrypoint_relative_path(build_plan)
456
+ entrypoint_path = output_dir / relative_path
457
+ analysis = analyze_entrypoint(build_plan, entrypoint_path)
458
+
459
+ if analysis.already_async:
460
+ return AsyncifyResult(
461
+ changed=False,
462
+ status="already-async",
463
+ message=analysis.skip_message or "",
464
+ relative_path=relative_path,
465
+ warnings=analysis.warnings,
466
+ )
467
+
468
+ if analysis.skip_message is not None or analysis.module is None:
469
+ return AsyncifyResult(
470
+ changed=False,
471
+ status="skipped",
472
+ message=analysis.skip_message or "",
473
+ relative_path=relative_path,
474
+ warnings=analysis.warnings,
475
+ )
476
+
477
+ module = analysis.module
478
+ transform_targets = analysis.transform_targets
479
+ assert module is not None
480
+ assert transform_targets is not None
481
+
482
+ if not module_imports_asyncio(module):
483
+ module.body.insert(0, ast.Import(names=[ast.alias(name="asyncio")]))
484
+
485
+ for transform_target in transform_targets:
486
+ if transform_target.game_loop is not None and not loop_yields_to_asyncio(
487
+ transform_target.game_loop
488
+ ):
489
+ transform_target.game_loop.body.append(asyncio_frame_sleep_statement())
490
+ if transform_target.await_calls_to:
491
+ await_calls_to_functions(
492
+ transform_target.function,
493
+ transform_target.await_calls_to,
494
+ )
495
+ replace_function(module, transform_target.function)
496
+
497
+ ast.fix_missing_locations(module)
498
+ transformed_source = ast.unparse(module) + "\n"
499
+ entrypoint_path.write_text(transformed_source, encoding="utf-8")
500
+
501
+ return AsyncifyResult(
502
+ changed=True,
503
+ status="changed",
504
+ message=(
505
+ f"Auto async: transformed {relative_path}, inserted {FRAME_YIELD_HINT}"
506
+ ),
507
+ relative_path=relative_path,
508
+ warnings=analysis.warnings,
509
+ )
510
+
511
+
512
+ def module_imports_asyncio(module: ast.Module) -> bool:
513
+ return any(
514
+ isinstance(statement, ast.Import)
515
+ and any(alias.name == "asyncio" for alias in statement.names)
516
+ for statement in module.body
517
+ )
518
+
519
+
520
+ def loop_yields_to_asyncio(loop: ast.While) -> bool:
521
+ return any(
522
+ isinstance(node, ast.Await)
523
+ and isinstance(node.value, ast.Call)
524
+ and dotted_name(node.value.func) == "asyncio.sleep"
525
+ for statement in loop.body
526
+ for node in ast.walk(statement)
527
+ )
528
+
529
+
530
+ def asyncio_frame_sleep_statement() -> ast.Expr:
531
+ """Yield with half a frame budget so work + sleep can still hit target FPS."""
532
+ return ast.Expr(
533
+ value=ast.Await(
534
+ value=ast.Call(
535
+ func=ast.Attribute(
536
+ value=ast.Name(id="asyncio", ctx=ast.Load()),
537
+ attr="sleep",
538
+ ctx=ast.Load(),
539
+ ),
540
+ args=[
541
+ ast.BinOp(
542
+ left=ast.Constant(value=1),
543
+ op=ast.Div(),
544
+ right=ast.BinOp(
545
+ left=ast.Constant(value=DEFAULT_TARGET_FPS),
546
+ op=ast.Mult(),
547
+ right=ast.Constant(value=2),
548
+ ),
549
+ )
550
+ ],
551
+ keywords=[],
552
+ )
553
+ )
554
+ )
555
+
556
+
557
+ def await_calls_to_functions(
558
+ function: ast.FunctionDef, function_names: frozenset[str]
559
+ ) -> None:
560
+ transformer = AwaitCallsTransformer(function_names)
561
+ function.body = [transformer.visit(statement) for statement in function.body]
562
+
563
+
564
+ class AwaitCallsTransformer(ast.NodeTransformer):
565
+ def __init__(self, function_names: frozenset[str]) -> None:
566
+ self._function_names = function_names
567
+
568
+ def visit_Call(self, node: ast.Call) -> ast.AST:
569
+ node = self.generic_visit(node)
570
+ if isinstance(node.func, ast.Name) and node.func.id in self._function_names:
571
+ return ast.Await(value=node)
572
+ return node
573
+
574
+
575
+ def replace_function(module: ast.Module, function: ast.FunctionDef) -> None:
576
+ replacement_kwargs = {
577
+ "name": function.name,
578
+ "args": function.args,
579
+ "body": function.body,
580
+ "decorator_list": function.decorator_list,
581
+ "returns": function.returns,
582
+ "type_comment": function.type_comment,
583
+ }
584
+ if hasattr(function, "type_params"):
585
+ replacement_kwargs["type_params"] = function.type_params
586
+
587
+ replacement = ast.AsyncFunctionDef(**replacement_kwargs)
588
+
589
+ for index, statement in enumerate(module.body):
590
+ if statement is function:
591
+ module.body[index] = replacement
592
+ return
593
+
594
+
595
+ __all__ = [
596
+ "AsyncifyDiagnostic",
597
+ "AsyncifyResult",
598
+ "MANUAL_ASYNC_GUIDANCE",
599
+ "asyncify_entrypoint",
600
+ "diagnose_entrypoint",
601
+ "format_smoke_async_warnings",
602
+ "resolve_auto_async",
603
+ ]
@@ -0,0 +1,9 @@
1
+ """Build planning, pipeline orchestration, and packaging."""
2
+
3
+ from pygodide.builder.pipeline import build_app
4
+ from pygodide.builder.plan import BuildPlan
5
+
6
+ __all__ = [
7
+ "BuildPlan",
8
+ "build_app",
9
+ ]