Skip to content

Commit f3bedd7

Browse files
Copilotbact
andcommitted
Refine read-only mode: only block implicit background writes, allow explicit user saves
Co-authored-by: bact <128572+bact@users.noreply.github.com>
1 parent 937ef54 commit f3bedd7

10 files changed

Lines changed: 87 additions & 90 deletions

File tree

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ See details in
9393
| `PYTHAINLP_DATA` | Path to the data directory (default: `~/pythainlp-data`). | Current |
9494
| `PYTHAINLP_DATA_DIR` | Legacy alias for `PYTHAINLP_DATA`. Emits a `DeprecationWarning`. Setting both raises `ValueError`. | Deprecated; use `PYTHAINLP_DATA` |
9595
| `PYTHAINLP_OFFLINE` | Set to `1` to disable automatic corpus downloads. Explicit `download()` calls still work. | Current |
96-
| `PYTHAINLP_READ_ONLY` | Set to `1` to enable read-only mode, which prevents all write operations (downloads and catalog updates). | Current |
96+
| `PYTHAINLP_READ_ONLY` | Set to `1` to enable read-only mode, which prevents implicit background writes to PyThaiNLP's internal data directory (corpus downloads, catalog updates, directory creation). Explicit user-initiated saves to user-specified paths are unaffected. | Current |
9797
| `PYTHAINLP_READ_MODE` | Legacy alias for `PYTHAINLP_READ_ONLY`. Emits a `DeprecationWarning`. Setting both raises `ValueError`. | Deprecated; use `PYTHAINLP_READ_ONLY` |
9898

9999
### Offline mode
@@ -111,8 +111,15 @@ print(pythainlp.is_offline_mode()) # True if PYTHAINLP_OFFLINE=1
111111

112112
### Read-only mode
113113

114-
Set `PYTHAINLP_READ_ONLY=1` to disable **all** write operations,
115-
including both explicit and automatic corpus downloads as well as catalog updates.
114+
Set `PYTHAINLP_READ_ONLY=1` to prevent implicit background writes to PyThaiNLP's
115+
internal data directory. This blocks corpus downloads, catalog updates, and
116+
automatic data directory creation — writes that happen as side effects the user
117+
may not be aware of.
118+
119+
Operations where the user explicitly specifies an output path are unaffected
120+
(e.g., `model.save("path")`, `tagger.train(..., save_loc="path")`,
121+
`thainlp misspell --output myfile.txt`).
122+
116123
Use `pythainlp.is_read_only_mode()` to check the current state programmatically.
117124

118125
```python

README_TH.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ PyThaiNLP ดาวน์โหลดข้อมูล (ดูแค็ตต
124124
| `PYTHAINLP_DATA` | พาธของไดเรกทอรีข้อมูล (ค่าเริ่มต้น: `~/pythainlp-data`) | ปัจจุบัน |
125125
| `PYTHAINLP_DATA_DIR` | ชื่อเดิมของ `PYTHAINLP_DATA` แสดง `DeprecationWarning` และหากตั้งค่าทั้งสองพร้อมกันจะเกิด `ValueError` | เลิกใช้แล้ว; ใช้ `PYTHAINLP_DATA` แทน |
126126
| `PYTHAINLP_OFFLINE` | ตั้งเป็น `1` เพื่อปิดการดาวน์โหลด corpus อัตโนมัติ การเรียก `download()` โดยตรงยังคงใช้งานได้ | ปัจจุบัน |
127-
| `PYTHAINLP_READ_ONLY` | ตั้งเป็น `1` เพื่อเปิดโหมดอ่านอย่างเดียว ซึ่งป้องกันการเขียนทั้งหมด (ดาวน์โหลดและอัปเดตแค็ตตาล็อก) | ปัจจุบัน |
127+
| `PYTHAINLP_READ_ONLY` | ตั้งเป็น `1` เพื่อเปิดโหมดอ่านอย่างเดียว ป้องกันการเขียนพื้นหลังที่ผู้ใช้อาจไม่ทราบ (ดาวน์โหลด corpus, อัปเดตแค็ตตาล็อก, สร้างไดเรกทอรี) การบันทึกไฟล์ที่ผู้ใช้ระบุเองไม่ได้รับผลกระทบ | ปัจจุบัน |
128128
| `PYTHAINLP_READ_MODE` | ชื่อเดิมของ `PYTHAINLP_READ_ONLY` แสดง `DeprecationWarning` และหากตั้งค่าทั้งสองพร้อมกันจะเกิด `ValueError` | เลิกใช้แล้ว; ใช้ `PYTHAINLP_READ_ONLY` แทน |
129129

130130
### โหมดออฟไลน์
@@ -142,8 +142,14 @@ print(pythainlp.is_offline_mode()) # True ถ้า PYTHAINLP_OFFLINE=1
142142

143143
### โหมดอ่านอย่างเดียว
144144

145-
ตั้งค่า `PYTHAINLP_READ_ONLY=1` เพื่อปิดการเขียน**ทั้งหมด**
146-
รวมถึงการดาวน์โหลด corpus ทั้งแบบอัตโนมัติและแบบตั้งใจ รวมถึงการอัปเดตแค็ตตาล็อก
145+
ตั้งค่า `PYTHAINLP_READ_ONLY=1` เพื่อป้องกันการเขียนพื้นหลังที่เกิดขึ้นโดยอัตโนมัติ
146+
ในไดเรกทอรีข้อมูลภายในของ PyThaiNLP ได้แก่ การดาวน์โหลด corpus, การอัปเดตแค็ตตาล็อก
147+
และการสร้างไดเรกทอรีข้อมูล — การเขียนเหล่านี้เกิดขึ้นเป็นผลข้างเคียงที่ผู้ใช้อาจไม่ทราบ
148+
149+
การดำเนินการที่ผู้ใช้ระบุ path เอาไว้อย่างชัดเจนจะไม่ได้รับผลกระทบ
150+
เช่น `model.save("path")`, `tagger.train(..., save_loc="path")`,
151+
`thainlp misspell --output myfile.txt`
152+
147153
ใช้ `pythainlp.is_read_only_mode()` เพื่อตรวจสอบสถานะปัจจุบันในโค้ด
148154

149155
```python

