tinycwrap 0.0.7__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinycwrap
3
+ Version: 0.1.1
4
+ Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
+ Author: TinyCWrap Contributors
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=1.24
9
+ Requires-Dist: cffi>=1.15
10
+
11
+ # tinycwrap
12
+
13
+ TinyCWrap is a lightweight helper around CFFI that:
14
+
15
+ - compiles one or more C sources into a shared library,
16
+ - auto-generates the `cdef` from your function/struct declarations,
17
+ - builds NumPy-friendly Python wrappers that take/return arrays and structs,
18
+ - optionally hot-reloads when the C file changes (inside IPython).
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ pip install tinycwrap
24
+ ```
25
+
26
+ Write a small C file (only non-`static` functions are exported):
27
+
28
+ ```c
29
+ /* kernels.c */
30
+ double dot(const double *x, const double *y, int len_x)
31
+ /* Return dot product between x and y
32
+ Contract: len_x=len(x);
33
+ */
34
+ {
35
+ double acc = 0.0;
36
+ for (int i = 0; i < len_x; ++i)
37
+ acc += x[i] * y[i];
38
+ return acc;
39
+ }
40
+ ```
41
+
42
+ Wrap and call it from Python:
43
+
44
+ ```python
45
+ import numpy as np
46
+ from tinycwrap import CModule
47
+
48
+ cm = CModule("kernels.c") # builds the shared library, creates wrappers
49
+
50
+ x = np.arange(5, dtype=np.float64)
51
+ y = np.ones_like(x)
52
+
53
+ cm.dot(x, y) # -> 10.0, len_x auto-filled by the contract
54
+ print(cm.dot.__doc__) # docstring comes from the C comment
55
+ ```
56
+
57
+ See `examples/` and `tests/` for more involved cases.
58
+
59
+ ## C coding conventions
60
+
61
+ TinyCWrap infers how to build wrappers from simple C conventions:
62
+
63
+ - **Inputs vs outputs**
64
+ - `const T *arg` -> input NumPy array.
65
+ - `T *arg` -> output/in-place array (prefer naming it `out_*` when possible).
66
+ - `T arg[N]` in the signature -> fixed-size array (input if `const`, otherwise output).
67
+ - Plain scalars (`double`, `int`, ...) -> Python scalars.
68
+ - **Length parameters**: integers such as `len_x`, `n`, `size_x` can be auto-filled if you declare a contract (see below). Otherwise pass them explicitly from Python.
69
+ - **Docstrings**: the block comment immediately after the function header becomes the Python docstring.
70
+ - **Structs**: `typedef struct { ... } Name;` definitions in your headers/sources become Python classes with a `.dtype` and NumPy-backed storage.
71
+ - **Compilation**: extra sources can be passed (`CModule("main.c", "helper.c")`), and extra include dirs via `include_dirs=[...]`. Default compiler flags are `-O3 -shared -fPIC -march=native -mtune=native` plus the NumPy include path.
72
+
73
+ ## Contracts: tell the wrapper how to size things
74
+
75
+ Contracts are declared inside the doc comment with `Contract:` (or `Contracts:`). Separate multiple rules with semicolons. Supported forms:
76
+
77
+ - `len_x=len(x)` — mark `len_x` as the length of array `x`. If you omit `len_x` in Python, the wrapper fills it.
78
+ - `shape(out)=n,m` — allocate `out` with shape `(n, m)`. You can define `n,m=shape(a)` to capture the shape of an input first.
79
+ - `len(out)=len_x` — allocate a 1D output array.
80
+ - `postlen(out)=out_len` — slice the output after the call using an integer pointer result `out_len` (useful when the C code writes less than the allocated length).
81
+ - `own(return)` — the returned pointer is owned by the caller; the wrapper will `free` it after copying to NumPy (requires a `len(return)=...` contract).
82
+
83
+ Examples pulled from the test suite:
84
+
85
+ ```c
86
+ void mat_add(const double *a, const double *b, int n, int m, double *out)
87
+ /* Elementwise addition
88
+ Contract: n,m=shape(a); shape(out)=n,m;
89
+ */
90
+ ```
91
+
92
+ ```c
93
+ void merge_sorted(const double *a, const double *b, int len_a, int len_b,
94
+ double *out, int *out_len)
95
+ /* Merge unique sorted values
96
+ Contract: len_a=len(a); len_b=len(b);
97
+ len(out)=len_a+len_b; postlen(out)=out_len;
98
+ */
99
+ ```
100
+
101
+ ```c
102
+ double *alloc_random_array(int *out_len)
103
+ /* Return a freshly malloc'ed array
104
+ Contract: len(return)=out_len; own(return);
105
+ */
106
+ ```
107
+
108
+ Rules are parsed case-insensitively; whitespace does not matter.
109
+
110
+ ## Python wrapper behavior
111
+
112
+ - **Automatic allocation**: any `out_*` argument defaults to `None` in Python; TinyCWrap allocates it based on contracts, fixed array sizes, or by matching the shape of a related input (`out_x` matches `x` when no contract is present).
113
+ - **Struct pointer outputs**: non-const struct pointers are treated as in/out; when you pass an object/array explicitly, it is mutated in place and not returned. When you pass `None`, TinyCWrap allocates and returns the struct (or struct array).
114
+ - **Optional length arguments**: integer length parameters inferred from contracts default to `None` in the wrapper signature. If you pass them, they are cast to `int`; if not, the expression from the contract is evaluated.
115
+ - **Post contracts**: when a contract uses `postlen(...)` or `post shape(...)`, the wrapper slices/reshapes outputs after the C call using the values written by the C function.
116
+ - **Scalar pointer outputs**: pointers to integer-like types (e.g., `int *out_len`) are returned as plain Python integers alongside other outputs.
117
+ - **Structs**: for `typedef struct` declarations TinyCWrap generates a Python class:
118
+ - fields are accessible as properties backed by a NumPy structured dtype (`Name.dtype`);
119
+ - scalar struct outputs return `Name` objects;
120
+ - struct-array outputs return compact `NameArray` wrappers, with field arrays as properties (`points.x`), scalar items as `Name` objects (`points[0]`), and Python construction as `NameArray(array_like)` or `NameArray(x=..., y=...)`;
121
+ - pointer fields paired with `len_<field>` automatically allocate NumPy arrays when you instantiate the struct with `len_field` or when you pass an array for that field;
122
+ - `_data` holds the underlying structured array, `Name.zeros(n)` returns a raw array of that dtype, and `NameArray.zeros(n)` returns the typed wrapper.
123
+ - **Reloading**: inside IPython, pass `reload=True` (default) to auto-recompile before each cell if the C sources changed.
124
+
125
+ ## Worked examples
126
+
127
+ ### Two outputs and automatic lengths
128
+
129
+ ```c
130
+ void split_vectors(const double *inp, int len_inp,
131
+ double *out_even, double *out_odd)
132
+ /* Split even/odd elements
133
+ Contract: len_inp=len(inp); len(out_even)=len_inp/2; len(out_odd)=len_inp/2;
134
+ */
135
+ ```
136
+
137
+ ```python
138
+ data = np.array([0, 1, 2, 3, 4, 5], dtype=np.float64)
139
+ out_even, out_odd = cm.split_vectors(data) # lengths inferred, arrays allocated
140
+ ```
141
+
142
+ ### Structs and struct arrays
143
+
144
+ ```c
145
+ typedef struct {
146
+ double real;
147
+ double imag;
148
+ } ComplexPair;
149
+
150
+ double complex_magnitude(const ComplexPair *z);
151
+ ```
152
+
153
+ ```python
154
+ cp = cm.ComplexPair(real=3.0, imag=4.0)
155
+ cm.complex_magnitude(cp) # -> 5.0
156
+
157
+ # struct arrays are NumPy dtypes; useful when C expects pointers to arrays of structs
158
+ pairs = cm.ComplexPair.zeros(2)
159
+ pairs["real"] = [1.0, 2.0]
160
+ pairs["imag"] = [0.0, -1.0]
161
+ ```
162
+
163
+ ### Owned return value
164
+
165
+ ```c
166
+ double *alloc_random_array(int *out_len)
167
+ /* Contract: len(return)=out_len; own(return); */
168
+ ```
169
+
170
+ ```python
171
+ arr, n = cm.alloc_random_array() # returns NumPy array, frees the C buffer
172
+ ```
173
+
174
+ ## Tips
175
+
176
+ - Keep function declarations (or headers) visible at the top level; TinyCWrap scans the C files and headers you pass.
177
+ - If a length cannot be inferred from a contract, you must pass it explicitly from Python.
178
+ - Use `cm.<func>.__source__` to inspect the wrapper code if something behaves unexpectedly.
179
+ - For debugging parsed signatures/contracts call `cm._debug_specs()`.
180
+
181
+ That is all you need to start turning small C helpers into ergonomic Python callables.
@@ -0,0 +1,171 @@
1
+ # tinycwrap
2
+
3
+ TinyCWrap is a lightweight helper around CFFI that:
4
+
5
+ - compiles one or more C sources into a shared library,
6
+ - auto-generates the `cdef` from your function/struct declarations,
7
+ - builds NumPy-friendly Python wrappers that take/return arrays and structs,
8
+ - optionally hot-reloads when the C file changes (inside IPython).
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ pip install tinycwrap
14
+ ```
15
+
16
+ Write a small C file (only non-`static` functions are exported):
17
+
18
+ ```c
19
+ /* kernels.c */
20
+ double dot(const double *x, const double *y, int len_x)
21
+ /* Return dot product between x and y
22
+ Contract: len_x=len(x);
23
+ */
24
+ {
25
+ double acc = 0.0;
26
+ for (int i = 0; i < len_x; ++i)
27
+ acc += x[i] * y[i];
28
+ return acc;
29
+ }
30
+ ```
31
+
32
+ Wrap and call it from Python:
33
+
34
+ ```python
35
+ import numpy as np
36
+ from tinycwrap import CModule
37
+
38
+ cm = CModule("kernels.c") # builds the shared library, creates wrappers
39
+
40
+ x = np.arange(5, dtype=np.float64)
41
+ y = np.ones_like(x)
42
+
43
+ cm.dot(x, y) # -> 10.0, len_x auto-filled by the contract
44
+ print(cm.dot.__doc__) # docstring comes from the C comment
45
+ ```
46
+
47
+ See `examples/` and `tests/` for more involved cases.
48
+
49
+ ## C coding conventions
50
+
51
+ TinyCWrap infers how to build wrappers from simple C conventions:
52
+
53
+ - **Inputs vs outputs**
54
+ - `const T *arg` -> input NumPy array.
55
+ - `T *arg` -> output/in-place array (prefer naming it `out_*` when possible).
56
+ - `T arg[N]` in the signature -> fixed-size array (input if `const`, otherwise output).
57
+ - Plain scalars (`double`, `int`, ...) -> Python scalars.
58
+ - **Length parameters**: integers such as `len_x`, `n`, `size_x` can be auto-filled if you declare a contract (see below). Otherwise pass them explicitly from Python.
59
+ - **Docstrings**: the block comment immediately after the function header becomes the Python docstring.
60
+ - **Structs**: `typedef struct { ... } Name;` definitions in your headers/sources become Python classes with a `.dtype` and NumPy-backed storage.
61
+ - **Compilation**: extra sources can be passed (`CModule("main.c", "helper.c")`), and extra include dirs via `include_dirs=[...]`. Default compiler flags are `-O3 -shared -fPIC -march=native -mtune=native` plus the NumPy include path.
62
+
63
+ ## Contracts: tell the wrapper how to size things
64
+
65
+ Contracts are declared inside the doc comment with `Contract:` (or `Contracts:`). Separate multiple rules with semicolons. Supported forms:
66
+
67
+ - `len_x=len(x)` — mark `len_x` as the length of array `x`. If you omit `len_x` in Python, the wrapper fills it.
68
+ - `shape(out)=n,m` — allocate `out` with shape `(n, m)`. You can define `n,m=shape(a)` to capture the shape of an input first.
69
+ - `len(out)=len_x` — allocate a 1D output array.
70
+ - `postlen(out)=out_len` — slice the output after the call using an integer pointer result `out_len` (useful when the C code writes less than the allocated length).
71
+ - `own(return)` — the returned pointer is owned by the caller; the wrapper will `free` it after copying to NumPy (requires a `len(return)=...` contract).
72
+
73
+ Examples pulled from the test suite:
74
+
75
+ ```c
76
+ void mat_add(const double *a, const double *b, int n, int m, double *out)
77
+ /* Elementwise addition
78
+ Contract: n,m=shape(a); shape(out)=n,m;
79
+ */
80
+ ```
81
+
82
+ ```c
83
+ void merge_sorted(const double *a, const double *b, int len_a, int len_b,
84
+ double *out, int *out_len)
85
+ /* Merge unique sorted values
86
+ Contract: len_a=len(a); len_b=len(b);
87
+ len(out)=len_a+len_b; postlen(out)=out_len;
88
+ */
89
+ ```
90
+
91
+ ```c
92
+ double *alloc_random_array(int *out_len)
93
+ /* Return a freshly malloc'ed array
94
+ Contract: len(return)=out_len; own(return);
95
+ */
96
+ ```
97
+
98
+ Rules are parsed case-insensitively; whitespace does not matter.
99
+
100
+ ## Python wrapper behavior
101
+
102
+ - **Automatic allocation**: any `out_*` argument defaults to `None` in Python; TinyCWrap allocates it based on contracts, fixed array sizes, or by matching the shape of a related input (`out_x` matches `x` when no contract is present).
103
+ - **Struct pointer outputs**: non-const struct pointers are treated as in/out; when you pass an object/array explicitly, it is mutated in place and not returned. When you pass `None`, TinyCWrap allocates and returns the struct (or struct array).
104
+ - **Optional length arguments**: integer length parameters inferred from contracts default to `None` in the wrapper signature. If you pass them, they are cast to `int`; if not, the expression from the contract is evaluated.
105
+ - **Post contracts**: when a contract uses `postlen(...)` or `post shape(...)`, the wrapper slices/reshapes outputs after the C call using the values written by the C function.
106
+ - **Scalar pointer outputs**: pointers to integer-like types (e.g., `int *out_len`) are returned as plain Python integers alongside other outputs.
107
+ - **Structs**: for `typedef struct` declarations TinyCWrap generates a Python class:
108
+ - fields are accessible as properties backed by a NumPy structured dtype (`Name.dtype`);
109
+ - scalar struct outputs return `Name` objects;
110
+ - struct-array outputs return compact `NameArray` wrappers, with field arrays as properties (`points.x`), scalar items as `Name` objects (`points[0]`), and Python construction as `NameArray(array_like)` or `NameArray(x=..., y=...)`;
111
+ - pointer fields paired with `len_<field>` automatically allocate NumPy arrays when you instantiate the struct with `len_field` or when you pass an array for that field;
112
+ - `_data` holds the underlying structured array, `Name.zeros(n)` returns a raw array of that dtype, and `NameArray.zeros(n)` returns the typed wrapper.
113
+ - **Reloading**: inside IPython, pass `reload=True` (default) to auto-recompile before each cell if the C sources changed.
114
+
115
+ ## Worked examples
116
+
117
+ ### Two outputs and automatic lengths
118
+
119
+ ```c
120
+ void split_vectors(const double *inp, int len_inp,
121
+ double *out_even, double *out_odd)
122
+ /* Split even/odd elements
123
+ Contract: len_inp=len(inp); len(out_even)=len_inp/2; len(out_odd)=len_inp/2;
124
+ */
125
+ ```
126
+
127
+ ```python
128
+ data = np.array([0, 1, 2, 3, 4, 5], dtype=np.float64)
129
+ out_even, out_odd = cm.split_vectors(data) # lengths inferred, arrays allocated
130
+ ```
131
+
132
+ ### Structs and struct arrays
133
+
134
+ ```c
135
+ typedef struct {
136
+ double real;
137
+ double imag;
138
+ } ComplexPair;
139
+
140
+ double complex_magnitude(const ComplexPair *z);
141
+ ```
142
+
143
+ ```python
144
+ cp = cm.ComplexPair(real=3.0, imag=4.0)
145
+ cm.complex_magnitude(cp) # -> 5.0
146
+
147
+ # struct arrays are NumPy dtypes; useful when C expects pointers to arrays of structs
148
+ pairs = cm.ComplexPair.zeros(2)
149
+ pairs["real"] = [1.0, 2.0]
150
+ pairs["imag"] = [0.0, -1.0]
151
+ ```
152
+
153
+ ### Owned return value
154
+
155
+ ```c
156
+ double *alloc_random_array(int *out_len)
157
+ /* Contract: len(return)=out_len; own(return); */
158
+ ```
159
+
160
+ ```python
161
+ arr, n = cm.alloc_random_array() # returns NumPy array, frees the C buffer
162
+ ```
163
+
164
+ ## Tips
165
+
166
+ - Keep function declarations (or headers) visible at the top level; TinyCWrap scans the C files and headers you pass.
167
+ - If a length cannot be inferred from a contract, you must pass it explicitly from Python.
168
+ - Use `cm.<func>.__source__` to inspect the wrapper code if something behaves unexpectedly.
169
+ - For debugging parsed signatures/contracts call `cm._debug_specs()`.
170
+
171
+ That is all you need to start turning small C helpers into ergonomic Python callables.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tinycwrap"
7
- version = "0.0.7"
7
+ version = "0.1.1"
8
8
  description = "Lightweight C-to-Python wrapper generator using CFFI and NumPy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -33,6 +33,20 @@ def test_scale_explicit_output(cm):
