PyPtt 2.2.5__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.
Files changed (100) hide show
  1. {pyptt-2.2.5 → pyptt-2.2.6}/PKG-INFO +1 -1
  2. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/__init__.py +1 -1
  3. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/connect_core.py +29 -7
  4. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/screens.py +199 -51
  5. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt.egg-info/PKG-INFO +1 -1
  6. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt.egg-info/SOURCES.txt +1 -0
  7. pyptt-2.2.6/tests/test_incremental_parity.py +135 -0
  8. {pyptt-2.2.5 → pyptt-2.2.6}/LICENSE +0 -0
  9. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/PTT.py +0 -0
  10. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_bucket.py +0 -0
  11. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_call_status.py +0 -0
  12. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_change_pw.py +0 -0
  13. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_comment.py +0 -0
  14. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_del_post.py +0 -0
  15. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_board_info.py +0 -0
  16. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_board_list.py +0 -0
  17. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_bottom_post_list.py +0 -0
  18. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_favourite_board.py +0 -0
  19. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_newest_index.py +0 -0
  20. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_post.py +0 -0
  21. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_post_index.py +0 -0
  22. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_post_list.py +0 -0
  23. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_time.py +0 -0
  24. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_user.py +0 -0
  25. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_get_waterball.py +0 -0
  26. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_give_money.py +0 -0
  27. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_has_new_mail.py +0 -0
  28. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_loginout.py +0 -0
  29. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_lottery.py +0 -0
  30. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_mail.py +0 -0
  31. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_mark_post.py +0 -0
  32. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_post.py +0 -0
  33. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_reply_post.py +0 -0
  34. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_search_user.py +0 -0
  35. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_set_board_title.py +0 -0
  36. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_set_signature_file.py +0 -0
  37. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_api_util.py +0 -0
  38. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_uao/LICENSE +0 -0
  39. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_uao/__init__.py +0 -0
  40. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_uao/b2u.py +0 -0
  41. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/_uao/u2b.py +0 -0
  42. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/api_server.py +0 -0
  43. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/check_value.py +0 -0
  44. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/command.py +0 -0
  45. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/config.py +0 -0
  46. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/data_type.py +0 -0
  47. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/exceptions.py +0 -0
  48. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/i18n.py +0 -0
  49. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/lang_en_US.py +0 -0
  50. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/lang_zh_TW.py +0 -0
  51. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/lib_util.py +0 -0
  52. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/log.py +0 -0
  53. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/service.py +0 -0
  54. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt/ssl_config.py +0 -0
  55. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt.egg-info/dependency_links.txt +0 -0
  56. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt.egg-info/requires.txt +0 -0
  57. {pyptt-2.2.5 → pyptt-2.2.6}/PyPtt.egg-info/top_level.txt +0 -0
  58. {pyptt-2.2.5 → pyptt-2.2.6}/README.md +0 -0
  59. {pyptt-2.2.5 → pyptt-2.2.6}/pyproject.toml +0 -0
  60. {pyptt-2.2.5 → pyptt-2.2.6}/setup.cfg +0 -0
  61. {pyptt-2.2.5 → pyptt-2.2.6}/setup.py +0 -0
  62. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_bucket.py +0 -0
  63. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_change_pw.py +0 -0
  64. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_comment.py +0 -0
  65. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_del_post.py +0 -0
  66. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_del_post_unit.py +0 -0
  67. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_exceptions.py +0 -0
  68. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_fast_post.py +0 -0
  69. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_board_info.py +0 -0
  70. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_board_list.py +0 -0
  71. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_bottom_post_list.py +0 -0
  72. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_content_formula.py +0 -0
  73. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_favourite_boards.py +0 -0
  74. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_mail.py +0 -0
  75. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_newest_index.py +0 -0
  76. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_post.py +0 -0
  77. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_post_list.py +0 -0
  78. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_post_list_parser.py +0 -0
  79. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_post_parser.py +0 -0
  80. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_time.py +0 -0
  81. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_user.py +0 -0
  82. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_get_waterball.py +0 -0
  83. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_give_money.py +0 -0
  84. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_i18n.py +0 -0
  85. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_init.py +0 -0
  86. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_logger.py +0 -0
  87. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_login_logout.py +0 -0
  88. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_lottery.py +0 -0
  89. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_mail_send_and_del.py +0 -0
  90. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_mark_post.py +0 -0
  91. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_parse_query_post.py +0 -0
  92. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_post.py +0 -0
  93. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_post_anonymous.py +0 -0
  94. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_pyte_parity.py +0 -0
  95. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_reply_post.py +0 -0
  96. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_search_user.py +0 -0
  97. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_service.py +0 -0
  98. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_set_board_title.py +0 -0
  99. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_set_signature_file.py +0 -0
  100. {pyptt-2.2.5 → pyptt-2.2.6}/tests/test_vt100_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.2.5
3
+ Version: 2.2.6
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -1,4 +1,4 @@
1
- __version__ = '2.2.5'
1
+ __version__ = '2.2.6'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -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, receive_data_buffer, start_time, target_list, is_secret, refresh, msg):
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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:
@@ -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
- return 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
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
- class VT100Parser:
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.screen) - 1)
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.screen) - 1)
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.screen[self._cursor_y], self._cursor_x)
262
- self.screen[self._cursor_y] = self.screen[self._cursor_y][:pos]
263
- self.screen_length[self._cursor_y] = self._cursor_x
264
-
265
- def __init__(self, bytes_data, encoding, screen_height: int = 24):
266
- # self._data = data
267
- # https://www.csie.ntu.edu.tw/~r88009/Java/html/Network/vt100.htm
268
-
269
- self._cursor_x = 0
270
- self._cursor_y = 0
271
- self.screen = [''] * screen_height
272
- self.screen_length = dict()
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
- # print(f'-{data[:1]}-{len(data[:1].encode("big5-uao", "replace"))}')
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
- if self._cursor_y not in self.screen_length:
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.screen[self._cursor_y] += append_space
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
- break
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.screen[self._cursor_y]
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.screen[self._cursor_y] = line[:splice_start] + current_data + line[splice_end:]
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.screen_length[self._cursor_y]:
363
- self.screen_length[self._cursor_y] = self._cursor_x
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.screen[self._cursor_y] += current_data
406
+ self._lines[self._cursor_y] += current_data
366
407
  self._cursor_x += current_data_length
367
- self.screen_length[self._cursor_y] = self._cursor_x
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
- # print('\n'.join(self.screen))
372
- # print('\n'.join(self._screen))
373
- # print('=' * 20)
374
- # print(data)
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
- # print('Spend', count, 'cycle')
377
- self.screen = '\n'.join(self.screen)
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__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.2.5
3
+ Version: 2.2.6
4
4
  Summary: PyPtt github: https://github.com/PyPtt/PyPtt
5
5
  Author-email: CodingMan <pttcodingman@gmail.com>
6
6
  License: GNU Lesser General Public License v3 (LGPLv3)
@@ -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
@@ -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}'
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