libmorpheus 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,65 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Typed Python binding for the libmorpheus native runtime."""
3
+
4
+ from ._library import Context, Library, MorpheusError
5
+ from ._types import (
6
+ Analysis,
7
+ Case,
8
+ Degree,
9
+ Dialect,
10
+ Gender,
11
+ Generation,
12
+ GenerationOptions,
13
+ GeographicRegion,
14
+ Language,
15
+ Mood,
16
+ Number,
17
+ Option,
18
+ PartOfSpeech,
19
+ Person,
20
+ RawAnalysis,
21
+ RawGeneration,
22
+ Status,
23
+ Tense,
24
+ TruncatedField,
25
+ Voice,
26
+ has_morph_flag,
27
+ )
28
+ from ._version import (
29
+ MORPHEUS_NATIVE_ABI_VERSION,
30
+ MORPHEUS_NATIVE_VERSION,
31
+ MORPHEUS_PYTHON_VERSION,
32
+ )
33
+
34
+ __version__ = MORPHEUS_PYTHON_VERSION
35
+
36
+ __all__ = [
37
+ "Analysis",
38
+ "Case",
39
+ "Context",
40
+ "Degree",
41
+ "Dialect",
42
+ "Gender",
43
+ "Generation",
44
+ "GenerationOptions",
45
+ "GeographicRegion",
46
+ "Language",
47
+ "Library",
48
+ "Mood",
49
+ "MORPHEUS_NATIVE_ABI_VERSION",
50
+ "MORPHEUS_NATIVE_VERSION",
51
+ "MORPHEUS_PYTHON_VERSION",
52
+ "MorpheusError",
53
+ "Number",
54
+ "Option",
55
+ "PartOfSpeech",
56
+ "Person",
57
+ "RawAnalysis",
58
+ "RawGeneration",
59
+ "Status",
60
+ "Tense",
61
+ "TruncatedField",
62
+ "Voice",
63
+ "__version__",
64
+ "has_morph_flag",
65
+ ]
libmorpheus/_abi.py ADDED
@@ -0,0 +1,227 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Private ctypes declarations for libmorpheus ABI version 2."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ from os import PathLike, fspath
8
+
9
+ from ._version import MORPHEUS_NATIVE_ABI_VERSION
10
+
11
+ ABI_VERSION = MORPHEUS_NATIVE_ABI_VERSION
12
+ TEXT_CAPACITY = 64
13
+ DOMAIN_CAPACITY = 24
14
+ MORPH_FLAG_CAPACITY = 11
15
+ GENERATION_OPTIONS_VERSION = 1
16
+
17
+
18
+ class AnalysisRecord(ctypes.Structure):
19
+ _fields_ = [
20
+ ("struct_size", ctypes.c_uint32),
21
+ ("part_of_speech", ctypes.c_uint32),
22
+ ("dialect", ctypes.c_uint32),
23
+ ("geographic_region", ctypes.c_uint32),
24
+ ("person", ctypes.c_uint32),
25
+ ("number", ctypes.c_uint32),
26
+ ("gender", ctypes.c_uint32),
27
+ ("grammatical_case", ctypes.c_uint32),
28
+ ("tense", ctypes.c_uint32),
29
+ ("mood", ctypes.c_uint32),
30
+ ("voice", ctypes.c_uint32),
31
+ ("degree", ctypes.c_uint32),
32
+ ("raw", ctypes.c_char * TEXT_CAPACITY),
33
+ ("workword", ctypes.c_char * TEXT_CAPACITY),
34
+ ("lemma", ctypes.c_char * TEXT_CAPACITY),
35
+ ("preverb", ctypes.c_char * TEXT_CAPACITY),
36
+ ("augment", ctypes.c_char * TEXT_CAPACITY),
37
+ ("stem", ctypes.c_char * TEXT_CAPACITY),
38
+ ("suffix", ctypes.c_char * TEXT_CAPACITY),
39
+ ("ending", ctypes.c_char * TEXT_CAPACITY),
40
+ ("crasis", ctypes.c_char * TEXT_CAPACITY),
41
+ ("dictionary_form", ctypes.c_char * TEXT_CAPACITY),
42
+ ("english_form", ctypes.c_char * TEXT_CAPACITY),
43
+ ("raw_preverb", ctypes.c_char * TEXT_CAPACITY),
44
+ ("domains", ctypes.c_char * DOMAIN_CAPACITY),
45
+ ("morph_flags", ctypes.c_uint8 * MORPH_FLAG_CAPACITY),
46
+ ]
47
+
48
+
49
+ class GenerationRecord(ctypes.Structure):
50
+ _fields_ = [
51
+ ("struct_size", ctypes.c_uint32),
52
+ ("part_of_speech", ctypes.c_uint32),
53
+ ("dialect", ctypes.c_uint32),
54
+ ("geographic_region", ctypes.c_uint32),
55
+ ("person", ctypes.c_uint32),
56
+ ("number", ctypes.c_uint32),
57
+ ("gender", ctypes.c_uint32),
58
+ ("grammatical_case", ctypes.c_uint32),
59
+ ("tense", ctypes.c_uint32),
60
+ ("mood", ctypes.c_uint32),
61
+ ("voice", ctypes.c_uint32),
62
+ ("degree", ctypes.c_uint32),
63
+ ("surface", ctypes.c_char * TEXT_CAPACITY),
64
+ ("lemma", ctypes.c_char * TEXT_CAPACITY),
65
+ ("morph_flags", ctypes.c_uint8 * MORPH_FLAG_CAPACITY),
66
+ ]
67
+
68
+
69
+ class NativeGenerationOptions(ctypes.Structure):
70
+ _fields_ = [
71
+ ("version", ctypes.c_uint32),
72
+ ("struct_size", ctypes.c_uint32),
73
+ ("result_limit", ctypes.c_uint64),
74
+ ("flags", ctypes.c_uint32),
75
+ ("part_of_speech", ctypes.c_uint32),
76
+ ("dialect", ctypes.c_uint32),
77
+ ("geographic_region", ctypes.c_uint32),
78
+ ("person", ctypes.c_uint32),
79
+ ("number", ctypes.c_uint32),
80
+ ("gender", ctypes.c_uint32),
81
+ ("grammatical_case", ctypes.c_uint32),
82
+ ("tense", ctypes.c_uint32),
83
+ ("mood", ctypes.c_uint32),
84
+ ("voice", ctypes.c_uint32),
85
+ ("degree", ctypes.c_uint32),
86
+ ]
87
+
88
+
89
+ def _function(library: ctypes.CDLL, name: str, restype: object, *argtypes: object):
90
+ try:
91
+ function = getattr(library, name)
92
+ except AttributeError as error:
93
+ raise RuntimeError(f"Missing native symbol: {name}") from error
94
+ function.restype = restype
95
+ function.argtypes = list(argtypes)
96
+ return function
97
+
98
+
99
+ class NativeLibrary:
100
+ """Validated ctypes function table for one native shared library."""
101
+
102
+ def __init__(self, path: str | PathLike[str]) -> None:
103
+ self.path = fspath(path)
104
+ self.handle = ctypes.CDLL(self.path)
105
+ self.abi_version = _function(
106
+ self.handle, "morpheus_abi_version", ctypes.c_uint32
107
+ )
108
+ self.analysis_size = _function(
109
+ self.handle, "morpheus_analysis_size", ctypes.c_size_t
110
+ )
111
+ self.generation_size = _function(
112
+ self.handle, "morpheus_generation_size", ctypes.c_size_t
113
+ )
114
+ self.status_message = _function(
115
+ self.handle,
116
+ "morpheus_status_message",
117
+ ctypes.c_char_p,
118
+ ctypes.c_uint32,
119
+ )
120
+ self.open_path = _function(
121
+ self.handle,
122
+ "morpheus_open_path",
123
+ ctypes.c_uint32,
124
+ ctypes.c_uint32,
125
+ ctypes.POINTER(ctypes.c_uint8),
126
+ ctypes.c_size_t,
127
+ ctypes.c_uint32,
128
+ ctypes.POINTER(ctypes.c_void_p),
129
+ )
130
+ self.close_context = _function(
131
+ self.handle, "morpheus_close", None, ctypes.c_void_p
132
+ )
133
+ self.analyze = _function(
134
+ self.handle,
135
+ "morpheus_analyze",
136
+ ctypes.c_uint32,
137
+ ctypes.c_void_p,
138
+ ctypes.POINTER(ctypes.c_uint8),
139
+ ctypes.c_size_t,
140
+ ctypes.c_uint64,
141
+ ctypes.POINTER(ctypes.c_void_p),
142
+ )
143
+ self.result_count = _function(
144
+ self.handle, "morpheus_result_count", ctypes.c_size_t, ctypes.c_void_p
145
+ )
146
+ self.result_copy = _function(
147
+ self.handle,
148
+ "morpheus_result_copy",
149
+ ctypes.c_uint32,
150
+ ctypes.c_void_p,
151
+ ctypes.c_size_t,
152
+ ctypes.c_void_p,
153
+ ctypes.c_size_t,
154
+ )
155
+ self.result_truncated_fields = _function(
156
+ self.handle,
157
+ "morpheus_result_truncated_fields",
158
+ ctypes.c_uint32,
159
+ ctypes.c_void_p,
160
+ ctypes.c_size_t,
161
+ ctypes.POINTER(ctypes.c_uint32),
162
+ )
163
+ self.result_free = _function(
164
+ self.handle, "morpheus_result_free", None, ctypes.c_void_p
165
+ )
166
+ self.generate = _function(
167
+ self.handle,
168
+ "morpheus_generate",
169
+ ctypes.c_uint32,
170
+ ctypes.c_void_p,
171
+ ctypes.POINTER(ctypes.c_uint8),
172
+ ctypes.c_size_t,
173
+ ctypes.POINTER(NativeGenerationOptions),
174
+ ctypes.POINTER(ctypes.c_void_p),
175
+ )
176
+ self.generation_result_count = _function(
177
+ self.handle,
178
+ "morpheus_generation_result_count",
179
+ ctypes.c_size_t,
180
+ ctypes.c_void_p,
181
+ )
182
+ self.generation_result_copy = _function(
183
+ self.handle,
184
+ "morpheus_generation_result_copy",
185
+ ctypes.c_uint32,
186
+ ctypes.c_void_p,
187
+ ctypes.c_size_t,
188
+ ctypes.c_void_p,
189
+ ctypes.c_size_t,
190
+ )
191
+ self.generation_result_truncated_fields = _function(
192
+ self.handle,
193
+ "morpheus_generation_result_truncated_fields",
194
+ ctypes.c_uint32,
195
+ ctypes.c_void_p,
196
+ ctypes.c_size_t,
197
+ ctypes.POINTER(ctypes.c_uint32),
198
+ )
199
+ self.generation_result_free = _function(
200
+ self.handle,
201
+ "morpheus_generation_result_free",
202
+ None,
203
+ ctypes.c_void_p,
204
+ )
205
+
206
+ if self.abi_version() != ABI_VERSION:
207
+ raise RuntimeError("Unsupported libmorpheus ABI version")
208
+ self.analysis_record_size = self.analysis_size()
209
+ if self.analysis_record_size < ctypes.sizeof(AnalysisRecord):
210
+ raise RuntimeError(
211
+ "libmorpheus analysis record is smaller than ABI version 2"
212
+ )
213
+ self.generation_record_size = self.generation_size()
214
+ if self.generation_record_size < ctypes.sizeof(GenerationRecord):
215
+ raise RuntimeError(
216
+ "libmorpheus generation record is smaller than ABI version 2"
217
+ )
218
+
219
+
220
+ def encoded_buffer(value: str, label: str) -> tuple[bytes, object]:
221
+ encoded = value.encode("utf-8")
222
+ if b"\0" in encoded:
223
+ raise ValueError(f"{label} contains a NUL byte")
224
+ buffer = (ctypes.c_uint8 * max(1, len(encoded)))()
225
+ if encoded:
226
+ buffer[: len(encoded)] = encoded
227
+ return encoded, buffer
@@ -0,0 +1,332 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Resource-owning Python facade for the libmorpheus ABI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ctypes
7
+ from os import PathLike, fspath
8
+ from threading import RLock
9
+ from typing import Callable, Self
10
+
11
+ from ._abi import (
12
+ ABI_VERSION,
13
+ GENERATION_OPTIONS_VERSION,
14
+ AnalysisRecord,
15
+ GenerationRecord,
16
+ NativeGenerationOptions,
17
+ NativeLibrary,
18
+ encoded_buffer,
19
+ )
20
+ from ._types import (
21
+ Analysis,
22
+ Generation,
23
+ GenerationOptions,
24
+ Language,
25
+ Option,
26
+ RawAnalysis,
27
+ RawGeneration,
28
+ Status,
29
+ normalize_analysis,
30
+ normalize_generation,
31
+ )
32
+
33
+
34
+ class MorpheusError(RuntimeError):
35
+ """Error reported by the native ABI, including its numeric status."""
36
+
37
+ def __init__(self, status: int, message: str) -> None:
38
+ super().__init__(message)
39
+ self.status = status
40
+
41
+
42
+ def _message(native: NativeLibrary, status: int) -> str:
43
+ message = native.status_message(status)
44
+ return message.decode("utf-8") if message else f"Morpheus status {status}"
45
+
46
+
47
+ def _check(native: NativeLibrary, status: int) -> None:
48
+ if status != Status.OK:
49
+ raise MorpheusError(status, _message(native, status))
50
+
51
+
52
+ def _text(value: bytes) -> str:
53
+ return value.split(b"\0", 1)[0].decode("utf-8")
54
+
55
+
56
+ def _raw_analysis(record: AnalysisRecord, truncated_fields: int) -> RawAnalysis:
57
+ return RawAnalysis(
58
+ struct_size=record.struct_size,
59
+ part_of_speech=record.part_of_speech,
60
+ dialect=record.dialect,
61
+ geographic_region=record.geographic_region,
62
+ person=record.person,
63
+ number=record.number,
64
+ gender=record.gender,
65
+ grammatical_case=record.grammatical_case,
66
+ tense=record.tense,
67
+ mood=record.mood,
68
+ voice=record.voice,
69
+ degree=record.degree,
70
+ raw=_text(record.raw),
71
+ workword=_text(record.workword),
72
+ lemma=_text(record.lemma),
73
+ preverb=_text(record.preverb),
74
+ augment=_text(record.augment),
75
+ stem=_text(record.stem),
76
+ suffix=_text(record.suffix),
77
+ ending=_text(record.ending),
78
+ crasis=_text(record.crasis),
79
+ dictionary_form=_text(record.dictionary_form),
80
+ english_form=_text(record.english_form),
81
+ raw_preverb=_text(record.raw_preverb),
82
+ domains=_text(record.domains),
83
+ morph_flags=bytes(record.morph_flags),
84
+ truncated_fields=truncated_fields,
85
+ )
86
+
87
+
88
+ def _raw_generation(
89
+ record: GenerationRecord, truncated_fields: int
90
+ ) -> RawGeneration:
91
+ return RawGeneration(
92
+ struct_size=record.struct_size,
93
+ part_of_speech=record.part_of_speech,
94
+ dialect=record.dialect,
95
+ geographic_region=record.geographic_region,
96
+ person=record.person,
97
+ number=record.number,
98
+ gender=record.gender,
99
+ grammatical_case=record.grammatical_case,
100
+ tense=record.tense,
101
+ mood=record.mood,
102
+ voice=record.voice,
103
+ degree=record.degree,
104
+ surface=_text(record.surface),
105
+ lemma=_text(record.lemma),
106
+ morph_flags=bytes(record.morph_flags),
107
+ truncated_fields=truncated_fields,
108
+ )
109
+
110
+
111
+ def _generation_options(options: GenerationOptions) -> NativeGenerationOptions:
112
+ if not isinstance(options.result_limit, int) or isinstance(
113
+ options.result_limit, bool
114
+ ) or not 0 <= options.result_limit <= 65_536:
115
+ raise ValueError("result_limit must be an integer from 0 to 65536")
116
+ return NativeGenerationOptions(
117
+ version=GENERATION_OPTIONS_VERSION,
118
+ struct_size=ctypes.sizeof(NativeGenerationOptions),
119
+ result_limit=options.result_limit,
120
+ flags=1 if options.exclude_duals else 0,
121
+ part_of_speech=int(options.part_of_speech),
122
+ dialect=int(options.dialect),
123
+ geographic_region=int(options.geographic_region),
124
+ person=int(options.person),
125
+ number=int(options.number),
126
+ gender=int(options.gender),
127
+ grammatical_case=int(options.grammatical_case),
128
+ tense=int(options.tense),
129
+ mood=int(options.mood),
130
+ voice=int(options.voice),
131
+ degree=int(options.degree),
132
+ )
133
+
134
+
135
+ class Library:
136
+ """Owns one validated native library and creates stateful contexts."""
137
+
138
+ def __init__(self, path: str | PathLike[str]) -> None:
139
+ self._native: NativeLibrary | None = NativeLibrary(path)
140
+ self._contexts = 0
141
+ self._lock = RLock()
142
+
143
+ def context(
144
+ self, stemlib_path: str | PathLike[str], language: Language
145
+ ) -> Context:
146
+ with self._lock:
147
+ native = self._require_open()
148
+ path = fspath(stemlib_path)
149
+ if not isinstance(path, str):
150
+ raise TypeError("stemlib path must resolve to a string")
151
+ encoded, buffer = encoded_buffer(path, "stemlib path")
152
+ pointer = ctypes.c_void_p()
153
+ status = native.open_path(
154
+ ABI_VERSION,
155
+ buffer,
156
+ len(encoded),
157
+ int(language),
158
+ ctypes.byref(pointer),
159
+ )
160
+ _check(native, status)
161
+ if not pointer:
162
+ raise RuntimeError("libmorpheus returned a null context")
163
+ self._contexts += 1
164
+ return Context(native, pointer, self._context_closed)
165
+
166
+ def close(self) -> None:
167
+ with self._lock:
168
+ if self._native is None:
169
+ return
170
+ if self._contexts:
171
+ raise RuntimeError("Close all Morpheus contexts before the library")
172
+ self._native = None
173
+
174
+ def _require_open(self) -> NativeLibrary:
175
+ if self._native is None:
176
+ raise RuntimeError("Morpheus library is closed")
177
+ return self._native
178
+
179
+ def _context_closed(self) -> None:
180
+ with self._lock:
181
+ self._contexts -= 1
182
+
183
+ def __enter__(self) -> Self:
184
+ self._require_open()
185
+ return self
186
+
187
+ def __exit__(self, *_: object) -> None:
188
+ self.close()
189
+
190
+
191
+ class Context:
192
+ """One language and stemlib context with serialized native operations."""
193
+
194
+ def __init__(
195
+ self,
196
+ native: NativeLibrary,
197
+ pointer: ctypes.c_void_p,
198
+ on_close: Callable[[], None],
199
+ ) -> None:
200
+ self._native = native
201
+ self._pointer: ctypes.c_void_p | None = pointer
202
+ self._on_close: Callable[[], None] | None = on_close
203
+ self._lock = RLock()
204
+
205
+ def analyze(
206
+ self, beta_code: str, options: Option = Option.NONE
207
+ ) -> tuple[Analysis, ...]:
208
+ return tuple(normalize_analysis(item) for item in self.analyze_raw(beta_code, options))
209
+
210
+ def analyze_raw(
211
+ self, beta_code: str, options: Option = Option.NONE
212
+ ) -> tuple[RawAnalysis, ...]:
213
+ encoded, buffer = encoded_buffer(beta_code, "Beta Code input")
214
+ option_value = int(options)
215
+ if option_value < 0 or option_value > (1 << 64) - 1:
216
+ raise ValueError("analysis options exceed uint64")
217
+ with self._lock:
218
+ pointer = self._require_open()
219
+ result = ctypes.c_void_p()
220
+ status = self._native.analyze(
221
+ pointer,
222
+ buffer,
223
+ len(encoded),
224
+ option_value,
225
+ ctypes.byref(result),
226
+ )
227
+ _check(self._native, status)
228
+ try:
229
+ count = self._native.result_count(result)
230
+ analyses = []
231
+ for index in range(count):
232
+ storage = ctypes.create_string_buffer(
233
+ self._native.analysis_record_size
234
+ )
235
+ _check(
236
+ self._native,
237
+ self._native.result_copy(
238
+ result,
239
+ index,
240
+ storage,
241
+ self._native.analysis_record_size,
242
+ ),
243
+ )
244
+ truncated = ctypes.c_uint32()
245
+ _check(
246
+ self._native,
247
+ self._native.result_truncated_fields(
248
+ result, index, ctypes.byref(truncated)
249
+ ),
250
+ )
251
+ record = AnalysisRecord.from_buffer_copy(storage)
252
+ analyses.append(_raw_analysis(record, truncated.value))
253
+ return tuple(analyses)
254
+ finally:
255
+ self._native.result_free(result)
256
+
257
+ def generate(
258
+ self, lemma: str, options: GenerationOptions = GenerationOptions()
259
+ ) -> tuple[Generation, ...]:
260
+ """Generate normalized forms through the experimental native API."""
261
+ return tuple(
262
+ normalize_generation(item) for item in self.generate_raw(lemma, options)
263
+ )
264
+
265
+ def generate_raw(
266
+ self, lemma: str, options: GenerationOptions = GenerationOptions()
267
+ ) -> tuple[RawGeneration, ...]:
268
+ """Generate lossless ABI records through the experimental native API."""
269
+ encoded, buffer = encoded_buffer(lemma, "Beta Code lemma")
270
+ native_options = _generation_options(options)
271
+ with self._lock:
272
+ pointer = self._require_open()
273
+ result = ctypes.c_void_p()
274
+ status = self._native.generate(
275
+ pointer,
276
+ buffer,
277
+ len(encoded),
278
+ ctypes.byref(native_options),
279
+ ctypes.byref(result),
280
+ )
281
+ _check(self._native, status)
282
+ try:
283
+ count = self._native.generation_result_count(result)
284
+ generations = []
285
+ for index in range(count):
286
+ storage = ctypes.create_string_buffer(
287
+ self._native.generation_record_size
288
+ )
289
+ _check(
290
+ self._native,
291
+ self._native.generation_result_copy(
292
+ result,
293
+ index,
294
+ storage,
295
+ self._native.generation_record_size,
296
+ ),
297
+ )
298
+ truncated = ctypes.c_uint32()
299
+ _check(
300
+ self._native,
301
+ self._native.generation_result_truncated_fields(
302
+ result, index, ctypes.byref(truncated)
303
+ ),
304
+ )
305
+ record = GenerationRecord.from_buffer_copy(storage)
306
+ generations.append(_raw_generation(record, truncated.value))
307
+ return tuple(generations)
308
+ finally:
309
+ self._native.generation_result_free(result)
310
+
311
+ def close(self) -> None:
312
+ with self._lock:
313
+ if self._pointer is None:
314
+ return
315
+ self._native.close_context(self._pointer)
316
+ self._pointer = None
317
+ callback = self._on_close
318
+ self._on_close = None
319
+ if callback is not None:
320
+ callback()
321
+
322
+ def _require_open(self) -> ctypes.c_void_p:
323
+ if self._pointer is None:
324
+ raise RuntimeError("Morpheus context is closed")
325
+ return self._pointer
326
+
327
+ def __enter__(self) -> Self:
328
+ self._require_open()
329
+ return self
330
+
331
+ def __exit__(self, *_: object) -> None:
332
+ self.close()