Skip to content

Commit 001f58a

Browse files
authored
Merge pull request #1212 from PyThaiNLP/copilot/check-zip-safe-status
Make package zip-safe by migrating to importlib.resources
2 parents 7ff7488 + c2526e3 commit 001f58a

8 files changed

Lines changed: 203 additions & 64 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ issues = "https://github.com/PyThaiNLP/pythainlp/issues"
246246
thainlp = "pythainlp.__main__:main"
247247

248248
[tool.setuptools]
249-
zip-safe = false
250249
include-package-data = true
251250

252251
[tool.setuptools.packages.find]

pythainlp/corpus/core.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import os
1111
import re
1212
import sys
13+
from importlib.resources import files
1314

1415
from pythainlp import __version__
1516
from pythainlp.corpus import corpus_db_path, corpus_db_url, corpus_path
@@ -153,10 +154,10 @@ def get_corpus(filename: str, comments: bool = True) -> frozenset:
153154
# ...})
154155
155156
"""
156-
path = path_pythainlp_corpus(filename)
157-
lines = []
158-
with open(path, encoding="utf-8-sig") as fh:
159-
lines = fh.read().splitlines()
157+
corpus_files = files("pythainlp.corpus")
158+
corpus_file = corpus_files.joinpath(filename)
159+
text = corpus_file.read_text(encoding="utf-8-sig")
160+
lines = text.splitlines()
160161

161162
if not comments:
162163
# if the line has a '#' character, take only text before the first '#'
@@ -192,10 +193,10 @@ def get_corpus_as_is(filename: str) -> list:
192193
# output:
193194
# ['แต่', 'ไม่']
194195
"""
195-
path = path_pythainlp_corpus(filename)
196-
lines = []
197-
with open(path, encoding="utf-8-sig") as fh:
198-
lines = fh.read().splitlines()
196+
corpus_files = files("pythainlp.corpus")
197+
corpus_file = corpus_files.joinpath(filename)
198+
text = corpus_file.read_text(encoding="utf-8-sig")
199+
lines = text.splitlines()
199200

200201
return lines
201202

@@ -211,9 +212,10 @@ def get_corpus_default_db(name: str, version: str = "") -> str | None:
211212
If you want to edit default_db.json, \
212213
you can edit pythainlp/corpus/default_db.json
213214
"""
214-
default_db_path = path_pythainlp_corpus("default_db.json")
215-
with open(default_db_path, encoding="utf-8-sig") as fh:
216-
corpus_db = json.load(fh)
215+
corpus_files = files("pythainlp.corpus")
216+
default_db_file = corpus_files.joinpath("default_db.json")
217+
text = default_db_file.read_text(encoding="utf-8-sig")
218+
corpus_db = json.loads(text)
217219

218220
if name in corpus_db:
219221
if version in corpus_db[name]["versions"]:

pythainlp/corpus/th_en_translit.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
]
1818

1919
from collections import defaultdict
20-
21-
from pythainlp.corpus import path_pythainlp_corpus
20+
from importlib.resources import files
2221

2322
_FILE_NAME = "th_en_transliteration_v1.4.tsv"
2423
TRANSLITERATE_EN = "en"
@@ -30,8 +29,10 @@ def get_transliteration_dict() -> defaultdict:
3029
3130
The returned dict is in dict[str, dict[List[str], List[Optional[bool]]]] format.
3231
"""
33-
path = path_pythainlp_corpus(_FILE_NAME)
34-
if not path:
32+
corpus_files = files("pythainlp.corpus")
33+
corpus_file = corpus_files.joinpath(_FILE_NAME)
34+
35+
if not corpus_file.is_file():
3536
raise FileNotFoundError(
3637
f"Unable to load transliteration dictionary. "
3738
f"{_FILE_NAME} is not found under pythainlp/corpus."
@@ -42,24 +43,25 @@ def get_transliteration_dict() -> defaultdict:
4243
lambda: {TRANSLITERATE_EN: [], TRANSLITERATE_FOLLOW_RTSG: []}
4344
)
4445
try:
45-
with open(path, encoding="utf-8") as f:
46-
# assume that the first row contains column names, so skip it.
47-
for line in f.readlines()[1:]:
48-
stripped = line.strip()
49-
if stripped:
50-
th, *en_checked = stripped.split("\t")
51-
# replace in-between whitespace to prevent mismatched results from different tokenizers.
52-
# e.g. "บอยแบนด์"
53-
# route 1: "บอยแบนด์" -> ["บอย", "แบนด์"] -> ["boy", "band"] -> "boyband"
54-
# route 2: "บอยแบนด์" -> [""บอยแบนด์""] -> ["boy band"] -> "boy band"
55-
en_translit = en_checked[0].replace(" ", "")
56-
trans_dict[th][TRANSLITERATE_EN].append(en_translit)
57-
en_follow_rtgs = (
58-
bool(en_checked[1]) if len(en_checked) == 2 else None
59-
)
60-
trans_dict[th][TRANSLITERATE_FOLLOW_RTSG].append(
61-
en_follow_rtgs
62-
)
46+
text = corpus_file.read_text(encoding="utf-8")
47+
lines = text.splitlines()
48+
# assume that the first row contains column names, so skip it.
49+
for line in lines[1:]:
50+
stripped = line.strip()
51+
if stripped:
52+
th, *en_checked = stripped.split("\t")
53+
# replace in-between whitespace to prevent mismatched results from different tokenizers.
54+
# e.g. "บอยแบนด์"
55+
# route 1: "บอยแบนด์" -> ["บอย", "แบนด์"] -> ["boy", "band"] -> "boyband"
56+
# route 2: "บอยแบนด์" -> [""บอยแบนด์""] -> ["boy band"] -> "boy band"
57+
en_translit = en_checked[0].replace(" ", "")
58+
trans_dict[th][TRANSLITERATE_EN].append(en_translit)
59+
en_follow_rtgs = (
60+
bool(en_checked[1]) if len(en_checked) == 2 else None
61+
)
62+
trans_dict[th][TRANSLITERATE_FOLLOW_RTSG].append(
63+
en_follow_rtgs
64+
)
6365

