PyPtt 2.2.7__tar.gz → 2.3.1__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 (104) hide show
  1. {pyptt-2.2.7 → pyptt-2.3.1}/PKG-INFO +1 -1
  2. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/PTT.py +3 -71
  3. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/__init__.py +1 -1
  4. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_board_info.py +17 -0
  5. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_newest_index.py +4 -0
  6. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_post.py +11 -0
  7. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_post_index.py +2 -0
  8. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_user.py +3 -1
  9. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_mail.py +2 -0
  10. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_reply_post.py +5 -2
  11. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_util.py +4 -0
  12. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/connect_core.py +43 -0
  13. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/data_type.py +0 -21
  14. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/exceptions.py +0 -8
  15. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/lang_en_US.py +0 -3
  16. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/lang_zh_TW.py +0 -3
  17. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt.egg-info/PKG-INFO +1 -1
  18. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt.egg-info/SOURCES.txt +3 -2
  19. pyptt-2.3.1/tests/test_backup_param.py +41 -0
  20. pyptt-2.3.1/tests/test_connect_core_timeout.py +113 -0
  21. pyptt-2.3.1/tests/test_half_dead_connection.py +314 -0
  22. pyptt-2.2.7/PyPtt/_api_lottery.py +0 -164
  23. pyptt-2.2.7/tests/test_lottery.py +0 -92
  24. {pyptt-2.2.7 → pyptt-2.3.1}/LICENSE +0 -0
  25. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_bucket.py +0 -0
  26. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_call_status.py +0 -0
  27. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_change_pw.py +0 -0
  28. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_comment.py +0 -0
  29. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_del_post.py +0 -0
  30. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_board_list.py +0 -0
  31. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_bottom_post_list.py +0 -0
  32. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_favourite_board.py +0 -0
  33. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_post_list.py +0 -0
  34. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_time.py +0 -0
  35. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_get_waterball.py +0 -0
  36. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_give_money.py +0 -0
  37. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_has_new_mail.py +0 -0
  38. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_loginout.py +0 -0
  39. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_mark_post.py +0 -0
  40. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_post.py +0 -0
  41. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_search_user.py +0 -0
  42. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_set_board_title.py +0 -0
  43. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_api_set_signature_file.py +0 -0
  44. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_uao/LICENSE +0 -0
  45. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_uao/__init__.py +0 -0
  46. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_uao/b2u.py +0 -0
  47. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/_uao/u2b.py +0 -0
  48. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/api_server.py +0 -0
  49. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/check_value.py +0 -0
  50. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/command.py +0 -0
  51. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/config.py +0 -0
  52. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/i18n.py +0 -0
  53. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/lib_util.py +0 -0
  54. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/log.py +0 -0
  55. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/screens.py +0 -0
  56. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/service.py +0 -0
  57. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt/ssl_config.py +0 -0
  58. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt.egg-info/dependency_links.txt +0 -0
  59. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt.egg-info/requires.txt +0 -0
  60. {pyptt-2.2.7 → pyptt-2.3.1}/PyPtt.egg-info/top_level.txt +0 -0
  61. {pyptt-2.2.7 → pyptt-2.3.1}/README.md +0 -0
  62. {pyptt-2.2.7 → pyptt-2.3.1}/pyproject.toml +0 -0
  63. {pyptt-2.2.7 → pyptt-2.3.1}/setup.cfg +0 -0
  64. {pyptt-2.2.7 → pyptt-2.3.1}/setup.py +0 -0
  65. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_bucket.py +0 -0
  66. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_change_pw.py +0 -0
  67. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_comment.py +0 -0
  68. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_del_post.py +0 -0
  69. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_del_post_unit.py +0 -0
  70. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_exceptions.py +0 -0
  71. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_fast_post.py +0 -0
  72. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_board_info.py +0 -0
  73. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_board_list.py +0 -0
  74. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_bottom_post_list.py +0 -0
  75. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_content_formula.py +0 -0
  76. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_favourite_boards.py +0 -0
  77. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_mail.py +0 -0
  78. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_newest_index.py +0 -0
  79. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_post.py +0 -0
  80. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_post_list.py +0 -0
  81. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_post_list_parser.py +0 -0
  82. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_post_parser.py +0 -0
  83. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_time.py +0 -0
  84. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_user.py +0 -0
  85. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_get_waterball.py +0 -0
  86. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_give_money.py +0 -0
  87. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_i18n.py +0 -0
  88. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_incremental_parity.py +0 -0
  89. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_init.py +0 -0
  90. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_logger.py +0 -0
  91. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_login_logout.py +0 -0
  92. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_mail_send_and_del.py +0 -0
  93. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_mark_post.py +0 -0
  94. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_parse_query_post.py +0 -0
  95. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_post.py +0 -0
  96. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_post_anonymous.py +0 -0
  97. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_pyte_parity.py +0 -0
  98. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_reply_post.py +0 -0
  99. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_search_user.py +0 -0
  100. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_service.py +0 -0
  101. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_set_board_title.py +0 -0
  102. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_set_signature_file.py +0 -0
  103. {pyptt-2.2.7 → pyptt-2.3.1}/tests/test_uao_codec.py +0 -0
  104. {pyptt-2.2.7 → pyptt-2.3.1}/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.7
