PyPtt 2.2.6__tar.gz → 2.3.0__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 (103) hide show
  1. {pyptt-2.2.6 → pyptt-2.3.0}/PKG-INFO +1 -1
  2. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/PTT.py +0 -69
  3. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_board_info.py +17 -0
  5. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_newest_index.py +4 -0
  6. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post.py +11 -0
  7. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post_index.py +2 -0
  8. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_user.py +3 -1
  9. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_util.py +4 -0
  10. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/__init__.py +19 -7
  11. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/connect_core.py +56 -9
  12. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/data_type.py +0 -21
  13. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/exceptions.py +0 -8
  14. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lang_en_US.py +0 -3
  15. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lang_zh_TW.py +0 -3
  16. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/screens.py +9 -0
  17. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/PKG-INFO +1 -1
  18. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/SOURCES.txt +3 -2
  19. pyptt-2.3.0/tests/test_connect_core_timeout.py +113 -0
  20. pyptt-2.3.0/tests/test_half_dead_connection.py +314 -0
  21. pyptt-2.3.0/tests/test_uao_codec.py +93 -0
  22. pyptt-2.2.6/PyPtt/_api_lottery.py +0 -164
  23. pyptt-2.2.6/tests/test_lottery.py +0 -92
  24. {pyptt-2.2.6 → pyptt-2.3.0}/LICENSE +0 -0
  25. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_bucket.py +0 -0
  26. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_call_status.py +0 -0
  27. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_change_pw.py +0 -0
  28. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_comment.py +0 -0
  29. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_del_post.py +0 -0
  30. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_board_list.py +0 -0
  31. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_bottom_post_list.py +0 -0
  32. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_favourite_board.py +0 -0
  33. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post_list.py +0 -0
  34. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_time.py +0 -0
  35. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_waterball.py +0 -0
  36. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_give_money.py +0 -0
  37. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_has_new_mail.py +0 -0
  38. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_loginout.py +0 -0
  39. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_mail.py +0 -0
  40. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_mark_post.py +0 -0
  41. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_post.py +0 -0
  42. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_reply_post.py +0 -0
  43. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_search_user.py +0 -0
  44. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_set_board_title.py +0 -0
  45. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_set_signature_file.py +0 -0
  46. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/LICENSE +0 -0
  47. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/b2u.py +0 -0
  48. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/u2b.py +0 -0
  49. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/api_server.py +0 -0
  50. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/check_value.py +0 -0
  51. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/command.py +0 -0
  52. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/config.py +0 -0
  53. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/i18n.py +0 -0
  54. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lib_util.py +0 -0
  55. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/log.py +0 -0
  56. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/service.py +0 -0
  57. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/ssl_config.py +0 -0
  58. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/dependency_links.txt +0 -0
  59. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/requires.txt +0 -0
  60. {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/top_level.txt +0 -0
  61. {pyptt-2.2.6 → pyptt-2.3.0}/README.md +0 -0
  62. {pyptt-2.2.6 → pyptt-2.3.0}/pyproject.toml +0 -0
  63. {pyptt-2.2.6 → pyptt-2.3.0}/setup.cfg +0 -0
  64. {pyptt-2.2.6 → pyptt-2.3.0}/setup.py +0 -0
  65. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_bucket.py +0 -0
  66. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_change_pw.py +0 -0
  67. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_comment.py +0 -0
  68. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_del_post.py +0 -0
  69. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_del_post_unit.py +0 -0
  70. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_exceptions.py +0 -0
  71. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_fast_post.py +0 -0
  72. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_board_info.py +0 -0
  73. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_board_list.py +0 -0
  74. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_bottom_post_list.py +0 -0
  75. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_content_formula.py +0 -0
  76. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_favourite_boards.py +0 -0
  77. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_mail.py +0 -0
  78. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_newest_index.py +0 -0
  79. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post.py +0 -0
  80. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_list.py +0 -0
  81. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_list_parser.py +0 -0
  82. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_parser.py +0 -0
  83. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_time.py +0 -0
  84. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_user.py +0 -0
  85. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_waterball.py +0 -0
  86. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_give_money.py +0 -0
  87. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_i18n.py +0 -0
  88. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_incremental_parity.py +0 -0
  89. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_init.py +0 -0
  90. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_logger.py +0 -0
  91. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_login_logout.py +0 -0
  92. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_mail_send_and_del.py +0 -0
  93. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_mark_post.py +0 -0
  94. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_parse_query_post.py +0 -0
  95. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_post.py +0 -0
  96. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_post_anonymous.py +0 -0
  97. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_pyte_parity.py +0 -0
  98. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_reply_post.py +0 -0
  99. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_search_user.py +0 -0
  100. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_service.py +0 -0
  101. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_set_board_title.py +0 -0
  102. {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_set_signature_file.py +0 -0
  103. {pyptt-2.2.6 → pyptt-2.3.0}/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.6
3
+ Version: 2.3.0
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)
@@ -21,7 +21,6 @@ from . import _api_get_user
21
21
  from . import _api_give_money
22
22
  from . import _api_loginout
23
23
  from . import _api_get_waterball
24
- from . import _api_lottery
25
24
  from . import _api_mail
26
25
  from . import _api_mark_post
27
26
  from . import _api_post
@@ -603,74 +602,6 @@ class API:
603
602
 
604
603
  _api_give_money.give_money(self, ptt_id, money, red_bag_title, red_bag_content)
605
604
 
606
- def get_lottery(self, board: str) -> Dict:
607
-
608
- """
609
- 取得看板樂透(彩券)目前的開獎資訊,對應看板內按 `f` 鍵的功能。
610
-
611
- Args:
612
- board (str): 看板名稱。
613
-
614
- Returns:
615
- Dict,樂透資訊,詳見 :ref:`lottery-field`。
616
-
617
- Raises:
618
- RequireLogin: 需要登入。
619
- NoSuchBoard: 看板不存在。
620
- NoSuchLottery: 該看板目前並沒有舉辦樂透,或樂透已封盤/開獎。
621
-
622
- 範例::
623
-
624
- import PyPtt
625
-
626
- ptt_bot = PyPtt.API()
627
- try:
628
- # .. login ..
629
- lottery = ptt_bot.get_lottery(board='Test')
630
- # .. do something ..
631
- finally:
632
- ptt_bot.logout()
633
-
634
- """
635
-
636
- return _api_lottery.get_lottery(self, board)
637
-
638
- def bet_lottery(self, board: str, item: int = 1, amount: int = 1) -> Dict:
639
-
640
- """
641
- 在看板下注樂透(彩券),對應看板內按 `f` 鍵後購買彩券的功能。
642
-
643
- Args:
644
- board (str): 看板名稱。
645
- item (int): 要購買的樂透選項編號,範圍 1~8(依看板實際選項數而定)。
646
- amount (int): 購買張數,範圍 1~9999,並受限於帳戶內的 Ptt 幣餘額。
647
-
648
- Returns:
649
- Dict,下注結果,詳見 :ref:`lottery-bet-field`。
650
-
651
- Raises:
652
- RequireLogin: 需要登入。
653
- UnregisteredUser: 未註冊使用者。
654
- NoSuchBoard: 看板不存在。
655
- NoSuchLottery: 該看板目前並沒有舉辦樂透,或樂透已封盤/開獎。
656
- NoMoney: 餘額不足。
657
-
658
- 範例::
659
-
660
- import PyPtt
661
-
662
- ptt_bot = PyPtt.API()
663
- try:
664
- # .. login ..
665
- result = ptt_bot.bet_lottery(board='Test', item=1, amount=10)
666
- # .. do something ..
667
- finally:
668
- ptt_bot.logout()
669
-
670
- """
671
-
672
- return _api_lottery.bet_lottery(self, board, item, amount)
673
-
674
605
  def mail(self, ptt_id: str, title: str, content: str, sign_file: [int | str] = 0,
675
606
  backup: bool = True) -> None:
676
607
 
@@ -1,4 +1,4 @@
1
- __version__ = '2.2.6'
1
+ __version__ = '2.3.0'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -27,6 +27,11 @@ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -
27
27
 
28
28
  _api_util.goto_board(api, board, refresh=True)
29
29
 
30
+ # last_timeout_was_silent is only meaningful right after a send(), and
31
+ # goto_board()'s last action is one. Nothing below sends until the 'i'
32
+ # further down, so the flag stays valid for the whole parse block: a
33
+ # totally silent timeout means goto_board never redrew and we are about
34
+ # to parse a stale screen -- a half-dead connection, not a missing board.
30
35
  ori_screen = api.connect_core.get_screen_queue()[-1]
31
36
  # print(ori_screen)
32
37
 
@@ -41,16 +46,22 @@ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -
41
46
  break
42
47
 
43
48
  if online_user_line is None:
49
+ if api.connect_core.last_timeout_was_silent:
50
+ raise exceptions.ConnectionClosed()
44
51
  raise exceptions.NoSuchBoard(api.config, board)
45
52
 
46
53
  if '[靜]' in online_user_line:
47
54
  online_user = 0
48
55
  else:
49
56
  if '日 期' not in online_user_line or '作 者' not in online_user_line:
57
+ if api.connect_core.last_timeout_was_silent:
58
+ raise exceptions.ConnectionClosed()
50
59
  raise exceptions.NoSuchBoard(api.config, board)
51
60
  pattern = re.compile(r'[\d]+')
52
61
  r = pattern.search(online_user_line)
53
62
  if r is None:
63
+ if api.connect_core.last_timeout_was_silent:
64
+ raise exceptions.ConnectionClosed()
54
65
  raise exceptions.NoSuchBoard(api.config, board)
55
66
  # 減一是把自己本身拿掉
56
67
  online_user = int(r.group(0)) - 1
@@ -66,6 +77,8 @@ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -
66
77
  'i',
67
78
  target_list)
