jmcomic 0.0.2__py3-none-any.whl

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.
jmcomic/jm_option.py ADDED
@@ -0,0 +1,647 @@
1
+ from .jm_client_impl import *
2
+
3
+
4
+ class CacheRegistry:
5
+ REGISTRY = {}
6
+
7
+ @classmethod
8
+ def level_option(cls, option, _client):
9
+ registry = cls.REGISTRY
10
+ registry.setdefault(option, {})
11
+ return registry[option]
12
+
13
+ @classmethod
14
+ def level_client(cls, _option, client):
15
+ registry = cls.REGISTRY
16
+ registry.setdefault(client, {})
17
+ return registry[client]
18
+
19
+ @classmethod
20
+ def enable_client_cache_on_condition(cls,
21
+ option: 'JmOption',
22
+ client: JmcomicClient,
23
+ cache: Union[None, bool, str, Callable],
24
+ ):
25
+ """
26
+ cache parameter
27
+
28
+ if None: no cache
29
+
30
+ if bool:
31
+ true: level_option
32
+
33
+ false: no cache
34
+
35
+ if str:
36
+ (invoke corresponding Cache class method)
37
+
38
+ :param option: JmOption
39
+ :param client: JmcomicClient
40
+ :param cache: config dsl
41
+ """
42
+ if cache is None:
43
+ return
44
+
45
+ elif isinstance(cache, bool):
46
+ if cache is False:
47
+ return
48
+ else:
49
+ cache = cls.level_option
50
+
51
+ elif isinstance(cache, str):
52
+ func = getattr(cls, cache, None)
53
+ ExceptionTool.require_true(func is not None, f'未实现的cache配置名: {cache}')
54
+ cache = func
55
+
56
+ cache: Callable
57
+ client.set_cache_dict(cache(option, client))
58
+
59
+
60
+ class DirRule:
61
+ RULE_BASE_DIR = 'Bd'
62
+
63
+ def __init__(self, rule: str, base_dir=None):
64
+ base_dir = JmcomicText.parse_to_abspath(base_dir)
65
+ self.base_dir = base_dir
66
+ self.rule_dsl = rule
67
+ self.parser_list: List[Tuple[str, Callable]] = self.get_rule_parser_list(rule)
68
+
69
+ def decide_image_save_dir(self,
70
+ album: JmAlbumDetail,
71
+ photo: JmPhotoDetail,
72
+ ) -> str:
73
+ return self.apply_rule_to_path(album, photo)
74
+
75
+ def decide_album_root_dir(self, album: JmAlbumDetail) -> str:
76
+ return self.apply_rule_to_path(album, None, True)
77
+
78
+ def apply_rule_to_path(self, album, photo, only_album_rules=False) -> str:
79
+ path_ls = []
80
+ for rule, parser in self.parser_list:
81
+ if only_album_rules and not (rule == self.RULE_BASE_DIR or rule.startswith('A')):
82
+ continue
83
+
84
+ try:
85
+ path = parser(album, photo, rule)
86
+ except BaseException as e:
87
+ # noinspection PyUnboundLocalVariable
88
+ jm_log('dir_rule', f'路径规则"{rule}"的解析出错: {e}, album={album}, photo={photo}')
89
+ raise e
90
+ if parser != self.parse_bd_rule:
91
+ path = fix_windir_name(str(path)).strip()
92
+
93
+ path_ls.append(path)
94
+
95
+ return fix_filepath('/'.join(path_ls))
96
+
97
+ def get_rule_parser_list(self, rule_dsl: str):
98
+ """
99
+ 解析下载路径dsl,得到一个路径规则解析列表
100
+ """
101
+
102
+ rule_list = self.split_rule_dsl(rule_dsl)
103
+ parser_list: list = []
104
+
105
+ for rule in rule_list:
106
+ if rule == self.RULE_BASE_DIR:
107
+ parser_list.append((rule, self.parse_bd_rule))
108
+ continue
109
+
110
+ parser = self.get_rule_parser(rule)
111
+ if parser is None:
112
+ ExceptionTool.raises(f'不支持的dsl: "{rule}" in "{rule_dsl}"')
113
+
114
+ parser_list.append((rule, parser))
115
+
116
+ return parser_list
117
+
118
+ # noinspection PyUnusedLocal
119
+ def parse_bd_rule(self, album, photo, rule):
120
+ return self.base_dir
121
+
122
+ @classmethod
123
+ def parse_f_string_rule(cls, album, photo, rule: str):
124
+ properties = {}
125
+ if album:
126
+ properties.update(album.get_properties_dict())
127
+ if photo:
128
+ properties.update(photo.get_properties_dict())
129
+ return rule.format(**properties)
130
+
131
+ @classmethod
132
+ def parse_detail_rule(cls, album, photo, rule: str):
133
+ detail = album if rule.startswith('A') else photo
134
+ return str(DetailEntity.get_dirname(detail, rule[1:]))
135
+
136
+ # noinspection PyMethodMayBeStatic
137
+ def split_rule_dsl(self, rule_dsl: str) -> List[str]:
138
+ if '/' in rule_dsl:
139
+ rule_list = rule_dsl.split('/')
140
+ elif '_' in rule_dsl:
141
+ rule_list = rule_dsl.split('_')
142
+ else:
143
+ rule_list = [rule_dsl]
144
+
145
+ for i, e in enumerate(rule_list):
146
+ rule_list[i] = e.strip()
147
+
148
+ if rule_list[0] != self.RULE_BASE_DIR:
149
+ rule_list.insert(0, self.RULE_BASE_DIR)
150
+
151
+ return rule_list
152
+
153
+ @classmethod
154
+ def get_rule_parser(cls, rule: str):
155
+ if '{' in rule:
156
+ return cls.parse_f_string_rule
157
+
158
+ if rule.startswith(('A', 'P')):
159
+ return cls.parse_detail_rule
160
+
161
+ ExceptionTool.raises(f'不支持的rule配置: "{rule}"')
162
+
163
+ @classmethod
164
+ def apply_rule_to_filename(cls, album, photo, rule: str) -> str:
165
+ if album is None:
166
+ album = photo.from_album
167
+ # noinspection PyArgumentList
168
+ return fix_windir_name(cls.get_rule_parser(rule)(album, photo, rule)).strip()
169
+
170
+
171
+ class JmOption:
172
+
173
+ def __init__(self,
174
+ dir_rule: Dict,
175
+ download: Dict,
176
+ client: Dict,
177
+ plugins: Dict,
178
+ filepath=None,
179
+ call_after_init_plugin=True,
180
+ ):
181
+ # 路径规则配置
182
+ self.dir_rule = DirRule(**dir_rule)
183
+ # 客户端配置
184
+ self.client = AdvancedDict(client)
185
+ # 下载配置
186
+ self.download = AdvancedDict(download)
187
+ # 插件配置
188
+ self.plugins = AdvancedDict(plugins)
189
+ # 其他配置
190
+ self.filepath = filepath
191
+
192
+ # 需要主线程等待完成的插件
193
+ self.need_wait_plugins = []
194
+
195
+ if call_after_init_plugin:
196
+ self.call_all_plugin('after_init', safe=True)
197
+
198
+ def copy_option(self):
199
+ return self.__class__(
200
+ dir_rule={
201
+ 'rule': self.dir_rule.rule_dsl,
202
+ 'base_dir': self.dir_rule.base_dir,
203
+ },
204
+ download=self.download.src_dict,
205
+ client=self.client.src_dict,
206
+ plugins=self.plugins.src_dict,
207
+ filepath=self.filepath,
208
+ call_after_init_plugin=False
209
+ )
210
+
211
+ """
212
+ 下面是decide系列方法,为了支持重写和增加程序动态性。
213
+ """
214
+
215
+ # noinspection PyUnusedLocal
216
+ def decide_image_batch_count(self, photo: JmPhotoDetail):
217
+ return self.download.threading.image
218
+
219
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
220
+ def decide_photo_batch_count(self, album: JmAlbumDetail):
221
+ return self.download.threading.photo
222
+
223
+ # noinspection PyMethodMayBeStatic
224
+ def decide_image_filename(self, image: JmImageDetail) -> str:
225
+ """
226
+ 返回图片的文件名,不包含后缀
227
+ 默认返回禁漫的图片文件名,例如:00001 (.jpg)
228
+ """
229
+ return image.filename_without_suffix
230
+
231
+ def decide_image_suffix(self, image: JmImageDetail) -> str:
232
+ """
233
+ 返回图片的后缀,如果返回的后缀和原后缀不一致,则会进行图片格式转换
234
+ """
235
+ # 动图则使用原后缀
236
+ if image.is_gif:
237
+ return image.img_file_suffix
238
+
239
+ # 非动图,以配置为先
240
+ return self.download.image.suffix or image.img_file_suffix
241
+
242
+ def decide_image_save_dir(self, photo, ensure_exists=True) -> str:
243
+ # 使用 self.dir_rule 决定 save_dir
244
+ save_dir = self.dir_rule.decide_image_save_dir(
245
+ photo.from_album,
246
+ photo
247
+ )
248
+
249
+ if ensure_exists:
250
+ save_dir = JmcomicText.try_mkdir(save_dir)
251
+
252
+ return save_dir
253
+
254
+ def decide_image_filepath(self, image: JmImageDetail, consider_custom_suffix=True) -> str:
255
+ # 以此决定保存文件夹、后缀、不包含后缀的文件名
256
+ save_dir = self.decide_image_save_dir(image.from_photo)
257
+ suffix = self.decide_image_suffix(image) if consider_custom_suffix else image.img_file_suffix
258
+ return os.path.join(save_dir, fix_windir_name(self.decide_image_filename(image)) + suffix)
259
+
260
+ def decide_download_cache(self, _image: JmImageDetail) -> bool:
261
+ return self.download.cache
262
+
263
+ def decide_download_image_decode(self, image: JmImageDetail) -> bool:
264
+ # .gif file needn't be decoded
265
+ if image.is_gif:
266
+ return False
267
+
268
+ return self.download.image.decode
269
+
270
+ """
271
+ 下面是创建对象相关方法
272
+ """
273
+
274
+ @classmethod
275
+ def default_dict(cls) -> Dict:
276
+ return JmModuleConfig.option_default_dict()
277
+
278
+ @classmethod
279
+ def default(cls) -> 'JmOption':
280
+ """
281
+ 使用默认的 JmOption
282
+ """
283
+ return cls.construct({})
284
+
285
+ @classmethod
286
+ def construct(cls, origdic: Dict, cover_default=True) -> 'JmOption':
287
+ dic = cls.merge_default_dict(origdic) if cover_default else origdic
288
+
289
+ # log
290
+ log = dic.pop('log', True)
291
+ if log is False:
292
+ disable_jm_log()
293
+
294
+ # version
295
+ version = dic.pop('version', None)
296
+ # noinspection PyTypeChecker
297
+ if version is not None and float(version) >= float(JmModuleConfig.JM_OPTION_VER):
298
+ # 版本号更高,跳过兼容代码
299
+ return cls(**dic)
300
+
301
+ # 旧版本option,做兼容
302
+ cls.compatible_with_old_versions(dic)
303
+
304
+ return cls(**dic)
305
+
306
+ @classmethod
307
+ def compatible_with_old_versions(cls, dic):
308
+ """
309
+ 兼容旧的option版本
310
+ """
311
+ # 1: 并发配置项
312
+ dt: dict = dic['download']['threading']
313
+ if 'batch_count' in dt:
314
+ batch_count = dt.pop('batch_count')
315
+ dt['image'] = batch_count
316
+
317
+ # 2: 插件配置项 plugin -> plugins
318
+ if 'plugin' in dic:
319
+ dic['plugins'] = dic.pop('plugin')
320
+
321
+ def deconstruct(self) -> Dict:
322
+ return {
323
+ 'version': JmModuleConfig.JM_OPTION_VER,
324
+ 'log': JmModuleConfig.FLAG_ENABLE_JM_LOG,
325
+ 'dir_rule': {
326
+ 'rule': self.dir_rule.rule_dsl,
327
+ 'base_dir': self.dir_rule.base_dir,
328
+ },
329
+ 'download': self.download.src_dict,
330
+ 'client': self.client.src_dict,
331
+ 'plugins': self.plugins.src_dict
332
+ }
333
+
334
+ """
335
+ 下面是文件IO方法
336
+ """
337
+
338
+ @classmethod
339
+ def from_file(cls, filepath: str) -> 'JmOption':
340
+ dic: dict = PackerUtil.unpack(filepath)[0]
341
+ dic.setdefault('filepath', filepath)
342
+ return cls.construct(dic)
343
+
344
+ def to_file(self, filepath=None):
345
+ if filepath is None:
346
+ filepath = self.filepath
347
+
348
+ ExceptionTool.require_true(filepath is not None, "未指定JmOption的保存路径")
349
+
350
+ PackerUtil.pack(self.deconstruct(), filepath)
351
+
352
+ """
353
+ 下面是创建客户端的相关方法
354
+ """
355
+
356
+ @field_cache()
357
+ def build_jm_client(self, **kwargs):
358
+ """
359
+ 该方法会首次调用会创建JmcomicClient对象,
360
+ 然后保存在self中,
361
+ 多次调用`不会`创建新的JmcomicClient对象
362
+ """
363
+ return self.new_jm_client(**kwargs)
364
+
365
+ def new_jm_client(self, domain_list=None, impl=None, cache=None, **kwargs) -> Union[JmHtmlClient, JmApiClient]:
366
+ """
367
+ 创建新的Client(客户端),不同Client之间的元数据不共享
368
+ """
369
+ from copy import deepcopy
370
+
371
+ # 所有需要用到的 self.client 配置项如下
372
+ postman_conf: dict = deepcopy(self.client.postman.src_dict) # postman dsl 配置
373
+
374
+ meta_data: dict = postman_conf['meta_data'] # 元数据
375
+
376
+ retry_times: int = self.client.retry_times # 重试次数
377
+
378
+ cache: str = cache if cache is not None else self.client.cache # 启用缓存
379
+
380
+ impl: str = impl or self.client.impl # client_key
381
+
382
+ if isinstance(impl, type):
383
+ # eg: impl = JmHtmlClient
384
+ # noinspection PyUnresolvedReferences
385
+ impl = impl.client_key
386
+
387
+ # start construct client
388
+
389
+ # domain
390
+ def decide_domain_list():
391
+ nonlocal domain_list
392
+
393
+ if domain_list is None:
394
+ domain_list = self.client.domain
395
+
396
+ if not isinstance(domain_list, (list, str)):
397
+ # dict
398
+ domain_list = domain_list.get(impl, [])
399
+
400
+ if isinstance(domain_list, str):
401
+ # multi-lines text
402
+ domain_list = str_to_list(domain_list)
403
+
404
+ # list or str
405
+ if len(domain_list) == 0:
406
+ domain_list = self.decide_client_domain(impl)
407
+
408
+ return domain_list
409
+
410
+ # support kwargs overwrite meta_data
411
+ if len(kwargs) != 0:
412
+ meta_data.update(kwargs)
413
+
414
+ # postman
415
+ postman = Postmans.create(data=postman_conf)
416
+
417
+ # client
418
+ clazz = JmModuleConfig.client_impl_class(impl)
419
+ if clazz == AbstractJmClient or not issubclass(clazz, AbstractJmClient):
420
+ raise NotImplementedError(clazz)
421
+
422
+ client: AbstractJmClient = clazz(
423
+ postman=postman,
424
+ domain_list=decide_domain_list(),
425
+ retry_times=retry_times,
426
+ )
427
+
428
+ # enable cache
429
+ CacheRegistry.enable_client_cache_on_condition(self, client, cache)
430
+
431
+ # noinspection PyTypeChecker
432
+ return client
433
+
434
+ def update_cookies(self, cookies: dict):
435
+ metadata: dict = self.client.postman.meta_data.src_dict
436
+ orig_cookies: Optional[Dict] = metadata.get('cookies', None)
437
+ if orig_cookies is None:
438
+ metadata['cookies'] = cookies
439
+ else:
440
+ orig_cookies.update(cookies)
441
+ metadata['cookies'] = orig_cookies
442
+
443
+ # noinspection PyMethodMayBeStatic,PyTypeChecker
444
+ def decide_client_domain(self, client_key: str) -> List[str]:
445
+ def is_client_type(ctype) -> bool:
446
+ return self.client_key_is_given_type(client_key, ctype)
447
+
448
+ if is_client_type(JmApiClient):
449
+ # 移动端
450
+ return JmModuleConfig.DOMAIN_API_LIST
451
+
452
+ if is_client_type(JmHtmlClient):
453
+ # 网页端
454
+ domain_list = JmModuleConfig.DOMAIN_HTML_LIST
455
+ if domain_list is not None:
456
+ return domain_list
457
+ return [JmModuleConfig.get_html_domain()]
458
+
459
+ ExceptionTool.raises(f'没有配置域名,且是无法识别的client类型: {client_key}')
460
+
461
+ @classmethod
462
+ def client_key_is_given_type(cls, client_key, ctype: Type[JmcomicClient]):
463
+ if client_key == ctype.client_key:
464
+ return True
465
+
466
+ clazz = JmModuleConfig.client_impl_class(client_key)
467
+ if issubclass(clazz, ctype):
468
+ return True
469
+
470
+ return False
471
+
472
+ @classmethod
473
+ def merge_default_dict(cls, user_dict, default_dict=None):
474
+ """
475
+ 深度合并两个字典
476
+ """
477
+ if default_dict is None:
478
+ default_dict = cls.default_dict()
479
+
480
+ for key, value in user_dict.items():
481
+ if isinstance(value, dict) and isinstance(default_dict.get(key), dict):
482
+ default_dict[key] = cls.merge_default_dict(value, default_dict[key])
483
+ else:
484
+ default_dict[key] = value
485
+ return default_dict
486
+
487
+ # 下面的方法提供面向对象的调用风格
488
+
489
+ def download_album(self,
490
+ album_id,
491
+ downloader=None,
492
+ callback=None,
493
+ ):
494
+ from .api import download_album
495
+ download_album(album_id, self, downloader, callback)
496
+
497
+ def download_photo(self,
498
+ photo_id,
499
+ downloader=None,
500
+ callback=None
501
+ ):
502
+ from .api import download_photo
503
+ download_photo(photo_id, self, downloader, callback)
504
+
505
+ # 下面的方法为调用插件提供支持
506
+
507
+ def call_all_plugin(self, group: str, safe=True, **extra):
508
+ plugin_list: List[dict] = self.plugins.get(group, [])
509
+ if plugin_list is None or len(plugin_list) == 0:
510
+ return
511
+
512
+ # 保证 jm_plugin.py 被加载
513
+ from .jm_plugin import JmOptionPlugin
514
+
515
+ plugin_registry = JmModuleConfig.REGISTRY_PLUGIN
516
+ for pinfo in plugin_list:
517
+ key, kwargs = pinfo['plugin'], pinfo.get('kwargs', None) # kwargs为None
518
+ pclass: Optional[Type[JmOptionPlugin]] = plugin_registry.get(key, None)
519
+
520
+ ExceptionTool.require_true(pclass is not None, f'[{group}] 未注册的plugin: {key}')
521
+
522
+ try:
523
+ self.invoke_plugin(pclass, kwargs, extra, pinfo)
524
+ except BaseException as e:
525
+ if safe is True:
526
+ traceback_print_exec()
527
+ else:
528
+ raise e
529
+
530
+ def invoke_plugin(self, pclass, kwargs: Optional[Dict], extra: dict, pinfo: dict):
531
+ # 检查插件的参数类型
532
+ kwargs = self.fix_kwargs(kwargs)
533
+ # 把插件的配置数据kwargs和附加数据extra合并,extra会覆盖kwargs
534
+ if len(extra) != 0:
535
+ kwargs.update(extra)
536
+
537
+ # 保证 jm_plugin.py 被加载
538
+ from .jm_plugin import JmOptionPlugin, PluginValidationException
539
+
540
+ pclass: Type[JmOptionPlugin]
541
+ plugin: Optional[JmOptionPlugin] = None
542
+
543
+ try:
544
+ # 构建插件对象
545
+ plugin: JmOptionPlugin = pclass.build(self)
546
+
547
+ # 设置日志开关
548
+ if pinfo.get('log', True) is not True:
549
+ plugin.log_enable = False
550
+
551
+ jm_log('plugin.invoke', f'调用插件: [{pclass.plugin_key}]')
552
+
553
+ # 调用插件功能
554
+ plugin.invoke(**kwargs)
555
+
556
+ except PluginValidationException as e:
557
+ # 插件抛出的参数校验异常
558
+ self.handle_plugin_valid_exception(e, pinfo, kwargs, plugin, pclass)
559
+
560
+ except JmcomicException as e:
561
+ # 模块内部异常,通过不是插件抛出的,而是插件调用了例如Client,Client请求失败抛出的
562
+ self.handle_plugin_jmcomic_exception(e, pinfo, kwargs, plugin, pclass)
563
+
564
+ except BaseException as e:
565
+ # 为插件兜底,捕获其他所有异常
566
+ self.handle_plugin_unexpected_error(e, pinfo, kwargs, plugin, pclass)
567
+
568
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
569
+ def handle_plugin_valid_exception(self, e, pinfo: dict, kwargs: dict, _plugin, _pclass):
570
+ from .jm_plugin import PluginValidationException
571
+ e: PluginValidationException
572
+
573
+ mode = pinfo.get('valid', self.plugins.valid)
574
+
575
+ if mode == 'ignore':
576
+ # ignore
577
+ return
578
+
579
+ if mode == 'log':
580
+ # log
581
+ jm_log('plugin.validation',
582
+ f'插件 [{e.plugin.plugin_key}] 参数校验异常:{e.msg}'
583
+ )
584
+ return
585
+
586
+ if mode == 'raise':
587
+ # raise
588
+ raise e
589
+
590
+ # 其他的mode可以通过继承+方法重写来扩展
591
+
592
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
593
+ def handle_plugin_unexpected_error(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
594
+ msg = str(e)
595
+ jm_log('plugin.error', f'插件 [{pclass.plugin_key}],运行遇到未捕获异常,异常信息: [{msg}]')
596
+ raise e
597
+
598
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
599
+ def handle_plugin_jmcomic_exception(self, e, pinfo: dict, kwargs: dict, _plugin, pclass):
600
+ msg = str(e)
601
+ jm_log('plugin.exception', f'插件 [{pclass.plugin_key}] 调用失败,异常信息: [{msg}]')
602
+ raise e
603
+
604
+ # noinspection PyMethodMayBeStatic
605
+ def fix_kwargs(self, kwargs: Optional[Dict]) -> Dict[str, Any]:
606
+ """
607
+ kwargs将来要传给方法参数,这要求kwargs的key是str类型,
608
+ 该方法检查kwargs的key的类型,如果不是str,尝试转为str,不行则抛异常。
609
+ """
610
+ if kwargs is None:
611
+ kwargs = {}
612
+ else:
613
+ ExceptionTool.require_true(
614
+ isinstance(kwargs, dict),
615
+ f'插件的kwargs参数必须为dict类型,而不能是类型: {type(kwargs)}'
616
+ )
617
+
618
+ kwargs: dict
619
+ new_kwargs: Dict[str, Any] = {}
620
+
621
+ for k, v in kwargs.items():
622
+ if isinstance(v, str):
623
+ newv = JmcomicText.parse_dsl_text(v)
624
+ v = newv
625
+
626
+ if isinstance(k, str):
627
+ new_kwargs[k] = v
628
+ continue
629
+
630
+ if isinstance(k, (int, float)):
631
+ newk = str(k)
632
+ jm_log('plugin.kwargs', f'插件参数类型转换: {k} ({type(k)}) -> {newk} ({type(newk)})')
633
+ new_kwargs[newk] = v
634
+ continue
635
+
636
+ ExceptionTool.raises(
637
+ f'插件kwargs参数类型有误,'
638
+ f'字段: {k},预期类型为str,实际类型为{type(k)}'
639
+ )
640
+
641
+ return new_kwargs
642
+
643
+ def wait_all_plugins_finish(self):
644
+ from .jm_plugin import JmOptionPlugin
645
+ for plugin in self.need_wait_plugins:
646
+ plugin: JmOptionPlugin
647
+ plugin.wait_until_finish()