PyPtt 2.0.9__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.
- {pyptt-2.0.9 → pyptt-2.1.0}/PKG-INFO +2 -1
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/__init__.py +1 -1
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_post_list.py +7 -3
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/screens.py +51 -18
- pyptt-2.1.0/PyPtt/ssl_config.py +24 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt.egg-info/PKG-INFO +2 -1
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt.egg-info/SOURCES.txt +9 -1
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt.egg-info/requires.txt +1 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/pyproject.toml +1 -0
- pyptt-2.1.0/tests/test_bucket.py +52 -0
- pyptt-2.1.0/tests/test_fast_post.py +56 -0
- pyptt-2.1.0/tests/test_get_post_list_parser.py +176 -0
- pyptt-2.1.0/tests/test_mail_send_and_del.py +55 -0
- pyptt-2.1.0/tests/test_mark_post.py +76 -0
- pyptt-2.1.0/tests/test_pyte_parity.py +154 -0
- pyptt-2.1.0/tests/test_set_board_title.py +49 -0
- pyptt-2.1.0/tests/test_vt100_parser.py +447 -0
- pyptt-2.0.9/PyPtt/ssl_config.py +0 -24
- {pyptt-2.0.9 → pyptt-2.1.0}/LICENSE +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/PTT.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_del_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/_api_util.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/api_server.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/check_value.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/command.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/config.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/connect_core.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/data_type.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/exceptions.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/i18n.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/lib_util.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/log.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt/service.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/README.md +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/setup.cfg +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/setup.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_change_pw.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_comment.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_del_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_exceptions.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_board_info.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_board_list.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_mail.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_newest_index.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_post_list.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_time.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_user.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_get_waterball.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_give_money.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_i18n.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_init.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_logger.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_reply_post.py +0 -0
- {pyptt-2.0.9 → pyptt-2.1.0}/tests/test_search_user.py +0 -0
- {pyptt-2.0.9 → 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
|
|
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"
|
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
self.
|
|
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
|
-
|
|
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] =
|
|
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 =
|
|
322
|
-
# print('=', current_data, '=', current_data_length)
|
|
353
|
+
current_data_length = _cell_len(current_data)
|
|
323
354
|
if replace_mode:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
self.
|
|
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
|
|
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
|
|
@@ -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)
|