33
33
  np.testing.assert_allclose(scaled_explicit, x * 2.0)
34
34
 
35
35
 
36
+ def test_mat_add_shape_contract(cm):
37
+ a = np.arange(6, dtype=np.float64).reshape(2, 3)
38
+ b = np.ones_like(a)
39
+ out = cm.mat_add(a, b)
40
+ assert out.shape == (2, 3)
41
+ np.testing.assert_allclose(out, a + b)
42
+
43
+ flat = np.zeros(a.size, dtype=np.float64)
44
+ reshaped = cm.mat_add(a, b, out=flat)
45
+ assert reshaped.shape == (2, 3)
46
+ np.testing.assert_allclose(reshaped, a + b)
47
+ np.testing.assert_allclose(flat.reshape(a.shape), a + b)
48
+
49
+
36
50
  def test_struct_wrapper(cm):
37
51
  cp = cm.ComplexPair(real=1.5, imag=-2.5)
38
52
  assert repr(cp) == "ComplexPair(real=1.5, imag=-2.5)"
@@ -52,6 +66,53 @@ def test_struct_argument(cm):
52
66
  assert np.isclose(mag_sq, 5.0)
53
67
 
54
68
 
69
+ def test_struct_pointer_inplace_no_return(cm):
70
+ cp = cm.ComplexPair(real=2.0, imag=-3.0)
71
+ res = cm.scale_complex(cp, 2.0)
72
+ assert res is None
73
+ assert cp.real == 4.0
74
+ assert cp.imag == -6.0
75
+
76
+
77
+ def test_struct_scalar_output_returns_struct_object(cm):
78
+ a = cm.ComplexPair(real=2.0, imag=4.0)
79
+ b = cm.ComplexPair(real=6.0, imag=10.0)
80
+ mid = cm.midpoint_complex(a, b)
81
+ assert isinstance(mid, cm.ComplexPair)
82
+ assert mid.real == 4.0
83
+ assert mid.imag == 7.0
84
+
85
+ out = cm.ComplexPair()
86
+ res = cm.midpoint_complex(a, b, out_z=out)
87
+ assert res is None
88
+ assert out.real == 4.0
89
+ assert out.imag == 7.0
90
+
91
+
92
+ def test_struct_array_output_returns_array_wrapper(cm):
93
+ pairs = cm.make_complex_pairs()
94
+ assert isinstance(pairs, cm.ComplexPairArray)
95
+ assert repr(pairs) == "<Array ComplexPair[3]>"
96
+ assert pairs.shape == (3,)
97
+ np.testing.assert_allclose(pairs.real, np.array([0.0, 1.0, 2.0]))
98
+ np.testing.assert_allclose(pairs.imag, np.array([0.0, -1.0, -2.0]))
99
+ np.testing.assert_allclose(pairs["real"], pairs.real)
100
+ assert isinstance(pairs[1], cm.ComplexPair)
101
+ assert pairs[1].real == 1.0
102
+ assert pairs[1].imag == -1.0
103
+
104
+
105
+ def test_struct_array_initialization(cm):
106
+ from_array_like = cm.ComplexPairArray([(1.0, 2.0), (3.0, 4.0)])
107
+ assert isinstance(from_array_like[0], cm.ComplexPair)
108
+ np.testing.assert_allclose(from_array_like.real, [1.0, 3.0])
109
+ np.testing.assert_allclose(from_array_like.imag, [2.0, 4.0])
110
+
111
+ from_fields = cm.ComplexPairArray(real=[5.0, 6.0], imag=-1.0)
112
+ np.testing.assert_allclose(from_fields.real, [5.0, 6.0])
113
+ np.testing.assert_allclose(from_fields.imag, [-1.0, -1.0])
114
+
115
+
55
116
  def test_struct_array_member(cm):
