Skip to content

Commit

Permalink
Merge pull request #1 from ionite34/dev
Browse files Browse the repository at this point in the history
Pythonapi binding improvement with `protocols` decorators, typing fixes
  • Loading branch information
ionite34 committed Dec 18, 2022
2 parents 1eeeb94 + 021036d commit 52b86db
Show file tree
Hide file tree
Showing 22 changed files with 379 additions and 150 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# einspect

[![Build](https://github.com/ionite34/einspect/actions/workflows/build.yml/badge.svg)](https://github.com/ionite34/einspect/actions/workflows/build.yml)
[![codecov](https://codecov.io/gh/ionite34/einspect/branch/main/graph/badge.svg?token=v71SdG5Bo6)](https://codecov.io/gh/ionite34/einspect)

Extended Inspect for CPython

Provides simple and robust ways to view and modify the base memory structures of Python objects at runtime.

> *einspect* is in very early stages of development, API may change at any time without notice.
Note: The below examples show interactions with a `TupleView`, but applies much the same way generically for
many of the specialized `View` subtypes that are dynamically returned by the `view` function. If no specific
view is implemented, the base `View` will be used which represents limited interactions on the assumption of
Expand All @@ -15,12 +16,19 @@ view is implemented, the base `View` will be used which represents limited inter
```python
from einspect import view

obj = (1, 2, 3)
v = view(obj)

print(v)
print(view((1, 2)))
print(view([1, 2]))
print(view("hello"))
print(view(256))
print(view(object()))
```
> `TupleView[tuple](<PyTupleObject at 0x10078dd00>)`
> ```
> TupleView[tuple](<PyTupleObject at 0x100f19a00>)
> ListView[list](<PyListObject at 0x10124f800>)
> StrView[str](<PyUnicodeObject at 0x100f12ab0>)
> IntView[int](<PyLongObject at 0x102058920>)
> View[object](<PyObject at 0x100ea08a0>)
> ```
## 1. Viewing python object struct attributes

Expand Down Expand Up @@ -51,8 +59,6 @@ print(obj)
```
> `(1, 2)`
## 3. Writing to view attributes

Since `items` is an array of integer pointers to python objects, they can be replaced by `id()` addresses to modify
index items in the tuple.
```python
Expand Down
8 changes: 7 additions & 1 deletion src/einspect/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
from typing import ContextManager

from einspect.views import unsafe as _unsafe
from einspect.views.factory import view
from einspect.views.unsafe import unsafe

__all__ = ["view", "unsafe"]

unsafe: ContextManager[None] = _unsafe.Context.unsafe
7 changes: 4 additions & 3 deletions src/einspect/api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Typed methods from pythonapi."""
from __future__ import annotations

import _ctypes
import ctypes
from collections.abc import Callable
from ctypes import pythonapi, py_object, POINTER
from ctypes import POINTER, py_object, pythonapi
from typing import Union

from einspect.compat import python_req, Version
import _ctypes

from einspect.compat import Version, python_req

__all__ = ("Py", "Py_ssize_t", "PyObj_FromPtr")

Expand Down
2 changes: 1 addition & 1 deletion src/einspect/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from dataclasses import dataclass
from enum import Enum
from typing import Generic, TypeVar, NoReturn
from typing import Generic, NoReturn, TypeVar


class Version(Enum):
Expand Down
145 changes: 145 additions & 0 deletions src/einspect/protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Decorator protocols for binding class properties."""
from __future__ import annotations

import ctypes
import logging
import typing
from collections.abc import Callable, Sequence
from ctypes import POINTER
from functools import partial
from types import MethodType
from typing import (Any, Protocol, Type, TypeVar, get_type_hints,
runtime_checkable)

from typing_extensions import Self

from einspect.api import Py_ssize_t

log = logging.getLogger(__name__)

RES_TYPE_DEFAULT = ctypes.c_int
ARG_TYPES_DEFAULT = (ctypes.py_object,)


@runtime_checkable
class FuncPointer(Protocol):
restype: Any
argtypes: Sequence[type]

def __call__(self, *args: Any, **kwargs: Any) -> Any:
...


_F = TypeVar("_F", bound=typing.Callable[[Any], FuncPointer])
_R = TypeVar("_R")
_CT = TypeVar("_CT", bound=ctypes.Structure)

aliases = {
int: Py_ssize_t,
object: ctypes.py_object,
}


def cast_type_aliases(source: type[Any], owner_cls: type) -> type:
"""Cast some aliases for types."""
if source == Self:
source = owner_cls

if source in aliases:
return aliases[source]

# Replace with a pointer type if it's a structure
if issubclass(source, ctypes.Structure):
return POINTER(source)

return source


def bind_api(py_api: FuncPointer) -> Callable[[_F], _F]:
"""Decorator to bind a function to a ctypes function pointer."""
return partial(delayed_bind, py_api)


# noinspection PyPep8Naming
class delayed_bind(property):
def __init__(self, py_api: FuncPointer, func: _F):
super().__init__()
self.func = func
self.__doc__ = func.__doc__

self.py_api = py_api

self.attrname: str | None = None
self.restype = None
self.argtypes = None
self.func_set = False

def _get_defining_type_hints(self, cls: type) -> tuple[Sequence[type], type]:
"""Return the type hints for the attribute we're bound to, or None if it's not defined."""
# Get the function type hints
hints = get_type_hints(self.func)
log.debug(f"Found type hints for {self.attrname!r}: {hints}")
res_t = hints.pop("return", None)
arg_t = list(hints.values())

# Disallow any missing type hints
if None in arg_t or res_t is None:
raise TypeError(
"Cannot resolve bind function type hints. "
"Please provide them explicitly."
)

if res_t is not None:
res_t = cast_type_aliases(res_t, cls)
# Replace with None if NoneType
res_t = None if isinstance(None, res_t) else res_t
# Insert current class type as first argument
arg_t.insert(0, cls)
arg_t = [cast_type_aliases(t, cls) for t in arg_t]

log.debug(f"Converted: ({arg_t}) -> {res_t}")

return arg_t, res_t

def __set_name__(self, owner, name):
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same bind to two different names "
f"({self.attrname!r} and {name!r})."
)

# noinspection PyMethodOverriding
def __get__(self, instance: object | None, owner_cls: Type[_CT]) -> _F:
if self.attrname is None:
raise TypeError(
"Cannot use bind instance without calling __set_name__ on it."
)

if not self.func_set:
argtypes, restype = self._get_defining_type_hints(owner_cls)
self.py_api.restype = restype
self.py_api.argtypes = argtypes
self.func_set = True

if instance is None:
return self.py_api # type: ignore

try:
cache = instance.__dict__
except AttributeError:
raise TypeError("bind requires classes to support __dict__.") from None

bound_func = MethodType(self.py_api, instance)

try:
cache[self.attrname] = bound_func
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
)
raise TypeError(msg) from None

return bound_func
4 changes: 2 additions & 2 deletions src/einspect/structs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

from einspect.structs.deco import struct
from einspect.structs.py_object import PyObject, PyVarObject
from einspect.structs.py_tuple import PyTupleObject
from einspect.structs.py_list import PyListObject
from einspect.structs.py_long import PyLongObject
from einspect.structs.py_object import PyObject, PyVarObject
from einspect.structs.py_tuple import PyTupleObject
from einspect.structs.py_unicode import PyUnicodeObject

__all__ = (
Expand Down
5 changes: 4 additions & 1 deletion src/einspect/structs/deco.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from ctypes import Structure
from typing import get_type_hints, TypeVar, Type
from typing import Callable, Type, TypeVar, get_type_hints

from einspect.api import Py_ssize_t

Expand All @@ -20,6 +20,9 @@ def struct(cls: _T) -> _T:
# Skip actual values like _fields_
if name.startswith("_") and name.endswith("_"):
continue
# Skip callables
if type_hint == Callable:
continue
# Since get_type_hints also gets superclass hints, skip them
if name not in cls.__annotations__:
continue
Expand Down
6 changes: 1 addition & 5 deletions src/einspect/structs/py_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,12 @@ def from_object(cls, obj: _T) -> Self:
def into_object(self) -> py_object[_T]:
"""Cast the PyObject into a Python object."""
ptr = ctypes.pointer(self)
# Call Py_INCREF to prevent the object from being GC'd
# pythonapi.Py_IncRef(ptr)
obj = ctypes.cast(ptr, ctypes.py_object)
# pythonapi.Py_IncRef(obj)
# obj = ctypes.py_object.from_address(self._id)
return obj


@struct
class PyVarObject(PyObject):
class PyVarObject(PyObject[_T]):
"""
Defines a base PyVarObject Structure.
Expand Down
44 changes: 27 additions & 17 deletions src/einspect/structs/py_tuple.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
from __future__ import annotations

import ctypes
from collections.abc import Callable
from ctypes import pythonapi
from types import MethodType
from typing import Generic, TypeVar, get_args, overload

from typing_extensions import Self

from einspect.api import Py_ssize_t
from einspect.protocols import bind_api, delayed_bind
from einspect.structs.deco import struct
from einspect.structs.py_object import PyVarObject

_Tuple = TypeVar("_Tuple", bound=tuple)


# noinspection PyPep8Naming
@struct
class PyTupleObject(PyVarObject):
class PyTupleObject(PyVarObject[_Tuple]):
"""
Defines a PyTupleObject Structure.
Expand All @@ -18,6 +27,23 @@ class PyTupleObject(PyVarObject):
# Size of this array is only known after creation
_ob_item_0: Py_ssize_t * 0

@bind_api(pythonapi["PyTuple_GetItem"])
def GetItem(self, index: int) -> object:
"""Return the item at the given index."""

@bind_api(pythonapi["PyTuple_SetItem"])
def SetItem(self, index: int, value: object) -> None:
"""Set a value to a given index."""

@bind_api(pythonapi["_PyTuple_Resize"])
def Resize(self, size: int) -> None:
"""Resize the tuple to the given size."""

@classmethod
def from_object(cls, obj: _Tuple) -> PyTupleObject[_Tuple]:
"""Create a PyTupleObject from an object."""
return super(PyTupleObject, cls).from_object(obj) # type: ignore

@property
def mem_size(self) -> int:
"""Return the size of the PyObject in memory."""
Expand All @@ -31,19 +57,3 @@ def ob_item(self):
items_addr = ctypes.addressof(self._ob_item_0)
arr = Py_ssize_t * self.ob_size
return arr.from_address(items_addr)


PyTupleObject.GetItem = pythonapi["PyTuple_GetItem"]
"""(PyObject *o, Py_ssize_t index) -> Py_ssize_t"""
PyTupleObject.GetItem.argtypes = (ctypes.POINTER(PyTupleObject), Py_ssize_t)
PyTupleObject.GetItem.restype = ctypes.py_object

PyTupleObject.SetItem = pythonapi["PyTuple_SetItem"]
"""(PyObject *o, Py_ssize_t index, PyObject *v) -> int"""
PyTupleObject.SetItem.argtypes = (ctypes.POINTER(PyTupleObject), Py_ssize_t, ctypes.py_object)
PyTupleObject.SetItem.restype = None

PyTupleObject.Resize = pythonapi["_PyTuple_Resize"]
"""(PyObject *o, Py_ssize_t index, PyObject *v) -> int"""
PyTupleObject.Resize.argtypes = (ctypes.POINTER(PyTupleObject), Py_ssize_t)
PyTupleObject.Resize.restype = None
1 change: 0 additions & 1 deletion src/einspect/structs/py_unicode.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from ctypes import Array
from enum import IntEnum


from einspect.structs.deco import struct
from einspect.structs.py_object import PyObject

Expand Down
8 changes: 7 additions & 1 deletion src/einspect/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from __future__ import annotations

import ctypes
from ctypes import pythonapi
from typing import Any


def address(obj: Any) -> int:
"""Return the address of an object."""
return ctypes.c_void_p.from_buffer(ctypes.py_object(obj)).value
source = ctypes.py_object(obj)
addr = ctypes.c_void_p.from_buffer(source).value
if addr is None:
raise ValueError("address: NULL object")
return addr


def new_ref(obj: Any) -> int:
Expand Down
Loading

0 comments on commit 52b86db

Please sign in to comment.