3
+ Version: 2.3.1
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
 
@@ -734,7 +665,7 @@ class API:
734
665
  return _api_get_board_list.get_board_list(self)
735
666
 
736
667
  def reply_post(self, reply_to: data_type.ReplyTo, board: str, content: str, sign_file: [str | int] = 0,
737
- aid: Optional[str] = None, index: int = 0) -> None:
668
+ aid: Optional[str] = None, index: int = 0, backup: bool = True) -> None:
738
669
 
739
670
  """
740
671
  回覆文章。
@@ -746,6 +677,7 @@ class API:
746
677
  sign_file (str | int): 編號或隨機簽名檔 (x),預設為 **0** (不選)。
747
678
  aid: 文章編號。
748
679
  index: 文章編號。
680
+ backup (bool): 回信給作者時是否自存底稿,預設為 True。僅在回覆類型含站內信時有作用。
749
681
 
750
682
  Returns:
751
683
  None
@@ -772,7 +704,7 @@ class API:
772
704
  參考 :ref:`回覆類型 <reply-to>`、:ref:`取得最新文章編號 <api-get-newest-index>`
773
705
  """
774
706
 
775
- _api_reply_post.reply_post(self, reply_to, board, content, sign_file, aid, index)
707
+ _api_reply_post.reply_post(self, reply_to, board, content, sign_file, aid, index, backup)
776
708
 
777
709
  def set_board_title(self, board: str, new_title: str) -> None:
778
710
  """
@@ -1,4 +1,4 @@
1
- __version__ = '2.2.7'
1
+ __version__ = '2.3.1'
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)
@@ -34,6 +34,8 @@ def mail(api,
34
34
  check_value.check_type(ptt_id, str, 'ptt_id')
35
35
  check_value.check_type(title, str, 'title')
36
36
  check_value.check_type(content, str, 'content')
37
+ # 沒擋型別的話,backup='False' 這種字串是 truthy,會靜靜地存底稿。
38
+ check_value.check_type(backup, bool, 'backup')
37
39
 
38
40
  _api_util.check_user_exist(api, ptt_id)
39
41
 
@@ -10,7 +10,7 @@ from . import log
10
10
 
11
11
 
12
12
  def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_file, post_aid: str,
13
- post_index: int) -> None:
13
+ post_index: int, backup: bool = True) -> None:
14
14
  _api_util.one_thread(api)
15
15
 
16
16
  if not api._is_login:
@@ -21,6 +21,8 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
21
21
 
22
22
  check_value.check_type(board, str, 'board')
23
23
  check_value.check_type(content, str, 'content')
24
+ # 沒擋型別的話,backup='False' 這種字串是 truthy,會靜靜地存底稿。
25
+ check_value.check_type(backup, bool, 'backup')
24
26
  if post_aid is not None:
25
27
  check_value.check_type(post_aid, str, 'PostAID')
26
28
 
@@ -85,7 +87,8 @@ def reply_post(api, reply_to: data_type.ReplyTo, board: str, content: str, sign_
85
87
  connect_core.TargetUnit('請問要引用原文嗎', log_level=log.DEBUG, response='Y' + command.enter),
86
88
  connect_core.TargetUnit('採用原標題[Y/n]?', log_level=log.DEBUG, response='Y' + command.enter),
87
89
  reply_target_unit,
88
- connect_core.TargetUnit('已順利寄出,是否自存底稿', log_level=log.DEBUG, response='Y' + command.enter),
90
+ connect_core.TargetUnit('已順利寄出,是否自存底稿', log_level=log.DEBUG,
91
+ response=('Y' if backup else 'N') + command.enter),
89
92
  ]
90
93
 
91
94
  api.connect_core.send(
@@ -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
 
@@ -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):
@@ -369,6 +371,7 @@ 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')
@@ -408,6 +411,20 @@ class API(object):
408
411
  self._RDQ.add(screen)
409
412
  if use_too_many_res:
410
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
411
428
  return -1
412
429
 
413
430
  if target_index != -1:
@@ -428,6 +445,8 @@ class API(object):
428
445
  if self._UseTooManyResources not in target_list:
429
446
  target_list = target_list + [self._UseTooManyResources]
430
447
 
448
+ self.last_timeout_was_silent = False
449
+
431
450
  if self.config.connect_mode == data_type.ConnectMode.TELNET:
432
451
  # Original Telnet logic remains, as it doesn't use asyncio
433
452
  if screen_timeout == 0:
@@ -463,6 +482,14 @@ class API(object):
463
482
  try:
464
483
  data = self._core.read_very_eager()
465
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.
466
493
  return -1
467
494
  screen = self._stream_screen(self.current_encoding, data)
468
495
  screen, find_target, is_secret, break_detect_after_send, use_too_many_res, msg, target_index = \
@@ -508,6 +535,22 @@ class API(object):
508
535
  loop.run_until_complete(asyncio.wait_for(self._core.close(), timeout=2.0))
509
536
  except (asyncio.TimeoutError, RuntimeError):
510
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()
511
554
  else:
512
555
  self._core.close()
513
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": "無該使用者",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.2.7
3
+ Version: 2.3.1
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
@@ -56,9 +55,11 @@ PyPtt/_uao/LICENSE
56
55
  PyPtt/_uao/__init__.py
57
56
  PyPtt/_uao/b2u.py
58
57
  PyPtt/_uao/u2b.py
58
+ tests/test_backup_param.py
59
59
  tests/test_bucket.py
60
60
  tests/test_change_pw.py
61
61
  tests/test_comment.py
62
+ tests/test_connect_core_timeout.py
62
63
  tests/test_del_post.py
63
64
  tests/test_del_post_unit.py
64
65
  tests/test_exceptions.py
@@ -78,12 +79,12 @@ tests/test_get_time.py
78
79
  tests/test_get_user.py
79
80
  tests/test_get_waterball.py
80
81
  tests/test_give_money.py
82
+ tests/test_half_dead_connection.py
81
83
  tests/test_i18n.py
82
84
  tests/test_incremental_parity.py
83
85
  tests/test_init.py
84
86
  tests/test_logger.py
85
87
  tests/test_login_logout.py
86
- tests/test_lottery.py
87
88
  tests/test_mail_send_and_del.py
88
89
  tests/test_mark_post.py
89
90
  tests/test_parse_query_post.py
@@ -0,0 +1,41 @@
1
+ """
2
+ No-network unit tests for the `backup` parameter of mail / reply_post.
3
+
4
+ Regression guard: `backup` used to skip check_type, so a truthy non-bool
5
+ like the string 'False' silently answered 是否自存底稿 with 'y' and left a
6
+ backup in the sender's mailbox. `reply_post` had no `backup` parameter at
7
+ all and always answered 'Y'.
8
+ """
9
+ import threading
10
+
11
+ import pytest
12
+
13
+ import PyPtt
14
+ from PyPtt import _api_mail, _api_reply_post
15
+
16
+ NON_BOOLS = ['False', 'false', '0', 0, 1, None]
17
+
18
+
19
+ class _StubAPI:
20
+ """Just enough of PyPtt.API to reach the parameter checks."""
21
+ _thread_id = threading.get_ident()
22
+ _is_login = True
23
+ is_registered_user = True
24
+
25
+
26
+ @pytest.mark.parametrize('backup', NON_BOOLS)
27
+ def test_mail_non_bool_backup_raises(backup):
28
+ with pytest.raises(TypeError):
29
+ _api_mail.mail(_StubAPI(), 'someone', 'title', 'content', 0, backup)
30
+
31
+
32
+ @pytest.mark.parametrize('backup', NON_BOOLS)
33
+ def test_reply_post_non_bool_backup_raises(backup):
34
+ with pytest.raises(TypeError):
35
+ _api_reply_post.reply_post(_StubAPI(), PyPtt.ReplyTo.BOARD, 'Test', 'content', 0, None, 1, backup)
36
+
37
+
38
+ def test_reply_post_exposes_backup():
39
+ """回信給作者時的自存底稿以前寫死 'Y',關不掉。"""
40
+ import inspect
41
+ assert inspect.signature(PyPtt.API.reply_post).parameters['backup'].default is True