68
79
 
80
+ # Same flag window as above: the 'i' send() right before is the last one
81
+ # until the get_post_kind block, so the flag still describes it here.
69
82
  ori_screen = api.connect_core.get_screen_queue()[-1]
70
83
  # print(ori_screen)
71
84
 
@@ -76,10 +89,14 @@ def get_board_info(api, board: str, get_post_kind: bool, call_by_others: bool) -
76
89
  boardname = r.group(0)[1:-5].strip()
77
90
 
78
91
  if boardname is None:
92
+ if api.connect_core.last_timeout_was_silent:
93
+ raise exceptions.ConnectionClosed()
79
94
  raise exceptions.NoSuchBoard(api.config, board)
80
95
  logger.debug('看板名稱', boardname, board)
81
96
 
82
97
  if boardname.lower() != board.lower():
98
+ if api.connect_core.last_timeout_was_silent:
99
+ raise exceptions.ConnectionClosed()
83
100
  raise exceptions.NoSuchBoard(api.config, board)
84
101
 
85
102
  chinese_des = ''
@@ -104,6 +104,8 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
104
104
 
105
105
  index = api.connect_core.send(cmd, target_list)
106
106
  if index < 0:
107
+ if api.connect_core.last_timeout_was_silent:
108
+ raise exceptions.ConnectionClosed()
107
109
  raise exceptions.NoSuchBoard(api.config, board)
