cppkh-interface 0.1.3__py3-none-any.whl → 0.2.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.
cppkh_interface/main.py CHANGED
@@ -1,568 +1,656 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import ast
5
- import contextlib
6
- import hashlib
7
- import os
8
- import pathlib
9
- import platform
10
- import re
11
- import shlex
12
- import shutil
13
- import subprocess
14
- import sys
15
- import tempfile
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import contextlib
6
+ import ctypes
7
+ import hashlib
8
+ import os
9
+ import pathlib
10
+ import platform
11
+ import re
12
+ import shlex
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
16
17
  import uuid
17
- from importlib import resources
18
- from typing import Optional, Sequence, Union
19
-
20
- PdInput = Union[str, Sequence[Sequence[int]]]
21
- PdManyInput = Union[str, Sequence[PdInput]]
22
- _configured_cxx: Optional[str] = None
23
-
24
-
25
- class CppkhInterfaceError(RuntimeError):
26
- """Raised when the C++ executable cannot be built or run."""
27
-
28
-
29
- def _format_pd(crossings: Sequence[Sequence[int]]) -> str:
30
- parts = []
31
- for crossing in crossings:
32
- values = list(crossing)
33
- if len(values) != 4:
34
- raise ValueError(f"PD crossing must have four entries: {crossing!r}")
35
- parts.append("X[{},{},{},{}]".format(*(int(value) for value in values)))
36
- return "PD[" + ",".join(parts) + "]"
37
-
38
-
39
- def _parse_x_crossings(text: str) -> Optional[list[list[int]]]:
40
- pattern = r"X\s*\[\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\]"
41
- crossings = []
42
- for match in re.finditer(pattern, text):
43
- crossings.append([int(match.group(i)) for i in range(1, 5)])
44
- return crossings if crossings else None
45
-
46
-
18
+ import threading
19
+ from importlib import resources
20
+ from typing import Optional, Sequence, Union
21
+
22
+ PdInput = Union[str, Sequence[Sequence[int]]]
23
+ PdManyInput = Union[str, Sequence[PdInput]]
24
+ _configured_cxx: Optional[str] = None
25
+
26
+
27
+ class CppkhInterfaceError(RuntimeError):
28
+ """Raised when the C++ executable cannot be built or run."""
29
+
30
+
31
+ def _format_pd(crossings: Sequence[Sequence[int]]) -> str:
32
+ parts = []
33
+ for crossing in crossings:
34
+ values = list(crossing)
35
+ if len(values) != 4:
36
+ raise ValueError(f"PD crossing must have four entries: {crossing!r}")
37
+ parts.append("X[{},{},{},{}]".format(*(int(value) for value in values)))
38
+ return "PD[" + ",".join(parts) + "]"
39
+
40
+
41
+ def _parse_x_crossings(text: str) -> Optional[list[list[int]]]:
42
+ pattern = r"X\s*\[\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\]"
43
+ crossings = []
44
+ for match in re.finditer(pattern, text):
45
+ crossings.append([int(match.group(i)) for i in range(1, 5)])
46
+ return crossings if crossings else None
47
+
48
+
47
49
  def _as_crossings(pd_code: PdInput) -> list[list[int]]:
48
- if isinstance(pd_code, str):
49
- body = pd_code.strip()
50
- if ":" in body:
51
- body = body.split(":", 1)[1].strip()
52
- if body.replace(" ", "") in ("PD[]", "[]"):
53
- return []
54
-
55
- parsed = _parse_x_crossings(body)
56
- if parsed is not None:
57
- return parsed
58
-
59
- try:
60
- value = ast.literal_eval(body)
61
- except (SyntaxError, ValueError) as exc:
62
- raise ValueError(f"unsupported PD-code string format: {pd_code!r}") from exc
63
- else:
64
- value = pd_code
65
-
66
- crossings = []
67
- for crossing in value:
68
- values = list(crossing)
69
- if len(values) != 4:
70
- raise ValueError(f"PD crossing must have four entries: {crossing!r}")
71
- crossings.append([int(item) for item in values])
50
+ if isinstance(pd_code, str):
51
+ body = pd_code.strip()
52
+ if ":" in body:
53
+ body = body.split(":", 1)[1].strip()
54
+ if body.replace(" ", "") in ("PD[]", "[]"):
55
+ return []
56
+
57
+ parsed = _parse_x_crossings(body)
58
+ if parsed is not None:
59
+ return parsed
60
+
61
+ try:
62
+ value = ast.literal_eval(body)
63
+ except (SyntaxError, ValueError) as exc:
64
+ raise ValueError(f"unsupported PD-code string format: {pd_code!r}") from exc
65
+ else:
66
+ value = pd_code
67
+
68
+ crossings = []
69
+ for crossing in value:
70
+ values = list(crossing)
71
+ if len(values) != 4:
72
+ raise ValueError(f"PD crossing must have four entries: {crossing!r}")
73
+ crossings.append([int(item) for item in values])
72
74
  return crossings
73
75
 
74
76
 
