Skip to content

Commit 239ddbd

Browse files
committed
Reconfigure CFFI porting scripts for SDL3, add _libtcod.pyi
Many SDL functions were renamed and I'd have to port them blind without this new `_libtcod.py` file. Current solution is crude and incomplete.
1 parent ba3bb24 commit 239ddbd

File tree

12 files changed

+10203
-342
lines changed

12 files changed

+10203
-342
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@ This project adheres to [Semantic Versioning](https://semver.org/) since version
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- Switched to SDL3.
12+
This will cause several breaking changes such as the names of keyboard constants and other SDL enums.
13+
914
### Removed
1015

1116
- Support dropped for Python 3.8 and 3.9.
17+
- Removed `Joystick.get_current_power` due to SDL3 changes.
18+
- `WindowFlags.FULLSCREEN_DESKTOP` is now just `WindowFlags.FULLSCREEN`
19+
20+
### Fixed
21+
22+
- `Joystick.get_ball` was broken.
1223

1324
## [18.1.0] - 2025-05-05
1425

build_libtcod.py

Lines changed: 223 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from pathlib import Path
1414
from typing import Any, ClassVar
1515

16+
import attrs
17+
import pycparser # type: ignore[import-untyped]
18+
import pycparser.c_ast # type: ignore[import-untyped]
19+
import pycparser.c_generator # type: ignore[import-untyped]
1620
from cffi import FFI
1721

1822
# ruff: noqa: T201
@@ -204,7 +208,8 @@ def walk_sources(directory: str) -> Iterator[str]:
204208
extra_link_args.extend(GCC_CFLAGS[tdl_build])
205209

206210
ffi = FFI()
207-
ffi.cdef(build_sdl.get_cdef())
211+
sdl_cdef = build_sdl.get_cdef()
212+
ffi.cdef(sdl_cdef)
208213
for include in includes:
209214
try:
210215
ffi.cdef(include.header)
@@ -383,10 +388,10 @@ def write_library_constants() -> None:
383388
f.write(f"""{parse_sdl_attrs("SDL_SCANCODE", None)[0]}\n""")
384389

385390
f.write("\n# --- SDL keyboard symbols ---\n")
386-
f.write(f"""{parse_sdl_attrs("SDLK", None)[0]}\n""")
391+
f.write(f"""{parse_sdl_attrs("SDLK_", None)[0]}\n""")
387392

388393
f.write("\n# --- SDL keyboard modifiers ---\n")
389-
f.write("{}\n_REVERSE_MOD_TABLE = {}\n".format(*parse_sdl_attrs("KMOD", None)))
394+
f.write("{}\n_REVERSE_MOD_TABLE = {}\n".format(*parse_sdl_attrs("SDL_KMOD", None)))
390395

391396
f.write("\n# --- SDL wheel ---\n")
392397
f.write("{}\n_REVERSE_WHEEL_TABLE = {}\n".format(*parse_sdl_attrs("SDL_MOUSEWHEEL", all_names)))
@@ -411,5 +416,220 @@ def write_library_constants() -> None:
411416
Path("tcod/event.py").write_text(event_py, encoding="utf-8")
412417

413418

419+
def _fix_reserved_name(name: str) -> str:
420+
"""Add underscores to reserved Python keywords."""
421+
assert isinstance(name, str)
422+
if name in ("def", "in"):
423+
return name + "_"
424+
return name
425+
426+
427+
@attrs.define(frozen=True)
428+
class ConvertedParam:
429+
name: str = attrs.field(converter=_fix_reserved_name)
430+
hint: str
431+
original: str
432+
433+
434+
def _type_from_names(names: list[str]) -> str:
435+
if not names:
436+
return ""
437+
if names[-1] == "void":
438+
return "None"
439+
if names in (["unsigned", "char"], ["bool"]):
440+
return "bool"
441+
if names[-1] in ("size_t", "int", "ptrdiff_t"):
442+
return "int"
443+
return "Any"
444+
445+
446+
def _param_as_hint(node: pycparser.c_ast.Node, default_name: str) -> ConvertedParam:
447+
original = pycparser.c_generator.CGenerator().visit(node)
448+
name: str
449+
names: list[str]
450+
match node:
451+
case pycparser.c_ast.Typename(type=pycparser.c_ast.TypeDecl(type=pycparser.c_ast.IdentifierType(names=names))):
452+
# Unnamed type
453+
return ConvertedParam(default_name, _type_from_names(names), original)
454+
case pycparser.c_ast.Decl(
455+
name=name, type=pycparser.c_ast.TypeDecl(type=pycparser.c_ast.IdentifierType(names=names))
456+
):
457+
# Named type
458+
return ConvertedParam(name, _type_from_names(names), original)
459+
case pycparser.c_ast.Decl(
460+
name=name,
461+
type=pycparser.c_ast.ArrayDecl(
462+
type=pycparser.c_ast.TypeDecl(type=pycparser.c_ast.IdentifierType(names=names))
463+
),
464+
):
465+
# Named array
466+
return ConvertedParam(name, "Any", original)
467+
case pycparser.c_ast.Decl(name=name, type=pycparser.c_ast.PtrDecl()):
468+
# Named pointer
469+
return ConvertedParam(name, "Any", original)
470+
case pycparser.c_ast.Typename(name=name, type=pycparser.c_ast.PtrDecl()):
471+
# Forwarded struct
472+
return ConvertedParam(name or default_name, "Any", original)
473+
case pycparser.c_ast.TypeDecl(type=pycparser.c_ast.IdentifierType(names=names)):
474+
# Return type
475+
return ConvertedParam(default_name, _type_from_names(names), original)
476+
case pycparser.c_ast.PtrDecl():
477+
# Return pointer
478+
return ConvertedParam(default_name, "Any", original)
479+
case pycparser.c_ast.EllipsisParam():
480+
# C variable args
481+
return ConvertedParam("*__args", "Any", original)
482+
case _:
483+
raise AssertionError
484+
485+
486+
class DefinitionCollector(pycparser.c_ast.NodeVisitor): # type: ignore[misc]
487+
"""Gathers functions and names from C headers."""
488+
489+
def __init__(self) -> None:
490+
"""Initialize the object with empty values."""
491+
self.functions: list[str] = []
492+
"""Indented Python function definitions."""
493+
self.variables: set[str] = set()
494+
"""Python variable definitions."""
495+
496+
def parse_defines(self, string: str, /) -> None:
497+
"""Parse C define directives into hinted names."""
498+
for match in re.finditer(r"#define\s+(\S+)\s+(\S+)\s*", string):
499+
name, value = match.groups()
500+
if value == "...":
501+
self.variables.add(f"{name}: Final[int]")
502+
else:
503+
self.variables.add(f"{name}: Final[Literal[{value}]] = {value}")
504+
505+
def visit_Decl(self, node: pycparser.c_ast.Decl) -> None: # noqa: N802
506+
"""Parse C FFI functions into type hinted Python functions."""
507+
match node:
508+
case pycparser.c_ast.Decl(
509+
type=pycparser.c_ast.FuncDecl(),
510+
):
511+
assert isinstance(node.type.args, pycparser.c_ast.ParamList), type(node.type.args)
512+
arg_hints = [_param_as_hint(param, f"arg{i}") for i, param in enumerate(node.type.args.params)]
513+
return_hint = _param_as_hint(node.type.type, "")
514+
if len(arg_hints) == 1 and arg_hints[0].hint == "None": # Remove void parameter
515+
arg_hints = []
516+
517+
python_params = [f"{p.name}: {p.hint}" for p in arg_hints]
518+
if python_params:
519+
if arg_hints[-1].name.startswith("*"):
520+
python_params.insert(-1, "/")
521+
else:
522+
python_params.append("/")
523+
c_def = pycparser.c_generator.CGenerator().visit(node)
524+
python_def = f"""def {node.name}({", ".join(python_params)}) -> {return_hint.hint}:"""
525+
self.functions.append(f''' {python_def}\n """{c_def}"""''')
526+
527+
def visit_Enumerator(self, node: pycparser.c_ast.Enumerator) -> None: # noqa: N802
528+
"""Parse C enums into hinted names."""
529+
name: str | None
530+
value: str | int
531+
match node:
532+
case pycparser.c_ast.Enumerator(name=name, value=None):
533+
self.variables.add(f"{name}: Final[int]")
534+
case pycparser.c_ast.Enumerator(name=name, value=pycparser.c_ast.ID()):
535+
self.variables.add(f"{name}: Final[int]")
536+
case pycparser.c_ast.Enumerator(name=name, value=pycparser.c_ast.Constant(value=value)):
537+
value = int(str(value).removesuffix("u"), base=0)
538+
self.variables.add(f"{name}: Final[Literal[{value}]] = {value}")
539+
case pycparser.c_ast.Enumerator(
540+
name=name, value=pycparser.c_ast.UnaryOp(op="-", expr=pycparser.c_ast.Constant(value=value))
541+
):
542+
value = -int(str(value).removesuffix("u"), base=0)
543+
self.variables.add(f"{name}: Final[Literal[{value}]] = {value}")
544+
case pycparser.c_ast.Enumerator(name=name):
545+
self.variables.add(f"{name}: Final[int]")
546+
case _:
547+
raise AssertionError
548+
549+
550+
def write_hints() -> None:
551+
"""Write a custom _libtcod.pyi file from C definitions."""
552+
function_collector = DefinitionCollector()
553+
c = pycparser.CParser()
554+
555+
# Parse SDL headers
556+
cdef = sdl_cdef
557+
cdef = cdef.replace("int...", "int")
558+
cdef = (
559+
"""
560+
typedef int int8_t;
561+
typedef int uint8_t;
562+
typedef int int16_t;
563+
typedef int uint16_t;
564+
typedef int int32_t;
565+
typedef int uint32_t;
566+
typedef int int64_t;
567+
typedef int uint64_t;
568+
typedef int wchar_t;
569+
typedef int intptr_t;
570+
"""
571+
+ cdef
572+
)
573+
cdef = re.sub(r"(typedef enum SDL_PixelFormat).*(SDL_PixelFormat;)", r"\1 \2", cdef, flags=re.DOTALL)
574+
cdef = cdef.replace("padding[...]", "padding[]")
575+
cdef = cdef.replace("...;} SDL_TouchFingerEvent;", "} SDL_TouchFingerEvent;")
576+
function_collector.parse_defines(cdef)
577+
cdef = re.sub(r"\n#define .*", "", cdef)
578+
cdef = re.sub(r"""extern "Python" \{(.*?)\}""", r"\1", cdef, flags=re.DOTALL)
579+
cdef = re.sub(r"//.*", "", cdef)
580+
ast = c.parse(cdef)
581+
function_collector.visit(ast)
582+
583+
# Parse libtcod headers
584+
cdef = "\n".join(include.header for include in includes)
585+
function_collector.parse_defines(cdef)
586+
cdef = re.sub(r"\n?#define .*", "", cdef)
587+
cdef = re.sub(r"//.*", "", cdef)
588+
cdef = (
589+
"""
590+
typedef int int8_t;
591+
typedef int uint8_t;
592+
typedef int int16_t;
593+
typedef int uint16_t;
594+
typedef int int32_t;
595+
typedef int uint32_t;
596+
typedef int int64_t;
597+
typedef int uint64_t;
598+
typedef int wchar_t;
599+
typedef int intptr_t;
600+
typedef int ptrdiff_t;
601+
typedef int size_t;
602+
typedef unsigned char bool;
603+
typedef void* SDL_PropertiesID;
604+
"""
605+
+ cdef
606+
)
607+
cdef = re.sub(r"""extern "Python" \{(.*?)\}""", r"\1", cdef, flags=re.DOTALL)
608+
function_collector.visit(c.parse(cdef))
609+
610+
# Write PYI file
611+
out_functions = """\n\n @staticmethod\n""".join(sorted(function_collector.functions))
612+
out_variables = "\n ".join(sorted(function_collector.variables))
613+
614+
pyi = f"""\
615+
# Autogenerated with build_libtcod.py
616+
from typing import Any, Final, Literal
617+
618+
# pyi files for CFFI ports are not standard
619+
# ruff: noqa: A002, ANN401, D402, D403, D415, N801, N802, N803, N815, PLW0211, PYI021
620+
621+
class _lib:
622+
@staticmethod
623+
{out_functions}
624+
625+
{out_variables}
626+
627+
lib: _lib
628+
ffi: Any
629+
"""
630+
Path("tcod/_libtcod.pyi").write_text(pyi)
631+
632+
414633
if __name__ == "__main__":
634+
write_hints()
415635
write_library_constants()

build_sdl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def _should_track_define(self, tokens: list[Any]) -> bool:
188188
return False # Data type for printing, which is not needed.
189189
if tokens[0].value.startswith("SDL_PLATFORM_"):
190190
return False # Ignore platform definitions
191-
return bool(tokens[0].value.startswith("SDL_"))
191+
return bool(str(tokens[0].value).startswith(("SDL_", "SDLK_")))
192192

193193
def on_directive_handle(
194194
self,

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ requires = [
1010
"pycparser>=2.14",
1111
"pcpp==1.30",
1212
"requests>=2.28.1",
13+
"attrs",
1314
]
1415
build-backend = "setuptools.build_meta"
1516

0 commit comments

Comments
 (0)