108
110
 
109
111
  if index == 0:
@@ -127,6 +129,8 @@ def get_newest_index(api, index_type: data_type.NewIndex, board: Optional[str] =
127
129
 
128
130
  index = api.connect_core.send(cmd, target_list)
129
131
  if index < 0:
132
+ if api.connect_core.last_timeout_was_silent:
133
+ raise exceptions.ConnectionClosed()
130
134
  raise exceptions.NoSuchBoard(api.config, board)
131
135
 
132
136
  if index == 0:
@@ -156,6 +156,17 @@ def _get_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
156
156
  }
157
157
 
158
158
  if index < 0 or index == 1:
159
+ # Known gap (intentionally not fixed here): a connection that dies
160
+ # mid-redraw (bytes received, so last_timeout_was_silent is False)
161
+ # leaves a stale listing screen with a cursor line in RDQ, and
162
+ # _parse_deleted_post() will happily parse that into a normal-looking
163
+ # PostStatus.DELETED_BY_AUTHOR dict -- a false "deleted" report that
164
+ # this flag cannot catch. Fixing that needs dropping `index < 0` as a
165
+ # deleted-post signal outright, which first needs verifying what a
166
+ # real deleted post's Q-response looks like. Only the fully-silent
167
+ # (zero bytes received) timeout is handled below.
168
+ if index < 0 and api.connect_core.last_timeout_was_silent:
169
+ raise exceptions.ConnectionClosed()
159
170
  return _parse_deleted_post(api, post, board, last_screen)
