fastpython-native 0.1.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.
@@ -0,0 +1,545 @@
1
+ """
2
+ AST Transpiler for FastPython.
3
+ Translates Python numerical functions to ultra-high-performance C99/C++ code
4
+ optimized for execution on the host CPU with AVX2 SIMD and OpenMP multi-threading.
5
+ """
6
+
7
+ import ast
8
+ import inspect
9
+ import textwrap
10
+ from typing import Dict, List, Tuple, Optional, Any, Set
11
+
12
+
13
+ TYPE_MAP = {
14
+ int: "int64_t",
15
+ float: "double",
16
+ bool: "bool",
17
+ "int": "int64_t",
18
+ "float": "double",
19
+ "bool": "bool",
20
+ "int64_t": "int64_t",
21
+ "double": "double",
22
+ "list": "double* __restrict",
23
+ "buffer": "double* __restrict",
24
+ "array": "double* __restrict",
25
+ "list[float]": "double* __restrict",
26
+ "list[int]": "int64_t* __restrict",
27
+ }
28
+
29
+ MATH_FUNCS = {
30
+ "sqrt": "sqrt",
31
+ "sin": "sin",
32
+ "cos": "cos",
33
+ "tan": "tan",
34
+ "exp": "exp",
35
+ "log": "log",
36
+ "log2": "log2",
37
+ "log10": "log10",
38
+ "fabs": "fabs",
39
+ "floor": "floor",
40
+ "ceil": "ceil",
41
+ "pow": "pow",
42
+ "hypot": "hypot",
43
+ "atan": "atan",
44
+ "atan2": "atan2",
45
+ "sinh": "sinh",
46
+ "cosh": "cosh",
47
+ "tanh": "tanh",
48
+ }
49
+
50
+
51
+ class TranspileError(Exception):
52
+ pass
53
+
54
+
55
+ class PythonToCTranspiler(ast.NodeVisitor):
56
+ def __init__(self, func_name: str, type_hints: Dict[str, str], parallel: bool = False):
57
+ self.func_name = func_name
58
+ self.type_hints = type_hints.copy()
59
+ self.parallel = parallel
60
+ self.indent_level = 1
61
+ self.declared_vars: Dict[str, str] = {} # var_name -> c_type
62
+ self.loop_depth = 0
63
+ self.code_lines: List[str] = []
64
+ self.return_type = self.type_hints.get("return", "double")
65
+ self.used_math_funcs: Set[str] = set()
66
+
67
+ def indent(self) -> str:
68
+ return " " * self.indent_level
69
+
70
+ def emit(self, line: str):
71
+ self.code_lines.append(self.indent() + line)
72
+
73
+ def transpile_function(self, node: ast.FunctionDef) -> Tuple[str, Dict[str, str], str]:
74
+ # Identify arguments and assign C types
75
+ args_c_signature = []
76
+ for arg in node.args.args:
77
+ arg_name = arg.arg
78
+ if arg_name in self.type_hints:
79
+ c_type = self.type_hints[arg_name]
80
+ elif arg.annotation:
81
+ c_type = self._resolve_type_annotation(arg.annotation)
82
+ else:
83
+ c_type = "double"
84
+
85
+ self.type_hints[arg_name] = c_type
86
+ self.declared_vars[arg_name] = c_type
87
+ args_c_signature.append(f"{c_type} {arg_name}")
88
+
89
+ # Check return annotation
90
+ if node.returns:
91
+ self.return_type = self._resolve_type_annotation(node.returns)
92
+
93
+ # Process function body
94
+ for stmt in node.body:
95
+ self.visit(stmt)
96
+
97
+ body_code = "\n".join(self.code_lines)
98
+ sig = f"{self.return_type} kernel_{self.func_name}({', '.join(args_c_signature)})"
99
+ c_func = f"{sig} {{\n{body_code}\n}}"
100
+ return c_func, self.type_hints, self.return_type
101
+
102
+ def _resolve_type_annotation(self, node: ast.AST) -> str:
103
+ if isinstance(node, ast.Name):
104
+ return TYPE_MAP.get(node.id, "double")
105
+ elif isinstance(node, ast.Constant):
106
+ return TYPE_MAP.get(node.value, "double")
107
+ elif isinstance(node, ast.Subscript):
108
+ if isinstance(node.value, ast.Name) and node.value.id in ("list", "List", "Sequence", "array", "buffer"):
109
+ sub_t = self._resolve_type_annotation(node.slice)
110
+ base = "int64_t" if "int" in sub_t else "double"
111
+ return f"{base}* __restrict"
112
+ return "double"
113
+
114
+ def visit_Assign(self, node: ast.Assign):
115
+ if len(node.targets) != 1:
116
+ raise TranspileError("Multiple assignment targets not supported in fast kernel")
117
+
118
+ target = node.targets[0]
119
+ val_expr = self.visit(node.value)
120
+
121
+ if isinstance(target, ast.Name):
122
+ var_name = target.id
123
+ if var_name not in self.declared_vars:
124
+ inferred_type = self._infer_expr_type(node.value)
125
+ self.declared_vars[var_name] = inferred_type
126
+ self.emit(f"{inferred_type} {var_name} = {val_expr};")
127
+ else:
128
+ self.emit(f"{var_name} = {val_expr};")
129
+ elif isinstance(target, ast.Subscript):
130
+ target_expr = self.visit(target)
131
+ self.emit(f"{target_expr} = {val_expr};")
132
+ else:
133
+ raise TranspileError(f"Unsupported assignment target: {ast.dump(target)}")
134
+
135
+ def visit_AugAssign(self, node: ast.AugAssign):
136
+ target = self.visit(node.target)
137
+ val = self.visit(node.value)
138
+ op_map = {
139
+ ast.Add: "+=",
140
+ ast.Sub: "-=",
141
+ ast.Mult: "*=",
142
+ ast.Div: "/=",
143
+ ast.BitAnd: "&=",
144
+ ast.BitOr: "|=",
145
+ ast.BitXor: "^=",
146
+ ast.LShift: "<<=",
147
+ ast.RShift: ">>=",
148
+ }
149
+ op = op_map.get(type(node.op))
150
+ if not op:
151
+ raise TranspileError(f"Unsupported augmented operator: {type(node.op)}")
152
+ self.emit(f"{target} {op} {val};")
153
+
154
+ def _find_reductions(self, node: ast.For) -> List[str]:
155
+ reductions = []
156
+ for stmt in ast.walk(node):
157
+ if isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name):
158
+ var = stmt.target.id
159
+ if var in self.declared_vars and var not in reductions:
160
+ reductions.append(var)
161
+ return reductions
162
+
163
+ def visit_For(self, node: ast.For):
164
+ self.loop_depth += 1
165
+ is_outer = (self.loop_depth == 1)
166
+
167
+ if isinstance(node.iter, ast.Call) and isinstance(node.iter.func, ast.Name) and node.iter.func.id == "range":
168
+ args = node.iter.args
169
+ var_name = node.target.id
170
+ self.declared_vars[var_name] = "int64_t"
171
+
172
+ if len(args) == 1:
173
+ start = "0"
174
+ stop = self.visit(args[0])
175
+ step = "1"
176
+ elif len(args) == 2:
177
+ start = self.visit(args[0])
178
+ stop = self.visit(args[1])
179
+ step = "1"
180
+ elif len(args) == 3:
181
+ start = self.visit(args[0])
182
+ stop = self.visit(args[1])
183
+ step = self.visit(args[2])
184
+ else:
185
+ raise TranspileError("range() with invalid arguments")
186
+
187
+ if is_outer and self.parallel:
188
+ reductions = self._find_reductions(node)
189
+ red_clause = ""
190
+ if reductions:
191
+ red_clause = f" reduction(+:{', '.join(reductions)})"
192
+
193
+ self.emit(f"int {var_name};")
194
+ self.emit(f"#pragma omp parallel for schedule(guided){red_clause}")
195
+ step_expr = f"{var_name}++" if step == "1" else f"{var_name} += {step}"
196
+ self.emit(f"for ({var_name} = (int)({start}); {var_name} < (int)({stop}); {step_expr}) {{")
197
+ else:
198
+ # Add SIMD auto-vectorization hint for inner loops
199
+ self.emit("#pragma loop(ivdep)")
200
+ step_expr = f"{var_name}++" if step == "1" else f"{var_name} += {step}"
201
+ self.emit(f"for (int64_t {var_name} = {start}; {var_name} < {stop}; {step_expr}) {{")
202
+
203
+ self.indent_level += 1
204
+ for stmt in node.body:
205
+ self.visit(stmt)
206
+ self.indent_level -= 1
207
+ self.emit("}")
208
+ else:
209
+ raise TranspileError("Only range() iterations are currently supported in accelerated for-loops")
210
+
211
+ self.loop_depth -= 1
212
+
213
+ def visit_While(self, node: ast.While):
214
+ cond = self.visit(node.test)
215
+ self.emit(f"while ({cond}) {{")
216
+ self.indent_level += 1
217
+ for stmt in node.body:
218
+ self.visit(stmt)
219
+ self.indent_level -= 1
220
+ self.emit("}")
221
+
222
+ def visit_If(self, node: ast.If):
223
+ cond = self.visit(node.test)
224
+ self.emit(f"if ({cond}) {{")
225
+ self.indent_level += 1
226
+ for stmt in node.body:
227
+ self.visit(stmt)
228
+ self.indent_level -= 1
229
+
230
+ if node.orelse:
231
+ self.emit("} else {")
232
+ self.indent_level += 1
233
+ for stmt in node.orelse:
234
+ self.visit(stmt)
235
+ self.indent_level -= 1
236
+ self.emit("}")
237
+
238
+ def visit_Return(self, node: ast.Return):
239
+ if node.value is None:
240
+ self.emit("return;")
241
+ else:
242
+ val = self.visit(node.value)
243
+ self.emit(f"return ({self.return_type})({val});")
244
+
245
+ def visit_Break(self, node: ast.Break):
246
+ self.emit("break;")
247
+
248
+ def visit_Continue(self, node: ast.Continue):
249
+ self.emit("continue;")
250
+
251
+ def visit_Pass(self, node: ast.Pass):
252
+ pass
253
+
254
+ def visit_Subscript(self, node: ast.Subscript) -> str:
255
+ val = self.visit(node.value)
256
+ slc = self.visit(node.slice)
257
+ return f"{val}[{slc}]"
258
+
259
+ def visit_BinOp(self, node: ast.BinOp) -> str:
260
+ left = self.visit(node.left)
261
+ right = self.visit(node.right)
262
+ if isinstance(node.op, ast.Pow):
263
+ return f"pow((double)({left}), (double)({right}))"
264
+ elif isinstance(node.op, ast.FloorDiv):
265
+ return f"((int64_t)({left}) / (int64_t)({right}))"
266
+
267
+ op_symbols = {
268
+ ast.Add: "+",
269
+ ast.Sub: "-",
270
+ ast.Mult: "*",
271
+ ast.Div: "/",
272
+ ast.Mod: "%",
273
+ ast.BitAnd: "&",
274
+ ast.BitOr: "|",
275
+ ast.BitXor: "^",
276
+ ast.LShift: "<<",
277
+ ast.RShift: ">>",
278
+ }
279
+ sym = op_symbols.get(type(node.op), "+")
280
+ return f"({left} {sym} {right})"
281
+
282
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> str:
283
+ operand = self.visit(node.operand)
284
+ if isinstance(node.op, ast.USub):
285
+ return f"(-{operand})"
286
+ elif isinstance(node.op, ast.UAdd):
287
+ return f"(+{operand})"
288
+ elif isinstance(node.op, ast.Not):
289
+ return f"(!{operand})"
290
+ elif isinstance(node.op, ast.Invert):
291
+ return f"(~{operand})"
292
+ return operand
293
+
294
+ def visit_Compare(self, node: ast.Compare) -> str:
295
+ left = self.visit(node.left)
296
+ parts = []
297
+ curr_left = left
298
+ comp_ops = {
299
+ ast.Eq: "==",
300
+ ast.NotEq: "!=",
301
+ ast.Lt: "<",
302
+ ast.LtE: "<=",
303
+ ast.Gt: ">",
304
+ ast.GtE: ">=",
305
+ }
306
+ for op, comparator in zip(node.ops, node.comparators):
307
+ sym = comp_ops.get(type(op))
308
+ if not sym:
309
+ raise TranspileError(f"Unsupported comparison operator: {type(op)}")
310
+ right = self.visit(comparator)
311
+ parts.append(f"({curr_left} {sym} {right})")
312
+ curr_left = right
313
+ return " && ".join(parts)
314
+
315
+ def visit_BoolOp(self, node: ast.BoolOp) -> str:
316
+ op = "&&" if isinstance(node.op, ast.And) else "||"
317
+ values = [self.visit(v) for v in node.values]
318
+ return f"({f' {op} '.join(values)})"
319
+
320
+ def visit_Name(self, node: ast.Name) -> str:
321
+ if node.id == "True":
322
+ return "true"
323
+ elif node.id == "False":
324
+ return "false"
325
+ return node.id
326
+
327
+ def visit_Constant(self, node: ast.Constant) -> str:
328
+ if isinstance(node.value, bool):
329
+ return "true" if node.value else "false"
330
+ elif isinstance(node.value, (int, float)):
331
+ return str(node.value)
332
+ elif isinstance(node.value, str):
333
+ return f'"{node.value}"'
334
+ return str(node.value)
335
+
336
+ def visit_Call(self, node: ast.Call) -> str:
337
+ func_name = None
338
+ if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "math":
339
+ func_name = node.func.attr
340
+ elif isinstance(node.func, ast.Name):
341
+ func_name = node.func.id
342
+
343
+ if func_name in MATH_FUNCS:
344
+ c_name = MATH_FUNCS[func_name]
345
+ args = [self.visit(a) for a in node.args]
346
+ return f"{c_name}({', '.join(args)})"
347
+ elif func_name == "abs":
348
+ arg = self.visit(node.args[0])
349
+ return f"fabs((double)({arg}))"
350
+ elif func_name == "min":
351
+ a = self.visit(node.args[0])
352
+ b = self.visit(node.args[1])
353
+ return f"(({a}) < ({b}) ? ({a}) : ({b}))"
354
+ elif func_name == "max":
355
+ a = self.visit(node.args[0])
356
+ b = self.visit(node.args[1])
357
+ return f"(({a}) > ({b}) ? ({a}) : ({b}))"
358
+ elif func_name in ("int", "int64_t"):
359
+ arg = self.visit(node.args[0])
360
+ return f"((int64_t)({arg}))"
361
+ elif func_name in ("float", "double"):
362
+ arg = self.visit(node.args[0])
363
+ return f"((double)({arg}))"
364
+
365
+ raise TranspileError(f"Unsupported call: {func_name}()")
366
+
367
+ def _infer_expr_type(self, node: ast.AST) -> str:
368
+ if isinstance(node, ast.Constant):
369
+ if isinstance(node.value, bool):
370
+ return "bool"
371
+ elif isinstance(node.value, int):
372
+ return "int64_t"
373
+ elif isinstance(node.value, float):
374
+ return "double"
375
+ elif isinstance(node, ast.Name):
376
+ return self.declared_vars.get(node.id, "double")
377
+ elif isinstance(node, ast.BinOp):
378
+ left_t = self._infer_expr_type(node.left)
379
+ right_t = self._infer_expr_type(node.right)
380
+ if left_t == "double" or right_t == "double" or isinstance(node.op, ast.Div):
381
+ return "double"
382
+ return "int64_t"
383
+ elif isinstance(node, ast.UnaryOp):
384
+ return self._infer_expr_type(node.operand)
385
+ elif isinstance(node, ast.Call):
386
+ if isinstance(node.func, ast.Name) and node.func.id in ("int", "len"):
387
+ return "int64_t"
388
+ return "double"
389
+ elif isinstance(node, ast.Subscript):
390
+ if isinstance(node.value, ast.Name):
391
+ arr_t = self.declared_vars.get(node.value.id, "")
392
+ if "int64_t" in arr_t:
393
+ return "int64_t"
394
+ return "double"
395
+ return "double"
396
+
397
+
398
+ def generate_c_extension_source(
399
+ func: Any,
400
+ module_name: Optional[str] = None,
401
+ type_hints: Optional[Dict[str, Any]] = None,
402
+ parallel: bool = False,
403
+ fastmath: bool = True,
404
+ source_code: Optional[str] = None,
405
+ ast_node: Optional[ast.FunctionDef] = None
406
+ ) -> str:
407
+ if ast_node is not None:
408
+ func_def = ast_node
409
+ else:
410
+ if source_code is None:
411
+ source_code = inspect.getsource(func)
412
+ dedented = textwrap.dedent(source_code)
413
+ tree = ast.parse(dedented)
414
+
415
+ func_def = None
416
+ for item in tree.body:
417
+ if isinstance(item, ast.FunctionDef):
418
+ func_def = item
419
+ break
420
+
421
+ if not func_def:
422
+ raise TranspileError("Could not find function definition in source code")
423
+
424
+ if module_name is None:
425
+ module_name = f"native_{func_def.name}"
426
+
427
+ hints = {}
428
+ if type_hints:
429
+ for k, v in type_hints.items():
430
+ if v in TYPE_MAP:
431
+ hints[k] = TYPE_MAP[v]
432
+ elif isinstance(v, str):
433
+ if v in TYPE_MAP:
434
+ hints[k] = TYPE_MAP[v]
435
+ elif "list" in v or "array" in v:
436
+ hints[k] = "int64_t* __restrict" if "int" in v else "double* __restrict"
437
+
438
+ transpiler = PythonToCTranspiler(func_def.name, hints, parallel=parallel)
439
+ kernel_code, resolved_hints, ret_type = transpiler.transpile_function(func_def)
440
+
441
+ format_chars = []
442
+ c_var_decls = []
443
+ c_var_names = []
444
+ buffer_pre = []
445
+ buffer_post = []
446
+
447
+ for arg in func_def.args.args:
448
+ name = arg.arg
449
+ t = resolved_hints.get(name, "double")
450
+ if t == "int64_t":
451
+ format_chars.append("L")
452
+ c_var_decls.append(f" long long {name};")
453
+ c_var_names.append(f"(int64_t){name}")
454
+ elif t == "double":
455
+ format_chars.append("d")
456
+ c_var_decls.append(f" double {name};")
457
+ c_var_names.append(name)
458
+ elif t == "bool":
459
+ format_chars.append("p")
460
+ c_var_decls.append(f" int {name};")
461
+ c_var_names.append(f"(bool){name}")
462
+ elif "*" in t:
463
+ # Buffer / array pointer parameter
464
+ format_chars.append("O")
465
+ c_var_decls.append(f" PyObject* py_{name};")
466
+ c_var_decls.append(f" Py_buffer view_{name};")
467
+ base_type = "int64_t" if "int64_t" in t else "double"
468
+ buffer_pre.append(
469
+ f" if (PyObject_GetBuffer(py_{name}, &view_{name}, PyBUF_SIMPLE | PyBUF_WRITABLE) != 0) {{\n"
470
+ f" return NULL;\n"
471
+ f" }}\n"
472
+ f" {base_type}* {name} = ({base_type}*)view_{name}.buf;"
473
+ )
474
+ buffer_post.append(f" PyBuffer_Release(&view_{name});")
475
+ c_var_names.append(name)
476
+
477
+ parse_fmt = "".join(format_chars)
478
+ parse_ptrs = ", ".join([f"&py_{arg.arg}" if "*" in resolved_hints.get(arg.arg, "") else f"&{arg.arg}" for arg in func_def.args.args])
479
+
480
+ if ret_type == "int64_t":
481
+ ret_conv = "PyLong_FromLongLong((long long)result)"
482
+ elif ret_type == "double":
483
+ ret_conv = "PyFloat_FromDouble(result)"
484
+ elif ret_type == "bool":
485
+ ret_conv = "PyBool_FromLong(result ? 1 : 0)"
486
+ else:
487
+ ret_conv = "Py_None; Py_INCREF(Py_None)"
488
+
489
+ decls_str = "\n".join(c_var_decls)
490
+ buf_pre_str = "\n".join(buffer_pre)
491
+ buf_post_str = "\n".join(buffer_post)
492
+ kernel_call_str = f"kernel_{func_def.name}({', '.join(c_var_names)})"
493
+
494
+ c_source = f"""/* Auto-generated by FastPython Hardware-AOT Compiler */
495
+ #define PY_SSIZE_T_CLEAN
496
+ #include <Python.h>
497
+ #include <stdint.h>
498
+ #include <stdbool.h>
499
+ #include <math.h>
500
+
501
+ #ifdef _OPENMP
502
+ #include <omp.h>
503
+ #endif
504
+
505
+ /* Native Hardware-Optimized Kernel */
506
+ {kernel_code}
507
+
508
+ /* Python C-API Wrapper */
509
+ static PyObject* py_{func_def.name}(PyObject* self, PyObject* args) {{
510
+ {decls_str}
511
+ if (!PyArg_ParseTuple(args, "{parse_fmt}", {parse_ptrs})) {{
512
+ return NULL;
513
+ }}
514
+
515
+ {buf_pre_str}
516
+
517
+ /* Release GIL for pure bare-metal execution across all host cores */
518
+ {ret_type} result;
519
+ Py_BEGIN_ALLOW_THREADS
520
+ result = {kernel_call_str};
521
+ Py_END_ALLOW_THREADS
522
+
523
+ {buf_post_str}
524
+
525
+ return {ret_conv};
526
+ }}
527
+
528
+ static PyMethodDef FastMethods[] = {{
529
+ {{"{func_def.name}", py_{func_def.name}, METH_VARARGS, "FastPython Hardware-Optimized Native Function"}},
530
+ {{NULL, NULL, 0, NULL}}
531
+ }};
532
+
533
+ static struct PyModuleDef fastmodule = {{
534
+ PyModuleDef_HEAD_INIT,
535
+ "{module_name}",
536
+ "FastPython compiled hardware-native module",
537
+ -1,
538
+ FastMethods
539
+ }};
540
+
541
+ PyMODINIT_FUNC PyInit_{module_name}(void) {{
542
+ return PyModule_Create(&fastmodule);
543
+ }}
544
+ """
545
+ return c_source
@@ -0,0 +1,4 @@
1
+ from .toolchain import CompilerToolchain, CompilationError
2
+ from .cache import ModuleCache
3
+
4
+ __all__ = ["CompilerToolchain", "CompilationError", "ModuleCache"]
@@ -0,0 +1,57 @@
1
+ """
2
+ Cache manager for FastPython.
3
+ Manages disk caching of compiled native extensions using SHA-256 hashes
4
+ of code AST, CPU instruction sets, and compilation options.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import hashlib
10
+ import importlib.util
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Optional
13
+ from ..hardware.cpu import get_cpu_info
14
+
15
+
16
+ class ModuleCache:
17
+ def __init__(self, cache_dir: Optional[str] = None):
18
+ if cache_dir is None:
19
+ self.cache_dir = Path.cwd() / ".fastpython_cache"
20
+ else:
21
+ self.cache_dir = Path(cache_dir)
22
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ def compute_hash(self, source_code: str, extra_meta: str = "") -> str:
25
+ cpu = get_cpu_info()
26
+ hasher = hashlib.sha256()
27
+ hasher.update(source_code.encode("utf-8"))
28
+ hasher.update(cpu.summary().encode("utf-8"))
29
+ hasher.update(f"py{sys.version_info.major}.{sys.version_info.minor}".encode("utf-8"))
30
+ hasher.update(extra_meta.encode("utf-8"))
31
+ return hasher.hexdigest()[:16]
32
+
33
+ def get_module_paths(self, module_name: str, code_hash: str):
34
+ suffix = ".pyd" if sys.platform == "win32" else ".so"
35
+ c_file = self.cache_dir / f"{module_name}.c"
36
+ pyd_file = self.cache_dir / f"{module_name}{suffix}"
37
+ return str(c_file), str(pyd_file)
38
+
39
+ def is_cached(self, pyd_path: str) -> bool:
40
+ return os.path.exists(pyd_path) and os.path.getsize(pyd_path) > 0
41
+
42
+ def load_extension(self, module_name: str, pyd_path: str, func_name: str) -> Callable[..., Any]:
43
+ """Dynamically load the compiled native shared library and return the function."""
44
+ # Use unique module spec name to avoid collisions
45
+ spec_name = Path(pyd_path).stem
46
+ spec = importlib.util.spec_from_file_location(spec_name, pyd_path)
47
+ if spec is None or spec.loader is None:
48
+ raise ImportError(f"Could not create module spec from {pyd_path}")
49
+
50
+ module = importlib.util.module_from_spec(spec)
51
+ sys.modules[spec_name] = module
52
+ spec.loader.exec_module(module)
53
+
54
+ if not hasattr(module, func_name):
55
+ raise AttributeError(f"Module {spec_name} does not export function '{func_name}'")
56
+
57
+ return getattr(module, func_name)