tinycwrap 0.0.1__tar.gz → 0.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinycwrap
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
5
  Author: TinyCWrap Contributors
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tinycwrap"
7
- version = "0.0.1"
7
+ version = "0.0.3"
8
8
  description = "Lightweight C-to-Python wrapper generator using CFFI and NumPy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,4 +40,24 @@ def test_geom2d_return_strct(cg):
40
40
  assert seg.data[0] == 0.1
41
41
  assert seg.data[1] == 0.2
42
42
  assert np.isclose(seg.data[2], 0.1+0.3*0.5)
43
- assert np.isclose(seg.data[3], 0.2+0.4*0.3)
43
+ assert np.isclose(seg.data[3], 0.2+0.4*0.3)
44
+
45
+
46
+ def test_geom2d_return_strct_array_member(cg):
47
+ seg = cg.geom2d_rectangle_to_path(1,2)
48
+ assert len(seg) == 4
49
+
50
+
51
+ def test_docstring_contains_contract(cg):
52
+ doc = cg.geom2d_rectangle_to_path.__doc__
53
+ assert "Contract: len(out_segments)=4" in doc
54
+ assert "Auto-wrapped C function `geom2d_rectangle_to_path`." in doc
55
+
56
+
57
+ def test_polygon_length(cg):
58
+ pts = cg.G2DPoints(len_points=3)
59
+ pts.points[0]["x"] = 0.0; pts.points[0]["y"] = 0.0
60
+ pts.points[1]["x"] = 1.0; pts.points[1]["y"] = 0.0
61
+ pts.points[2]["x"] = 1.0; pts.points[2]["y"] = 1.0
62
+ length = cg.geom2d_polygon_length(pts.points)
63
+ assert length == 2.0
@@ -0,0 +1,41 @@
1
+ from pathlib import Path
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from tinycwrap import CModule
7
+
8
+
9
+ @pytest.fixture(scope="module")
10
+ def cp():
11
+ return CModule(Path("tests/t1/base.c"), Path("tests/t1/path.c"))
12
+
13
+
14
+ def test_path_get_steps_contract(cp):
15
+ seg = cp.G2DSegment()
16
+ # line from (0,0) to (1,0)
17
+ seg.type = 0
18
+ seg.data = [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
19
+ segments = np.array([seg._data], dtype=cp.G2DSegment.dtype)
20
+ steps = cp.geom2d_path_get_steps(segments, ds_min=0.25)
21
+ expected_len = cp.geom2d_path_get_len_steps(segments, len_segments=len(segments), ds_min=0.25)
22
+ assert len(steps) == expected_len
23
+ assert np.isclose(steps[-1], cp.geom2d_path_get_length(segments, len_segments=1))
24
+
25
+
26
+ def test_path_struct_pointer_array(cp):
27
+ path = cp.G2DPath(len_segments=3)
28
+ assert path.len_segments == 3
29
+ assert isinstance(path.segments, np.ndarray)
30
+ assert path.segments.shape == (3,)
31
+ assert path.segments.dtype == cp.G2DSegment.dtype
32
+
33
+
34
+ def test_return_class_path(cp):
35
+ path=cp.G2DPath(len_segments=1)
36
+ cp.geom2d_path_from_circle(1.0, path)
37
+ assert path.len_segments == 1
38
+ assert path.segments.shape == (1,)
39
+ seg = path.segments[0]
40
+ assert seg['type'] == 1 # circle segment
41
+ np.testing.assert_allclose(seg['data'][:3], [0.0, 0.0, 1.0]) # cx,cy,radius
@@ -6,6 +6,6 @@ from .cmodule import CModule
6
6
  try:
7
7
  __version__ = version("tinycwrap")
8
8
  except PackageNotFoundError:
9
- __version__ = "0.0.1"
9
+ __version__ = "0.0.3"
10
10
 
11
11
  __all__ = ["CModule", "__version__"]
@@ -53,6 +53,7 @@ class CModule:
53
53
  compiler="gcc",
54
54
  compile_args=None,
55
55
  reload=True,
56
+ verbose=False,
56
57
  ):