75
- def normalize_pd_code(pd_code: PdInput) -> str:
76
- """Normalize a supported PD-code value into standard ``PD[X[...],...]`` text."""
77
-
78
- return _format_pd(_as_crossings(pd_code))
79
-
80
-
81
- def normalize_pd_codes(pd_codes: PdManyInput) -> str:
82
- """Normalize one or more PD codes into a newline-separated PD document."""
83
-
84
- if isinstance(pd_codes, str):
85
- return pd_codes.strip()
86
- return "\n".join(normalize_pd_code(pd_code) for pd_code in pd_codes)
87
-
88
-
89
- @contextlib.contextmanager
90
- def _resource_source_path():
91
- source = resources.files("cppkh_interface") / "data" / "src" / "main.cpp"
92
- try:
93
- with resources.as_file(source) as resource_path:
94
- if pathlib.Path(resource_path).exists():
95
- yield pathlib.Path(resource_path)
96
- return
97
- except FileNotFoundError:
98
- pass
99
-
100
- current = pathlib.Path(__file__).resolve()
101
- for parent in current.parents:
102
- candidate = parent / "src" / "main.cpp"
103
- if candidate.exists():
104
- yield candidate
105
- return
106
-
107
- raise CppkhInterfaceError(
108
- "cppkh C++ source was not found. Installed wheels include it under "
109
- "cppkh_interface/data/src/main.cpp; editable checkouts use the "
110
- "repository root src/main.cpp."
111
- )
112
-
113
-
114
- def _cache_dir() -> pathlib.Path:
115
- env_value = os.environ.get("CPPKH_INTERFACE_CACHE_DIR")
116
- if env_value:
117
- root = pathlib.Path(env_value)
118
- elif sys.platform == "win32":
119
- root = pathlib.Path(os.environ.get("LOCALAPPDATA", pathlib.Path.home())) / "cppkh-interface"
120
- elif sys.platform == "darwin":
121
- root = pathlib.Path.home() / "Library" / "Caches" / "cppkh-interface"
122
- else:
123
- root = pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")) / "cppkh-interface"
124
- root.mkdir(parents=True, exist_ok=True)
125
- return root
126
-
127
-
128
- def _default_compile_flags() -> list[str]:
129
- flags = ["-std=c++14", "-O3", "-DNDEBUG"]
130
- native = os.environ.get("CPPKH_INTERFACE_NATIVE", "1").strip().lower()
131
- if native not in ("0", "false", "no", "off"):
132
- flags.append("-march=native")
133
- if platform.system() != "Windows":
134
- flags.append("-pthread")
135
- extra = os.environ.get("CPPKH_INTERFACE_CXXFLAGS", "").strip()
136
- if extra:
137
- flags.extend(shlex.split(extra))
138
- return flags
139
-
140
-
141
- def _exe_suffix() -> str:
142
- return ".exe" if platform.system() == "Windows" else ""
77
+ def _check_sanity(crossings: list[list[int]]) -> None:
78
+ counts = {}
79
+ for crossing in crossings:
80
+ for label in crossing:
81
+ counts[label] = counts.get(label, 0) + 1
82
+ if any(count != 2 for count in counts.values()):
83
+ raise TypeError("each PD label must occur exactly twice")
84
+
85
+
86
+ def normalize_pd_code(pd_code: PdInput) -> str:
87
+ """Normalize a supported PD-code value into standard ``PD[X[...],...]`` text."""
88
+
89
+ return _format_pd(_as_crossings(pd_code))
90
+
91
+
92
+ def normalize_pd_codes(pd_codes: PdManyInput) -> str:
93
+ """Normalize one or more PD codes into a newline-separated PD document."""
94
+
95
+ if isinstance(pd_codes, str):
96
+ return pd_codes.strip()
97
+ return "\n".join(normalize_pd_code(pd_code) for pd_code in pd_codes)
98
+
99
+
100
+ @contextlib.contextmanager
101
+ def _resource_source_path():
102
+ source = resources.files("cppkh_interface") / "data" / "src" / "main.cpp"
103
+ try:
104
+ with resources.as_file(source) as resource_path:
105
+ if pathlib.Path(resource_path).exists():
106
+ yield pathlib.Path(resource_path)
107
+ return
108
+ except FileNotFoundError:
109
+ pass
110
+
111
+ current = pathlib.Path(__file__).resolve()
112
+ for parent in current.parents:
113
+ candidate = parent / "src" / "main.cpp"
114
+ if candidate.exists():
115
+ yield candidate
116
+ return
117
+
118
+ raise CppkhInterfaceError(
119
+ "cppkh C++ source was not found. Installed wheels include it under "
120
+ "cppkh_interface/data/src/main.cpp; editable checkouts use the "
121
+ "repository root src/main.cpp."
122
+ )
123
+
124
+
125
+ def _cache_dir() -> pathlib.Path:
126
+ env_value = os.environ.get("CPPKH_INTERFACE_CACHE_DIR")
127
+ if env_value:
128
+ root = pathlib.Path(env_value)
129
+ elif sys.platform == "win32":
130
+ root = pathlib.Path(os.environ.get("LOCALAPPDATA", pathlib.Path.home())) / "cppkh-interface"
131
+ elif sys.platform == "darwin":
132
+ root = pathlib.Path.home() / "Library" / "Caches" / "cppkh-interface"
133
+ else:
134
+ root = pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")) / "cppkh-interface"
135
+ root.mkdir(parents=True, exist_ok=True)
136
+ return root
137
+
138
+
139
+ def _default_compile_flags() -> list[str]:
140
+ flags = ["-std=c++14", "-O3", "-DNDEBUG"]
141
+ native = os.environ.get("CPPKH_INTERFACE_NATIVE", "1").strip().lower()
142
+ if native not in ("0", "false", "no", "off"):
143
+ flags.append("-march=native")
144
+ if platform.system() != "Windows":
145
+ flags.append("-pthread")
146
+ extra = os.environ.get("CPPKH_INTERFACE_CXXFLAGS", "").strip()
147
+ if extra:
148
+ flags.extend(shlex.split(extra))
149
+ return flags
150
+
151
+
152
+ def _exe_suffix() -> str:
153
+ return ".exe" if platform.system() == "Windows" else ""
154
+
155
+
156
+ def _shared_suffix() -> str:
157
+ return ".dll" if platform.system() == "Windows" else (".dylib" if platform.system() == "Darwin" else ".so")
143
158
 
144
159
 
145
160
  def _compiler_parts(command: str) -> list[str]:
146
161
  command = command.strip()
147
162
  if not command:
148
163
  return []