6466
except ValueError as exc:
6567
raise ValueError(

pythainlp/spell/symspellpy.py

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,64 @@
1313

1414
from __future__ import annotations
1515

16+
import threading
17+
from importlib.resources import as_file, files
18+
1619
try:
1720
from symspellpy import SymSpell, Verbosity
1821
except ImportError:
1922
raise ImportError(
2023
"Import Error; Install symspellpy by pip install symspellpy"
2124
)
2225

23-
from pythainlp.corpus import get_corpus_path, path_pythainlp_corpus
26+
from pythainlp.corpus import get_corpus_path
2427

2528
_UNIGRAM_FILENAME = "tnc_freq.txt"
2629
_BIGRAM_CORPUS_NAME = "tnc_bigram_word_freqs"
2730

28-
sym_spell = SymSpell()
29-
sym_spell.load_dictionary(
30-
path_pythainlp_corpus(_UNIGRAM_FILENAME),
31-
0,
32-
1,
33-
separator="\t",
34-
encoding="utf-8-sig",
35-
)
36-
sym_spell.load_bigram_dictionary(
37-
get_corpus_path(_BIGRAM_CORPUS_NAME),
38-
0,
39-
2,
40-
separator="\t",
41-
encoding="utf-8-sig",
42-
)
31+
_sym_spell = None
32+
_unigram_file_ctx = None # File context manager kept alive for program lifetime
33+
_load_lock = threading.Lock() # Thread safety for lazy loading
34+
35+
36+
def _get_sym_spell():
37+
"""Lazy load the symspell instance.
38+
39+
This function uses a lock to ensure thread-safe initialization.
40+
The context manager is kept alive for the lifetime of the program
41+
to prevent cleanup of temporary files while SymSpell is in use.
42+
"""
43+
global _sym_spell, _unigram_file_ctx
44+
if _sym_spell is None:
45+
with _load_lock:
46+
# Double-check pattern to avoid race conditions
47+
if _sym_spell is None:
48+
_sym_spell = SymSpell()
49+
# Load unigram dictionary from bundled corpus
50+
corpus_files = files("pythainlp.corpus")
51+
unigram_file = corpus_files.joinpath(_UNIGRAM_FILENAME)
52+
_unigram_file_ctx = as_file(unigram_file)
53+
unigram_path = _unigram_file_ctx.__enter__()
54+
_sym_spell.load_dictionary(
55+
str(unigram_path),
56+
0,
57+
1,
58+
separator="\t",
59+
encoding="utf-8-sig",
60+
)
61+
# Load bigram dictionary from downloaded corpus
62+
_sym_spell.load_bigram_dictionary(
63+
get_corpus_path(_BIGRAM_CORPUS_NAME),
64+
0,
65+
2,
66+
separator="\t",
67+
encoding="utf-8-sig",
68+
)
69+
return _sym_spell
4370

4471

4572
def spell(text: str, max_edit_distance: int = 2) -> list[str]:
73+
sym_spell = _get_sym_spell()
4674
return [
4775
str(i).split(",", maxsplit=1)[0]
4876
for i in list(
@@ -60,6 +88,7 @@ def correct(text: str, max_edit_distance: int = 1) -> str:
6088
def spell_sent(
6189
list_words: list[str], max_edit_distance: int = 2
6290
) -> list[list[str]]:
91+
sym_spell = _get_sym_spell()
6392
temp = [
6493
str(i).split(",", maxsplit=1)[0].split(" ")
6594
for i in list(

pythainlp/tag/crfchunk.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
# SPDX-License-Identifier: Apache-2.0
44
from __future__ import annotations
55

6+
from importlib.resources import as_file, files
7+
68
from pycrfsuite import Tagger as CRFTagger
79

8-
from pythainlp.corpus import path_pythainlp_corpus, thai_stopwords
10+
from pythainlp.corpus import thai_stopwords
911

1012

1113
def _is_stopword(word: str) -> bool: # check Thai stopword
@@ -55,16 +57,60 @@ def extract_features(doc):
5557

5658

5759
class CRFchunk:
60+
"""CRF-based chunker for Thai text.
61+
62+
This class can be used as a context manager to ensure proper cleanup
63+
of resources. Example:
64+
65+
with CRFchunk() as chunker:
66+
result = chunker.parse(tokens)
67+
68+
Alternatively, the object will attempt to clean up resources when
69+
garbage collected, though this is not guaranteed.
70+
"""
71+
5872
def __init__(self, corpus: str = "orchidpp"):
5973
self.corpus = corpus
74+
self._model_file_ctx = None
6075
self.load_model(self.corpus)
6176

6277
def load_model(self, corpus: str):
6378
self.tagger = CRFTagger()
6479
if corpus == "orchidpp":
65-
self.path = path_pythainlp_corpus("crfchunk_orchidpp.model")
66-
self.tagger.open(self.path)
80+
corpus_files = files("pythainlp.corpus")
81+
model_file = corpus_files.joinpath("crfchunk_orchidpp.model")
82+
self._model_file_ctx = as_file(model_file)
83+
model_path = self._model_file_ctx.__enter__()
84+
self.tagger.open(str(model_path))
6785

6886
def parse(self, token_pos: list[tuple[str, str]]) -> list[str]:
6987
self.xseq = extract_features(token_pos)
7088
return self.tagger.tag(self.xseq)
89+
90+
def __enter__(self):
91+
"""Context manager entry."""
92+
return self
93+
94+
def __exit__(self, exc_type, exc_val, exc_tb):
95+
"""Context manager exit - clean up resources."""
96+
if self._model_file_ctx is not None:
97+
try:
98+
self._model_file_ctx.__exit__(exc_type, exc_val, exc_tb)
99+
self._model_file_ctx = None
100+
except Exception: # noqa: S110
101+
pass
102+
return False
103+
104+
def __del__(self):
105+
"""Clean up the context manager when object is destroyed.
106+
107+
Note: __del__ is not guaranteed to be called and should not be
108+
relied upon for critical cleanup. Use the context manager protocol
109+
(with statement) for reliable resource management.
110+
"""
111+
if self._model_file_ctx is not None:
112+
try:
113+
self._model_file_ctx.__exit__(None, None, None)
114+
except Exception: # noqa: S110
115+
# Silently ignore cleanup errors during garbage collection
116+
pass

pythainlp/tokenize/han_solo.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from __future__ import annotations
1010

11-
from pythainlp.corpus import path_pythainlp_corpus
11+
import threading
12+
from importlib.resources import as_file, files
1213

1314
try:
1415
import pycrfsuite
@@ -17,8 +18,30 @@
1718
"ImportError; Install pycrfsuite by pip install python-crfsuite"
1819
)
1920

20-
tagger = pycrfsuite.Tagger()
21-
tagger.open(path_pythainlp_corpus("han_solo.crfsuite"))
21+
_tagger = None
22+
_model_file_ctx = None # File context manager kept alive for program lifetime
23+
_load_lock = threading.Lock() # Thread safety for lazy loading
24+
25+
26+
def _get_tagger():
27+
"""Lazy load the tagger model.
28+
29+
This function uses a lock to ensure thread-safe initialization.
30+
The context manager is kept alive for the lifetime of the program
31+
to prevent cleanup of temporary files while the tagger is in use.
32+
"""
33+
global _tagger, _model_file_ctx
34+
if _tagger is None:
35+
with _load_lock:
36+
# Double-check pattern to avoid race conditions
37+
if _tagger is None:
38+
_tagger = pycrfsuite.Tagger()
39+
corpus_files = files("pythainlp.corpus")
40+
model_file = corpus_files.joinpath("han_solo.crfsuite")
41+
_model_file_ctx = as_file(model_file)
42+
model_path = _model_file_ctx.__enter__()
43+
_tagger.open(str(model_path))
44+
return _tagger
2245

2346

2447
class Featurizer:
@@ -119,6 +142,7 @@ def featurize(
119142

120143

121144
def segment(text: str) -> list[str]:
145+
tagger = _get_tagger()
122146
x = _to_feature.featurize(text)["X"]
123147
y_pred = tagger.tag(x)
124148
list_cut = []

pythainlp/tokenize/nlpo3.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,41 @@
33
# SPDX-License-Identifier: Apache-2.0
44
from __future__ import annotations
55

6+
import threading
7+
from importlib.resources import as_file, files
68
from sys import stderr
79

810
from nlpo3 import load_dict as nlpo3_load_dict
911
from nlpo3 import segment as nlpo3_segment
1012

11-
from pythainlp.corpus import path_pythainlp_corpus
1213
from pythainlp.corpus.common import _THAI_WORDS_FILENAME
1314

1415
_NLPO3_DEFAULT_DICT_NAME = "_73bcj049dzbu9t49b4va170k" # supposed to be unique
15-
_NLPO3_DEFAULT_DICT = nlpo3_load_dict(
16-
path_pythainlp_corpus(_THAI_WORDS_FILENAME), _NLPO3_DEFAULT_DICT_NAME
17-
) # preload default dict, so it can be accessible by _NLPO3_DEFAULT_DICT_NAME
16+
_NLPO3_DEFAULT_DICT = None # Will be lazily loaded
17+
_dict_file_ctx = None # File context manager kept alive for program lifetime
18+
_load_lock = threading.Lock() # Thread safety for lazy loading
19+
20+
21+
def _ensure_default_dict_loaded():
22+
"""Ensure the default dictionary is loaded.
23+
24+
This function uses a lock to ensure thread-safe initialization.
25+
The context manager is kept alive for the lifetime of the program
26+
to prevent cleanup of temporary files while the dictionary is in use.
27+
"""
28+
global _NLPO3_DEFAULT_DICT, _dict_file_ctx
29+
if _NLPO3_DEFAULT_DICT is None:
30+
with _load_lock:
31+
# Double-check pattern to avoid race conditions
32+
if _NLPO3_DEFAULT_DICT is None:
33+
corpus_files = files("pythainlp.corpus")
34+
dict_file = corpus_files.joinpath(_THAI_WORDS_FILENAME)
35+
_dict_file_ctx = as_file(dict_file)
36+
dict_path = _dict_file_ctx.__enter__()
37+
_NLPO3_DEFAULT_DICT = nlpo3_load_dict(
38+
str(dict_path), _NLPO3_DEFAULT_DICT_NAME
39+
)
40+
return _NLPO3_DEFAULT_DICT
1841

1942

2043
def load_dict(file_path: str, dict_name: str) -> bool:
@@ -64,6 +87,10 @@ def segment(
6487
* \
6588
https://github.com/PyThaiNLP/nlpo3
6689
"""
90+
# Ensure default dict is loaded if it's being used
91+
if custom_dict == _NLPO3_DEFAULT_DICT_NAME:
92+
_ensure_default_dict_loaded()
93+
6794
return nlpo3_segment(
6895
text=text,
6996
dict_name=custom_dict,

0 commit comments

Comments
 (0)