160
171
 
161
172
  # index == 0: QueryPost screen matched
@@ -35,6 +35,8 @@ def get_post_index(api, board: str, aid: str) -> int:
35
35
  ori_screen = api.connect_core.get_screen_queue()[-1]
36
36
  if index < 0:
37
37
  # print(OriScreen)
38
+ if api.connect_core.last_timeout_was_silent:
39
+ raise exceptions.ConnectionClosed()
38
40
  raise exceptions.NoSuchBoard(api.config, board)
39
41
 
40
42
  # if index == 5:
@@ -59,9 +59,11 @@ def get_user(api, ptt_id: str) -> Dict:
59
59
  ]
60
60
 
61
61
  index = api.connect_core.send(cmd, target_list)
62
- ori_screen = api.connect_core.get_screen_queue()[-1]
63
62
  if index != 0:
63
+ if api.connect_core.last_timeout_was_silent:
64
+ raise exceptions.ConnectionClosed()
64
65
  raise exceptions.NoSuchUser(ptt_id)
66
+ ori_screen = api.connect_core.get_screen_queue()[-1]
65
67
  # PTT1
66
68
  # 《ID暱稱》CodingMan (專業程式 BUG 製造機)《經濟狀況》小康 ($73866)
67
69
  # 《登入次數》1118 次 (同天內只計一次) 《有效文章》15 篇 (退:0)
@@ -448,12 +448,16 @@ def goto_board(api, board: str, refresh: bool = False, end: bool = False) -> Non
448
448
  # 但某些看板會卡在進版動畫中,但沒有顯示任意鍵繼續或互動是動畫,所以當 index == -1 (表示找不到標的 timeout 了)
449
449
  # 可以嘗試修改 cmd_list
450
450
  index = api.connect_core.send(cmd, target_list, refresh=current_refresh)
451
+ if index == -1 and api.connect_core.last_timeout_was_silent:
452
+ raise exceptions.ConnectionClosed()
451
453
 
452
454
  # index == 4 表示偵測到 MainMenu_Exiting,代表導航到看板失敗
453
455
  # 可能是暫時性的問題(閒置太久、連線狀態改變等),重試一次
454
456
  if index == 4:
455
457
  log.logger.debug('goto_board', board, 'failed, retrying')
456
458
  index = api.connect_core.send(cmd, target_list, refresh=True)
459
+ if index == -1 and api.connect_core.last_timeout_was_silent:
460
+ raise exceptions.ConnectionClosed()
457
461
  if index == 4:
458
462
  raise exceptions.NoSuchBoard(api.config, board)
459
463
 
@@ -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])
@@ -162,6 +162,7 @@ class API(object):
162
162
  self._loop = None
163
163
  self._stream_parsers = {}
164
164
  self._ssl_context = ssl_init(self.config.verify_ssl)
165
+ self.last_timeout_was_silent = False
165
166
 
166
167
  def _get_event_loop(self):
167
168
  if self._loop and not self._loop.is_closed():
@@ -322,6 +323,7 @@ class API(object):
322
323
  is_secret = secret
323
324
  break_detect_after_send = False
324
325
  use_too_many_res = False
326
+ received_any_byte = False
325
327
 
326
328
  while True:
327
329
  if refresh and msg and not msg.endswith(command.refresh):
@@ -350,11 +352,11 @@ class API(object):
350
352
  return -1
351
353
 
352
354
  msg = ''
353
- receive_data_buffer = bytes()
354
355
  # Fresh incremental parsers for this screen sequence. Each arriving
355
356
  # chunk is fed once (per encoding) instead of re-parsing the whole
356
357
  # accumulated buffer every time — turning the receive loop from
357
- # O(N²) into O(N).
358
+ # O(N²) into O(N). The parsers also hold the rendered screen, so no
359
+ # separate raw byte buffer is accumulated.
358
360
  self._stream_parsers = {}
