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.
@@ -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())