Skip to content

Commit f462b9f

Browse files
authored
Merge pull request #1339 from PyThaiNLP/copilot/refactor-inconsistent-naming-conventions
Fix PEP 8 naming violations: add `is_thai_char`/`is_thai`/`count_thai`; create `pythainlp.chunk` with `CRFChunkParser`; enforce private API boundaries
2 parents 2bae5be + e18f7c3 commit f462b9f

16 files changed

Lines changed: 502 additions & 202 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,24 @@ and this project adheres to
1919

2020
## [5.3.2] - 2026-03-20
2121

22-
This release focuses on security issues related to path traversal.
22+
This release focuses on security issues related to path traversal
23+
and renaming functions to conform with PEP 8. Old function names are
24+
still accessible but migration to new names are recommended.
25+
26+
### Added
27+
28+
- `pythainlp.chunk` module: canonical home for chunking/phrase-structure
29+
parsing, following the NLTK `nltk.chunk` naming convention.
30+
31+
### Deprecated
32+
33+
The following names are deprecated and will be removed in 6.0 (#1339):
34+
35+
- `pythainlp.util.isthaichar()`: use `pythainlp.util.is_thai_char()`.
36+
- `pythainlp.util.isthai()`: use `pythainlp.util.is_thai()`.
37+
- `pythainlp.util.countthai()`: use `pythainlp.util.count_thai()`.
38+
- `pythainlp.tag.crfchunk.CRFchunk`: use `pythainlp.chunk.CRFChunkParser`.
39+
- `pythainlp.tag.chunk_parse()`: use `pythainlp.chunk.chunk_parse()`.
2340

2441
### Security
2542

pythainlp/chunk/__init__.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
2+
# SPDX-FileType: SOURCE
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Thai phrase structure (chunking) module.
5+
6+
This module provides chunk parsing for Thai text, following the
7+
NLTK :mod:`nltk.chunk` naming convention.
8+
9+
:Example:
10+
11+
.. code-block:: python
12+
13+
from pythainlp.chunk import chunk_parse, CRFChunkParser
14+
from pythainlp.tag import pos_tag
15+
16+
tokens = ["ผม", "รัก", "คุณ"]
17+
tokens_pos = pos_tag(tokens, engine="perceptron", corpus="orchid")
18+
19+
# Using the convenience function
20+
print(chunk_parse(tokens_pos))
21+
# output: ['B-NP', 'B-VP', 'I-VP']
22+
23+
# Using the class directly
24+
with CRFChunkParser() as parser:
25+
print(parser.parse(tokens_pos))
26+
# output: ['B-NP', 'B-VP', 'I-VP']
27+
"""
28+
from __future__ import annotations
29+
30+
__all__: list[str] = [
31+
"CRFChunkParser",
32+
"chunk_parse",
33+
]
34+
35+
from pythainlp.chunk.crfchunk import CRFChunkParser
36+
37+
38+
def chunk_parse(
39+
sent: list[tuple[str, str]],
40+
engine: str = "crf",
41+
corpus: str = "orchidpp",
42+
) -> list[str]:
43+
"""Parse a Thai sentence into phrase-structure chunks (IOB format).
44+
45+
:param list[tuple[str, str]] sent: list of (word, POS-tag) pairs.
46+
:param str engine: chunking engine; currently only ``"crf"`` is
47+
supported.
48+
:param str corpus: corpus name for the CRF model; currently only
49+
``"orchidpp"`` is supported.
50+
:return: list of IOB chunk labels, one per token.
51+
:rtype: list[str]
52+
53+
:Example:
54+
55+
.. code-block:: python
56+
57+
from pythainlp.chunk import chunk_parse
58+
from pythainlp.tag import pos_tag
59+
60+
tokens = ["ผม", "รัก", "คุณ"]
61+
tokens_pos = pos_tag(tokens, engine="perceptron", corpus="orchid")
62+
63+
print(chunk_parse(tokens_pos))
64+
# output: ['B-NP', 'B-VP', 'I-VP']
65+
"""
66+
_parser = CRFChunkParser(corpus=corpus)
67+
return _parser.parse(sent)

pythainlp/chunk/crfchunk.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
2+
# SPDX-FileType: SOURCE
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""CRF-based Thai phrase structure (chunk) parser."""
5+
from __future__ import annotations
6+
7+
from importlib.resources import as_file, files
8+
from typing import TYPE_CHECKING, Any, Optional, Union
9+
10+
if TYPE_CHECKING:
11+
import types
12+
from contextlib import AbstractContextManager
13+
14+
from pycrfsuite import (
15+
Tagger as CRFTagger, # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-module-attribute]
16+
)
17+
18+
from pythainlp.corpus import thai_stopwords
19+
20+
21+
def _is_stopword(word: str) -> bool:
22+
return word in thai_stopwords()
23+
24+
25+
def _doc2features(
26+
tokens: list[tuple[str, str]], index: int
27+
) -> dict[str, Union[str, bool]]:
28+
"""Extract features for a single token in a POS-tagged sentence.
29+
30+
:param list[tuple[str, str]] tokens: POS-tagged sentence,
31+
a list of (word, POS-tag) pairs.
32+
:param int index: index of the token to extract features for.
33+
:return: feature dictionary for the token.
34+
:rtype: dict[str, Union[str, bool]]
35+
"""
36+
word, pos = tokens[index]
37+
f: dict[str, Union[str, bool]] = {
38+
"word": word,
39+
"word_is_stopword": _is_stopword(word),
40+
"pos": pos,
41+
}
42+
if index > 1:
43+
prevprevword, prevprevpos = tokens[index - 2]
44+
f["prev-prev-word"] = prevprevword
45+
f["prev-prevz-word_is_stopword"] = _is_stopword(prevprevword)
46+
f["prev-prevz-pos"] = prevprevpos
47+
if index > 0:
48+
prevword, prevpos = tokens[index - 1]
49+
f["prev-word"] = prevword
50+
f["prev-word_is_stopword"] = _is_stopword(prevword)
51+
f["prev-pos"] = prevpos
52+
else:
53+
f["BOS"] = True
54+
if index < len(tokens) - 2:
55+
nextnextword, nextnextpos = tokens[index + 2]
56+
f["nextnext-word"] = nextnextword
57+
f["nextnext-word_is_stopword"] = _is_stopword(nextnextword)
58+
f["nextnext-pos"] = nextnextpos
59+
if index < len(tokens) - 1:
60+
nextword, nextpos = tokens[index + 1]
61+
f["next-word"] = nextword
62+
f["next-word_is_stopword"] = _is_stopword(nextword)
63+
f["next-pos"] = nextpos
64+
else:
65+
f["EOS"] = True
66+
67+
return f
68+
69+
70+
def _extract_features(
71+
doc: list[tuple[str, str]],
72+
) -> list[dict[str, Union[str, bool]]]:
73+
return [_doc2features(doc, i) for i in range(len(doc))]
74+
75+
76+
class CRFChunkParser:
77+
"""CRF-based chunk parser for Thai text.
78+
79+
Parses a POS-tagged sentence into phrase-structure chunks
80+
(IOB format), following the NLTK :class:`nltk.chunk.ChunkParserI`
81+
convention.
82+
83+
This class supports the context manager protocol for deterministic
84+
resource cleanup:
85+
86+
.. code-block:: python
87+
88+
from pythainlp.chunk import CRFChunkParser
89+
90+
with CRFChunkParser() as parser:
91+
result = parser.parse(tokens_pos)
92+
93+
:param str corpus: corpus name for the CRF model
94+
(default: ``"orchidpp"``).
95+
"""
96+
97+
corpus: str
98+
_model_file_ctx: Optional[AbstractContextManager[Any]]
99+
tagger: CRFTagger
100+
xseq: list[dict[str, Union[str, bool]]]
101+
102+
def __init__(self, corpus: str = "orchidpp") -> None:
103+
self.corpus = corpus
104+
self._model_file_ctx = None
105+
self.load_model(self.corpus)
106+
107+
def load_model(self, corpus: str) -> None:
108+
"""Load the CRF model for the given corpus.
109+
110+
:param str corpus: corpus name.
111+
"""
112+
from pycrfsuite import (
113+
Tagger as CRFTagger, # noqa: PLC0415 # pyright: ignore[reportAttributeAccessIssue] # pyrefly: ignore[missing-module-attribute]
114+
)
115+
116+
self.tagger = CRFTagger()
117+
if corpus == "orchidpp":
118+
corpus_files = files("pythainlp.corpus")
119+
model_file = corpus_files.joinpath("crfchunk_orchidpp.model")
120+
self._model_file_ctx = as_file(model_file)
121+
model_path = self._model_file_ctx.__enter__()
122+
self.tagger.open(str(model_path))
123+
124+
def parse(self, token_pos: list[tuple[str, str]]) -> list[str]:
125+
"""Parse a POS-tagged sentence into IOB chunk labels.
126+
127+
:param list[tuple[str, str]] token_pos: list of (word, POS-tag)
128+
pairs.
129+
:return: list of IOB chunk labels, one per token.
130+
:rtype: list[str]
131+
"""
132+
self.xseq = _extract_features(token_pos)
133+
return self.tagger.tag(self.xseq) # type: ignore[no-any-return]
134+
135+
def __enter__(self) -> CRFChunkParser:
136+
"""Context manager entry."""
137+
return self
138+
139+
def __exit__(
140+
self,
141+
exc_type: Optional[type[BaseException]],
142+
exc_val: Optional[BaseException],
143+
exc_tb: Optional[types.TracebackType],
144+
) -> None:
145+
"""Context manager exit — clean up resources."""
146+
if self._model_file_ctx is not None:
147+
try:
148+
self._model_file_ctx.__exit__(exc_type, exc_val, exc_tb)
149+
self._model_file_ctx = None
150+
except Exception: # noqa: S110
151+
pass
152+
153+
def __del__(self) -> None:
154+
"""Attempt resource cleanup on garbage collection.
155+
156+
.. note::
157+
:meth:`__del__` is not guaranteed to be called.
158+
Use the context manager protocol for reliable cleanup.
159+
"""
160+
if self._model_file_ctx is not None:
161+
try:
162+
self._model_file_ctx.__exit__(None, None, None)
163+
except Exception: # noqa: S110
164+
pass

pythainlp/spell/pn.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from pythainlp import thai_digits, thai_letters
1919
from pythainlp.corpus import phupha, thai_orst_words
20-
from pythainlp.util import isthaichar
20+
from pythainlp.util import is_thai_char
2121

2222

2323
def _no_filter(word: str) -> bool:
@@ -26,7 +26,7 @@ def _no_filter(word: str) -> bool:
2626

2727
def _is_thai_and_not_num(word: str) -> bool:
2828
for ch in word:
29-
if ch != "." and not isthaichar(ch):
29+
if ch != "." and not is_thai_char(ch):
3030
return False
3131
if ch in thai_digits or ch in digits:
3232
return False

pythainlp/tag/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
66
Tagging each token in a sentence with supplementary information,
77
such as its part-of-speech (POS) tag, and named entity (NE) tag.
8+
9+
.. note::
10+
:func:`chunk_parse` has moved to :mod:`pythainlp.chunk`.
11+
Importing it from :mod:`pythainlp.tag` still works but emits a
12+
:class:`DeprecationWarning` and will be removed in 6.0.
813
"""
914

1015
__all__: list[str] = [

pythainlp/tag/chunk.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,37 @@
11
# SPDX-FileCopyrightText: 2016-2026 PyThaiNLP Project
22
# SPDX-FileType: SOURCE
33
# SPDX-License-Identifier: Apache-2.0
4+
"""Deprecated. Use :func:`pythainlp.chunk.chunk_parse` instead.
5+
6+
.. deprecated:: 5.3.2
7+
:func:`chunk_parse` has moved to :mod:`pythainlp.chunk`.
8+
"""
49
from __future__ import annotations
510

11+
from pythainlp.chunk import chunk_parse as _chunk_parse
12+
from pythainlp.tools import warn_deprecation
13+
614

715
def chunk_parse(
8-
sent: list[tuple[str, str]], engine: str = "crf", corpus: str = "orchidpp"
16+
sent: list[tuple[str, str]],
17+
engine: str = "crf",
18+
corpus: str = "orchidpp",
919
) -> list[str]:
10-
"""This function parses Thai sentence to phrase structure in IOB format.
20+
"""Parse a Thai sentence into phrase-structure chunks (IOB format).
1121
12-
:param list[tuple[str, str]] sent: list [(word, part-of-speech)]
13-
:param str engine: chunk parse engine (now, it has crf only)
14-
:param str corpus: chunk parse corpus (now, it has orchidpp only)
22+
.. deprecated:: 5.3.2
23+
Use :func:`pythainlp.chunk.chunk_parse` instead.
1524
16-
:return: a list of tuples (word, part-of-speech, chunking)
25+
:param list[tuple[str, str]] sent: list of (word, POS-tag) pairs.
26+
:param str engine: chunking engine (default: ``"crf"``).
27+
:param str corpus: corpus name (default: ``"orchidpp"``).
28+
:return: list of IOB chunk labels, one per token.
1729
:rtype: list[str]
18-
19-
:Example:
20-
::
21-
22-
from pythainlp.tag import chunk_parse, pos_tag
23-
24-
tokens = ["ผม", "รัก", "คุณ"]
25-
tokens_pos = pos_tag(tokens, engine="perceptron", corpus="orchid")
26-
27-
print(chunk_parse(tokens_pos))
28-
# output: ['B-NP', 'B-VP', 'I-VP']
2930
"""
30-
from .crfchunk import CRFchunk
31-
32-
_engine = CRFchunk()
33-
return _engine.parse(sent)
31+
warn_deprecation(
32+
"pythainlp.tag.chunk_parse",
33+
"pythainlp.chunk.chunk_parse",
34+
"5.3.2",
35+
"6.0",
36+
)
37+
return _chunk_parse(sent, engine=engine, corpus=corpus)

0 commit comments

Comments
 (0)