paper-scoring-reporting 0.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.
- paper_scoring_reporting-0.1.0/LICENSE +21 -0
- paper_scoring_reporting-0.1.0/PKG-INFO +107 -0
- paper_scoring_reporting-0.1.0/README.md +73 -0
- paper_scoring_reporting-0.1.0/pyproject.toml +85 -0
- paper_scoring_reporting-0.1.0/setup.cfg +4 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/__init__.py +33 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/email.py +78 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/models.py +136 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/normalize.py +344 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/py.typed +1 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/renderer.py +500 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/smtp.py +124 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/templates/report.html +29 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/templates/report.txt +6 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/validation.py +64 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/PKG-INFO +107 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/SOURCES.txt +23 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/dependency_links.txt +1 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/requires.txt +17 -0
- paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/top_level.txt +1 -0
- paper_scoring_reporting-0.1.0/tests/test_email.py +128 -0
- paper_scoring_reporting-0.1.0/tests/test_functional.py +41 -0
- paper_scoring_reporting-0.1.0/tests/test_renderer.py +328 -0
- paper_scoring_reporting-0.1.0/tests/test_smtp.py +283 -0
- paper_scoring_reporting-0.1.0/tests/test_validation.py +56 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paper Scoring contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paper-scoring-reporting
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safe HTML, text, PDF, and SMTP reporting for Paper Scoring results
|
|
5
|
+
Author: Paper Scoring contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/maikeruSan/paper-scoring-reporting
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Communications :: Email
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: paper-scoring-core<0.2,>=0.1
|
|
19
|
+
Requires-Dist: reportlab<5,>=4.2
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: build<2,>=1.2; extra == "test"
|
|
22
|
+
Requires-Dist: mypy<2,>=1.18; extra == "test"
|
|
23
|
+
Requires-Dist: pypdf<7,>=5; extra == "test"
|
|
24
|
+
Requires-Dist: pytest<10,>=9.0.3; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-cov<8,>=6; extra == "test"
|
|
26
|
+
Requires-Dist: ruff<1,>=0.12; extra == "test"
|
|
27
|
+
Requires-Dist: twine<7,>=6; extra == "test"
|
|
28
|
+
Requires-Dist: types-reportlab<5,>=4.4; extra == "test"
|
|
29
|
+
Provides-Extra: security
|
|
30
|
+
Requires-Dist: bandit[toml]<2,>=1.8; extra == "security"
|
|
31
|
+
Requires-Dist: detect-secrets<2,>=1.5; extra == "security"
|
|
32
|
+
Requires-Dist: pip-audit<3,>=2.9; extra == "security"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# Paper Scoring Reporting
|
|
36
|
+
|
|
37
|
+
`paper-scoring-reporting` turns `paper-scoring-core` score results into plain-text,
|
|
38
|
+
mobile-friendly HTML, and PDF reports. SMTP delivery is a separate, explicitly
|
|
39
|
+
configured step. The package never reads `.env` files or process environment
|
|
40
|
+
variables.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```shell
|
|
45
|
+
python -m pip install paper-scoring-reporting
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Render and deliver
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from datetime import date
|
|
52
|
+
|
|
53
|
+
from paper_scoring_reporting import (
|
|
54
|
+
ReportMetadata,
|
|
55
|
+
SMTPSettings,
|
|
56
|
+
SMTPSender,
|
|
57
|
+
compose_report_email,
|
|
58
|
+
render_report,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
report = render_report(
|
|
62
|
+
score_results,
|
|
63
|
+
ReportMetadata(
|
|
64
|
+
report_date=date.today(),
|
|
65
|
+
category="cs.AI",
|
|
66
|
+
scoring_version="3.0.0",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
settings = SMTPSettings(
|
|
71
|
+
host="smtp.example.invalid",
|
|
72
|
+
port=587,
|
|
73
|
+
sender="automation@example.invalid",
|
|
74
|
+
username="automation@example.invalid",
|
|
75
|
+
password=runtime_password,
|
|
76
|
+
)
|
|
77
|
+
email = compose_report_email(
|
|
78
|
+
sender=settings.sender,
|
|
79
|
+
recipients=["reviewer@example.invalid"],
|
|
80
|
+
subject="Paper scoring report",
|
|
81
|
+
report=report,
|
|
82
|
+
)
|
|
83
|
+
SMTPSender(settings).send(email)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Inject credentials at runtime from your process supervisor, secret store, CLI, or
|
|
87
|
+
MCP composition root. Do not put credentials in source files or report data.
|
|
88
|
+
|
|
89
|
+
## Accepted result values
|
|
90
|
+
|
|
91
|
+
The renderer accepts a sequence of `paper_scoring_core.ScoreResult` objects, objects
|
|
92
|
+
with a safe `model_dump()` method, dataclass instances, or mappings. Only the public
|
|
93
|
+
score-report fields are selected; arbitrary object attributes and unknown mapping
|
|
94
|
+
keys are ignored. Legacy title-cased mapping keys are accepted for migration.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```shell
|
|
99
|
+
python -m pip install -e '.[test,security]'
|
|
100
|
+
pytest
|
|
101
|
+
ruff check .
|
|
102
|
+
bandit -c pyproject.toml -r src
|
|
103
|
+
pip-audit
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
CI exercises Python 3.11 through 3.13 on Ubuntu and Windows, builds wheel and sdist
|
|
107
|
+
artifacts, and verifies their metadata.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Paper Scoring Reporting
|
|
2
|
+
|
|
3
|
+
`paper-scoring-reporting` turns `paper-scoring-core` score results into plain-text,
|
|
4
|
+
mobile-friendly HTML, and PDF reports. SMTP delivery is a separate, explicitly
|
|
5
|
+
configured step. The package never reads `.env` files or process environment
|
|
6
|
+
variables.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```shell
|
|
11
|
+
python -m pip install paper-scoring-reporting
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Render and deliver
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from datetime import date
|
|
18
|
+
|
|
19
|
+
from paper_scoring_reporting import (
|
|
20
|
+
ReportMetadata,
|
|
21
|
+
SMTPSettings,
|
|
22
|
+
SMTPSender,
|
|
23
|
+
compose_report_email,
|
|
24
|
+
render_report,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
report = render_report(
|
|
28
|
+
score_results,
|
|
29
|
+
ReportMetadata(
|
|
30
|
+
report_date=date.today(),
|
|
31
|
+
category="cs.AI",
|
|
32
|
+
scoring_version="3.0.0",
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
settings = SMTPSettings(
|
|
37
|
+
host="smtp.example.invalid",
|
|
38
|
+
port=587,
|
|
39
|
+
sender="automation@example.invalid",
|
|
40
|
+
username="automation@example.invalid",
|
|
41
|
+
password=runtime_password,
|
|
42
|
+
)
|
|
43
|
+
email = compose_report_email(
|
|
44
|
+
sender=settings.sender,
|
|
45
|
+
recipients=["reviewer@example.invalid"],
|
|
46
|
+
subject="Paper scoring report",
|
|
47
|
+
report=report,
|
|
48
|
+
)
|
|
49
|
+
SMTPSender(settings).send(email)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Inject credentials at runtime from your process supervisor, secret store, CLI, or
|
|
53
|
+
MCP composition root. Do not put credentials in source files or report data.
|
|
54
|
+
|
|
55
|
+
## Accepted result values
|
|
56
|
+
|
|
57
|
+
The renderer accepts a sequence of `paper_scoring_core.ScoreResult` objects, objects
|
|
58
|
+
with a safe `model_dump()` method, dataclass instances, or mappings. Only the public
|
|
59
|
+
score-report fields are selected; arbitrary object attributes and unknown mapping
|
|
60
|
+
keys are ignored. Legacy title-cased mapping keys are accepted for migration.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
python -m pip install -e '.[test,security]'
|
|
66
|
+
pytest
|
|
67
|
+
ruff check .
|
|
68
|
+
bandit -c pyproject.toml -r src
|
|
69
|
+
pip-audit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
CI exercises Python 3.11 through 3.13 on Ubuntu and Windows, builds wheel and sdist
|
|
73
|
+
artifacts, and verifies their metadata.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools==84.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "paper-scoring-reporting"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Safe HTML, text, PDF, and SMTP reporting for Paper Scoring results"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Paper Scoring contributors" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Communications :: Email",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"paper-scoring-core>=0.1,<0.2",
|
|
25
|
+
"reportlab>=4.2,<5",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = [
|
|
30
|
+
"build>=1.2,<2",
|
|
31
|
+
"mypy>=1.18,<2",
|
|
32
|
+
"pypdf>=5,<7",
|
|
33
|
+
"pytest>=9.0.3,<10",
|
|
34
|
+
"pytest-cov>=6,<8",
|
|
35
|
+
"ruff>=0.12,<1",
|
|
36
|
+
"twine>=6,<7",
|
|
37
|
+
"types-reportlab>=4.4,<5",
|
|
38
|
+
]
|
|
39
|
+
security = [
|
|
40
|
+
"bandit[toml]>=1.8,<2",
|
|
41
|
+
"detect-secrets>=1.5,<2",
|
|
42
|
+
"pip-audit>=2.9,<3",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Repository = "https://github.com/maikeruSan/paper-scoring-reporting"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
paper_scoring_reporting = ["py.typed", "templates/*.html", "templates/*.txt"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
addopts = "-ra --strict-markers"
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
|
|
58
|
+
[tool.coverage.run]
|
|
59
|
+
branch = true
|
|
60
|
+
relative_files = true
|
|
61
|
+
source = ["paper_scoring_reporting"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
fail_under = 90
|
|
65
|
+
show_missing = true
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 88
|
|
69
|
+
target-version = "py311"
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["E", "F", "I", "B", "S", "UP"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.per-file-ignores]
|
|
75
|
+
"src/paper_scoring_reporting/renderer.py" = ["E501"]
|
|
76
|
+
"tests/**" = ["S101", "S106"]
|
|
77
|
+
"tests/check_distribution.py" = ["S603"]
|
|
78
|
+
|
|
79
|
+
[tool.bandit]
|
|
80
|
+
exclude_dirs = ["tests"]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.11"
|
|
84
|
+
strict = true
|
|
85
|
+
files = ["src/paper_scoring_reporting"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Render and deliver Paper Scoring reports without import-time side effects."""
|
|
2
|
+
|
|
3
|
+
from .email import compose_email, compose_report_email
|
|
4
|
+
from .models import (
|
|
5
|
+
Attachment,
|
|
6
|
+
PreparedEmail,
|
|
7
|
+
RenderedReport,
|
|
8
|
+
ReportMetadata,
|
|
9
|
+
SMTPSettings,
|
|
10
|
+
TLSMode,
|
|
11
|
+
)
|
|
12
|
+
from .renderer import ReportRenderer, build_report, render_report
|
|
13
|
+
from .smtp import SMTPDeliveryError, SMTPDeliveryResult, SMTPSender, send_email
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Attachment",
|
|
17
|
+
"PreparedEmail",
|
|
18
|
+
"RenderedReport",
|
|
19
|
+
"ReportMetadata",
|
|
20
|
+
"ReportRenderer",
|
|
21
|
+
"SMTPDeliveryError",
|
|
22
|
+
"SMTPDeliveryResult",
|
|
23
|
+
"SMTPSettings",
|
|
24
|
+
"SMTPSender",
|
|
25
|
+
"TLSMode",
|
|
26
|
+
"build_report",
|
|
27
|
+
"compose_email",
|
|
28
|
+
"compose_report_email",
|
|
29
|
+
"render_report",
|
|
30
|
+
"send_email",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Compose standards-compliant report email without opening a network connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
from email.policy import SMTP
|
|
8
|
+
|
|
9
|
+
from .models import Attachment, PreparedEmail, RenderedReport
|
|
10
|
+
from .validation import validate_email_address, validate_header, validate_mime_type
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compose_email(
|
|
14
|
+
*,
|
|
15
|
+
sender: str,
|
|
16
|
+
recipients: Sequence[str],
|
|
17
|
+
subject: str,
|
|
18
|
+
plain_text: str,
|
|
19
|
+
html: str,
|
|
20
|
+
attachments: Sequence[Attachment] = (),
|
|
21
|
+
) -> PreparedEmail:
|
|
22
|
+
"""Build a validated MIME email without performing delivery."""
|
|
23
|
+
|
|
24
|
+
sender = validate_email_address(sender)
|
|
25
|
+
if isinstance(recipients, (str, bytes, bytearray)):
|
|
26
|
+
raise TypeError("recipients must be a sequence of email addresses")
|
|
27
|
+
validated_recipients = tuple(validate_email_address(item) for item in recipients)
|
|
28
|
+
if not validated_recipients:
|
|
29
|
+
raise ValueError("at least one recipient is required")
|
|
30
|
+
subject = validate_header(subject, name="subject")
|
|
31
|
+
if not isinstance(plain_text, str) or not isinstance(html, str):
|
|
32
|
+
raise TypeError("plain_text and html must be strings")
|
|
33
|
+
if isinstance(attachments, (str, bytes, bytearray)):
|
|
34
|
+
raise TypeError("attachments must be a sequence")
|
|
35
|
+
|
|
36
|
+
message = EmailMessage(policy=SMTP)
|
|
37
|
+
message["From"] = sender
|
|
38
|
+
message["To"] = ", ".join(validated_recipients)
|
|
39
|
+
message["Subject"] = subject
|
|
40
|
+
message.set_content(plain_text, subtype="plain", charset="utf-8")
|
|
41
|
+
message.add_alternative(html, subtype="html", charset="utf-8")
|
|
42
|
+
for attachment in attachments:
|
|
43
|
+
if not isinstance(attachment, Attachment):
|
|
44
|
+
raise TypeError("attachments must contain Attachment values")
|
|
45
|
+
main_type, subtype = validate_mime_type(attachment.content_type)
|
|
46
|
+
message.add_attachment(
|
|
47
|
+
attachment.content,
|
|
48
|
+
maintype=main_type,
|
|
49
|
+
subtype=subtype,
|
|
50
|
+
filename=attachment.filename,
|
|
51
|
+
)
|
|
52
|
+
return PreparedEmail(message, validated_recipients)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compose_report_email(
|
|
56
|
+
*,
|
|
57
|
+
sender: str,
|
|
58
|
+
recipients: Sequence[str],
|
|
59
|
+
subject: str,
|
|
60
|
+
report: RenderedReport,
|
|
61
|
+
attachments: Sequence[Attachment] = (),
|
|
62
|
+
) -> PreparedEmail:
|
|
63
|
+
"""Build a MIME message with text/HTML alternatives and a PDF attachment."""
|
|
64
|
+
|
|
65
|
+
if not isinstance(report, RenderedReport):
|
|
66
|
+
raise TypeError("report must be a RenderedReport")
|
|
67
|
+
all_attachments = (
|
|
68
|
+
Attachment(report.pdf_filename, report.pdf, "application/pdf"),
|
|
69
|
+
*attachments,
|
|
70
|
+
)
|
|
71
|
+
return compose_email(
|
|
72
|
+
sender=sender,
|
|
73
|
+
recipients=recipients,
|
|
74
|
+
subject=subject,
|
|
75
|
+
plain_text=report.plain_text,
|
|
76
|
+
html=report.html,
|
|
77
|
+
attachments=all_attachments,
|
|
78
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Public immutable value objects for reports and email delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date
|
|
7
|
+
from email.message import EmailMessage
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from .validation import (
|
|
11
|
+
validate_attachment_filename,
|
|
12
|
+
validate_email_address,
|
|
13
|
+
validate_header,
|
|
14
|
+
validate_hostname,
|
|
15
|
+
validate_mime_type,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TLSMode(StrEnum):
|
|
20
|
+
"""Supported secure SMTP transports. Clear-text SMTP is intentionally absent."""
|
|
21
|
+
|
|
22
|
+
STARTTLS = "starttls"
|
|
23
|
+
IMPLICIT = "implicit"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ReportMetadata:
|
|
28
|
+
report_date: date | str
|
|
29
|
+
category: str
|
|
30
|
+
scoring_version: str
|
|
31
|
+
title: str | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
value = self.report_date
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
try:
|
|
37
|
+
value = date.fromisoformat(value)
|
|
38
|
+
except ValueError as error:
|
|
39
|
+
raise ValueError("report_date must use YYYY-MM-DD") from error
|
|
40
|
+
object.__setattr__(self, "report_date", value)
|
|
41
|
+
elif not isinstance(value, date):
|
|
42
|
+
raise TypeError("report_date must be a date or ISO date string")
|
|
43
|
+
category = validate_header(self.category, name="category").strip()
|
|
44
|
+
version = validate_header(self.scoring_version, name="scoring_version").strip()
|
|
45
|
+
if len(category) > 100 or len(version) > 100:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"category and scoring_version must be at most 100 characters"
|
|
48
|
+
)
|
|
49
|
+
object.__setattr__(self, "category", category)
|
|
50
|
+
object.__setattr__(self, "scoring_version", version)
|
|
51
|
+
if self.title is not None:
|
|
52
|
+
title = validate_header(self.title, name="title").strip()
|
|
53
|
+
if len(title) > 200:
|
|
54
|
+
raise ValueError("title must be at most 200 characters")
|
|
55
|
+
object.__setattr__(self, "title", title)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def display_title(self) -> str:
|
|
59
|
+
return self.title or f"{self.category} 論文評分摘要"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class RenderedReport:
|
|
64
|
+
plain_text: str
|
|
65
|
+
html: str
|
|
66
|
+
pdf: bytes
|
|
67
|
+
pdf_filename: str
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
validate_attachment_filename(self.pdf_filename)
|
|
71
|
+
if not self.pdf.startswith(b"%PDF"):
|
|
72
|
+
raise ValueError("pdf must contain a PDF document")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class Attachment:
|
|
77
|
+
filename: str
|
|
78
|
+
content: bytes
|
|
79
|
+
content_type: str = "application/octet-stream"
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
object.__setattr__(
|
|
83
|
+
self, "filename", validate_attachment_filename(self.filename)
|
|
84
|
+
)
|
|
85
|
+
if not isinstance(self.content, bytes):
|
|
86
|
+
raise TypeError("attachment content must be bytes")
|
|
87
|
+
validate_mime_type(self.content_type)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class PreparedEmail:
|
|
92
|
+
message: EmailMessage
|
|
93
|
+
envelope_recipients: tuple[str, ...]
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
if not isinstance(self.message, EmailMessage):
|
|
97
|
+
raise TypeError("message must be an EmailMessage")
|
|
98
|
+
if not self.envelope_recipients:
|
|
99
|
+
raise ValueError("at least one recipient is required")
|
|
100
|
+
validated = tuple(
|
|
101
|
+
validate_email_address(item) for item in self.envelope_recipients
|
|
102
|
+
)
|
|
103
|
+
object.__setattr__(self, "envelope_recipients", validated)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class SMTPSettings:
|
|
108
|
+
host: str = field(repr=False)
|
|
109
|
+
port: int
|
|
110
|
+
sender: str = field(repr=False)
|
|
111
|
+
username: str | None = field(default=None, repr=False)
|
|
112
|
+
password: str | None = field(default=None, repr=False)
|
|
113
|
+
tls_mode: TLSMode = TLSMode.STARTTLS
|
|
114
|
+
timeout_seconds: float = 30.0
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
object.__setattr__(self, "host", validate_hostname(self.host))
|
|
118
|
+
object.__setattr__(self, "sender", validate_email_address(self.sender))
|
|
119
|
+
if not isinstance(self.port, int) or isinstance(self.port, bool):
|
|
120
|
+
raise TypeError("SMTP port must be an integer")
|
|
121
|
+
if not 1 <= self.port <= 65535:
|
|
122
|
+
raise ValueError("SMTP port must be between 1 and 65535")
|
|
123
|
+
if not isinstance(self.timeout_seconds, (int, float)):
|
|
124
|
+
raise TypeError("timeout_seconds must be numeric")
|
|
125
|
+
if not 0 < float(self.timeout_seconds) <= 300:
|
|
126
|
+
raise ValueError("timeout_seconds must be between 0 and 300")
|
|
127
|
+
try:
|
|
128
|
+
tls_mode = TLSMode(self.tls_mode)
|
|
129
|
+
except (TypeError, ValueError) as error:
|
|
130
|
+
raise ValueError("unsupported TLS mode") from error
|
|
131
|
+
object.__setattr__(self, "tls_mode", tls_mode)
|
|
132
|
+
if (self.username is None) != (self.password is None):
|
|
133
|
+
raise ValueError("username and password must be supplied together")
|
|
134
|
+
if self.username is not None:
|
|
135
|
+
username = validate_header(self.username, name="SMTP username").strip()
|
|
136
|
+
object.__setattr__(self, "username", username)
|