docs/notes/installation.rst

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,32 @@ Runtime configurations
174174
.. envvar:: PYTHAINLP_READ_ONLY
175175

176176
When set to a truthy value (``1``, ``true``, ``yes``, ``on``), PyThaiNLP operates in
177-
*read-only mode*: all write operations are disabled, including corpus downloads and
178-
corpus catalog updates.
177+
*read-only mode*: implicit background writes to PyThaiNLP's internal data directory
178+
are disabled.
179179

180-
In read-only mode:
180+
**What read-only mode blocks** (implicit writes the user may not be aware of):
181181

182-
- :func:`pythainlp.corpus.download` refuses to download any corpus.
183-
- :func:`pythainlp.corpus.remove` refuses to remove any corpus.
182+
- Creating the PyThaiNLP data directory (``~/pythainlp-data`` or
183+
as configured by :envvar:`PYTHAINLP_DATA`).
184+
- :func:`pythainlp.corpus.download` — corpus file downloads and catalog
185+
(``db.json``) updates.
186+
- :func:`pythainlp.corpus.remove` — corpus file and catalog deletions.
187+
188+
**What read-only mode does NOT block** (explicit user-initiated writes):
189+
190+
- Saving trained models or vocabularies to a user-specified path
191+
(e.g., ``model.save("my_model.json")``, ``tagger.train(..., save_loc="...")``,
192+
``tokenizer.save_vocabulary("my_dir/")``) — the user explicitly provided the
193+
destination path.
194+
- CLI output files written to a user-specified location
195+
(e.g., ``thainlp benchmark --save-details``,
196+
``thainlp misspell --output myfile.txt``).
184197

185198
Use :func:`pythainlp.is_read_only_mode` to check the current state programmatically.
186199

187200
.. note::
188-
For disabling only *automatic* downloads while still allowing explicit downloads,
189-
use :envvar:`PYTHAINLP_OFFLINE` instead.
201+
To disable only *automatic* background downloads while keeping explicit
202+
``download()`` calls working, use :envvar:`PYTHAINLP_OFFLINE` instead.
190203

191204
If both :envvar:`PYTHAINLP_READ_ONLY` and :envvar:`PYTHAINLP_READ_MODE` are set at the
192205
same time, PyThaiNLP raises :exc:`ValueError`.

pythainlp/classify/param_free.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,6 @@ def predict(self, x1: str, k: int = 1) -> str:
9696

