tinycwrap 0.0.0__py3-none-any.whl

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.
tinycwrap/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from .cmodule import CModule
4
+
5
+
6
+ try:
7
+ __version__ = version("tinycwrap")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
10
+
11
+ __all__ = ["CModule", "__version__"]
tinycwrap/cmodule.py ADDED
@@ -0,0 +1,507 @@
1
+ import re
2
+ import hashlib
3
+ import tempfile
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from cffi import FFI
10
+
11
+
12
+ # ---------------- helpers --------------------------------------------------
13
+
14
+
15
+ def _strip_restrict_keywords(text: str) -> str:
16
+ """Remove C restrict qualifiers (including compiler-specific variants)."""
17
+ return re.sub(r"\b(__restrict__|__restrict|restrict)\b", "", text)
18
+
19
+
20
+ def _base_type_from_ctype(ctype: str) -> str:
21
+ """Normalize base C type (strip const, *, etc.)."""
22
+ ctype = _strip_restrict_keywords(ctype)
23
+ ctype = ctype.replace("const", "").replace("volatile", "")
24
+ ctype = ctype.replace("*", "").strip()
25
+ return " ".join(ctype.split())
26
+
27
+
28
+ def _numpy_dtype_for_base_type(base: str):
29
+ """Map base C type -> numpy dtype."""
30
+ if base == "double":
31
+ return np.float64
32
+ if base == "float":
33
+ return np.float32
34
+ if base in ("int", "signed int"):
35
+ return np.int32
36
+ if base in ("long long", "long long int", "signed long long"):
37
+ return np.int64
38
+ if base in ("unsigned int", "unsigned"):
39
+ return np.uint32
40
+ # Extend here if you need more types
41
+ raise TypeError(f"Unsupported C base type for NumPy mapping: {base!r}")
42
+
43
+
44
+ def _is_length_name(name: str) -> bool:
45
+ name = name.lower()
46
+ if name in ("n", "len", "length", "size"):
47
+ return True
48
+ return name.startswith("len_") or name.startswith("n_") or name.startswith("size_")
49
+
50
+
51
+ @dataclass
52
+ class ArgSpec:
53
+ name: str
54
+ raw_ctype: str
55
+ base_type: str
56
+ is_pointer: bool
57
+ is_const: bool
58
+ is_length_param: bool = False
59
+ is_array_in: bool = False
60
+ is_array_out: bool = False
61
+ is_scalar: bool = False
62
+
63
+
64
+ @dataclass
65
+ class FuncSpec:
66
+ name: str
67
+ return_ctype: str
68
+ args: list # list[ArgSpec]
69
+ doc: str | None = None
70
+
71
+
72
+ # ---------------- main class ----------------------------------------------
73
+
74
+
75
+ class CModule:
76
+ """
77
+ Compile & hot-reload a C file into a shared library and:
78
+
79
+ * auto-generate CFFI cdef from function definitions
80
+ * auto-generate NumPy-friendly Python wrappers
81
+
82
+ Conventions:
83
+
84
+ * We export all **non-static** functions found in the C file.
85
+ * Arguments:
86
+ - `const double *x` -> input NumPy array
87
+ - `double *x` -> in-place / output NumPy array
88
+ - `int len_x`, `int n`, `int size_x` -> length parameter (hidden)
89
+ - other scalars (int/double/float/long long) -> Python scalars
90
+ * A block comment immediately after the function header is used
91
+ as the Python docstring, e.g.:
92
+
93
+ double dot(const double *x, const double *y, int len_x)
94
+ /* Return dot product between x and y */
95
+ {
96
+ ...
97
+ }
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ c_path,
103
+ cdef: str | None = None,
104
+ extra_sources=None,
105
+ include_dirs=None,
106
+ compiler="gcc",
107
+ compile_args=None,
108
+ auto_wrap=True,
109
+ auto_cdef=True,
110
+ ):
111
+ self.c_path = Path(c_path)
112
+ self.extra_sources = [Path(p) for p in (extra_sources or [])]
113
+ self.include_dirs = list(include_dirs or [])
114
+ self.compiler = compiler
115
+ self.compile_args = compile_args or ["-O3", "-shared", "-fPIC"]
116
+ self.auto_wrap = auto_wrap
117
+ self.auto_cdef = auto_cdef
118
+
119
+ self._ffi = None
120
+ self._lib = None
121
+ self._sig = None
122
+ self._so_path = None
123
+
124
+ # will be filled after parsing
125
+ self._func_specs: dict[str, FuncSpec] = {}
126
+
127
+ # if cdef not provided, generate it from C file
128
+ if cdef is None and auto_cdef:
129
+ cdef = self._generate_cdef_from_source()
130
+ self.cdef = cdef
131
+
132
+ self.ensure_compiled()
133
+
134
+ # ---------- build & reload ---------------------------------------------
135
+
136
+ def _compute_sig(self):
137
+ h = hashlib.sha1()
138
+ all_paths = [self.c_path] + self.extra_sources
139
+ for p in all_paths:
140
+ st = p.stat()
141
+ h.update(str(p.resolve()).encode("utf-8"))
142
+ h.update(str(st.st_mtime_ns).encode("utf-8"))
143
+ return h.hexdigest()[:16]
144
+
145
+ def _needs_recompile(self):
146
+ if self._sig is None:
147
+ return True
148
+ return self._compute_sig() != self._sig
149
+
150
+ def _compile_and_load(self):
151
+ sig = self._compute_sig()
152
+ build_dir = Path(tempfile.gettempdir()) / "cmodule_build"
153
+ build_dir.mkdir(parents=True, exist_ok=True)
154
+
155
+ so_name = f"cmodule_{self.c_path.stem}_{sig}.so"
156
+ so_path = build_dir / so_name
157
+
158
+ cmd = [self.compiler, *self.compile_args]
159
+
160
+ include_dirs = self.include_dirs + [np.get_include()]
161
+ for inc in include_dirs:
162
+ cmd.extend(["-I", str(inc)])
163
+
164
+ sources = [str(self.c_path), *(str(p) for p in self.extra_sources)]
165
+ cmd.extend(["-o", str(so_path), *sources])
166
+
167
+ print(f"[CModule] Compiling: {' '.join(cmd)}")
168
+ subprocess.run(cmd, check=True)
169
+
170
+ ffi = FFI()
171
+ ffi.cdef(self.cdef)
172
+ lib = ffi.dlopen(str(so_path))
173
+
174
+ self._ffi = ffi
175
+ self._lib = lib
176
+ self._sig = sig
177
+ self._so_path = so_path
178
+
179
+ print(f"[CModule] Loaded {so_path}")
180
+
181
+ # Re-parse cdef and attach docs from C source, then create wrappers
182
+ if self.auto_wrap:
183
+ self._func_specs = self._parse_cdef(self.cdef)
184
+ self._attach_docs_from_source()
185
+ self._create_wrappers()
186
+
187
+ def ensure_compiled(self):
188
+ if self._needs_recompile():
189
+ self._compile_and_load()
190
+
191
+ # ---------- properties ---------------------------------------------------
192
+
193
+ @property
194
+ def ffi(self):
195
+ self.ensure_compiled()
196
+ return self._ffi
197
+
198
+ @property
199
+ def lib(self):
200
+ self.ensure_compiled()
201
+ return self._lib
202
+
203
+ # ---------- 1) auto-generate cdef from C source -------------------------
204
+
205
+ def _generate_cdef_from_source(self) -> str:
206
+ """
207
+ Parse the C file and auto-generate a minimal cdef string.
208
+
209
+ We look for *definitions* of non-static functions of the form:
210
+
211
+ [static] <ret> name(<args>)
212
+ /* optional doc */
213
+ {
214
+
215
+ and turn them into:
216
+ <ret> name(<args>);
217
+ """
218
+ src = self.c_path.read_text(encoding="utf8")
219
+
220
+ # Remove preprocessor lines to simplify
221
+ src_wo_pp = re.sub(r"^\s*#.*$", "", src, flags=re.MULTILINE)
222
+
223
+ # regex for function definitions
224
+ func_def_re = re.compile(
225
+ r"""
226
+ (?P<prefix>static\s+)? # optional 'static'
227
+ (?P<ret>[^{}();]+?) # return type
228
+ \s+
229
+ (?P<name>\w+)\s* # function name
230
+ \(
231
+ (?P<args>[^)]*) # arguments (no nested parentheses)
232
+ \)
233
+ \s*
234
+ (?:/\*.*?\*/\s*)? # optional trailing comment
235
+ \{ # function body begins
236
+ """,
237
+ re.VERBOSE | re.DOTALL,
238
+ )
239
+
240
+ prototypes = []
241
+
242
+ for m in func_def_re.finditer(src_wo_pp):
243
+ if m.group("prefix") and "static" in m.group("prefix"):
244
+ # ignore static functions, not exported
245
+ continue
246
+ ret = " ".join(_strip_restrict_keywords(m.group("ret")).split())
247
+ name = m.group("name")
248
+ args = " ".join(_strip_restrict_keywords(m.group("args")).split())
249
+ proto = f"{ret} {name}({args});"
250
+ prototypes.append(proto)
251
+
252
+ if not prototypes:
253
+ raise RuntimeError(f"No functions found in {self.c_path} to generate cdef")
254
+
255
+ cdef = "\n".join(prototypes)
256
+ print("[CModule] Auto-generated cdef:\n" + cdef)
257
+ return cdef
258
+
259
+ # ---------- 2) parse cdef -> FuncSpec/ArgSpec ---------------------------
260
+
261
+ def _parse_cdef(self, cdef: str) -> dict[str, FuncSpec]:
262
+ text = cdef
263
+ text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
264
+ text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE)
265
+ text = _strip_restrict_keywords(text)
266
+
267
+ decls = []
268
+ buff = []
269
+ for line in text.splitlines():
270
+ line = line.strip()
271
+ if not line:
272
+ continue
273
+ buff.append(line)
274
+ if ";" in line:
275
+ decl = " ".join(buff)
276
+ decls.append(decl)
277
+ buff = []
278
+
279
+ func_re = re.compile(r"(.+?)\s+(\w+)\s*\((.*?)\)\s*;")
280
+
281
+ funcs: dict[str, FuncSpec] = {}
282
+
283
+ for decl in decls:
284
+ m = func_re.match(decl)
285
+ if not m:
286
+ continue
287
+ ret_ctype, fname, arglist = m.groups()
288
+ ret_ctype = ret_ctype.strip()
289
+ arglist = arglist.strip()
290
+
291
+ if arglist == "void" or arglist == "":
292
+ argspecs = []
293
+ else:
294
+ argspecs = []
295
+
296
+ for raw_arg in re.split(r"\s*,\s*", arglist):
297
+ raw_arg = raw_arg.strip()
298
+ if not raw_arg:
299
+ continue
300
+
301
+ # Match: [type stuff possibly with *] [name]
302
+ # Handles: "const double *x", "double* x", "double * x", etc.
303
+ m_arg = re.match(r"(.+?)\s*([A-Za-z_]\w*)$", raw_arg)
304
+ if not m_arg:
305
+ # couldn't parse this arg, skip or raise
306
+ # raise ValueError(f"Cannot parse argument: {raw_arg!r}")
307
+ continue
308
+
309
+ ctype = m_arg.group(1).strip()
310
+ name = m_arg.group(2)
311
+
312
+ is_pointer = "*" in ctype
313
+ is_const = "const" in ctype
314
+ base = _base_type_from_ctype(ctype)
315
+
316
+ aspec = ArgSpec(
317
+ name=name,
318
+ raw_ctype=ctype,
319
+ base_type=base,
320
+ is_pointer=is_pointer,
321
+ is_const=is_const,
322
+ )
323
+ argspecs.append(aspec)
324
+
325
+
326
+ # classify
327
+ for a in argspecs:
328
+ if (not a.is_pointer) and _is_length_name(a.name) and a.base_type in (
329
+ "int",
330
+ "unsigned int",
331
+ "unsigned",
332
+ ):
333
+ a.is_length_param = True
334
+
335
+ for a in argspecs:
336
+ if a.is_pointer:
337
+ if a.is_const:
338
+ a.is_array_in = True
339
+ else:
340
+ a.is_array_out = True
341
+ else:
342
+ if not a.is_length_param:
343
+ a.is_scalar = True
344
+
345
+ funcs[fname] = FuncSpec(name=fname, return_ctype=ret_ctype, args=argspecs)
346
+
347
+ return funcs
348
+
349
+ # ---------- 3) attach docstrings from C comments ------------------------
350
+
351
+ def _attach_docs_from_source(self):
352
+ try:
353
+ src = self.c_path.read_text(encoding="utf8")
354
+ except OSError:
355
+ return
356
+
357
+ for fname, fspec in self._func_specs.items():
358
+ # we look for: name ( ... ) /* ... */
359
+ pattern = rf"{re.escape(fname)}\s*\([^{{;]*\)\s*/\*(.*?)\*/"
360
+ m = re.search(pattern, src, flags=re.DOTALL)
361
+ if not m:
362
+ continue
363
+ doc = m.group(1).strip()
364
+ doc = re.sub(r"\s+\n", "\n", doc)
365
+ doc = re.sub(r"\n\s+", "\n", doc)
366
+ fspec.doc = doc
367
+
368
+ # ---------- 4) create NumPy wrappers ------------------------------------
369
+
370
+ def _create_wrappers(self):
371
+ for fname, fspec in self._func_specs.items():
372
+ if hasattr(self, fname):
373
+ continue
374
+ try:
375
+ wrapper = self._make_wrapper_from_spec(fspec)
376
+ except NotImplementedError:
377
+ # too complex for our heuristics, skip
378
+ continue
379
+ setattr(self, fname, wrapper)
380
+
381
+ def _make_wrapper_from_spec(self, fspec: FuncSpec):
382
+ array_args = [a for a in fspec.args if a.is_array_in or a.is_array_out]
383
+ scalar_args = [a for a in fspec.args if a.is_scalar]
384
+ length_args = [a for a in fspec.args if a.is_length_param]
385
+
386
+ if len(length_args) > 1:
387
+ raise NotImplementedError(
388
+ f"{fspec.name}: multiple length params not supported yet"
389
+ )
390
+
391
+ # validate array types
392
+ for a in array_args:
393
+ _numpy_dtype_for_base_type(a.base_type)
394
+
395
+ def wrapper(*args):
396
+ self.ensure_compiled()
397
+ lib = self.lib
398
+ ffi = self.ffi
399
+ cfun = getattr(lib, fspec.name)
400
+
401
+ out_array_specs = [
402
+ a for a in array_args if a.is_array_out and a.name.lower().startswith("out")
403
+ ]
404
+ in_array_specs = [a for a in array_args if a not in out_array_specs]
405
+
406
+ if len(args) != len(in_array_specs) + len(scalar_args):
407
+ raise TypeError(
408
+ f"{fspec.name} expects {len(in_array_specs)} array args and "
409
+ f"{len(scalar_args)} scalar args, got {len(args)}"
410
+ )
411
+
412
+ py_array_vals = args[: len(in_array_specs)]
413
+ py_scalar_vals = args[len(in_array_specs) :]
414
+
415
+ # prepare arrays
416
+ c_array_ptrs = []
417
+ lengths = []
418
+ output_arrays: list[np.ndarray] = []
419
+
420
+ # user-provided (input) arrays
421
+ for a, val in zip(in_array_specs, py_array_vals):
422
+ base_dtype = _numpy_dtype_for_base_type(a.base_type)
423
+ arr = np.ascontiguousarray(val, dtype=base_dtype)
424
+ lengths.append(arr.size)
425
+ ctype = f"{'const ' if a.is_const else ''}{a.base_type} *"
426
+ ptr = ffi.cast(ctype, ffi.from_buffer(arr))
427
+ c_array_ptrs.append((a, arr, ptr))
428
+
429
+ # auto-created output arrays
430
+ for a in out_array_specs:
431
+ base_dtype = _numpy_dtype_for_base_type(a.base_type)
432
+ ref_arr = None
433
+ if a.name.lower().startswith("out_"):
434
+ ref_name = a.name[4:]
435
+ for (aa, arr, _ptr) in c_array_ptrs:
436
+ if aa.name == ref_name:
437
+ ref_arr = arr
438
+ break
439
+ if ref_arr is not None:
440
+ arr = np.empty_like(ref_arr, dtype=base_dtype)
441
+ else:
442
+ target_len = lengths[0] if lengths else None
443
+ if target_len is None:
444
+ raise ValueError(
445
+ f"{fspec.name}: cannot determine length for output array {a.name}"
446
+ )
447
+ arr = np.empty(target_len, dtype=base_dtype)
448
+ lengths.append(arr.size)
449
+ ctype = f"{a.base_type} *"
450
+ ptr = ffi.cast(ctype, ffi.from_buffer(arr))
451
+ c_array_ptrs.append((a, arr, ptr))
452
+ output_arrays.append(arr)
453
+
454
+ if lengths:
455
+ n0 = lengths[0]
456
+ for ln in lengths[1:]:
457
+ if ln != n0:
458
+ raise ValueError("Array arguments must have same length")
459
+ inferred_len = n0
460
+ else:
461
+ inferred_len = None
462
+
463
+ scalar_vals_iter = iter(py_scalar_vals)
464
+ c_args = []
465
+
466
+ for a in fspec.args:
467
+ if a.is_array_in or a.is_array_out:
468
+ for (aa, arr, ptr) in c_array_ptrs:
469
+ if aa is a:
470
+ c_args.append(ptr)
471
+ break
472
+ else:
473
+ raise RuntimeError("Internal error mapping array arg")
474
+ elif a.is_length_param:
475
+ if inferred_len is None:
476
+ raise ValueError(
477
+ f"{fspec.name}: cannot infer length for {a.name}"
478
+ )
479
+ c_args.append(inferred_len)
480
+ elif a.is_scalar:
481
+ try:
482
+ sv = next(scalar_vals_iter)
483
+ except StopIteration:
484
+ raise TypeError("Not enough scalar arguments")
485
+ c_args.append(sv)
486
+ else:
487
+ raise RuntimeError("Arg classification inconsistent")
488
+
489
+ res = cfun(*c_args)
490
+
491
+ if output_arrays:
492
+ outputs = output_arrays[0] if len(output_arrays) == 1 else tuple(output_arrays)
493
+ if fspec.return_ctype.strip() == "void":
494
+ return outputs
495
+ return outputs, res
496
+
497
+ if fspec.return_ctype.strip() == "void":
498
+ return None
499
+ else:
500
+ return res
501
+
502
+ wrapper.__name__ = fspec.name
503
+ doc_lines = [f"Auto-wrapped C function `{fspec.name}`."]
504
+ if fspec.doc:
505
+ doc_lines.append(fspec.doc)
506
+ wrapper.__doc__ = "\n".join(doc_lines)
507
+ return wrapper
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinycwrap
3
+ Version: 0.0.0
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.
@@ -0,0 +1,6 @@
1
+ tinycwrap/__init__.py,sha256=1mwJcIAulfsBoVSUQXRz_zz4b3Dh2yhKGmvchJBUhjA,230
2
+ tinycwrap/cmodule.py,sha256=4_PAFuxbJbdbFpETjGeRHLqWAyGuCS9yRsMjcWg9LyI,16972
3
+ tinycwrap-0.0.0.dist-info/METADATA,sha256=lAf9f80uJKGtgxVlYPVpONlae8ul0Oj_Y-TqANPfcsk,430
4
+ tinycwrap-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ tinycwrap-0.0.0.dist-info/top_level.txt,sha256=p2bqckqsfD7QKVnH2eC3rt73_QvPwLmtqigrH5XSvbA,10
6
+ tinycwrap-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tinycwrap