Section 5: Hands-On ctypes Memory Inspection#
Overview#
Now that you understand Python’s object model (Section 2), integer internals (Section 3), and string internals (Section 4), it’s time to get your hands dirty with direct memory inspection.
In this section, you’ll learn:
What is ctypes and why it’s Python’s “window into C”
Building custom ctypes.Structure definitions for Python objects
Reading raw memory with
ctypes.string_at()Pointer arithmetic and following object references
Advanced techniques like type punning and casting
3 Practical Projects to solidify your understanding
Integration with omnivault.utils.memory tools
Learning Objectives#
By the end of this section, you will be able to:
Define ctypes structures matching CPython’s internal structures
Read and interpret raw memory bytes from Python objects
Follow pointers between objects in memory
Build custom memory inspection tools
Understand the safety concerns and limitations of memory inspection
Prerequisites#
You should have completed:
Section 0: Introduction
Section 1: C Memory Basics
Section 2: Python Object Model
Section 3: Integer Internals (recommended)
Section 4: String Internals (recommended)
What is ctypes?#
ctypes is Python’s built-in Foreign Function Interface (FFI) library that allows you to:
Call C functions from shared libraries
Define C-compatible data structures
Access memory directly
Convert between Python and C types
Think of ctypes as a bridge between Python’s high-level world and C’s low-level memory.
┌─────────────────────────────────────────┐
│ Python Layer │
│ - Objects with type info │
│ - Automatic memory management │
│ - Dynamic typing │
├─────────────────────────────────────────┤
│ ctypes Layer (Bridge) │ ← We are here!
│ - ctypes.Structure definitions │
│ - ctypes.string_at() for memory reads │
│ - id() to get addresses │
├─────────────────────────────────────────┤
│ C Layer │
│ - Raw memory bytes │
│ - Structs and pointers │
│ - Manual memory management │
└─────────────────────────────────────────┘
Warning
SAFETY WARNING: ctypes Can Crash Python
The official Python documentation states: “There are, however, enough ways to crash Python with ctypes, so you should be careful anyway.”
What this means:
Reading beyond allocated memory → Segmentation fault (instant crash)
Incorrect pointer arithmetic → Memory corruption
Accessing garbage-collected objects → Undefined behavior
Wrong structure definitions → Data misalignment crashes
This tutorial teaches safe patterns, but ctypes gives you “C-level power” with “C-level danger.”
When in doubt:
Use
faulthandlermodule to debug crashesStart with small byte counts (8, 16, 32 bytes)
Never read more bytes than
sys.getsizeof()returnsKeep a reference to objects you’re inspecting
5.1 Setup and Platform Detection#
Let’s start by importing the tools we’ll need and detecting our platform.
from __future__ import annotations
import ctypes
import sys
from typing import Any, ClassVar, Final
from dataclasses import dataclass
# Import our memory utilities
from omnivault.utils.memory import (
MemoryInspector,
PyObject,
PyVarObject,
inspect_memory_bytes,
format_hex_dump,
inspect_structure_layout,
)
# Platform information
POINTER_SIZE: Final[int] = ctypes.sizeof(ctypes.c_void_p)
IS_64BIT: Final[bool] = POINTER_SIZE == 8
print(f"Platform Information:")
print(f" Pointer size: {POINTER_SIZE} bytes")
print(f" Architecture: {'64-bit' if IS_64BIT else '32-bit'}")
print(f" Python version: {sys.version}")
print(f" sys.maxsize: {sys.maxsize}")
Note
Platform Differences:
32-bit systems: Pointers are 4 bytes,
sys.maxsizeis 2^31-164-bit systems: Pointers are 8 bytes,
sys.maxsizeis 2^63-1
This tutorial assumes 64-bit CPython. Most code will work on 32-bit, but structure sizes will differ.
5.2 Understanding ctypes.Structure#
What is a ctypes.Structure?#
A ctypes.Structure is Python’s way of representing a C struct. It defines a memory layout with named fields at specific offsets.
Why Do We Need This?#
To inspect Python objects, we need to:
Know the exact memory layout (which bytes mean what)
Define that layout in Python using
ctypes.Structure“Cast” raw memory to our structure definition
Read the fields
Let’s see this in action with a simple example.
Example: A Simple Point Structure#
# Define a simple C-style Point structure
class Point(ctypes.Structure):
"""
Equivalent C code:
struct Point {
int32_t x;
int32_t y;
};
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("x", ctypes.c_int32),
("y", ctypes.c_int32),
]
# Create an instance
point: Point = Point(10, 20)
print(f"Point Structure Analysis")
print(f"{'='*60}")
print(f"Size: {ctypes.sizeof(Point)} bytes")
print(f"Fields:")
print(f" x: value={point.x}, offset={Point.x.offset} bytes")
print(f" y: value={point.y}, offset={Point.y.offset} bytes")
print()
# Inspect memory layout
print(f"Memory Layout:")
layout = inspect_structure_layout(Point)
for field in layout:
print(f" {field.field_name:>10}: offset={field.offset:>2}, size={field.size} bytes, type={field.type_name}")
print()
# Read raw memory
print(f"Raw Memory Dump:")
address = ctypes.addressof(point)
raw_bytes = ctypes.string_at(address, ctypes.sizeof(Point))
print(format_hex_dump(raw_bytes))
Expected Output:
Point Structure Analysis
============================================================
Size: 8 bytes
Fields:
x: value=10, offset=0 bytes
y: value=20, offset=4 bytes
Memory Layout:
x: offset= 0, size=4 bytes, type=c_int32
y: offset= 4, size=4 bytes, type=c_int32
Raw Memory Dump:
0000: 0a 00 00 00 14 00 00 00 |........|
Memory Alignment and Padding#
C structures often have padding bytes inserted by the compiler to align fields on natural boundaries (typically 4 or 8 bytes).
Why? CPUs access aligned memory faster. Unaligned access can be slower or even cause crashes on some architectures.
class PaddedStruct(ctypes.Structure):
"""
Equivalent C code:
struct PaddedStruct {
char a; // 1 byte
// 3 bytes padding here!
int32_t b; // 4 bytes
char c; // 1 byte
// 3 bytes padding at end!
};
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("a", ctypes.c_char),
("b", ctypes.c_int32),
("c", ctypes.c_char),
]
class OptimizedStruct(ctypes.Structure):
"""
Equivalent C code (fields reordered):
struct OptimizedStruct {
int32_t b; // 4 bytes
char a; // 1 byte
char c; // 1 byte
// 2 bytes padding at end
};
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("b", ctypes.c_int32),
("a", ctypes.c_char),
("c", ctypes.c_char),
]
# Compare
padded = PaddedStruct(b'A', 42, b'B')
optimized = OptimizedStruct(42, b'A', b'B')
print(f"Padding Comparison")
print(f"{'='*60}")
print(f"PaddedStruct:")
print(f" Size: {ctypes.sizeof(PaddedStruct)} bytes")
for name, _ in PaddedStruct._fields_:
offset = getattr(PaddedStruct, name).offset
print(f" {name}: offset={offset}")
print()
print(f"OptimizedStruct:")
print(f" Size: {ctypes.sizeof(OptimizedStruct)} bytes")
for name, _ in OptimizedStruct._fields_:
offset = getattr(OptimizedStruct, name).offset
print(f" {name}: offset={offset}")
print()
print(f"Memory saved: {ctypes.sizeof(PaddedStruct) - ctypes.sizeof(OptimizedStruct)} bytes")
Visualizing Memory Layout#
PaddedStruct (12 bytes):
┌─────┬─────┬─────┬─────┐
│ a │ pad │ pad │ pad │ 0-3 (char + 3 padding)
├─────┴─────┴─────┴─────┤
│ b (int32) │ 4-7 (aligned to 4-byte boundary)
├─────┬─────┬─────┬─────┤
│ c │ pad │ pad │ pad │ 8-11 (char + 3 padding)
└─────┴─────┴─────┴─────┘
OptimizedStruct (8 bytes):
┌─────────────────────────┐
│ b (int32) │ 0-3 (int first)
├─────┬─────┬─────┬─────┤
│ a │ c │ pad │ pad │ 4-7 (chars together + 2 padding)
└─────┴─────┴─────┴─────┘
Key Lesson: Field ordering matters! Reordering can reduce memory usage.
5.3 Building CPython Structure Definitions#
Now let’s recreate the actual CPython structures for inspecting Python objects.
The PyObject Structure (Revisited)#
We’ve seen this before in Section 2, but now we’ll build it ourselves to understand every detail.
# We already have PyObject from omnivault.utils.memory, but let's rebuild it
# to understand every detail
class PyObjectManual(ctypes.Structure):
"""
Manual definition of CPython's PyObject.
From CPython source (Include/object.h):
typedef struct _object {
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
} PyObject;
On 64-bit systems:
- ob_refcnt: 8 bytes (signed size_t)
- ob_type: 8 bytes (pointer to type object)
Total: 16 bytes
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("ob_refcnt", ctypes.c_ssize_t),
("ob_type", ctypes.c_void_p),
]
# Test it on a real Python object
test_obj: int = 42
obj_address: int = id(test_obj)
# Cast the address to our structure
pyobj: PyObjectManual = PyObjectManual.from_address(obj_address)
print(f"PyObject Inspection")
print(f"{'='*60}")
print(f"Object: {test_obj}")
print(f"Address: {obj_address:#x}")
print(f"Reference count: {pyobj.ob_refcnt}")
print(f"Type pointer: {pyobj.ob_type:#x}")
print()
# Verify with sys.getrefcount
print(f"sys.getrefcount(): {sys.getrefcount(test_obj)}")
print(f"Note: getrefcount includes temporary references")
Tip
Why the refcount difference?
sys.getrefcount(obj) creates a temporary reference when passing obj as an argument, so it’s typically 1 higher than the actual count.
The PyVarObject Structure#
Variable-sized objects (lists, tuples, strings, integers) use PyVarObject:
class PyVarObjectManual(ctypes.Structure):
"""
Manual definition of CPython's PyVarObject.
From CPython source:
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size;
} PyVarObject;
On 64-bit systems: 24 bytes total
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("ob_base", PyObjectManual),
("ob_size", ctypes.c_ssize_t),
]
# Test on a list
test_list: list[int] = [1, 2, 3, 4, 5]
list_address: int = id(test_list)
pyvar: PyVarObjectManual = PyVarObjectManual.from_address(list_address)
print(f"PyVarObject Inspection")
print(f"{'='*60}")
print(f"Object: {test_list}")
print(f"Address: {list_address:#x}")
print(f"Reference count: {pyvar.ob_base.ob_refcnt}")
print(f"Type pointer: {pyvar.ob_base.ob_type:#x}")
print(f"Size (number of items): {pyvar.ob_size}")
print(f"Actual length: {len(test_list)}")
print()
# Compare with inspector
inspector = MemoryInspector(test_list)
print(f"Using MemoryInspector:")
print(inspector.summary())
The PyLongObject Structure#
Now the big one - integers! This is where we finally understand the 28 bytes.
class PyLongObjectSimple(ctypes.Structure):
"""
Simplified PyLongObject for small integers.
From CPython source (Include/longintrepr.h):
struct _longobject {
PyVarObject ob_base;
digit ob_digit[1]; // flexible array member
};
Where digit is uint32_t on most platforms.
For small integers (one digit):
- PyVarObject: 24 bytes
- ob_digit[0]: 4 bytes
Total: 28 bytes
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("ob_base", PyVarObjectManual),
("ob_digit", ctypes.c_uint32 * 1), # Simplified: just one digit
]
# Inspect a small integer
small_int: int = 5
int_address: int = id(small_int)
pylong: PyLongObjectSimple = PyLongObjectSimple.from_address(int_address)
print(f"PyLongObject Inspection")
print(f"{'='*60}")
print(f"Integer value: {small_int}")
print(f"Address: {int_address:#x}")
print()
print(f"PyVarObject header:")
print(f" ob_refcnt: {pylong.ob_base.ob_base.ob_refcnt}")
print(f" ob_type: {pylong.ob_base.ob_base.ob_type:#x}")
print(f" ob_size: {pylong.ob_base.ob_size} (number of digits)")
print()
print(f"Integer data:")
print(f" ob_digit[0]: {pylong.ob_digit[0]} (the actual value)")
print()
print(f"Total size: {sys.getsizeof(small_int)} bytes")
print()
# Memory dump
inspector_int = MemoryInspector(small_int)
print(f"Memory dump:")
print(inspector_int.dump_hex(32))
The 28-Byte Mystery Solved!
For integer 5:
PyObject header: 16 bytes (ob_refcnt + ob_type)
ob_size field: 8 bytes (number of digits = 1)
ob_digit[0]: 4 bytes (value = 5)
Total: 28 bytes
This is why sys.getsizeof(5) returns 28!
5.4 Reading Raw Memory with ctypes.string_at()#
The Power (and Danger) of ctypes.string_at()#
ctypes.string_at(address, size) is your direct window into memory. It reads size bytes starting at address and returns them as a bytes object.
How It Works#
Memory:
Address Value
0x1000 → [0x2A 0x00 0x00 0x00] ← 4 bytes representing int(42)
ctypes.string_at(0x1000, 4) → b'*\x00\x00\x00'
What: Read any memory you have an address to Why: Inspect object internals directly Danger: Reading invalid memory crashes your program!
Danger
CRITICAL SAFETY RULES
Only read from valid objects: Use
id(obj)to get addressesDon’t read past the object: Use
sys.getsizeof()to know the limitExpect the unexpected: Memory layouts can vary by Python version
Test on disposable data: Don’t inspect critical production objects
Example of instant crash:
# DON'T DO THIS - will crash Python!
ctypes.string_at(0, 1024) # Reading from NULL pointer
Safe Memory Reading Pattern#
def safe_memory_read(obj: object, num_bytes: int | None = None) -> bytes:
"""
Safely read memory from a Python object.
Args:
obj: Object to inspect
num_bytes: Number of bytes to read (default: sys.getsizeof(obj))
Returns:
Raw bytes from object memory
Raises:
ValueError: If num_bytes exceeds object size
"""
obj_size: int = sys.getsizeof(obj)
if num_bytes is None:
num_bytes = obj_size
if num_bytes > obj_size:
raise ValueError(
f"Requested {num_bytes} bytes but object is only {obj_size} bytes"
)
address: int = id(obj)
return ctypes.string_at(address, num_bytes)
# Example 1: Read an integer's memory
value: int = 42
memory: bytes = safe_memory_read(value, 32)
print(f"Reading Integer Memory")
print(f"{'='*60}")
print(f"Value: {value}")
print(f"Size: {sys.getsizeof(value)} bytes")
print()
print(f"First 32 bytes:")
print(format_hex_dump(memory))
print()
# Example 2: Read a string's memory
text: str = "hello"
memory_str: bytes = safe_memory_read(text, 64)
print(f"Reading String Memory")
print(f"{'='*60}")
print(f"String: {text!r}")
print(f"Size: {sys.getsizeof(text)} bytes")
print()
print(f"First 64 bytes:")
print(format_hex_dump(memory_str))
Interpreting Raw Bytes#
When you read raw memory, you get bytes. But what do they mean?
You need to know:
Endianness: Little-endian (Intel) vs big-endian
Data types: Are these bytes an int? A pointer? A float?
Structure layout: Where does each field start?
def read_int64_le(data: bytes, offset: int = 0) -> int:
"""
Read a 64-bit little-endian integer from bytes.
Args:
data: Byte array
offset: Starting offset (default: 0)
Returns:
Integer value
Example:
>>> data = b'\x2a\x00\x00\x00\x00\x00\x00\x00'
>>> read_int64_le(data)
42
"""
return int.from_bytes(data[offset:offset+8], byteorder='little', signed=True)
def read_uint32_le(data: bytes, offset: int = 0) -> int:
"""Read a 32-bit little-endian unsigned integer from bytes."""
return int.from_bytes(data[offset:offset+4], byteorder='little', signed=False)
def read_uint64_le(data: bytes, offset: int = 0) -> int:
"""Read a 64-bit little-endian unsigned integer from bytes."""
return int.from_bytes(data[offset:offset+8], byteorder='little', signed=False)
# Test on integer memory
test_value: int = 12345
memory: bytes = safe_memory_read(test_value, 32)
print(f"Manual Memory Interpretation")
print(f"{'='*60}")
print(f"Value: {test_value}")
print()
# Parse PyLongObject fields manually
ob_refcnt: int = read_int64_le(memory, 0)
ob_type: int = read_uint64_le(memory, 8)
ob_size: int = read_int64_le(memory, 16)
ob_digit0: int = read_uint32_le(memory, 24)
print(f"Manually parsed fields:")
print(f" ob_refcnt (offset 0): {ob_refcnt}")
print(f" ob_type (offset 8): {ob_type:#x}")
print(f" ob_size (offset 16): {ob_size}")
print(f" ob_digit[0] (offset 24): {ob_digit0}")
print()
# Compare with ctypes.Structure approach
pylong_struct: PyLongObjectSimple = PyLongObjectSimple.from_address(id(test_value))
print(f"Using ctypes.Structure:")
print(f" ob_refcnt: {pylong_struct.ob_base.ob_base.ob_refcnt}")
print(f" ob_type: {pylong_struct.ob_base.ob_base.ob_type:#x}")
print(f" ob_size: {pylong_struct.ob_base.ob_size}")
print(f" ob_digit[0]: {pylong_struct.ob_digit[0]}")
print()
print(f"✓ Both methods produce identical results!")
5.5 Following Pointers and Pointer Arithmetic#
Understanding Pointers in Python#
Every Python object can reference other objects. These references are pointers - memory addresses pointing to other objects.
For example:
A list contains pointers to its elements
An object’s
ob_typefield points to its type objectA class instance’s
__dict__points to a dictionary
Let’s learn to follow these pointers and inspect the objects they point to.
class PyListObject(ctypes.Structure):
"""
Simplified PyListObject.
From CPython source (Include/cpython/listobject.h):
typedef struct {
PyVarObject ob_base;
PyObject **ob_item; // Pointer to array of pointers
Py_ssize_t allocated;
} PyListObject;
"""
_fields_: ClassVar[list[tuple[str, type[ctypes._CData]]]] = [
("ob_base", PyVarObjectManual),
("ob_item", ctypes.POINTER(ctypes.c_void_p)), # Array of pointers
("allocated", ctypes.c_ssize_t),
]
# Create a list
my_list: list[int] = [100, 200, 300]
list_addr: int = id(my_list)
# Cast to PyListObject
pylist: PyListObject = PyListObject.from_address(list_addr)
print(f"Following Pointers in a List")
print(f"{'='*60}")
print(f"List: {my_list}")
print(f"List address: {list_addr:#x}")
print()
print(f"PyListObject fields:")
print(f" ob_refcnt: {pylist.ob_base.ob_base.ob_refcnt}")
print(f" ob_size (length): {pylist.ob_base.ob_size}")
print(f" ob_item (array pointer): {ctypes.cast(pylist.ob_item, ctypes.c_void_p).value:#x}")
print(f" allocated: {pylist.allocated}")
print()
# Follow pointers to list elements
print(f"Following pointers to elements:")
for i in range(min(pylist.ob_base.ob_size, len(my_list))):
elem_ptr: int = pylist.ob_item[i]
print(f" [{i}] pointer: {elem_ptr:#x}")
# Cast to PyLongObject and read value
if elem_ptr != 0: # Check for NULL pointer
pylong_elem: PyLongObjectSimple = PyLongObjectSimple.from_address(elem_ptr)
value: int = pylong_elem.ob_digit[0]
print(f" value: {value}")
print(f" refcount: {pylong_elem.ob_base.ob_base.ob_refcnt}")
print()
print(f"✓ We successfully followed pointers from list to its elements!")
Visualizing Pointer Relationships#
List Object @ 0x12345000
┌──────────────────────────────┐
│ PyListObject │
│ ob_size: 3 │
│ allocated: 4 │
│ ob_item: 0xABCD0000 ────────┼──┐
└──────────────────────────────┘ │
│
Element Array @ 0xABCD0000 ◄──┘
┌──────────────────────────────┐
│ [0] → 0x56780000 ────────────┼────┐
│ [1] → 0x56780100 ────────────┼──┐ │
│ [2] → 0x56780200 ────────────┼┐ │ │
│ [3] → NULL (unused) ││ │ │
└──────────────────────────────┘│ │ │
│ │ │
Integer 100 @ 0x56780000 ◄───┘ │ │
Integer 200 @ 0x56780100 ◄─────┘ │
Integer 300 @ 0x56780200 ◄───────┘
5.6 Practical Project 1: PyObject Header Viewer#
Difficulty: ⭐ Beginner Time: 30 minutes Goal: Build a tool that displays PyObject header information for any Python object
Project Description#
You’ll create a comprehensive inspector that:
Takes any Python object as input
Reads its PyObject/PyVarObject header
Displays all fields in a formatted table
Works with any object type
Implementation#
@dataclass
class HeaderInfo:
"""Information extracted from PyObject header."""
address: int
refcount: int
type_address: int
type_name: str
size_bytes: int
ob_size: int | None = None
python_len: int | None = None
def inspect_pyobject_header(obj: Any) -> HeaderInfo:
"""
Inspect PyObject header fields.
Args:
obj: Any Python object to inspect
Returns:
HeaderInfo with all details
"""
addr = id(obj)
header = PyObjectManual.from_address(addr)
# Try to get ob_size for variable objects
ob_size_value: int | None = None
python_len_value: int | None = None
try:
var_header = PyVarObjectManual.from_address(addr)
ob_size_value = var_header.ob_size
python_len_value = len(obj) if hasattr(obj, "__len__") else None
except Exception:
pass
return HeaderInfo(
address=addr,
refcount=header.ob_refcnt,
type_address=header.ob_type,
type_name=type(obj).__name__,
size_bytes=sys.getsizeof(obj),
ob_size=ob_size_value,
python_len=python_len_value,
)
def display_header_info(obj: Any) -> None:
"""
Display object header information in a formatted table.
Args:
obj: Python object to inspect
"""
info = inspect_pyobject_header(obj)
print(f"\nPyObject Header: {repr(obj)[:50]}")
print("=" * 60)
print(f"Type: {info.type_name}")
print(f"Address: {info.address:#x}")
print(f"Refcount: {info.refcount}")
print(f"Type Pointer: {info.type_address:#x}")
print(f"Size: {info.size_bytes} bytes")
if info.ob_size is not None:
print(f"ob_size: {info.ob_size}")
if info.python_len is not None:
print(f"len(): {info.python_len}")
# Test on various objects
test_objects = [
42, # Small int (cached)
1000, # Large int (not cached)
"hello", # String
[1, 2, 3], # List
(1, 2, 3), # Tuple
{"a": 1}, # Dict
]
print("PROJECT 1: PyObject Header Viewer")
print("=" * 60)
for obj in test_objects:
display_header_info(obj)
Challenge
Extend this project:
Add comparison mode to show headers side-by-side
Track refcount changes through operations
Follow the
ob_typepointer to explore type relationships
5.7 Practical Project 2: Reference Count Tracker#
Difficulty: ⭐⭐ Intermediate Time: 35 minutes Goal: Build a system that tracks reference count changes over time
Project Description#
Monitor how different operations affect an object’s reference count, helping understand Python’s memory management.
Implementation#
@dataclass
class RefcountSnapshot:
"""Snapshot of an object's reference count at a moment in time."""
timestamp: float
refcount: int
operation: str
class RefcountTracker:
"""Track reference count changes for a Python object."""
def __init__(self, obj: Any) -> None:
"""Initialize tracker with an object to monitor."""
self.obj: Any = obj
self.snapshots: list[RefcountSnapshot] = []
self.baseline_refcount: int = sys.getrefcount(obj) - 1
self.snapshot("Initial state")
def current_refcount(self) -> int:
"""Get current reference count (adjusted)."""
return sys.getrefcount(self.obj) - 1
def snapshot(self, operation: str) -> RefcountSnapshot:
"""Take a snapshot of current reference count."""
import time
snap = RefcountSnapshot(
timestamp=time.time(),
refcount=self.current_refcount(),
operation=operation,
)
self.snapshots.append(snap)
return snap
def delta(self, index: int = -1) -> int:
"""Get change in refcount since previous snapshot."""
if len(self.snapshots) < 2:
return 0
current = self.snapshots[index]
previous = self.snapshots[index - 1]
return current.refcount - previous.refcount
def report(self) -> None:
"""Generate a report of reference count changes."""
print("\nREFERENCE COUNT TRACKING REPORT")
print("=" * 80)
print(f"Object: {repr(self.obj)[:60]}")
print(f"Baseline refcount: {self.baseline_refcount}")
print(f"Current refcount: {self.current_refcount()}")
print(f"Total change: {self.current_refcount() - self.baseline_refcount:+d}")
print(f"\nSnapshots: {len(self.snapshots)}")
print("-" * 80)
print(f"{'#':<4} {'Refcount':<10} {'Delta':<8} {'Operation':<50}")
print("-" * 80)
for i, snap in enumerate(self.snapshots):
delta = self.delta(i) if i > 0 else 0
delta_str = f"{delta:+d}" if i > 0 else "--"
print(f"{i:<4} {snap.refcount:<10} {delta_str:<8} {snap.operation[:50]}")
print("=" * 80)
# Demo
print("PROJECT 2: Reference Count Tracker")
print("=" * 60)
obj = [1, 2, 3]
tracker = RefcountTracker(obj)
# Create references
refs: list[Any] = []
refs.append(obj)
tracker.snapshot("refs.append(obj)")
refs.append(obj)
tracker.snapshot("refs.append(obj) again")
refs.pop()
tracker.snapshot("refs.pop()")
tracker.report()
5.8 Practical Project 3: List Growth Visualizer#
Difficulty: ⭐⭐⭐ Advanced Time: 45 minutes Goal: Visualize how Python lists grow their internal arrays
Project Description#
Build a comprehensive tool that shows Python’s over-allocation strategy, tracking reallocations and capacity management.
Implementation#
@dataclass
class GrowthEvent:
"""Record of a list growth event."""
operation: str
length_before: int
length_after: int
allocated_before: int
allocated_after: int
@property
def reallocated(self) -> bool:
"""Check if capacity changed."""
return self.allocated_after != self.allocated_before
def inspect_list_internals(lst: list[Any]) -> dict[str, Any]:
"""Inspect internal structure of a Python list."""
addr = id(lst)
list_obj = PyListObject.from_address(addr)
length = list_obj.ob_base.ob_size
allocated = list_obj.allocated
return {
"length": length,
"allocated": allocated,
"load_factor": length / allocated if allocated > 0 else 0.0,
}
class ListGrowthVisualizer:
"""Track and visualize how Python lists grow."""
def __init__(self) -> None:
self.lst: list[Any] = []
self.events: list[GrowthEvent] = []
self._last_info = inspect_list_internals(self.lst)
def _record_event(self, operation: str) -> None:
"""Record a growth event."""
current_info = inspect_list_internals(self.lst)
event = GrowthEvent(
operation=operation,
length_before=self._last_info["length"],
length_after=current_info["length"],
allocated_before=self._last_info["allocated"],
allocated_after=current_info["allocated"],
)
if (event.length_after != event.length_before or
event.allocated_after != event.allocated_before):
self.events.append(event)
self._last_info = current_info
def track_append(self, item: Any) -> None:
"""Append item and track growth."""
self.lst.append(item)
self._record_event(f"append({repr(item)[:20]})")
def report(self) -> None:
"""Generate comprehensive growth report."""
print("\nLIST GROWTH TRACKING REPORT")
print("=" * 100)
print(f"Final length: {len(self.lst)}")
print(f"Final capacity: {self._last_info['allocated']}")
print(f"Load factor: {self._last_info['load_factor']:.2%}")
print(f"\nTotal events: {len(self.events)}")
print(f"Reallocations: {sum(1 for e in self.events if e.reallocated)}")
print("-" * 100)
print(f"{'#':<4} {'Operation':<30} {'Length':<12} {'Capacity':<12} {'Realloc?':<10}")
print("-" * 100)
for i, event in enumerate(self.events[:20]): # First 20
len_str = f"{event.length_before}→{event.length_after}"
cap_str = f"{event.allocated_before}→{event.allocated_after}"
realloc_str = "YES" if event.reallocated else "no"
print(f"{i:<4} {event.operation:<30} {len_str:<12} {cap_str:<12} {realloc_str:<10}")
if len(self.events) > 20:
print(f"... and {len(self.events) - 20} more events")
print("=" * 100)
# Demo
print("PROJECT 3: List Growth Visualizer")
print("=" * 60)
viz = ListGrowthVisualizer()
for i in range(50):
viz.track_append(i)
viz.report()
Python’s Growth Strategy
Python uses the formula: new_allocated = (size >> 3) + (size < 9 ? 3 : 6) + size
This gives roughly 12.5% over-allocation, balancing memory efficiency with reallocation frequency.
5.9 Integration with omnivault.utils.memory#
Throughout this tutorial, we’ve built custom ctypes code. Let’s integrate with the production-ready omnivault.utils.memory utilities:
from omnivault.utils.memory import (
MemoryInspector,
track_memory,
)
print("Integration with omnivault.utils.memory")
print("=" * 60)
# Example: Combine MemoryInspector with custom structures
value: int = 12345
inspector = MemoryInspector(value)
# High-level view
print("High-level inspection (MemoryInspector):")
print(inspector.summary())
print()
# Low-level details with custom PyLongObject
pylong: PyLongObjectSimple = PyLongObjectSimple.from_address(id(value))
print("Low-level inspection (custom PyLongObject):")
print(f" Digit value: {pylong.ob_digit[0]}")
print(f" Digit count: {pylong.ob_base.ob_size}")
5.10 Safety Guidelines and Best Practices#
Danger
CRITICAL SAFETY RULES
1. Never read beyond object boundaries
# BAD: Will likely crash
obj = 42
memory = ctypes.string_at(id(obj), 1000000) # Way too many bytes!
# GOOD: Use sys.getsizeof()
memory = ctypes.string_at(id(obj), sys.getsizeof(obj))
2. Check for NULL pointers before dereferencing
# BAD: Might crash if pointer is NULL
elem_ptr = pylist.ob_item[i]
value = PyLongObject.from_address(elem_ptr) # Crash if NULL!
# GOOD: Check first
elem_ptr = pylist.ob_item[i]
if elem_ptr != 0: # NULL check
value = PyLongObject.from_address(elem_ptr)
3. Keep references to inspected objects
# BAD: Object may be garbage collected
inspector = MemoryInspector(42 + 1) # Temporary object!
data = inspector.read_bytes(0, 32) # May crash
# GOOD: Keep strong reference
obj = 42 + 1 # Store the object
inspector = MemoryInspector(obj)
data = inspector.read_bytes(0, 32) # Safe
When to Use ctypes Inspection#
Good use cases:
Learning about CPython internals ✅
Debugging memory issues ✅
Understanding performance characteristics ✅
Building educational tools ✅
Bad use cases:
Production code (too fragile) ❌
Critical applications (unsafe) ❌
Cross-platform tools (layout varies) ❌
Instead, use:
sys.getsizeof()for sizesys.getrefcount()for refcounttracemallocfor memory profilingomnivault.utils.memoryfor safe inspection
5.11 Summary and Next Steps#
What You Learned#
ctypes.Structure basics: Defining C-compatible structures in Python
Memory reading: Using
ctypes.string_at()andfrom_address()CPython structures: PyObject, PyVarObject, PyLongObject, PyListObject
Pointer following: Navigating object references in memory
Practical tools: Built 3 complete inspection tools
Safety: Critical rules for preventing crashes
Key Takeaways#
ctypes is a bridge between Python and C memory
Everything has a structure - Python objects are C structs
Following pointers reveals object relationships
Safety is critical - one mistake can crash the interpreter
Use production tools for real applications
Skills Acquired#
You can now:
✅ Define custom ctypes structures matching CPython internals
✅ Read and interpret raw memory bytes
✅ Navigate complex object hierarchies
✅ Build custom memory inspection tools
✅ Integrate with omnivault.utils.memory utilities
✅ Understand the dangers and limitations
Next Steps#
Section 6: Optimization Techniques will teach you:
When to optimize (and when not to)
Using
__slots__to reduce memoryGenerators vs lists for memory efficiency
NumPy arrays for numerical data
Memory profiling best practices
Continue to Section 6: Optimization Techniques →
Additional Resources#
CPython Source Code#
Include/object.h - PyObject definitions
Include/longintrepr.h - PyLongObject
Include/cpython/listobject.h - PyListObject