Skip to content

Commit 13e458b

Browse files
committed
Move functional API to suitesparse_graphblas.api subpackage and add modules for all GraphBLAS types
Reorganize the functional API (matrix, vector, scalar, iterator, io) into a dedicated api subpackage, separating it from the low-level CFFI binding layer. Users can now import via `from suitesparse_graphblas import api` with backward- compatible re-exports preserving all existing import paths. Add new API modules for all remaining GraphBLAS types: GrB_Type, UnaryOp, BinaryOp, IndexUnaryOp, IndexBinaryOp, Monoid, Semiring, Descriptor, Context, SelectOp, Container, and Global. Extract global_option functions into their own module and deduplicate _capture_c_output into api/utils.py. Update CLAUDE.md with Docker-based testing instructions and configure pytest to collect doctests via --doctest-modules for comprehensive test coverage.
1 parent 442c63a commit 13e458b

28 files changed

Lines changed: 2725 additions & 133 deletions

CLAUDE.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this package is
6+
7+
`suitesparse-graphblas` is a low-level Python CFFI binding around the C library
8+
[SuiteSparse:GraphBLAS](https://github.com/DrTimothyAldenDavis/GraphBLAS). It exposes the
9+
raw `ffi` and `lib` symbols and, on top of them, a small **functional API** in
10+
`suitesparse_graphblas.matrix`, `suitesparse_graphblas.vector`, and
11+
`suitesparse_graphblas.scalar`. These three modules are the **main entry point for users
12+
of this library directly** — they are module-level functions operating on opaque CFFI
13+
handles, e.g. `A = matrix.new(lib.GrB_BOOL, 3, 3); matrix.set_bool(A, True, 2, 2)`. Higher-level
14+
syntax wrappers ([python-graphblas](https://github.com/python-graphblas/python-graphblas)
15+
and [pygraphblas](https://github.com/Graphegon/pygraphblas)) build on top of this same
16+
package for users who want a more Pythonic, OO-style interface.
17+
18+
The currently targeted SuiteSparse:GraphBLAS version is pinned in `GB_VERSION.txt`.
19+
20+
## Building from source
21+
22+
Building requires a working SuiteSparse:GraphBLAS C library on the system. Point at it via
23+
`GraphBLAS_ROOT` (must contain `include/GraphBLAS.h` and `lib/`):
24+
25+
```bash
26+
export GraphBLAS_ROOT="/path/to/graphblas" # or `$(brew --prefix suitesparse)` on macOS
27+
pip install -e . --no-deps # editable dev install
28+
```
29+
30+
If `GraphBLAS_ROOT` is unset, the build falls back to:
31+
- `C:\GraphBLAS` on Windows
32+
- `/usr/local` if `/usr/local/include/suitesparse` exists (the path used by `suitesparse.sh`)
33+
- `sys.prefix` (works for conda-installed `graphblas`)
34+
35+
The CI build uses conda-forge `graphblas=$(cat GB_VERSION.txt)`. To build SuiteSparse from
36+
source instead, run `bash suitesparse.sh refs/tags/$(cat GB_VERSION.txt).0`. That script also
37+
honors `SUITESPARSE_FAST_BUILD` / `SUITESPARSE_FASTEST_BUILD` env vars to disable many type
38+
specializations for much faster local builds.
39+
40+
## Tests, lint, and other commands
41+
42+
Testing requires the compiled CFFI extension and the SuiteSparse:GraphBLAS C library, so
43+
tests should be run inside the Docker container. Build the image once (this compiles
44+
GraphBLAS from source and takes several minutes), then run tests against it:
45+
46+
```bash
47+
# Build the test image (uses the psg stage which includes pytest)
48+
docker build --target psg \
49+
--build-arg SUITESPARSE=v$(cat GB_VERSION.txt) \
50+
--build-arg VERSION=99.0.0.0 \
51+
-t psg-test .
52+
53+
# Run the full test suite (unit tests + all doctests)
54+
docker run --rm -w /tmp psg-test pytest --pyargs suitesparse_graphblas --doctest-modules -v
55+
56+
# Run only the unit tests (no doctests)
57+
docker run --rm -w /tmp psg-test pytest --pyargs suitesparse_graphblas.tests -v
58+
59+
# Run a single test file
60+
docker run --rm -w /tmp psg-test pytest --pyargs suitesparse_graphblas.tests.test_scalar -v
61+
62+
# Run by test name substring
63+
docker run --rm -w /tmp psg-test pytest --pyargs suitesparse_graphblas --doctest-modules -k test_print_jit_config -v
64+
65+
# Run linters/formatters (locally, no C library needed)
66+
pre-commit run --all-files
67+
```
68+
69+
Rebuild the Docker image after making changes — the `ADD . /psg` layer picks up the
70+
current working tree. The SuiteSparse compilation layer is cached so rebuilds are fast.
71+
72+
`conftest.py` calls `suitesparse_graphblas.initialize()` once per session — `GrB_init` may
73+
only be called once per process, so `test_initialize.py` is run as a separate process in CI:
74+
75+
```bash
76+
docker run --rm -w /tmp psg-test python3 -m suitesparse_graphblas.tests.test_initialize
77+
```
78+
79+
Coverage runs in CI use `CYTHON_COVERAGE=true` so the Cython `utils.pyx` extension is
80+
recompiled with line tracing.
81+
82+
## Architecture: how the binding is generated and assembled
83+
84+
There are two layers of generated code, and understanding them is essential before touching
85+
anything related to types, defines, or the FFI surface.
86+
87+
### 1. Header generation — `suitesparse_graphblas/create_headers.py`
88+
89+
This script regenerates **`suitesparse_graphblas.h`**, **`suitesparse_graphblas_no_complex.h`**,
90+
and **`source.c`** from an upstream `GraphBLAS.h`. It:
91+
92+
- Copies `GraphBLAS.h` from the install, runs the C preprocessor (using pycparser's
93+
`fake_libc_include`), and parses the result with pycparser.
94+
- Emits a cleaned-up header that cffi can `cdef()`, plus a complex-free variant for
95+
platforms (notably MSVC) where `_Complex` types don't work.
96+
- Manually tracks `DEFINES`, `CHAR_DEFINES`, `IGNORE_DEFINES`, and `DEPRECATED` sets.
97+
**When updating to a new SuiteSparse:GraphBLAS version, these lists are the things most
98+
likely to need editing.** New macros, new deprecations, or removed symbols all flow
99+
through here.
100+
- CI runs this script and `git diff --exit-code` to fail the build if the committed headers
101+
drift from upstream. Re-running it locally and committing the result is the standard fix.
102+
103+
### 2. CFFI compilation — `build_graphblas_cffi.py`
104+
105+
`ffibuilder` calls `set_source()` with `source.c` and `cdef()` with `suitesparse_graphblas.h`
106+
to produce the compiled extension `suitesparse_graphblas._graphblas` (which exposes `ffi`
107+
and `lib`). On Windows it instead emits a `_graphblas.c` file and runs a textual patch
108+
(`float _Complex``_Fcomplex`, `double _Complex``_Dcomplex`, `-DGxB_HAVE_COMPLEX_MSVC`)
109+
because cffi cannot represent MSVC's complex types — see `get_extension()` for the patching
110+
logic. `setup.py` chooses between the cffi-driven and Extension-driven paths based on
111+
`build_graphblas_cffi.is_win`.
112+
113+
`setup.py` also cythonizes any `*.pyx` under `suitesparse_graphblas/` (currently
114+
`utils.pyx`). When Cython is unavailable, it falls back to checked-in `*.c` files; the
115+
build will refuse to proceed if any are missing.
116+
117+
### 3. Python layer (`suitesparse_graphblas/__init__.py` and friends)
118+
119+
The package re-exports `ffi`/`lib` from the compiled extension and adds only thin helpers:
120+
121+
- `initialize(blocking=False, memory_manager="numpy")` — must be called exactly once before
122+
any GraphBLAS calls. The `numpy` memory manager routes allocation through
123+
`PyDataMem_NEW`/`FREE` (defined in `utils.pyx::call_gxb_init`) so buffers can be claimed
124+
zero-copy by NumPy and tracked by `tracemalloc`.
125+
- `check_status(obj, info)` — central error handler. Maps `GrB_Info` codes to exception
126+
classes in `exceptions.py` and pulls the human-readable message via the type-specific
127+
`*_error()` function, looked up by cdata cname in `_error_func_lookup`.
128+
- `vararg(val)` — a workaround for variadic GraphBLAS calls on `osx-arm64` and `ppc64le`
129+
where ARM64 calling conventions force variadic args onto the stack. Prefer the
130+
non-variadic typed variants (e.g. `GxB_Matrix_Option_get_INT32`) when they exist.
131+
- `libget(name)` — fallback that retries a `GrB_*` lookup as `GxB_*` when SuiteSparse moves
132+
a symbol between standard and extension namespaces.
133+
- `burble` — context manager / global toggle for `GxB_BURBLE` diagnostic output.
134+
- `matrix.py`, `vector.py`, `scalar.py`**the functional API** of the package, and the
135+
primary user-facing surface for code that uses `suitesparse-graphblas` directly. Each
136+
module follows the same convention: `<module>.new(...)` returns an `ffi.gc`-managed
137+
cdata handle (`GrB_Matrix*`, `GrB_Vector*`, or `GxB_Scalar*`) and every other function
138+
takes that handle as its first argument and routes errors through `check_status`. The
139+
design is deliberately functional rather than class-based so the same handles can be
140+
passed through higher-level wrappers without object-identity friction. When adding
141+
features to this package, this is the layer where new user-facing helpers belong.
142+
- `io/serialize.py`, `io/binary.py` — supporting I/O helpers (compressed serialize /
143+
deserialize, binary format read/write). `matrix.py` and `vector.py` already re-export
144+
`serialize` / `deserialize` from `io/serialize.py` so callers can reach them as
145+
`matrix.serialize(A)` etc.
146+
147+
### 4. `utils.pyx` and free-threading
148+
149+
The Cython module exists primarily to (a) call `GxB_init` with NumPy's allocators by
150+
casting the cffi function pointer through `uintptr_t` into a real C function pointer, and
151+
(b) wrap NumPy buffers around GraphBLAS-allocated memory with `claim_buffer` /
152+
`unclaim_buffer`, transferring ownership of the underlying allocation to NumPy.
153+
154+
The file is marked `freethreading_compatible=True`. The package does nothing special for
155+
free-threading itself — correctness depends on SuiteSparse:GraphBLAS being thread-safe,
156+
which it is required to be.
157+
158+
## Style and tooling
159+
160+
- Black, isort, flake8 (config in `.flake8`, line length 100, double quotes), pyupgrade
161+
(`--py311-plus`), autoflake, shellcheck — all wired through `pre-commit`. Run
162+
`pre-commit run --all-files` before pushing.
163+
- `pre-commit` also blocks direct commits to `main`.
164+
- Python ≥ 3.11. NumPy ≥ 2.0 is required at build time (CFFI extension), ≥ 1.24 at runtime.
165+
- Generated headers (`suitesparse_graphblas.h`, `suitesparse_graphblas_no_complex.h`,
166+
`source.c`) are checked in and **must be regenerated via `create_headers.py`** rather
167+
than hand-edited. CI enforces this.

pyproject.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ test = [
8383
[tool.setuptools]
8484
packages = [
8585
'suitesparse_graphblas',
86+
'suitesparse_graphblas.api',
87+
'suitesparse_graphblas.api.io',
8688
'suitesparse_graphblas.tests',
8789
'suitesparse_graphblas.io',
8890
]
@@ -124,5 +126,9 @@ exclude_lines = [
124126
"raise NotImplementedError",
125127
]
126128

127-
[tool.pytest]
128-
testpaths = ["suitesparse_graphblas/tests"]
129+
[tool.pytest.ini_options]
130+
testpaths = ["suitesparse_graphblas"]
131+
addopts = "--doctest-modules"
132+
# NOTE: When running in Docker, use -w /tmp and --pyargs to avoid the source
133+
# directory shadowing the installed package (compiled extensions aren't in the
134+
# source tree). See CLAUDE.md for the full Docker test commands.

suitesparse_graphblas/__init__.py

Lines changed: 13 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -108,86 +108,6 @@ def libget(name):
108108
raise
109109

110110

111-
# ---------------------------------------------------------------------------
112-
# Global option get/set
113-
# ---------------------------------------------------------------------------
114-
115-
116-
def global_option_get_int32(field):
117-
"""Get a global option as an int32.
118-
119-
>>> global_option_get_int32(lib.GxB_BURBLE) in (0, 1)
120-
True
121-
122-
"""
123-
val = ffi.new("int32_t*")
124-
info = lib.GxB_Global_Option_get_INT32(field, val)
125-
if info != lib.GrB_SUCCESS:
126-
raise _error_code_lookup.get(info, RuntimeError)(
127-
f"GxB_Global_Option_get_INT32 failed with info={info}"
128-
)
129-
return val[0]
130-
131-
132-
def global_option_set_int32(field, value):
133-
"""Set a global option from an int32.
134-
135-
>>> global_option_set_int32(lib.GxB_BURBLE, 0)
136-
137-
"""
138-
info = lib.GxB_Global_Option_set_INT32(field, ffi.cast("int32_t", value))
139-
if info != lib.GrB_SUCCESS:
140-
raise _error_code_lookup.get(info, RuntimeError)(
141-
f"GxB_Global_Option_set_INT32 failed with info={info}"
142-
)
143-
144-
145-
def global_option_get_fp64(field):
146-
"""Get a global option as a float64.
147-
148-
>>> isinstance(global_option_get_fp64(lib.GxB_HYPER_SWITCH), float)
149-
True
150-
151-
"""
152-
val = ffi.new("double*")
153-
info = lib.GxB_Global_Option_get_FP64(field, val)
154-
if info != lib.GrB_SUCCESS:
155-
raise _error_code_lookup.get(info, RuntimeError)(
156-
f"GxB_Global_Option_get_FP64 failed with info={info}"
157-
)
158-
return val[0]
159-
160-
161-
def global_option_set_fp64(field, value):
162-
"""Set a global option from a float64.
163-
164-
>>> default = global_option_get_fp64(lib.GxB_HYPER_SWITCH)
165-
>>> global_option_set_fp64(lib.GxB_HYPER_SWITCH, default)
166-
167-
"""
168-
info = lib.GxB_Global_Option_set_FP64(field, ffi.cast("double", value))
169-
if info != lib.GrB_SUCCESS:
170-
raise _error_code_lookup.get(info, RuntimeError)(
171-
f"GxB_Global_Option_set_FP64 failed with info={info}"
172-
)
173-
174-
175-
def global_option_get_char(field):
176-
"""Get a global option as a string.
177-
178-
>>> 'SuiteSparse:GraphBLAS' in global_option_get_char(lib.GxB_LIBRARY_NAME)
179-
True
180-
181-
"""
182-
val = ffi.new("char**")
183-
info = lib.GxB_Global_Option_get_CHAR(field, val)
184-
if info != lib.GrB_SUCCESS:
185-
raise _error_code_lookup.get(info, RuntimeError)(
186-
f"GxB_Global_Option_get_CHAR failed with info={info}"
187-
)
188-
return ffi.string(val[0]).decode()
189-
190-
191111
bool_types = frozenset((lib.GrB_BOOL,))
192112

193113
signed_integer_types = frozenset(
@@ -268,6 +188,9 @@ def global_option_get_char(field):
268188
"struct GB_Matrix_opaque *": lib.GrB_Matrix_error,
269189
"struct GB_Vector_opaque *": lib.GrB_Vector_error,
270190
"struct GB_Descriptor_opaque *": lib.GrB_Descriptor_error,
191+
"struct GB_Context_opaque *": lib.GxB_Context_error,
192+
"struct GB_IndexUnaryOp_opaque *": lib.GrB_IndexUnaryOp_error,
193+
"struct GB_IndexBinaryOp_opaque *": lib.GxB_IndexBinaryOp_error,
271194
}
272195

273196

@@ -383,3 +306,13 @@ def __repr__(self):
383306

384307

385308
burble = burble()
309+
310+
# Backward-compatible re-exports: functional API moved to suitesparse_graphblas.api
311+
from suitesparse_graphblas.api import iterator, matrix, scalar, vector # noqa: E402,F401
312+
from suitesparse_graphblas.api.global_options import ( # noqa: E402,F401
313+
global_option_get_char,
314+
global_option_get_fp64,
315+
global_option_get_int32,
316+
global_option_set_fp64,
317+
global_option_set_int32,
318+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from suitesparse_graphblas.api import (
2+
binaryop,
3+
container,
4+
context,
5+
descriptor,
6+
global_,
7+
global_options,
8+
grb_type,
9+
indexbinaryop,
10+
indexunaryop,
11+
iterator,
12+
matrix,
13+
monoid,
14+
scalar,
15+
semiring,
16+
selectop,
17+
unaryop,
18+
vector,
19+
)
20+
from suitesparse_graphblas.api import io # noqa: F401

0 commit comments

Comments
 (0)