56
117
  p = cm.Particle()
57
118
  p.pos = [1.0, 2.0, 3.0]
@@ -98,7 +159,14 @@ def test_owned_array(cm):
98
159
  def test_struct_output_array(cm):
99
160
  n = 4
100
161
  particles = cm.Particle.zeros(n)
101
- out_particles = cm.make_particles(3.0, out_p=particles, len_p=n)
102
- np.testing.assert_array_equal(out_particles, particles)
162
+ res = cm.make_particles(3.0, out_p=particles, len_p=n)
163
+ assert res is None
103
164
  np.testing.assert_allclose(particles["pos"], np.ones((n, 3)))
104
165
  np.testing.assert_allclose(particles["vel"], np.array([[3.0, 0.0, 0.0]] * n))
166
+
167
+
168
+ def test_struct_output_array_auto_return(cm):
169
+ n = 3
170
+ particles = cm.make_particles(2.5, len_p=n)
171
+ np.testing.assert_allclose(particles["pos"], np.ones((n, 3)))
172
+ np.testing.assert_allclose(particles["vel"], np.array([[2.5, 0.0, 0.0]] * n))
@@ -6,6 +6,6 @@ from .cmodule import CModule
6
6
  try:
7
7
  __version__ = version("tinycwrap")
8
8
  except PackageNotFoundError:
9
- __version__ = "0.0.7"
9
+ __version__ = "0.1.1"
10
10
 
11
11
  __all__ = ["CModule", "__version__"]
@@ -97,6 +97,7 @@ class CModule:
97
97
  self._struct_specs: dict[str, StructSpec] = {}
98
98
  self._struct_dtypes: dict[str, np.dtype] = {}
99
99
  self._struct_classes: dict[str, type] = {}
100
+ self._struct_array_classes: dict[str, type] = {}
100
101
  self._ipython_hook = None
101
102
  self._cdef: str | None = None
102
103
 
@@ -187,10 +188,18 @@ class CModule:
187
188
  delattr(self, sname)
188
189
  except Exception:
189
190
  pass
191
+ for sname in list(self._struct_array_classes.keys()):
192
+ aname = f"{sname}Array"
193
+ if hasattr(self, aname):
194
+ try:
195
+ delattr(self, aname)
196
+ except Exception:
197
+ pass
190
198
  self._func_specs.clear()
191
199
  self._struct_specs.clear()
192
200
  self._struct_dtypes.clear()
193
201
  self._struct_classes.clear()
202
+ self._struct_array_classes.clear()
194
203
  self._ffi = None
195
204
  self._lib = None
196
205
  self._so_path = None
@@ -439,29 +448,71 @@ class CModule:
439
448
  fspec.doc = doc
440
449
  contracts = []
441
450
  owns: list[str] = []
451
+
452
+ def _normalize_shape_expr(expr: str) -> str:
453
+ expr = expr.strip()
454
+ if "," in expr and not expr.startswith("("):
455
+ expr = f"({expr})"
456
+ return expr
457
+
458
+ contract_items: list[str] = []
442
459
  for line in doc.splitlines():
443
- m_contract = re.search(r"(post-)?contract:\s*(.*)", line, flags=re.IGNORECASE)
444
- if not m_contract:
445
- m_own = re.search(r"own:\s*(.*)", line, flags=re.IGNORECASE)
446
- if m_own:
447
- for name in m_own.group(1).split(","):
448
- n = name.strip()
449
- if n:
450
- owns.append(n)
460
+ m_contract = re.search(r"contracts?\s*:\s*(.*)", line, flags=re.IGNORECASE)
461
+ if m_contract:
462
+ after = m_contract.group(1)
463
+ for part in after.split(";"):
464
+ part = part.strip()
465
+ if part:
466
+ contract_items.append(part)
451
467
  continue
452
- is_post = m_contract.group(1) is not None
453
- after = m_contract.group(2)
454
- for part in after.split(";"):
455
- part = part.strip()
456
- if not part:
457
- continue
458
- mlen = re.match(r"len\((\w+)\)\s*=\s*(.+)", part)
459
- if not mlen:
460
- mlen = re.match(r"(\w+)\s*=\s*(.+)", part)
461
- if mlen:
462
- target = mlen.group(1)
463
- expr = mlen.group(2).strip()
464
- contracts.append((target, expr, is_post))
468
+ m_own = re.search(r"own:\s*(.*)", line, flags=re.IGNORECASE)
469
+ if m_own:
470
+ for name in m_own.group(1).split(","):
471
+ n = name.strip()
472
+ if n:
473
+ owns.append(n)
474
+
475
+ for item in contract_items:
476
+ entry = item.strip()
477
+ if not entry:
478
+ continue
479
+ m_own_call = re.match(r"own\s*\(\s*([^)]+)\s*\)\s*$", entry, flags=re.IGNORECASE)
480
+ if m_own_call:
481
+ for name in m_own_call.group(1).split(","):
482
+ n = name.strip()
483
+ if n:
484
+ owns.append(n)
485
+ continue
486
+ is_post = False
487
+ m_post = re.match(r"post\s*(.+)", entry, flags=re.IGNORECASE)
488
+ if m_post and (m_post.group(1).strip().lower().startswith("len(") or m_post.group(1).strip().lower().startswith("shape(")):
489
+ is_post = True
490
+ entry = m_post.group(1).strip()
491
+ mshape_out = re.match(r"shape\((\w+)\)\s*=\s*(.+)", entry, flags=re.IGNORECASE)
492
+ if mshape_out:
493
+ target = f"shape({mshape_out.group(1)})"
494
+ expr = _normalize_shape_expr(mshape_out.group(2))
495
+ contracts.append((target, expr, is_post))
496
+ continue
497
+ mshape_in = re.match(r"(.+?)\s*=\s*shape\((\w+)\)", entry, flags=re.IGNORECASE)
498
+ if mshape_in:
499
+ targets_raw = mshape_in.group(1)
500
+ arr_name = mshape_in.group(2)
501
+ targets = [t.strip() for t in targets_raw.split(",") if t.strip()]
502
+ for idx, tgt in enumerate(targets):
503
+ contracts.append((tgt, f"shape({arr_name})[{idx}]", is_post))
504
+ continue
505
+ mlen = re.match(r"len\((\w+)\)\s*=\s*(.+)", entry, flags=re.IGNORECASE)
506
+ if mlen:
507
+ target = mlen.group(1)
508
+ expr = mlen.group(2).strip()
509
+ contracts.append((target, expr, is_post))
510
+ continue
511
+ mgeneric = re.match(r"(\w+)\s*=\s*(.+)", entry)
512
+ if mgeneric:
513
+ target = mgeneric.group(1)
514
+ expr = mgeneric.group(2).strip()
515
+ contracts.append((target, expr, is_post))
465
516
  fspec.contracts = contracts or None
466
517
  fspec.owns = owns or None
467
518
 
@@ -633,10 +684,23 @@ class CModule:
633
684
  parts = ", ".join(parts_list)
634
685
  return f"{spec.name}({parts})"
635
686
 
