dwyeapi 0.9.4__tar.gz → 0.9.6__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 (55) hide show
  1. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/PKG-INFO +2 -1
  2. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/README.md +2 -1
  3. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/pyproject.toml +3 -1
  4. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/__init__.py +2 -1
  5. dwyeapi-0.9.6/src/dwyeapi/email.py +41 -0
  6. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/__init__.py +3 -2
  7. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/base.py +21 -5
  8. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/config.py +4 -4
  9. dwyeapi-0.9.6/src/dwyeapi/providers/email/factory.py +86 -0
  10. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/protocol.py +4 -4
  11. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/email/resend.py +24 -0
  12. dwyeapi-0.9.6/src/dwyeapi/tasks/schema.py +42 -0
  13. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/PKG-INFO +2 -1
  14. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/SOURCES.txt +2 -0
  15. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/requires.txt +1 -0
  16. dwyeapi-0.9.6/tests/test_email.py +31 -0
  17. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_tasks.py +24 -0
  18. dwyeapi-0.9.4/src/dwyeapi/providers/email/factory.py +0 -80
  19. dwyeapi-0.9.4/src/dwyeapi/tasks/schema.py +0 -31
  20. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/setup.cfg +0 -0
  21. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/cache.py +0 -0
  22. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/config.py +0 -0
  23. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/database.py +0 -0
  24. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/dependencies.py +0 -0
  25. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/dt.py +0 -0
  26. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/exceptions.py +0 -0
  27. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/health.py +0 -0
  28. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/logger.py +0 -0
  29. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/masking.py +0 -0
  30. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/pagination.py +0 -0
  31. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/providers/__init__.py +0 -0
  32. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/response.py +0 -0
  33. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/security.py +0 -0
  34. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/__init__.py +0 -0
  35. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/context.py +0 -0
  36. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/model.py +0 -0
  37. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/pool.py +0 -0
  38. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/registry.py +0 -0
  39. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/router.py +0 -0
  40. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/service.py +0 -0
  41. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi/tasks/worker.py +0 -0
  42. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/dependency_links.txt +0 -0
  43. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/src/dwyeapi.egg-info/top_level.txt +0 -0
  44. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_cache.py +0 -0
  45. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_config.py +0 -0
  46. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_database.py +0 -0
  47. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_dependencies.py +0 -0
  48. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_dt.py +0 -0
  49. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_exceptions.py +0 -0
  50. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_health.py +0 -0
  51. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_logger.py +0 -0
  52. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_masking.py +0 -0
  53. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_pagination.py +0 -0
  54. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_response.py +0 -0
  55. {dwyeapi-0.9.4 → dwyeapi-0.9.6}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.9.4
3
+ Version: 0.9.6
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/danweiyuancircle/dwy-ai-experience
@@ -27,3 +27,4 @@ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
27
27
  Requires-Dist: ruff>=0.6.0; extra == "dev"
28
28
  Requires-Dist: arq>=0.26.0; extra == "dev"
29
29
  Requires-Dist: fakeredis>=2.0.0; extra == "dev"
30
+ Requires-Dist: resend[async]>=2.29.0; extra == "dev"
@@ -19,7 +19,8 @@ pip install dwyeapi
19
19
  - `response` — Unified API response helpers
20
20
  - `pagination` — PaginationParams + paginate helper
21
21
  - `logger` — Facade logger (loguru-backed) with daily + size rotation and stdlib interception; downstream code depends on `dwyeapi.logger.Logger`, not loguru directly
22
- - `providers.email` — 邮件验证码 Provider 工厂(内置 resend;支持业务通过 `register_email_provider` 注入自定义实现)
22
+ - `email` — 邮箱规范化(Gmail plus / 点号 / googlemail 折成同一收件箱;注册查重、登录查找必须走 `canonicalize_email`)
23
+ - `providers.email` — 邮件验证码 Provider 工厂(内置 resend;支持业务通过 `register_email_provider` 注入自定义实现;验证码 Redis key 已自动规范化)
23
24
 
24
25
  ## Environment (dev / prod)
25
26
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dwyeapi"
3
- version = "0.9.4"
3
+ version = "0.9.6"
4
4
  description = "Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger"
5
5
  requires-python = ">=3.11"
6
6
  license = {text = "MIT"}
