Skip to content

Add a helper for ctypes function annotations #18534

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
541bfb3
Add a helper for ctypes function annotations
LeonarddeR Jul 21, 2025
d04874c
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Jul 22, 2025
79399a2
Add tests
LeonarddeR Jul 22, 2025
88ce14f
Add more tests and errcheck support
LeonarddeR Jul 22, 2025
3d0d8fc
Apply ctypes helper to Magnification API in screen curtain
LeonarddeR Jul 22, 2025
c53eab3
Several fixes
LeonarddeR Jul 22, 2025
5c70bd9
Allow getting the func name from the placeholder python func
LeonarddeR Jul 22, 2025
df66b92
Update tests/unit/test_util/test_ctypesUtils.py
LeonarddeR Jul 23, 2025
1cd366a
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Jul 23, 2025
12267df
Additional checks
LeonarddeR Jul 23, 2025
96fe65a
Provide our own pointer annotations
LeonarddeR Jul 23, 2025
d79962e
Register more pointer types
LeonarddeR Jul 23, 2025
94abec7
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Jul 25, 2025
52fda43
Improve documentation in doc string
LeonarddeR Jul 25, 2025
e2ebbe3
Enhance outparam
LeonarddeR Jul 25, 2025
2497409
Finish documentation
LeonarddeR Jul 25, 2025
76a5f05
Typo and test fixes
LeonarddeR Jul 25, 2025
fafe5fd
Apply suggestions from code review
LeonarddeR Jul 31, 2025
c8f6570
Apply suggestions from code review
LeonarddeR Jul 31, 2025
85d9746
Use typing.Annotated and typing.Annotated directly, clarify IN_OUT
LeonarddeR Jul 31, 2025
3f84458
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Jul 31, 2025
3ec34a4
Additional docs, add windowsErrCheck
LeonarddeR Jul 31, 2025
80fb559
Improve decorator type hints
LeonarddeR Aug 1, 2025
51b6395
annotateOriginalCFunc=False by default
LeonarddeR Aug 1, 2025
5d56bcf
Support None as restype because it is valid in ctypes, similar to c void
LeonarddeR Aug 1, 2025
e64f5b3
Hopefully satisfy type checkers for byref
LeonarddeR Aug 1, 2025
a947ad8
Docmentation
LeonarddeR Aug 1, 2025
9f2ee6c
Improved doc string formatting
LeonarddeR Aug 1, 2025
c2909ee
Improve types
LeonarddeR Aug 1, 2025
961b6e1
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Aug 6, 2025
52f291b
Some winBindings example conversions
LeonarddeR Aug 6, 2025
5746f59
Undo submodule change
LeonarddeR Aug 6, 2025
6f31213
Merge remote-tracking branch 'origin/master' into winBindings
LeonarddeR Aug 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions projectDocs/dev/codingStandards.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,72 @@ Type hints make reasoning about code much easier, and allow static analysis tool
* Prefer union shorthand (`X | Y`) over explicitly using `typing.Union`.
* Corollary: prefer `T | None` over `typing.Optional[T]`.

### Calling non-python code

Todo: Merge with section in #18207

#### using the `ctypesUtils` module

The goal of the `utils.ctypesUtils` module is to make calling external C functions easier.
It does so by aiding in two areas:

1. Annotating the ctypes C function pointer with type information (restype and argtypes) so that parameters passed to the function are properly validated by ctypes.
2. Providing type hints to the several parameters of the function.

The decorator is best explained by a basic example.

```python
from ctypes import windll
from ctypes.wintypes import BOOL, HANDLE
from typing import Annotated
from utils.ctypesUtils import dllFunc

@dllFunc(windll.kernel32)
def CloseHandle(hObject: int | HANDLE) -> Annotated[int, BOOL]:
...
```

A properly annotated function is a function of the form:
`def FunctionNameINDll(param1: hint1, param2: hint2) -> ReturnHint:`

