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 +16 -0
- pygodide/asyncify.py +603 -0
- pygodide/builder/__init__.py +9 -0
- pygodide/builder/display_size.py +331 -0
- pygodide/builder/pipeline.py +182 -0
- pygodide/builder/plan.py +476 -0
- pygodide/builder/zip.py +123 -0
- pygodide/cli/__init__.py +0 -0
- pygodide/cli/main.py +375 -0
- pygodide/cli/runners.py +266 -0
- pygodide/deps.py +259 -0
- pygodide/logs.py +251 -0
- pygodide/project_config.py +195 -0
- pygodide/rendering.py +302 -0
- pygodide/serving.py +74 -0
- pygodide/smoke/__init__.py +45 -0
- pygodide/smoke/manifest.py +349 -0
- pygodide/smoke/playwright_smoke.py +236 -0
- pygodide/smoke/runner.py +257 -0
- pygodide/templates/boot.js +655 -0
- pygodide/templates/favicon.svg +110 -0
- pygodide/templates/index.html +548 -0
- pygodide/templates/pygodide-logo.svg +143 -0
- pygodide/templates/startup.py.j2 +103 -0
- pygodide-0.1.0.dist-info/METADATA +176 -0
- pygodide-0.1.0.dist-info/RECORD +29 -0
- pygodide-0.1.0.dist-info/WHEEL +4 -0
- pygodide-0.1.0.dist-info/entry_points.txt +2 -0
- pygodide-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|