@@ -35,6 +35,8 @@ dev = [
35
35
  "ruff>=0.6.0",
36
36
  "arq>=0.26.0",
37
37
  "fakeredis>=2.0.0",
38
+ # 内置 Email provider 是 Resend;不装则构造/_send 用例只能 skip,测不到内置实现
39
+ "resend[async]>=2.29.0",
38
40
  ]
39
41
 
40
42
  [project.urls]
@@ -15,6 +15,7 @@ Modules:
15
15
  logger — 基于 loguru 的全局日志,支持按天与按大小轮转、stdlib 拦截
16
16
  tasks — 异步任务系统(基于 ARQ,需安装 [tasks] extra)
17
17
  masking — PII 数据脱敏工具
18
+ email — 邮箱规范化(Gmail plus/点号/googlemail 折成同一收件箱)
18
19
  health — 健康检查路由工厂(只探活,不探依赖)
19
20
  """
20
21
 
@@ -32,4 +33,4 @@ __all__ = [
32
33
  "is_prod",
33
34
  "logger",
34
35
  ]
35
- __version__ = "0.9.4"
36
+ __version__ = "0.9.6"
@@ -0,0 +1,41 @@
1
+ """邮箱规范化.
2
+
3
+ Gmail / Googlemail 把 plus 别名和点号视为同一收件箱;其它域名只做大小写折叠.
4
+ 查重、发码 Redis key、登录查找必须走规范化结果,否则同一邮箱可开多个号.
5
+ """
6
+
7
+ # Gmail 把 googlemail.com 当成 gmail.com 的别名,必须折到同一域名.
8
+ GMAIL_DOMAINS = frozenset({"gmail.com", "googlemail.com"})
9
+
10
+
11
+ def canonicalize_email(email: str) -> str:
12
+ """把邮箱折成唯一收件箱标识.
13
+
14
+ Args:
15
+ email: 用户输入的邮箱.长度建议 [3, 254].示例:``Bai.Wen+1@Gmail.com``.
16
+
17
+ Returns:
18
+ 规范化邮箱.Gmail 示例:``baiwen@gmail.com``;其它域名仅 lower.
19
+ 无 ``@`` 时返回 strip + lower,不抛异常,交给上游格式校验.
20
+ """
21
+ normalized = email.strip().lower()
22
+ if "@" not in normalized:
23
+ return normalized
24
+ local, domain = normalized.rsplit("@", 1)
25
+ if domain in GMAIL_DOMAINS:
26
+ local = local.split("+", 1)[0].replace(".", "")
27
+ domain = "gmail.com"
28
+ return f"{local}@{domain}"
29
+
30
+
31
+ def is_folded_alias(email: str) -> bool:
32
+ """输入是否因 Gmail 点号 / plus / googlemail 被折叠.
33
+
34
+ Args:
35
+ email: 用户输入.示例:``a+shop@gmail.com``.
36
+
37
+ Returns:
38
+ True 表示与规范化结果不同,应按同一收件箱处理.
39
+ 仅大小写差异不算折叠(``A@gmail.com`` -> False).
40
+ """
41
+ return canonicalize_email(email) != email.strip().lower()
@@ -1,8 +1,9 @@
1
1
  """Email Provider 模块 -- 提供可插拔的邮件验证码发送能力。
2
2
 
3
3
  模块由 Protocol 抽象 + 抽象基类 + 配置类 + 工厂函数 + 内置 resend 实现组成。
4
- 内置仅 resend 一种 provider;业务项目可通过 `register_email_provider` 注入自定义 provider
5
- (继承 `EmailProviderBase` 实现 `_send`,即可复用 Redis 验证码 + 品牌化模板)。
4
+ 内置与自定义走同一注册表;`make_email_provider` 只按名查表。
5
+ 业务可通过 `register_email_provider` 注入自定义 provider
6
+ (继承 `EmailProviderBase` 实现 `_send`,即可复用 Redis 验证码 + 品牌化模板,无需改 eapi)。
6
7
 
7
8
  典型用法 -- 内置 resend:
8
9
  from dwyeapi.providers.email import EmailSettings, make_email_provider
@@ -7,6 +7,7 @@ from html import escape
7
7
  import redis.asyncio as aioredis
8
8
 
9
9
  from dwyeapi.cache import get_redis
10
+ from dwyeapi.email import canonicalize_email
10
11
 