359
361
  start_time = time.time()
360
362
  find_target = False
@@ -369,10 +371,10 @@ class API(object):
369
371
  if use_too_many_res:
370
372
  raise exceptions.UseTooManyResources()
371
373
  raise exceptions.ConnectionClosed()
374
+ received_any_byte = True
372
375
 
373
376
  if isinstance(data_chunk, str):
374
377
  data_chunk = data_chunk.encode('utf-8')
375
- receive_data_buffer += data_chunk
376
378
 
377
379
  screen = self._stream_screen(self.current_encoding, data_chunk)
378
380
  screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
@@ -396,12 +398,33 @@ class API(object):
396
398
  if find_target:
397
399
  break
398
400
  except TimeoutError:
399
- if len(receive_data_buffer) > 0:
400
- vt100_p = screens.VT100Parser(receive_data_buffer, self.current_encoding, self.config.screen_height)
401
- screens.show(self.config, vt100_p.screen)
402
- self._RDQ.add(vt100_p.screen)
401
+ # The incremental parser for the current encoding already holds
402
+ # the fully-rendered screen for every byte received this round
403
+ # (byte-identical to a fresh VT100Parser over the whole buffer —
404
+ # see tests/test_incremental_parity.py), so reuse it instead of
405
+ # re-decoding and re-parsing the entire buffer from scratch.
406
+ parser = self._stream_parsers.get(self.current_encoding)
407
+ if parser is not None:
408
+ screen = parser.screen
409
+ if len(screen) > 0:
410
+ screens.show(self.config, screen)
411
+ self._RDQ.add(screen)
403
412
  if use_too_many_res:
404
413
  raise exceptions.UseTooManyResources()
414
+ # ponytail: zero bytes received this send() is NOT proof the
415
+ # connection is dead — pttbbs silently drops invalid keys
416
+ # without redrawing (measured: 5s of zero bytes on the lottery
417
+ # menu after sending a bad key, then the very next `q` exits
418
+ # cleanly in 0.00s, proving the connection was alive the whole
419
+ # time). So this is a hint for the caller, not a death
420
+ # verdict: record whether this round was totally silent and
421
+ # let the caller (e.g. _api_get_user.py) decide what a
422
+ # timeout with no bytes at all should mean for it. Counted
423
+ # per-send, not per-round, on purpose: a multi-round send()
424
+ # that goes silent midway still returns -1, which keeps
425
+ # _api_del_post.py's `timed_out = result == -1` reporting
426
+ # intact ("delete succeeded, but ...") on an irreversible op.
427
+ self.last_timeout_was_silent = not received_any_byte
405
428
  return -1
406
429
 
407
430
  if target_index != -1:
@@ -422,6 +445,8 @@ class API(object):
422
445
  if self._UseTooManyResources not in target_list:
423
446
  target_list = target_list + [self._UseTooManyResources]
424
447
 
448
+ self.last_timeout_was_silent = False
449
+
425
450
  if self.config.connect_mode == data_type.ConnectMode.TELNET:
426
451
  # Original Telnet logic remains, as it doesn't use asyncio
427
452
  if screen_timeout == 0:
@@ -450,7 +475,6 @@ class API(object):
450
475
  if break_detect_after_send:
451
476
  return -1
452
477
  msg = ''
453
- receive_data_buffer = bytes()
454
478
  self._stream_parsers = {}
455
479
  start_time = time.time()
456
480
  mid_time = time.time()
@@ -458,8 +482,15 @@ class API(object):
458
482
  try:
459
483
  data = self._core.read_very_eager()
460
484
  except EOFError:
485
+ # ponytail: known gap — write EOF above raises
486
+ # ConnectionClosed, read EOF here returns -1, so a dead
487
+ # Telnet link can still surface as NoSuchUser/NoSuchBoard
488
+ # upstream (same bug the websocket path was fixed for).
489
+ # Left alone deliberately: raising here carries the
490
+ # _api_del_post.py:351 `timed_out = result == -1`
491
+ # blast radius and there is no Telnet test harness to
492
+ # verify against. Fix when Telnet gets one.
461
493
  return -1
462
- receive_data_buffer += data
463
494
  screen = self._stream_screen(self.current_encoding, data)
