Skip to content

Commit 69f00fa

Browse files
authored
Merge branch 'dev' into bump-532
2 parents 7c2d51a + f462b9f commit 69f00fa

22 files changed

Lines changed: 589 additions & 247 deletions

AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<https://github.com/PyThaiNLP/pythainlp/blob/dev/tests/README.md>.
77
The document list test categories, their dependency sets,
88
and test naming conventions.
9-
- [ ] Use reStructuredText for docstring (PEP 287), targetting Sphinx.
9+
- [ ] Use reStructuredText for docstring (PEP 287), targeting Sphinx.
1010
- [ ] When possible, follow NLTK established convention of submodule
1111
name (tend to be a verb or a generic noun), function name, and
1212
configuration. Communicate this to the users during code review.
@@ -20,7 +20,7 @@
2020
the repo. Read its usage and information it generates at
2121
<https://github.com/PyThaiNLP/pythainlp/blob/dev/build_tools/analysis/README.md>.
2222
Mind that the analyzer can create false positives,
23-
please refer to Python tyep specification when in doubt.
23+
please refer to Python type specification when in doubt.
2424
- [ ] Complete type annotations for function, method, class, variable, etc.
2525
Maintain near-100% type annotation coverage.
2626
- [ ] Add tests for new functionality or behavior.
@@ -31,6 +31,9 @@
3131
- [ ] Major changes should be logged in the change log at
3232
<https://github.com/PyThaiNLP/pythainlp/blob/dev/CHANGELOG.md>.
3333
Provide issue number or PR number if available.
34+
- [ ] Do not use os.path.join();
35+
always use pythainlp.tools.safe_path_join() instead,
36+
to prevent path traversal vulnerabilities (CWE-22).
3437

3538
## Project contribution guidelines
3639

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/corpus/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@
2222
"get_corpus_db_detail",
2323
"get_corpus_default_db",
2424
"get_corpus_path",
25-
"get_path_folder_corpus",
2625
"get_hf_hub",
27-
"path_pythainlp_corpus",
2826
"provinces",
2927
"remove",
3028
"thai_dict",
@@ -89,9 +87,7 @@ def corpus_db_path() -> str:
8987
get_corpus_default_db,
9088
get_corpus_path,
9189
get_hf_hub,
92-
get_path_folder_corpus,
9390
make_safe_directory_name,
94-
path_pythainlp_corpus,
9591
remove,
9692
)
9793
from pythainlp.corpus.common import (

pythainlp/corpus/core.py

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from pythainlp import __version__
1919
from pythainlp.corpus import corpus_db_path, corpus_db_url, corpus_path
2020
from pythainlp.tools import get_full_data_path
21-
from pythainlp.tools.path import is_offline_mode, is_read_only_mode
21+
from pythainlp.tools.path import (
22+
is_offline_mode,
23+
is_read_only_mode,
24+
safe_path_join,
25+
)
2226

2327
if TYPE_CHECKING:
2428
from http.client import HTTPMessage, HTTPResponse
@@ -103,16 +107,6 @@ def get_corpus_db_detail(name: str, version: str = "") -> dict[str, Any]:
103107
return {}
104108

105109

106-
def path_pythainlp_corpus(filename: str) -> str:
107-
"""Get path pythainlp.corpus data
108-
109-
:param str filename: filename of the corpus to be read
110-
111-
:return: : path of corpus
112-
:rtype: str
113-
"""
114-
return os.path.join(corpus_path(), filename)
115-
116110

117111
@lru_cache(maxsize=None)
118112
def get_corpus(filename: str, comments: bool = True) -> frozenset[str]:
@@ -245,13 +239,15 @@ def get_corpus_default_db(name: str, version: str = "") -> Optional[str]:
245239

246240
if name in corpus_db:
247241
if version in corpus_db[name]["versions"]:
248-
return path_pythainlp_corpus(
249-
corpus_db[name]["versions"][version]["filename"]
242+
return safe_path_join(
243+
corpus_path(),
244+
corpus_db[name]["versions"][version]["filename"],
250245
)
251246
elif not version: # load latest version
252247
version = corpus_db[name]["latest_version"]
253-
return path_pythainlp_corpus(
254-
corpus_db[name]["versions"][version]["filename"]
248+
return safe_path_join(
249+
corpus_path(),
250+
corpus_db[name]["versions"][version]["filename"],
255251
)
256252

257253
return None
@@ -852,17 +848,6 @@ def remove(name: str) -> bool:
852848
return False
853849

854850

855-
def get_path_folder_corpus(name: str, version: str, *path: str) -> str:
856-
corpus_path = get_corpus_path(name, version)
857-
if not corpus_path:
858-
raise FileNotFoundError(
859-
f"corpus-not-found name={name!r} version={version!r}\n"
860-
f" Corpus '{name}' (version {version}) not found.\n"
861-
f" Python: pythainlp.corpus.download('{name}')\n"
862-
f" CLI: thainlp data get {name}"
863-
)
864-
return os.path.join(corpus_path, *path)
865-
866851

867852
def make_safe_directory_name(name: str) -> str:
868853
"""Make safe directory name

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] = [

0 commit comments

Comments
 (0)