149
- unquoted = command
150
- if len(unquoted) >= 2 and unquoted[0] == unquoted[-1] and unquoted[0] in ("'", '"'):
151
- unquoted = unquoted[1:-1]
152
- if pathlib.Path(unquoted).is_file():
153
- return [unquoted]
154
- try:
155
- parts = shlex.split(command, posix=os.name != "nt")
156
- except ValueError as exc:
157
- raise CppkhInterfaceError(f"invalid C++ compiler command: {command!r}") from exc
158
- return [part.strip("\"'") for part in parts if part.strip("\"'")]
159
-
160
-
161
- def _subprocess_kwargs() -> dict:
162
- kwargs = {
163
- "stdout": subprocess.PIPE,
164
- "stderr": subprocess.PIPE,
165
- "text": True,
166
- "encoding": "utf-8",
167
- "errors": "replace",
168
- }
169
- if platform.system() == "Windows":
170
- kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
171
- return kwargs
172
-
173
-
174
- def _probe_compiler(parts: Sequence[str]) -> subprocess.CompletedProcess:
175
- if not parts:
176
- raise CppkhInterfaceError("empty C++ compiler command")
177
- try:
178
- result = subprocess.run([*parts, "--version"], timeout=15, **_subprocess_kwargs())
179
- except (OSError, subprocess.SubprocessError) as exc:
180
- raise CppkhInterfaceError(f"could not run C++ compiler {' '.join(parts)!r}: {exc}") from exc
181
- if result.returncode != 0:
182
- detail = (result.stderr or result.stdout or "").strip()
183
- raise CppkhInterfaceError(detail or f"C++ compiler {' '.join(parts)!r} is not usable")
184
- return result
185
-
186
-
187
- def _resolve_compiler(cxx: Optional[str] = None) -> list[str]:
188
- global _configured_cxx
189
-
190
- explicit = cxx or _configured_cxx
191
- if not explicit:
192
- explicit = os.environ.get("CPPKH_INTERFACE_CXX") or os.environ.get("CXX")
193
- if explicit:
194
- parts = _compiler_parts(explicit)
195
- _probe_compiler(parts)
196
- if cxx:
197
- _configured_cxx = cxx
198
- return parts
199
-
200
- errors = []
201
- for name in ("g++", "clang++", "c++"):
202
- candidate = shutil.which(name)
203
- if not candidate:
204
- continue
205
- parts = [candidate]
206
- try:
207
- _probe_compiler(parts)
208
- return parts
209
- except CppkhInterfaceError as exc:
210
- errors.append(str(exc))
211
- detail = f" ({'; '.join(errors)})" if errors else ""
212
- raise CppkhInterfaceError(
213
- "no usable C++ compiler was found. Install a C++14 compiler or set "
214
- f"CPPKH_INTERFACE_CXX/CXX to its command{detail}"
215
- )
216
-
217
-
164
+ unquoted = command
165
+ if len(unquoted) >= 2 and unquoted[0] == unquoted[-1] and unquoted[0] in ("'", '"'):
166
+ unquoted = unquoted[1:-1]
167
+ if pathlib.Path(unquoted).is_file():
168
+ return [unquoted]
169
+ try:
170
+ parts = shlex.split(command, posix=os.name != "nt")
171
+ except ValueError as exc:
172
+ raise CppkhInterfaceError(f"invalid C++ compiler command: {command!r}") from exc
173
+ return [part.strip("\"'") for part in parts if part.strip("\"'")]
174
+
175
+
176
+ def _subprocess_kwargs() -> dict:
177
+ kwargs = {
178
+ "stdout": subprocess.PIPE,
179
+ "stderr": subprocess.PIPE,
180
+ "text": True,
181
+ "encoding": "utf-8",
182
+ "errors": "replace",
183
+ }
184
+ if platform.system() == "Windows":
185
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
186
+ return kwargs
187
+
188
+
189
+ def _probe_compiler(parts: Sequence[str]) -> subprocess.CompletedProcess:
190
+ if not parts:
191
+ raise CppkhInterfaceError("empty C++ compiler command")
192
+ try:
193
+ result = subprocess.run([*parts, "--version"], timeout=15, **_subprocess_kwargs())
194
+ except (OSError, subprocess.SubprocessError) as exc:
195
+ raise CppkhInterfaceError(f"could not run C++ compiler {' '.join(parts)!r}: {exc}") from exc
196
+ if result.returncode != 0:
197
+ detail = (result.stderr or result.stdout or "").strip()
198
+ raise CppkhInterfaceError(detail or f"C++ compiler {' '.join(parts)!r} is not usable")
199
+ return result
200
+
201
+
202
+ def _resolve_compiler(cxx: Optional[str] = None) -> list[str]:
203
+ global _configured_cxx
204
+
205
+ explicit = cxx or _configured_cxx
206
+ if not explicit:
207
+ explicit = os.environ.get("CPPKH_INTERFACE_CXX") or os.environ.get("CXX")
208
+ if explicit:
209
+ parts = _compiler_parts(explicit)
210
+ _probe_compiler(parts)
211
+ if cxx:
212
+ _configured_cxx = cxx
213
+ return parts
214
+
215
+ errors = []
216
+ for name in ("g++", "clang++", "c++"):
217
+ candidate = shutil.which(name)
218
+ if not candidate:
219
+ continue
220
+ parts = [candidate]
221
+ try:
222
+ _probe_compiler(parts)
223
+ return parts
224
+ except CppkhInterfaceError as exc:
225
+ errors.append(str(exc))
226
+ detail = f" ({'; '.join(errors)})" if errors else ""
227
+ raise CppkhInterfaceError(
228
+ "no usable C++ compiler was found. Install a C++14 compiler or set "
229
+ f"CPPKH_INTERFACE_CXX/CXX to its command{detail}"
230
+ )
231
+
232
+
218
233
  def _compiler_runtime_path_entries(compiler: Sequence[str]) -> list[str]:
219
234
  paths = []
220
235
  for candidate in compiler:
221
- resolved = shutil.which(candidate) or candidate
222
- path = pathlib.Path(resolved)
223
- if path.exists() and path.is_file():
224
- parent = str(path.resolve().parent)
225
- if parent not in paths:
226
- paths.append(parent)
227
- return paths
228
-
229
-
230
- def _compiler_identity(compiler: Sequence[str]) -> str:
231
- result = _probe_compiler(compiler)
232
- executable = shutil.which(compiler[0]) or compiler[0]
233
- return "\0".join([str(pathlib.Path(executable).resolve()), *compiler[1:], result.stdout.strip()])
234
-
235
-
236
- def _cache_key(source_bytes: bytes, flags: Sequence[str], compiler: Sequence[str]) -> str:
237
- digest = hashlib.sha256()
238
- digest.update(source_bytes)
239
- digest.update("\0".join(flags).encode("utf-8"))
240
- digest.update(_compiler_identity(compiler).encode("utf-8"))
241
- digest.update(platform.platform().encode("utf-8"))
242
- return digest.hexdigest()[:20]
243
-
244
-
245
- def _compile_source(
246
- compiler: Sequence[str],
247
- source: pathlib.Path,
248
- output: pathlib.Path,
249
- flags: Sequence[str],
250
- ) -> subprocess.CompletedProcess:
251
- command = [*compiler, *flags, str(source), "-o", str(output)]
252
- try:
253
- return subprocess.run(command, **_subprocess_kwargs())
254
- except OSError as exc:
255
- raise CppkhInterfaceError(f"could not start C++ compiler: {exc}") from exc
256
-
257
-
258
- def compile_cppkh(
259
- *,
260
- force: bool = False,
261
- cxx: Optional[str] = None,
262
- extra_flags: Optional[Sequence[str]] = None,
263
- ) -> pathlib.Path:
264
- """Compile the packaged C++ source and return the cached executable path."""
265
-
266
- with _resource_source_path() as source:
236
+ resolved = shutil.which(candidate) or candidate
237
+ path = pathlib.Path(resolved)
238
+ if path.exists() and path.is_file():
239
+ parent = str(path.resolve().parent)
240
+ if parent not in paths:
241
+ paths.append(parent)
242
+ return paths
243
+
244
+
245
+ def _compiler_identity(compiler: Sequence[str]) -> str:
246
+ result = _probe_compiler(compiler)
247
+ executable = shutil.which(compiler[0]) or compiler[0]
248
+ return "\0".join([str(pathlib.Path(executable).resolve()), *compiler[1:], result.stdout.strip()])
249
+
250
+
251
+ def _cache_key(source_bytes: bytes, flags: Sequence[str], compiler: Sequence[str]) -> str:
252
+ digest = hashlib.sha256()
253
+ digest.update(source_bytes)
254
+ digest.update("\0".join(flags).encode("utf-8"))
255
+ digest.update(_compiler_identity(compiler).encode("utf-8"))
256
+ digest.update(platform.platform().encode("utf-8"))
257
+ return digest.hexdigest()[:20]
258
+
259
+
260
+ def _compile_source(
261
+ compiler: Sequence[str],
262
+ source: pathlib.Path,
263
+ output: pathlib.Path,
264
+ flags: Sequence[str],
265
+ ) -> subprocess.CompletedProcess:
266
+ command = [*compiler, *flags, str(source), "-o", str(output)]
267
+ try:
268
+ return subprocess.run(command, **_subprocess_kwargs())
269
+ except OSError as exc:
270
+ raise CppkhInterfaceError(f"could not start C++ compiler: {exc}") from exc
271
+
272
+
273
+ def compile_cppkh(
274
+ *,
275
+ force: bool = False,
276
+ cxx: Optional[str] = None,
277
+ extra_flags: Optional[Sequence[str]] = None,
278
+ ) -> pathlib.Path:
279
+ """Compile the packaged C++ source and return the cached executable path."""
280
+
281
+ with _resource_source_path() as source:
282
+ source_path = pathlib.Path(source)
283
+ source_bytes = source_path.read_bytes()
284
+ compiler = _resolve_compiler(cxx)
285
+ flags = _default_compile_flags()
286
+ if extra_flags:
287
+ flags.extend(str(flag) for flag in extra_flags)
288
+
289
+ cache = _cache_dir()
290
+ exe = cache / f"cppkh-{_cache_key(source_bytes, flags, compiler)}{_exe_suffix()}"
291
+ if exe.exists() and not force:
292
+ return exe
293
+
294
+ tmp_exe = cache / f"{exe.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}{_exe_suffix()}"
295
+ try:
296
+ result = _compile_source(compiler, source_path, tmp_exe, flags)
297
+ if result.returncode != 0 and "-march=native" in flags:
298
+ fallback_flags = [flag for flag in flags if flag != "-march=native"]
299
+ result = _compile_source(compiler, source_path, tmp_exe, fallback_flags)
300
+ if result.returncode != 0:
301
+ detail = (result.stderr or result.stdout or "").strip()
302
+ raise CppkhInterfaceError(detail or "C++ compilation failed")
303
+ if not tmp_exe.exists():
304
+ raise CppkhInterfaceError(f"compiled executable was not created: {tmp_exe}")
305
+
306
+ if exe.exists() and not force:
307
+ return exe
308
+ try:
309
+ os.replace(tmp_exe, exe)
310
+ except OSError:
311
+ if force or not exe.exists():
312
+ raise
313
+ try:
314
+ exe.chmod(exe.stat().st_mode | 0o755)
315
+ except OSError:
316
+ pass
317
+ return exe
318
+ finally:
319
+ try:
320
+ tmp_exe.unlink()
321
+ except OSError:
322
+ pass
323
+
324
+
325
+ def compile_cppkh_shared(*, force: bool = False) -> pathlib.Path:
326
+ """Compile and cache the cppkh C API shared library."""
327
+ with _resource_source_path() as source:
267
328
  source_path = pathlib.Path(source)
