tricu.py (10879B)
1 """Small ctypes binding for native Haskell ABI 1; handles are owned integers. 2 3 Use Context as a context manager. Release intermediate handles in long-lived 4 contexts; close releases all remaining roots. Returned Python bytes own their data. 5 """ 6 import ctypes as C 7 from pathlib import Path 8 import os 9 from threading import Lock 10 11 _loaded_path = None 12 _loaded_native = None 13 _load_lock = Lock() 14 15 16 def find_library(): 17 override = os.environ.get("TRICU_LIB") 18 if override: 19 path = Path(override).resolve() 20 if not path.is_file(): 21 raise FileNotFoundError(f"TRICU_LIB not found: {path}") 22 return path 23 for directory in (Path(__file__).parent, Path(__file__).parents[2]/"lib"): 24 for name in ("libtricu-haskell.so", "libtricu-haskell.dylib", "tricu-haskell.dll"): 25 path = directory/name 26 if path.is_file(): 27 return path.resolve() 28 raise FileNotFoundError("Set TRICU_LIB to the Haskell embedding library") 29 30 U64, U32, SIZE = C.c_uint64, C.c_uint32, C.c_size_t 31 PTR = C.c_void_p 32 P64, P32 = C.POINTER(U64), C.POINTER(U32) 33 34 35 class TricuError(RuntimeError): 36 def __init__(self, status, message): 37 super().__init__(f"tricu status {status}: {message}") 38 self.status = status 39 40 41 class Library: 42 def __init__(self, path=None): 43 global _loaded_path, _loaded_native 44 resolved = Path(path).resolve() if path is not None else find_library() 45 with _load_lock: 46 if _loaded_path is not None and resolved != _loaded_path: 47 raise RuntimeError('One Haskell embedding library per process') 48 if _loaded_native is None: 49 _loaded_native = C.CDLL(str(resolved)) 50 _loaded_path = resolved 51 self.native = _loaded_native 52 signatures = { 53 'init': (None, []), 'abi_version': (U32, []), 54 'context_new': (U32, [P64]), 'context_free': (U32, [U64]), 55 'retain': (U32, [U64, U64, P64]), 'release': (U32, [U64, U64]), 56 'leaf': (U32, [U64, P64]), 'stem': (U32, [U64, U64, P64]), 57 'fork': (U32, [U64, U64, U64, P64]), 58 'apply': (U32, [U64, U64, U64, P64]), 59 'reduce': (U32, [U64, U64, P64]), 'kind': (U32, [U64, U64, P32]), 60 'child': (U32, [U64, U64, U32, P64]), 61 'to_bool': (U32, [U64, U64, P32]), 62 'from_u64': (U32, [U64, U64, P64]), 'to_u64': (U32, [U64, U64, P64]), 63 'from_bytes': (U32, [U64, PTR, SIZE, P64]), 64 'from_utf8': (U32, [U64, PTR, SIZE, P64]), 65 'to_bytes': (U32, [U64, U64, C.POINTER(PTR), C.POINTER(SIZE)]), 66 'to_utf8': (U32, [U64, U64, C.POINTER(PTR), C.POINTER(SIZE)]), 67 'load_bundle': (U32, [U64, PTR, SIZE, PTR, SIZE, P64]), 68 'run_io': (U32, [U64, U64, P32, P64, P64]), 69 'last_error': (U32, [U64, C.POINTER(PTR), C.POINTER(SIZE)]), 70 'buffer_free': (None, [PTR]), 71 } 72 for name, (result, args) in signatures.items(): 73 fn = getattr(self.native, 'tricu_' + name) 74 fn.restype, fn.argtypes = result, args 75 self.native.tricu_init() 76 if self.native.tricu_abi_version() != 1: 77 raise RuntimeError('unsupported Haskell embedding ABI') 78 79 def context(self): 80 return Context(self) 81 82 83 class Context: 84 def __init__(self, library): 85 self.library, self.native = library, library.native 86 out = U64() 87 status = self.native.tricu_context_new(C.byref(out)) 88 if status: 89 raise TricuError(status, 'creating context') 90 self.identity = out.value 91 92 def _check(self, status): 93 if status: 94 ptr, size = PTR(), SIZE() 95 diagnostic = self.native.tricu_last_error(self.identity, C.byref(ptr), C.byref(size)) 96 message = self._copy(ptr, size).decode('utf-8') if not diagnostic else 'invalid context' 97 raise TricuError(status, message) 98 99 def _copy(self, ptr, size): 100 try: 101 return C.string_at(ptr, size.value) 102 finally: 103 self.native.tricu_buffer_free(ptr) 104 105 def _out(self, name, *args, output=U64): 106 out = output() 107 self._check(getattr(self.native, 'tricu_' + name)(self.identity, *args, C.byref(out))) 108 return out.value 109 110 def close(self): 111 if self.identity: 112 self._check(self.native.tricu_context_free(self.identity)) 113 self.identity = 0 114 115 def __enter__(self): 116 return self 117 118 def __exit__(self, *_): 119 self.close() 120 121 def scope(self): 122 """Own temporary handles created/adopted through the returned scope.""" 123 return HandleScope(self) 124 125 def apply_many(self, function, *arguments): 126 """Borrow inputs; return one owned curried application (or retained fn).""" 127 current = self.retain(function) 128 try: 129 for argument in arguments: 130 following = self.apply(current, argument) 131 self.release(current) 132 current = following 133 return current 134 except BaseException: 135 self.release(current) 136 raise 137 138 def release(self, handle): 139 self._check(self.native.tricu_release(self.identity, handle)) 140 141 def retain(self, handle): 142 return self._out('retain', handle) 143 144 def leaf(self): 145 return self._out('leaf') 146 147 def stem(self, child): 148 return self._out('stem', child) 149 150 def fork(self, left, right): 151 return self._out('fork', left, right) 152 153 def apply(self, fn, arg): 154 return self._out('apply', fn, arg) 155 156 def reduce(self, value): 157 return self._out('reduce', value) 158 159 def kind(self, value): 160 return self._out('kind', value, output=U32) 161 162 def child(self, value, index): 163 if not isinstance(index, int) or not 0 <= index <= 0xffffffff: 164 raise ValueError('invalid child index') 165 return self._out('child', value, index) 166 167 def number(self, value): 168 if not isinstance(value, int) or not 0 <= value < 2**64: 169 raise ValueError('number must fit uint64') 170 return self._out('from_u64', value) 171 172 def to_bool(self, handle): 173 return bool(self._out('to_bool', handle, output=U32)) 174 175 def to_number(self, handle): 176 return self._out('to_u64', handle) 177 178 def bytes(self, value): 179 return self._out('from_bytes', value, len(value)) 180 181 def string(self, value): 182 encoded = value.encode('utf-8') 183 return self._out('from_utf8', encoded, len(encoded)) 184 185 def _buffer(self, name, handle): 186 ptr, size = PTR(), SIZE() 187 self._check(getattr(self.native, 'tricu_' + name)( 188 self.identity, handle, C.byref(ptr), C.byref(size))) 189 return self._copy(ptr, size) 190 191 def to_bytes(self, handle): 192 return self._buffer('to_bytes', handle) 193 194 def to_string(self, handle): 195 return self._buffer('to_utf8', handle).decode('utf-8') 196 197 def load_bundle(self, data, name): 198 encoded = name.encode('utf-8') 199 return self._out('load_bundle', data, len(data), encoded, len(encoded)) 200 201 def run_io(self, program): 202 """Return (outcome, value_handle, cleanup_handle); payloads stay lazy.""" 203 outcome, value, cleanup = U32(), U64(), U64() 204 self._check(self.native.tricu_run_io(self.identity, program, 205 C.byref(outcome), C.byref(value), C.byref(cleanup))) 206 return outcome.value, value.value, cleanup.value 207 208 209 class HandleScope: 210 """Deterministic roots for one synchronous operation in a reusable context. 211 212 Context methods remain usable independently. Only handles returned by this 213 scope's constructors/operations or explicitly adopted with own() belong here. 214 Inputs are borrowed. detach() transfers a root back to the caller. 215 """ 216 def __init__(self, context): 217 self.context = context 218 self._handles = {} 219 self._closed = False 220 221 def __enter__(self): 222 self._check_open() 223 return self 224 225 def __exit__(self, kind, error, traceback): 226 try: 227 self.close() 228 except Exception as cleanup: 229 if error is None: 230 raise 231 if hasattr(error, 'add_note'): 232 error.add_note(f'Handle scope cleanup failed: {cleanup}') 233 234 def _check_open(self): 235 if self._closed: 236 raise RuntimeError('handle scope is closed') 237 238 def own(self, handle): 239 """Adopt one existing independently owned root, without retaining it.""" 240 self._check_open() 241 if handle in self._handles: 242 raise ValueError('handle already owned by scope') 243 self._handles[handle] = None 244 return handle 245 246 def detach(self, handle): 247 self._check_open() 248 if handle not in self._handles: 249 raise ValueError('handle is not owned by scope') 250 del self._handles[handle] 251 return handle 252 253 def release(self, handle): 254 self._check_open() 255 if handle not in self._handles: 256 raise ValueError('handle is not owned by scope') 257 self.context.release(handle) 258 del self._handles[handle] 259 260 def close(self): 261 if self._closed: 262 return 263 self._closed = True 264 handles, self._handles = self._handles, {} 265 first_error = None 266 if self.context.identity: 267 for handle in reversed(handles): 268 try: 269 self.context.release(handle) 270 except Exception as error: 271 if first_error is None: 272 first_error = error 273 if first_error is not None: 274 raise first_error 275 276 def _create(self, method, *args): 277 self._check_open() 278 return self.own(getattr(self.context, method)(*args)) 279 280 def leaf(self): return self._create('leaf') 281 def stem(self, child): return self._create('stem', child) 282 def fork(self, left, right): return self._create('fork', left, right) 283 def retain(self, value): return self._create('retain', value) 284 def reduce(self, value): return self._create('reduce', value) 285 def child(self, value, index): return self._create('child', value, index) 286 def number(self, value): return self._create('number', value) 287 def string(self, value): return self._create('string', value) 288 def bytes(self, value): return self._create('bytes', value) 289 def apply(self, function, argument): return self._create('apply', function, argument) 290 def apply_many(self, function, *arguments): return self._create('apply_many', function, *arguments) 291 def load_bundle(self, data, name): return self._create('load_bundle', data, name) 292 293 def run_io(self, program): 294 self._check_open() 295 outcome, value, cleanup = self.context.run_io(program) 296 self.own(value) 297 self.own(cleanup) 298 return outcome, value, cleanup