PyPtt 2.0.10__tar.gz → 2.1.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 (88) hide show
  1. {pyptt-2.0.10 → pyptt-2.1.0}/PKG-INFO +2 -1
  2. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/__init__.py +1 -1
  3. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_post_list.py +7 -3
  4. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/screens.py +51 -18
  5. pyptt-2.1.0/PyPtt/ssl_config.py +24 -0
  6. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt.egg-info/PKG-INFO +2 -1
  7. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt.egg-info/SOURCES.txt +9 -1
  8. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt.egg-info/requires.txt +1 -0
  9. {pyptt-2.0.10 → pyptt-2.1.0}/pyproject.toml +1 -0
  10. pyptt-2.1.0/tests/test_bucket.py +52 -0
  11. pyptt-2.1.0/tests/test_fast_post.py +56 -0
  12. pyptt-2.1.0/tests/test_get_post_list_parser.py +176 -0
  13. pyptt-2.1.0/tests/test_mail_send_and_del.py +55 -0
  14. pyptt-2.1.0/tests/test_mark_post.py +76 -0
  15. pyptt-2.1.0/tests/test_pyte_parity.py +154 -0
  16. pyptt-2.1.0/tests/test_set_board_title.py +49 -0
  17. pyptt-2.1.0/tests/test_vt100_parser.py +447 -0
  18. pyptt-2.0.10/PyPtt/ssl_config.py +0 -24
  19. {pyptt-2.0.10 → pyptt-2.1.0}/LICENSE +0 -0
  20. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/PTT.py +0 -0
  21. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_bucket.py +0 -0
  22. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_call_status.py +0 -0
  23. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_change_pw.py +0 -0
  24. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_comment.py +0 -0
  25. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_del_post.py +0 -0
  26. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_board_info.py +0 -0
  27. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_board_list.py +0 -0
  28. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_bottom_post_list.py +0 -0
  29. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_favourite_board.py +0 -0
  30. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_newest_index.py +0 -0
  31. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_post.py +0 -0
  32. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_post_index.py +0 -0
  33. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_time.py +0 -0
  34. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_user.py +0 -0
  35. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_get_waterball.py +0 -0
  36. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_give_money.py +0 -0
  37. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_has_new_mail.py +0 -0
  38. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_loginout.py +0 -0
  39. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_mail.py +0 -0
  40. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_mark_post.py +0 -0
  41. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_post.py +0 -0
  42. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_reply_post.py +0 -0
  43. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_search_user.py +0 -0
  44. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_set_board_title.py +0 -0
  45. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/_api_util.py +0 -0
  46. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/api_server.py +0 -0
  47. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/check_value.py +0 -0
  48. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/command.py +0 -0
  49. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/config.py +0 -0
  50. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/connect_core.py +0 -0
  51. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/data_type.py +0 -0
  52. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/exceptions.py +0 -0
  53. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/i18n.py +0 -0
  54. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/lang_en_US.py +0 -0
  55. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/lang_zh_TW.py +0 -0
  56. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/lib_util.py +0 -0
  57. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/log.py +0 -0
  58. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt/service.py +0 -0
  59. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt.egg-info/dependency_links.txt +0 -0
  60. {pyptt-2.0.10 → pyptt-2.1.0}/PyPtt.egg-info/top_level.txt +0 -0
  61. {pyptt-2.0.10 → pyptt-2.1.0}/README.md +0 -0
  62. {pyptt-2.0.10 → pyptt-2.1.0}/setup.cfg +0 -0
  63. {pyptt-2.0.10 → pyptt-2.1.0}/setup.py +0 -0
  64. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_change_pw.py +0 -0
  65. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_comment.py +0 -0
  66. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_del_post.py +0 -0
  67. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_exceptions.py +0 -0
  68. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_board_info.py +0 -0
  69. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_board_list.py +0 -0
  70. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_bottom_post_list.py +0 -0
  71. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_favourite_boards.py +0 -0
  72. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_mail.py +0 -0
  73. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_newest_index.py +0 -0
  74. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_post.py +0 -0
  75. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_post_list.py +0 -0
  76. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_post_parser.py +0 -0
  77. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_time.py +0 -0
  78. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_user.py +0 -0
  79. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_get_waterball.py +0 -0
  80. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_give_money.py +0 -0
  81. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_i18n.py +0 -0
  82. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_init.py +0 -0
  83. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_logger.py +0 -0
  84. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_parse_query_post.py +0 -0
  85. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_post.py +0 -0
  86. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_reply_post.py +0 -0
  87. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_search_user.py +0 -0
  88. {pyptt-2.0.10 → pyptt-2.1.0}/tests/test_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.0.10