* By default, the `dllFunc` decorator infers the function name from the name of the Python function.
It can be overridden by passing an additional parameter to the decorator.
* Parameter type hints can be of several forms, but should at least reference a ctypes compatible type.
* Form 1: Just a ctypes type, e.g. `HWND`
* Form 2: `int | HWND`. Both `int` and `HWND` are reported as valid by type checkers.
The first ctypes type found (`HWND`) is used in `restypes`.
This is the preferred approach.
* Form 3: `typing.Annotated[int, HWND]`. Only `int` is reported as valid by type checkers. The annotation (i.e. `HWND`) is used in `restypes`. This can be used when the desired ctypes type might be incompatible with type checkers.
* Return type hints can also be of several forms.
* Form 1: Just a ctypes type, e.g. `BOOL`. It will be used as `restype`.
* Form 2: `typing.Annotated[int, BOOL]`. Prefer, because ctypes will automatically convert a `BOOL` to an `int`, whereas `BOOL` will be the `restype`.
* Note that the `int | BOOL` form (e.g. input parameter form 2) is not supported here, since a ctypes function will never return a `BOOL`, it will always create a more pythonic value whenever possible.

Output parameters are more complex.
When ctypes knows that a certain parameter is an output parameter, it will automatically create an object and passes a pointer to that object to the C function.
Therefore, output parameters are defined in a `typing.Annotated` hint as a `OutParam` object. e.g.

```python
from ctypes import windll
from ctypes.wintypes import BOOL, HWND, RECT
from typing import Annotated
from utils.ctypesUtils import dllFunc, OutParam, Pointer

@dllFunc(windll.user32, restype=BOOL)
def GetClientRect(hWnd: Annotated[int, HWND]) -> Annotated[RECT, OutParam("lpRect", 1)]: ...
...
```

Note that:

* Since specifying output parameters in ctypes swallows up the restype, `restype` needs to be defined on the `dllFunc` decorator explicitly. Not doing so results in a `TypeError`.
* ctypes automatically returns the contained value of a pointer object. So the return annotation here means:
* Treat `RECT` as the return type. Type checkers will communicate as such.
* Assume `ctypes.POINTER(RECT)` in `argtypes`, unless the return type is an array (e.g. `RECT * 1)`. To override the pointer type, use the `type` parameter of the `OutParam` class.
* The out param is the second entry in the `argtypes` array, index=1.

For a function with multiple arg types, specify a type hint like `tuple[Annotated[RECT, OutParam(Pointer[RECT], "lpRect1", 1)], Annotated[RECT, OutParam(Pointer[RECT], "lpRect2", 2)]`.

### Language choices

The NVDA community is large and diverse, and we have a responsibility to make everyone feel welcome in it.
Expand Down
282 changes: 282 additions & 0 deletions source/utils/ctypesUtils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2025 NV Access Limited, Leonard de Ruijter

"""Utilities to annotate ctypes dll exports."""

import abc
import ctypes
import functools
import inspect
import dataclasses
import types
import typing
from enum import IntEnum

from logHandler import log


class ParamDirectionFlag(IntEnum):
"""Flags to indicate the direction of parameters in ctypes function signatures."""

IN = 1
"""Specifies an input parameter to the function."""
OUT = 2
"""Output parameter. The foreign function fills in a value."""
# Note: IN | OUT is not supported, as ctypes will require this as input parameter and will also return it, which is useless.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow this. There are plenty of functions in the Windows API that use inout parameters. For example, a pointer to a size variable that [in] specifies the amount of space reserved for the function to write to, and [out] on success specifies the amount written or on failure specifies the space needed.



class CType(abc.ABC):
"""Abstract class for ctypes types.
This class is used to validate type annotations for ctypes functions.
"""

def __new__(cls, *args, **kwargs):
raise TypeError(
f"{cls.__name__} may not be instantiated. "
"It is only used as an abstract class to annotate ctypes objects or parameters.",
)


# Hacky, but there's no other way to get to the base class for ctypes types.
CType.register(ctypes.c_int.__mro__[2])

if typing.TYPE_CHECKING:
from ctypes import _Pointer as Pointer
else:

class Pointer(CType):
"""A pointer type that can be used as a type annotation for ctypes functions."""

@classmethod
def __class_getitem__(cls, t: type) -> type:
return ctypes.POINTER(t)

# Register known pointer types
for t in (ctypes._Pointer, ctypes._CFuncPtr, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_wchar_p):
Pointer.register(t)


@dataclasses.dataclass
class OutParam:
"""Annotation for output parameters in function signatures.
This is used to specify that a parameter is an output parameter, which will be filled by the wrapped foreign function."""

name: str
"""The name of the output parameter."""
position: int = 0
"""The position of the output parameter in argtypes."""
type: Pointer | None = None
"""The type of the output parameter. This should be a pointer type.
If None, the type from the annotation is used and a pointer type is created from it automatically."""
default: CType | inspect.Parameter.empty = inspect.Parameter.empty
"""The default value for the output parameter."""


def windowsErrCheck(result: int, func: ctypes._CFuncPtr, args: typing.Any) -> typing.Any:
if result == 0:
raise ctypes.WinError()
return args


@dataclasses.dataclass
class FuncSpec:
"""Specification of a ctypes function."""

restype: type[CType]
argtypes: tuple[CType]
paramFlags: tuple[
tuple[ParamDirectionFlag, str] | tuple[ParamDirectionFlag, str, int | ctypes._SimpleCData]
]


def getFuncSpec(
pyFunc: types.FunctionType,
restype: type[CType] | None = None,
) -> FuncSpec:
"""
Generates a function specification (`FuncSpec`) to generate a ctypes foreign function wrapper.

This function inspects the signature and type annotations of the given Python function to determine the argument types,
parameter flags (input/output), and return type(s) for use with ctypes. It enforces that all parameters and the return
type are properly annotated with ctypes-compatible types, and supports handling of output parameters via `Annotated` types.

:param pyFunc: The Python function to inspect. Must have type annotations for all parameters and the return type.
:param restype: Optional explicit ctypes return type. Required if the function has output parameters.

:raises TypeError: If parameter kinds are unsupported, type annotations are missing or invalid, or output parameter annotations are incorrect.
:raises IndexError: If output parameter positions are invalid or duplicated.

:returns: A `FuncSpec` object containing the ctypes-compatible function specification.
"""
sig = inspect.signature(pyFunc)
# Extract argument types from annotations
argtypes = []
paramFlags = []
for param in sig.parameters.values():
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
raise TypeError(
f"Unsupported parameter kind: {param.kind} for parameter: {param.name} "
"*args and **kwargs are not supported.",
)
t = param.annotation
if t is inspect.Parameter.empty:
raise TypeError(f"Missing type annotation for parameter: {param.name}")
elif typing.get_origin(t) in (typing.Union, types.UnionType):
t = next((c for c in typing.get_args(t) if issubclass(c, CType)), t)
elif typing.get_origin(t) is typing.Annotated:
if len(t.__metadata__) != 1 or not issubclass(t.__metadata__[0], CType):
raise TypeError(f"Expected single annotation of a ctypes type for parameter: {param.name}")
t = t.__metadata__[0]
if not issubclass(t, CType):
raise TypeError(
f"Expected a ctypes compatible type for parameter: {param.name}, got {t!r}",
)
argtypes.append(t)
if param.default is inspect.Parameter.empty:
paramFlags.append((ParamDirectionFlag.IN, param.name))
else:
paramFlags.append((ParamDirectionFlag.IN, param.name, param.default))

# Extract return type
expectedRestype = sig.return_annotation
if expectedRestype is inspect.Signature.empty:
raise TypeError("Missing return type annotation")
elif isinstance(expectedRestype, tuple):
if restype is None:
raise TypeError("restype should be provided when using a tuple for return type")
requireOutParamAnnotations = True
restypes = list(expectedRestype)
else:
requireOutParamAnnotations = restype is not None
restypes = [expectedRestype]
for i, t in enumerate(restypes):
handledPositions = []
isAnnotated = typing.get_origin(t) is typing.Annotated and len(t.__metadata__) == 1
if requireOutParamAnnotations:
if not isAnnotated or not isinstance(t.__metadata__[0], OutParam):
raise TypeError(f"Expected single annotation of type 'OutParam' for parameter: {param.name}")
outParam = t.__metadata__[0]
if len(argtypes) < outParam.position:
raise IndexError(
f"Output parameter {outParam.name} at position {outParam.position} "
f"exceeds the number of processed input parameters ({len(argtypes)})",
)
elif outParam.position in handledPositions:
raise IndexError(
f"Output parameter at position {outParam.position} has already been processed",
)
if outParam.type is None:
outParam.type = (
t.__origin__ if isinstance(t.__origin__, ctypes.Array) else ctypes.POINTER(t.__origin__)
)
handledPositions.append(outParam.position)
argtypes.insert(outParam.position, outParam.type)
if outParam.default is inspect.Parameter.empty:
paramFlags.insert(outParam.position, (ParamDirectionFlag.OUT, outParam.name))
else:
paramFlags.insert(
outParam.position,
(ParamDirectionFlag.OUT, outParam.name, outParam.default),
)
elif isAnnotated:
annotation = t.__metadata__[0]
if not issubclass(annotation, CType):
raise TypeError(
f"Expected single annotation of a ctypes type for result type, got {annotation!r}",
)
restype = annotation
else:
restype = t

return FuncSpec(
restype=restype,
argtypes=tuple(argtypes),
paramFlags=tuple(paramFlags),
)


def dllFunc(
library: ctypes.CDLL,
funcName: str | None = None,
restype: type[CType] = None,
*,
cFunctype=ctypes.WINFUNCTYPE,
annotateOriginalCFunc=True,
wrapNewCFunc=True,
errcheck=None,
):
"""
Decorator to bind a Python function to a C function from a DLL using ctypes,
automatically setting argument and return types based on the Python function's signature.

This decorator simplifies the process of wrapping C functions from a DLL,
by inferring argument and return types from the Python function and applying them to the C function pointer.

:param library: The ctypes.CDLL instance representing the loaded DLL.
:param funcName: The name of the function in the DLL. If None, uses the Python function's name.
:param restype: Optional explicit ctypes return type. Required if the function has output parameters.
:param cFunctype: The ctypes function type to use (e.g., ctypes.WINFUNCTYPE or ctypes.CFUNCTYPE).
:param annotateOriginalCFunc: Whether to annotate the original C function with argtypes/restype.
:param wrapNewCFunc: Whether to return a new ctypes function pointer or the original.
:param errcheck: Optional error checking function to attach to the ctypes function.
this parameter only applies when `wrapNewCFunc` is True.

:raises TypeError: If the decorated object is not a function, if parameter kinds are unsupported, type annotations are missing or invalid, or output parameter annotations are incorrect.
:raises IndexError: If output parameter positions are invalid or duplicated.

:returns: The decorated function, now bound to the C function from the DLL.

:example:


user32 = ctypes.windll.user32

@dllFunc(user32, restype=ctypes.c_bool, errcheck=windowsErrCheck)
def GetClientRect(
hWnd: int | HWND,
) -> Annotated[RECT, OutParam(Pointer[RECT], "lpRect", 1)]: ...
'''Wraps the GetClientRect function from user32.dll.
:param hWnd: Handle to the window.
:return: A RECT structure that contains the coordinates of the client area.
:raise WindowsError: If the function fails, an exception is raised with the error'''
pass

"""

def decorator(pyFunc: types.FunctionType):
if not isinstance(pyFunc, types.FunctionType):
raise TypeError(f"Expected a function, got {type(pyFunc)!r}")
if typing.TYPE_CHECKING:
# Return early when type checking.
return pyFunc
nonlocal restype, funcName
funcName = funcName or pyFunc.__name__
cFunc = getattr(library, funcName)
spec = getFuncSpec(pyFunc, restype)
# Set ctypes metadata for the original function in case it is called from outside
if annotateOriginalCFunc:
if cFunc.argtypes is not None:
log.warning(
f"Overriding existing argtypes for {pyFunc!r}: {cFunc.argtypes} -> {spec.argtypes}",
stack_info=True,
)
cFunc.argtypes = spec.argtypes
if cFunc.restype is not None:
log.warning(
f"Overriding existing restype for {pyFunc!r}: {cFunc.restype} -> {spec.restype}",
stack_info=True,
)
cFunc.restype = spec.restype

wrapper = functools.wraps(pyFunc)
if not wrapNewCFunc:
return wrapper(cFunc)
newCFuncClass = cFunctype(spec.restype, *spec.argtypes)
newCFunc = newCFuncClass((funcName, library), spec.paramFlags)
if errcheck:
newCFunc.errcheck = errcheck
return wrapper(newCFunc)

return decorator
Loading