687
+ @classmethod
688
+ def _from_data(cls, data, copy=False):
689
+ arr = np.asarray(data, dtype=dtype)
690
+ if arr.shape != ():
691
+ raise ValueError(f"{spec.name} expects scalar data, got shape {arr.shape}")
692
+ if copy:
693
+ arr = np.array(arr, dtype=dtype, copy=True)
694
+ obj = cls()
695
+ object.__setattr__(obj, "_data", arr)
696
+ object.__setattr__(obj, "_children", {})
697
+ return obj
698
+
636
699
  namespace = {
637
700
  "__slots__": slots,
638
701
  "__init__": __init__,
639
702
  "__repr__": __repr__,
703
+ "_from_data": _from_data,
640
704
  "__doc__": f"Python wrapper for C struct {spec.name}. Fields: {', '.join(dtype.names)}.",
641
705
  "dtype": dtype,
642
706
  "zeros": staticmethod(
@@ -679,10 +743,147 @@ class CModule:
679
743
 
680
744
  return type(spec.name, (), namespace)
681
745
 
746
+ def make_struct_array_class(spec, dtype, struct_cls):
747
+ slots = ("_data",)
748
+ class_name = f"{spec.name}Array"
749
+
750
+ def __init__(self, data=None, copy=False, **kwargs):
751
+ if data is not None and kwargs:
752
+ raise TypeError(f"{class_name} accepts either data or field values, not both")
753
+ if kwargs:
754
+ unknown = sorted(set(kwargs) - set(dtype.names))
755
+ if unknown:
756
+ names = ", ".join(unknown)
757
+ raise TypeError(f"{class_name} got unknown field(s): {names}")
758
+ field_values = {}
759
+ lengths = []
760
+ for fname, value in kwargs.items():
761
+ field_dtype = dtype.fields[fname][0]
762
+ if field_dtype.shape == ():
763
+ value_arr = np.asarray(value, dtype=field_dtype)
764
+ if value_arr.shape != ():
765
+ lengths.append(value_arr.shape[0])
766
+ else:
767
+ value_arr = np.asarray(value, dtype=field_dtype.base)
768
+ if value_arr.shape == field_dtype.shape:
769
+ pass
770
+ elif value_arr.shape[1:] == field_dtype.shape:
771
+ lengths.append(value_arr.shape[0])
772
+ else:
773
+ raise ValueError(
774
+ f"Field {fname} expects shape {field_dtype.shape} or "
775
+ f"(n, {', '.join(str(s) for s in field_dtype.shape)}), "
776
+ f"got {value_arr.shape}"
777
+ )
778
+ field_values[fname] = value_arr
779
+ if lengths and len(set(lengths)) != 1:
780
+ raise ValueError(f"{class_name} field values have inconsistent lengths: {lengths}")
781
+ n = lengths[0] if lengths else 1
782
+ arr = np.zeros(n, dtype=dtype)
783
+ for fname, value_arr in field_values.items():
784
+ arr[fname] = value_arr
785
+ elif data is None:
786
+ arr = np.zeros(0, dtype=dtype)
787
+ elif hasattr(data, "_data") and getattr(data, "_data", None) is not None:
788
+ arr = np.asarray(data._data, dtype=dtype)
789
+ elif isinstance(data, (list, tuple)) and data and hasattr(data[0], "_data"):
790
+ arr = np.asarray([item._data for item in data], dtype=dtype)
791
+ else:
792
+ arr = np.asarray(data, dtype=dtype)
793
+ if arr.shape == ():
794
+ arr = arr.reshape(1)
795
+ if copy:
796
+ arr = np.array(arr, dtype=dtype, copy=True)
797
+ object.__setattr__(self, "_data", arr)
798
+
799
+ @classmethod
800
+ def _from_data(cls, data, copy=False):
801
+ return cls(data, copy=copy)
802
+
803
+ @staticmethod
804
+ def zeros(n):
805
+ return array_cls(np.zeros(n, dtype=dtype))
806
+
807
+ def __len__(self):
808
+ return len(self._data)
809
+
810
+ def __iter__(self):
811
+ for idx in range(len(self)):
812
+ yield self[idx]
813
+
814
+ def __array__(self, dtype=None, copy=None):
815
+ arr = np.asarray(self._data, dtype=dtype)
816
+ if copy:
817
+ arr = np.array(arr, copy=True)
818
+ return arr
819
+
820
+ def __getitem__(self, key):
821
+ if isinstance(key, str):
822
+ return self._data[key]
823
+ if isinstance(key, (int, np.integer)):
824
+ idx = int(key)
825
+ if idx < 0:
826
+ idx += len(self._data)
827
+ return struct_cls._from_data(self._data[idx:idx + 1].reshape(()))
828
+ value = self._data[key]
829
+ if getattr(value, "dtype", None) == dtype:
830
+ if getattr(value, "shape", ()) == ():
831
+ return struct_cls._from_data(value)
832
+ return array_cls(value)
833
+ return value
834
+
835
+ def __setitem__(self, key, value):
836
+ if hasattr(value, "_data") and getattr(value, "_data", None) is not None:
837
+ self._data[key] = value._data
838
+ else:
839
+ self._data[key] = value
840
+
841
+ def __repr__(self):
842
+ return f"<Array {spec.name}[{len(self)}]>"
843
+
844
+ namespace = {
845
+ "__slots__": slots,
846
+ "__init__": __init__,
847
+ "__repr__": __repr__,
848
+ "__len__": __len__,
849
+ "__iter__": __iter__,
850
+ "__array__": __array__,
851
+ "__getitem__": __getitem__,
852
+ "__setitem__": __setitem__,
853
+ "_from_data": _from_data,
854
+ "zeros": zeros,
855
+ "dtype": dtype,
856
+ "__doc__": f"Python wrapper for arrays of C struct {spec.name}.",
857
+ "array": property(lambda self: self._data),
858
+ "shape": property(lambda self: self._data.shape),
859
+ "ndim": property(lambda self: self._data.ndim),
860
+ "size": property(lambda self: self._data.size),
861
+ "ctypes": property(lambda self: self._data.ctypes),
862
+ }
863
+
864
+ for fname in dtype.names:
865
+ namespace[fname] = property(lambda self, fname=fname: self._data[fname])
866
+
867
+ array_cls = type(class_name, (), namespace)
868
+ return array_cls
869
+
682
870
  struct_cls = make_struct_class(sspec, dtype)
871
+ struct_array_cls = make_struct_array_class(sspec, dtype, struct_cls)
683
872
  self._struct_dtypes[sname] = dtype
684
873
  self._struct_classes[sname] = struct_cls
874
+ self._struct_array_classes[sname] = struct_array_cls
685
875
  setattr(self, sname, struct_cls)
876
+ setattr(self, f"{sname}Array", struct_array_cls)
877
+
878
+ def _wrap_struct_output(self, value, struct_name: str):
879
+ struct_cls = self._struct_classes[struct_name]
880
+ struct_array_cls = self._struct_array_classes[struct_name]
881
+ if isinstance(value, (struct_cls, struct_array_cls)):
882
+ return value
883
+ arr = np.asarray(value, dtype=self._struct_dtypes[struct_name])
884
+ if arr.shape == ():
885
+ return struct_cls._from_data(arr)
886
+ return struct_array_cls._from_data(arr)
686
887
 
687
888
  def _make_wrapper_from_spec(self, fspec: FuncSpec):
688
889
  struct_names = set(self._struct_specs.keys())
@@ -709,6 +910,7 @@ class CModule:
709
910
  "base_type_from_ctype": base_type_from_ctype,
710
911
  "_struct_classes": self._struct_classes,
711
912
  "_struct_dtypes": self._struct_dtypes,
913
+ "_struct_array_classes": self._struct_array_classes,
712
914
  }
713
915
  filename = f"<cmodule:{self._c_path.name}:{fspec.name}>"
714
916
  linecache.cache[filename] = (
@@ -813,8 +1015,18 @@ class CModule:
813
1015
 
814
1016
  contract_map: dict[str, str] = {}
815
1017
  post_contract_map: dict[str, str] = {}
1018
+ shape_contract_map: dict[str, str] = {}
1019
+ post_shape_contract_map: dict[str, str] = {}
816
1020
  if getattr(fspec, "contracts", None):
817
1021
  for target, expr, is_post in fspec.contracts:
1022
+ is_shape = target.startswith("shape(") and target.endswith(")")
1023
+ if is_shape:
1024
+ arr_name = target[len("shape(") : -1]
1025
+ if is_post:
1026
+ post_shape_contract_map[arr_name] = expr
1027
+ else:
1028
+ shape_contract_map[arr_name] = expr
1029
+ continue
818
1030
  if is_post:
819
1031
  post_contract_map[target] = expr
820
1032
  else:
@@ -848,6 +1060,7 @@ class CModule:
848
1060
  def _expr_py(expr: str) -> str:
849
1061
  expr = expr.strip()
850
1062
  expr = re.sub(r"len\((\w+)\)", r"len(arr_\1)", expr)
1063
+ expr = re.sub(r"shape\((\w+)\)", r"np.shape(arr_\1)", expr)
851
1064
  for name in pointer_scalar_names:
852
1065
  expr = re.sub(rf"\b{name}\b", f"int(arr_{name}.ravel()[0])", expr)
853
1066
  expr = re.sub(rf"\b{name}\b", f"scalar_{name}", expr)
@@ -870,6 +1083,8 @@ class CModule:
870
1083
  call_args: list[str] = []
871
1084
  output_vars: list[str] = []
872
1085
  output_names: list[str] = []
1086
+ output_conditions: dict[str, str] = {}
1087
+ struct_output_types: dict[str, str] = {}
873
1088
  pointer_scalar_outputs: list[tuple[str, str]] = []
874
1089
  struct_scalar_outputs: list[tuple[str, str, str]] = []
875
1090
  pre_lines: list[str] = []
@@ -952,20 +1167,34 @@ class CModule:
952
1167
  else:
953
1168
  dtype_expr = f"np.dtype('{np.dtype(numpy_dtype_for_base_type(a.base_type)).name}')"
954
1169
  ref_name = a.name[4:] if a.name.lower().startswith("out_") else None
1170
+ shape_expr = shape_contract_map.get(a.name)
1171
+ len_expr = contract_map.get(a.name)
955
1172
  out_lines += [
956
1173
  f" base_dtype = {dtype_expr}",
1174
+ ]
1175
+ if a.base_type in struct_names:
1176
+ out_lines += [
1177
+ f" _ret_{a.name} = {a.name} is None",
1178
+ ]
1179
+ out_lines += [
957
1180
  f" if {a.name} is None:",
958
1181
  ]
959
1182
  if a.array_len is not None:
960
1183
  out_lines += [
961
1184
  f" arr_{a.name} = np.zeros({int(a.array_len)}, dtype=base_dtype)"
962
1185
  ]
963
- elif a.name in contract_map:
964
- expr_py = _expr_py(contract_map[a.name])
1186
+ elif shape_expr is not None:
1187
+ expr_py = _expr_py(shape_expr)
1188
+ out_lines += [
1189
+ f" _shape_{a.name} = tuple({expr_py})",
1190
+ f" arr_{a.name} = np.zeros(_shape_{a.name}, dtype=base_dtype)",
1191
+ ]
1192
+ elif len_expr is not None:
1193
+ expr_py = _expr_py(len_expr)
965
1194
  out_lines += [
966
1195
  f" arr_{a.name} = np.zeros(int({expr_py}), dtype=base_dtype)"
967
1196
  ]
968
- elif a.array_len is None and a.name not in contract_map:
1197
+ elif a.array_len is None and len_expr is None and shape_expr is None:
969
1198
  out_lines += [
970
1199
  f" arr_{a.name} = np.zeros((), dtype=base_dtype)"
971
1200
  ]
@@ -983,11 +1212,33 @@ class CModule:
983
1212
  ]
984
1213
  out_lines += [
985
1214
  " else:",
986
- f" arr_{a.name} = np.ascontiguousarray({a.name}, dtype=base_dtype)",
1215
+ ]
1216
+ if a.base_type in struct_names:
1217
+ out_lines += [
1218
+ f" if hasattr({a.name}, '_data') and getattr({a.name}, '_data', None) is not None and getattr({a.name}, '_data').dtype == base_dtype:",
1219
+ f" arr_{a.name} = {a.name}._data",
1220
+ " else:",
1221
+ f" arr_{a.name} = np.ascontiguousarray({a.name}, dtype=base_dtype)",
1222
+ ]
1223
+ else:
1224
+ out_lines += [
1225
+ f" arr_{a.name} = np.ascontiguousarray({a.name}, dtype=base_dtype)",
1226
+ ]
1227
+ if shape_expr is not None:
1228
+ expr_py = _expr_py(shape_expr)
1229
+ out_lines += [
1230
+ f" _expected_shape_{a.name} = tuple({expr_py})",
1231
+ f" if arr_{a.name}.shape != _expected_shape_{a.name}:",
1232
+ f" arr_{a.name} = np.reshape(arr_{a.name}, _expected_shape_{a.name})",
1233
+ ]
1234
+ out_lines += [
987
1235
  f" ptr_{a.name} = _self._ffi.cast('{a.base_type} *', _self._ffi.from_buffer(arr_{a.name}))",
988
1236
  ]
989
1237
  arg_call_args[idx] = f"ptr_{a.name}"
990
1238
  output_names.append(a.name)
1239
+ if a.base_type in struct_names:
1240
+ output_conditions[a.name] = f"_ret_{a.name}"
1241
+ struct_output_types[a.name] = a.base_type
991
1242
  if a.name in pointer_scalar_names:
992
1243
  pointer_scalar_outputs.append((a.name, f"arr_{a.name}"))
993
1244
  else:
@@ -1014,6 +1265,7 @@ class CModule:
1014
1265
  ]
