PyPtt 2.2.5__tar.gz → 2.2.7__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.5 → pyptt-2.2.7}/PKG-INFO +1 -1
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/__init__.py +1 -1
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/__init__.py +19 -7
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/connect_core.py +41 -15
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/screens.py +208 -51
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/SOURCES.txt +2 -0
- pyptt-2.2.7/tests/test_incremental_parity.py +135 -0
- pyptt-2.2.7/tests/test_uao_codec.py +93 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/LICENSE +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/PTT.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_lottery.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_set_signature_file.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_util.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/api_server.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/check_value.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/command.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/config.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/data_type.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/exceptions.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/i18n.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lib_util.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/log.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/service.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/README.md +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/pyproject.toml +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/setup.cfg +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/setup.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_bucket.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_change_pw.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_comment.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_del_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_del_post_unit.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_exceptions.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_fast_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_board_info.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_board_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_mail.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_list.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_time.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_user.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_waterball.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_give_money.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_i18n.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_init.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_logger.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_login_logout.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_lottery.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_mark_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_reply_post.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_search_user.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_service.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_set_board_title.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_set_signature_file.py +0 -0
- {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_vt100_parser.py +0 -0
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"""Vendored Big5-UAO codec.
|
|
2
2
|
|
|
3
|
-
Vendored
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Vendored from PttCodingMan/pyUAO, a fork of eight04/pyUAO, to drop the
|
|
4
|
+
external `uao` PyPI dependency (unmaintained upstream) and pick up this
|
|
5
|
+
fork's correctness fixes and incremental/stream codec support.
|
|
6
|
+
|
|
7
|
+
Locally modified: `_decode` batches ASCII runs with a single C-level decode
|
|
8
|
+
instead of one chr()/append per byte. PTT screens are ~70% ASCII bytes even
|
|
9
|
+
in Big5, so this speeds up the receive path measurably while producing
|
|
10
|
+
byte-identical output (the byte→unicode table is unchanged). Python's stdlib
|
|
11
|
+
`big5` codec is *not* a usable fast path — it disagrees with the UAO table on
|
|
12
|
+
~260 sequences (e.g. 0xa1 0x45 → '•' vs '‧') and can't decode UAO's
|
|
13
|
+
extensions at all.
|
|
8
14
|
|
|
9
15
|
Source: https://github.com/PttCodingMan/pyUAO
|
|
10
16
|
Commit: 9d9a8bf59dbe60800d214b66ce972bc2ddf8da89 (v0.3.0)
|
|
@@ -89,8 +95,14 @@ def _decode(input, errors, final):
|
|
|
89
95
|
while i < n:
|
|
90
96
|
c = input[i]
|
|
91
97
|
if c < 0x80:
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
# At a character boundary every byte < 0x80 is a standalone ASCII
|
|
99
|
+
# char, so decode the whole run at once. latin-1 maps 0x00-0x7f
|
|
100
|
+
# identically to ASCII and never raises.
|
|
101
|
+
j = i + 1
|
|
102
|
+
while j < n and input[j] < 0x80:
|
|
103
|
+
j += 1
|
|
104
|
+
append(input[i:j].decode('latin-1'))
|
|
105
|
+
i = j
|
|
94
106
|
continue
|
|
95
107
|
if i + 1 < n:
|
|
96
108
|
u = _b2u_get(c * 0x100 + input[i + 1])
|
|
@@ -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
|
|
@@ -341,7 +350,12 @@ class API(object):
|
|
|
341
350
|
return -1
|
|
342
351
|
|
|
343
352
|
msg = ''
|
|
344
|
-
|
|
353
|
+
# Fresh incremental parsers for this screen sequence. Each arriving
|
|
354
|
+
# chunk is fed once (per encoding) instead of re-parsing the whole
|
|
355
|
+
# accumulated buffer every time — turning the receive loop from
|
|
356
|
+
# O(N²) into O(N). The parsers also hold the rendered screen, so no
|
|
357
|
+
# separate raw byte buffer is accumulated.
|
|
358
|
+
self._stream_parsers = {}
|
|
345
359
|
start_time = time.time()
|
|
346
360
|
find_target = False
|
|
347
361
|
target_index = -1
|
|
@@ -358,16 +372,21 @@ class API(object):
|
|
|
358
372
|
|
|
359
373
|
if isinstance(data_chunk, str):
|
|
360
374
|
data_chunk = data_chunk.encode('utf-8')
|
|
361
|
-
receive_data_buffer += data_chunk
|
|
362
375
|
|
|
376
|
+
screen = self._stream_screen(self.current_encoding, data_chunk)
|
|
363
377
|
screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
364
|
-
self._decode_screen(
|
|
378
|
+
self._decode_screen(screen, start_time, target_list, is_secret, refresh, msg)
|
|
365
379
|
|
|
366
380
|
if not find_target:
|
|
381
|
+
# Auto-detect the server encoding: the alternate
|
|
382
|
+
# parser has also seen every chunk so far (this
|
|
383
|
+
# branch runs on every non-matching chunk), so
|
|
384
|
+
# feeding it this chunk keeps it in sync.
|
|
367
385
|
original_encoding = self.current_encoding
|
|
368
386
|
self.current_encoding = 'big5uao' if original_encoding == 'utf-8' else 'utf-8'
|
|
387
|
+
screen_ = self._stream_screen(self.current_encoding, data_chunk)
|
|
369
388
|
screen_, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
370
|
-
self._decode_screen(
|
|
389
|
+
self._decode_screen(screen_, start_time, target_list, is_secret, refresh, msg)
|
|
371
390
|
if find_target:
|
|
372
391
|
screen = screen_
|
|
373
392
|
else:
|
|
@@ -376,10 +395,17 @@ class API(object):
|
|
|
376
395
|
if find_target:
|
|
377
396
|
break
|
|
378
397
|
except TimeoutError:
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
398
|
+
# The incremental parser for the current encoding already holds
|
|
399
|
+
# the fully-rendered screen for every byte received this round
|
|
400
|
+
# (byte-identical to a fresh VT100Parser over the whole buffer —
|
|
401
|
+
# see tests/test_incremental_parity.py), so reuse it instead of
|
|
402
|
+
# re-decoding and re-parsing the entire buffer from scratch.
|
|
403
|
+
parser = self._stream_parsers.get(self.current_encoding)
|
|
404
|
+
if parser is not None:
|
|
405
|
+
screen = parser.screen
|
|
406
|
+
if len(screen) > 0:
|
|
407
|
+
screens.show(self.config, screen)
|
|
408
|
+
self._RDQ.add(screen)
|
|
383
409
|
if use_too_many_res:
|
|
384
410
|
raise exceptions.UseTooManyResources()
|
|
385
411
|
return -1
|
|
@@ -430,7 +456,7 @@ class API(object):
|
|
|
430
456
|
if break_detect_after_send:
|
|
431
457
|
return -1
|
|
432
458
|
msg = ''
|
|
433
|
-
|
|
459
|
+
self._stream_parsers = {}
|
|
434
460
|
start_time = time.time()
|
|
435
461
|
mid_time = time.time()
|
|
436
462
|
while mid_time - start_time < current_screen_timeout:
|
|
@@ -438,9 +464,9 @@ class API(object):
|
|
|
438
464
|
data = self._core.read_very_eager()
|
|
439
465
|
except EOFError:
|
|
440
466
|
return -1
|
|
441
|
-
|
|
467
|
+
screen = self._stream_screen(self.current_encoding, data)
|
|
442
468
|
screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
|
|
443
|
-
self._decode_screen(
|
|
469
|
+
self._decode_screen(screen, start_time, target_list, is_secret, refresh, msg)
|
|
444
470
|
if target_index != -1:
|
|
445
471
|
return target_index
|
|
446
472
|
if use_too_many_res:
|
|
@@ -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,10 +26,21 @@ 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
|
+
if ch.isascii(): # every ASCII char is a single narrow cell
|
|
30
|
+
return 1
|
|
31
|
+
w = _cell_width_cache.get(ch)
|
|
32
|
+
if w is None:
|
|
33
|
+
w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
|
|
34
|
+
_cell_width_cache[ch] = w
|
|
35
|
+
return w
|
|
21
36
|
|
|
22
37
|
|
|
23
38
|
def _cell_len(text: str) -> int:
|
|
39
|
+
# PTT screens are mostly ASCII, and an all-ASCII string is exactly one cell
|
|
40
|
+
# per character — str.isascii() (a fast C scan) lets whole segments skip the
|
|
41
|
+
# per-character loop.
|
|
42
|
+
if text.isascii():
|
|
43
|
+
return len(text)
|
|
24
44
|
return sum(_cell_width(c) for c in text)
|
|
25
45
|
|
|
26
46
|
|
|
@@ -32,6 +52,8 @@ def _str_pos_at_cells(line: str, cells: int) -> int:
|
|
|
32
52
|
"""
|
|
33
53
|
if cells <= 0:
|
|
34
54
|
return 0
|
|
55
|
+
if line.isascii(): # 1 cell per char → cell offset == string index
|
|
56
|
+
return min(cells, len(line))
|
|
35
57
|
consumed = 0
|
|
36
58
|
for i, ch in enumerate(line):
|
|
37
59
|
if consumed >= cells:
|
|
@@ -241,51 +263,76 @@ def show(config, screen_queue, function_name=None):
|
|
|
241
263
|
|
|
242
264
|
xy_pattern_h = re.compile(r'^=ESC=\[[\d]+;[\d]+H')
|
|
243
265
|
xy_pattern_s = re.compile(r'^=ESC=\[[\d]+;[\d]+s')
|
|
266
|
+
_color_sgr = re.compile(r'\x1B\[[\d+;]*m')
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _preprocess(data: str) -> str:
|
|
270
|
+
"""Normalise a decoded chunk: strip color, mark escapes, drop CR, and
|
|
271
|
+
collapse PTT's ' \\x08' wide-char reservation pairs.
|
|
272
|
+
|
|
273
|
+
Only the SGR (color) strip genuinely needs a regex; the remaining
|
|
274
|
+
substitutions are literal, and str.replace is several times faster than
|
|
275
|
+
re.sub — this runs on the whole buffer on every parse."""
|
|
276
|
+
data = _color_sgr.sub('', data)
|
|
277
|
+
data = data.replace('\x1B', '=ESC=')
|
|
278
|
+
data = data.replace('\r', '')
|
|
279
|
+
while ' \x08' in data:
|
|
280
|
+
data = data.replace(' \x08', '')
|
|
281
|
+
return data
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class _VT100Engine:
|
|
285
|
+
"""Shared VT100 stepping engine.
|
|
286
|
+
|
|
287
|
+
Both the batch ``VT100Parser`` and the streaming ``IncrementalScreen``
|
|
288
|
+
drive the same left-to-right state machine over a preprocessed string
|
|
289
|
+
(escapes marked as ``=ESC=``). The screen is tracked in terminal cells
|
|
290
|
+
(see the module-level cell-width helpers), not bytes.
|
|
244
291
|
|
|
292
|
+
``ESC [ 2J`` (clear screen) is handled as an in-loop reset: processing
|
|
293
|
+
left-to-right with a reset at every 2J yields the same final screen as
|
|
294
|
+
the classic "discard everything before the last 2J" slice, because each
|
|
295
|
+
reset wipes prior state — only work after the final reset survives."""
|
|
245
296
|
|
|
246
|
-
|
|
297
|
+
def __init__(self, screen_height: int = 24):
|
|
298
|
+
self._height = screen_height
|
|
299
|
+
self._reset_screen()
|
|
300
|
+
|
|
301
|
+
def _reset_screen(self):
|
|
302
|
+
self._cursor_x = 0
|
|
303
|
+
self._cursor_y = 0
|
|
304
|
+
self._lines = [''] * self._height
|
|
305
|
+
self._screen_length = dict()
|
|
306
|
+
|
|
307
|
+
# ── cursor / erase primitives ─────────────────────────────────────────
|
|
247
308
|
def _h(self):
|
|
248
309
|
self._cursor_x = 0
|
|
249
310
|
self._cursor_y = 0
|
|
250
311
|
|
|
251
312
|
def _move(self, x, y):
|
|
252
313
|
self._cursor_x = x
|
|
253
|
-
self._cursor_y = min(y, len(self.
|
|
314
|
+
self._cursor_y = min(y, len(self._lines) - 1)
|
|
254
315
|
|
|
255
316
|
def _newline(self):
|
|
256
317
|
self._cursor_x = 0
|
|
257
|
-
self._cursor_y = min(self._cursor_y + 1, len(self.
|
|
318
|
+
self._cursor_y = min(self._cursor_y + 1, len(self._lines) - 1)
|
|
258
319
|
|
|
259
320
|
def _k(self):
|
|
260
321
|
# 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
|
|
322
|
+
pos = _str_pos_at_cells(self._lines[self._cursor_y], self._cursor_x)
|
|
323
|
+
self._lines[self._cursor_y] = self._lines[self._cursor_y][:pos]
|
|
324
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
325
|
+
|
|
326
|
+
# ── the stepping loop ─────────────────────────────────────────────────
|
|
327
|
+
def _run(self, data: str) -> str:
|
|
328
|
+
"""Consume `data` left-to-right, mutating screen state.
|
|
329
|
+
|
|
330
|
+
Returns the unconsumed tail: '' when everything was consumed, or the
|
|
331
|
+
remainder starting at a front ``=ESC=`` the machine does not
|
|
332
|
+
recognise. The batch parser ignores that tail (matching the classic
|
|
333
|
+
"bail on unknown escape" behaviour); the incremental parser carries
|
|
334
|
+
it until more bytes arrive or freezes on it."""
|
|
287
335
|
while data:
|
|
288
|
-
count += 1
|
|
289
336
|
while True:
|
|
290
337
|
if not data.startswith('=ESC='):
|
|
291
338
|
break
|
|
@@ -300,6 +347,10 @@ class VT100Parser:
|
|
|
300
347
|
elif data.startswith('=ESC=[s'):
|
|
301
348
|
data = data[len('=ESC=[s'):]
|
|
302
349
|
continue
|
|
350
|
+
elif data.startswith('=ESC=[2J'):
|
|
351
|
+
data = data[len('=ESC=[2J'):]
|
|
352
|
+
self._reset_screen()
|
|
353
|
+
continue
|
|
303
354
|
break
|
|
304
355
|
|
|
305
356
|
xy_result = None
|
|
@@ -317,7 +368,6 @@ class VT100Parser:
|
|
|
317
368
|
new_y = int(xy_part[6:xy_part.find(';')]) - 1
|
|
318
369
|
# VT100 columns are 1-based; convert to 0-based cursor_x.
|
|
319
370
|
new_x = int(xy_part[xy_part.find(';') + 1: -1]) - 1
|
|
320
|
-
# log.py.info('xy', xy_part, new_x, new_y)
|
|
321
371
|
self._move(new_x, new_y)
|
|
322
372
|
|
|
323
373
|
data = data[len(xy_part):]
|
|
@@ -328,16 +378,14 @@ class VT100Parser:
|
|
|
328
378
|
self._newline()
|
|
329
379
|
continue
|
|
330
380
|
|
|
331
|
-
|
|
381
|
+
if self._cursor_y not in self._screen_length:
|
|
382
|
+
self._screen_length[self._cursor_y] = _cell_len(self._lines[self._cursor_y])
|
|
332
383
|
|
|
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]
|
|
384
|
+
current_line_length = self._screen_length[self._cursor_y]
|
|
337
385
|
replace_mode = False
|
|
338
386
|
if current_line_length < self._cursor_x:
|
|
339
387
|
append_space = ' ' * (self._cursor_x - current_line_length)
|
|
340
|
-
self.
|
|
388
|
+
self._lines[self._cursor_y] += append_space
|
|
341
389
|
elif current_line_length > self._cursor_x:
|
|
342
390
|
replace_mode = True
|
|
343
391
|
|
|
@@ -347,34 +395,143 @@ class VT100Parser:
|
|
|
347
395
|
next_esc = data.find('=ESC=')
|
|
348
396
|
next_esc = 1920 if next_esc < 0 else next_esc
|
|
349
397
|
if next_esc == 0:
|
|
350
|
-
|
|
398
|
+
# A front escape the machine does not consume. Hand the
|
|
399
|
+
# remainder back to the caller instead of dropping it.
|
|
400
|
+
return data
|
|
351
401
|
|
|
352
402
|
current_index = min(next_newline, next_esc)
|
|
353
403
|
|
|
354
404
|
current_data = data[:current_index]
|
|
355
405
|
current_data_length = _cell_len(current_data)
|
|
356
406
|
if replace_mode:
|
|
357
|
-
line = self.
|
|
407
|
+
line = self._lines[self._cursor_y]
|
|
358
408
|
splice_start = _str_pos_at_cells(line, self._cursor_x)
|
|
359
409
|
splice_end = _str_pos_at_cells(line, self._cursor_x + current_data_length)
|
|
360
|
-
self.
|
|
410
|
+
self._lines[self._cursor_y] = line[:splice_start] + current_data + line[splice_end:]
|
|
361
411
|
self._cursor_x += current_data_length
|
|
362
|
-
if self._cursor_x > self.
|
|
363
|
-
self.
|
|
412
|
+
if self._cursor_x > self._screen_length[self._cursor_y]:
|
|
413
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
364
414
|
else:
|
|
365
|
-
self.
|
|
415
|
+
self._lines[self._cursor_y] += current_data
|
|
366
416
|
self._cursor_x += current_data_length
|
|
367
|
-
self.
|
|
417
|
+
self._screen_length[self._cursor_y] = self._cursor_x
|
|
368
418
|
|
|
369
419
|
data = data[current_index:]
|
|
420
|
+
return ''
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class VT100Parser(_VT100Engine):
|
|
424
|
+
"""Batch parser: decode the whole byte buffer and render one screen.
|
|
370
425
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
426
|
+
Kept as the reference implementation and the target-matching path for a
|
|
427
|
+
full (non-streamed) buffer. ``IncrementalScreen`` fed the same bytes in
|
|
428
|
+
any chunking produces a byte-identical screen (see
|
|
429
|
+
tests/test_incremental_parity.py)."""
|
|
375
430
|
|
|
376
|
-
|
|
377
|
-
|
|
431
|
+
def __init__(self, bytes_data, encoding, screen_height: int = 24):
|
|
432
|
+
# https://www.csie.ntu.edu.tw/~r88009/Java/html/Network/vt100.htm
|
|
433
|
+
super().__init__(screen_height)
|
|
434
|
+
data = _preprocess(bytes_data.decode(encoding, errors='replace'))
|
|
435
|
+
# Leftover (a trailing/unknown escape) is intentionally ignored here,
|
|
436
|
+
# matching the classic parser's "bail on unknown escape" behaviour.
|
|
437
|
+
self._run(data)
|
|
438
|
+
self.screen = '\n'.join(self._lines)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# CSI final bytes are 0x40-0x7e; parameter/intermediate bytes are 0x20-0x3f.
|
|
442
|
+
def _escape_complete(seq: str) -> bool:
|
|
443
|
+
"""Is `seq` (which starts with a raw ESC) a complete escape sequence?
|
|
444
|
+
|
|
445
|
+
Used by the streaming parser to decide whether a trailing ESC run might
|
|
446
|
+
still be extended by the next chunk. A CSI (``ESC [``) is complete once a
|
|
447
|
+
final byte (0x40-0x7e) appears; a non-CSI ESC is treated as complete (the
|
|
448
|
+
engine will bail on it, same as the batch parser)."""
|
|
449
|
+
if len(seq) < 2:
|
|
450
|
+
return False # lone ESC — wait for more
|
|
451
|
+
if seq[1] != '[':
|
|
452
|
+
return True # ESC + non-'[': engine bails on it
|
|
453
|
+
for ch in seq[2:]:
|
|
454
|
+
if 0x40 <= ord(ch) <= 0x7e:
|
|
455
|
+
return True
|
|
456
|
+
return False # still inside CSI parameters
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class IncrementalScreen(_VT100Engine):
|
|
460
|
+
"""Streaming VT100 parser: feed byte chunks, read the current screen.
|
|
461
|
+
|
|
462
|
+
Removes the O(N²) re-parse of the receive loop — each byte is decoded and
|
|
463
|
+
stepped through the state machine exactly once, instead of the whole
|
|
464
|
+
accumulated buffer being re-parsed on every arriving chunk.
|
|
465
|
+
|
|
466
|
+
Correctness is anchored to ``VT100Parser``: feeding the same bytes in any
|
|
467
|
+
chunking (down to one byte at a time) yields the identical screen."""
|
|
468
|
+
|
|
469
|
+
def __init__(self, encoding, screen_height: int = 24):
|
|
470
|
+
super().__init__(screen_height)
|
|
471
|
+
self._decoder = codecs.getincrementaldecoder(encoding)(errors='replace')
|
|
472
|
+
self._raw = '' # decoded but not-yet-safe-to-process tail
|
|
473
|
+
self._leftover = '' # preprocessed front escape the engine paused on
|
|
474
|
+
self._bailed = False # hit a complete-but-unknown escape → frozen
|
|
475
|
+
|
|
476
|
+
def _safe_cut(self, s: str) -> int:
|
|
477
|
+
"""Length of the prefix of `s` safe to process now.
|
|
478
|
+
|
|
479
|
+
Holds back the maximal trailing run of spaces and backspaces, and an
|
|
480
|
+
incomplete trailing escape.
|
|
481
|
+
|
|
482
|
+
The ' \\x08' collapse cascades backwards through a run of spaces
|
|
483
|
+
(``' \\x08\\x08'`` → ``' '``), and the ``\\x08`` bytes can arrive in a
|
|
484
|
+
later chunk, so the whole trailing ``[ \\x08]`` run is ambiguous until a
|
|
485
|
+
character that cannot participate in the collapse follows it. Only
|
|
486
|
+
commit up to that terminating character."""
|
|
487
|
+
cut = len(s)
|
|
488
|
+
while cut > 0 and s[cut - 1] in ' \x08':
|
|
489
|
+
cut -= 1
|
|
490
|
+
esc = s.rfind('\x1b')
|
|
491
|
+
if esc != -1 and not _escape_complete(s[esc:]):
|
|
492
|
+
cut = min(cut, esc)
|
|
493
|
+
return cut
|
|
494
|
+
|
|
495
|
+
def feed(self, chunk) -> None:
|
|
496
|
+
if self._bailed:
|
|
497
|
+
return
|
|
498
|
+
if isinstance(chunk, str):
|
|
499
|
+
chunk = chunk.encode('utf-8')
|
|
500
|
+
self._raw += self._decoder.decode(chunk)
|
|
501
|
+
cut = self._safe_cut(self._raw)
|
|
502
|
+
if cut == 0:
|
|
503
|
+
return
|
|
504
|
+
safe = _preprocess(self._raw[:cut])
|
|
505
|
+
self._raw = self._raw[cut:]
|
|
506
|
+
leftover = self._run(self._leftover + safe)
|
|
507
|
+
self._leftover = leftover
|
|
508
|
+
if leftover:
|
|
509
|
+
# _safe_cut only forwards complete escapes, so a surviving front
|
|
510
|
+
# escape is complete but unrecognised — the batch parser bails on
|
|
511
|
+
# it forever, so we freeze to match.
|
|
512
|
+
self._bailed = True
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def screen(self) -> str:
|
|
516
|
+
"""Current screen as a ``screen_height``-line string, including any
|
|
517
|
+
still-buffered tail — matches VT100Parser over all bytes fed so far."""
|
|
518
|
+
dec_state = self._decoder.getstate()
|
|
519
|
+
if not self._raw and not self._leftover and not dec_state[0]:
|
|
520
|
+
# Nothing held and no partial multibyte in the decoder.
|
|
521
|
+
return '\n'.join(self._lines)
|
|
522
|
+
# Tentatively flush the held tail on a snapshot so a partial escape, an
|
|
523
|
+
# unpaired trailing space, or a partial multibyte char is reflected
|
|
524
|
+
# (matching the batch parser's errors='replace') without committing.
|
|
525
|
+
saved = (self._cursor_x, self._cursor_y, list(self._lines),
|
|
526
|
+
dict(self._screen_length))
|
|
527
|
+
try:
|
|
528
|
+
tail = self._raw + self._decoder.decode(b'', final=True)
|
|
529
|
+
self._run(self._leftover + _preprocess(tail))
|
|
530
|
+
return '\n'.join(self._lines)
|
|
531
|
+
finally:
|
|
532
|
+
(self._cursor_x, self._cursor_y, self._lines,
|
|
533
|
+
self._screen_length) = saved
|
|
534
|
+
self._decoder.setstate(dec_state)
|
|
378
535
|
|
|
379
536
|
|
|
380
537
|
if __name__ == '__main__':
|
|
@@ -79,6 +79,7 @@ tests/test_get_user.py
|
|
|
79
79
|
tests/test_get_waterball.py
|
|
80
80
|
tests/test_give_money.py
|
|
81
81
|
tests/test_i18n.py
|
|
82
|
+
tests/test_incremental_parity.py
|
|
82
83
|
tests/test_init.py
|
|
83
84
|
tests/test_logger.py
|
|
84
85
|
tests/test_login_logout.py
|
|
@@ -94,4 +95,5 @@ tests/test_search_user.py
|
|
|
94
95
|
tests/test_service.py
|
|
95
96
|
tests/test_set_board_title.py
|
|
96
97
|
tests/test_set_signature_file.py
|
|
98
|
+
tests/test_uao_codec.py
|
|
97
99
|
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,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Regression tests for the vendored Big5-UAO codec (PyPtt/_uao).
|
|
3
|
+
|
|
4
|
+
`_decode` batches ASCII runs (one C-level decode instead of a chr()/append per
|
|
5
|
+
byte). These tests pin the invariant that the batched path produces exactly the
|
|
6
|
+
same output as a naive byte-by-byte decode, across ASCII runs, character
|
|
7
|
+
boundaries, incremental feeding and UAO-specific characters that stdlib `big5`
|
|
8
|
+
gets wrong or cannot represent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import codecs
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
import PyPtt.screens # noqa: F401 — importing registers the big5uao codec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def naive_decode(data: bytes, errors: str = 'replace') -> str:
|
|
19
|
+
"""Reference: decode one unit at a time, no ASCII batching."""
|
|
20
|
+
from PyPtt._uao.b2u import b2u_table
|
|
21
|
+
get = b2u_table.get
|
|
22
|
+
out = []
|
|
23
|
+
i, n = 0, len(data)
|
|
24
|
+
while i < n:
|
|
25
|
+
c = data[i]
|
|
26
|
+
if c < 0x80:
|
|
27
|
+
out.append(chr(c))
|
|
28
|
+
i += 1
|
|
29
|
+
continue
|
|
30
|
+
pair = get(c * 0x100 + data[i + 1]) if i + 1 < n else None
|
|
31
|
+
if pair is not None:
|
|
32
|
+
out.append(pair)
|
|
33
|
+
i += 2
|
|
34
|
+
continue
|
|
35
|
+
single = get(c)
|
|
36
|
+
if single is not None:
|
|
37
|
+
out.append(single)
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
out.append('�') # errors='replace'
|
|
41
|
+
i += 1
|
|
42
|
+
return ''.join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ASCII_CASES = [
|
|
46
|
+
b'',
|
|
47
|
+
b'hello world 12345',
|
|
48
|
+
b' leading and trailing spaces ',
|
|
49
|
+
b'[Ctrl-P] [d]delete [z]digest (100%)',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize('data', ASCII_CASES)
|
|
54
|
+
def test_ascii_runs_decode_verbatim(data):
|
|
55
|
+
assert data.decode('big5uao') == data.decode('ascii')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_mixed_ascii_and_cjk():
|
|
59
|
+
s = ' 9439 3 1/10 littrabble ◎ [問題] 測試中文標題 discussion'
|
|
60
|
+
assert s.encode('big5uao').decode('big5uao') == s
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_uao_specific_characters_round_trip():
|
|
64
|
+
# Characters where stdlib big5 disagrees with UAO or can't encode them.
|
|
65
|
+
for s in ['‧', '~', '¥', '¢', '﹨', '⊕', '◎', '※', '←', '→', '─']:
|
|
66
|
+
assert s.encode('big5uao').decode('big5uao') == s
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize('errors', ['replace', 'ignore', 'strict'])
|
|
70
|
+
def test_batched_matches_naive_on_valid_content(errors):
|
|
71
|
+
s = '看板《Python》 test 混合 content 123 ‧~ ◎[軟工] tail'
|
|
72
|
+
data = s.encode('big5uao')
|
|
73
|
+
assert data.decode('big5uao', errors) == naive_decode(data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_incremental_byte_by_byte_matches_batch():
|
|
77
|
+
s = '批踢踢 Python 測試 ‧~¥ ◎[軟工] 12345'
|
|
78
|
+
data = s.encode('big5uao')
|
|
79
|
+
dec = codecs.getincrementaldecoder('big5uao')('replace')
|
|
80
|
+
out = ''.join(dec.decode(bytes([b])) for b in data) + dec.decode(b'', final=True)
|
|
81
|
+
assert out == data.decode('big5uao') == s
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_incremental_splits_multibyte_across_chunks():
|
|
85
|
+
data = '中文'.encode('big5uao')
|
|
86
|
+
dec = codecs.getincrementaldecoder('big5uao')('replace')
|
|
87
|
+
# Split inside the first character's byte pair.
|
|
88
|
+
out = dec.decode(data[:1]) + dec.decode(data[1:], final=True)
|
|
89
|
+
assert out == '中文'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_lone_0xff_maps_to_uao_private_use():
|
|
93
|
+
assert b'\xff'.decode('big5uao') == ''
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|