11
12
  CODE_KEY_PREFIX = "dwyeapi:email:code:"
12
13
  DEFAULT_CODE_TTL = 300
@@ -62,17 +63,32 @@ class EmailProviderBase(ABC):
62
63
  """生成指定位数的数字验证码。"""
63
64
  return "".join(secrets.choice("0123456789") for _ in range(self._length))
64
65
 
66
+ def _code_key(self, target: str) -> str:
67
+ """验证码 Redis key.Gmail plus/点号/googlemail 折成同一收件箱,避免别名绕过."""
68
+ return f"{CODE_KEY_PREFIX}{canonicalize_email(target)}"
69
+
65
70
  async def send_code(self, target: str) -> bool:
66
- """生成验证码存 Redis 并调用 `_send()` 发送。"""
71
+ """生成验证码:先发送成功再写入 Redis.
72
+
73
+ 顺序约束:``_send`` 失败时不落库,避免用户未收到邮件却有可校验码.
74
+ 发送成功后写 Redis;若写库失败则返回 False(邮件已发出属极端边界,业务可重发覆盖).
75
+ 发信信封用用户输入的地址;Redis key 走 ``canonicalize_email``,Gmail 别名共用同一码.
76
+ """
67
77
  code = self._generate_code()
78
+ deliver_to = target.strip()
79
+ if not await self._send(deliver_to, code):
80
+ return False
68
81
  redis = await self._get_redis()
69
- await redis.set(f"{CODE_KEY_PREFIX}{target}", code, ex=self._ttl)
70
- return await self._send(target, code)
82
+ await redis.set(self._code_key(target), code, ex=self._ttl)
83
+ return True
71
84
 
72
85
  async def verify_code(self, target: str, code: str) -> bool:
73
- """从 Redis 读取存储的验证码比对,成功则删除 key(一次性)。"""
86
+ """从 Redis 读取存储的验证码比对,成功则删除 key(一次性).
87
+
88
+ ``target`` 经 ``canonicalize_email`` 后再查 key,与 ``send_code`` 对齐.
89
+ """
74
90
  redis = await self._get_redis()
75
- key = f"{CODE_KEY_PREFIX}{target}"
91
+ key = self._code_key(target)
76
92
  stored = await redis.get(key)
77
93
  if stored is None:
78
94
  return False
@@ -34,16 +34,16 @@ class EmailSettings(BaseModel):
34
34
  `.env` 自动识别(双下划线嵌套):
35
35
  EMAIL__PROVIDER=resend
36
36
  EMAIL__BRAND_NAME=宽舟科技
37
- EMAIL__BRAND_URL=https://quantzone.tech
38
- EMAIL__SUPPORT_EMAIL=support@quantzone.tech
37
+ EMAIL__BRAND_URL=https://example.com
38
+ EMAIL__SUPPORT_EMAIL=support@example.com
39
39
  EMAIL__RESEND__API_KEY=re_xxx