1015
1266
  else:
1016
1267
  out_lines += [
1268
+ f" _ret_{a.name} = {a.name} is None",
1017
1269
  f" if {a.name} is None:",
1018
1270
  f" obj_{a.name} = _struct_classes['{a.base_type}']()",
1019
1271
  f" arr_{a.name} = obj_{a.name}._data",
@@ -1026,6 +1278,7 @@ class CModule:
1026
1278
  f" ptr_{a.name} = _self._ffi.cast('{a.base_type} *', _self._ffi.from_buffer(arr_{a.name}))",
1027
1279
  ]
1028
1280
  output_names.append(a.name)
1281
+ output_conditions[a.name] = f"_ret_{a.name}"
1029
1282
  output_vars.append(f"obj_{a.name} if obj_{a.name} is not None else arr_{a.name}")
1030
1283
  arg_call_args[idx] = f"ptr_{a.name}"
1031
1284
  elif a.is_scalar and not a.is_length_param and arg_call_args[idx] is None:
@@ -1061,6 +1314,7 @@ class CModule:
1061
1314
  pairs_sorted = sorted(pairs, key=lambda p: 1 if p[1] in pointer_scalar_map else 0)
1062
1315
  output_names_reordered: list[str] = []
1063
1316
  output_vars_final: list[str] = []
1317
+ output_conditions_final: list[str | None] = []
1064
1318
  for name, ov in pairs_sorted:
1065
1319
  output_names_reordered.append(name)
1066
1320
  if ov in struct_scalar_map:
@@ -1070,11 +1324,32 @@ class CModule:
1070
1324
  output_vars_final.append(pointer_scalar_map[ov][1])
1071
1325
  else:
1072
1326
  output_vars_final.append(ov)
1327
+ output_conditions_final.append(output_conditions.get(name))
1073
1328
 
1074
1329
  own_return_len_expr = None
1075
1330
  if own_return and contract_map.get("return"):
1076
1331
  own_return_len_expr = _expr_py(contract_map["return"])
1077
1332
 
1333
+ output_value_exprs = list(output_vars_final)
1334
+ if output_value_exprs:
1335
+ scalar_names = {n for n, _ in pointer_scalar_outputs}
1336
+ for idx, (out_name, out_var) in enumerate(
1337
+ zip(output_names_reordered, output_value_exprs)
1338
+ ):
1339
+ if out_name in post_contract_map:
1340
+ expr_py = _expr_py(post_contract_map[out_name])
1341
+ if out_name in scalar_names:
1342
+ expr_py = expr_py.replace(out_name, f"scalar_{out_name}")
1343
+ output_value_exprs[idx] = f"({out_var})[:int({expr_py})]"
1344
+ elif out_name in post_shape_contract_map:
1345
+ expr_py = _expr_py(post_shape_contract_map[out_name])
1346
+ output_value_exprs[idx] = f"np.reshape({out_var}, tuple({expr_py}))"
1347
+ if out_name in struct_output_types:
1348
+ output_value_exprs[idx] = (
1349
+ f"_self._wrap_struct_output({output_value_exprs[idx]}, "
1350
+ f"'{struct_output_types[out_name]}')"
1351
+ )
1352
+
1078
1353
  if own_return:
1079
1354
  base_ret = base_type_from_ctype(fspec.return_ctype)
1080
1355
  ret_dtype_expr = f"np.dtype('{np.dtype(numpy_dtype_for_base_type(base_ret)).name}')"
@@ -1105,77 +1380,60 @@ class CModule:
1105
1380
  lines.append(
1106
1381
  f" arr_ret = np.frombuffer(_self._ffi.buffer(res_buf, int({own_return_len_expr}) * np.dtype({ret_dtype_expr}).itemsize), dtype={ret_dtype_expr})"
1107
1382
  )
1108
- ret_parts: list[str] = ["arr_ret"]
1109
- if output_vars_final:
1110
- for v in output_vars_final:
1111
- if v not in ret_parts:
1112
- ret_parts.append(v)
1113
- # append any scalar lengths not already in ret_parts
1383
+ lines.append(" _ret_vals = []")
1384
+ lines.append(" _ret_names = set()")
1385
+ if output_value_exprs:
1386
+ for out_name, ov, cond in zip(
1387
+ output_names_reordered, output_value_exprs, output_conditions_final
1388
+ ):
1389
+ if cond:
1390
+ lines.append(f" if {cond}:")
1391
+ lines.append(f" _ret_vals.append({ov})")
1392
+ lines.append(f" _ret_names.add('{out_name}')")
1393
+ else:
1394
+ lines.append(f" _ret_vals.append({ov})")
1395
+ lines.append(f" _ret_names.add('{out_name}')")
1396
+ lines.append(" ret_parts = [arr_ret]")
1397
+ lines.append(" if _ret_vals:")
1398
+ lines.append(" ret_parts.extend(_ret_vals)")
1114
1399
  for name, _arr in pointer_scalar_outputs:
1115
- sval = f"scalar_{name}"
1116
- if sval not in ret_parts:
1117
- ret_parts.append(sval)
1118
- if len(ret_parts) == 1:
1119
- ret_expr = ret_parts[0]
1120
- else:
1121
- ret_expr = "(" + ", ".join(ret_parts) + ")"
1122
- lines.append(f" return {ret_expr}")
1400
+ lines.append(f" if '{name}' not in _ret_names:")
1401
+ lines.append(f" ret_parts.append(scalar_{name})")
1402
+ lines.append(f" _ret_names.add('{name}')")
1403
+ lines.append(" if len(ret_parts) == 1:")
1404
+ lines.append(" return ret_parts[0]")
1405
+ lines.append(" return tuple(ret_parts)")
1123
1406
  return "\n".join(lines)
1124
1407
 