9797
def save(self, path: str) -> None:
9898
""":param str path: path to save model"""
99-
from pythainlp.tools.path import is_read_only_mode
100-
101-
if is_read_only_mode():
102-
raise PermissionError(
103-
"PyThaiNLP is in read-only mode. Cannot save model."
104-
)
10599
with open(path, "w", encoding="utf-8") as f:
106100
json.dump(
107101
{

pythainlp/cli/benchmark.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,6 @@ def __init__(self, name: str, argv: Sequence[str]) -> None:
157157
dir_name = os.path.dirname(args.input_file)
158158
file_name = args.input_file.split("/")[-1].split(".")[0]
159159

160-
from pythainlp.tools.path import is_read_only_mode
161-
162-
if is_read_only_mode():
163-
safe_print(
164-
"PyThaiNLP is in read-only mode. "
165-
"Benchmark details cannot be saved."
166-
)
167-
return
168-
169160
res_path = "%s/eval-%s.yml" % (dir_name, file_name)
170161
safe_print("Evaluation result is saved to %s" % res_path)
171162

pythainlp/cli/misspell.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,5 @@ def __init__(self, argv: Sequence[str]) -> None:
7070
base, ext = os.path.splitext(args.file)
7171
args.output = f"{base}-misspelled-r{args.misspell_ratio}-seed{args.seed}{ext}"
7272

73-
from pythainlp.tools.path import is_read_only_mode
74-
75-
if is_read_only_mode():
76-
print(
77-
"PyThaiNLP is in read-only mode. "
78-
f"Cannot write output to {args.output!r}."
79-
)
80-
return
81-
8273
with open(args.output, "w", encoding="utf-8") as f:
8374
f.writelines(misspelled_lines)

pythainlp/tag/_tag_perceptron.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,6 @@ def train(
202202

203203
# save the model
204204
if save_loc is not None:
205-
from pythainlp.tools.path import is_read_only_mode
206-
207-
if is_read_only_mode():
208-
raise PermissionError(
209-
"PyThaiNLP is in read-only mode. Cannot save model."
210-
)
211205
data: dict[str, Union[dict, list]] = {}
212206
data["weights"] = self.model.weights
213207
data["tagdict"] = self.tagdict

pythainlp/tools/path.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
def is_read_only_mode() -> bool:
2727
"""Return whether PyThaiNLP is operating in read-only mode.
2828
29-
Read-only mode prevents any write operations such as corpus downloads
30-
and catalog updates. It is activated by setting the
29+
Read-only mode prevents **implicit background writes** to PyThaiNLP's
30+
internal data directory — writes that happen as side effects the user
31+
may not be aware of. It is activated by setting the
3132
``PYTHAINLP_READ_ONLY`` environment variable to a truthy value
3233
(e.g. ``"1"``).
3334
@@ -36,17 +37,27 @@ def is_read_only_mode() -> bool:
3637
Use ``PYTHAINLP_READ_ONLY`` instead.
3738
Setting both variables at the same time raises :exc:`ValueError`.
3839
39-
When read-only mode is active:
40+
When read-only mode is active, the following implicit writes are blocked:
4041
41-
- :func:`pythainlp.corpus.download` refuses to download any corpus.
42-
- :func:`pythainlp.corpus.remove` refuses to remove any corpus.
42+
- Creating the PyThaiNLP data directory
43+
(``~/pythainlp-data`` or as set by ``PYTHAINLP_DATA``).
44+
- :func:`pythainlp.corpus.download` — corpus downloads and catalog
45+
updates.
46+
- :func:`pythainlp.corpus.remove` — corpus file and catalog deletions.
47+
48+
The following **explicit** user-initiated writes are **not** blocked,
49+
because the user deliberately provided the destination path:
50+
51+
- Saving a trained model to a user-specified path
52+
(e.g. ``model.save("my_model.json")``).
53+
- Training a tagger with an explicit ``save_loc`` argument.
54+
- Saving a tokenizer vocabulary to a user-specified directory.
55+
- CLI output files written to a path the user specified or invoked.
4356
4457
.. note::
45-
Use :func:`~pythainlp.tools.path.is_offline_mode` to disable only
46-
*automatic* downloads triggered by
47-
:func:`~pythainlp.corpus.get_corpus_path`. ``PYTHAINLP_OFFLINE``
48-
still allows explicit :func:`~pythainlp.corpus.download` calls;
49-
``PYTHAINLP_READ_ONLY`` blocks all writes.
58+
Use :func:`~pythainlp.tools.path.is_offline_mode` (``PYTHAINLP_OFFLINE``)
59+
to disable only *automatic* background downloads while still allowing
60+
explicit :func:`~pythainlp.corpus.download` calls.
5061
5162
:return: ``True`` if PyThaiNLP is in read-only mode, ``False`` otherwise.
5263
:rtype: bool

pythainlp/translate/tokenization_small100.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -375,12 +375,6 @@ def __setstate__(self, d: dict) -> None:
375375
def save_vocabulary(
376376
self, save_directory: str, filename_prefix: Optional[str] = None
377377
) -> tuple[str, str]:
378-
from pythainlp.tools.path import is_read_only_mode
379-
380-
if is_read_only_mode():
381-
raise PermissionError(
382-
"PyThaiNLP is in read-only mode. Cannot save vocabulary."
383-
)
384378
save_dir = Path(save_directory)
385379
if not save_dir.is_dir():
386380
raise OSError(f"{save_directory} should be a directory")
@@ -474,11 +468,5 @@ def load_json(path: str) -> Union[dict[str, str], list[str]]:
474468
def save_json(
475469
data: Union[Mapping[str, Union[str, int]], list[str]], path: str
476470
) -> None:
477-
from pythainlp.tools.path import is_read_only_mode
478-
479-
if is_read_only_mode():
480-
raise PermissionError(
481-
"PyThaiNLP is in read-only mode. Cannot save file."
482-
)
483471
with open(path, "w") as f:
484472
json.dump(data, f, indent=2)

tests/core/test_tools.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,11 @@ def test_is_read_only_mode_conflict(self):
250250
self.assertIn("PYTHAINLP_READ_MODE", str(ctx.exception))
251251

252252
def test_get_pythainlp_data_path_no_makedirs_in_read_only(self):
253-
"""Test that get_pythainlp_data_path skips makedirs in read-only mode."""
253+
"""Test that get_pythainlp_data_path skips makedirs in read-only mode.
254+
255+
Directory creation is an implicit side-effect the user may not be
256+
aware of. Read-only mode must suppress it.
257+
"""
254258
with tempfile.TemporaryDirectory() as tmpdir:
255259
new_dir = os.path.join(tmpdir, "new-pythainlp-data")
256260
with patch.dict(
@@ -268,38 +272,36 @@ def test_get_pythainlp_data_path_no_makedirs_in_read_only(self):
268272
"Data directory should not be created in read-only mode",
269273
)
270274

271-
def test_param_free_save_blocked_in_read_only(self):
272-
"""Test that GzipModel.save raises PermissionError in read-only mode."""
275+
def test_explicit_save_allowed_in_read_only(self):
276+
"""Test that user-explicit saves are allowed in read-only mode.
277+
278+
Read-only mode only blocks implicit background writes to PyThaiNLP's
279+
internal data directory. Operations where the user explicitly
280+
specifies an output path must proceed normally.
281+
"""
273282
from pythainlp.classify.param_free import GzipModel
283+
from pythainlp.tag._tag_perceptron import PerceptronTagger
274284

275-
# Bypass __init__ (which needs numpy) — only the guard is under test
276-
model = object.__new__(GzipModel)
277285
with patch.dict(
278286
os.environ,
279287
{"PYTHAINLP_READ_ONLY": "1"},
280288
clear=False,
281289
):
282290
os.environ.pop("PYTHAINLP_READ_MODE", None)
283291
with tempfile.TemporaryDirectory() as tmpdir:
284-
with self.assertRaises(PermissionError):
285-
model.save(os.path.join(tmpdir, "model.json"))
292+
# GzipModel.save — user explicitly provides the path
293+
model = object.__new__(GzipModel)
294+
import numpy as np
286295

287-
def test_perceptron_train_save_blocked_in_read_only(self):
288-
"""Test that PerceptronTagger.train raises PermissionError when saving in read-only mode."""
289-
from pythainlp.tag._tag_perceptron import PerceptronTagger
296+
model.training_data = np.array([("text", "label")])
297+
model.cx2_list = [4]
298+
out = os.path.join(tmpdir, "model.json")
299+
model.save(out)
300+
self.assertTrue(os.path.isfile(out))
290301

291-
tagger = PerceptronTagger()
292-
sentences = [[("กิน", "VV"), ("ข้าว", "NN")]]
293-
with patch.dict(
294-
os.environ,
295-
{"PYTHAINLP_READ_ONLY": "1"},
296-
clear=False,
297-
):
298-
os.environ.pop("PYTHAINLP_READ_MODE", None)
299-
with tempfile.TemporaryDirectory() as tmpdir:
300-
with self.assertRaises(PermissionError):
301-
tagger.train(
302-
sentences,
303-
save_loc=os.path.join(tmpdir, "tagger.json"),
304-
nr_iter=1,
305-
)
302+
# PerceptronTagger.train — user explicitly provides save_loc
303+
tagger = PerceptronTagger()
304+
sentences = [[("กิน", "VV"), ("ข้าว", "NN")]]
305+
tagger_out = os.path.join(tmpdir, "tagger.json")
306+
tagger.train(sentences, save_loc=tagger_out, nr_iter=1)
307+
self.assertTrue(os.path.isfile(tagger_out))

0 commit comments

Comments
 (0)