Skip to content

Commit 2866026

Browse files
authored
v2.7.1: 优化 async 重试配置; 改造 batch download 返回值类型; IndexedEntity 继承 Sequence 以支持标准切片协议. (#546)
1 parent 15b4cbb commit 2866026

6 files changed

Lines changed: 112 additions & 27 deletions

File tree

src/jmcomic/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# 被依赖方 <--- 使用方
33
# config <--- entity <--- toolkit <--- client <--- option <--- downloader
44

5-
__version__ = '2.7.0'
5+
__version__ = '2.7.1'
66

77
from .api import *
88
from .jm_plugin import *

src/jmcomic/api.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
from .jm_downloader import *
44

5-
__DOWNLOAD_API_RET = Tuple[JmAlbumDetail, JmDownloader]
5+
__DOWNLOAD_API_RET = DownloadResult
66

77

88
def download_batch(download_api,
99
jm_id_iter: Union[Iterable, Generator],
1010
option=None,
1111
downloader=None,
1212
**kwargs,
13-
) -> Set[__DOWNLOAD_API_RET]:
13+
) -> BatchResult:
1414
"""
1515
批量下载 album / photo
1616
17-
一个album/photo,对应一个线程,对应一个option
17+
一个album/photo,对应一个线程,对应一个option。
18+
返回 BatchResult(set),支持 for album, dler in result 遍历。
19+
失败项收集在 result.failed 中,不会静默丢失。
1820
1921
:param download_api: 下载api
2022
:param jm_id_iter: jmid (album_id, photo_id) 的迭代器
@@ -26,22 +28,23 @@ def download_batch(download_api,
2628
if option is None:
2729
option = JmModuleConfig.option_class().default()
2830

29-
result = set()
31+
result = BatchResult()
3032

31-
def callback(*ret):
32-
result.add(ret)
33+
def _safe_download(aid):
34+
"""batch 内部的单任务包装:确保异常被收集而非静默丢失"""
35+
try:
36+
ret = download_api(aid, option, downloader, **kwargs)
37+
result.add(ret)
38+
except Exception as e:
39+
jm_log('batch.failed', f'批量下载失败: [{aid}], 异常: [{e}]', e)
40+
result.failed[str(aid)] = e
3341

3442
multi_thread_launcher(
3543
iter_objs=set(
3644
JmcomicText.parse_to_jm_id(jmid)
3745
for jmid in jm_id_iter
3846
),
39-
apply_each_obj_func=lambda aid: download_api(aid,
40-
option,
41-
downloader,
42-
callback=callback,
43-
**kwargs,
44-
),
47+
apply_each_obj_func=_safe_download,
4548
wait_finish=True
4649
)
4750

@@ -81,7 +84,7 @@ def download_album(jm_album_id,
8184
callback(album, dler)
8285
if check_exception:
8386
dler.raise_if_has_exception()
84-
return album, dler
87+
return DownloadResult(album, dler)
8588

8689

8790
def download_photo(jm_photo_id,
@@ -106,7 +109,7 @@ def download_photo(jm_photo_id,
106109
callback(photo, dler)
107110
if check_exception:
108111
dler.raise_if_has_exception()
109-
return photo, dler
112+
return DownloadResult(photo, dler)
110113

111114

112115
def new_downloader(option=None, downloader=None) -> JmDownloader:
@@ -183,7 +186,7 @@ async def download_album_async(jm_album_id,
183186
if check_exception:
184187
dler.raise_if_has_exception()
185188

186-
return album, dler
189+
return DownloadResult(album, dler)
187190

188191

189192
async def download_photo_async(jm_photo_id,
@@ -213,18 +216,19 @@ async def download_photo_async(jm_photo_id,
213216
if check_exception:
214217
dler.raise_if_has_exception()
215218

216-
return photo, dler
219+
return DownloadResult(photo, dler)
217220

218221

219222
async def download_batch_async(download_api,
220223
jm_id_iter,
221224
option=None,
222225
downloader=None,
223226
**kwargs,
224-
):
227+
) -> BatchResult:
225228
"""
226229
异步批量下载 album / photo。
227230
- 容错机制:单个 album/photo 失败不会中止整批,也不会丢失其它已完成结果。
231+
- 返回 BatchResult(set),失败项收集在 result.failed 中。
228232
"""
229233
if option is None:
230234
option = JmModuleConfig.option_class().default()
@@ -236,12 +240,13 @@ async def download_batch_async(download_api,
236240
return_exceptions=True,
237241
)
238242

239-
# 失败不抛出,但要记录,便于排查
240-
result_set = set()
243+
# 失败不抛出,但要记录到 result.failed,便于调用者排查
244+
result = BatchResult()
241245
for jmid, r in zip(jm_ids, results):
242246
if isinstance(r, BaseException):
243247
jm_log('async.batch.failed', f'批量下载失败: [{jmid}], 异常: [{r}]', r)
248+
result.failed[str(jmid)] = r
244249
else:
245-
result_set.add(r)
250+
result.add(r)
246251

247-
return result_set
252+
return result

src/jmcomic/jm_async_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ class AsyncJmApiClient(AsyncJmcomicClient):
5757
def __init__(self, option: JmOption, max_clients=None, **kwargs):
5858
self.option = option
5959
self._domain_list = self._resolve_domain_list()
60-
self._retry_times = option.client.get('retry_times', 5) or 5
60+
retry_times = option.client.get('retry_times')
61+
self._retry_times = retry_times if retry_times is not None else 5
6162
self._timeout = option.client.get('timeout', 30) or 30
6263
# AsyncSession 句柄池大小:优先用调用方(下载器)传入的实际图片并发,
6364
# 否则回退到 option 配置;避免因默认限制导致真实并发被隐式压低。

src/jmcomic/jm_downloader.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import NamedTuple
2+
13
from .jm_option import *
24

35

@@ -240,6 +242,38 @@ def raise_if_has_exception(self):
240242
)
241243

242244

245+
class DownloadResult(NamedTuple):
246+
"""单个下载结果。
247+
248+
支持解包:album, dler = download_album(id)
249+
也支持属性访问:result.detail, result.downloader
250+
NamedTuple 是 tuple 子类,isinstance(result, tuple) 为 True。
251+
"""
252+
detail: DetailEntity
253+
downloader: BaseDownloader
254+
255+
256+
class BatchResult(set):
257+
"""批量下载结果集。
258+
259+
继承 set,完全兼容旧写法 for album, dler in result。
260+
新增 .failed 属性,记录失败的 jm_id 和对应异常。
261+
"""
262+
263+
def __init__(self):
264+
super().__init__()
265+
self.failed: Dict[str, BaseException] = {}
266+
267+
@property
268+
def all_succeeded(self) -> bool:
269+
return len(self.failed) == 0
270+
271+
@property
272+
def total(self) -> int:
273+
"""预期总数(成功 + 失败)"""
274+
return len(self) + len(self.failed)
275+
276+
243277
class JmDownloader(BaseDownloader):
244278
"""
245279
JmDownloader = BaseDownloader + 同步 I/O 调度逻辑