1125
- if output_vars_final:
1126
- if ret_type == "void":
1127
- if len(output_vars_final) == 1:
1128
- ret_expr = output_vars_final[0]
1129
- else:
1130
- ret_expr = "(" + ", ".join(output_vars_final) + ")"
1131
- else:
1132
- if len(output_vars_final) == 1:
1133
- ret_expr = f"{output_vars_final[0]}, res"
1134
- else:
1135
- ret_expr = "(" + ", ".join(output_vars_final) + "), res"
1136
- # apply post-contract slicing using scalar lengths if available
1137
- for out_name, out_var in zip(output_names_reordered, output_vars_final):
1138
- if out_name in post_contract_map:
1139
- expr_py = _expr_py(post_contract_map[out_name])
1140
- scalar_names = {n for n, _ in pointer_scalar_outputs}
1141
- if out_name in scalar_names:
1142
- expr_py = expr_py.replace(out_name, f"scalar_{out_name}")
1143
- ret_expr = ret_expr.replace(out_var, f"({out_var})[:int({expr_py})]")
1144
- if pointer_scalar_outputs:
1145
- scalars = [
1146
- f"scalar_{n}"
1147
- for n, _ in pointer_scalar_outputs
1148
- if f"scalar_{n}" not in output_vars_final
1149
- ]
1150
- if isinstance(ret_expr, str) and ret_expr.startswith("("):
1151
- ret_expr = (
1152
- ret_expr[:-1]
1153
- + (", " if len(scalars) else "")
1154
- + ", ".join(scalars)
1155
- + ")"
1156
- )
1408
+ lines.append(" _ret_vals = []")
1409
+ lines.append(" _ret_names = set()")
1410
+ if output_value_exprs:
1411
+ for out_name, ov, cond in zip(
1412
+ output_names_reordered, output_value_exprs, output_conditions_final
1413
+ ):
1414
+ if cond:
1415
+ lines.append(f" if {cond}:")
1416
+ lines.append(f" _ret_vals.append({ov})")
1417
+ lines.append(f" _ret_names.add('{out_name}')")
1157
1418
  else:
1158
- if len(scalars) == 1:
1159
- ret_expr = (
1160
- "(" + ret_expr + ", " + scalars[0] + ")"
1161
- if ret_expr
1162
- else scalars[0]
1163
- )
1164
- else:
1165
- ret_expr = "(" + ret_expr + ", " + ", ".join(scalars) + ")"
1166
- lines.append(f" return {ret_expr}")
1419
+ lines.append(f" _ret_vals.append({ov})")
1420
+ lines.append(f" _ret_names.add('{out_name}')")
1421
+ for name, _arr in pointer_scalar_outputs:
1422
+ lines.append(f" if '{name}' not in _ret_names:")
1423
+ lines.append(f" _ret_vals.append(scalar_{name})")
1424
+ lines.append(f" _ret_names.add('{name}')")
1425
+ if ret_type == "void":
1426
+ lines.append(" if _ret_vals:")
1427
+ lines.append(" if len(_ret_vals) == 1:")
1428
+ lines.append(" return _ret_vals[0]")
1429
+ lines.append(" return tuple(_ret_vals)")
1430
+ lines.append(" return None")
1167
1431
  else:
1168
- if ret_type == "void":
1169
- lines.append(" return None")
1170
- else:
1171
- ret_expr = "res"
1172
- if pointer_scalar_outputs:
1173
- scalars = [f"scalar_{n}" for n, _ in pointer_scalar_outputs]
1174
- if len(scalars) == 1:
1175
- ret_expr = f"({ret_expr}, {scalars[0]})"
1176
- else:
1177
- ret_expr = f"({ret_expr}, " + ", ".join(scalars) + ")"
1178
- lines.append(f" return {ret_expr}")
1432
+ lines.append(" if _ret_vals:")
1433
+ lines.append(" if len(_ret_vals) == 1:")
1434
+ lines.append(" return _ret_vals[0], res")
1435
+ lines.append(" return tuple(_ret_vals), res")
1436
+ lines.append(" return res")
1179
1437
 
1180
1438
  return "\n".join(lines)
1181
1439
 
@@ -240,7 +240,8 @@ def _parse_functions_regex(cdef: str) -> dict[str, FuncSpec]:
240
240
  decls.append(decl)
241
241
  buff = []
242
242
 
243
- func_re = re.compile(r"(.+?)\s+(\w+)\s*\((.*?)\)\s*;")
243
+ # allow pointer-return functions where the * may be glued to the name
244
+ func_re = re.compile(r"(.+?)\s+(\*?\w+)\s*\((.*?)\)\s*;")
244
245
 
245
246
  funcs: dict[str, FuncSpec] = {}
246
247
  for decl in decls:
@@ -249,6 +250,12 @@ def _parse_functions_regex(cdef: str) -> dict[str, FuncSpec]:
249
250
  continue
250
251
  ret_ctype, fname, arglist = m.groups()
251
252
  ret_ctype = ret_ctype.strip()
253
+ fname = fname.strip()
254
+ # handle prototypes like `double *fn(...)` where `*` attaches to the name
255
+ if fname.startswith("*"):
256
+ star_count = len(fname) - len(fname.lstrip("*"))
257
+ fname = fname.lstrip("*")
258
+ ret_ctype = f"{ret_ctype} {'*' * star_count}".strip()
252
259
  arglist = arglist.strip()
253
260
 
254
261
  argspecs: list[ArgSpec] = []
@@ -305,6 +312,9 @@ def _parse_structs_regex(cdef: str) -> dict[str, StructSpec]:
305
312
  name = m.group("name")
306
313
  fields: list[StructField] = []
307
314
  body_clean = re.sub(r"\bstruct\b", "", body)
315
+ # Strip C++-style comments before splitting fields so inline comments
316
+ # don't swallow subsequent declarations (e.g., "double x; // comment\n double y;").
317
+ body_clean = re.sub(r"//.*?$", "", body_clean, flags=re.MULTILINE)
308
318
  for line in body_clean.split(";"):
309
319
  line = line.strip()
