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.
Files changed (25) hide show
  1. paper_scoring_reporting-0.1.0/LICENSE +21 -0
  2. paper_scoring_reporting-0.1.0/PKG-INFO +107 -0
  3. paper_scoring_reporting-0.1.0/README.md +73 -0
  4. paper_scoring_reporting-0.1.0/pyproject.toml +85 -0
  5. paper_scoring_reporting-0.1.0/setup.cfg +4 -0
  6. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/__init__.py +33 -0
  7. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/email.py +78 -0
  8. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/models.py +136 -0
  9. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/normalize.py +344 -0
  10. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/py.typed +1 -0
  11. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/renderer.py +500 -0
  12. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/smtp.py +124 -0
  13. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/templates/report.html +29 -0
  14. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/templates/report.txt +6 -0
  15. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting/validation.py +64 -0
  16. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/PKG-INFO +107 -0
  17. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/SOURCES.txt +23 -0
  18. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/dependency_links.txt +1 -0
  19. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/requires.txt +17 -0
  20. paper_scoring_reporting-0.1.0/src/paper_scoring_reporting.egg-info/top_level.txt +1 -0
  21. paper_scoring_reporting-0.1.0/tests/test_email.py +128 -0
  22. paper_scoring_reporting-0.1.0/tests/test_functional.py +41 -0
  23. paper_scoring_reporting-0.1.0/tests/test_renderer.py +328 -0
  24. paper_scoring_reporting-0.1.0/tests/test_smtp.py +283 -0
  25. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)