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.
- {pyptt-2.2.6 → pyptt-2.3.0}/PKG-INFO +1 -1
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/PTT.py +0 -69
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/__init__.py +1 -1
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_board_info.py +17 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_newest_index.py +4 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post.py +11 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post_index.py +2 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_user.py +3 -1
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_util.py +4 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/__init__.py +19 -7
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/connect_core.py +56 -9
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/data_type.py +0 -21
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/exceptions.py +0 -8
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lang_en_US.py +0 -3
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lang_zh_TW.py +0 -3
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/screens.py +9 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/SOURCES.txt +3 -2
- pyptt-2.3.0/tests/test_connect_core_timeout.py +113 -0
- pyptt-2.3.0/tests/test_half_dead_connection.py +314 -0
- pyptt-2.3.0/tests/test_uao_codec.py +93 -0
- pyptt-2.2.6/PyPtt/_api_lottery.py +0 -164
- pyptt-2.2.6/tests/test_lottery.py +0 -92
- {pyptt-2.2.6 → pyptt-2.3.0}/LICENSE +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_api_set_signature_file.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/LICENSE +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/b2u.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/_uao/u2b.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/api_server.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/check_value.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/command.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/config.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/i18n.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/lib_util.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/log.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/service.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt/ssl_config.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/README.md +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/pyproject.toml +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/setup.cfg +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/setup.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_bucket.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_change_pw.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_comment.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_del_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_del_post_unit.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_exceptions.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_fast_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_board_info.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_board_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_mail.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_list.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_time.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_user.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_get_waterball.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_give_money.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_i18n.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_incremental_parity.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_init.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_logger.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_login_logout.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_mark_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_post_anonymous.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_reply_post.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_search_user.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_service.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_set_board_title.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_set_signature_file.py +0 -0
- {pyptt-2.2.6 → pyptt-2.3.0}/tests/test_vt100_parser.py +0 -0
|
@@ -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
|
|
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Vendored from PttCodingMan/pyUAO, a fork of eight04/pyUAO, to drop the
|
|
4
|
+
external `uao` PyPI dependency (unmaintained upstream) and pick up this
|
|
5
|
+
fork's correctness fixes and incremental/stream codec support.
|
|
6
|
+
|
|
7
|
+
Locally modified: `_decode` batches ASCII runs with a single C-level decode
|
|
8
|
+
instead of one chr()/append per byte. PTT screens are ~70% ASCII bytes even
|
|
9
|
+
in Big5, so this speeds up the receive path measurably while producing
|
|
10
|
+
byte-identical output (the byte→unicode table is unchanged). Python's stdlib
|
|
11
|
+
`big5` codec is *not* a usable fast path — it disagrees with the UAO table on
|
|
12
|
+
~260 sequences (e.g. 0xa1 0x45 → '•' vs '‧') and can't decode UAO's
|
|
13
|
+
extensions at all.
|
|
8
14
|
|
|
9
15
|
Source: https://github.com/PttCodingMan/pyUAO
|
|
10
16
|
Commit: 9d9a8bf59dbe60800d214b66ce972bc2ddf8da89 (v0.3.0)
|
|
@@ -89,8 +95,14 @@ def _decode(input, errors, final):
|
|
|
89
95
|
while i < n:
|
|
90
96
|
c = input[i]
|
|
91
97
|
if c < 0x80:
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
# At a character boundary every byte < 0x80 is a standalone ASCII
|
|
99
|
+
# char, so decode the whole run at once. latin-1 maps 0x00-0x7f
|
|
100
|
+
# identically to ASCII and never raises.
|
|
101
|
+
j = i + 1
|
|
102
|
+
while j < n and input[j] < 0x80:
|
|
103
|
+
j += 1
|
|
104
|
+
append(input[i:j].decode('latin-1'))
|
|
105
|
+
i = j
|
|
94
106
|
continue
|
|
95
107
|
if i + 1 < n:
|
|
96
108
|
u = _b2u_get(c * 0x100 + input[i + 1])
|
|
@@ -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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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:
|
|
@@ -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
|