PyPtt 2.2.4__tar.gz → 2.2.6__tar.gz
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.
- {pyptt-2.2.4 → pyptt-2.2.6}/PKG-INFO +1 -1
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/PTT.py +33 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/__init__.py +1 -1
- pyptt-2.2.6/PyPtt/_api_set_signature_file.py +86 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/connect_core.py +29 -7
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/lang_en_US.py +1 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/lang_zh_TW.py +1 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/screens.py +199 -51
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt.egg-info/SOURCES.txt +3 -0
- pyptt-2.2.6/tests/test_incremental_parity.py +135 -0
- pyptt-2.2.6/tests/test_set_signature_file.py +32 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/LICENSE +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_lottery.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_api_util.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_uao/__init__.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/api_server.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/check_value.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/command.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/config.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/data_type.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/exceptions.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/i18n.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/lib_util.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/log.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/service.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/README.md +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/pyproject.toml +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/setup.cfg +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/setup.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_bucket.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_change_pw.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_comment.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_del_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_del_post_unit.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_exceptions.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_fast_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_board_info.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_board_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_mail.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_post_list.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_time.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_user.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_get_waterball.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_give_money.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_i18n.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_init.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_logger.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_login_logout.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_lottery.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_mark_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_reply_post.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_search_user.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_service.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_set_board_title.py +0 -0
- {pyptt-2.2.4 → pyptt-2.2.6}/tests/test_vt100_parser.py +0 -0
|
@@ -28,6 +28,7 @@ from . import _api_post
|
|
|
28
28
|
from . import _api_reply_post
|
|
29
29
|
from . import _api_search_user
|
|
30
30
|
from . import _api_set_board_title
|
|
31
|
+
from . import _api_set_signature_file
|
|
31
32
|
from . import check_value
|
|
32
33
|
from . import config
|
|
33
34
|
from . import connect_core
|
|
@@ -806,6 +807,38 @@ class API:
|
|
|
806
807
|
|
|
807
808
|
_api_set_board_title.set_board_title(self, board, new_title)
|
|
808
809
|
|
|
810
|
+
def set_signature_file(self, content: str) -> None:
|
|
811
|
+
|
|
812
|
+
"""
|
|
813
|
+
更新登入帳號的名片檔(PTT 的 plan),對應【個人設定】->【個人檔案】
|
|
814
|
+
->(Q)ueryEdit 編輯名片檔的功能。`get_user()` 回傳的 `signature_file`
|
|
815
|
+
欄位讀的就是這份內容。
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
content (str): 新的名片檔內容,會整份取代原本的內容。
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
None
|
|
822
|
+
|
|
823
|
+
Raises:
|
|
824
|
+
RequireLogin: 需要登入。
|
|
825
|
+
|
|
826
|
+
範例::
|
|
827
|
+
|
|
828
|
+
import PyPtt
|
|
829
|
+
|
|
830
|
+
ptt_bot = PyPtt.API()
|
|
831
|
+
try:
|
|
832
|
+
# .. login ..
|
|
833
|
+
ptt_bot.set_signature_file(content='你好,我是 PyPtt')
|
|
834
|
+
# .. do something ..
|
|
835
|
+
finally:
|
|
836
|
+
ptt_bot.logout()
|
|
837
|
+
|
|
838
|
+
"""
|
|
839
|
+
|
|
840
|
+
_api_set_signature_file.set_signature_file(self, content)
|
|
841
|
+
|
|
809
842
|
def mark_post(self, mark_type: int, board: str, aid: Optional[str] = None, index: int = 0, search_type: int = 0,
|
|
810
843
|
search_condition: Optional[str] = None) -> None:
|
|
811
844
|
"""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from . import _api_util
|
|
2
|
+
from . import check_value
|
|
3
|
+
from . import command
|
|
4
|
+
from . import connect_core
|
|
5
|
+
from . import exceptions
|
|
6
|
+
from . import i18n
|
|
7
|
+
from . import lib_util
|
|
8
|
+
from . import log
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_signature_file(api, content: str) -> None:
|
|
12
|
+
_api_util.one_thread(api)
|
|
13
|
+
|
|
14
|
+
if not api._is_login:
|
|
15
|
+
raise exceptions.RequireLogin(i18n.require_login)
|
|
16
|
+
|
|
17
|
+
check_value.check_type(content, str, 'content')
|
|
18
|
+
|
|
19
|
+
log.logger.info(i18n.set_signature_file)
|
|
20
|
+
|
|
21
|
+
cmd_list = []
|
|
22
|
+
cmd_list.append(command.go_main_menu)
|
|
23
|
+
cmd_list.append('U')
|
|
24
|
+
cmd_list.append(command.enter)
|
|
25
|
+
cmd_list.append('M')
|
|
26
|
+
cmd_list.append(command.enter)
|
|
27
|
+
cmd_list.append('Q')
|
|
28
|
+
cmd_list.append(command.enter)
|
|
29
|
+
cmd = ''.join(cmd_list)
|
|
30
|
+
|
|
31
|
+
target_list = [
|
|
32
|
+
# Existing signature file -> getans asks Delete/Edit/Cancel first.
|
|
33
|
+
# max_match=1: the echoed prompt text can linger on the next
|
|
34
|
+
# screen refresh while waiting for the editor to draw (same
|
|
35
|
+
# quirk documented for '確定要儲存檔案嗎' in _api_post.py); without
|
|
36
|
+
# it this would re-fire 'E' + Enter into the editor buffer.
|
|
37
|
+
connect_core.TargetUnit('名片 (D)刪除', response='E' + command.enter, max_match=1),
|
|
38
|
+
# Empty signature file skips the getans prompt and opens the
|
|
39
|
+
# editor directly.
|
|
40
|
+
connect_core.TargetUnit('編輯文章', break_detect=True),
|
|
41
|
+
]
|
|
42
|
+
index = api.connect_core.send(
|
|
43
|
+
cmd,
|
|
44
|
+
target_list,
|
|
45
|
+
screen_timeout=api.config.screen_long_timeout)
|
|
46
|
+
if index < 0:
|
|
47
|
+
ori_screen = api.connect_core.get_screen_queue()[-1]
|
|
48
|
+
raise exceptions.UnknownError(ori_screen)
|
|
49
|
+
|
|
50
|
+
content = lib_util.uniform_new_line(content)
|
|
51
|
+
|
|
52
|
+
cmd_list = []
|
|
53
|
+
# ponytail: unlike post's blank template, the card-file editor preloads
|
|
54
|
+
# the account's existing signature file, so it has to be cleared line by
|
|
55
|
+
# line before writing the new content. ctrl_y*60 is a "clear up to 60
|
|
56
|
+
# pre-loaded lines" ceiling -- bump it if a real card file ever runs
|
|
57
|
+
# longer than that.
|
|
58
|
+
cmd_list.append(command.ctrl_y * 60)
|
|
59
|
+
cmd_list.append(content)
|
|
60
|
+
cmd_list.append(command.ctrl_x)
|
|
61
|
+
cmd = ''.join(cmd_list)
|
|
62
|
+
|
|
63
|
+
target_list = [
|
|
64
|
+
connect_core.TargetUnit('確定要儲存檔案嗎', response='s' + command.enter, max_match=1),
|
|
65
|
+
connect_core.TargetUnit('名片更新完畢', break_detect=True),
|
|
66
|
+
]
|
|
67
|
+
index = api.connect_core.send(
|
|
68
|
+
cmd,
|
|
69
|
+
target_list,
|
|
70
|
+
screen_timeout=api.config.screen_long_timeout)
|
|
71
|
+
if index < 0:
|
|
72
|
+
ori_screen = api.connect_core.get_screen_queue()[-1]
|
|
73
|
+
raise exceptions.UnknownError(ori_screen)
|
|
74
|
+
|
|
75
|
+
# The leading space in go_main_menu dismisses the "請按任意鍵繼續" banner
|
|
76
|
+
# left by the save; the trailing left-arrows walk back out of the
|
|
77
|
+
# 個人設定/個人檔案 submenus to the main menu.
|
|
78
|
+
target_list = [
|
|
79
|
+
connect_core.TargetUnit('主功能表', break_detect=True),
|
|
80
|
+
]
|
|
81
|
+
api.connect_core.send(
|
|
82
|
+
command.go_main_menu,
|
|
83
|
+
target_list,
|
|
84
|
+
screen_timeout=api.config.screen_long_timeout)
|
|
85
|
+
|
|
86
|
+
log.logger.info(i18n.set_signature_file, '...', i18n.success)
|
|
@@ -160,6 +160,7 @@ class API(object):
|
|
|
160
160
|
self._UseTooManyResources = TargetUnit(screens.Target.use_too_many_resources,
|
|
161
161
|
exceptions_=exceptions.UseTooManyResources())
|
|
162
162
|
self._loop = None
|
|
163
|
+
self._stream_parsers = {}
|
|
163
164
|
self._ssl_context = ssl_init(self.config.verify_ssl)
|
|
164
165
|
|
|
165
166
|
def _get_event_loop(self):
|
|
@@ -257,14 +258,11 @@ class API(object):
|
|
|
257
258
|
if not connect_success:
|
|
258
259
|
raise exceptions.ConnectError(self.config)
|
|
259
260
|
|
|
260
|
-
def _decode_screen(self,
|
|
261
|
+
def _decode_screen(self, screen, start_time, target_list, is_secret, refresh, msg):
|
|
261
262
|
|
|
262
263
|
break_detect_after_send = False
|
|
263
264
|
use_too_many_res = False
|
|
264
265
|
|
|
265
|
-
vt100_p = screens.VT100Parser(receive_data_buffer, self.current_encoding, self.config.screen_height)
|
|
266
|
-
screen = vt100_p.screen
|
|
267
|
-
|
|
268
266
|
find_target = False
|
|
269
267
|
target_index = -1
|
|
270
268
|
for target in target_list:
|
|
@@ -308,6 +306,17 @@ class API(object):
|
|
|
308
306
|
break
|
|
309
307
|
return screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index
|
|
310
308
|
|
|
309
|
+
def _stream_screen(self, encoding: str, data_chunk: bytes) -> str:
|
|
310
|
+
"""Feed a newly-received chunk to the per-encoding incremental parser
|
|
311
|
+
and return the current screen. Each parser decodes and steps through
|
|
312
|
+
every byte exactly once across the whole receive sequence."""
|
|
313
|
+
parser = self._stream_parsers.get(encoding)
|
|
314
|
+
if parser is None:
|
|
315
|
+
parser = screens.IncrementalScreen(encoding, self.config.screen_height)
|
|
316
|
+
self._stream_parsers[encoding] = parser
|
|
317
|
+
parser.feed(data_chunk)
|
|
318
|
+
return parser.screen
|
|
319
|
+
|
|
311
320
|
async def _async_send(self, msg: str, target_list: list, screen_timeout: int, refresh: bool, secret: bool) -> int:
|
|
312
321
|
current_screen_timeout = self.config.screen_timeout if screen_timeout == 0 else screen_timeout
|
|
313
322
|
is_secret = secret
|
|
@@ -342,6 +351,11 @@ class API(object):
|
|
|
342
351
|
|
|
343
352
|
msg = ''
|
|
344
353
|
receive_data_buffer = bytes()
|
|
354
|
+
# Fresh incremental parsers for this screen sequence. Each arriving
|
|
355
|
+
# chunk is fed once (per encoding) instead of re-parsing the whole
|
|
356
|
+
# accumulated buffer every time — turning the receive loop from
|
|
357
|
+
# O(N²) into O(N).
|
|
358
|
+
self._stream_parsers = {}
|
|
345
359
|
start_time = time.time()
|
|
346
360
|
find_target = False
|
|
347
361
|
target_index = -1
|
|
@@ -360,14 +374,20 @@ class API(object):
|
|
|
360
374
|
data_chunk = data_chunk.encode('utf-8')
|
|
361
375
|
receive_data_buffer += data_chunk
|
|
362
376
|
|
|
377
|
+
screen = self._stream_screen(self.current_encoding, data_chunk)
|
|
363
378
|
screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
364
|
-
self._decode_screen(
|
|
379
|
+
self._decode_screen(screen, start_time, target_list, is_secret, refresh, msg)
|
|
365
380
|
|
|
366
381
|
if not find_target:
|
|
382
|
+
# Auto-detect the server encoding: the alternate
|
|
383
|
+
# parser has also seen every chunk so far (this
|
|
384
|
+
# branch runs on every non-matching chunk), so
|
|
385
|
+
# feeding it this chunk keeps it in sync.
|
|
367
386
|
original_encoding = self.current_encoding
|
|
368
387
|
self.current_encoding = 'big5uao' if original_encoding == 'utf-8' else 'utf-8'
|
|
388
|
+
screen_ = self._stream_screen(self.current_encoding, data_chunk)
|
|
369
389
|
screen_, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
370
|
-
self._decode_screen(
|
|
390
|
+
self._decode_screen(screen_, start_time, target_list, is_secret, refresh, msg)
|
|
371
391
|
if find_target:
|
|
372
392
|
screen = screen_
|
|
373
393
|
else:
|
|
@@ -431,6 +451,7 @@ class API(object):
|
|
|
431
451
|
return -1
|
|
432
452
|
msg = ''
|
|
433
453
|
receive_data_buffer = bytes()
|
|
454
|
+
self._stream_parsers = {}
|
|
434
455
|
start_time = time.time()
|
|
435
456
|
mid_time = time.time()
|
|
436
457
|
while mid_time - start_time < current_screen_timeout:
|
|
@@ -439,8 +460,9 @@ class API(object):
|
|
|
439
460
|
except EOFError:
|
|
440
461
|
return -1
|
|
441
462
|
receive_data_buffer += data
|
|
463
|
+
screen = self._stream_screen(self.current_encoding, data)
|
|
442
464
|
screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
443
|
-
self._decode_screen(
|
|
465
|
+
self._decode_screen(screen, start_time, target_list, is_secret, refresh, msg)
|
|
444
466
|
if target_index != -1:
|
|
445
467
|
return target_index
|
|
446
468
|
if use_too_many_res:
|
|
@@ -106,6 +106,7 @@ string_data = {
|
|
|
106
106
|
"set_connect_host": "Set up the connect host",
|
|
107
107
|
"set_connect_mode": "Set up the connect mode",
|
|
108
108
|
"set_contact_mail_first": "Password can only be changed after setting the contact mailbox",
|
|
109
|
+
"set_signature_file": "Update signature file",
|
|
109
110
|
"set_up_lang_module": "Set up language module",
|
|
110
111
|
"spend_time": "Spend time",
|
|
111
112
|
"substandard_post": "Substandard post",
|
|
@@ -106,6 +106,7 @@ string_data = {
|
|
|
106
106
|
"set_connect_host": "設定連線主機",
|
|
107
107
|
"set_connect_mode": "設定連線模式",
|
|
108
108
|
"set_contact_mail_first": "請先設定聯絡信箱後才能修改密碼",
|
|
109
|
+
"set_signature_file": "更新名片檔",
|
|
109
110
|
"set_up_lang_module": "設定語言模組",
|
|
110
111
|
"spend_time": "花費時間",
|
|
111
112
|
"substandard_post": "不合規範文章",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import codecs
|
|
1
2
|
import re
|
|
2
3
|
import sys
|
|
3
4
|
import unicodedata
|
|
@@ -9,6 +10,14 @@ from . import log
|
|
|
9
10
|
register_uao()
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
# Memoise the per-character width. east_asian_width is a C call, but a PTT
|
|
14
|
+
# screen is a few thousand characters drawn from a small alphabet, and the
|
|
15
|
+
# parser walks each line repeatedly (_str_pos_at_cells, replace mode). Caching
|
|
16
|
+
# turns the hot path into a dict hit. Writes are idempotent, so the plain-dict
|
|
17
|
+
# race under free-threading is benign.
|
|
18
|
+
_cell_width_cache: dict = {}
|
|
19
|
+
|
|
20
|
+
|
|
12
21
|
def _cell_width(ch: str) -> int:
|
|
13
22
|
"""Return the terminal-cell width of a single character.
|
|
14
23
|
|
|
@@ -17,7 +26,11 @@ def _cell_width(ch: str) -> int:
|
|
|
17
26
|
Ambiguous includes glyphs like ※, ←, →, ◎ that PTT's protocol treats
|
|
18
27
|
as full-width when positioning the cursor.
|
|
19
28
|
"""
|
|
20
|
-
|
|
29
|
+
w = _cell_width_cache.get(ch)
|
|
30
|
+
if w is None:
|
|
31
|
+
w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
|
|
32
|
+
_cell_width_cache[ch] = w
|
|
33
|
+
return w
|
|
21
34
|
|
|
22
35
|
|
|
23
36
|
def _cell_len(text: str) -> int:
|
|
@@ -241,51 +254,76 @@ def show(config, screen_queue, function_name=None):
|
|
|
241
254
|
|
|
242
255
|
xy_pattern_h = re.compile(r'^=ESC=\[[\d]+;[\d]+H')
|
|
243
256
|
xy_pattern_s = re.compile(r'^=ESC=\[[\d]+;[\d]+s')
|
|
257
|
+
_color_sgr = re.compile(r'\x1B\[[\d+;]*m')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _preprocess(data: str) -> str:
|
|
261
|
+
"""Normalise a decoded chunk: strip color, mark escapes, drop CR, and
|
|
262
|
+
collapse PTT's ' \\x08' wide-char reservation pairs.
|
|
263
|
+
|
|
264
|
+
Only the SGR (color) strip genuinely needs a regex; the remaining
|
|
265
|
+
substitutions are literal, and str.replace is several times faster than
|
|
266
|
+
re.sub — this runs on the whole buffer on every parse."""
|
|
267
|
+
data = _color_sgr.sub('', data)
|
|
268
|
+
data = data.replace('\x1B', '=ESC=')
|
|
269
|
+
data = data.replace('\r', '')
|
|
270
|
+
while ' \x08' in data:
|
|
271
|
+
data = data.replace(' \x08', '')
|
|
272
|
+
return data
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class _VT100Engine:
|
|
276
|
+
"""Shared VT100 stepping engine.
|
|
277
|
+
|
|
278
|
+
Both the batch ``VT100Parser`` and the streaming ``IncrementalScreen``
|
|
279
|
+
drive the same left-to-right state machine over a preprocessed string
|
|
280
|
+
(escapes marked as ``=ESC=``). The screen is tracked in terminal cells
|
|
281
|
+
(see the module-level cell-width helpers), not bytes.
|
|
244
282
|
|
|
283
|
+
``ESC [ 2J`` (clear screen) is handled as an in-loop reset: processing
|
|
284
|
+
left-to-right with a reset at every 2J yields the same final screen as
|
|
285
|
+
the classic "discard everything before the last 2J" slice, because each
|
|
286
|
+
reset wipes prior state — only work after the final reset survives."""
|
|
245
287
|
|
|
246
|
-
|
|
288
|
+
def __init__(self, screen_height: int = 24):
|
|
289
|
+
self._height = screen_height
|
|
290
|
+
self._reset_screen()
|
|
291
|
+
|
|
292
|
+
def _reset_screen(self):
|
|
293
|
+
self._cursor_x = 0
|
|
294
|
+
self._cursor_y = 0
|
|
295
|
+
self._lines = [''] * self._height
|
|
296
|
+
self._screen_length = dict()
|
|
297
|
+
|
|
298
|
+
# ── cursor / erase primitives ─────────────────────────────────────────
|
|
247
299
|
def _h(self):
|
|
248
300
|
self._cursor_x = 0
|
|
249
301
|
self._cursor_y = 0
|
|
250
302
|
|
|
251
303
|
def _move(self, x, y):
|
|
252
304
|
self._cursor_x = x
|
|
253
|
-
self._cursor_y = min(y, len(self.
|
|
305
|
+
self._cursor_y = min(y, len(self._lines) - 1)
|
|
254
306
|
|
|
255
307
|
def _newline(self):
|
|
256
308
|
self._cursor_x = 0
|
|
257
|
-
self._cursor_y = min(self._cursor_y + 1, len(self.
|
|
309
|
+
self._cursor_y = min(self._cursor_y + 1, len(self._lines) - 1)
|
|
258
310
|
|
|
259
311
|
def _k(self):
|
|
260
312
|
# Erase from cursor to end of line (VT100 ESC [ K).
|
|
261
|
-
pos = _str_pos_at_cells(self.
|
|
262
|
-
self.
|
|
263
|
-
self.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
data = bytes_data.decode(encoding, errors='replace')
|
|
275
|
-
|
|
276
|
-
# remove color
|
|
277
|
-
data = re.sub(r'\x1B\[[\d+;]*m', '', data)
|
|
278
|
-
data = re.sub(r'[\x1B]', '=ESC=', data)
|
|
279
|
-
data = re.sub(r'[\r]', '', data)
|
|
280
|
-
while ' \x08' in data:
|
|
281
|
-
data = re.sub(r' \x08', '', data)
|
|
282
|
-
|
|
283
|
-
if '=ESC=[2J' in data:
|
|
284
|
-
data = data[data.rfind('=ESC=[2J') + len('=ESC=[2J'):]
|
|
285
|
-
|
|
286
|
-
count = 0
|
|
313
|
+
pos = _str_pos_at_cells(self._lines[self._cursor_y], self._cursor_x)
|
|
314
|
+
self._lines[self._cursor_y] = self._lines[self._cursor_y][:pos]
|
|
315
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
316
|
+
|
|
317
|
+
# ── the stepping loop ─────────────────────────────────────────────────
|
|
318
|
+
def _run(self, data: str) -> str:
|
|
319
|
+
"""Consume `data` left-to-right, mutating screen state.
|
|
320
|
+
|
|
321
|
+
Returns the unconsumed tail: '' when everything was consumed, or the
|
|
322
|
+
remainder starting at a front ``=ESC=`` the machine does not
|
|
323
|
+
recognise. The batch parser ignores that tail (matching the classic
|
|
324
|
+
"bail on unknown escape" behaviour); the incremental parser carries
|
|
325
|
+
it until more bytes arrive or freezes on it."""
|
|
287
326
|
while data:
|
|
288
|
-
count += 1
|
|
289
327
|
while True:
|
|
290
328
|
if not data.startswith('=ESC='):
|
|
291
329
|
break
|
|
@@ -300,6 +338,10 @@ class VT100Parser:
|
|
|
300
338
|
elif data.startswith('=ESC=[s'):
|
|
301
339
|
data = data[len('=ESC=[s'):]
|
|
302
340
|
continue
|
|
341
|
+
elif data.startswith('=ESC=[2J'):
|
|
342
|
+
data = data[len('=ESC=[2J'):]
|
|
343
|
+
self._reset_screen()
|
|
344
|
+
continue
|
|
303
345
|
break
|
|
304
346
|
|
|
305
347
|
xy_result = None
|
|
@@ -317,7 +359,6 @@ class VT100Parser:
|
|
|
317
359
|
new_y = int(xy_part[6:xy_part.find(';')]) - 1
|
|
318
360
|
# VT100 columns are 1-based; convert to 0-based cursor_x.
|
|
319
361
|
new_x = int(xy_part[xy_part.find(';') + 1: -1]) - 1
|
|
320
|
-
# log.py.info('xy', xy_part, new_x, new_y)
|
|
321
362
|
self._move(new_x, new_y)
|
|
322
363
|
|
|
323
364
|
data = data[len(xy_part):]
|
|
@@ -328,16 +369,14 @@ class VT100Parser:
|
|
|
328
369
|
self._newline()
|
|
329
370
|
continue
|
|
330
371
|
|
|
331
|
-
|
|
372
|
+
if self._cursor_y not in self._screen_length:
|
|
373
|
+
self._screen_length[self._cursor_y] = _cell_len(self._lines[self._cursor_y])
|
|
332
374
|
|
|
333
|
-
|
|
334
|
-
self.screen_length[self._cursor_y] = _cell_len(self.screen[self._cursor_y])
|
|
335
|
-
|
|
336
|
-
current_line_length = self.screen_length[self._cursor_y]
|
|
375
|
+
current_line_length = self._screen_length[self._cursor_y]
|
|
337
376
|
replace_mode = False
|
|
338
377
|
if current_line_length < self._cursor_x:
|
|
339
378
|
append_space = ' ' * (self._cursor_x - current_line_length)
|
|
340
|
-
self.
|
|
379
|
+
self._lines[self._cursor_y] += append_space
|
|
341
380
|
elif current_line_length > self._cursor_x:
|
|
342
381
|
replace_mode = True
|
|
343
382
|
|
|
@@ -347,34 +386,143 @@ class VT100Parser:
|
|
|
347
386
|
next_esc = data.find('=ESC=')
|
|
348
387
|
next_esc = 1920 if next_esc < 0 else next_esc
|
|
349
388
|
if next_esc == 0:
|
|
350
|
-
|
|
389
|
+
# A front escape the machine does not consume. Hand the
|
|
390
|
+
# remainder back to the caller instead of dropping it.
|
|
391
|
+
return data
|
|
351
392
|
|
|
352
393
|
current_index = min(next_newline, next_esc)
|
|
353
394
|
|
|
354
395
|
current_data = data[:current_index]
|
|
355
396
|
current_data_length = _cell_len(current_data)
|
|
356
397
|
if replace_mode:
|
|
357
|
-
line = self.
|
|
398
|
+
line = self._lines[self._cursor_y]
|
|
358
399
|
splice_start = _str_pos_at_cells(line, self._cursor_x)
|
|
359
400
|
splice_end = _str_pos_at_cells(line, self._cursor_x + current_data_length)
|
|
360
|
-
self.
|
|
401
|
+
self._lines[self._cursor_y] = line[:splice_start] + current_data + line[splice_end:]
|
|
361
402
|
self._cursor_x += current_data_length
|
|
362
|
-
if self._cursor_x > self.
|
|
363
|
-
self.
|
|
403
|
+
if self._cursor_x > self._screen_length[self._cursor_y]:
|
|
404
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
364
405
|
else:
|
|
365
|
-
self.
|
|
406
|
+
self._lines[self._cursor_y] += current_data
|
|
366
407
|
self._cursor_x += current_data_length
|
|
367
|
-
self.
|
|
408
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
368
409
|
|
|
369
410
|
data = data[current_index:]
|
|
411
|
+
return ''
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class VT100Parser(_VT100Engine):
|
|
415
|
+
"""Batch parser: decode the whole byte buffer and render one screen.
|
|
370
416
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
417
|
+
Kept as the reference implementation and the target-matching path for a
|
|
418
|
+
full (non-streamed) buffer. ``IncrementalScreen`` fed the same bytes in
|
|
419
|
+
any chunking produces a byte-identical screen (see
|
|
420
|
+
tests/test_incremental_parity.py)."""
|
|
375
421
|
|
|
376
|
-
|
|
377
|
-
|
|
422
|
+
def __init__(self, bytes_data, encoding, screen_height: int = 24):
|
|
423
|
+
# https://www.csie.ntu.edu.tw/~r88009/Java/html/Network/vt100.htm
|
|
424
|
+
super().__init__(screen_height)
|
|
425
|
+
data = _preprocess(bytes_data.decode(encoding, errors='replace'))
|
|
426
|
+
# Leftover (a trailing/unknown escape) is intentionally ignored here,
|
|
427
|
+
# matching the classic parser's "bail on unknown escape" behaviour.
|
|
428
|
+
self._run(data)
|
|
429
|
+
self.screen = '\n'.join(self._lines)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# CSI final bytes are 0x40-0x7e; parameter/intermediate bytes are 0x20-0x3f.
|
|
433
|
+
def _escape_complete(seq: str) -> bool:
|
|
434
|
+
"""Is `seq` (which starts with a raw ESC) a complete escape sequence?
|
|
435
|
+
|
|
436
|
+
Used by the streaming parser to decide whether a trailing ESC run might
|
|
437
|
+
still be extended by the next chunk. A CSI (``ESC [``) is complete once a
|
|
438
|
+
final byte (0x40-0x7e) appears; a non-CSI ESC is treated as complete (the
|
|
439
|
+
engine will bail on it, same as the batch parser)."""
|
|
440
|
+
if len(seq) < 2:
|
|
441
|
+
return False # lone ESC — wait for more
|
|
442
|
+
if seq[1] != '[':
|
|
443
|
+
return True # ESC + non-'[': engine bails on it
|
|
444
|
+
for ch in seq[2:]:
|
|
445
|
+
if 0x40 <= ord(ch) <= 0x7e:
|
|
446
|
+
return True
|
|
447
|
+
return False # still inside CSI parameters
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class IncrementalScreen(_VT100Engine):
|
|
451
|
+
"""Streaming VT100 parser: feed byte chunks, read the current screen.
|
|
452
|
+
|
|
453
|
+
Removes the O(N²) re-parse of the receive loop — each byte is decoded and
|
|
454
|
+
stepped through the state machine exactly once, instead of the whole
|
|
455
|
+
accumulated buffer being re-parsed on every arriving chunk.
|
|
456
|
+
|
|
457
|
+
Correctness is anchored to ``VT100Parser``: feeding the same bytes in any
|
|
458
|
+
chunking (down to one byte at a time) yields the identical screen."""
|
|
459
|
+
|
|
460
|
+
def __init__(self, encoding, screen_height: int = 24):
|
|
461
|
+
super().__init__(screen_height)
|
|
462
|
+
self._decoder = codecs.getincrementaldecoder(encoding)(errors='replace')
|
|
463
|
+
self._raw = '' # decoded but not-yet-safe-to-process tail
|
|
464
|
+
self._leftover = '' # preprocessed front escape the engine paused on
|
|
465
|
+
self._bailed = False # hit a complete-but-unknown escape → frozen
|
|
466
|
+
|
|
467
|
+
def _safe_cut(self, s: str) -> int:
|
|
468
|
+
"""Length of the prefix of `s` safe to process now.
|
|
469
|
+
|
|
470
|
+
Holds back the maximal trailing run of spaces and backspaces, and an
|
|
471
|
+
incomplete trailing escape.
|
|
472
|
+
|
|
473
|
+
The ' \\x08' collapse cascades backwards through a run of spaces
|
|
474
|
+
(``' \\x08\\x08'`` → ``' '``), and the ``\\x08`` bytes can arrive in a
|
|
475
|
+
later chunk, so the whole trailing ``[ \\x08]`` run is ambiguous until a
|
|
476
|
+
character that cannot participate in the collapse follows it. Only
|
|
477
|
+
commit up to that terminating character."""
|
|
478
|
+
cut = len(s)
|
|
479
|
+
while cut > 0 and s[cut - 1] in ' \x08':
|
|
480
|
+
cut -= 1
|
|
481
|
+
esc = s.rfind('\x1b')
|
|
482
|
+
if esc != -1 and not _escape_complete(s[esc:]):
|
|
483
|
+
cut = min(cut, esc)
|
|
484
|
+
return cut
|
|
485
|
+
|
|
486
|
+
def feed(self, chunk) -> None:
|
|
487
|
+
if self._bailed:
|
|
488
|
+
return
|
|
489
|
+
if isinstance(chunk, str):
|
|
490
|
+
chunk = chunk.encode('utf-8')
|
|
491
|
+
self._raw += self._decoder.decode(chunk)
|
|
492
|
+
cut = self._safe_cut(self._raw)
|
|
493
|
+
if cut == 0:
|
|
494
|
+
return
|
|
495
|
+
safe = _preprocess(self._raw[:cut])
|
|
496
|
+
self._raw = self._raw[cut:]
|
|
497
|
+
leftover = self._run(self._leftover + safe)
|
|
498
|
+
self._leftover = leftover
|
|
499
|
+
if leftover:
|
|
500
|
+
# _safe_cut only forwards complete escapes, so a surviving front
|
|
501
|
+
# escape is complete but unrecognised — the batch parser bails on
|
|
502
|
+
# it forever, so we freeze to match.
|
|
503
|
+
self._bailed = True
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def screen(self) -> str:
|
|
507
|
+
"""Current screen as a ``screen_height``-line string, including any
|
|
508
|
+
still-buffered tail — matches VT100Parser over all bytes fed so far."""
|
|
509
|
+
dec_state = self._decoder.getstate()
|
|
510
|
+
if not self._raw and not self._leftover and not dec_state[0]:
|
|
511
|
+
# Nothing held and no partial multibyte in the decoder.
|
|
512
|
+
return '\n'.join(self._lines)
|
|
513
|
+
# Tentatively flush the held tail on a snapshot so a partial escape, an
|
|
514
|
+
# unpaired trailing space, or a partial multibyte char is reflected
|
|
515
|
+
# (matching the batch parser's errors='replace') without committing.
|
|
516
|
+
saved = (self._cursor_x, self._cursor_y, list(self._lines),
|
|
517
|
+
dict(self._screen_length))
|
|
518
|
+
try:
|
|
519
|
+
tail = self._raw + self._decoder.decode(b'', final=True)
|
|
520
|
+
self._run(self._leftover + _preprocess(tail))
|
|
521
|
+
return '\n'.join(self._lines)
|
|
522
|
+
finally:
|
|
523
|
+
(self._cursor_x, self._cursor_y, self._lines,
|
|
524
|
+
self._screen_length) = saved
|
|
525
|
+
self._decoder.setstate(dec_state)
|
|
378
526
|
|
|
379
527
|
|
|
380
528
|
if __name__ == '__main__':
|
|
@@ -30,6 +30,7 @@ PyPtt/_api_post.py
|
|
|
30
30
|
PyPtt/_api_reply_post.py
|
|
31
31
|
PyPtt/_api_search_user.py
|
|
32
32
|
PyPtt/_api_set_board_title.py
|
|
33
|
+
PyPtt/_api_set_signature_file.py
|
|
33
34
|
PyPtt/_api_util.py
|
|
34
35
|
PyPtt/api_server.py
|
|
35
36
|
PyPtt/check_value.py
|
|
@@ -78,6 +79,7 @@ tests/test_get_user.py
|
|
|
78
79
|
tests/test_get_waterball.py
|
|
79
80
|
tests/test_give_money.py
|
|
80
81
|
tests/test_i18n.py
|
|
82
|
+
tests/test_incremental_parity.py
|
|
81
83
|
tests/test_init.py
|
|
82
84
|
tests/test_logger.py
|
|
83
85
|
tests/test_login_logout.py
|
|
@@ -92,4 +94,5 @@ tests/test_reply_post.py
|
|
|
92
94
|
tests/test_search_user.py
|
|
93
95
|
tests/test_service.py
|
|
94
96
|
tests/test_set_board_title.py
|
|
97
|
+
tests/test_set_signature_file.py
|
|
95
98
|
tests/test_vt100_parser.py
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parity tests for PyPtt.screens.IncrementalScreen.
|
|
3
|
+
|
|
4
|
+
The receive loop in connect_core streams bytes and must decide, after every
|
|
5
|
+
arriving chunk, whether the current screen matches a target. Re-parsing the
|
|
6
|
+
whole accumulated buffer on every chunk is O(N²); IncrementalScreen feeds each
|
|
7
|
+
byte through the VT100 state machine exactly once instead.
|
|
8
|
+
|
|
9
|
+
Correctness is anchored to the batch VT100Parser: feeding the same bytes in any
|
|
10
|
+
chunking — down to one byte at a time, the worst case for split escapes, split
|
|
11
|
+
multibyte characters and split ' \\x08' wide-char pairs — must yield the exact
|
|
12
|
+
same screen string. These tests pin that invariant so a future change to either
|
|
13
|
+
parser can't silently diverge.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import glob
|
|
17
|
+
import random
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from PyPtt.screens import IncrementalScreen, VT100Parser
|
|
23
|
+
|
|
24
|
+
FIXTURE_DIR = Path(__file__).resolve().parent / 'fixtures' / 'vt100'
|
|
25
|
+
|
|
26
|
+
CHUNK_SIZES = [1, 2, 3, 5, 7, 13, 64, 256, 100000]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def feed_in_chunks(data: bytes, encoding: str, chunk: int,
|
|
30
|
+
read_each: bool = False) -> str:
|
|
31
|
+
parser = IncrementalScreen(encoding, 24)
|
|
32
|
+
for i in range(0, len(data), chunk):
|
|
33
|
+
parser.feed(data[i:i + chunk])
|
|
34
|
+
if read_each:
|
|
35
|
+
# Reading .screen mid-stream (as the receive loop does for target
|
|
36
|
+
# matching) must not corrupt the committed state.
|
|
37
|
+
_ = parser.screen
|
|
38
|
+
return parser.screen
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── crafted byte streams that stress the chunk boundaries ─────────────────────
|
|
42
|
+
|
|
43
|
+
CRAFTED = [
|
|
44
|
+
b'',
|
|
45
|
+
b'hello world',
|
|
46
|
+
b'\x1b[1;37;44mHELLO\x1b[0m', # color strip
|
|
47
|
+
b'BEFORE\x1b[2JAFTER', # clear screen
|
|
48
|
+
b'A\x1b[2JB\x1b[2JC', # only last 2J wins
|
|
49
|
+
b'helloworld\x1b[1;6H\x1b[K', # erase to EOL
|
|
50
|
+
b'abc \x08def', # single ' \x08'
|
|
51
|
+
b'a \x08\x08\x08\x08b', # cascading ' \x08'
|
|
52
|
+
b' \x08\x08\x08\x08\x08x', # more BS than spaces
|
|
53
|
+
b'\x1b[5;3HHi\nNext', # positioning + newline
|
|
54
|
+
b' \x08\x08\xe2\x97\x8e tail', # ◎ via reservation pair
|
|
55
|
+
b'\x1b[10;1H \x08\x08\xe2\x80\xbb\x1b[10;3H \xe7\x99\xbc\xe4\xbf\xa1\xe7\xab\x99: x',
|
|
56
|
+
b'\x1b[6nHELLO', # unknown escape -> bail
|
|
57
|
+
b'line1\r\nline2', # CRLF
|
|
58
|
+
b'helloworld\x1b[1;2HXY', # replace mode
|
|
59
|
+
b'\x1b[100;1HX', # row clamp
|
|
60
|
+
b'\xe2\x94\x80' * 40, # long box-drawing run
|
|
61
|
+
'中文範例'.encode('utf-8'), # wide chars
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize('data', CRAFTED)
|
|
66
|
+
@pytest.mark.parametrize('chunk', CHUNK_SIZES)
|
|
67
|
+
def test_crafted_parity_utf8(data, chunk):
|
|
68
|
+
ref = VT100Parser(data, 'utf-8', 24).screen
|
|
69
|
+
assert feed_in_chunks(data, 'utf-8', chunk) == ref
|
|
70
|
+
assert feed_in_chunks(data, 'utf-8', chunk, read_each=True) == ref
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
BIG5_TEXTS = ['中文', '程式範例', '◎[軟工]※←→─▄▆', '看板《Python》 測試']
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize('text', BIG5_TEXTS)
|
|
77
|
+
@pytest.mark.parametrize('chunk', [1, 2, 3, 5, 64])
|
|
78
|
+
def test_big5uao_parity(text, chunk):
|
|
79
|
+
data = text.encode('big5')
|
|
80
|
+
ref = VT100Parser(data, 'big5uao', 24).screen
|
|
81
|
+
assert feed_in_chunks(data, 'big5uao', chunk) == ref
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_big5uao_realistic_stream_parity():
|
|
85
|
+
# A PTT-shaped big5uao screen: clear, positioning, wide chars via the
|
|
86
|
+
# ' \x08' reservation pattern, box drawing, erase-to-EOL, comment glyphs.
|
|
87
|
+
def b5(s):
|
|
88
|
+
return s.encode('big5')
|
|
89
|
+
|
|
90
|
+
stream = (
|
|
91
|
+
b'\x1b[2J\x1b[1;1H' + b5('看板《Python》')
|
|
92
|
+
+ b'\x1b[3;1H \x08\x08' + b5('◎') + b5(' [問題] 測試中文標題 ')
|
|
93
|
+
+ b'\x1b[5;1H' + b5('※ 發信站: 批踢踢實業坊(ptt.cc)')
|
|
94
|
+
+ b'\x1b[6;1H' + b5('推 user123: 這是一則推文') + b'\x1b[K'
|
|
95
|
+
+ b'\x1b[10;1H' + b5('─' * 30)
|
|
96
|
+
+ b'\x1b[23;1H' + b5('瀏覽 第 1/2 頁')
|
|
97
|
+
)
|
|
98
|
+
ref = VT100Parser(stream, 'big5uao', 24).screen
|
|
99
|
+
for chunk in CHUNK_SIZES:
|
|
100
|
+
assert feed_in_chunks(stream, 'big5uao', chunk) == ref
|
|
101
|
+
assert feed_in_chunks(stream, 'big5uao', chunk, read_each=True) == ref
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── real captured PTT byte streams, every boundary ────────────────────────────
|
|
105
|
+
|
|
106
|
+
FIXTURES = sorted(glob.glob(str(FIXTURE_DIR / '*.bin')))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize('fixture', FIXTURES, ids=lambda p: Path(p).stem)
|
|
110
|
+
@pytest.mark.parametrize('chunk', CHUNK_SIZES)
|
|
111
|
+
def test_fixture_parity(fixture, chunk):
|
|
112
|
+
data = Path(fixture).read_bytes()
|
|
113
|
+
encoding = Path(fixture[:-4] + '.encoding').read_text().strip()
|
|
114
|
+
ref = VT100Parser(data, encoding, 24).screen
|
|
115
|
+
assert feed_in_chunks(data, encoding, chunk) == ref
|
|
116
|
+
assert feed_in_chunks(data, encoding, chunk, read_each=True) == ref
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize('fixture', FIXTURES, ids=lambda p: Path(p).stem)
|
|
120
|
+
def test_fixture_parity_random_chunking(fixture):
|
|
121
|
+
# Randomised chunk boundaries with a mid-stream .screen read each step —
|
|
122
|
+
# exactly how the receive loop drives the parser.
|
|
123
|
+
data = Path(fixture).read_bytes()
|
|
124
|
+
encoding = Path(fixture[:-4] + '.encoding').read_text().strip()
|
|
125
|
+
ref = VT100Parser(data, encoding, 24).screen
|
|
126
|
+
for seed in range(25):
|
|
127
|
+
rnd = random.Random(seed)
|
|
128
|
+
parser = IncrementalScreen(encoding, 24)
|
|
129
|
+
i = 0
|
|
130
|
+
while i < len(data):
|
|
131
|
+
n = rnd.randint(1, 9)
|
|
132
|
+
parser.feed(data[i:i + n])
|
|
133
|
+
_ = parser.screen
|
|
134
|
+
i += n
|
|
135
|
+
assert parser.screen == ref, f'seed={seed}'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Integration test for set_signature_file (the PTT "名片檔"/plan).
|
|
2
|
+
|
|
3
|
+
Self-restoring round-trip: capture the account's current signature file via
|
|
4
|
+
`get_user`, overwrite it with an identifiable marker, verify the marker shows
|
|
5
|
+
up on a fresh `get_user` read, then set it back to the original content so
|
|
6
|
+
the test doesn't leave a permanent change on the account.
|
|
7
|
+
"""
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from PyPtt import UserField
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_set_signature_file_round_trip(ptt_bots):
|
|
14
|
+
for ptt_bot in ptt_bots:
|
|
15
|
+
original = ptt_bot.get_user(ptt_bot.ptt_id)[UserField.signature_file]
|
|
16
|
+
|
|
17
|
+
marker = f'PyPtt set_signature_file test {int(time.time())}'
|
|
18
|
+
try:
|
|
19
|
+
ptt_bot.set_signature_file(marker)
|
|
20
|
+
time.sleep(1)
|
|
21
|
+
|
|
22
|
+
updated = ptt_bot.get_user(ptt_bot.ptt_id)[UserField.signature_file]
|
|
23
|
+
assert marker in updated.strip()
|
|
24
|
+
finally:
|
|
25
|
+
# Restore -- must always run, even if the assertion above fails.
|
|
26
|
+
ptt_bot.set_signature_file(original)
|
|
27
|
+
time.sleep(1)
|
|
28
|
+
|
|
29
|
+
restored = ptt_bot.get_user(ptt_bot.ptt_id)[UserField.signature_file]
|
|
30
|
+
# Tolerate trailing whitespace/blank-line drift from the
|
|
31
|
+
# editor round-trip; compare on stripped content.
|
|
32
|
+
assert restored.strip() == original.strip()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|