40
40
  """
41
41
 
42
42
  provider: str = Field(
43
43
  default="resend",
44
44
  description=(
45
- "Email Provider 名称。内置仅支持 'resend';"
46
- "业务可通过 register_email_provider(name, factory) 注册自定义 provider 后填入对应名称"
45
+ "Email Provider 名称。内置见工厂注册表(当前 resend);"
46
+ "自定义先 register_email_provider(name, factory) 再填对应名称"
47
47
  ),
48
48
  )
49
49
  code_ttl: int = Field(
@@ -0,0 +1,86 @@
1
+ """Email Provider 工厂 -- 内置与自定义走同一注册表。
2
+
3
+ `make_email_provider` 只按名查表,不再对 resend 写死 if。
4
+ 内置通道用懒加载器写入注册表,缺 extra 时由对应 builder 抛 ImportError。
5
+ """
6
+
7
+ from collections.abc import Callable
8
+
9
+ from dwyeapi.logger import get_logger
10
+ from dwyeapi.providers.email.config import EmailSettings
11
+ from dwyeapi.providers.email.protocol import EmailProvider
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ EmailProviderFactory = Callable[[EmailSettings], EmailProvider]
16
+
17
+ _REGISTRY: dict[str, EmailProviderFactory] = {}
18
+ # 测试仍 import 此名;与 _REGISTRY 是同一 dict
19
+ _PROVIDER_REGISTRY = _REGISTRY
20
+
21
+
22
+ def _load_resend() -> EmailProviderFactory:
23
+ """延迟 import,避免未装 email-resend extra 时加载 SDK。"""
24
+ from dwyeapi.providers.email.resend import build_resend_provider
25
+
26
+ return build_resend_provider
27
+
28
+
29
+ # 新增内置通道只在此加一行,工厂查找逻辑不动
30
+ _BUILTIN_LOADERS: dict[str, Callable[[], EmailProviderFactory]] = {
31
+ "resend": _load_resend,
32
+ }
33
+
34
+
35
+ def _ensure_builtin(name: str) -> None:
36
+ """首次用到某内置名时写入注册表。注册表被测试 clear 后也能补回。"""
37
+ if name in _REGISTRY or name not in _BUILTIN_LOADERS:
38
+ return
39
+ _REGISTRY[name] = _BUILTIN_LOADERS[name]()
40
+
41
+
42
+ def register_email_provider(name: str, factory: EmailProviderFactory) -> None:
43
+ """注册自定义 Email Provider。
44
+
45
+ 业务项目继承 `EmailProviderBase` 实现 `_send` 后,通过此函数注册到工厂,
46
+ `.env` 设 `EMAIL__PROVIDER=<name>` 即可启用,无需改 eapi。
47
+
48
+ Args:
49
+ name: provider 名称,与 EMAIL__PROVIDER 环境变量值对应。
50
+ factory: 工厂函数,接收 EmailSettings 返回 EmailProvider 实例。
51
+
52
+ Raises:
53
+ ValueError: 名称为空或与内置名称冲突。
54
+ """
55
+ if not name:
56
+ raise ValueError("provider 名称不能为空")
57
+ if name in _BUILTIN_LOADERS:
58
+ raise ValueError(f"'{name}' 为内置 provider 名称,不可覆盖")
59
+ if name in _REGISTRY:
60
+ logger.warning("Email provider %s 被覆盖注册", name)
61
+ _REGISTRY[name] = factory
62
+
63
+
64
+ def make_email_provider(settings: EmailSettings) -> EmailProvider:
65
+ """根据 `settings.provider` 从注册表构造实例。
66
+
67
+ Args:
68
+ settings: Email 模块配置(项目 Settings 的嵌套字段)。
69
+
70
+ Returns:
71
+ 实现了 `EmailProvider` Protocol 的实例。
72
+
73
+ Raises:
74
+ ValueError: 未知 provider 或必填配置缺失。
75
+ ImportError: 对应内置 extra 未安装。
76
+ """
77
+ name = settings.provider
78
+ _ensure_builtin(name)
79
+ factory = _REGISTRY.get(name)
80
+ if factory is None:
81
+ builtins = ", ".join(sorted(_BUILTIN_LOADERS))
82
+ raise ValueError(
83
+ f"未知 email provider: {name};"
84
+ f"内置: {builtins};自定义需先调用 register_email_provider 注册"
85
+ )
86
+ return factory(settings)
@@ -6,15 +6,15 @@ from typing import Protocol
6
6
  class EmailProvider(Protocol):
7
7
  """邮箱验证码服务抽象接口。
8
8
 
9
- 内置实现:ResendEmailProvider;业务可通过 `register_email_provider` 注入自定义实现。
10
- 通过 `dwyeapi.providers.email.make_email_provider(settings.email)` 工厂根据配置选择实现。
9
+ 内置与自定义走同一注册表;通过 `make_email_provider(settings.email)` 按名构造。
10
+ 业务可通过 `register_email_provider` 注入自定义实现,无需改 eapi。
11
11
  """
12
12
 
13
13
  async def send_code(self, target: str) -> bool:
14
14
  """生成并发送邮箱验证码。
15
15
 
16
16
  Args:
17
- target: 收件邮箱。
17
+ target: 收件邮箱.发信用原地址;Redis key 走 ``canonicalize_email``.
18
18
 
19
19
  Returns:
20
20
  发送成功 True,失败 False(异常已记录日志)。