464
495
  screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
465
496
  self._decode_screen(screen, start_time, target_list, is_secret, refresh, msg)
@@ -504,6 +535,22 @@ class API(object):
504
535
  loop.run_until_complete(asyncio.wait_for(self._core.close(), timeout=2.0))
505
536
  except (asyncio.TimeoutError, RuntimeError):
506
537
  pass
538
+ # 若 close 逾時,websocket 的背景 task(keepalive_ping/transfer_data/
539
+ # close_connection)會殘留 pending;主動取消並跑完,再關閉 loop,否則 loop 被 GC 時
540
+ # 會噴 "Task was destroyed but it is pending" 與 "no running event loop"。
541
+ if self._loop is not None and not self._loop.is_closed():
542
+ try:
543
+ pending = asyncio.all_tasks(self._loop)
544
+ for task in pending:
545
+ task.cancel()
546
+ if pending:
547
+ self._loop.run_until_complete(
548
+ asyncio.gather(*pending, return_exceptions=True))
549
+ self._loop.run_until_complete(self._loop.shutdown_asyncgens())
550
+ except RuntimeError:
551
+ pass
552
+ finally:
553
+ self._loop.close()
507
554
  else:
508
555
  self._core.close()
509
556
 
@@ -219,27 +219,6 @@ class BoardField(AutoStrEnum):
219
219
  post_kind_list = auto()
220
220
 
221
221
 
222
- class LotteryField(AutoStrEnum):
223
- board = auto()
224
- price = auto()
225
- total = auto()
226
- options = auto()
227
-
228
-
229
- class LotteryOptionField(AutoStrEnum):
230
- index = auto()
231
- name = auto()
232
- sold = auto()
233
-
234
-
235
- class LotteryBetField(AutoStrEnum):
236
- board = auto()
237
- item = auto()
238
- name = auto()
239
- amount = auto()
240
- cost = auto()
241
-
242
-
243
222
  class Compare(AutoStrEnum):
244
223
  BIGGER = auto()
245
224
  SAME = auto()
@@ -215,14 +215,6 @@ class NoSuchPost(Error):
215
215
  return self.message
216
216
 
217
217
 
218
- class NoSuchLottery(Error):
219
- def __init__(self, board):
220
- self.message = i18n.no_such_lottery + ': ' + board
221
-
222
- def __str__(self):
223
- return self.message
224
-
225
-
226
218
  class CanNotUseSearchPostCode(Error):
227
219
  """
228
220
  此狀態下無法使用搜尋文章代碼(AID)功能
@@ -5,7 +5,6 @@ string_data = {
5
5
  "author": "Author",
6
6
  "bad_post": "bad post",
7
7
  "bad_post_not_recorded": "the post was deleted, but the bad-post record was not made",
8
- "bet_lottery": "bet lottery _target0_",
9
8
  "board": "Board",
10
9
  "can_not_use_search_post_code_f": "This status can not use the search PostField code function",
11
10
  "catch_bottom_post": "Catch bottom post",
@@ -32,7 +31,6 @@ string_data = {
32
31
  "find_newest_index": "Find newest index",
33
32
  "get_board_info": "Get board info _target0_",
34
33
  "get_favourite_board_list": "Query favourite board list",
35
- "get_lottery": "Get lottery info _target0_",
36
34
  "get_mail": "Get mail",
37
35
  "get_waterball": "Get waterball records",
38
36
  "give_money_to": "give _target0_ _target1_ P coins",
@@ -78,7 +76,6 @@ string_data = {
78
76
  "no_response": "This post has been closed and marked, no response",
79
77
  "no_search_result": "No Search Result",
80
78
  "no_such_board": "No such board",
81
- "no_such_lottery": "No lottery is currently running, or betting is closed",
82
79
  "no_such_mail": "No such mail",
83
80
  "no_such_post": "In _target0_, the post code is not EXISTS _target1_",
84
81
  "no_such_user": "No such user",
@@ -5,7 +5,6 @@ string_data = {
5
5
  "author": "作者",
6
6
  "bad_post": "劣文",
7
7
  "bad_post_not_recorded": "文章已刪除,但退文(惡退)未成功記錄",
8
- "bet_lottery": "下注樂透 _target0_",
9
8
  "board": "看板",
10
9
  "can_not_use_search_post_code_f": "此狀態下無法使用搜尋文章代碼(AID)功能",
11
10
  "catch_bottom_post": "取得置底文章",
@@ -32,7 +31,6 @@ string_data = {
32
31
  "find_newest_index": "找到最新編號",
33
32
  "get_board_info": "取得看板資訊 _target0_",
34
33
  "get_favourite_board_list": "取得我的最愛",
35
- "get_lottery": "取得樂透資訊 _target0_",
36
34
  "get_mail": "取得信件",
37
35
  "get_waterball": "取得水球紀錄",
38
36
  "give_money_to": "給 _target0_ _target1_ P 幣",
@@ -78,7 +76,6 @@ string_data = {
78
76
  "no_response": "很抱歉, 此文章已結案並標記, 不得回應",
79
77
  "no_search_result": "沒有搜尋結果",
80
78
  "no_such_board": "無該看板",
81
- "no_such_lottery": "目前並沒有舉辦樂透或已封盤",
82
79
  "no_such_mail": "無此信件",
83
80
  "no_such_post": "_target0_ 板找不到這個文章代碼 _target1_",
84
81
  "no_such_user": "無該使用者",
@@ -26,6 +26,8 @@ def _cell_width(ch: str) -> int:
26
26
  Ambiguous includes glyphs like ※, ←, →, ◎ that PTT's protocol treats
27
27
  as full-width when positioning the cursor.
28
28
  """
29
+ if ch.isascii(): # every ASCII char is a single narrow cell
30
+ return 1
29
31
  w = _cell_width_cache.get(ch)
30
32
  if w is None:
31
33
  w = 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
@@ -34,6 +36,11 @@ def _cell_width(ch: str) -> int:
34
36
 
35
37
 
36
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)
37
44
  return sum(_cell_width(c) for c in text)
38
45
 
39
46
 
@@ -45,6 +52,8 @@ def _str_pos_at_cells(line: str, cells: int) -> int:
45
52
  """
46
53
  if cells <= 0:
47
54
  return 0
55
+ if line.isascii(): # 1 cell per char → cell offset == string index
56
+ return min(cells, len(line))
48
57
  consumed = 0
49
58
  for i, ch in enumerate(line):
50
59
  if consumed >= cells:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.2.6
3
+ Version: 2.3.0
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)
@@ -23,7 +23,6 @@ PyPtt/_api_get_waterball.py
23
23
  PyPtt/_api_give_money.py
24
24
  PyPtt/_api_has_new_mail.py
25
25
  PyPtt/_api_loginout.py
26
- PyPtt/_api_lottery.py
27
26
  PyPtt/_api_mail.py
28
27
  PyPtt/_api_mark_post.py
29
28
  PyPtt/_api_post.py
@@ -59,6 +58,7 @@ PyPtt/_uao/u2b.py
59
58
  tests/test_bucket.py
60
59
  tests/test_change_pw.py
61
60
  tests/test_comment.py
61
+ tests/test_connect_core_timeout.py
62
62
  tests/test_del_post.py
63
63
  tests/test_del_post_unit.py
64
64
  tests/test_exceptions.py
@@ -78,12 +78,12 @@ tests/test_get_time.py
78
78
  tests/test_get_user.py
79
79
  tests/test_get_waterball.py
80
80
  tests/test_give_money.py
81
+ tests/test_half_dead_connection.py
81
82
  tests/test_i18n.py
82
83
  tests/test_incremental_parity.py
83
84
  tests/test_init.py
84
85
  tests/test_logger.py
85
86
  tests/test_login_logout.py
86
- tests/test_lottery.py
87
87
  tests/test_mail_send_and_del.py
88
88
  tests/test_mark_post.py
89
89
  tests/test_parse_query_post.py
@@ -95,4 +95,5 @@ tests/test_search_user.py
95
95
  tests/test_service.py
96
96
  tests/test_set_board_title.py
97
97
  tests/test_set_signature_file.py
98
+ tests/test_uao_codec.py
98
99
  tests/test_vt100_parser.py