3
+ Version: 2.1.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)
@@ -38,6 +38,7 @@ Provides-Extra: dev
38
38
  Requires-Dist: pytest; extra == "dev"
39
39
  Requires-Dist: python-dotenv; extra == "dev"
40
40
  Requires-Dist: flake8; extra == "dev"
41
+ Requires-Dist: pyte; extra == "dev"
41
42
  Requires-Dist: sphinx; extra == "dev"
42
43
  Requires-Dist: sphinx-copybutton; extra == "dev"
43
44
  Requires-Dist: pygments==2.15.0; extra == "dev"
@@ -1,4 +1,4 @@
1
- __version__ = '2.0.10'
1
+ __version__ = '2.1.0'
2
2
  __author__ = 'CodingMan'
3
3
  __email__ = 'pttcodingman@gmail.com'
4
4
 
@@ -72,9 +72,13 @@ def get_post_list(api, board: str, limit: int = 20, offset: int = 0) -> List[Dic
72
72
  # replace the first cursor in the line
73
73
  line = line.replace(api.cursor, ' ' * len(api.cursor.encode('big5uao')), 1)
74
74
 
75
- if api.cursor == data_type.Cursor.NEW:
76
- # add one space in the beginning to align the old cursor line
77
- line = f' {line}'
75
+ if api.cursor == data_type.Cursor.NEW:
76
+ # Pad one leading space so the fixed-column offsets below
77
+ # apply equally to cursor and non-cursor rows. Before the
78
+ # VT100Parser cell-tracking fix, the parser inserted this
79
+ # padding implicitly via its off-by-one column quirk; the
80
+ # offsets here were tuned to that padded layout.
81
+ line = f' {line}'
78
82
 
79
83
  cur_index = int(line[:8].strip())
80
84
  status = line[8:10].strip()
@@ -1,5 +1,6 @@
1
1
  import re
2
2
  import sys
3
+ import unicodedata
3
4
 
4
5
  from uao import register_uao
5
6
 
@@ -8,6 +9,37 @@ from . import log
8
9
  register_uao()
9
10
 
10
11
 
12
+ def _cell_width(ch: str) -> int:
13
+ """Return the terminal-cell width of a single character.
14
+
15
+ PTT is a Chinese BBS rendered on East Asian terminals. Wide ('W'),
16
+ Fullwidth ('F'), and Ambiguous ('A') characters all occupy 2 cells —
17
+ Ambiguous includes glyphs like ※, ←, →, ◎ that PTT's protocol treats
18
+ as full-width when positioning the cursor.
19
+ """
20
+ return 2 if unicodedata.east_asian_width(ch) in ('W', 'F', 'A') else 1
21
+
22
+
23
+ def _cell_len(text: str) -> int:
24
+ return sum(_cell_width(c) for c in text)
25
+
26
+
27
+ def _str_pos_at_cells(line: str, cells: int) -> int:
28
+ """Return the string index that corresponds to `cells` visual cells.
29
+
30
+ If `cells` lands inside a wide character, the index of the character *after*
31
+ it is returned (i.e. the wide character is treated as fully consumed).
32
+ """
33
+ if cells <= 0:
34
+ return 0
35
+ consumed = 0
36
+ for i, ch in enumerate(line):
37
+ if consumed >= cells:
38
+ return i
39
+ consumed += _cell_width(ch)
40
+ return len(line)
41
+
42
+
11
43
  class Target:
12
44
  MainMenu = [
13
45
  '離開,再見',
@@ -160,7 +192,10 @@ class Target:
160
192
 
161
193
  CursorToGoodbye = MainMenu.copy()
162
194
 
163
- content_start = '─── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ──'
195
+ # PTT separator: ~39 consecutive box-drawing horizontals. The legacy
196
+ # parser produced cell-padded output ('─── ── ──...'); with cell-aware
197
+ # cursor tracking the spaces are gone and we see a continuous run.
198
+ content_start = '───────────────────────────────────────'
164
199
  content_end_list = [
165
200
  '--\n※ 發信站: 批踢踢實業坊',
166
201
  '--\n※ 發信站: 批踢踢兔(ptt2.cc)',
@@ -211,10 +246,6 @@ class VT100Parser:
211
246
  self._cursor_x = 0
212
247
  self._cursor_y = 0
213
248
 
214
- def _2j(self):
215
- self.screen = [''] * 24
216
- self.screen_length = dict()
217
-
218
249
  def _move(self, x, y):
219
250
  self._cursor_x = x
220
251
  self._cursor_y = min(y, len(self.screen) - 1)
@@ -224,10 +255,10 @@ class VT100Parser:
224
255
  self._cursor_y = min(self._cursor_y + 1, len(self.screen) - 1)
225
256
 
226
257
  def _k(self):
227
- if self._cursor_x == 0:
228
- # nothing happen but cause error
229
- return
230
- self.screen[self._cursor_y] = self.screen[self._cursor_y][:self._cursor_x]
258
+ # Erase from cursor to end of line (VT100 ESC [ K).
259
+ pos = _str_pos_at_cells(self.screen[self._cursor_y], self._cursor_x)
260
+ self.screen[self._cursor_y] = self.screen[self._cursor_y][:pos]
261
+ self.screen_length[self._cursor_y] = self._cursor_x
231
262
 
232
263
  def __init__(self, bytes_data, encoding):
233
264
  # self._data = data
@@ -282,7 +313,8 @@ class VT100Parser:
282
313
  xy_part = xy_result.group(0)
283
314
 
284
315
  new_y = int(xy_part[6:xy_part.find(';')]) - 1
285
- new_x = int(xy_part[xy_part.find(';') + 1: -1])
316
+ # VT100 columns are 1-based; convert to 0-based cursor_x.
317
+ new_x = int(xy_part[xy_part.find(';') + 1: -1]) - 1
286
318
  # log.py.info('xy', xy_part, new_x, new_y)
287
319
  self._move(new_x, new_y)
288
320
 
@@ -297,7 +329,7 @@ class VT100Parser:
297
329
  # print(f'-{data[:1]}-{len(data[:1].encode("big5-uao", "replace"))}')
298
330
 
299
331
  if self._cursor_y not in self.screen_length:
300
- self.screen_length[self._cursor_y] = len(self.screen[self._cursor_y].encode(encoding, 'replace'))
332
+ self.screen_length[self._cursor_y] = _cell_len(self.screen[self._cursor_y])
301
333
 
302
334
  current_line_length = self.screen_length[self._cursor_y]
303
335
  replace_mode = False
@@ -318,14 +350,15 @@ class VT100Parser:
318
350
  current_index = min(next_newline, next_esc)
319
351
 
320
352
  current_data = data[:current_index]
321
- current_data_length = len(current_data.encode(encoding, 'replace'))
322
- # print('=', current_data, '=', current_data_length)
353
+ current_data_length = _cell_len(current_data)
323
354
  if replace_mode:
324
- current_line = self.screen[self._cursor_y][:self._cursor_x]
325
- current_line += current_data
326
- current_line += self.screen[self._cursor_y][self._cursor_x + len(current_data):]
327
-
328
- self.screen[self._cursor_y] = current_line
355
+ line = self.screen[self._cursor_y]
356
+ splice_start = _str_pos_at_cells(line, self._cursor_x)
357
+ splice_end = _str_pos_at_cells(line, self._cursor_x + current_data_length)
358
+ self.screen[self._cursor_y] = line[:splice_start] + current_data + line[splice_end:]
359
+ self._cursor_x += current_data_length
360
+ if self._cursor_x > self.screen_length[self._cursor_y]:
361
+ self.screen_length[self._cursor_y] = self._cursor_x
329
362
  else:
330
363
  self.screen[self._cursor_y] += current_data
331
364
  self._cursor_x += current_data_length
@@ -0,0 +1,24 @@
1
+ key = """-----BEGIN EC PARAMETERS-----
2
+ BggqhkjOPQMBBw==
3
+ -----END EC PARAMETERS-----
4
+ -----BEGIN EC PRIVATE KEY-----
5
+ MHcCAQEEIO2MHcOPROQP2B/IzywPM+VycdprTWfPpc/khcpekRscoAoGCCqGSM49
6
+ AwEHoUQDQgAEjVADKUyxMVKt7TRXiP3gdfPblx/KsRafJ1SvrZNwXHb0Q1WEqT45
7
+ LJ+2C4/hGUTdCAKYej7vO0ibHHJ3AXF3Zg==
8
+ -----END EC PRIVATE KEY-----
9
+ """
10
+
11
+ cert = """-----BEGIN CERTIFICATE-----
12
+ MIIB6TCCAY8CFCmxmdBhhrcpMq15P8fGZ0kvsYp5MAoGCCqGSM49BAMCMHcxCzAJ
13
+ BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
14
+ CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
15
+ EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA1MjQwMjU5NDJaFw0zNjA1MjEwMjU5
16
+ NDJaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
17
+ eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
18
+ bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
19
+ AwEHA0IABI1QAylMsTFSre00V4j94HXz25cfyrEWnydUr62TcFx29ENVhKk+OSyf
20
+ tguP4RlE3QgCmHo+7ztImxxydwFxd2YwCgYIKoZIzj0EAwIDSAAwRQIgfQDJrQCU
21
+ 59HONPJD3U6a8z7rOuDk0KiwvKMSgqJZWUYCIQCznuvlbggeMnlm/9EDxyawnGKs
22
+ IZ2nArdE8SMFaxoStQ==
23
+ -----END CERTIFICATE-----
24
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPtt
3
- Version: 2.0.10
3
+ Version: 2.1.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)
@@ -38,6 +38,7 @@ Provides-Extra: dev
38
38
  Requires-Dist: pytest; extra == "dev"
39
39
  Requires-Dist: python-dotenv; extra == "dev"
40
40
  Requires-Dist: flake8; extra == "dev"
41
+ Requires-Dist: pyte; extra == "dev"
41
42
  Requires-Dist: sphinx; extra == "dev"
42
43
  Requires-Dist: sphinx-copybutton; extra == "dev"
43
44
  Requires-Dist: pygments==2.15.0; extra == "dev"
@@ -50,10 +50,12 @@ PyPtt.egg-info/SOURCES.txt
50
50
  PyPtt.egg-info/dependency_links.txt
51
51
  PyPtt.egg-info/requires.txt
52
52
  PyPtt.egg-info/top_level.txt
53
+ tests/test_bucket.py
53
54
  tests/test_change_pw.py
54
55
  tests/test_comment.py
55
56
  tests/test_del_post.py
56
57
  tests/test_exceptions.py
58
+ tests/test_fast_post.py
57
59
  tests/test_get_board_info.py
58
60
  tests/test_get_board_list.py
59
61
  tests/test_get_bottom_post_list.py
@@ -62,6 +64,7 @@ tests/test_get_mail.py
62
64
  tests/test_get_newest_index.py
63
65
  tests/test_get_post.py
64
66
  tests/test_get_post_list.py
67
+ tests/test_get_post_list_parser.py
65
68
  tests/test_get_post_parser.py
66
69
  tests/test_get_time.py
67
70
  tests/test_get_user.py
@@ -70,8 +73,13 @@ tests/test_give_money.py
70
73
  tests/test_i18n.py
71
74
  tests/test_init.py
72
75
  tests/test_logger.py
76
+ tests/test_mail_send_and_del.py
77
+ tests/test_mark_post.py
73
78
  tests/test_parse_query_post.py
74
79
  tests/test_post.py
80
+ tests/test_pyte_parity.py
75
81
  tests/test_reply_post.py
76
82
  tests/test_search_user.py
77
- tests/test_service.py
83
+ tests/test_service.py
84
+ tests/test_set_board_title.py
85
+ tests/test_vt100_parser.py
@@ -13,6 +13,7 @@ uvicorn[standard]
13
13
  pytest
14
14
  python-dotenv
15
15
  flake8
16
+ pyte
16
17
  sphinx
17
18
  sphinx-copybutton
18
19
  pygments==2.15.0
@@ -53,6 +53,7 @@ dev = [
53
53
  "pytest",
54
54
  "python-dotenv",
55
55
  "flake8",
56
+ "pyte",
56
57
  "sphinx",
57
58
  "sphinx-copybutton",
58
59
  "pygments==2.15.0",
@@ -0,0 +1,52 @@
1
+ """Integration tests for bucket (water-bucket / mute, moderator-only).
2
+
3
+ The success-path test mutes `config.TEST_USER` for 1 day on a board the
4
+ bot moderates (`config.MOD_BOARD`, skipped if unset). PTT's
5
+ `UserHasPreviouslyBeenBanned` guard is treated as an acceptable outcome
6
+ on re-runs (the previous run created the ban record and PTT hasn't
7
+ released it yet) — that still proves the API drove the full command
8
+ sequence and reached PTT's own duplicate-detection.
9
+
10
+ A no-moderator-permission test for `bucket` is intentionally omitted:
11
+ when called with a real user ID, the function's `check_user_exist` step
12
+ is sensitive to PyPtt's session state and returns `NoSuchUser` instead
13
+ of reaching the permission check on this branch. That's a pre-existing
14
+ quirk, not something this test file should pin down.
15
+ """
16
+ import pytest
17
+
18
+ import PyPtt
19
+ from tests import config
20
+
21
+
22
+ def test_bucket_nonexistent_user_raises(ptt_bots):
23
+ """`_api_util.check_user_exist` runs early, so any board works for
24
+ this assertion. Uses a clearly-invalid ID — not a real account."""
25
+ for ptt_bot in ptt_bots:
26
+ with pytest.raises(PyPtt.NoSuchUser):
27
+ ptt_bot.bucket(
28
+ board='Test',
29
+ bucket_days=1,
30
+ reason='will-not-apply',
31
+ ptt_id='zz_no_such_user_xx',
32
+ )
33
+
34
+
35
+ def test_bucket_on_moderated_board(ptt_bots):
36
+ """Bucket config.TEST_USER on config.MOD_BOARD. Tolerates 'already
37
+ banned' on re-runs."""
38
+ if not config.MOD_BOARD:
39
+ pytest.skip('MOD_BOARD env var not set')
40
+ for ptt_bot in ptt_bots:
41
+ if ptt_bot.host != PyPtt.HOST.PTT1:
42
+ continue
43
+ try:
44
+ ptt_bot.bucket(
45
+ board=config.MOD_BOARD,
46
+ bucket_days=1,
47
+ reason='PyPtt automated bucket test',
48
+ ptt_id=config.TEST_USER,
49
+ )
50
+ except PyPtt.UserHasPreviouslyBeenBanned:
51
+ # Re-run hit PTT's duplicate guard — also a success signal.
52
+ pass
@@ -0,0 +1,56 @@
1
+ """Integration test for fast_post_step0 + fast_post_step1.
2
+
3
+ These two methods (and the bundled `fast_post`) all call
4
+ `api.connect_core.fast_send(...)`, but `connect_core.API` only defines
5
+ `send` — `fast_send` does not exist. So every invocation raises
6
+ `AttributeError: 'API' object has no attribute 'fast_send'`. This is
7
+ pre-existing broken code, not a regression from the parser overhaul.
8
+
9
+ The test below is marked xfail so it visibly tracks the bug. Once
10
+ `fast_send` is implemented (or the methods are rewired to `send`), the
11
+ test should switch from xfail to pass.
12
+ """
13
+ import time
14
+
15
+ import pytest
16
+
17
+ import PyPtt
18
+ from PyPtt import PostField, PostStatus, NewIndex
19
+
20
+
21
+ @pytest.mark.xfail(
22
+ reason='fast_post_step0/1 call api.connect_core.fast_send which is not '
23
+ 'defined — see _api_post.py:34, :52, :84',
24
+ strict=True,
25
+ raises=AttributeError,
26
+ )
27
+ def test_fast_post_round_trip(ptt_bots):
28
+ """Drive the two-step API and confirm the article reaches Test board."""
29
+ for ptt_bot in ptt_bots:
30
+ marker = f'fast-post-marker-{int(time.time())}-{ptt_bot.host}'
31
+ title = f'PyPtt fast_post test {marker}'
32
+ content = (
33
+ 'PyPtt fast_post integration test\n'
34
+ f'{marker}\n'
35
+ )
36
+
37
+ ptt_bot.fast_post_step0(
38
+ board='Test',
39
+ title=title,
40
+ content=content,
41
+ post_type=1,
42
+ )
43
+ ptt_bot.fast_post_step1(sign_file=0)
44
+ time.sleep(1)
45
+
46
+ newest = ptt_bot.get_newest_index(index_type=NewIndex.BOARD, board='Test')
47
+ for i in range(10):
48
+ post = ptt_bot.get_post(board='Test', index=newest - i)
49
+ if post[PostField.post_status] != PostStatus.EXISTS:
50
+ continue
51
+ if post[PostField.author].split(' ')[0] != ptt_bot.ptt_id:
52
+ continue
53
+ if marker in (post[PostField.content] or ''):
54
+ assert title in post[PostField.title]
55
+ return
56
+ pytest.fail(f'fast_post article {marker!r} not found on Test')
@@ -0,0 +1,176 @@
1
+ """
2
+ Unit tests for _api_get_post_list line-parsing logic.
3
+
4
+ Drive get_post_list() with a mocked api that returns a hand-crafted board
5
+ listing screen, then assert the parsed post records line up with the input.
6
+ No network required.
7
+
8
+ The fixed-column offsets in _api_get_post_list ([:8], [8:10], [10:12], etc.)
9
+ were calibrated to the "1 extra leading space" layout that VT100Parser used
10
+ to produce. After the cursor-tracking overhaul the implicit pad is gone, so
11
+ the function adds an explicit `f' {line}'` prefix to keep the offsets valid.
12
+ These tests pin that contract down.
13
+ """
14
+ import threading
15
+ from unittest.mock import MagicMock
16
+
17
+ import pytest
18
+
19
+ import PyPtt
20
+ from PyPtt import _api_get_post_list, data_type, i18n
21
+
22
+ # i18n strings are accessed by _api_get_post_list's error paths; init once.
23
+ i18n.init(PyPtt.Language.ENGLISH)
24
+
25
+
26
+ def _build_screen(cursor_line: str, after_cursor_lines: list[str] | None = None) -> str:
27
+ """Compose a 24-row listing screen with header + cursor + (body) + footer.
28
+
29
+ Real PTT layout when paginating to start_index:
30
+ [0] board title row
31
+ [1] hotkey hints
32
+ [2] column header (編號, 日 期, ...)
33
+ [3] cursor row (the starting index PTT landed on)
34
+ [4..] subsequent listing rows
35
+ [23] footer hints
36
+
37
+ `_api_get_post_list` skips lines until it finds the cursor row, then
38
+ parses it and every later row. So body rows should come *after* the
39
+ cursor row to be visible to the parser.
40
+ """
41
+ header = [
42
+ '【徵求中】 Python 程式語言 看板《Python》',
43
+ '[←]離開 [→]閱讀 [Ctrl-P]發表文章 [d]刪除 [z]精華區 [i]看板資訊/設定 [h]說明',
44
+ ' 編號 日 期 作 者 文 章 標 題 人氣:1 ',
45
+ ]
46
+ footer = ' 文章選讀 (y)回應(X)推文(^X)轉錄 (=[]<>)相關主題(/?a)找標題/作者 (b)進板畫面 '
47
+ rows = [cursor_line] + (after_cursor_lines or [])
48
+ blank_pad = 24 - len(header) - len(rows) - 1
49
+ if blank_pad > 0:
50
+ rows.extend([''] * blank_pad)
51
+ elif blank_pad < 0:
52
+ rows = rows[:24 - len(header) - 1]
53
+ return '\n'.join(header + rows + [footer])
54
+
55
+
56
+ def _build_api(screen: str, cursor: str = '>', newest: int = 9458):
57
+ api = MagicMock()
58
+ api._is_login = True
59
+ api._thread_id = threading.get_ident() # bypass one_thread guard
60
+ api.cursor = cursor
61
+ api.get_newest_index = MagicMock(return_value=newest)
62
+ api.connect_core = MagicMock()
63
+ api.connect_core.send = MagicMock(return_value=0)
64
+ api.connect_core.get_screen_queue = MagicMock(return_value=[screen])
65
+ return api
66
+
67
+
68
+ # ── basic row layouts ─────────────────────────────────────────────────────────
69
+
70
+ class TestLineLayouts:
71
+ """Each row format PTT can render, verified through the public function."""
72
+
73
+ def test_simple_single_digit_push(self):
74
+ # PTT layout: 3 leading spaces + index(4) + space + push(1) + ' ' + date.
75
+ cursor = '> 9458 2 5/07 fragmentwing □ [問題] openpyxl 全局Font?'
76
+ api = _build_api(_build_screen(cursor, []))
77
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
78
+ assert len(posts) == 1
79
+ p = posts[0]
80
+ assert p[data_type.PostField.index] == 9458
81
+ assert p[data_type.PostField.push_number] == '2'
82
+ assert p[data_type.PostField.list_date] == '5/07'
83
+ assert p[data_type.PostField.author] == 'fragmentwing'
84
+ assert '[問題] openpyxl 全局Font?' in p[data_type.PostField.title]
85
+
86
+ def test_two_digit_push(self):
87
+ # 10+ pushes occupy 2 chars, eat into the date column gap.
88
+ cursor = '> 9447 17 7/17 ok8ok8 □ [閒聊] 各位現在用 os.path'
89
+ api = _build_api(_build_screen(cursor, []), newest=9447)
90
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
91
+ assert posts[0][data_type.PostField.push_number] == '17'
92
+ assert posts[0][data_type.PostField.list_date] == '7/17'
93
+
94
+ def test_plus_prefix_push(self):
95
+ # '+N' is the "new pushes this session" marker; PTT packs it tight
96
+ # against a 2-digit-month date with no separating space.
97
+ cursor = '> 9449 + 610/09 edward740706 □ [問題] OMIA PLUS 自學嗎?'
98
+ api = _build_api(_build_screen(cursor, []), newest=9449)
99
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
100
+ assert posts[0][data_type.PostField.push_number] == '6'
101
+ assert posts[0][data_type.PostField.list_date] == '10/09'
102
+ assert posts[0][data_type.PostField.author] == 'edward740706'
103
+
104
+ def test_tilde_prefix_push(self):
105
+ # '~N' is the alt push prefix that should slice the same as '+N'.
106
+ cursor = '> 9456 ~ 7 2/24 leolarrel □ [問題] super() 與 MRO'
107
+ api = _build_api(_build_screen(cursor, []), newest=9456)
108
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
109
+ assert posts[0][data_type.PostField.push_number] == '7'
110
+ assert posts[0][data_type.PostField.list_date] == '2/24'
111
+
112
+ def test_no_push_count(self):
113
+ # New post / no pushes yet → push column is blank spaces.
114
+ cursor = '> 9457 4/17 Sumiremywife □ [問題] 關於爬蟲的一些問題'
115
+ api = _build_api(_build_screen(cursor, []), newest=9457)
116
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
117
+ assert posts[0][data_type.PostField.push_number] == ''
118
+ assert posts[0][data_type.PostField.list_date] == '4/17'
119
+
120
+ def test_reply_marker_R_in_title(self):
121
+ # Title section includes the kind marker ('R:', '□', '轉', '鎖').
122
+ cursor = '> 9440 4 1/15 ddavid R: [問題] @property 真正的運用是啥'
123
+ api = _build_api(_build_screen(cursor, []), newest=9440)
124
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
125
+ assert posts[0][data_type.PostField.title].lstrip().startswith('R:')
126
+
127
+
128
+ # ── multi-row listing (non-cursor rows align too) ─────────────────────────────
129
+
130
+ class TestMultiRowListing:
131
+ """The fix's key claim: non-cursor rows pick up the same leading-space
132
+ pad as cursor rows, so the column offsets work uniformly."""
133
+
134
+ def test_non_cursor_rows_parse_with_same_offsets(self):
135
+ # Cursor lands on the starting index; later rows follow.
136
+ cursor = '> 9449 610/09 edward740706 □ [問題] OMIA PLUS 自學嗎?'
137
+ after = [
138
+ ' 9450 + 110/22 dosiris □ [問題] 關於正規表示法',
139
+ ' 9451 ~ 110/29 kakar0to □ [問題] python 3.14 free thread',
140
+ ' 9452 ~ 112/13 iread □ 執行 Python 程式的六種方法',
141
+ ]
142
+ api = _build_api(_build_screen(cursor, after), newest=9452)
143
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=4)
144
+ assert [p[data_type.PostField.index] for p in posts] == [9449, 9450, 9451, 9452]
145
+ assert [p[data_type.PostField.list_date] for p in posts] == \
146
+ ['10/09', '10/22', '10/29', '12/13']
147
+ assert [p[data_type.PostField.author] for p in posts] == \
148
+ ['edward740706', 'dosiris', 'kakar0to', 'iread']
149
+ # Non-cursor rows must NOT have the '□' marker bleeding into author.
150
+ for p in posts[1:]:
151
+ assert '□' not in p[data_type.PostField.author]
152
+
153
+
154
+ # ── regression: master before the fix would mis-slice these ───────────────────
155
+
156
+ class TestRegressionAgainstMasterBug:
157
+ """Before the prepend-space fix, non-cursor rows lost one character per
158
+ column: '10/22' → '0/22', 'dosiris' → 'osiris', and the '□' marker
159
+ leaked into the author slot. These tests would have failed on master."""
160
+
161
+ @pytest.mark.parametrize('cursor_line,want', [
162
+ ('> 9450 + 110/22 dosiris □ [問題] 關於正規表示法',
163
+ {'index': 9450, 'date': '10/22', 'author': 'dosiris', 'push': '1'}),
164
+ ('> 9447 17 7/17 ok8ok8 □ [閒聊] os.path',
165
+ {'index': 9447, 'date': '7/17', 'author': 'ok8ok8', 'push': '17'}),
166
+ ('> 9453 + 212/23 tamacugi □ [閒聊] 合購股票pythony資料庫',
167
+ {'index': 9453, 'date': '12/23', 'author': 'tamacugi', 'push': '2'}),
168
+ ])
169
+ def test_no_off_by_one_on_packed_layouts(self, cursor_line, want):
170
+ api = _build_api(_build_screen(cursor_line, []), newest=want['index'])
171
+ posts = _api_get_post_list.get_post_list(api, 'Python', limit=1)
172
+ p = posts[0]
173
+ assert p[data_type.PostField.index] == want['index']
174
+ assert p[data_type.PostField.list_date] == want['date']
175
+ assert p[data_type.PostField.author] == want['author']
176
+ assert p[data_type.PostField.push_number] == want['push']
@@ -0,0 +1,55 @@
1
+ """Integration test for mail + del_mail.
2
+
3
+ Round-trip: send a mail to self, locate it in the mailbox, verify its
4
+ content, then delete it. Tests both `mail` (untested elsewhere) and
5
+ `del_mail` (also untested), and exercises `get_mail` against a freshly
6
+ posted item.
7
+ """
8
+ import time
9
+
10
+ import PyPtt
11
+ from PyPtt import MailField, NewIndex
12
+
13
+
14
+ def test_mail_send_to_self_then_delete(ptt_bots):
15
+ for ptt_bot in ptt_bots:
16
+ title = f'PyPtt mail test {int(time.time())}'
17
+ content = (
18
+ 'PyPtt 自動化測試\n'
19
+ 'mail round-trip integration test\n'
20
+ f'host={ptt_bot.host}\n'
21
+ )
22
+
23
+ # Mail to self so we don't spam another account.
24
+ ptt_bot.mail(
25
+ ptt_id=ptt_bot.ptt_id,
26
+ title=title,
27
+ content=content,
28
+ sign_file=0,
29
+ backup=True,
30
+ )
31
+ time.sleep(2) # let PTT deliver the mail
32
+
33
+ newest = ptt_bot.get_newest_index(index_type=NewIndex.MAIL)
34
+ assert newest > 0, 'mailbox empty after send'
35
+
36
+ # Walk back from the newest mail until we find ours (newest entry
37
+ # is usually it, but PTT sometimes interleaves system mail).
38
+ target_index = None
39
+ for i in range(5):
40
+ mail_info = ptt_bot.get_mail(newest - i)
41
+ if mail_info[MailField.title].endswith(title):
42
+ target_index = newest - i
43
+ break
44
+ assert target_index is not None, f'sent mail {title!r} not found in mailbox'
45
+
46
+ # Verify the content survived the round-trip.
47
+ mail_info = ptt_bot.get_mail(target_index)
48
+ assert 'PyPtt 自動化測試' in mail_info[MailField.content]
49
+ assert 'mail round-trip integration test' in mail_info[MailField.content]
50
+
51
+ # Clean up — del_mail must succeed (raises on failure).
52
+ # We don't assert on the post-delete newest_index because PyPtt
53
+ # caches that value (`api._newest_index_data`) and the immediate
54
+ # next read can return stale data.
55
+ ptt_bot.del_mail(target_index)