@@ -25,7 +25,7 @@ class EmailProvider(Protocol):
25
25
  """校验邮箱验证码,成功后一次性删除。
26
26
 
27
27
  Args:
28
- target: 收件邮箱。
28
+ target: 收件邮箱(Gmail 别名与发码时同一收件箱即可).
29
29
  code: 用户提交的验证码。
30
30
 
31
31
  Returns:
@@ -7,6 +7,7 @@ import redis.asyncio as aioredis
7
7
 
8
8
  from dwyeapi.logger import get_logger
9
9
  from dwyeapi.providers.email.base import DEFAULT_CODE_LENGTH, DEFAULT_CODE_TTL, EmailProviderBase
10
+ from dwyeapi.providers.email.config import EmailSettings
10
11
 
11
12
  logger = get_logger(__name__)
12
13
 
@@ -85,3 +86,26 @@ class ResendEmailProvider(EmailProviderBase):
85
86
  except Exception as e:
86
87
  logger.error("Resend email failed: target=%s error=%s", target, e)
87
88
  return False
89
+
90
+
91
+ def build_resend_provider(settings: EmailSettings) -> ResendEmailProvider:
92
+ """注册表回调:校验 Resend 配置后构造实例。
93
+
94
+ 工厂只按名查表,通道特有校验留在本函数,避免 factory.py 写死 if resend。
95
+ """
96
+ if not settings.resend.api_key:
97
+ raise ValueError("EMAIL__RESEND__API_KEY 未配置")
98
+ if not settings.resend.from_email:
99
+ raise ValueError("EMAIL__RESEND__FROM_EMAIL 未配置")
100
+ return ResendEmailProvider(
101
+ api_key=settings.resend.api_key,
102
+ from_email=settings.resend.from_email,
103
+ subject=settings.resend.subject,
104
+ code_ttl=settings.code_ttl,
105
+ code_length=settings.code_length,
106
+ brand_name=settings.brand_name,
107
+ brand_tagline=settings.brand_tagline,
108
+ brand_url=settings.brand_url,
109
+ brand_slogan=settings.brand_slogan,
110
+ support_email=settings.support_email,
111
+ )
@@ -0,0 +1,42 @@
1
+ """任务 API 请求/响应的 Pydantic schema。"""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from dwyeapi.tasks.model import TaskStatus
9
+
10
+ # 与 Task ORM 列 String(50) 对齐;params 键数/体积限制防超大 body
11
+ _TASK_TYPE_MAX_LEN = 50
12
+ _PARAMS_MAX_KEYS = 50
13
+
14
+
15
+ class TaskCreate(BaseModel):
16
+ """提交新任务的请求体。
17
+
18
+ 约束与 ORM / 输入限长规则对齐:``task_type`` 最长 50;``params`` 最多 50 个键。
19
+ """
20
+
21
+ task_type: str = Field(min_length=1, max_length=_TASK_TYPE_MAX_LEN, description="已注册的任务类型名")
22
+ params: dict[str, Any] = Field(
23
+ default_factory=dict,
24
+ description="任务参数;键数量上限 50,避免超大 JSON 入库",
25
+ max_length=_PARAMS_MAX_KEYS,
26
+ )
27
+
28
+
29
+ class TaskResponse(BaseModel):
30
+ """任务详情响应体,覆盖状态、进度、日志与结果等字段。"""
31
+
32
+ id: str
33
+ task_type: str
34
+ status: TaskStatus
35
+ params: dict[str, Any]
36
+ result: dict[str, Any] | None
37
+ progress: int
38
+ logs: str
39
+ created_at: datetime
40
+ updated_at: datetime
41
+
42
+ model_config = ConfigDict(from_attributes=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dwyeapi
3
- Version: 0.9.4
3
+ Version: 0.9.6
4
4
  Summary: Lightweight FastAPI infrastructure — exceptions, database, config, security, cache, pagination, logger
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/danweiyuancircle/dwy-ai-experience
@@ -27,3 +27,4 @@ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
27
27
  Requires-Dist: ruff>=0.6.0; extra == "dev"
28
28
  Requires-Dist: arq>=0.26.0; extra == "dev"
29
29
  Requires-Dist: fakeredis>=2.0.0; extra == "dev"
30
+ Requires-Dist: resend[async]>=2.29.0; extra == "dev"
@@ -6,6 +6,7 @@ src/dwyeapi/config.py
6
6
  src/dwyeapi/database.py
7
7
  src/dwyeapi/dependencies.py
8
8
  src/dwyeapi/dt.py
9
+ src/dwyeapi/email.py
9
10
  src/dwyeapi/exceptions.py
10
11
  src/dwyeapi/health.py
11
12
  src/dwyeapi/logger.py
@@ -39,6 +40,7 @@ tests/test_config.py
39
40
  tests/test_database.py
40
41
  tests/test_dependencies.py
41
42
  tests/test_dt.py
43
+ tests/test_email.py
42
44
  tests/test_exceptions.py
43
45
  tests/test_health.py
44
46
  tests/test_logger.py
@@ -15,6 +15,7 @@ aiosqlite>=0.20.0
15
15
  ruff>=0.6.0
16
16
  arq>=0.26.0
17
17
  fakeredis>=2.0.0
18
+ resend[async]>=2.29.0
18
19
 
19
20
  [email]
20
21
  resend[async]>=2.29.0
@@ -0,0 +1,31 @@
1
+ """Tests for dwyeapi.email(Gmail plus/点号/googlemail 折成同一收件箱)."""
2
+
3
+ from dwyeapi.email import canonicalize_email, is_folded_alias
4
+
5
+
6
+ class TestCanonicalizeEmail:
7
+ def test_strips_gmail_plus_and_dots(self):
8
+ """gmail.com / googlemail.com 去掉 +tag 与点,域名统一 gmail.com."""
9
+ assert canonicalize_email("BaiWen4243+5623@gmail.com") == "baiwen4243@gmail.com"
10
+ assert canonicalize_email("bai.wen.4243+x@googlemail.com") == "baiwen4243@gmail.com"
11
+ assert canonicalize_email(" User.Name+tag@Gmail.COM ") == "username@gmail.com"
12
+
13
+ def test_other_domains_only_lower(self):
14
+ """非 Gmail 只做大小写与首尾空白规范化,保留 + 与点."""
15
+ assert canonicalize_email("Alfred.Yuan+ops@icloud.com") == "alfred.yuan+ops@icloud.com"
16
+ assert canonicalize_email("a.b@example.com") == "a.b@example.com"
17
+
18
+ def test_missing_at_returns_stripped_lower(self):
19
+ """无 @ 的非法输入不抛,只做 strip + lower,交给上游校验."""
20
+ assert canonicalize_email(" NotAnEmail ") == "notanemail"
21
+
22
+
23
+ class TestIsFoldedAlias:
24
+ def test_detects_gmail_variants(self):
25
+ """Gmail plus / 点 / googlemail 视为折叠别名;仅大小写不算."""
26
+ assert is_folded_alias("a+x@gmail.com") is True
27
+ assert is_folded_alias("a.b@gmail.com") is True
28
+ assert is_folded_alias("a@googlemail.com") is True
29
+ assert is_folded_alias("a@gmail.com") is False
30
+ assert is_folded_alias("A@gmail.com") is False
31
+ assert is_folded_alias("a+x@icloud.com") is False
@@ -127,6 +127,30 @@ class TestSchemas:
127
127
  assert tc.task_type == "export"
128
128
  assert tc.params == {"format": "csv"}
129
129
 
130
+ def test_task_create_rejects_empty_task_type(self):
131
+ """task_type 最短 1 字符。"""
132
+ import pytest
133
+ from pydantic import ValidationError
134
+
135
+ with pytest.raises(ValidationError):
136
+ TaskCreate(task_type="", params={})
137
+
138
+ def test_task_create_rejects_long_task_type(self):
139
+ """task_type 最长 50,对齐 ORM String(50)。"""
140
+ import pytest
141
+ from pydantic import ValidationError
142
+
143
+ with pytest.raises(ValidationError):
144
+ TaskCreate(task_type="x" * 51, params={})
145
+
146
+ def test_task_create_rejects_too_many_params_keys(self):
147
+ """params 键数上限 50。"""
148
+ import pytest
149
+ from pydantic import ValidationError
150
+
151
+ with pytest.raises(ValidationError):
152
+ TaskCreate(task_type="export", params={f"k{i}": i for i in range(51)})
153
+
130
154
  async def test_task_response_from_attributes(self, session):
131
155
  """TaskResponse should be constructible from a persisted Task ORM object."""
132
156
  task = Task(
@@ -1,80 +0,0 @@
1
- """Email Provider 工厂 -- 内置 resend,支持外部注册自定义 provider。"""
2
-
3
- from collections.abc import Callable
4
-
5
- from dwyeapi.logger import get_logger
6
- from dwyeapi.providers.email.config import EmailSettings
7
- from dwyeapi.providers.email.protocol import EmailProvider
8
-
9
- logger = get_logger(__name__)
10
-
11
- EmailProviderFactory = Callable[[EmailSettings], EmailProvider]
12
-
13
- _BUILTIN_RESEND = "resend"
14
- _PROVIDER_REGISTRY: dict[str, EmailProviderFactory] = {}
15
-
16
-
17
- def register_email_provider(name: str, factory: EmailProviderFactory) -> None:
18
- """注册自定义 Email Provider。
19
-
20
- 业务项目继承 `EmailProviderBase` 实现 `_send` 后,通过此函数注册到工厂,
21
- `.env` 设 `EMAIL__PROVIDER=<name>` 即可启用。
22
-
23
- Args:
24
- name: provider 名称,与 EMAIL__PROVIDER 环境变量值对应。
25
- factory: 工厂函数,接收 EmailSettings 返回 EmailProvider 实例。
26
-
27
- Raises:
28
- ValueError: 名称为空或与内置名称冲突。
29
- """
30
- if not name:
31
- raise ValueError("provider 名称不能为空")
32
- if name == _BUILTIN_RESEND:
33
- raise ValueError(f"'{name}' 为内置 provider 名称,不可覆盖")
34
- if name in _PROVIDER_REGISTRY:
35
- logger.warning("Email provider %s 被覆盖注册", name)
36
- _PROVIDER_REGISTRY[name] = factory
37
-
38
-
39
- def make_email_provider(settings: EmailSettings) -> EmailProvider:
40
- """根据 `settings.provider` 构造对应的 Email Provider 实例。
41
-
42
- Args:
43
- settings: Email 模块配置(项目 Settings 的嵌套字段)。
44
-
45
- Returns:
46
- 实现了 `EmailProvider` Protocol 的实例。
47
-
48
- Raises:
49
- ValueError: 未知 provider 或必填配置缺失。
50
- ImportError: resend extra 未安装。
51
- """
52
- common = {
53
- "code_ttl": settings.code_ttl,
54
- "code_length": settings.code_length,
55
- "brand_name": settings.brand_name,
56
- "brand_tagline": settings.brand_tagline,
57
- "brand_url": settings.brand_url,
58
- "brand_slogan": settings.brand_slogan,
59
- "support_email": settings.support_email,
60
- }
61
-
62
- if settings.provider == _BUILTIN_RESEND:
63
- from dwyeapi.providers.email.resend import ResendEmailProvider
64
-
65
- if not settings.resend.api_key:
66
- raise ValueError("EMAIL__RESEND__API_KEY 未配置")
67
- return ResendEmailProvider(
68
- api_key=settings.resend.api_key,
69
- from_email=settings.resend.from_email,
70
- subject=settings.resend.subject,
71
- **common,
72
- )
73
-
74
- if settings.provider in _PROVIDER_REGISTRY:
75
- return _PROVIDER_REGISTRY[settings.provider](settings)
76
-
77
- raise ValueError(
78
- f"未知 email provider: {settings.provider};"
79
- f"内置仅支持 'resend',自定义 provider 需先调用 register_email_provider 注册"
80
- )
@@ -1,31 +0,0 @@
1
- """任务 API 请求/响应的 Pydantic schema。"""
2
-
3
- from datetime import datetime
4
- from typing import Any
5
-
6
- from pydantic import BaseModel, ConfigDict
7
-
8
- from dwyeapi.tasks.model import TaskStatus
9
-
10
-
11
- class TaskCreate(BaseModel):
12
- """提交新任务的请求体。"""
13
-
14
- task_type: str
15
- params: dict[str, Any]
16
-
17
-
18
- class TaskResponse(BaseModel):
19
- """任务详情响应体,覆盖状态、进度、日志与结果等字段。"""
20
-
21
- id: str
22
- task_type: str
23
- status: TaskStatus
24
- params: dict[str, Any]
25
- result: dict[str, Any] | None
26
- progress: int
27
- logs: str
28
- created_at: datetime
29
- updated_at: datetime
30
-
31
- model_config = ConfigDict(from_attributes=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes