cpp-pinyin2 2.0.0__py3-none-win_amd64.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.
- cpp_pinyin2/__init__.py +860 -0
- cpp_pinyin2/_errors.py +64 -0
- cpp_pinyin2/_ffi.py +411 -0
- cpp_pinyin2/_native/libcpp-pinyin2.dll +0 -0
- cpp_pinyin2/_native/name.txt +1 -0
- cpp_pinyin2/_native.py +80 -0
- cpp_pinyin2/_version.py +4 -0
- cpp_pinyin2/data/dict/cantonese/License.txt +13 -0
- cpp_pinyin2/data/dict/cantonese/dialect.json +15 -0
- cpp_pinyin2/data/dict/cantonese/format.txt +1 -0
- cpp_pinyin2/data/dict/cantonese/phrases_dict.txt +1477 -0
- cpp_pinyin2/data/dict/cantonese/phrases_map.txt +461 -0
- cpp_pinyin2/data/dict/cantonese/trans_word.txt +1799 -0
- cpp_pinyin2/data/dict/cantonese/user_dict.txt +0 -0
- cpp_pinyin2/data/dict/cantonese/word.txt +19483 -0
- cpp_pinyin2/data/dict/mandarin/License.txt +32 -0
- cpp_pinyin2/data/dict/mandarin/dialect.json +15 -0
- cpp_pinyin2/data/dict/mandarin/format.txt +1 -0
- cpp_pinyin2/data/dict/mandarin/phrases_dict.txt +6975 -0
- cpp_pinyin2/data/dict/mandarin/phrases_map.txt +1003 -0
- cpp_pinyin2/data/dict/mandarin/trans_word.txt +6110 -0
- cpp_pinyin2/data/dict/mandarin/user_dict.txt +101 -0
- cpp_pinyin2/data/dict/mandarin/word.txt +17525 -0
- cpp_pinyin2-2.0.0.dist-info/METADATA +509 -0
- cpp_pinyin2-2.0.0.dist-info/RECORD +29 -0
- cpp_pinyin2-2.0.0.dist-info/WHEEL +5 -0
- cpp_pinyin2-2.0.0.dist-info/licenses/LICENSE +202 -0
- cpp_pinyin2-2.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +44 -0
- cpp_pinyin2-2.0.0.dist-info/top_level.txt +1 -0
cpp_pinyin2/__init__.py
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"""Python shell for cpp-pinyin2: a ctypes binding over the C ABI in ``c_api.h``.
|
|
2
|
+
|
|
3
|
+
What this is not: a second implementation. Splitting, reading selection and style conversion
|
|
4
|
+
all happen inside the library; this package marshals bytes in and copies results out
|
|
5
|
+
(`docs/DECISIONS.md` §2.1).
|
|
6
|
+
|
|
7
|
+
Invariants kept here (same section):
|
|
8
|
+
|
|
9
|
+
* one native handle per object, held in a slot that ``close()`` empties -- ``cppp2_destroy``
|
|
10
|
+
only ``delete``s the engine, so a second call on a non-NULL pointer is a use-after-free,
|
|
11
|
+
and the C header's "NULL is a no-op" covers NULL only;
|
|
12
|
+
* every non-``OK`` status raises, with a fallback for codes this shell has never seen;
|
|
13
|
+
* results are copied out before ``cppp2_result_free`` runs, in a ``finally``;
|
|
14
|
+
* U+0000 in text or in a path is refused before crossing the boundary, because the C ABI
|
|
15
|
+
takes NUL-terminated strings and would silently truncate;
|
|
16
|
+
* only default (GIL) CPython builds are supported: a free-threaded build loses the
|
|
17
|
+
"a CDLL call runs under the GIL" premise that the library's lock-free read path relies on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ctypes
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import IntEnum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Iterable, List, Optional, Tuple, Union
|
|
28
|
+
|
|
29
|
+
from . import _ffi
|
|
30
|
+
from ._errors import (
|
|
31
|
+
CppPinyinError,
|
|
32
|
+
DictionaryError,
|
|
33
|
+
DictionaryIncompatibleError,
|
|
34
|
+
InternalError,
|
|
35
|
+
InvalidArgumentError,
|
|
36
|
+
NotReadyError,
|
|
37
|
+
UnknownDialectError,
|
|
38
|
+
)
|
|
39
|
+
from ._native import load_library
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from ._version import __commit__, __version__
|
|
43
|
+
except ImportError as exc: # pragma: no cover - packaging guard
|
|
44
|
+
raise CppPinyinError(
|
|
45
|
+
"cpp_pinyin2/_version.py is missing: this package was not assembled by "
|
|
46
|
+
"python/tools/build_native.py. The version has exactly one source (the root "
|
|
47
|
+
"CMakeLists.txt `project(... VERSION)`), and falling back to a default would "
|
|
48
|
+
"produce a package that reports a version nobody built."
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"CppPinyinError",
|
|
53
|
+
"DictionaryError",
|
|
54
|
+
"DictionaryIncompatibleError",
|
|
55
|
+
"Engine",
|
|
56
|
+
"InternalError",
|
|
57
|
+
"InvalidArgumentError",
|
|
58
|
+
"MAX_TEXT_BYTES",
|
|
59
|
+
"MAX_USER_DICT_KEY",
|
|
60
|
+
"NotReadyError",
|
|
61
|
+
"OnError",
|
|
62
|
+
"Segment",
|
|
63
|
+
"Style",
|
|
64
|
+
"Syllable",
|
|
65
|
+
"UnknownDialectError",
|
|
66
|
+
"__commit__",
|
|
67
|
+
"__version__",
|
|
68
|
+
"code_text",
|
|
69
|
+
"to_text",
|
|
70
|
+
"dialect_ids",
|
|
71
|
+
"version",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
#: Largest accepted input, checked on the UTF-8 encoding of the four text entry points. The
|
|
75
|
+
#: library has no limit of its own (its sliding window is linear, not quadratic); this exists
|
|
76
|
+
#: to refuse memory-exhaustion-sized inputs at the boundary.
|
|
77
|
+
MAX_TEXT_BYTES = 16 * 1024 * 1024
|
|
78
|
+
|
|
79
|
+
#: Longest user-dictionary key the library can index. `encodePhrasesKey` packs 16 bits per
|
|
80
|
+
#: character into a uint64, so a longer key has its leading characters shifted out and loads
|
|
81
|
+
#: under the wrong key without any error.
|
|
82
|
+
MAX_USER_DICT_KEY = 4
|
|
83
|
+
|
|
84
|
+
_LIB: Optional[ctypes.CDLL] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _library() -> ctypes.CDLL:
|
|
88
|
+
"""The process-wide CDLL, loaded once."""
|
|
89
|
+
global _LIB
|
|
90
|
+
if _LIB is None:
|
|
91
|
+
_LIB = load_library()
|
|
92
|
+
return _LIB
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Style(IntEnum):
|
|
96
|
+
"""The library's own style numbering -- NOT pypinyin's.
|
|
97
|
+
|
|
98
|
+
``NORMAL`` (0) is the only value both built-in dialects share; ``FIRST_LETTER`` (3) and
|
|
99
|
+
``TONE3`` (8) exist for both; the rest are Mandarin-only. A style a dialect does not
|
|
100
|
+
support returns the input unchanged, which is the library's documented contract, and this
|
|
101
|
+
shell does not second-guess it.
|
|
102
|
+
|
|
103
|
+
``DEFAULT`` (-1) is not a style: it is the sentinel "whatever this dialect defaults to",
|
|
104
|
+
and it is what every entry point below asks for when the caller names no style. The
|
|
105
|
+
library resolves it (`core/Types.h` -> ``resolveStyle``), so an unspecified style means
|
|
106
|
+
Mandarin ``TONE`` and Cantonese ``TONE3`` in ALL FOUR layers -- Python, C#, C++ and the C
|
|
107
|
+
ABI alike. It used to mean ``NORMAL`` here and ``style = 0`` in the C ABI, which made this
|
|
108
|
+
shell and the C ABI the two layers that disagreed with the C++ facades about the same
|
|
109
|
+
sentence (C4/N27 fixed that by giving "unset" a value of its own).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
DEFAULT = -1
|
|
113
|
+
NORMAL = 0
|
|
114
|
+
TONE = 1
|
|
115
|
+
TONE2 = 2
|
|
116
|
+
FIRST_LETTER = 3
|
|
117
|
+
BOPOMOFO = 4
|
|
118
|
+
INITIALS = 5
|
|
119
|
+
FINALS = 6
|
|
120
|
+
FINALS_TONE3 = 7
|
|
121
|
+
TONE3 = 8
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OnError(IntEnum):
|
|
125
|
+
"""``ErrorHandling``: ``DEFAULT`` marks a span it could not read, ``DROP`` drops it."""
|
|
126
|
+
|
|
127
|
+
DEFAULT = 0
|
|
128
|
+
DROP = 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Syllable:
|
|
133
|
+
"""One STRUCTURED reading -- ``cppp2_syllable``: data, never a rendering.
|
|
134
|
+
|
|
135
|
+
``initial`` is "" for a zero-initial syllable; ``fin`` is the canonical final (ü written as
|
|
136
|
+
``v``, ``uei``/``iou``/``uen`` spelled out, ``zhi`` -> ``i``) -- the two strings the
|
|
137
|
+
``INITIALS`` and ``FINALS`` styles render. The pair is a PROJECTION of the table reading, not
|
|
138
|
+
a re-encoding of it, so it does not always rebuild the spelling the table stores -- ``spelling``
|
|
139
|
+
is that spelling, verbatim, and is what :meth:`Engine.render_syllable` renders when it is set.
|
|
140
|
+
|
|
141
|
+
``tone`` is a dialect-internal tone-category number and is NOT comparable across dialects
|
|
142
|
+
(Mandarin 0..5, Cantonese 1..6): the same integer means a different category in each. It is
|
|
143
|
+
filled for every style, including the ones whose string carries no tone. ``tone_kind`` and
|
|
144
|
+
``source`` are the two value domains of the C header (``CPPP2_TONE_KIND_*`` / ``CPPP2_SOURCE_*``);
|
|
145
|
+
the shell does not rename them, so a caller can compare them against that header directly.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
initial: str
|
|
149
|
+
fin: str
|
|
150
|
+
tone: int
|
|
151
|
+
tone_kind: int
|
|
152
|
+
source: int
|
|
153
|
+
#: The table's own reading bytes -- this row's IDENTITY. ``initial``/``fin`` are its projection.
|
|
154
|
+
#: Declared LAST because it is optional: a caller holding only the two halves can leave it empty
|
|
155
|
+
#: and rendering falls back to the projection, which is exactly what a NULL ``spelling`` means in
|
|
156
|
+
#: the C struct. A reading that came out of :meth:`Engine.query` always carries it.
|
|
157
|
+
spelling: str = ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Segment:
|
|
162
|
+
"""One input span and what the engine read for it -- ``cppp2_segment``.
|
|
163
|
+
|
|
164
|
+
``text`` is a span of the input, not a character: a run of ASCII letters is one span.
|
|
165
|
+
``rendered`` is the ONE rendered string of this span -- it equals ``text`` when the span
|
|
166
|
+
failed, which is why the shell does not call it a "reading": ``Segment.syllables`` is where
|
|
167
|
+
the reading as data lives.
|
|
168
|
+
|
|
169
|
+
``failure_kind`` is the failure classification (numerically a ``cpp_pinyin2::v2::Code``, 0 = OK;
|
|
170
|
+
:func:`code_text` is the single authority for its wording). It -- never "is ``syllables``
|
|
171
|
+
filled" -- is the failure test: ``syllables`` is empty both when the span failed and when
|
|
172
|
+
the query did not ask for the structure (``include_reading=False``).
|
|
173
|
+
|
|
174
|
+
``candidates`` is empty unless the query asked for candidates -- and it is not necessarily a
|
|
175
|
+
superset of ``rendered``: a phrase-table hit can carry a reading the character table does
|
|
176
|
+
not list.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
text: str
|
|
180
|
+
rendered: str
|
|
181
|
+
failure_kind: int
|
|
182
|
+
candidates: Tuple[str, ...] = field(default=())
|
|
183
|
+
syllables: Tuple[Syllable, ...] = field(default=())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def code_text(code: int) -> str:
|
|
187
|
+
"""The library's own wording for a status code (``cppp2_code_text``).
|
|
188
|
+
|
|
189
|
+
A shell that wants to say what went wrong asks here instead of writing its own sentence, so
|
|
190
|
+
the wording has one authority -- the library. Accepts a ``failure_kind`` from a
|
|
191
|
+
:class:`Segment` as well: it is the same value domain.
|
|
192
|
+
"""
|
|
193
|
+
return _ffi.decode(_library().cppp2_code_text(int(code)))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def to_text(segments: Iterable["Segment"]) -> str:
|
|
197
|
+
"""``query()``'s segments joined into one space-separated string -- the shape ``Result::join()`` has in C++.
|
|
198
|
+
|
|
199
|
+
A projection of the result, not a second way to ask the engine: the frozen C++ face puts the string form on
|
|
200
|
+
the RESULT (``Result::join`` in ``include/cpp-pinyin2/core/Types.h``), and this mirrors it. A failed span
|
|
201
|
+
contributes its original text (``rendered`` equals ``text`` there), so the input stays readable in the output.
|
|
202
|
+
|
|
203
|
+
>>> to_text(engine.query("中国"))
|
|
204
|
+
'zhōng guó'
|
|
205
|
+
"""
|
|
206
|
+
return " ".join(segment.rendered for segment in segments)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _encode_text(value: str, what: str) -> bytes:
|
|
210
|
+
"""Validate and encode an argument that crosses the boundary as UTF-8 text."""
|
|
211
|
+
if not isinstance(value, str):
|
|
212
|
+
raise TypeError(f"{what} must be str, not {type(value).__name__}")
|
|
213
|
+
if "\x00" in value:
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"{what} contains U+0000: the C ABI takes NUL-terminated UTF-8, so an embedded "
|
|
216
|
+
f"NUL would be silently truncated instead of rejected"
|
|
217
|
+
)
|
|
218
|
+
encoded = value.encode("utf-8") # a lone surrogate raises UnicodeEncodeError, by design
|
|
219
|
+
if len(encoded) > MAX_TEXT_BYTES:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"{what} is {len(encoded)} bytes, over MAX_TEXT_BYTES ({MAX_TEXT_BYTES})"
|
|
222
|
+
)
|
|
223
|
+
return encoded
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _encode_path(value: Union[str, os.PathLike], what: str) -> bytes:
|
|
227
|
+
"""Validate and encode a filesystem path argument."""
|
|
228
|
+
raw = os.fspath(value)
|
|
229
|
+
if isinstance(raw, bytes):
|
|
230
|
+
raw = raw.decode("utf-8")
|
|
231
|
+
if not isinstance(raw, str):
|
|
232
|
+
raise TypeError(f"{what} must be str or os.PathLike, not {type(value).__name__}")
|
|
233
|
+
if "\x00" in raw:
|
|
234
|
+
raise ValueError(f"{what} contains U+0000, which the C ABI would silently truncate")
|
|
235
|
+
return raw.encode("utf-8")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_dict_file(path: Path) -> None:
|
|
239
|
+
"""Refuse a user dictionary the library would accept while loading nothing useful.
|
|
240
|
+
|
|
241
|
+
Transport-layer only (section 6.8.1, rule 10) -- this is not a second parser. Two
|
|
242
|
+
measured library behaviours motivate it:
|
|
243
|
+
|
|
244
|
+
* the loader behind ``cppp2_load_user_dict`` returns success unconditionally
|
|
245
|
+
(``src/dict/DictUtil.cpp``, the end of ``loadUserDictLayer``), so a file that parses to zero
|
|
246
|
+
entries loads "fine";
|
|
247
|
+
* a key longer than :data:`MAX_USER_DICT_KEY` is silently truncated by
|
|
248
|
+
``encodePhrasesKey``.
|
|
249
|
+
|
|
250
|
+
Lines without ``:`` are skipped here as well, matching ``splitLine`` in
|
|
251
|
+
``src/dict/DictUtil.cpp``, which ignores them rather than failing: a stray line is not
|
|
252
|
+
evidence of a malformed file.
|
|
253
|
+
"""
|
|
254
|
+
entries = 0
|
|
255
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
256
|
+
for lineno, line in enumerate(handle, 1):
|
|
257
|
+
stripped = line.strip()
|
|
258
|
+
if not stripped:
|
|
259
|
+
continue
|
|
260
|
+
separator = stripped.find(":")
|
|
261
|
+
if separator <= 0:
|
|
262
|
+
continue
|
|
263
|
+
key = stripped[:separator]
|
|
264
|
+
if len(key) > MAX_USER_DICT_KEY:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"{path}:{lineno}: key {key!r} is {len(key)} characters, over "
|
|
267
|
+
f"MAX_USER_DICT_KEY ({MAX_USER_DICT_KEY}); the library would truncate it "
|
|
268
|
+
f"to the last {MAX_USER_DICT_KEY} characters and index the wrong key"
|
|
269
|
+
)
|
|
270
|
+
entries += 1
|
|
271
|
+
if entries == 0:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"{path}: no `key:value` entries. The library reports success for a zero-entry "
|
|
274
|
+
f"file on this path, which would leave the engine silently unchanged"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _make_opts(
|
|
279
|
+
style: Union[Style, int],
|
|
280
|
+
*,
|
|
281
|
+
on_error: Union[OnError, int] = OnError.DEFAULT,
|
|
282
|
+
include_candidates: bool = False,
|
|
283
|
+
v_to_u: bool = False,
|
|
284
|
+
mark_neutral_as_5: bool = False,
|
|
285
|
+
include_reading: bool = False,
|
|
286
|
+
) -> "_ffi.QueryOpts":
|
|
287
|
+
"""Build a caller-owned ``cppp2_query_opts`` for one call.
|
|
288
|
+
|
|
289
|
+
``struct_size`` is written BEFORE ``cppp2_query_opts_init`` (the documented order, decision D19).
|
|
290
|
+
init writes exactly the fields inside the declared size; a zeroed ``struct_size`` is read as
|
|
291
|
+
"the whole struct" and every field is written, so a zeroed size is not a way to reach the
|
|
292
|
+
library's defaults -- declare the real size.
|
|
293
|
+
"""
|
|
294
|
+
opts = _ffi.QueryOpts()
|
|
295
|
+
opts.struct_size = ctypes.sizeof(_ffi.QueryOpts)
|
|
296
|
+
_library().cppp2_query_opts_init(ctypes.byref(opts))
|
|
297
|
+
opts.style = int(style)
|
|
298
|
+
opts.on_error = int(on_error)
|
|
299
|
+
opts.include_candidates = 1 if include_candidates else 0
|
|
300
|
+
opts.v_to_u = 1 if v_to_u else 0
|
|
301
|
+
opts.mark_neutral_as_5 = 1 if mark_neutral_as_5 else 0
|
|
302
|
+
opts.include_reading = 1 if include_reading else 0
|
|
303
|
+
return opts
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _checked_opts(handle, dialect_id, style, **kw):
|
|
307
|
+
"""``_make_opts``, after refusing a style this dialect does not declare (N20=A).
|
|
308
|
+
|
|
309
|
+
The library's pass-through is a deliberate contract: a style it cannot apply comes back
|
|
310
|
+
unchanged. A shell is in a different position -- it has names, and the engine will hand it the
|
|
311
|
+
dialect's OWN style table -- so it refuses instead of rendering something else. Measured on this
|
|
312
|
+
shell before the check existed: cantonese with style=3 rendered "z g" (Mandarin FIRST_LETTER over
|
|
313
|
+
Cantonese readings), and style=9 / style=-2 both silently rendered a dialect default.
|
|
314
|
+
"""
|
|
315
|
+
value = int(style)
|
|
316
|
+
if value != -1: # -1 is the sentinel Style.DEFAULT: "the dialect's own default"
|
|
317
|
+
lib = _library()
|
|
318
|
+
rows = ctypes.POINTER(_ffi.Style)()
|
|
319
|
+
count = ctypes.c_size_t(0)
|
|
320
|
+
status = lib.cppp2_style_list(handle, ctypes.byref(rows), ctypes.byref(count))
|
|
321
|
+
_ffi.raise_for_status(status, "cppp2_style_list")
|
|
322
|
+
try:
|
|
323
|
+
aliases = {rows[i].alias: rows[i].name.decode("utf-8") for i in range(count.value)}
|
|
324
|
+
finally:
|
|
325
|
+
lib.cppp2_style_list_free(rows, count)
|
|
326
|
+
if value not in aliases:
|
|
327
|
+
declared = ", ".join(f"{alias} ({name})" for alias, name in sorted(aliases.items()))
|
|
328
|
+
raise InvalidArgumentError(
|
|
329
|
+
f"{value} is not a style of dialect {dialect_id!r}; it declares {declared} -- "
|
|
330
|
+
f"or pass Style.DEFAULT for the dialect's own default"
|
|
331
|
+
)
|
|
332
|
+
return _make_opts(style, **kw)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Engine:
|
|
337
|
+
"""One dialect over one dictionary root, backed by one ``cppp2_engine *``.
|
|
338
|
+
|
|
339
|
+
Use it as a context manager, or call :meth:`close`. After ``close()`` every method raises
|
|
340
|
+
``ValueError`` -- including a second ``close()``, which is a no-op instead.
|
|
341
|
+
|
|
342
|
+
Reads (``query``, ``readings``, ``to_simplified``, ``is_polyphonic``) are safe from
|
|
343
|
+
several threads at once on the same engine. ``load_user_dict`` and
|
|
344
|
+
``load_traditional_dict`` mutate tables those reads walk: see their docstrings.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
__slots__ = ("_handle", "_dialect_id", "_dict_dir")
|
|
348
|
+
|
|
349
|
+
#: The environment variable a deployment sets to point at its own dictionary root. It is read HERE, in
|
|
350
|
+
#: the shell, and never inside the library: the C++/C surface deliberately has no implicit path
|
|
351
|
+
#: (`include/cpp-pinyin2/ext/c_api.h`), and a shell is the layer that decides what to fall back on.
|
|
352
|
+
DICT_DIR_ENV = "CPP_PINYIN2_DICT_DIR"
|
|
353
|
+
|
|
354
|
+
def __init__(
|
|
355
|
+
self,
|
|
356
|
+
dialect_id: str = "mandarin",
|
|
357
|
+
dict_dir: Optional[Union[str, os.PathLike]] = None,
|
|
358
|
+
) -> None:
|
|
359
|
+
lib = _library()
|
|
360
|
+
# Validate BOTH arguments before touching the filesystem. pathlib swallows the
|
|
361
|
+
# ValueError a NUL byte raises and reports the path as simply not existing, so
|
|
362
|
+
# checking the directory first makes "bad\x00path" look like a missing directory
|
|
363
|
+
# instead of an invalid argument -- the wrong diagnosis, from the right guard.
|
|
364
|
+
dialect_bytes = _encode_path(dialect_id, "dialect_id")
|
|
365
|
+
root = Path(os.fspath(dict_dir)) if dict_dir is not None else self.default_dict_dir()
|
|
366
|
+
root_bytes = _encode_path(root, "dict_dir")
|
|
367
|
+
if not root.is_dir():
|
|
368
|
+
raise CppPinyinError(f"dictionary root does not exist: {root}")
|
|
369
|
+
|
|
370
|
+
error = _ffi.Error()
|
|
371
|
+
handle = lib.cppp2_open(None, dialect_bytes, root_bytes, ctypes.byref(error))
|
|
372
|
+
if not handle:
|
|
373
|
+
# The status code is what distinguishes the two failures the old cppp_create collapsed
|
|
374
|
+
# into "NULL handle plus a message": NOT_READY is an id the registry does not know
|
|
375
|
+
# (the library never resolves it to another dialect), LOAD is a dictionary that would
|
|
376
|
+
# not load. The library's own detail is passed on -- naming the failing files is its
|
|
377
|
+
# job, not this shell's.
|
|
378
|
+
detail = _ffi.take_detail(error, lib)
|
|
379
|
+
where = f"cppp2_open for dialect {dialect_id!r} with dictionary root {root}"
|
|
380
|
+
if error.code == _ffi.NOT_READY:
|
|
381
|
+
suffix = f": {detail}" if detail else ""
|
|
382
|
+
raise UnknownDialectError(
|
|
383
|
+
f"{where} failed with {_ffi.status_name(error.code)}{suffix}"
|
|
384
|
+
)
|
|
385
|
+
_ffi.raise_for_status(error.code, where, detail)
|
|
386
|
+
# On success the contract says detail is NULL and nothing is freed; calling it anyway keeps
|
|
387
|
+
# the ownership rule in one place instead of "free only when the call failed".
|
|
388
|
+
_ffi.take_detail(error, lib)
|
|
389
|
+
self._handle: Optional[int] = handle
|
|
390
|
+
self._dialect_id = dialect_id
|
|
391
|
+
self._dict_dir = root
|
|
392
|
+
|
|
393
|
+
# -- introspection ------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def default_dict_dir() -> Path:
|
|
397
|
+
"""The dictionary root used when no ``dict_dir`` is given.
|
|
398
|
+
|
|
399
|
+
Precedence (D1 of docs/DECISIONS.md section 4.1): the
|
|
400
|
+
``CPP_PINYIN2_DICT_DIR`` environment variable -- a deployment override for containers, read-only
|
|
401
|
+
images or a relocated application directory -- then the copy shipped inside this package. An empty
|
|
402
|
+
value counts as unset: an exported-but-empty variable is a shell accident, not an instruction.
|
|
403
|
+
|
|
404
|
+
The result is NOT checked for existence here. A caller that overrides the variable with a wrong
|
|
405
|
+
directory must see the library's own diagnosis ("which directory, which file") instead of being
|
|
406
|
+
silently handed the packaged copy, which would hide a broken deployment.
|
|
407
|
+
|
|
408
|
+
The packaged path is deliberately a plain __file__-relative path rather than
|
|
409
|
+
importlib.resources.files(). Measured on real interpreters: resources.files() exists only from
|
|
410
|
+
Python 3.9, and it was the single thing pinning this package's floor there -- on 3.8 the whole
|
|
411
|
+
suite failed with "module 'importlib.resources' has no attribute 'files'". With this line the same
|
|
412
|
+
91 tests pass on 3.8.10 as on 3.9 through 3.13. The package is a regular package installed as
|
|
413
|
+
ordinary files, which is the case this has to handle; nothing here claims to serve a zipimport or
|
|
414
|
+
namespace-package layout.
|
|
415
|
+
"""
|
|
416
|
+
override = os.environ.get(Engine.DICT_DIR_ENV, "")
|
|
417
|
+
if override:
|
|
418
|
+
return Path(override)
|
|
419
|
+
return Path(__file__).resolve().parent / "data" / "dict"
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def dict_dir_candidate() -> Optional[Path]:
|
|
423
|
+
"""The dictionary root the NATIVE library was installed to, or ``None`` when it has no anchor.
|
|
424
|
+
|
|
425
|
+
D2's candidate: the library reports its own module's neighbour
|
|
426
|
+
(``<module dir>/../share/cpp-pinyin2/dict``), which is what lets a relocated application work without
|
|
427
|
+
hard-coding a path. It is deliberately NOT consulted by :meth:`default_dict_dir`: a wheel carries its
|
|
428
|
+
own copy and that copy is the right one for a wheel consumer. Use it to diagnose "the native library
|
|
429
|
+
was installed somewhere other than this package".
|
|
430
|
+
|
|
431
|
+
``None`` means "no anchor" -- a static build, where the library is code inside the host, or a
|
|
432
|
+
platform whose module lookup failed. The value is not checked for existence: it is a candidate.
|
|
433
|
+
"""
|
|
434
|
+
pointer = _library().cppp2_dict_dir_candidate()
|
|
435
|
+
return Path(_ffi.decode(pointer)) if pointer else None
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def dialect_id(self) -> str:
|
|
439
|
+
"""The dialect id this engine was created with."""
|
|
440
|
+
return self._dialect_id
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def dict_dir(self) -> Path:
|
|
444
|
+
"""The dictionary root this engine was created with."""
|
|
445
|
+
return self._dict_dir
|
|
446
|
+
|
|
447
|
+
# -- lifecycle ----------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def _require_handle(self) -> int:
|
|
450
|
+
handle = self._handle
|
|
451
|
+
if handle is None:
|
|
452
|
+
raise ValueError("this Engine is closed")
|
|
453
|
+
return handle
|
|
454
|
+
|
|
455
|
+
def close(self) -> None:
|
|
456
|
+
"""Release the native handle. Idempotent: the slot is emptied, so the second call,
|
|
457
|
+
and every call after it, never reaches ``cppp2_destroy`` a second time."""
|
|
458
|
+
handle = self._handle
|
|
459
|
+
if handle is not None:
|
|
460
|
+
self._handle = None
|
|
461
|
+
_library().cppp2_destroy(handle)
|
|
462
|
+
|
|
463
|
+
def __enter__(self) -> "Engine":
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def __exit__(self, *exc_info) -> None:
|
|
467
|
+
self.close()
|
|
468
|
+
|
|
469
|
+
def __del__(self) -> None:
|
|
470
|
+
# Best effort: at interpreter shutdown the ctypes module may already be gone, and an
|
|
471
|
+
# exception here is noise the user cannot act on.
|
|
472
|
+
try:
|
|
473
|
+
self.close()
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
# -- queries ------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def query(
|
|
480
|
+
self,
|
|
481
|
+
text: str,
|
|
482
|
+
*,
|
|
483
|
+
style: Union[Style, int] = Style.DEFAULT,
|
|
484
|
+
include_candidates: bool = False,
|
|
485
|
+
include_reading: bool = False,
|
|
486
|
+
v_to_u: bool = False,
|
|
487
|
+
mark_neutral_as_5: bool = False,
|
|
488
|
+
on_error: Union[OnError, int] = OnError.DEFAULT,
|
|
489
|
+
) -> List[Segment]:
|
|
490
|
+
"""Convert ``text`` into one :class:`Segment` per input span.
|
|
491
|
+
|
|
492
|
+
With ``on_error=OnError.DROP`` a span that has no reading is dropped instead of
|
|
493
|
+
marked: no segment comes back with a non-zero ``failure_kind`` and ``len(result)`` is
|
|
494
|
+
*not* the number of input spans. An empty list is not an error.
|
|
495
|
+
|
|
496
|
+
``include_reading=True`` asks for the structural side (:class:`Syllable` rows): every
|
|
497
|
+
non-failed span then carries exactly one, and a FAILED span carries none -- a failure has
|
|
498
|
+
no structure to describe, and a failed span deliberately does not enter the array. The
|
|
499
|
+
default is off, and "off" is not "this span has no reading": ``rendered`` carries the
|
|
500
|
+
reading on every span either way.
|
|
501
|
+
|
|
502
|
+
Two measured details worth knowing before relying on the defaults:
|
|
503
|
+
|
|
504
|
+
* ``include_candidates=False`` still yields one candidate on a *failed* span -- the span's
|
|
505
|
+
own text. The flag governs spans that converted successfully. (Measured 2026-10-01 while
|
|
506
|
+
building the conformance fixture: ``query("abc你")`` returns ``"abc"`` with a non-zero
|
|
507
|
+
``failure_kind`` and ``candidates == ("abc",)``, then ``"你"`` with none.)
|
|
508
|
+
* ``v_to_u`` reads backwards from its name. ü is written ``v`` *by default*, and setting
|
|
509
|
+
``v_to_u=True`` switches the output to ``ü`` -- ``Types.h`` states it ("ü 记作 v,除非
|
|
510
|
+
vToU=true"). Cantonese ignores the switch entirely, so the same call on both dialects is
|
|
511
|
+
only a fair comparison with it left alone.
|
|
512
|
+
"""
|
|
513
|
+
return self._run_query(
|
|
514
|
+
text,
|
|
515
|
+
sandhi=None,
|
|
516
|
+
style=style,
|
|
517
|
+
on_error=on_error,
|
|
518
|
+
include_candidates=include_candidates,
|
|
519
|
+
include_reading=include_reading,
|
|
520
|
+
v_to_u=v_to_u,
|
|
521
|
+
mark_neutral_as_5=mark_neutral_as_5,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def _run_query(self, text, *, sandhi,
|
|
525
|
+
style: Union[Style, int] = Style.DEFAULT,
|
|
526
|
+
include_candidates: bool = False,
|
|
527
|
+
include_reading: bool = False,
|
|
528
|
+
v_to_u: bool = False,
|
|
529
|
+
mark_neutral_as_5: bool = False,
|
|
530
|
+
on_error: Union[OnError, int] = OnError.DEFAULT,
|
|
531
|
+
) -> List[Segment]:
|
|
532
|
+
"""One conversion, and optionally the Mandarin tone sandhi that has to run on the
|
|
533
|
+
result while it is still alive (see :meth:`tone_sandhi`). Every parameter keeps the
|
|
534
|
+
meaning documented on :meth:`query`; `sandhi` is None or (third_tone, bu, yi)."""
|
|
535
|
+
lib = _library()
|
|
536
|
+
handle = self._require_handle()
|
|
537
|
+
raw = _encode_text(text, "text")
|
|
538
|
+
opts = _checked_opts(self._handle, self._dialect_id,
|
|
539
|
+
style,
|
|
540
|
+
on_error=on_error,
|
|
541
|
+
include_candidates=include_candidates,
|
|
542
|
+
include_reading=include_reading,
|
|
543
|
+
v_to_u=v_to_u,
|
|
544
|
+
mark_neutral_as_5=mark_neutral_as_5,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
out = ctypes.POINTER(_ffi.Result)()
|
|
548
|
+
status = lib.cppp2_query(handle, raw, ctypes.byref(opts), ctypes.byref(out))
|
|
549
|
+
_ffi.raise_for_status(status, "cppp2_query")
|
|
550
|
+
if sandhi is not None:
|
|
551
|
+
third_tone, bu, yi = sandhi
|
|
552
|
+
sandhi_opts = _ffi.ToneSandhiOpts()
|
|
553
|
+
# struct_size first, before the call: the library reads it to learn what this caller owns.
|
|
554
|
+
sandhi_opts.struct_size = ctypes.sizeof(_ffi.ToneSandhiOpts)
|
|
555
|
+
sandhi_opts.third_tone = 1 if third_tone else 0
|
|
556
|
+
sandhi_opts.bu = 1 if bu else 0
|
|
557
|
+
sandhi_opts.yi = 1 if yi else 0
|
|
558
|
+
# On the LIVE result, before the copy-out below and before the finally-frees it.
|
|
559
|
+
changed = ctypes.c_size_t()
|
|
560
|
+
status = lib.cppp2_apply_tone_sandhi(handle, out, ctypes.byref(sandhi_opts), ctypes.byref(changed))
|
|
561
|
+
try:
|
|
562
|
+
# INSIDE the try on purpose: a refused pass still hands back a live result the caller
|
|
563
|
+
# owns, and the `finally` below is what frees it. Raising from before the `try` leaked
|
|
564
|
+
# the whole result -- and `cppp2_apply_tone_sandhi` refuses outright on a dialect with
|
|
565
|
+
# no sandhi pass (Cantonese), so that was the common path, not a corner (F21).
|
|
566
|
+
_ffi.raise_for_status(status, "cppp2_apply_tone_sandhi")
|
|
567
|
+
result = out.contents
|
|
568
|
+
count = int(result.count)
|
|
569
|
+
items: List[Segment] = []
|
|
570
|
+
for index in range(count):
|
|
571
|
+
span = result.segments[index]
|
|
572
|
+
candidates: Tuple[str, ...] = ()
|
|
573
|
+
if span.candidate_count:
|
|
574
|
+
# The pointer is NULL when candidates were not requested; count is what
|
|
575
|
+
# says whether it may be dereferenced.
|
|
576
|
+
candidates = tuple(
|
|
577
|
+
_ffi.decode(span.candidates[i])
|
|
578
|
+
for i in range(int(span.candidate_count))
|
|
579
|
+
)
|
|
580
|
+
syllables: Tuple[Syllable, ...] = ()
|
|
581
|
+
if span.syllable_count:
|
|
582
|
+
# Same rule: NULL unless the query asked for the structure, and a failed span
|
|
583
|
+
# never enters the array -- so count, not failure_kind, decides dereferencing.
|
|
584
|
+
syllables = tuple(
|
|
585
|
+
Syllable(
|
|
586
|
+
initial=_ffi.decode(span.syllables[i].initial),
|
|
587
|
+
fin=_ffi.decode(span.syllables[i].fin),
|
|
588
|
+
spelling=_ffi.decode(span.syllables[i].spelling),
|
|
589
|
+
tone=int(span.syllables[i].tone),
|
|
590
|
+
tone_kind=int(span.syllables[i].tone_kind),
|
|
591
|
+
source=int(span.syllables[i].source),
|
|
592
|
+
)
|
|
593
|
+
for i in range(int(span.syllable_count))
|
|
594
|
+
)
|
|
595
|
+
items.append(
|
|
596
|
+
Segment(
|
|
597
|
+
text=_ffi.decode(span.text),
|
|
598
|
+
rendered=_ffi.decode(span.rendered),
|
|
599
|
+
failure_kind=int(span.failure_kind),
|
|
600
|
+
candidates=candidates,
|
|
601
|
+
syllables=syllables,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
return items
|
|
605
|
+
finally:
|
|
606
|
+
lib.cppp2_result_free(out)
|
|
607
|
+
|
|
608
|
+
def tone_sandhi(
|
|
609
|
+
self,
|
|
610
|
+
text: str,
|
|
611
|
+
*,
|
|
612
|
+
style: Union[Style, int] = Style.DEFAULT,
|
|
613
|
+
include_candidates: bool = False,
|
|
614
|
+
include_reading: bool = False,
|
|
615
|
+
v_to_u: bool = False,
|
|
616
|
+
mark_neutral_as_5: bool = False,
|
|
617
|
+
on_error: Union[OnError, int] = OnError.DEFAULT,
|
|
618
|
+
third_tone: bool = True,
|
|
619
|
+
bu: bool = True,
|
|
620
|
+
yi: bool = True,
|
|
621
|
+
) -> List[Segment]:
|
|
622
|
+
"""Convert ``text``, then apply Mandarin tone sandhi to the result.
|
|
623
|
+
|
|
624
|
+
The rules are NOT implemented here: this is ``query`` followed by the C entry point
|
|
625
|
+
``cppp2_apply_tone_sandhi``, which is the C face of ``core/ToneSandhi.h`` -- the one place the
|
|
626
|
+
three rules (third-tone runs, bu, yi) and their two deliberate differences from pypinyin's
|
|
627
|
+
``contrib.tone_sandhi`` are written down. The three switches default to ON, the library's own
|
|
628
|
+
default, and are always sent explicitly: passing NULL would test the C default rather than the
|
|
629
|
+
value the caller asked for.
|
|
630
|
+
|
|
631
|
+
SCOPED BY THE DIALECT. The pass is whatever this engine\'s dialect declares (Dialect::toneSandhi in
|
|
632
|
+
C++); Mandarin is the only one today. Cantonese declares none -- jyutping readings are letters plus a
|
|
633
|
+
tone digit too, so no shape test could tell them apart -- and asking for sandhi on a Cantonese engine
|
|
634
|
+
raises CppPinyinError (CPPP2_ERR_INVALID_ARG) instead of rewriting the rows.
|
|
635
|
+
|
|
636
|
+
What changes is ``rendered``, and only where the reading is spelled as letters plus a tone
|
|
637
|
+
digit; the span count never changes. Everything else is documented on :meth:`query`.
|
|
638
|
+
"""
|
|
639
|
+
return self._run_query(
|
|
640
|
+
text,
|
|
641
|
+
sandhi=(third_tone, bu, yi),
|
|
642
|
+
style=style,
|
|
643
|
+
on_error=on_error,
|
|
644
|
+
include_candidates=include_candidates,
|
|
645
|
+
include_reading=include_reading,
|
|
646
|
+
v_to_u=v_to_u,
|
|
647
|
+
mark_neutral_as_5=mark_neutral_as_5,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def render_syllable(
|
|
651
|
+
self,
|
|
652
|
+
reading: Syllable,
|
|
653
|
+
*,
|
|
654
|
+
style: Union[Style, int] = Style.DEFAULT,
|
|
655
|
+
v_to_u: bool = False,
|
|
656
|
+
mark_neutral_as_5: bool = False,
|
|
657
|
+
) -> str:
|
|
658
|
+
"""Render ONE structured reading in ``style`` -- ``cppp2_render_syllable``.
|
|
659
|
+
|
|
660
|
+
This is why the shell does not implement a style itself: ``engine.render_syllable(s)`` is
|
|
661
|
+
the string this engine's own dialect prints for ``s``, including the dialect's tone
|
|
662
|
+
notation. Passing a reading from another dialect's engine would render the wrong bytes,
|
|
663
|
+
which is why the dialect comes from this handle rather than from the reading.
|
|
664
|
+
|
|
665
|
+
A style the dialect does not support is the library's normal contract: the reading comes
|
|
666
|
+
back unchanged, not as an error. An empty string is a legitimate answer too (``INITIALS`` of
|
|
667
|
+
a zero-initial syllable is ``""``), and is returned as such. An
|
|
668
|
+
:class:`InvalidArgumentError` means the structure does not denote one syllable (a separator
|
|
669
|
+
inside ``initial``/``fin``) or it carries neither a ``spelling`` nor a projection that can
|
|
670
|
+
rebuild one -- passing a reading that came out of :meth:`query` always has the spelling.
|
|
671
|
+
"""
|
|
672
|
+
lib = _library()
|
|
673
|
+
handle = self._require_handle()
|
|
674
|
+
if not isinstance(reading, Syllable):
|
|
675
|
+
raise TypeError(f"reading must be a Syllable, not {type(reading).__name__}")
|
|
676
|
+
for value, what in (
|
|
677
|
+
(reading.initial, "initial"),
|
|
678
|
+
(reading.fin, "fin"),
|
|
679
|
+
(reading.spelling, "spelling"),
|
|
680
|
+
):
|
|
681
|
+
if "\x00" in value:
|
|
682
|
+
raise ValueError(f"{what} contains U+0000, which the C ABI would silently truncate")
|
|
683
|
+
|
|
684
|
+
raw = _ffi.Syllable()
|
|
685
|
+
# ctypes keeps the encoded bytes alive inside the struct, so the pointers stay valid for
|
|
686
|
+
# as long as `raw` is referenced -- which is this whole call.
|
|
687
|
+
raw.initial = reading.initial.encode("utf-8")
|
|
688
|
+
raw.fin = reading.fin.encode("utf-8")
|
|
689
|
+
raw.spelling = reading.spelling.encode("utf-8")
|
|
690
|
+
raw.tone = int(reading.tone)
|
|
691
|
+
raw.tone_kind = int(reading.tone_kind)
|
|
692
|
+
raw.source = int(reading.source)
|
|
693
|
+
|
|
694
|
+
opts = _checked_opts(self._handle, self._dialect_id, style, v_to_u=v_to_u, mark_neutral_as_5=mark_neutral_as_5)
|
|
695
|
+
out = ctypes.c_char_p()
|
|
696
|
+
status = lib.cppp2_render_syllable(
|
|
697
|
+
handle, ctypes.byref(raw), ctypes.byref(opts), ctypes.byref(out)
|
|
698
|
+
)
|
|
699
|
+
_ffi.raise_for_status(status, "cppp2_render_syllable")
|
|
700
|
+
try:
|
|
701
|
+
return _ffi.decode(out)
|
|
702
|
+
finally:
|
|
703
|
+
lib.cppp2_string_free(out)
|
|
704
|
+
|
|
705
|
+
def tone_convert(self, reading: str, style: "Style" = None) -> str:
|
|
706
|
+
"""``reading`` rendered in ``style`` -- the dialect's own converter, through the C ABI.
|
|
707
|
+
|
|
708
|
+
Any notation this dialect reads goes in (``"ni3"``, ``"nǐ"``, ``"ni"``); a style the dialect does not
|
|
709
|
+
implement comes back unchanged, which is the library's contract rather than an error. No v-to-u and no
|
|
710
|
+
neutral-5: this converts styles, and changing the spelling would make it look like a different reading.
|
|
711
|
+
"""
|
|
712
|
+
lib = _library()
|
|
713
|
+
handle = self._require_handle()
|
|
714
|
+
raw = _encode_text(reading, "reading")
|
|
715
|
+
out = ctypes.c_char_p()
|
|
716
|
+
status = lib.cppp2_tone_convert(handle, raw, int(style if style is not None else Style.DEFAULT),
|
|
717
|
+
ctypes.byref(out))
|
|
718
|
+
_ffi.raise_for_status(status, "cppp2_tone_convert")
|
|
719
|
+
try:
|
|
720
|
+
return _ffi.decode(out)
|
|
721
|
+
finally:
|
|
722
|
+
lib.cppp2_string_free(out)
|
|
723
|
+
def readings(
|
|
724
|
+
self,
|
|
725
|
+
hanzi: str,
|
|
726
|
+
*,
|
|
727
|
+
style: Union[Style, int] = Style.DEFAULT,
|
|
728
|
+
v_to_u: bool = False,
|
|
729
|
+
mark_neutral_as_5: bool = False,
|
|
730
|
+
) -> List[str]:
|
|
731
|
+
"""Every reading of ``hanzi``, converted with ``style`` and with the two switches.
|
|
732
|
+
|
|
733
|
+
The order is the library's (``core/Types.h`` -> ``Segment::candidates``): the dialect's own
|
|
734
|
+
dictionary order, de-duplicated by the CONVERTED string keeping the first occurrence. The
|
|
735
|
+
switches are therefore visible in this call's *count* as well as its spellings: ``的`` in
|
|
736
|
+
TONE3 has no reading ending in a tone digit, and ``mark_neutral_as_5=True`` turns its first
|
|
737
|
+
reading from ``de`` into ``de5`` (the tone its table row does not spell out), while ``女``
|
|
738
|
+
answers ``['nv3', 'nü']`` instead of ``['nv3', 'nv']`` with ``v_to_u=True``.
|
|
739
|
+
|
|
740
|
+
``v_to_u`` / ``mark_neutral_as_5`` mean what they mean on :meth:`query` (they are masked by
|
|
741
|
+
the dialect's capability flags, so Cantonese ignores them). They are accepted here because
|
|
742
|
+
the C entry point honours them -- an older shell that did not pass them silently answered a
|
|
743
|
+
different question than the C ABI did for the same arguments.
|
|
744
|
+
|
|
745
|
+
The library reads one character (the first one); passing a longer string is not a
|
|
746
|
+
sentence-level call.
|
|
747
|
+
"""
|
|
748
|
+
lib = _library()
|
|
749
|
+
handle = self._require_handle()
|
|
750
|
+
raw = _encode_text(hanzi, "hanzi")
|
|
751
|
+
opts = _checked_opts(
|
|
752
|
+
self._handle,
|
|
753
|
+
self._dialect_id,
|
|
754
|
+
style,
|
|
755
|
+
v_to_u=v_to_u,
|
|
756
|
+
mark_neutral_as_5=mark_neutral_as_5,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
out_list = ctypes.POINTER(ctypes.c_char_p)()
|
|
760
|
+
out_count = ctypes.c_size_t()
|
|
761
|
+
status = lib.cppp2_readings(
|
|
762
|
+
handle, raw, ctypes.byref(opts), ctypes.byref(out_list), ctypes.byref(out_count)
|
|
763
|
+
)
|
|
764
|
+
_ffi.raise_for_status(status, "cppp2_readings")
|
|
765
|
+
try:
|
|
766
|
+
return [_ffi.decode(out_list[i]) for i in range(int(out_count.value))]
|
|
767
|
+
finally:
|
|
768
|
+
lib.cppp2_string_list_free(out_list, out_count)
|
|
769
|
+
|
|
770
|
+
def to_simplified(self, text: str) -> str:
|
|
771
|
+
"""Traditional to simplified, for the FIRST character of ``text``."""
|
|
772
|
+
lib = _library()
|
|
773
|
+
handle = self._require_handle()
|
|
774
|
+
raw = _encode_text(text, "text")
|
|
775
|
+
out = ctypes.c_char_p()
|
|
776
|
+
status = lib.cppp2_to_simplified(handle, raw, ctypes.byref(out))
|
|
777
|
+
_ffi.raise_for_status(status, "cppp2_to_simplified")
|
|
778
|
+
try:
|
|
779
|
+
return _ffi.decode(out)
|
|
780
|
+
finally:
|
|
781
|
+
lib.cppp2_string_free(out)
|
|
782
|
+
|
|
783
|
+
def is_polyphonic(self, hanzi: str) -> bool:
|
|
784
|
+
"""True when the FIRST character of ``hanzi`` starts a phrase in this dialect's corpus.
|
|
785
|
+
|
|
786
|
+
Not "has several readings of its own": a single-reading character that opens a phrase
|
|
787
|
+
answers True. The authority is the ``Engine::isPolyphonic`` comment in the C++ header.
|
|
788
|
+
"""
|
|
789
|
+
lib = _library()
|
|
790
|
+
handle = self._require_handle()
|
|
791
|
+
raw = _encode_text(hanzi, "hanzi")
|
|
792
|
+
out = ctypes.c_int32()
|
|
793
|
+
status = lib.cppp2_is_polyphonic(handle, raw, ctypes.byref(out))
|
|
794
|
+
_ffi.raise_for_status(status, "cppp2_is_polyphonic")
|
|
795
|
+
return bool(out.value)
|
|
796
|
+
|
|
797
|
+
# -- dictionaries -------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
def load_user_dict(
|
|
800
|
+
self, path: Union[str, os.PathLike], clear_existing: bool = True
|
|
801
|
+
) -> None:
|
|
802
|
+
"""Load a user dictionary (``word:reading[,reading...]``) into this engine.
|
|
803
|
+
|
|
804
|
+
The path is pre-checked for entries and for keys longer than
|
|
805
|
+
:data:`MAX_USER_DICT_KEY`, because the library would otherwise load a zero-entry file
|
|
806
|
+
successfully and truncate an over-long key.
|
|
807
|
+
|
|
808
|
+
This MUTATES the engine: it must not run concurrently with any read (``query``,
|
|
809
|
+
``readings``, ``to_simplified``, ``is_polyphonic``) on the same engine -- that is a
|
|
810
|
+
data race, not thread-safe, and the C++ contract says so as well.
|
|
811
|
+
"""
|
|
812
|
+
self._load_dict("cppp2_load_user_dict", path, clear_existing)
|
|
813
|
+
|
|
814
|
+
def load_traditional_dict(
|
|
815
|
+
self, path: Union[str, os.PathLike], clear_existing: bool = True
|
|
816
|
+
) -> None:
|
|
817
|
+
"""Load a traditional-to-simplified table into this engine.
|
|
818
|
+
|
|
819
|
+
This MUTATES the engine: it must not run concurrently with any read on the same
|
|
820
|
+
engine (a data race, not thread-safe), and the same path pre-check as
|
|
821
|
+
:meth:`load_user_dict` applies.
|
|
822
|
+
"""
|
|
823
|
+
self._load_dict("cppp2_load_traditional_dict", path, clear_existing)
|
|
824
|
+
|
|
825
|
+
def _load_dict(self, entry_point: str, path: Union[str, os.PathLike], clear: bool) -> None:
|
|
826
|
+
lib = _library()
|
|
827
|
+
handle = self._require_handle()
|
|
828
|
+
target = Path(os.fspath(path))
|
|
829
|
+
if not target.is_file():
|
|
830
|
+
raise CppPinyinError(f"dictionary file does not exist: {target}")
|
|
831
|
+
_check_dict_file(target)
|
|
832
|
+
call = getattr(lib, entry_point)
|
|
833
|
+
status = call(handle, _encode_path(target, "path"), 1 if clear else 0)
|
|
834
|
+
_ffi.raise_for_status(status, entry_point, str(target))
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def dialect_ids() -> List[str]:
|
|
838
|
+
"""The dialects the library has registered, in registry order.
|
|
839
|
+
|
|
840
|
+
Read from the library rather than hardcoded, so a shell cannot drift from the engine set.
|
|
841
|
+
The registry argument is NULL: this shell has no C++ host to hand it one, and NULL is the
|
|
842
|
+
header's own way of saying "the library's built-ins".
|
|
843
|
+
"""
|
|
844
|
+
lib = _library()
|
|
845
|
+
out_list = ctypes.POINTER(ctypes.c_char_p)()
|
|
846
|
+
out_count = ctypes.c_size_t()
|
|
847
|
+
status = lib.cppp2_dialect_ids(None, ctypes.byref(out_list), ctypes.byref(out_count))
|
|
848
|
+
_ffi.raise_for_status(status, "cppp2_dialect_ids")
|
|
849
|
+
try:
|
|
850
|
+
return [_ffi.decode(out_list[i]) for i in range(int(out_count.value))]
|
|
851
|
+
finally:
|
|
852
|
+
lib.cppp2_string_list_free(out_list, out_count)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def version() -> str:
|
|
856
|
+
"""The library's version, as reported by ``cppp2_version()``.
|
|
857
|
+
|
|
858
|
+
Must equal :data:`__version__`: both come from the single source in ``CMakeLists.txt``.
|
|
859
|
+
"""
|
|
860
|
+
return _ffi.decode(_library().cppp2_version())
|