57
58
  """
58
59
  Parameters
@@ -69,6 +70,8 @@ class CModule:
69
70
  reload : bool, default True
70
71
  If True and running inside IPython, register a pre-run-cell hook to auto-recompile
71
72
  when the C source changes.
73
+ verbose : bool, default False
74
+ If True, print compilation and parsing details.
72
75
  """
73
76
  if not c_sources:
74
77
  raise ValueError("At least one C source path is required")
@@ -80,6 +83,7 @@ class CModule:
80
83
  "compile_args": compile_args
81
84
  or ["-O3", "-shared", "-fPIC", "-march=native", "-mtune=native"],
82
85
  }
86
+ self._verbose = verbose
83
87
 
84
88
  self._ffi = None
85
89
  self._lib = None
@@ -136,12 +140,13 @@ class CModule:
136
140
  cmd.extend(["-I", str(inc)])
137
141
 
138
142
  # regenerate cdef from source
139
- self._cdef = self._generate_cdef_from_source(verbose=self._cdef is None)
143
+ self._cdef = self._generate_cdef_from_source(verbose=self._verbose)
140
144
 
141
145
  sources = [str(self._c_path), *(str(p) for p in self._extra_sources)]
142
146
  cmd.extend(["-o", str(so_path), *sources])
143
147
 
144
- print(f"[CModule] Compiling: {' '.join(cmd)}")
148
+ if self._verbose:
149
+ print(f"[CModule] Compiling: {' '.join(cmd)}")
145
150
  subprocess.run(cmd, check=True)
146
151
 
147
152
  ffi = FFI()
@@ -153,7 +158,8 @@ class CModule:
153
158
  self._sig = sig
154
159
  self._so_path = so_path
155
160
 
156
- print(f"[CModule] Loaded {so_path}")
161
+ if self._verbose:
162
+ print(f"[CModule] Loaded {so_path}")
157
163
 
158
164
  # Re-parse cdef and attach docs from C source, then create wrappers
159
165
  self._func_specs = parse_functions_from_cdef(self._cdef)
@@ -187,6 +193,23 @@ class CModule:
187
193
  self._so_path = None
188
194
  self._compile_and_load()
189
195
 
196
+ def _debug_specs(self):
197
+ """Return a pretty string of parsed function specs and contracts (for debugging)."""
198
+ lines: list[str] = []
199
+ for name, spec in self._func_specs.items():
200
+ arg_lines = [f" {a.raw_ctype} {a.name}" for a in spec.args]
201
+ if arg_lines:
202
+ lines.append(f"{name}")
203
+ lines.extend(arg_lines)
204
+ lines.append(f" -> {spec.return_ctype}")
205
+ else:
206
+ lines.append(f"{name}() -> {spec.return_ctype}")
207
+ if spec.contracts:
208
+ for target, expr, is_post in spec.contracts:
209
+ prefix = "Post-Contract" if is_post else "Contract"
210
+ lines.append(f" {prefix}: {target} = {expr}")
211
+ print("\n".join(lines))
212
+
190
213
  # ---------- IPython auto-reload hook ------------------------------------
191
214
 
192
215
  def _register_ipython_autoreload(self):
@@ -230,7 +253,7 @@ class CModule:
230
253
 
231
254
  # ---------- 1) auto-generate cdef from C source -------------------------
232
255
 
