1313from pathlib import Path
1414from 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]
1620from 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
206210ffi = FFI ()
207- ffi .cdef (build_sdl .get_cdef ())
211+ sdl_cdef = build_sdl .get_cdef ()
212+ ffi .cdef (sdl_cdef )
208213for 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+
414633if __name__ == "__main__" :
634+ write_hints ()
415635 write_library_constants ()
0 commit comments