268
- source_bytes = source_path.read_bytes()
269
- compiler = _resolve_compiler(cxx)
270
- flags = _default_compile_flags()
271
- if extra_flags:
272
- flags.extend(str(flag) for flag in extra_flags)
273
-
329
+ compiler = _resolve_compiler()
330
+ flags = _default_compile_flags() + ["-shared", "-DCPPKH_SHARED_LIBRARY"]
274
331
  cache = _cache_dir()
275
- exe = cache / f"cppkh-{_cache_key(source_bytes, flags, compiler)}{_exe_suffix()}"
276
- if exe.exists() and not force:
277
- return exe
278
-
279
- tmp_exe = cache / f"{exe.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}{_exe_suffix()}"
332
+ library = cache / f"cppkh-{_cache_key(source_path.read_bytes(), flags, compiler)}{_shared_suffix()}"
333
+ if library.exists() and not force:
334
+ return library
335
+ temporary = cache / f"{library.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}{_shared_suffix()}"
280
336
  try:
281
- result = _compile_source(compiler, source_path, tmp_exe, flags)
337
+ result = _compile_source(compiler, source_path, temporary, flags)
282
338
  if result.returncode != 0 and "-march=native" in flags:
283
- fallback_flags = [flag for flag in flags if flag != "-march=native"]
284
- result = _compile_source(compiler, source_path, tmp_exe, fallback_flags)
339
+ result = _compile_source(compiler, source_path, temporary, [flag for flag in flags if flag != "-march=native"])
285
340
  if result.returncode != 0:
286
- detail = (result.stderr or result.stdout or "").strip()
287
- raise CppkhInterfaceError(detail or "C++ compilation failed")
288
- if not tmp_exe.exists():
289
- raise CppkhInterfaceError(f"compiled executable was not created: {tmp_exe}")
290
-
291
- if exe.exists() and not force:
292
- return exe
293
- try:
294
- os.replace(tmp_exe, exe)
295
- except OSError:
296
- if force or not exe.exists():
297
- raise
298
- try:
299
- exe.chmod(exe.stat().st_mode | 0o755)
300
- except OSError:
301
- pass
302
- return exe
341
+ raise CppkhInterfaceError((result.stderr or result.stdout or "C++ shared compilation failed").strip())
342
+ os.replace(temporary, library)
343
+ return library
303
344
  finally:
304
345
  try:
305
- tmp_exe.unlink()
346
+ temporary.unlink()
306
347
  except OSError:
307
348
  pass
308
-
309
-
310
- def get_cppkh_executable() -> pathlib.Path:
311
- """Return the cached executable path, compiling it first when necessary."""
312
-
313
- return compile_cppkh()
314
-
315
-
316
- def _run_cppkh_document(
317
- pd_text: str,
318
- *,
319
- encoding: Optional[str] = None,
320
- threads: Union[str, int] = "1",
321
- de_r1: bool = False,
322
- de_k8: bool = False,
323
- print_simplified_pd: bool = False,
324
- ) -> list[str]:
325
- exe = compile_cppkh()
326
- with tempfile.NamedTemporaryFile("w", suffix=".pd", encoding="utf-8", delete=False) as handle:
327
- handle.write(pd_text)
328
- if pd_text and not pd_text.endswith("\n"):
329
- handle.write("\n")
330
- pd_file = handle.name
331
-
332
- command = [
333
- str(exe),
334
- "--pd-file",
335
- pd_file,
336
- "--quiet",
337
- "--threads",
338
- str(threads),
339
- ]
340
- command.append("--simplify-r1" if de_r1 else "--no-simplify-r1")
341
- command.append("--simplify-nugatory" if de_k8 else "--no-simplify-nugatory")
342
- if print_simplified_pd:
343
- command.append("--print-simplified-pd")
344
-
345
- kwargs = {
346
- "stdout": subprocess.PIPE,
347
- "stderr": subprocess.PIPE,
348
- "text": True,
349
- "encoding": encoding or "utf-8",
350
- "errors": "replace",
351
- }
352
- env = os.environ.copy()
349
+
350
+
351
+ _shared_lock = threading.Lock()
352
+ _shared_library = None
353
+ _dll_directory_handles = []
354
+
355
+
356
+ def _load_shared_library():
357
+ global _shared_library
358
+ if _shared_library is not None:
359
+ return _shared_library
353
360
  runtime_paths = _compiler_runtime_path_entries(_resolve_compiler())
