ddddtools 0.1.5__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.
- ddddtools-0.1.5/PKG-INFO +965 -0
- ddddtools-0.1.5/README.md +943 -0
- ddddtools-0.1.5/pyproject.toml +36 -0
- ddddtools-0.1.5/setup.cfg +4 -0
- ddddtools-0.1.5/src/ddddtools/__init__.py +22 -0
- ddddtools-0.1.5/src/ddddtools/decorator/__init__.py +5 -0
- ddddtools-0.1.5/src/ddddtools/decorator/log_call.py +69 -0
- ddddtools-0.1.5/src/ddddtools/decorator/timer.py +17 -0
- ddddtools-0.1.5/src/ddddtools/encryption/__init__.py +12 -0
- ddddtools-0.1.5/src/ddddtools/encryption/aes.py +78 -0
- ddddtools-0.1.5/src/ddddtools/encryption/rsa.py +184 -0
- ddddtools-0.1.5/src/ddddtools/ftp/__init__.py +14 -0
- ddddtools-0.1.5/src/ddddtools/ftp/connection.py +67 -0
- ddddtools-0.1.5/src/ddddtools/ftp/delete.py +76 -0
- ddddtools-0.1.5/src/ddddtools/ftp/download.py +75 -0
- ddddtools-0.1.5/src/ddddtools/ftp/list.py +100 -0
- ddddtools-0.1.5/src/ddddtools/ftp/upload.py +86 -0
- ddddtools-0.1.5/src/ddddtools/logging/__init__.py +110 -0
- ddddtools-0.1.5/src/ddddtools/mail/__init__.py +167 -0
- ddddtools-0.1.5/src/ddddtools/mail/template/__init__.py +451 -0
- ddddtools-0.1.5/src/ddddtools/mongodb/__init__.py +50 -0
- ddddtools-0.1.5/src/ddddtools/mongodb/collection.py +104 -0
- ddddtools-0.1.5/src/ddddtools/redis/__init__.py +161 -0
- ddddtools-0.1.5/src/ddddtools/redis/decorator.py +116 -0
- ddddtools-0.1.5/src/ddddtools/wechat/__init__.py +298 -0
- ddddtools-0.1.5/src/ddddtools.egg-info/PKG-INFO +965 -0
- ddddtools-0.1.5/src/ddddtools.egg-info/SOURCES.txt +28 -0
- ddddtools-0.1.5/src/ddddtools.egg-info/dependency_links.txt +1 -0
- ddddtools-0.1.5/src/ddddtools.egg-info/requires.txt +2 -0
- ddddtools-0.1.5/src/ddddtools.egg-info/top_level.txt +1 -0
ddddtools-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ddddtools
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: 常用工具函数集合
|
|
5
|
+
Author-email: Owner <owner@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/owner/dtools
|
|
8
|
+
Keywords: utils,tools
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: pycryptodome>=3.19.0
|
|
21
|
+
Requires-Dist: pymongo>=4.6.0
|
|
22
|
+
|
|
23
|
+
# ddddtools
|
|
24
|
+
|
|
25
|
+
常用工具函数集合。支持 Python 3.8+。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 目录
|
|
30
|
+
|
|
31
|
+
- [安装](#安装)
|
|
32
|
+
- [快速开始](#快速开始)
|
|
33
|
+
- [日志模块 (logging)](#日志模块-logging)
|
|
34
|
+
- [装饰器 (decorator)](#装饰器-decorator)
|
|
35
|
+
- [FTP操作 (ftp)](#ftp操作-ftp)
|
|
36
|
+
- [邮件发送 (mail)](#邮件发送-mail)
|
|
37
|
+
- [HTML模板 (mail.template)](#html模板-mailtemplate)
|
|
38
|
+
- [缓存管理 (redis)](#缓存管理-redis)
|
|
39
|
+
- [文件结构](#文件结构)
|
|
40
|
+
- [常见问题](#常见问题)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
### PyPI 安装(稳定版)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install ddddtools
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 本地安装(开发版)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd /path/to/ddddtools
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 从源码安装
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/yourname/ddddtools.git
|
|
63
|
+
cd ddddtools
|
|
64
|
+
pip install -e .
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 快速开始
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# 导入所有功能
|
|
73
|
+
from ddddtools import logger, timer, log_call, ftp, mail
|
|
74
|
+
|
|
75
|
+
# 1. 日志自动启动
|
|
76
|
+
logger.info("ddddtools 已就绪")
|
|
77
|
+
|
|
78
|
+
# 2. 使用装饰器
|
|
79
|
+
@timer
|
|
80
|
+
@log_call()
|
|
81
|
+
def fetch_data():
|
|
82
|
+
return {"data": [1, 2, 3]}
|
|
83
|
+
|
|
84
|
+
result = fetch_data()
|
|
85
|
+
|
|
86
|
+
# 3. FTP操作
|
|
87
|
+
with ftp.connect("ftp.example.com", username="user", password="pass") as conn:
|
|
88
|
+
files = conn.list_files("/")
|
|
89
|
+
conn.download_file("/pub/data.csv", "./data.csv")
|
|
90
|
+
|
|
91
|
+
# 4. 发送邮件
|
|
92
|
+
mail.send_simple(
|
|
93
|
+
to_addrs=["admin@example.com"],
|
|
94
|
+
subject="系统通知",
|
|
95
|
+
content="任务已完成",
|
|
96
|
+
username="your@qq.com",
|
|
97
|
+
password="your授权码"
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 日志模块 (logging)
|
|
104
|
+
|
|
105
|
+
自动创建logs目录,按日期存储,自动清理7天前日志。
|
|
106
|
+
|
|
107
|
+
### 默认日志器
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from ddddtools import logger
|
|
111
|
+
|
|
112
|
+
logger.debug("调试信息")
|
|
113
|
+
logger.info("普通信息")
|
|
114
|
+
logger.warning("警告")
|
|
115
|
+
logger.error("错误")
|
|
116
|
+
logger.critical("严重错误")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 自定义日志器
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from ddddtools import get_logger
|
|
123
|
+
|
|
124
|
+
# 自定义名称、目录、保留天数
|
|
125
|
+
app_logger = get_logger(
|
|
126
|
+
name="MyApp", # 日志器名称
|
|
127
|
+
log_dir="/var/log/myapp", # 日志目录
|
|
128
|
+
days=30 # 保留30天
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
app_logger.info("自定义日志器")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 日志格式
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
2026-02-07 14:03 | INFO | ddddtools | ddddtools 已就绪
|
|
138
|
+
2026-02-07 14:03 | INFO | MyApp | 自定义日志器
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- 自动在运行目录创建 `logs` 文件夹
|
|
142
|
+
- 按日期切割:`2026-02-07.log`
|
|
143
|
+
- 同时输出到控制台和文件
|
|
144
|
+
- 默认保留7天
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 装饰器 (decorator)
|
|
149
|
+
|
|
150
|
+
### timer - 耗时统计
|
|
151
|
+
|
|
152
|
+
统计函数执行时间(毫秒)。
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from ddddtools import timer
|
|
156
|
+
|
|
157
|
+
@timer
|
|
158
|
+
def slow_function():
|
|
159
|
+
time.sleep(2)
|
|
160
|
+
return "完成"
|
|
161
|
+
|
|
162
|
+
result = slow_function()
|
|
163
|
+
# 输出: [slow_function] 耗时: 2002.35ms
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### log_call - 调用日志
|
|
167
|
+
|
|
168
|
+
记录函数调用详情。
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from ddddtools import log_call
|
|
172
|
+
|
|
173
|
+
# 基本用法(使用函数名作为日志名)
|
|
174
|
+
@log_call()
|
|
175
|
+
def add(a, b):
|
|
176
|
+
return a + b
|
|
177
|
+
|
|
178
|
+
# 自定义日志名
|
|
179
|
+
@log_call(name="计算器")
|
|
180
|
+
def multiply(x, y):
|
|
181
|
+
return x * y
|
|
182
|
+
|
|
183
|
+
# 自定义参数截断长度
|
|
184
|
+
@log_call(arg_limit=500, return_limit=800)
|
|
185
|
+
def process(data):
|
|
186
|
+
return {"result": data}
|
|
187
|
+
|
|
188
|
+
# 带关键字参数
|
|
189
|
+
@log_call()
|
|
190
|
+
def create_user(name, age, *, email=None):
|
|
191
|
+
return {"name": name, "age": age, "email": email}
|
|
192
|
+
|
|
193
|
+
# 调用示例
|
|
194
|
+
add(1, 2)
|
|
195
|
+
# 输出:
|
|
196
|
+
# [add] 传入: (a=1, b=2)
|
|
197
|
+
# [add] 返回: 3 | 耗时: 0.05ms
|
|
198
|
+
|
|
199
|
+
create_user("张三", 25, email="zhangsan@example.com")
|
|
200
|
+
# 输出:
|
|
201
|
+
# [create_user] 传入: (name='张三', age=25, email='zhangsan@example.com')
|
|
202
|
+
# [create_user] 返回: {'name': '张三', 'age': 25, 'email': 'zhangsan@example.com'} | 耗时: 0.12ms
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
#### log_call 参数
|
|
206
|
+
|
|
207
|
+
| 参数 | 类型 | 默认值 | 说明 |
|
|
208
|
+
|------|------|--------|------|
|
|
209
|
+
| `name` | str | 函数名 | 日志名称 |
|
|
210
|
+
| `arg_limit` | int | 1000 | 参数字符串截断长度 |
|
|
211
|
+
| `return_limit` | int | 1000 | 返回值字符串截断长度 |
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## FTP操作 (ftp)
|
|
216
|
+
|
|
217
|
+
### 连接管理
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from ddddtools import ftp
|
|
221
|
+
|
|
222
|
+
# 基本连接
|
|
223
|
+
with ftp.connect("ftp.example.com") as conn:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
# 带认证
|
|
227
|
+
with ftp.connect(
|
|
228
|
+
host="ftp.example.com",
|
|
229
|
+
port=21,
|
|
230
|
+
username="your_user",
|
|
231
|
+
password="your_password",
|
|
232
|
+
timeout=30
|
|
233
|
+
) as conn:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# 匿名连接
|
|
237
|
+
with ftp.connect("ftp.example.com", username="anonymous", password="") as conn:
|
|
238
|
+
pass
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### 获取文件列表
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from ddddtools import ftp
|
|
245
|
+
|
|
246
|
+
with ftp.connect("ftp.example.com", username="user") as conn:
|
|
247
|
+
# 获取当前目录列表
|
|
248
|
+
files = conn.list_files("/")
|
|
249
|
+
for f in files:
|
|
250
|
+
print(f"{'[DIR]' if f.is_dir else 'FILE']} {f.name} ({f.size} bytes)")
|
|
251
|
+
|
|
252
|
+
# 递归获取所有文件
|
|
253
|
+
all_files = conn.list_all("/", recursive=True)
|
|
254
|
+
|
|
255
|
+
# FTPFile 属性
|
|
256
|
+
# - name: 文件名/完整路径
|
|
257
|
+
# - size: 文件大小(字节)
|
|
258
|
+
# - is_dir: 是否为文件夹
|
|
259
|
+
# - modify_time: 修改时间
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### 下载文件
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from ddddtools import ftp
|
|
266
|
+
|
|
267
|
+
with ftp.connect("ftp.example.com", username="user") as conn:
|
|
268
|
+
# 下载单个文件
|
|
269
|
+
conn.download_file(
|
|
270
|
+
remote_path="/pub/data/report.csv",
|
|
271
|
+
local_path="./downloads/report.csv",
|
|
272
|
+
overwrite=True # 是否覆盖
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# 下载整个文件夹
|
|
276
|
+
conn.download_folder(
|
|
277
|
+
remote_folder="/pub/backups",
|
|
278
|
+
local_folder="./backups",
|
|
279
|
+
overwrite=True
|
|
280
|
+
)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### 上传文件
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from ddddtools import ftp
|
|
287
|
+
|
|
288
|
+
with ftp.connect("ftp.example.com", username="user") as conn:
|
|
289
|
+
# 上传单个文件
|
|
290
|
+
conn.upload_file(
|
|
291
|
+
local_path="./data.csv",
|
|
292
|
+
remote_path="/pub/uploads/data.csv",
|
|
293
|
+
overwrite=True
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# 上传整个文件夹
|
|
297
|
+
conn.upload_folder(
|
|
298
|
+
local_folder="./project",
|
|
299
|
+
remote_path="/pub/projects/project",
|
|
300
|
+
overwrite=True
|
|
301
|
+
)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### 删除操作
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from ddddtools import ftp
|
|
308
|
+
|
|
309
|
+
with ftp.connect("ftp.example.com", username="user") as conn:
|
|
310
|
+
# 删除文件
|
|
311
|
+
conn.delete_file("/pub/old_file.txt")
|
|
312
|
+
|
|
313
|
+
# 删除空文件夹
|
|
314
|
+
conn.delete_folder("/pub/empty_folder")
|
|
315
|
+
|
|
316
|
+
# 递归删除(文件夹及其内容)
|
|
317
|
+
conn.delete_recursive("/pub/to_delete")
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### FTP 完整示例
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from ddddtools import ftp, logger
|
|
324
|
+
|
|
325
|
+
def backup_website():
|
|
326
|
+
"""备份网站文件"""
|
|
327
|
+
logger.info("开始备份")
|
|
328
|
+
|
|
329
|
+
with ftp.connect("ftp.yoursite.com", username="admin", password="pass") as conn:
|
|
330
|
+
# 获取文件列表
|
|
331
|
+
files = conn.list_all("/public_html", recursive=True)
|
|
332
|
+
logger.info(f"共找到 {len(files)} 个文件")
|
|
333
|
+
|
|
334
|
+
# 下载
|
|
335
|
+
conn.download_folder(
|
|
336
|
+
remote_folder="/public_html",
|
|
337
|
+
local_folder="./backup"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
logger.info("备份完成")
|
|
341
|
+
|
|
342
|
+
backup_website()
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 邮件发送 (mail)
|
|
348
|
+
|
|
349
|
+
### 快速发送
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
from ddddtools import mail
|
|
353
|
+
|
|
354
|
+
# 发送普通文本邮件
|
|
355
|
+
mail.send_simple(
|
|
356
|
+
to_addrs=["admin@example.com", "support@example.com"],
|
|
357
|
+
subject="系统通知",
|
|
358
|
+
content="您的订单已发货,请注意查收。",
|
|
359
|
+
username="123456@qq.com",
|
|
360
|
+
password="your授权码"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# 发送HTML邮件
|
|
364
|
+
mail.send_html(
|
|
365
|
+
to_addrs=["user@example.com"],
|
|
366
|
+
subject="欢迎注册",
|
|
367
|
+
html_content="<h1>欢迎使用 ddddtools</h1><p>感谢您的注册</p>",
|
|
368
|
+
username="123456@qq.com",
|
|
369
|
+
password="your授权码"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# 发送带附件的邮件
|
|
373
|
+
mail.send_with_attachment(
|
|
374
|
+
to_addrs=["boss@example.com"],
|
|
375
|
+
subject="月度报告",
|
|
376
|
+
content="请查收附件中的月度报告。",
|
|
377
|
+
attachments=[
|
|
378
|
+
"/path/to/report.pdf",
|
|
379
|
+
"/path/to/summary.xlsx"
|
|
380
|
+
],
|
|
381
|
+
username="123456@qq.com",
|
|
382
|
+
password="your授权码"
|
|
383
|
+
)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### 使用 MailClient
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from ddddtools import mail
|
|
390
|
+
|
|
391
|
+
# 创建客户端(重复发送时更高效)
|
|
392
|
+
client = mail.create_client(
|
|
393
|
+
username="123456@qq.com",
|
|
394
|
+
password="your授权码",
|
|
395
|
+
smtp_host="smtp.qq.com",
|
|
396
|
+
smtp_port=465
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# 多次发送
|
|
400
|
+
client.send(
|
|
401
|
+
to_addrs=["user1@example.com"],
|
|
402
|
+
subject="通知1",
|
|
403
|
+
content="内容1"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
client.send(
|
|
407
|
+
to_addrs=["user2@example.com"],
|
|
408
|
+
subject="通知2",
|
|
409
|
+
content="内容2",
|
|
410
|
+
is_html=True,
|
|
411
|
+
attachments=["file.pdf"]
|
|
412
|
+
)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### 参数说明
|
|
416
|
+
|
|
417
|
+
| 参数 | 说明 |
|
|
418
|
+
|------|------|
|
|
419
|
+
| `to_addrs` | 收件人列表,支持多个 |
|
|
420
|
+
| `subject` | 邮件主题 |
|
|
421
|
+
| `content` | 邮件内容(普通或HTML) |
|
|
422
|
+
| `is_html` | 是否为HTML格式,默认False |
|
|
423
|
+
| `attachments` | 附件路径列表 |
|
|
424
|
+
| `username` | 发件人邮箱 |
|
|
425
|
+
| `password` | SMTP授权码(不是密码) |
|
|
426
|
+
| `smtp_host` | SMTP服务器,默认 smtp.qq.com |
|
|
427
|
+
| `smtp_port` | SMTP端口,默认 465 |
|
|
428
|
+
|
|
429
|
+
### QQ邮箱授权码获取
|
|
430
|
+
|
|
431
|
+
1. 登录 QQ 邮箱
|
|
432
|
+
2. 进入「设置」→「账户」
|
|
433
|
+
3. 找到「POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务」
|
|
434
|
+
4. 开启「POP3/SMTP服务」
|
|
435
|
+
5. 点击「生成授权码」
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## HTML模板 (mail.template)
|
|
440
|
+
|
|
441
|
+
### 8种内置模板
|
|
442
|
+
|
|
443
|
+
| 模板名 | 用途 | 变量 |
|
|
444
|
+
|--------|------|------|
|
|
445
|
+
| `simple` | 基础模板 | `content`, `now` |
|
|
446
|
+
| `notification` | 通知/公告 | `title`, `content`, `button_url`, `button_text`, `highlights` |
|
|
447
|
+
| `verification` | 验证码 | `title`, `description`, `code` |
|
|
448
|
+
| `welcome` | 欢迎邮件 | `username`, `site_name`, `message`, `feature1`, `feature2`, `feature3` |
|
|
449
|
+
| `data_table` | 表格数据 | `title`, `description`, `table_html`, `summary` |
|
|
450
|
+
| `order` | 订单通知 | `username`, `order_no`, `order_time`, `status`, `tracking_no`, `total_amount`, `order_details` |
|
|
451
|
+
| `password_reset` | 密码重置 | `username`, `reset_url`, `expire_time` |
|
|
452
|
+
| `report` | 周报/月报 | `report_title`, `period`, `metrics_html`, `highlights_html`, `notes` |
|
|
453
|
+
|
|
454
|
+
### 基础用法
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from ddddtools.mail.template import render
|
|
458
|
+
|
|
459
|
+
# 渲染模板
|
|
460
|
+
html = render("simple", content="这是一封测试邮件")
|
|
461
|
+
html = render("notification", title="重要通知", content="您的账户已更新")
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### 验证码模板
|
|
465
|
+
|
|
466
|
+
```python
|
|
467
|
+
from ddddtools.mail.template import render, mail
|
|
468
|
+
|
|
469
|
+
html = render(
|
|
470
|
+
"verification",
|
|
471
|
+
title="验证码",
|
|
472
|
+
description="您的验证码如下,请于5分钟内完成验证:",
|
|
473
|
+
code="852741"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
mail.send_html(
|
|
477
|
+
to_addrs=["user@example.com"],
|
|
478
|
+
subject="验证码",
|
|
479
|
+
html_content=html,
|
|
480
|
+
username="123456@qq.com",
|
|
481
|
+
password="授权码"
|
|
482
|
+
)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### 订单通知模板
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
from ddddtools.mail.template import render, mail
|
|
489
|
+
|
|
490
|
+
html = render(
|
|
491
|
+
"order",
|
|
492
|
+
username="张三",
|
|
493
|
+
order_no="DD20260207001",
|
|
494
|
+
order_time="2026-02-07 14:30:00",
|
|
495
|
+
status="已发货",
|
|
496
|
+
tracking_no="SF1234567890",
|
|
497
|
+
total_amount="299.00"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
mail.send_html(
|
|
501
|
+
to_addrs=["user@example.com"],
|
|
502
|
+
subject="订单已发货",
|
|
503
|
+
html_content=html,
|
|
504
|
+
username="123456@qq.com",
|
|
505
|
+
password="授权码"
|
|
506
|
+
)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### 数据表格模板
|
|
510
|
+
|
|
511
|
+
```python
|
|
512
|
+
from ddddtools.mail.template import render, mail, make_table_html
|
|
513
|
+
|
|
514
|
+
# 生成表格HTML
|
|
515
|
+
table = make_table_html(
|
|
516
|
+
headers=["姓名", "数学", "语文", "英语"],
|
|
517
|
+
rows=[
|
|
518
|
+
["张三", 95, 88, 92],
|
|
519
|
+
["李四", 85, 90, 87],
|
|
520
|
+
["王五", 92, 85, 94]
|
|
521
|
+
]
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
html = render(
|
|
525
|
+
"data_table",
|
|
526
|
+
title="期末考试成绩",
|
|
527
|
+
description="本次考试共3人参加",
|
|
528
|
+
table_html=table,
|
|
529
|
+
summary="平均分: 90.2"
|
|
530
|
+
)
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### 指标卡片模板
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
from ddddtools.mail.template import render, mail, make_metrics_html
|
|
537
|
+
|
|
538
|
+
# 生成指标HTML
|
|
539
|
+
metrics = make_metrics_html({
|
|
540
|
+
"总访问量": {"value": "12,345", "change": "+15%", "type": "up"},
|
|
541
|
+
"新增用户": {"value": "528", "change": "+8%", "type": "up"},
|
|
542
|
+
"转化率": {"value": "3.2%", "change": "-0.5%", "type": "down"},
|
|
543
|
+
"流失率": {"value": "5.1%", "change": "0%", "type": "neutral"}
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
html = render(
|
|
547
|
+
"report",
|
|
548
|
+
report_title="周报",
|
|
549
|
+
period="2026-02-01 ~ 2026-02-07",
|
|
550
|
+
metrics_html=metrics
|
|
551
|
+
)
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### 辅助函数
|
|
555
|
+
|
|
556
|
+
```python
|
|
557
|
+
from ddddtools.mail.template import make_table_html, make_metrics_html, make_highlights_html
|
|
558
|
+
|
|
559
|
+
# 表格
|
|
560
|
+
make_table_html(
|
|
561
|
+
headers=["列1", "列2", "列3"],
|
|
562
|
+
rows=[["a", "b", "c"], ["d", "e", "f"]]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# 指标(up=绿色,down=红色,neutral=黑色)
|
|
566
|
+
make_metrics_html({
|
|
567
|
+
"指标名": {"value": "数值", "change": "+10%", "type": "up"}
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
# 亮点列表
|
|
571
|
+
make_highlights_html(["亮点1", "亮点2", "亮点3"])
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### 注册自定义模板
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
from ddddtools.mail.template import register
|
|
578
|
+
|
|
579
|
+
# 注册模板
|
|
580
|
+
register("newsletter", """
|
|
581
|
+
<!DOCTYPE html>
|
|
582
|
+
<html>
|
|
583
|
+
<body>
|
|
584
|
+
<h1>{{title}}</h1>
|
|
585
|
+
<div>{{content}}</div>
|
|
586
|
+
<footer>unsubscribe at {{unsubscribe_url}}</footer>
|
|
587
|
+
</body>
|
|
588
|
+
</html>
|
|
589
|
+
""")
|
|
590
|
+
|
|
591
|
+
# 使用
|
|
592
|
+
html = render("newsletter", title="新闻简报", content="...", unsubscribe_url="...")
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
## 缓存管理 (redis)
|
|
598
|
+
|
|
599
|
+
### 连接管理
|
|
600
|
+
|
|
601
|
+
```python
|
|
602
|
+
from ddddtools import connect, RedisClient
|
|
603
|
+
|
|
604
|
+
# 方式一:使用 connect 函数
|
|
605
|
+
redis = connect(host="localhost", port=6379, db=0, password="your_password")
|
|
606
|
+
|
|
607
|
+
# 方式二:使用连接字符串
|
|
608
|
+
redis = RedisClient(host="redis.example.com", port=6379, password="pass")
|
|
609
|
+
|
|
610
|
+
# 测试连接
|
|
611
|
+
redis.ping() # True/False
|
|
612
|
+
|
|
613
|
+
# 使用原生 Redis 客户端
|
|
614
|
+
redis.client.set("key", "value")
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### 基础操作
|
|
618
|
+
|
|
619
|
+
```python
|
|
620
|
+
from ddddtools import redis
|
|
621
|
+
|
|
622
|
+
r = redis.connect(host="localhost", password="your_password")
|
|
623
|
+
|
|
624
|
+
# 字符串操作
|
|
625
|
+
r.set("name", "张三", ex=3600) # 1小时过期
|
|
626
|
+
value = r.get("name")
|
|
627
|
+
|
|
628
|
+
# 检查存在
|
|
629
|
+
r.exists("name") # 返回数量
|
|
630
|
+
|
|
631
|
+
# 设置过期
|
|
632
|
+
r.expire("name", 1800) # 30秒
|
|
633
|
+
print(r.ttl("name")) # 查看剩余时间
|
|
634
|
+
|
|
635
|
+
# 删除
|
|
636
|
+
r.delete("name")
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Hash 操作
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
r = redis.connect()
|
|
643
|
+
|
|
644
|
+
# 设置 Hash
|
|
645
|
+
r.hset("user:1", "name", "张三")
|
|
646
|
+
r.hset("user:1", "age", "25")
|
|
647
|
+
|
|
648
|
+
# 获取字段
|
|
649
|
+
r.hget("user:1", "name") # "张三"
|
|
650
|
+
|
|
651
|
+
# 获取全部
|
|
652
|
+
r.hgetall("user:1") # {'name': '张三', 'age': '25'}
|
|
653
|
+
|
|
654
|
+
# 删除字段
|
|
655
|
+
r.hdel("user:1", "age")
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### List 操作
|
|
659
|
+
|
|
660
|
+
```python
|
|
661
|
+
r = redis.connect()
|
|
662
|
+
|
|
663
|
+
# 插入
|
|
664
|
+
r.lpush("queue", "task1", "task2")
|
|
665
|
+
r.rpush("queue", "task3")
|
|
666
|
+
|
|
667
|
+
# 获取
|
|
668
|
+
r.lrange("queue", 0, -1) # ['task2', 'task1', 'task3']
|
|
669
|
+
r.lpop("queue") # 'task2'
|
|
670
|
+
r.llen("queue") # 长度
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### 缓存装饰器
|
|
674
|
+
|
|
675
|
+
```python
|
|
676
|
+
from ddddtools import cache
|
|
677
|
+
|
|
678
|
+
# 简单缓存
|
|
679
|
+
@cache(key="user:{user_id}", expire=600)
|
|
680
|
+
def get_user(user_id: int):
|
|
681
|
+
# 首次调用:执行函数并缓存结果
|
|
682
|
+
return db.query_user(user_id)
|
|
683
|
+
# 后续调用:直接从 Redis 返回缓存
|
|
684
|
+
|
|
685
|
+
# 动态键
|
|
686
|
+
@cache(key="{id}", expire=300)
|
|
687
|
+
def get_data(id: int):
|
|
688
|
+
return api.fetch(id)
|
|
689
|
+
|
|
690
|
+
# 清除缓存
|
|
691
|
+
from ddddtools import clear_cache, clear_prefix
|
|
692
|
+
|
|
693
|
+
clear_cache(key="user:123") # 清除单个
|
|
694
|
+
clear_prefix(prefix="cache") # 清除前缀(谨慎)
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
### 缓存装饰器参数
|
|
698
|
+
|
|
699
|
+
| 参数 | 类型 | 默认值 | 说明 |
|
|
700
|
+
|------|------|--------|------|
|
|
701
|
+
| `key` | str | 空 | 缓存键,支持 `{func_name}`, `{args[i]}`, `{kwargs[key]}` |
|
|
702
|
+
| `expire` | int | 300 | 过期时间(秒) |
|
|
703
|
+
| `prefix` | str | "cache" | 缓存前缀 |
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
## 加密模块 (encryption)
|
|
708
|
+
|
|
709
|
+
### AES加密
|
|
710
|
+
|
|
711
|
+
```python
|
|
712
|
+
from ddddtools import AESEncrypt, encrypt_aes, decrypt_aes
|
|
713
|
+
|
|
714
|
+
# 方式一:使用类
|
|
715
|
+
aes = AESEncrypt(key="16位密钥字符串")
|
|
716
|
+
encrypted = aes.encrypt("Hello World")
|
|
717
|
+
decrypted = aes.decrypt(encrypted)
|
|
718
|
+
|
|
719
|
+
# 方式二:快速函数
|
|
720
|
+
encrypted = encrypt_aes("敏感数据", "密钥")
|
|
721
|
+
decrypted = decrypt_aes(encrypted, "密钥")
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
### RSA加密
|
|
725
|
+
|
|
726
|
+
```python
|
|
727
|
+
from ddddtools import RSAEncrypt, generate_rsa_keys, encrypt_rsa, decrypt_rsa
|
|
728
|
+
|
|
729
|
+
# 生成密钥对
|
|
730
|
+
private_key, public_key = generate_rsa_keys(key_size=2048)
|
|
731
|
+
|
|
732
|
+
# 加密/解密
|
|
733
|
+
rsa = RSAEncrypt(private_key=private_key, public_key=public_key)
|
|
734
|
+
encrypted = rsa.encrypt("秘密消息")
|
|
735
|
+
decrypted = rsa.decrypt(encrypted)
|
|
736
|
+
|
|
737
|
+
# 快速函数
|
|
738
|
+
encrypted = encrypt_rsa("消息", public_key)
|
|
739
|
+
decrypted = decrypt_rsa(encrypted, private_key)
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### RSA签名/验签
|
|
743
|
+
|
|
744
|
+
```python
|
|
745
|
+
from ddddtools import sign_data, verify_signature
|
|
746
|
+
|
|
747
|
+
# 签名
|
|
748
|
+
private_key = "-----BEGIN RSA PRIVATE KEY-----..."
|
|
749
|
+
signature = sign_data("要签名的数据", private_key, hash_method="sha256")
|
|
750
|
+
|
|
751
|
+
# 验签
|
|
752
|
+
public_key = "-----BEGIN PUBLIC KEY-----..."
|
|
753
|
+
is_valid = verify_signature("要验证的数据", signature, public_key)
|
|
754
|
+
# 返回 True/False
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### 参数说明
|
|
758
|
+
|
|
759
|
+
| 功能 | 参数 | 说明 |
|
|
760
|
+
|------|------|------|
|
|
761
|
+
| `generate_rsa_keys` | key_size | 密钥长度(1024/2048/4096)|
|
|
762
|
+
| `AESEncrypt` | key | 16/24/32字节(对应AES-128/192/256)|
|
|
763
|
+
| `sign_data` | hash_method | 哈希算法(md5/sha1/sha256)|
|
|
764
|
+
|
|
765
|
+
---
|
|
766
|
+
|
|
767
|
+
## MongoDB数据库 (mongodb)
|
|
768
|
+
|
|
769
|
+
### 连接管理
|
|
770
|
+
|
|
771
|
+
```python
|
|
772
|
+
from ddddtools import MongoDB, mongo_connect
|
|
773
|
+
|
|
774
|
+
# 方式一:使用 connect 函数
|
|
775
|
+
db = mongo_connect("mongodb://localhost:27017", db_name="myapp")
|
|
776
|
+
|
|
777
|
+
# 方式二:使用类
|
|
778
|
+
mongo = MongoDB("mongodb://localhost:27017")
|
|
779
|
+
mongo.set_db("myapp")
|
|
780
|
+
|
|
781
|
+
# 测试连接
|
|
782
|
+
print(mongo.ping()) # True/False
|
|
783
|
+
|
|
784
|
+
mongo.close()
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### 集合操作
|
|
788
|
+
|
|
789
|
+
```python
|
|
790
|
+
from ddddtools import mongo_connect, MongoCollection
|
|
791
|
+
|
|
792
|
+
db = mongo_connect(db_name="testdb")
|
|
793
|
+
users = db.get_collection("users") # 返回 MongoCollection 对象
|
|
794
|
+
|
|
795
|
+
# 插入
|
|
796
|
+
id = users.insert_one({"name": "张三", "age": 25})
|
|
797
|
+
ids = users.insert_many([
|
|
798
|
+
{"name": "李四", "age": 30},
|
|
799
|
+
{"name": "王五", "age": 28}
|
|
800
|
+
])
|
|
801
|
+
|
|
802
|
+
# 查询
|
|
803
|
+
user = users.find_one({"name": "张三"})
|
|
804
|
+
user = users.find_by_id("507f1f77bcf86cd799439011") # 根据ID查询
|
|
805
|
+
|
|
806
|
+
all_users = users.find_all() # 查询所有
|
|
807
|
+
all_users = users.find_all({"age": {"$gt": 25}}) # 条件查询
|
|
808
|
+
all_users = users.find_all(sort=[("age", -1)], limit=10) # 排序和限制
|
|
809
|
+
|
|
810
|
+
count = users.count({"name": "张三"}) # 统计数量
|
|
811
|
+
exists = users.exists({"name": "李四"}) # 判断是否存在
|
|
812
|
+
|
|
813
|
+
# 更新
|
|
814
|
+
users.update_one({"name": "张三"}, {"age": 26}) # 更新单个
|
|
815
|
+
users.update_by_id("507f1f77bcf86cd799439011", {"age": 27}) # 根据ID更新
|
|
816
|
+
users.increment({"name": "李四"}, "age", 1) # 字段自增
|
|
817
|
+
|
|
818
|
+
# 删除
|
|
819
|
+
users.delete_one({"name": "王五"}) # 删除单个
|
|
820
|
+
users.delete_by_id("507f1f77bcf86cd799439011") # 根据ID删除
|
|
821
|
+
users.delete_many({"status": "inactive"}) # 删除多个
|
|
822
|
+
|
|
823
|
+
# 聚合
|
|
824
|
+
pipeline = [
|
|
825
|
+
{"$match": {"age": {"$gte": 25}}},
|
|
826
|
+
{"$group": {"_id": None, "avg_age": {"$avg": "$age"}, "count": {"$sum": 1}}}
|
|
827
|
+
]
|
|
828
|
+
result = users.aggregate(pipeline)
|
|
829
|
+
|
|
830
|
+
# 索引
|
|
831
|
+
users.create_index([("name", 1)]) # 普通索引
|
|
832
|
+
users.create_unique_index("email") # 唯一索引
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
### MongoCollection 方法
|
|
836
|
+
|
|
837
|
+
| 方法 | 说明 |
|
|
838
|
+
|------|------|
|
|
839
|
+
| `insert_one(doc)` | 插入单个文档,返回ID |
|
|
840
|
+
| `insert_many(docs)` | 插入多个文档,返回ID列表 |
|
|
841
|
+
| `find_one(query)` | 查询单个文档 |
|
|
842
|
+
| `find_by_id(id)` | 根据ID查询 |
|
|
843
|
+
| `find_all(query, sort, limit)` | 查询多个文档 |
|
|
844
|
+
| `count(query)` | 统计数量 |
|
|
845
|
+
| `exists(query)` | 判断是否存在 |
|
|
846
|
+
| `update_one(query, update)` | 更新单个 |
|
|
847
|
+
| `update_by_id(id, update)` | 根据ID更新 |
|
|
848
|
+
| `update_many(query, update)` | 更新多个 |
|
|
849
|
+
| `increment(query, field, amount)` | 字段自增 |
|
|
850
|
+
| `delete_one(query)` | 删除单个 |
|
|
851
|
+
| `delete_by_id(id)` | 根据ID删除 |
|
|
852
|
+
| `delete_many(query)` | 删除多个 |
|
|
853
|
+
| `aggregate(pipeline)` | 聚合查询 |
|
|
854
|
+
| `create_index(keys)` | 创建索引 |
|
|
855
|
+
| `create_unique_index(field)` | 创建唯一索引 |
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
## 文件结构
|
|
860
|
+
|
|
861
|
+
```
|
|
862
|
+
ddddtools/
|
|
863
|
+
├── pyproject.toml # Python项目配置
|
|
864
|
+
├── README.md # 本文档
|
|
865
|
+
├── .gitignore # Git忽略配置
|
|
866
|
+
└── src/ddddtools/
|
|
867
|
+
├── __init__.py # 统一导出所有功能
|
|
868
|
+
├── logging/ # 日志模块
|
|
869
|
+
│ └── __init__.py # 自动日志、get_logger
|
|
870
|
+
├── decorator/ # 装饰器
|
|
871
|
+
│ ├── __init__.py # timer, log_call
|
|
872
|
+
│ ├── timer.py # 耗时统计
|
|
873
|
+
│ └── log_call.py # 调用日志
|
|
874
|
+
├── ftp/ # FTP操作
|
|
875
|
+
│ ├── __init__.py # 导出所有FTP函数
|
|
876
|
+
│ ├── connection.py # 连接管理
|
|
877
|
+
│ ├── list.py # 文件列表
|
|
878
|
+
│ ├── download.py # 下载
|
|
879
|
+
│ ├── upload.py # 上传
|
|
880
|
+
│ └── delete.py # 删除
|
|
881
|
+
├── mail/ # 邮件发送
|
|
882
|
+
│ ├── __init__.py # send_simple, send_html, send_with_attachment
|
|
883
|
+
│ └── template/ # HTML模板
|
|
884
|
+
│ └── __init__.py # 8种模板 + 辅助函数
|
|
885
|
+
├── redis/ # 缓存管理
|
|
886
|
+
│ ├── __init__.py # RedisClient, connect, cache, clear_cache
|
|
887
|
+
│ └── decorator.py # 缓存装饰器
|
|
888
|
+
├── encryption/ # 加密模块
|
|
889
|
+
│ ├── __init__.py # RSAEncrypt, AESEncrypt
|
|
890
|
+
│ ├── rsa.py # RSA加解密、签名验签
|
|
891
|
+
│ └── aes.py # AES加解密
|
|
892
|
+
├── mongodb/ # MongoDB数据库
|
|
893
|
+
│ ├── __init__.py # MongoDB, connect, MongoCollection
|
|
894
|
+
│ └── collection.py # 集合操作
|
|
895
|
+
├── file/ # (预留) 文件操作
|
|
896
|
+
├── string/ # (预留) 字符串处理
|
|
897
|
+
├── system/ # (预留) 系统工具
|
|
898
|
+
└── datetime/ # (预留) 日期时间
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
## 常见问题
|
|
904
|
+
|
|
905
|
+
### Q: 日志文件在哪里?
|
|
906
|
+
|
|
907
|
+
默认在运行目录下创建 `logs` 文件夹。可通过 `get_logger(log_dir="/path")` 自定义。
|
|
908
|
+
|
|
909
|
+
### Q: 如何更改日志保留天数?
|
|
910
|
+
|
|
911
|
+
```python
|
|
912
|
+
logger = get_logger(days=30) # 保留30天
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
### Q: log_call 如何自定义日志名?
|
|
916
|
+
|
|
917
|
+
```python
|
|
918
|
+
@log_call(name="MyFunction")
|
|
919
|
+
def my_func(): ...
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### Q: FTP 连接失败?
|
|
923
|
+
|
|
924
|
+
- 检查用户名密码是否正确
|
|
925
|
+
- 检查服务器地址和端口
|
|
926
|
+
- 确保防火墙开放 FTP 端口(21)
|
|
927
|
+
- 尝试使用主动模式:`ftp.connect(...)._ftp.set_pasv(False)`
|
|
928
|
+
|
|
929
|
+
### Q: QQ 邮箱发送失败?
|
|
930
|
+
|
|
931
|
+
- 确保已开启 POP3/SMTP 服务
|
|
932
|
+
- 使用授权码而非登录密码
|
|
933
|
+
- 确认 SMTP 地址和端口(smtp.qq.com:465)
|
|
934
|
+
|
|
935
|
+
### Q: 如何调试 log_call?
|
|
936
|
+
|
|
937
|
+
设置环境变量或查看控制台输出,它会显示参数和返回值。
|
|
938
|
+
|
|
939
|
+
### Q: 支持异步吗?
|
|
940
|
+
|
|
941
|
+
当前版本为同步实现。如需异步支持,可使用 `asyncio.to_thread` 包装。
|
|
942
|
+
|
|
943
|
+
### Q: Redis 连接失败?
|
|
944
|
+
|
|
945
|
+
- 检查 Redis 服务是否启动
|
|
946
|
+
- 确认 host、port、password 是否正确
|
|
947
|
+
- 检查防火墙是否开放 6379 端口
|
|
948
|
+
|
|
949
|
+
### Q: 缓存装饰器如何动态生成键?
|
|
950
|
+
|
|
951
|
+
```python
|
|
952
|
+
@cache(key="{user_id}:{page}", expire=300)
|
|
953
|
+
def get_user_posts(user_id: int, page: int):
|
|
954
|
+
return db.query_posts(user_id, page)
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
### Q: 如何查看当前缓存?
|
|
958
|
+
|
|
959
|
+
直接使用 `redis.client.keys("cache:*")` 或 `scan_iter` 遍历。
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
## License
|
|
964
|
+
|
|
965
|
+
MIT License
|