233
- def _generate_cdef_from_source(self, verbose: bool = True) -> str:
256
+ def _generate_cdef_from_source(self, verbose: bool | None = None) -> str:
234
257
  """
235
258
  Parse the C file and auto-generate a minimal cdef string.
236
259
 
@@ -334,21 +357,21 @@ class CModule:
334
357
  continue
335
358
  cdef_lines.append(line)
336
359
  cdef = "\n".join(cdef_lines)
337
- if verbose:
360
+ show = self._verbose if verbose is None else verbose
361
+ if show:
338
362
  print("[CModule] Auto-generated cdef:\n" + cdef)
339
363
  return cdef
340
364
 
341
365
  # ---------- 2) parse cdef -> FuncSpec/ArgSpec ---------------------------
342
366
 
343
367
  def _attach_docs_from_source(self):
344
- try:
345
- src = self._c_path.read_text(encoding="utf8")
346
- except OSError:
368
+ src = self._combined_source()
369
+ if not src:
347
370
  return
348
371
 
349
372
  for fname, fspec in self._func_specs.items():
350
- # we look for: name ( ... ) /* ... */
351
- pattern = rf"{re.escape(fname)}\s*\([^{{;]*\)\s*/\*(.*?)\*/"
373
+ # we look for: name ( ... ) [optional brace] /* ... */
374
+ pattern = rf"{re.escape(fname)}\s*\([^{{;]*\)\s*\{{?\s*/\*(.*?)\*/"
352
375
  m = re.search(pattern, src, flags=re.DOTALL)
353
376
  if not m:
354
377
  continue
@@ -439,8 +462,19 @@ class CModule:
439
462
  for sname, sspec in self._struct_specs.items():
440
463
  try:
441
464
  dtype_fields = []
465
+ pointer_meta = []
442
466
  for f in sspec.fields:
443
- base_dtype = numpy_dtype_for_base_type(f.base_type)
467
+ try:
468
+ base_dtype = numpy_dtype_for_base_type(f.base_type)
469
+ except TypeError:
470
+ base_dtype = None
471
+ if f.is_pointer:
472
+ dtype_fields.append((f.name, np.uintp))
473
+ pointer_meta.append(f)
474
+ if base_dtype is None:
475
+ continue
476
+ if base_dtype is None:
477
+ continue
444
478
  if f.array_len:
445
479
  dtype_fields.append((f.name, (base_dtype, (f.array_len,))))
446
480
  else:
@@ -449,15 +483,35 @@ class CModule:
449
483
  except TypeError:
450
484
  continue
451
485
 
452
- def make_struct_class(spec, dtype):
453
- slots = ("_data",)
486
+ def make_struct_class(spec, dtype, _struct_dtypes=self._struct_dtypes):
487
+ slots = ("_data", "_children")
454
488
 
455
489
  def __init__(self, **kwargs):
456
490
  data = np.zeros((), dtype=dtype)
491
+ children = {}
492
+ # handle pointer+len convention
493
+ for f in spec.fields:
494
+ if f.is_pointer and f.name in dtype.names:
495
+ len_field = f"len_{f.name}"
496
+ target_dtype = _struct_dtypes.get(f.base_type)
497
+ provided = kwargs.get(f.name)
498
+ if provided is not None and target_dtype is not None:
499
+ arr = np.ascontiguousarray(provided, dtype=target_dtype)
500
+ children[f.name] = arr
501
+ data[f.name] = arr.ctypes.data
502
+ if len_field in dtype.names and len_field not in kwargs:
503
+ data[len_field] = len(arr)
504
+ elif len_field in kwargs and target_dtype is not None:
505
+ llen = int(kwargs[len_field])
506
+ arr = np.zeros(llen, dtype=target_dtype)
507
+ children[f.name] = arr
508
+ data[f.name] = arr.ctypes.data
509
+ data[len_field] = llen
457
510
  for k in dtype.names:
458
511
  if k in kwargs:
459
512
  data[k] = kwargs[k]
460
513
  object.__setattr__(self, "_data", data)
514
+ object.__setattr__(self, "_children", children)
461
515
 
462
516
  def __repr__(self):
463
517
  parts_list = []
@@ -485,7 +539,9 @@ class CModule:
485
539
  field_info = dtype.fields[fname][0]
486
540
  is_scalar = field_info.shape == ()
487
541
 
488
- def getter(self, fname=fname, is_scalar=is_scalar):
542
+ def getter(self, fname=fname, is_scalar=is_scalar, field_info=field_info):
543
+ if fname in getattr(self, "_children", {}):
544
+ return self._children[fname]
489
545
  val = self._data[fname]
490
546
  return val.item() if is_scalar else val
491
547
 
@@ -496,6 +552,11 @@ class CModule:
496
552
  field_info=field_info,
497
553
  is_scalar=is_scalar,
498
554
  ):
555
+ if fname in getattr(self, "_children", {}):
556
+ arr = np.ascontiguousarray(value, dtype=self._children[fname].dtype)
557
+ self._children[fname] = arr
558
+ self._data[fname] = arr.ctypes.data
559
+ return
499
560
  if is_scalar:
500
561
  self._data[fname] = value
501
562
  else:
@@ -550,7 +611,7 @@ class CModule:
550
611
  wrapper = namespace[fspec.name]
551
612
  wrapper.__source__ = src
552
613
  try:
553
- wrapper.__c_source__ = self._c_path.read_text(encoding="utf8")
614
+ wrapper.__c_source__ = self._function_source(fspec.name) or self._combined_source()
554
615
  except OSError:
555
616
  wrapper.__c_source__ = None
556
617
  return wrapper
@@ -560,10 +621,13 @@ class CModule:
560
621
  Build the Python source string for a wrapper with an explicit signature.
561
622
  Keeping this separate allows inspection/debugging of the generated code.
562
623
  """