310
320
  if not line:
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinycwrap
3
+ Version: 0.1.1
4
+ Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
+ Author: TinyCWrap Contributors
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=1.24
9
+ Requires-Dist: cffi>=1.15
10
+
11
+ # tinycwrap
12
+
13
+ TinyCWrap is a lightweight helper around CFFI that:
14
+
15
+ - compiles one or more C sources into a shared library,
16
+ - auto-generates the `cdef` from your function/struct declarations,
17
+ - builds NumPy-friendly Python wrappers that take/return arrays and structs,
18
+ - optionally hot-reloads when the C file changes (inside IPython).
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ pip install tinycwrap
24
+ ```
25
+
26
+ Write a small C file (only non-`static` functions are exported):
27
+
28
+ ```c
29
+ /* kernels.c */
30
+ double dot(const double *x, const double *y, int len_x)
31
+ /* Return dot product between x and y
32
+ Contract: len_x=len(x);
33
+ */
34
+ {
35
+ double acc = 0.0;
36
+ for (int i = 0; i < len_x; ++i)
37
+ acc += x[i] * y[i];
38
+ return acc;
39
+ }
40
+ ```
41
+
42
+ Wrap and call it from Python:
43
+
44
+ ```python
45
+ import numpy as np
46
+ from tinycwrap import CModule
47
+
48
+ cm = CModule("kernels.c") # builds the shared library, creates wrappers
49
+
50
+ x = np.arange(5, dtype=np.float64)
51
+ y = np.ones_like(x)
52
+
53
+ cm.dot(x, y) # -> 10.0, len_x auto-filled by the contract
54
+ print(cm.dot.__doc__) # docstring comes from the C comment
55
+ ```
56
+
57
+ See `examples/` and `tests/` for more involved cases.
58
+
59
+ ## C coding conventions
60
+
61
+ TinyCWrap infers how to build wrappers from simple C conventions:
62
+
63
+ - **Inputs vs outputs**
64
+ - `const T *arg` -> input NumPy array.
65
+ - `T *arg` -> output/in-place array (prefer naming it `out_*` when possible).
66
+ - `T arg[N]` in the signature -> fixed-size array (input if `const`, otherwise output).
67
+ - Plain scalars (`double`, `int`, ...) -> Python scalars.
68
+ - **Length parameters**: integers such as `len_x`, `n`, `size_x` can be auto-filled if you declare a contract (see below). Otherwise pass them explicitly from Python.
69
+ - **Docstrings**: the block comment immediately after the function header becomes the Python docstring.
70
+ - **Structs**: `typedef struct { ... } Name;` definitions in your headers/sources become Python classes with a `.dtype` and NumPy-backed storage.
71
+ - **Compilation**: extra sources can be passed (`CModule("main.c", "helper.c")`), and extra include dirs via `include_dirs=[...]`. Default compiler flags are `-O3 -shared -fPIC -march=native -mtune=native` plus the NumPy include path.
72
+
73
+ ## Contracts: tell the wrapper how to size things
74
+
75
+ Contracts are declared inside the doc comment with `Contract:` (or `Contracts:`). Separate multiple rules with semicolons. Supported forms:
76
+
77
+ - `len_x=len(x)` — mark `len_x` as the length of array `x`. If you omit `len_x` in Python, the wrapper fills it.
78
+ - `shape(out)=n,m` — allocate `out` with shape `(n, m)`. You can define `n,m=shape(a)` to capture the shape of an input first.
79
+ - `len(out)=len_x` — allocate a 1D output array.
80
+ - `postlen(out)=out_len` — slice the output after the call using an integer pointer result `out_len` (useful when the C code writes less than the allocated length).
81
+ - `own(return)` — the returned pointer is owned by the caller; the wrapper will `free` it after copying to NumPy (requires a `len(return)=...` contract).
82
+
83
+ Examples pulled from the test suite:
84
+
85
+ ```c
86
+ void mat_add(const double *a, const double *b, int n, int m, double *out)
87
+ /* Elementwise addition
88
+ Contract: n,m=shape(a); shape(out)=n,m;
89
+ */
90
+ ```
91
+
92
+ ```c
93
+ void merge_sorted(const double *a, const double *b, int len_a, int len_b,
94
+ double *out, int *out_len)
95
+ /* Merge unique sorted values
96
+ Contract: len_a=len(a); len_b=len(b);
97
+ len(out)=len_a+len_b; postlen(out)=out_len;
98
+ */
99
+ ```
100
+
101
+ ```c
102
+ double *alloc_random_array(int *out_len)
103
+ /* Return a freshly malloc'ed array
104
+ Contract: len(return)=out_len; own(return);
105
+ */
106
+ ```
107
+
108
+ Rules are parsed case-insensitively; whitespace does not matter.
109
+
110
+ ## Python wrapper behavior
111
+
112
+ - **Automatic allocation**: any `out_*` argument defaults to `None` in Python; TinyCWrap allocates it based on contracts, fixed array sizes, or by matching the shape of a related input (`out_x` matches `x` when no contract is present).
113
+ - **Struct pointer outputs**: non-const struct pointers are treated as in/out; when you pass an object/array explicitly, it is mutated in place and not returned. When you pass `None`, TinyCWrap allocates and returns the struct (or struct array).
114
+ - **Optional length arguments**: integer length parameters inferred from contracts default to `None` in the wrapper signature. If you pass them, they are cast to `int`; if not, the expression from the contract is evaluated.
115
+ - **Post contracts**: when a contract uses `postlen(...)` or `post shape(...)`, the wrapper slices/reshapes outputs after the C call using the values written by the C function.
116
+ - **Scalar pointer outputs**: pointers to integer-like types (e.g., `int *out_len`) are returned as plain Python integers alongside other outputs.
117
+ - **Structs**: for `typedef struct` declarations TinyCWrap generates a Python class:
118
+ - fields are accessible as properties backed by a NumPy structured dtype (`Name.dtype`);
119
+ - scalar struct outputs return `Name` objects;
120
+ - struct-array outputs return compact `NameArray` wrappers, with field arrays as properties (`points.x`), scalar items as `Name` objects (`points[0]`), and Python construction as `NameArray(array_like)` or `NameArray(x=..., y=...)`;
121
+ - pointer fields paired with `len_<field>` automatically allocate NumPy arrays when you instantiate the struct with `len_field` or when you pass an array for that field;
122
+ - `_data` holds the underlying structured array, `Name.zeros(n)` returns a raw array of that dtype, and `NameArray.zeros(n)` returns the typed wrapper.
123
+ - **Reloading**: inside IPython, pass `reload=True` (default) to auto-recompile before each cell if the C sources changed.
124
+
125
+ ## Worked examples
126
+
127
+ ### Two outputs and automatic lengths
128
+
129
+ ```c
130
+ void split_vectors(const double *inp, int len_inp,
131
+ double *out_even, double *out_odd)
132
+ /* Split even/odd elements
133
+ Contract: len_inp=len(inp); len(out_even)=len_inp/2; len(out_odd)=len_inp/2;
134
+ */
135
+ ```
136
+
137
+ ```python
138
+ data = np.array([0, 1, 2, 3, 4, 5], dtype=np.float64)
139
+ out_even, out_odd = cm.split_vectors(data) # lengths inferred, arrays allocated
140
+ ```
141
+
142
+ ### Structs and struct arrays
143
+
144
+ ```c
145
+ typedef struct {
146
+ double real;
147
+ double imag;
148
+ } ComplexPair;
149
+
150
+ double complex_magnitude(const ComplexPair *z);
151
+ ```
152
+
153
+ ```python
154
+ cp = cm.ComplexPair(real=3.0, imag=4.0)
155
+ cm.complex_magnitude(cp) # -> 5.0
156
+
157
+ # struct arrays are NumPy dtypes; useful when C expects pointers to arrays of structs
158
+ pairs = cm.ComplexPair.zeros(2)
159
+ pairs["real"] = [1.0, 2.0]
160
+ pairs["imag"] = [0.0, -1.0]
161
+ ```
162
+
163
+ ### Owned return value
164
+
165
+ ```c
166
+ double *alloc_random_array(int *out_len)
167
+ /* Contract: len(return)=out_len; own(return); */
168
+ ```
169
+
170
+ ```python
171
+ arr, n = cm.alloc_random_array() # returns NumPy array, frees the C buffer
172
+ ```
173
+
174
+ ## Tips
175
+
176
+ - Keep function declarations (or headers) visible at the top level; TinyCWrap scans the C files and headers you pass.
177
+ - If a length cannot be inferred from a contract, you must pass it explicitly from Python.
178
+ - Use `cm.<func>.__source__` to inspect the wrapper code if something behaves unexpectedly.
179
+ - For debugging parsed signatures/contracts call `cm._debug_specs()`.
180
+
181
+ That is all you need to start turning small C helpers into ergonomic Python callables.
tinycwrap-0.0.7/PKG-INFO DELETED
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: tinycwrap
3
- Version: 0.0.7
4
- Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
- Author: TinyCWrap Contributors
6
- Requires-Python: >=3.9
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: numpy>=1.24
9
- Requires-Dist: cffi>=1.15
10
-
11
- # tinycwrap
12
-
13
- TinyCWrap provides a small helper (`CModule`) to compile C sources with CFFI, auto-generate `cdef`s, and expose NumPy-friendly Python wrappers.
tinycwrap-0.0.7/README.md DELETED
@@ -1,3 +0,0 @@
1
- # tinycwrap
2
-
3
- TinyCWrap provides a small helper (`CModule`) to compile C sources with CFFI, auto-generate `cdef`s, and expose NumPy-friendly Python wrappers.
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: tinycwrap
3
- Version: 0.0.7
4
- Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
- Author: TinyCWrap Contributors
6
- Requires-Python: >=3.9
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: numpy>=1.24
9
- Requires-Dist: cffi>=1.15
10
-
11
- # tinycwrap
12
-
13
- TinyCWrap provides a small helper (`CModule`) to compile C sources with CFFI, auto-generate `cdef`s, and expose NumPy-friendly Python wrappers.
File without changes
File without changes
File without changes