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.
Files changed (101) hide show
  1. {pyptt-2.2.5 → pyptt-2.2.7}/PKG-INFO +1 -1
  2. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/__init__.py +1 -1
  3. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/__init__.py +19 -7
  4. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/connect_core.py +41 -15
  5. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/screens.py +208 -51
  6. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/PKG-INFO +1 -1
  7. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/SOURCES.txt +2 -0
  8. pyptt-2.2.7/tests/test_incremental_parity.py +135 -0
  9. pyptt-2.2.7/tests/test_uao_codec.py +93 -0
  10. {pyptt-2.2.5 → pyptt-2.2.7}/LICENSE +0 -0
  11. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/PTT.py +0 -0
  12. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_bucket.py +0 -0
  13. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_call_status.py +0 -0
  14. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_change_pw.py +0 -0
  15. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_comment.py +0 -0
  16. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_del_post.py +0 -0
  17. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_board_info.py +0 -0
  18. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_board_list.py +0 -0
  19. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_bottom_post_list.py +0 -0
  20. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_favourite_board.py +0 -0
  21. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_newest_index.py +0 -0
  22. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post.py +0 -0
  23. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post_index.py +0 -0
  24. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_post_list.py +0 -0
  25. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_time.py +0 -0
  26. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_user.py +0 -0
  27. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_get_waterball.py +0 -0
  28. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_give_money.py +0 -0
  29. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_has_new_mail.py +0 -0
  30. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_loginout.py +0 -0
  31. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_lottery.py +0 -0
  32. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_mail.py +0 -0
  33. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_mark_post.py +0 -0
  34. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_post.py +0 -0
  35. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_reply_post.py +0 -0
  36. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_search_user.py +0 -0
  37. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_set_board_title.py +0 -0
  38. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_set_signature_file.py +0 -0
  39. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_api_util.py +0 -0
  40. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/LICENSE +0 -0
  41. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/b2u.py +0 -0
  42. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/_uao/u2b.py +0 -0
  43. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/api_server.py +0 -0
  44. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/check_value.py +0 -0
  45. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/command.py +0 -0
  46. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/config.py +0 -0
  47. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/data_type.py +0 -0
  48. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/exceptions.py +0 -0
  49. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/i18n.py +0 -0
  50. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lang_en_US.py +0 -0
  51. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lang_zh_TW.py +0 -0
  52. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/lib_util.py +0 -0
  53. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/log.py +0 -0
  54. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/service.py +0 -0
  55. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt/ssl_config.py +0 -0
  56. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/dependency_links.txt +0 -0
  57. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/requires.txt +0 -0
  58. {pyptt-2.2.5 → pyptt-2.2.7}/PyPtt.egg-info/top_level.txt +0 -0
  59. {pyptt-2.2.5 → pyptt-2.2.7}/README.md +0 -0
  60. {pyptt-2.2.5 → pyptt-2.2.7}/pyproject.toml +0 -0
  61. {pyptt-2.2.5 → pyptt-2.2.7}/setup.cfg +0 -0
  62. {pyptt-2.2.5 → pyptt-2.2.7}/setup.py +0 -0
  63. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_bucket.py +0 -0
  64. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_change_pw.py +0 -0
  65. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_comment.py +0 -0
  66. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_del_post.py +0 -0
  67. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_del_post_unit.py +0 -0
  68. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_exceptions.py +0 -0
  69. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_fast_post.py +0 -0
  70. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_board_info.py +0 -0
  71. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_board_list.py +0 -0
  72. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_bottom_post_list.py +0 -0
  73. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_content_formula.py +0 -0
  74. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_favourite_boards.py +0 -0
  75. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_mail.py +0 -0
  76. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_newest_index.py +0 -0
  77. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post.py +0 -0
  78. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_list.py +0 -0
  79. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_list_parser.py +0 -0
  80. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_post_parser.py +0 -0
  81. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_time.py +0 -0
  82. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_user.py +0 -0
  83. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_get_waterball.py +0 -0
  84. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_give_money.py +0 -0
  85. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_i18n.py +0 -0
  86. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_init.py +0 -0
  87. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_logger.py +0 -0
  88. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_login_logout.py +0 -0
  89. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_lottery.py +0 -0
  90. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_mail_send_and_del.py +0 -0
  91. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_mark_post.py +0 -0
  92. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_parse_query_post.py +0 -0
  93. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_post.py +0 -0
  94. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_post_anonymous.py +0 -0
  95. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_pyte_parity.py +0 -0
  96. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_reply_post.py +0 -0
  97. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_search_user.py +0 -0
  98. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_service.py +0 -0
  99. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_set_board_title.py +0 -0
  100. {pyptt-2.2.5 → pyptt-2.2.7}/tests/test_set_signature_file.py +0 -0
  101. {pyptt-2.2.5 → pyptt-2.2.7}/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.7
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.7'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -1,10 +1,16 @@
1
1
  """Vendored Big5-UAO codec.
2
2
 
3
- Vendored (unmodified) from PttCodingMan/pyUAO, a fork of eight04/pyUAO,
4
- to drop the external `uao` PyPI dependency (unmaintained upstream) and
5
- pick up this fork's correctness fixes and incremental/stream codec
6
- support. Vendoring is for dependency control, not performance: PyPtt is
7
- I/O bound, so codec speed is not the point here.
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
- append(chr(c))
93
- i += 1
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, 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
@@ -341,7 +350,12 @@ class API(object):
341
350
  return -1
342
351
 
343
352
  msg = ''
344
- receive_data_buffer = bytes()
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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
- if len(receive_data_buffer) > 0:
380
- vt100_p = screens.VT100Parser(receive_data_buffer, self.current_encoding, self.config.screen_height)
381
- screens.show(self.config, vt100_p.screen)
382
- self._RDQ.add(vt100_p.screen)
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
- receive_data_buffer = bytes()
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
- receive_data_buffer += data
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(receive_data_buffer, start_time, target_list, is_secret, refresh, msg)
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
- return 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
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
- class VT100Parser:
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.screen) - 1)
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.screen) - 1)
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.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
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
- # print(f'-{data[:1]}-{len(data[:1].encode("big5-uao", "replace"))}')
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
- 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]
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.screen[self._cursor_y] += append_space
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
- break
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.screen[self._cursor_y]
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.screen[self._cursor_y] = line[:splice_start] + current_data + line[splice_end:]
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.screen_length[self._cursor_y]:
363
- self.screen_length[self._cursor_y] = self._cursor_x
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.screen[self._cursor_y] += current_data
415
+ self._lines[self._cursor_y] += current_data
366
416
  self._cursor_x += current_data_length
367
- self.screen_length[self._cursor_y] = self._cursor_x
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
- # print('\n'.join(self.screen))
372
- # print('\n'.join(self._screen))
373
- # print('=' * 20)
374
- # print(data)
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
- # print('Spend', count, 'cycle')
377
- self.screen = '\n'.join(self.screen)
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__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.2.5
3
+ Version: 2.2.7
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
@@ -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