src/jmcomic/jm_entity.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Sequence
12
from functools import lru_cache
23

34
from common import *
@@ -37,7 +38,7 @@ def is_page(cls):
3738
return False
3839

3940

40-
class IndexedEntity:
41+
class IndexedEntity(Sequence):
4142
def getindex(self, index: int):
4243
raise NotImplementedError
4344

@@ -46,12 +47,14 @@ def __len__(self):
4647

4748
def __getitem__(self, item) -> Any:
4849
if isinstance(item, slice):
49-
start = item.start or 0
50-
stop = item.stop or len(self)
51-
step = item.step or 1
50+
start, stop, step = item.indices(len(self))
5251
return [self.getindex(index) for index in range(start, stop, step)]
5352

5453
elif isinstance(item, int):
54+
if item < 0:
55+
item += len(self)
56+
if item < 0 or item >= len(self):
57+
raise IndexError(f"index {item} out of range")
5558
return self.getindex(item)
5659

5760
else:

tests/test_jmcomic/test_jm_client.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,45 @@ def test_ranking(self):
357357
This test retrieves the page 1 ranking data from the configured client and writes it to standard output.
358358
"""
359359
print(self.client.month_ranking(1))
360+
361+
def test_indexed_entity_slice(self):
362+
from collections.abc import Sequence
363+
364+
# 获取真实的 JmSearchPage 对象
365+
page = self.client.search_site("中文", page=1)
366+
empty_page = self.client.search_site("测试很长的不知道什么鬼123", page=1)
367+
368+
self.assertTrue(isinstance(page, Sequence))
369+
370+
# 切片越界应该正常截断,不报错
371+
items = page[:1000]
372+
self.assertEqual(len(items), len(page))
373+
self.assertEqual(page[1000:2000], [])
374+
375+
self.assertEqual(empty_page[:10], [])
376+
377+
# 负数切片
378+
if len(page) >= 2:
379+
rev = page[::-1]
380+
self.assertEqual(rev[0], page[-1])
381+
self.assertEqual(rev[-1], page[0])
382+
383+
if len(page) >= 2:
384+
self.assertEqual(page[-1], page[len(page) - 1])
385+
self.assertEqual(page[-2], page[len(page) - 2])
386+
387+
with self.assertRaises(IndexError):
388+
_ = page[len(page) + 10]
389+
with self.assertRaises(IndexError):
390+
_ = page[-(len(page) + 10)]
391+
392+
res = []
393+
for i in page:
394+
res.append(i)
395+
self.assertEqual(len(res), len(page))
396+
397+
if len(page) > 0:
398+
# JmSearchPage overrides __iter__ to yield different elements than __getitem__
399+
# So we take an item from iter() to test __contains__
400+
first_item = next(iter(page))
401+
self.assertTrue(first_item in page)

0 commit comments

Comments
 (0)