354
- if runtime_paths:
355
- env["PATH"] = os.pathsep.join(runtime_paths + [env.get("PATH", "")])
356
- kwargs["env"] = env
357
- if platform.system() == "Windows":
358
- kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
359
-
360
- try:
361
- result = subprocess.run(command, **kwargs)
362
- finally:
363
- try:
364
- os.unlink(pd_file)
365
- except OSError:
366
- pass
367
-
368
- if result.returncode != 0:
369
- detail = (result.stderr or result.stdout or "").strip()
370
- raise CppkhInterfaceError(detail or f"cppkh exited with code {result.returncode}")
371
-
372
- if print_simplified_pd:
373
- lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
374
- return [line.split("\t", 1)[-1] for line in lines]
375
-
376
- matches = re.findall(r'"([^"]*)"', result.stdout)
377
- if not matches:
378
- raise CppkhInterfaceError(f"result not found in cppkh output: {result.stdout!r}")
379
- return matches
380
-
381
-
382
- def _run_cppkh(
383
- pd_text: str,
384
- *,
385
- encoding: Optional[str] = None,
386
- threads: Union[str, int] = "1",
387
- de_r1: bool = False,
388
- de_k8: bool = False,
389
- print_simplified_pd: bool = False,
390
- ) -> str:
391
- results = _run_cppkh_document(
392
- pd_text,
393
- encoding=encoding,
394
- threads=threads,
395
- de_r1=de_r1,
396
- de_k8=de_k8,
397
- print_simplified_pd=print_simplified_pd,
398
- )
399
- if len(results) != 1:
400
- raise CppkhInterfaceError(f"expected exactly one result, got {len(results)}")
401
- return results[0]
402
-
403
-
404
- def _prepare_many_for_cppkh(pd_codes: PdManyInput) -> str:
405
- if isinstance(pd_codes, str):
406
- return pd_codes.strip()
407
- return "\n".join(normalize_pd_code(pd_code) for pd_code in pd_codes)
408
-
409
-
410
- def _compute_one(
411
- pd_code: PdInput,
412
- *,
413
- encoding: Optional[str],
414
- de_r1: bool,
415
- de_k8: bool,
416
- show_real_pdcode: bool,
417
- threads: Union[str, int],
418
- ) -> str:
419
- document = normalize_pd_code(pd_code)
420
- if show_real_pdcode:
421
- real_pd = _run_cppkh(
422
- document,
423
- encoding=encoding,
424
- threads=threads,
425
- de_r1=de_r1,
426
- de_k8=de_k8,
427
- print_simplified_pd=True,
428
- )
429
- display_pd = real_pd if de_r1 and de_k8 else _as_crossings(real_pd)
430
- print(f"Real PD code after de_r1 and de_k8: {display_pd}")
431
- return _run_cppkh(
432
- document,
433
- encoding=encoding,
434
- threads=threads,
435
- de_r1=de_r1,
436
- de_k8=de_k8,
437
- )
438
-
439
-
440
- def solve_khovanov(
441
- pd_code: PdInput,
442
- encoding: Optional[str] = None,
443
- de_r1: bool = True,
444
- de_k8: bool = True,
445
- show_real_pdcode: bool = False,
446
- ) -> str:
447
- """Compute Khovanov homology with a javakh-interface compatible signature."""
448
-
449
- return _compute_one(
450
- pd_code,
451
- encoding=encoding,
452
- de_r1=de_r1,
453
- de_k8=de_k8,
454
- show_real_pdcode=show_real_pdcode,
455
- threads="1",
456
- )
457
-
458
-
459
- def solve_many_khovanov(
460
- pd_codes: PdManyInput,
461
- encoding: Optional[str] = None,
462
- de_r1: bool = True,
463
- de_k8: bool = True,
464
- show_real_pdcode: bool = False,
465
- threads: Union[str, int] = "1",
466
- ) -> list[str]:
467
- """Compute many PD codes in one cppkh process.
468
-
469
- With the default ``de_r1=True`` and ``de_k8=True`` settings, the raw PD
470
- document is passed directly to cppkh and the C++ simplifier handles R1 then
471
- nugatory crossing removal for the whole batch.
472
- """
473
-
474
- document = _prepare_many_for_cppkh(pd_codes)
475
- if not document:
476
- return []
477
- if show_real_pdcode:
478
- simplified = _run_cppkh_document(
479
- document,
480
- encoding=encoding,
481
- threads=threads,
482
- de_r1=de_r1,
483
- de_k8=de_k8,
484
- print_simplified_pd=True,
485
- )
486
- print(f"Real PD code after de_r1 and de_k8: {simplified}")
487
- return _run_cppkh_document(
488
- document,
489
- encoding=encoding,
490
- threads=threads,
491
- de_r1=de_r1,
492
- de_k8=de_k8,
493
- )
494
-
495
-
496
- def compute_pd(
497
- pd_code: PdInput,
498
- *,
499
- encoding: Optional[str] = None,
500
- de_r1: bool = True,
501
- de_k8: bool = True,
502
- show_real_pdcode: bool = False,
503
- threads: Union[str, int] = "1",
504
- ) -> str:
505
- """Compute Khovanov homology using the same defaults as solve_khovanov."""
506
-
507
- return _compute_one(
508
- pd_code,
509
- encoding=encoding,
510
- de_r1=de_r1,
511
- de_k8=de_k8,
512
- show_real_pdcode=show_real_pdcode,
513
- threads=threads,
514
- )
515
-
516
-
517
- def compute_many_pd(
518
- pd_codes: PdManyInput,
519
- *,
520
- encoding: Optional[str] = None,
521
- de_r1: bool = True,
522
- de_k8: bool = True,
523
- show_real_pdcode: bool = False,
524
- threads: Union[str, int] = "1",
525
- ) -> list[str]:
526
- """Compute many PD codes in one cached cppkh executable invocation."""
527
-
528
- return solve_many_khovanov(
529
- pd_codes,
530
- encoding=encoding,
531
- de_r1=de_r1,
532
- de_k8=de_k8,
533
- show_real_pdcode=show_real_pdcode,
534
- threads=threads,
535
- )
536
-
537
-
538
- def simplify_pd(pd_code: PdInput, *, de_r1: bool = True, de_k8: bool = True) -> str:
539
- """Return the normalized PD string after optional R1 and nugatory simplification."""
540
-
541
- return _run_cppkh(
542
- normalize_pd_code(pd_code),
543
- de_r1=de_r1,
544
- de_k8=de_k8,
545
- print_simplified_pd=True,
546
- )
547
-
548
-
549
- def main(argv: Optional[Sequence[str]] = None) -> int:
550
- parser = argparse.ArgumentParser(description="Compute Khovanov homology with cppkh-interface.")
551
- parser.add_argument("pd_code", help="PD code as PD[...] text or a Python-style list of crossings.")
552
- parser.add_argument("--no-de-r1", action="store_true", help="Disable R1-move removal.")
553
- parser.add_argument("--no-de-k8", action="store_true", help="Disable nugatory-crossing removal.")
554
- parser.add_argument("--threads", default="1", help="cppkh --threads value for direct compute_pd mode.")
555
- args = parser.parse_args(argv)
556
- print(
557
- compute_pd(
558
- args.pd_code,
559
- de_r1=not args.no_de_r1,
560
- de_k8=not args.no_de_k8,
561
- threads=args.threads,
562
- )
563
- )
564
- return 0
565
-
566
-
567
- if __name__ == "__main__":
568
- raise SystemExit(main())
361
+ if runtime_paths:
362
+ os.environ["PATH"] = os.pathsep.join(runtime_paths + [os.environ.get("PATH", "")])
363
+ library_path = compile_cppkh_shared()
364
+ if platform.system() == "Windows" and hasattr(os, "add_dll_directory"):
365
+ for directory in [str(library_path.parent), *runtime_paths]:
366
+ _dll_directory_handles.append(os.add_dll_directory(directory))
367
+ library = ctypes.CDLL(str(library_path))
368
+ library.cppkh_compute_pd_signed_variants_ex.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
369
+ library.cppkh_compute_pd_signed_variants_ex.restype = ctypes.c_void_p
370
+ library.cppkh_last_error.restype = ctypes.c_char_p
371
+ library.cppkh_free.argtypes = [ctypes.c_void_p]
372
+ _shared_library = library
373
+ return library
374
+
375
+
376
+ def compute_signed_variants(pd_code: PdInput, signs: Sequence[Sequence[int]]) -> list[str]:
377
+ """Compute several explicit crossing-sign variants in one native call."""
378
+ crossings = _as_crossings(pd_code)
379
+ _check_sanity(crossings)
380
+ rows = [list(row) for row in signs]
381
+ if any(len(row) != len(crossings) or any(sign not in (-1, 1) for sign in row) for row in rows):
382
+ raise ValueError("each sign row must contain one +1/-1 value per crossing")
383
+ pd_text = _format_pd(crossings).encode("utf-8")
384
+ signs_text = "\n".join(" ".join(map(str, row)) for row in rows).encode("ascii")
385
+ with _shared_lock:
386
+ library = _load_shared_library()
387
+ pointer = library.cppkh_compute_pd_signed_variants_ex(pd_text, signs_text, 1)
388
+ if not pointer:
389
+ error = library.cppkh_last_error()
390
+ raise CppkhInterfaceError(error.decode("utf-8", "replace") if error else "signed computation failed")
391
+ try:
392
+ result = ctypes.string_at(pointer).decode("utf-8")
393
+ finally:
394
+ library.cppkh_free(pointer)
395
+ return result.splitlines()
396
+
397
+
398
+ def get_cppkh_executable() -> pathlib.Path:
399
+ """Return the cached executable path, compiling it first when necessary."""
400
+
401
+ return compile_cppkh()
402
+
403
+
404
+ def _run_cppkh_document(
405
+ pd_text: str,
406
+ *,
407
+ encoding: Optional[str] = None,
408
+ threads: Union[str, int] = "1",
409
+ de_r1: bool = False,
410
+ de_k8: bool = False,
411
+ print_simplified_pd: bool = False,
412
+ ) -> list[str]:
413
+ exe = compile_cppkh()
414
+ with tempfile.NamedTemporaryFile("w", suffix=".pd", encoding="utf-8", delete=False) as handle:
415
+ handle.write(pd_text)
416
+ if pd_text and not pd_text.endswith("\n"):
417
+ handle.write("\n")
418
+ pd_file = handle.name
419
+
420
+ command = [
421
+ str(exe),
422
+ "--pd-file",
423
+ pd_file,
424
+ "--quiet",
425
+ "--threads",
426
+ str(threads),
427
+ ]
428
+ command.append("--simplify-r1" if de_r1 else "--no-simplify-r1")
429
+ command.append("--simplify-nugatory" if de_k8 else "--no-simplify-nugatory")
430
+ if print_simplified_pd:
431
+ command.append("--print-simplified-pd")
432
+
433
+ kwargs = {
434
+ "stdout": subprocess.PIPE,
435
+ "stderr": subprocess.PIPE,
436
+ "text": True,
437
+ "encoding": encoding or "utf-8",
438
+ "errors": "replace",
439
+ }
440
+ env = os.environ.copy()
441
+ runtime_paths = _compiler_runtime_path_entries(_resolve_compiler())
442
+ if runtime_paths:
443
+ env["PATH"] = os.pathsep.join(runtime_paths + [env.get("PATH", "")])
444
+ kwargs["env"] = env
445
+ if platform.system() == "Windows":
446
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
447
+
448
+ try:
449
+ result = subprocess.run(command, **kwargs)
450
+ finally:
451
+ try:
452
+ os.unlink(pd_file)
453
+ except OSError:
454
+ pass
455
+
456
+ if result.returncode != 0:
457
+ detail = (result.stderr or result.stdout or "").strip()
458
+ raise CppkhInterfaceError(detail or f"cppkh exited with code {result.returncode}")
459
+
460
+ if print_simplified_pd:
461
+ lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
462
+ return [line.split("\t", 1)[-1] for line in lines]
463
+
464
+ matches = re.findall(r'"([^"]*)"', result.stdout)
465
+ if not matches:
466
+ raise CppkhInterfaceError(f"result not found in cppkh output: {result.stdout!r}")
467
+ return matches
468
+
469
+
470
+ def _run_cppkh(
471
+ pd_text: str,
472
+ *,
473
+ encoding: Optional[str] = None,
474
+ threads: Union[str, int] = "1",
475
+ de_r1: bool = False,
476
+ de_k8: bool = False,
477
+ print_simplified_pd: bool = False,
478
+ ) -> str:
479
+ results = _run_cppkh_document(
480
+ pd_text,
481
+ encoding=encoding,
482
+ threads=threads,
483
+ de_r1=de_r1,
484
+ de_k8=de_k8,
485
+ print_simplified_pd=print_simplified_pd,
486
+ )
487
+ if len(results) != 1:
488
+ raise CppkhInterfaceError(f"expected exactly one result, got {len(results)}")
489
+ return results[0]
490
+
491
+
492
+ def _prepare_many_for_cppkh(pd_codes: PdManyInput) -> str:
493
+ if isinstance(pd_codes, str):
494
+ return pd_codes.strip()
495
+ return "\n".join(normalize_pd_code(pd_code) for pd_code in pd_codes)
496
+
497
+
498
+ def _compute_one(
499
+ pd_code: PdInput,
500
+ *,
501
+ encoding: Optional[str],
502
+ de_r1: bool,
503
+ de_k8: bool,
504
+ show_real_pdcode: bool,
505
+ threads: Union[str, int],
506
+ ) -> str:
507
+ document = normalize_pd_code(pd_code)
508
+ if show_real_pdcode:
509
+ real_pd = _run_cppkh(
510
+ document,
511
+ encoding=encoding,
512
+ threads=threads,
513
+ de_r1=de_r1,
514
+ de_k8=de_k8,
515
+ print_simplified_pd=True,
516
+ )
517
+ display_pd = real_pd if de_r1 and de_k8 else _as_crossings(real_pd)
518
+ print(f"Real PD code after de_r1 and de_k8: {display_pd}")
519
+ return _run_cppkh(
520
+ document,
521
+ encoding=encoding,
522
+ threads=threads,
523
+ de_r1=de_r1,
524
+ de_k8=de_k8,
525
+ )
526
+
527
+
528
+ def solve_khovanov(
529
+ pd_code: PdInput,
530
+ encoding: Optional[str] = None,
531
+ de_r1: bool = True,
532
+ de_k8: bool = True,
533
+ show_real_pdcode: bool = False,
534
+ ) -> str:
535
+ """Compute Khovanov homology with a javakh-interface compatible signature."""
536
+
537
+ return _compute_one(
538
+ pd_code,
539
+ encoding=encoding,
540
+ de_r1=de_r1,
541
+ de_k8=de_k8,
542
+ show_real_pdcode=show_real_pdcode,
543
+ threads="1",
544
+ )
545
+
546
+
547
+ def solve_many_khovanov(
548
+ pd_codes: PdManyInput,
549
+ encoding: Optional[str] = None,
550
+ de_r1: bool = True,
551
+ de_k8: bool = True,
552
+ show_real_pdcode: bool = False,
553
+ threads: Union[str, int] = "1",
554
+ ) -> list[str]:
555
+ """Compute many PD codes in one cppkh process.
556
+
557
+ With the default ``de_r1=True`` and ``de_k8=True`` settings, the raw PD
558
+ document is passed directly to cppkh and the C++ simplifier handles R1 then
559
+ nugatory crossing removal for the whole batch.
560
+ """
561
+
562
+ document = _prepare_many_for_cppkh(pd_codes)
563
+ if not document:
564
+ return []
565
+ if show_real_pdcode:
566
+ simplified = _run_cppkh_document(
567
+ document,
568
+ encoding=encoding,
569
+ threads=threads,
570
+ de_r1=de_r1,
571
+ de_k8=de_k8,
572
+ print_simplified_pd=True,
573
+ )
574
+ print(f"Real PD code after de_r1 and de_k8: {simplified}")
575
+ return _run_cppkh_document(
576
+ document,
577
+ encoding=encoding,
578
+ threads=threads,
579
+ de_r1=de_r1,
580
+ de_k8=de_k8,
581
+ )
582
+
583
+
584
+ def compute_pd(
585
+ pd_code: PdInput,
586
+ *,
587
+ encoding: Optional[str] = None,
588
+ de_r1: bool = True,
589
+ de_k8: bool = True,
590
+ show_real_pdcode: bool = False,
591
+ threads: Union[str, int] = "1",
592
+ ) -> str:
593
+ """Compute Khovanov homology using the same defaults as solve_khovanov."""
594
+
595
+ return _compute_one(
596
+ pd_code,
597
+ encoding=encoding,
598
+ de_r1=de_r1,
599
+ de_k8=de_k8,
600
+ show_real_pdcode=show_real_pdcode,
601
+ threads=threads,
602
+ )
603
+
604
+
605
+ def compute_many_pd(
606
+ pd_codes: PdManyInput,
607
+ *,
608
+ encoding: Optional[str] = None,
609
+ de_r1: bool = True,
610
+ de_k8: bool = True,
611
+ show_real_pdcode: bool = False,
612
+ threads: Union[str, int] = "1",
613
+ ) -> list[str]:
614
+ """Compute many PD codes in one cached cppkh executable invocation."""
615
+
616
+ return solve_many_khovanov(
617
+ pd_codes,
618
+ encoding=encoding,
619
+ de_r1=de_r1,
620
+ de_k8=de_k8,
621
+ show_real_pdcode=show_real_pdcode,
622
+ threads=threads,
623
+ )
624
+
625
+
626
+ def simplify_pd(pd_code: PdInput, *, de_r1: bool = True, de_k8: bool = True) -> str:
627
+ """Return the normalized PD string after optional R1 and nugatory simplification."""
628
+
629
+ return _run_cppkh(
630
+ normalize_pd_code(pd_code),
631
+ de_r1=de_r1,
632
+ de_k8=de_k8,
633
+ print_simplified_pd=True,
634
+ )
635
+
636
+
637
+ def main(argv: Optional[Sequence[str]] = None) -> int:
638
+ parser = argparse.ArgumentParser(description="Compute Khovanov homology with cppkh-interface.")
639
+ parser.add_argument("pd_code", help="PD code as PD[...] text or a Python-style list of crossings.")
640
+ parser.add_argument("--no-de-r1", action="store_true", help="Disable R1-move removal.")
641
+ parser.add_argument("--no-de-k8", action="store_true", help="Disable nugatory-crossing removal.")
642
+ parser.add_argument("--threads", default="1", help="cppkh --threads value for direct compute_pd mode.")
643
+ args = parser.parse_args(argv)
644
+ print(
645
+ compute_pd(
646
+ args.pd_code,
647
+ de_r1=not args.no_de_r1,
648
+ de_k8=not args.no_de_k8,
649
+ threads=args.threads,
650
+ )
651
+ )
652
+ return 0
653
+
654
+
655
+ if __name__ == "__main__":
656
+ raise SystemExit(main())