563
- try:
564
- c_source_text = self._c_path.read_text(encoding="utf8")
565
- except OSError:
566
- c_source_text = None
624
+ src_parts: list[str] = []
625
+ func_text = self._function_source(fspec.name)
626
+ if func_text:
627
+ c_source_text = func_text
628
+ else:
629
+ combined = self._combined_source()
630
+ c_source_text = combined if combined else None
567
631
 
568
632
  struct_names = set(self._struct_specs.keys())
569
633
  params: list[str] = []
@@ -662,11 +726,27 @@ class CModule:
662
726
  )
663
727
  }
664
728
 
729
+ func_names = set(self._func_specs.keys())
730
+
665
731
  def _expr_py(expr: str) -> str:
666
732
  expr = expr.strip()
667
733
  expr = re.sub(r"len\((\w+)\)", r"len(arr_\1)", expr)
668
734
  for name in pointer_scalar_names:
669
735
  expr = re.sub(rf"\b{name}\b", f"int(arr_{name}.ravel()[0])", expr)
736
+ if func_names:
737
+ pattern = r"\b(" + "|".join(re.escape(n) for n in func_names) + r")\s*\(([^()]*)\)"
738
+
739
+ def _repl(m):
740
+ fname = m.group(1)
741
+ spec = self._func_specs.get(fname)
742
+ if not spec:
743
+ return m.group(0)
744
+ call_parts = []
745
+ for arg in spec.args:
746
+ call_parts.append(f"{arg.name}={arg.name}")
747
+ return f"_self.{fname}(" + ", ".join(call_parts) + ")"
748
+
749
+ expr = re.sub(pattern, _repl, expr)
670
750
  return expr
671
751
 
672
752
  call_args: list[str] = []
@@ -746,12 +826,12 @@ class CModule:
746
826
  ]
747
827
  if a.array_len is not None:
748
828
  out_lines += [
749
- f" arr_{a.name} = np.empty({int(a.array_len)}, dtype=base_dtype)"
829
+ f" arr_{a.name} = np.zeros({int(a.array_len)}, dtype=base_dtype)"
750
830
  ]
751
831
  elif a.name in contract_map:
752
832
  expr_py = _expr_py(contract_map[a.name])
753
833
  out_lines += [
754
- f" arr_{a.name} = np.empty(int({expr_py}), dtype=base_dtype)"
834
+ f" arr_{a.name} = np.zeros(int({expr_py}), dtype=base_dtype)"
755
835
  ]
756
836
  elif a.array_len is None and a.name not in contract_map:
757
837
  out_lines += [
@@ -761,7 +841,7 @@ class CModule:
761
841
  out_lines += [
762
842
  f" ref_arr = locals().get('arr_{ref_name}', None)",
763
843
  " if ref_arr is not None:",
764
- f" arr_{a.name} = np.empty_like(ref_arr, dtype=base_dtype)",
844
+ f" arr_{a.name} = np.zeros_like(ref_arr, dtype=base_dtype)",
765
845
  " else:",
766
846
  f" raise ValueError('{fspec.name}: provide {a.name} or a Contract for its length')",
767
847
  ]
@@ -912,3 +992,36 @@ class CModule:
912
992
  lines.append(f" return {ret_expr}")
913
993
 
914
994
  return "\n".join(lines)
995
+
996
+ def _combined_source(self) -> str:
997
+ texts: list[str] = []
998
+ for path in [self._c_path, *self._extra_sources]:
999
+ try:
1000
+ texts.append(path.read_text(encoding="utf8"))
1001
+ except OSError:
1002
+ continue
1003
+ return "\n".join(texts)
1004
+
1005
+ def _function_source(self, fname: str) -> str | None:
1006
+ """Return the source (including body) of the given C function if found."""
1007
+ src = self._combined_source()
1008
+ if not src:
1009
+ return None
1010
+ pattern = re.compile(
1011
+ rf"([\w\s\*\r\n]+{re.escape(fname)}\s*\([^{{;]*\))\s*(?:/\*.*?\*/\s*)?\{{",
1012
+ re.DOTALL,
1013
+ )
1014
+ m = pattern.search(src)
1015
+ if not m:
1016
+ return None
1017
+ brace_start = m.end() - 1
1018
+ depth = 0
1019
+ for idx in range(brace_start, len(src)):
1020
+ ch = src[idx]
1021
+ if ch == "{":
1022
+ depth += 1
1023
+ elif ch == "}":
1024
+ depth -= 1
1025
+ if depth == 0:
1026
+ return src[m.start() : idx + 1]
1027
+ return None
@@ -200,10 +200,6 @@ def _parse_structs_with_pycparser(cdef: str) -> dict[str, StructSpec]:
200
200
  for decl in struct.decls:
201
201
  ctype, is_ptr, is_const, arr_len = _ctype_from_decl(decl.type)
202
202
  base = base_type_from_ctype(ctype)
203
- try:
204
- numpy_dtype_for_base_type(base)
205
- except TypeError:
206
- continue
207
203
  fields.append(
208
204
  StructField(
209
205
  name=decl.name,
@@ -311,7 +307,7 @@ def _parse_structs_regex(cdef: str) -> dict[str, StructSpec]:
311
307
  if not line:
312
308
  continue
313
309
  line = re.sub(r"/\*.*?\*/", "", line).strip()
314
- m_field = re.match(r"(.+?)\s+([A-Za-z_]\w*)(\s*\[(\d+)\])?$", line)
310
+ m_field = re.match(r"(.+?\*?)\s*([A-Za-z_]\w*)(\s*\[(\d+)\])?$", line)
315
311
  if not m_field:
316
312
  continue
317
313
  raw_ctype = m_field.group(1).strip()
@@ -320,10 +316,6 @@ def _parse_structs_regex(cdef: str) -> dict[str, StructSpec]:
320
316
  is_pointer = "*" in raw_ctype
321
317
  is_const = "const" in raw_ctype
322
318
  base = base_type_from_ctype(raw_ctype)
323
- try:
324
- numpy_dtype_for_base_type(base)
325
- except TypeError:
326
- continue
327
319
  fields.append(
328
320
  StructField(
329
321
  name=fname,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinycwrap
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Lightweight C-to-Python wrapper generator using CFFI and NumPy
5
5
  Author: TinyCWrap Contributors
6
6
  Requires-Python: >=3.9
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  tests/test_examples.py
4
4
  tests/test_geom.py
5
+ tests/test_path.py
5
6
  tinycwrap/__init__.py
6
7
  tinycwrap/cmodule.py
7
8
  tinycwrap/parsing.py
File without changes
File without changes