Add document export (.docx) and email delivery feature

Press releases now auto-generate .docx files alongside .txt for native
Google Docs import. New email_file chat tool sends files via Gmail SMTP
with app password auth, auto-converting .txt to .docx before sending.
Also includes Press Advantage API config and submit_press_release tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-16 17:00:54 -06:00
parent 7864ec6f17
commit 8f6e218221
10 changed files with 753 additions and 2 deletions

View File

@ -48,6 +48,22 @@ class ClickUpConfig:
enabled: bool = False enabled: bool = False
@dataclass
class PressAdvantageConfig:
api_key: str = ""
base_url: str = "https://app.pressadvantage.com"
@dataclass
class EmailConfig:
smtp_host: str = "smtp.gmail.com"
smtp_port: int = 465
username: str = ""
password: str = ""
default_to: str = ""
enabled: bool = False
@dataclass @dataclass
class Config: class Config:
chat_model: str = "openai/gpt-4o-mini" chat_model: str = "openai/gpt-4o-mini"
@ -61,6 +77,8 @@ class Config:
scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) scheduler: SchedulerConfig = field(default_factory=SchedulerConfig)
shell: ShellConfig = field(default_factory=ShellConfig) shell: ShellConfig = field(default_factory=ShellConfig)
clickup: ClickUpConfig = field(default_factory=ClickUpConfig) clickup: ClickUpConfig = field(default_factory=ClickUpConfig)
press_advantage: PressAdvantageConfig = field(default_factory=PressAdvantageConfig)
email: EmailConfig = field(default_factory=EmailConfig)
# Derived paths # Derived paths
root_dir: Path = field(default_factory=lambda: ROOT_DIR) root_dir: Path = field(default_factory=lambda: ROOT_DIR)
@ -99,6 +117,14 @@ def load_config() -> Config:
for k, v in data["clickup"].items(): for k, v in data["clickup"].items():
if hasattr(cfg.clickup, k): if hasattr(cfg.clickup, k):
setattr(cfg.clickup, k, v) setattr(cfg.clickup, k, v)
if "press_advantage" in data and isinstance(data["press_advantage"], dict):
for k, v in data["press_advantage"].items():
if hasattr(cfg.press_advantage, k):
setattr(cfg.press_advantage, k, v)
if "email" in data and isinstance(data["email"], dict):
for k, v in data["email"].items():
if hasattr(cfg.email, k):
setattr(cfg.email, k, v)
# Env var overrides (CHEDDAH_ prefix) # Env var overrides (CHEDDAH_ prefix)
cfg.openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "") cfg.openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "")
@ -121,6 +147,19 @@ def load_config() -> Config:
# Auto-enable if token is present # Auto-enable if token is present
cfg.clickup.enabled = bool(cfg.clickup.api_token) cfg.clickup.enabled = bool(cfg.clickup.api_token)
# Press Advantage env var override
if key := os.getenv("PRESS_ADVANTAGE_API"):
cfg.press_advantage.api_key = key
# Email env var overrides
if gmail_user := os.getenv("GMAIL_USERNAME"):
cfg.email.username = gmail_user
if gmail_pass := os.getenv("GMAIL_APP_PASSWORD"):
cfg.email.password = gmail_pass
if default_to := os.getenv("EMAIL_DEFAULT_TO"):
cfg.email.default_to = default_to
cfg.email.enabled = bool(cfg.email.username and cfg.email.password)
# Ensure data directories exist # Ensure data directories exist
cfg.data_dir.mkdir(parents=True, exist_ok=True) cfg.data_dir.mkdir(parents=True, exist_ok=True)
(cfg.data_dir / "uploads").mkdir(exist_ok=True) (cfg.data_dir / "uploads").mkdir(exist_ok=True)

View File

@ -0,0 +1,77 @@
"""Convert plain-text press releases to formatted .docx files."""
from __future__ import annotations
import logging
from pathlib import Path
from docx import Document
from docx.shared import Pt
log = logging.getLogger(__name__)
# Standard PR format
_HEADLINE_FONT = "Times New Roman"
_HEADLINE_SIZE = Pt(16)
_BODY_FONT = "Times New Roman"
_BODY_SIZE = Pt(12)
def text_to_docx(text: str, output_path: Path) -> Path:
"""Convert a plain-text press release into a formatted .docx file.
Layout:
- First non-blank line headline (bold, 16pt Times New Roman)
- Remaining lines body paragraphs (12pt Times New Roman)
- Blank lines in the source start new paragraphs.
Returns the output path.
"""
doc = Document()
# Set default font for the document
style = doc.styles["Normal"]
style.font.name = _BODY_FONT
style.font.size = _BODY_SIZE
lines = text.strip().splitlines()
if not lines:
doc.save(str(output_path))
return output_path
# First non-blank line is the headline
headline = lines[0].strip()
h_para = doc.add_paragraph()
h_run = h_para.add_run(headline)
h_run.bold = True
h_run.font.name = _HEADLINE_FONT
h_run.font.size = _HEADLINE_SIZE
# Group remaining lines into paragraphs (split on blank lines)
body_lines = lines[1:]
current_para_lines: list[str] = []
for line in body_lines:
if line.strip() == "":
if current_para_lines:
_add_body_paragraph(doc, " ".join(current_para_lines))
current_para_lines = []
else:
current_para_lines.append(line.strip())
# Flush any remaining lines
if current_para_lines:
_add_body_paragraph(doc, " ".join(current_para_lines))
output_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(output_path))
log.info("Saved .docx: %s", output_path)
return output_path
def _add_body_paragraph(doc: Document, text: str) -> None:
"""Add a body paragraph with standard PR formatting."""
para = doc.add_paragraph()
run = para.add_run(text)
run.font.name = _BODY_FONT
run.font.size = _BODY_SIZE

View File

@ -0,0 +1,56 @@
"""Email client for sending files via Gmail SMTP."""
from __future__ import annotations
import logging
import smtplib
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
log = logging.getLogger(__name__)
class EmailClient:
"""Send emails with attachments via SMTP_SSL (Gmail app-password friendly)."""
def __init__(self, smtp_host: str, smtp_port: int, username: str, password: str):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
def send(
self,
to: str,
subject: str,
body: str,
attachments: list[Path] | None = None,
) -> None:
"""Send an email, optionally with file attachments.
Raises smtplib.SMTPException on failure.
"""
msg = MIMEMultipart()
msg["From"] = self.username
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
for file_path in attachments or []:
part = MIMEBase("application", "octet-stream")
part.set_payload(file_path.read_bytes())
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename={file_path.name}",
)
msg.attach(part)
log.info("Sending email to %s — subject: %s", to, subject)
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port) as server:
server.login(self.username, self.password)
server.send_message(msg)
log.info("Email sent successfully to %s", to)

View File

@ -0,0 +1,73 @@
"""Email delivery tool — send files as attachments via Gmail SMTP."""
from __future__ import annotations
import logging
from pathlib import Path
from ..docx_export import text_to_docx
from ..email import EmailClient
from . import tool
log = logging.getLogger(__name__)
@tool(
"email_file",
description=(
"Email a file as an attachment. If the file is .txt it is auto-converted "
"to .docx before sending. Defaults to the configured recipient address."
),
category="delivery",
)
def email_file(
file_path: str,
to: str = "",
subject: str = "",
ctx: dict | None = None,
) -> str:
"""Send a file by email, auto-converting .txt to .docx."""
if not ctx or "config" not in ctx:
return "Error: email tool requires agent context."
email_cfg = ctx["config"].email
if not email_cfg.enabled:
return "Error: email not configured. Set GMAIL_USERNAME and GMAIL_APP_PASSWORD in .env."
src = Path(file_path)
if not src.exists():
return f"Error: file not found: {file_path}"
# Auto-convert .txt → .docx
if src.suffix.lower() == ".txt":
docx_path = src.with_suffix(".docx")
text_to_docx(src.read_text(encoding="utf-8"), docx_path)
attachment = docx_path
else:
attachment = src
recipient = to or email_cfg.default_to
if not recipient:
return "Error: no recipient specified and EMAIL_DEFAULT_TO not set."
mail_subject = subject or f"CheddahBot: {attachment.stem}"
client = EmailClient(
smtp_host=email_cfg.smtp_host,
smtp_port=email_cfg.smtp_port,
username=email_cfg.username,
password=email_cfg.password,
)
try:
client.send(
to=recipient,
subject=mail_subject,
body=f"Attached: {attachment.name}",
attachments=[attachment],
)
except Exception as e:
log.error("Email send failed: %s", e)
return f"Error sending email: {e}"
return f"Sent {attachment.name} to {recipient}."

View File

@ -17,6 +17,8 @@ import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from ..docx_export import text_to_docx
from ..press_advantage import PressAdvantageClient
from . import tool from . import tool
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -370,6 +372,7 @@ def write_press_releases(
log.info("[PR Pipeline] Step 3/4: Writing 2 press releases...") log.info("[PR Pipeline] Step 3/4: Writing 2 press releases...")
pr_texts: list[str] = [] pr_texts: list[str] = []
pr_files: list[str] = [] pr_files: list[str] = []
docx_files: list[str] = []
for i, headline in enumerate(winners): for i, headline in enumerate(winners):
log.info("[PR Pipeline] Writing PR %d/2: %s", i + 1, headline[:60]) log.info("[PR Pipeline] Writing PR %d/2: %s", i + 1, headline[:60])
_set_status(ctx, f"Step 3/4: Writing press release {i+1}/2 — {headline[:60]}...") _set_status(ctx, f"Step 3/4: Writing press release {i+1}/2 — {headline[:60]}...")
@ -403,6 +406,11 @@ def write_press_releases(
filepath.write_text(clean_result, encoding="utf-8") filepath.write_text(clean_result, encoding="utf-8")
pr_files.append(str(filepath)) pr_files.append(str(filepath))
# Also save as .docx for Google Docs import
docx_path = output_dir / f"{slug}_{today}.docx"
text_to_docx(clean_result, docx_path)
docx_files.append(str(docx_path))
# ── Step 4: Generate 2 JSON-LD schemas (Sonnet + WebSearch) ─────────── # ── Step 4: Generate 2 JSON-LD schemas (Sonnet + WebSearch) ───────────
log.info("[PR Pipeline] Step 4/4: Generating 2 JSON-LD schemas...") log.info("[PR Pipeline] Step 4/4: Generating 2 JSON-LD schemas...")
schema_texts: list[str] = [] schema_texts: list[str] = []
@ -455,7 +463,8 @@ def write_press_releases(
wc = _word_count(pr_texts[i]) wc = _word_count(pr_texts[i])
output_parts.append(f"## Press Release {label}: {winners[i]}") output_parts.append(f"## Press Release {label}: {winners[i]}")
output_parts.append(f"**Word count:** {wc}") output_parts.append(f"**Word count:** {wc}")
output_parts.append(f"**File:** `{pr_files[i]}`\n") output_parts.append(f"**File:** `{pr_files[i]}`")
output_parts.append(f"**Docx:** `{docx_files[i]}`\n")
output_parts.append(pr_texts[i]) output_parts.append(pr_texts[i])
output_parts.append("\n---\n") output_parts.append("\n---\n")
output_parts.append(f"### Schema {label}") output_parts.append(f"### Schema {label}")
@ -474,6 +483,85 @@ def write_press_releases(
return "\n".join(output_parts) return "\n".join(output_parts)
def _parse_company_org_ids(companies_text: str) -> dict[str, int]:
"""Parse companies.md and return {company_name_lower: pa_org_id}."""
mapping: dict[str, int] = {}
current_company = ""
for line in companies_text.splitlines():
line = line.strip()
if line.startswith("## "):
current_company = line[3:].strip()
elif line.startswith("- **PA Org ID:**") and current_company:
try:
org_id = int(line.split(":**")[1].strip())
mapping[current_company.lower()] = org_id
except (ValueError, IndexError):
pass
return mapping
def _fuzzy_match_company(name: str, candidates: dict[str, int]) -> int | None:
"""Try to match a company name against the org ID mapping.
Tries exact match first, then substring containment in both directions.
"""
name_lower = name.lower().strip()
# Exact match
if name_lower in candidates:
return candidates[name_lower]
# Substring: input contains a known company name, or vice versa
for key, org_id in candidates.items():
if key in name_lower or name_lower in key:
return org_id
return None
def _text_to_html(text: str, links: list[dict] | None = None) -> str:
"""Convert plain text to HTML with link injection.
Args:
text: Plain text press release body.
links: List of dicts with 'url' and 'anchor' keys. Each anchor's first
occurrence in the text is wrapped in an <a> tag.
Returns:
HTML string with <p> tags and injected links.
"""
# Inject anchor text links before paragraph splitting
if links:
for link in links:
anchor = link.get("anchor", "")
url = link.get("url", "")
if anchor and url:
# Replace first occurrence only
html_link = f'<a href="{url}">{anchor}</a>'
text = text.replace(anchor, html_link, 1)
# Split into paragraphs on double newlines
paragraphs = re.split(r"\n\s*\n", text.strip())
html_parts = []
for para in paragraphs:
# Collapse internal newlines to spaces within a paragraph
para = re.sub(r"\s*\n\s*", " ", para).strip()
if not para:
continue
# Convert bare URLs to links (skip already-linked ones)
para = re.sub(
r'(?<!href=")(?<!">)(https?://\S+)',
r'<a href="\1">\1</a>',
para,
)
html_parts.append(f"<p>{para}</p>")
return "\n".join(html_parts)
def _extract_json(text: str) -> str | None: def _extract_json(text: str) -> str | None:
"""Try to pull a JSON object out of LLM output (strip fences, prose, etc).""" """Try to pull a JSON object out of LLM output (strip fences, prose, etc)."""
stripped = text.strip() stripped = text.strip()
@ -505,4 +593,143 @@ def _extract_json(text: str) -> str | None:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
return None return None # noqa: RET501
# ---------------------------------------------------------------------------
# Submit tool
# ---------------------------------------------------------------------------
@tool(
"submit_press_release",
description=(
"Submit a press release to Press Advantage as a draft. Takes the PR text "
"(or file path), headline, company name, and links to inject. Converts to "
"HTML, resolves the PA organization ID, and creates a draft release for "
"review. The release will NOT auto-publish — Bryan must review and approve "
"it in the PA dashboard."
),
category="content",
)
def submit_press_release(
headline: str,
company_name: str,
links: str = "",
pr_text: str = "",
file_path: str = "",
description: str = "",
ctx: dict = None,
) -> str:
"""Submit a finished press release to Press Advantage as a draft."""
# --- Get config ---
if not ctx or "config" not in ctx:
return "Error: submit_press_release requires agent context."
config = ctx["config"]
api_key = config.press_advantage.api_key
if not api_key:
return (
"Error: PRESS_ADVANTAGE_API key not configured. "
"Set the PRESS_ADVANTAGE_API environment variable in .env."
)
# --- Get PR text ---
if not pr_text and file_path:
path = Path(file_path)
if not path.exists():
return f"Error: file not found: {file_path}"
pr_text = path.read_text(encoding="utf-8")
if not pr_text:
return "Error: provide either pr_text or file_path with the press release content."
# --- Validate word count ---
wc = _word_count(pr_text)
if wc < 550:
return (
f"Error: press release is only {wc} words. "
f"Press Advantage requires at least 550 words. Please expand the content."
)
# --- Parse links ---
link_list: list[dict] = []
if links:
try:
link_list = json.loads(links)
except json.JSONDecodeError:
return "Error: 'links' must be a valid JSON array, e.g. '[{\"url\": \"...\", \"anchor\": \"...\"}]'"
# --- Convert to HTML ---
html_body = _text_to_html(pr_text, link_list)
# --- Look up PA org ID ---
companies_text = _load_file_if_exists(_COMPANIES_FILE)
org_mapping = _parse_company_org_ids(companies_text)
org_id = _fuzzy_match_company(company_name, org_mapping)
# Fallback: try live API lookup
if org_id is None:
log.info("Org ID not found in companies.md for '%s', trying live API...", company_name)
try:
client = PressAdvantageClient(api_key)
try:
orgs = client.get_organizations()
# Build a mapping from API results and try fuzzy match
api_mapping: dict[str, int] = {}
for org in orgs:
org_name = org.get("name", "")
oid = org.get("id")
if org_name and oid:
api_mapping[org_name.lower()] = int(oid)
org_id = _fuzzy_match_company(company_name, api_mapping)
finally:
client.close()
except Exception as e:
log.warning("Failed to fetch orgs from PA API: %s", e)
if org_id is None:
return (
f"Error: could not find Press Advantage organization for '{company_name}'. "
f"Add a 'PA Org ID' entry to skills/companies.md or check the company name."
)
# --- Auto-generate description if not provided ---
if not description:
# Extract a keyword from the headline (drop the company name, take remaining key phrase)
keyword = headline
for part in [company_name, "Inc.", "LLC", "Corp.", "Ltd.", "Limited", "Inc"]:
keyword = keyword.replace(part, "").strip()
# Clean up and take first meaningful chunk
keyword = re.sub(r"\s+", " ", keyword).strip(" -\u2013\u2014,")
description = f"{company_name} - {keyword}" if keyword else company_name
# --- Submit to PA ---
log.info("Submitting PR to Press Advantage: org=%d, title='%s'", org_id, headline[:60])
client = PressAdvantageClient(api_key)
try:
result = client.create_release(
org_id=org_id,
title=headline,
body=html_body,
description=description,
distribution="standard",
schedule_distribution="false",
)
except Exception as e:
return f"Error submitting to Press Advantage: {e}"
finally:
client.close()
# --- Format response ---
release_id = result.get("id", "unknown")
status = result.get("state", result.get("status", "draft"))
return (
f"Press release submitted to Press Advantage as a DRAFT.\n\n"
f"- **Release ID:** {release_id}\n"
f"- **Status:** {status}\n"
f"- **Organization:** {company_name} (ID: {org_id})\n"
f"- **Title:** {headline}\n"
f"- **Word count:** {wc}\n"
f"- **Links injected:** {len(link_list)}\n\n"
f"**Next step:** Review and approve in the Press Advantage dashboard before publishing."
)

View File

@ -34,6 +34,11 @@ shell:
- ":(){:|:&};:" - ":(){:|:&};:"
require_approval: false # If true, shell commands need user confirmation require_approval: false # If true, shell commands need user confirmation
# Email delivery (credentials in .env, not here)
email:
smtp_host: "smtp.gmail.com"
smtp_port: 465
# ClickUp integration # ClickUp integration
clickup: clickup:
poll_interval_minutes: 20 # 3x per hour poll_interval_minutes: 20 # 3x per hour

View File

@ -14,6 +14,7 @@ dependencies = [
"beautifulsoup4>=4.12", "beautifulsoup4>=4.12",
"croniter>=2.0", "croniter>=2.0",
"edge-tts>=6.1", "edge-tts>=6.1",
"python-docx>=1.2.0",
] ]
[build-system] [build-system]

View File

@ -0,0 +1,74 @@
"""Tests for the .docx export module."""
from __future__ import annotations
from pathlib import Path
from docx import Document
from cheddahbot.docx_export import text_to_docx
SAMPLE_PR = (
"Acme Corp Expands Digital Marketing Services Nationwide\n"
"\n"
"NEW YORK, Feb 16, 2026 -- Acme Corp, a leader in digital marketing\n"
"solutions, today announced the expansion of its services to all 50 states.\n"
"\n"
"The company has been operating in the Northeast for the past decade and is\n"
"now bringing its full suite of SEO, content marketing, and paid advertising\n"
"services to clients across the country.\n"
"\n"
'"We are thrilled to bring our proven approach to businesses nationwide,"\n'
"said Jane Smith, CEO of Acme Corp.\n"
"\n"
"For more information, visit www.acmecorp.com.\n"
)
def test_text_to_docx_creates_file(tmp_path: Path):
"""Verify .docx file is created at the expected path."""
out = tmp_path / "test.docx"
result = text_to_docx(SAMPLE_PR, out)
assert result == out
assert out.exists()
assert out.stat().st_size > 0
def test_text_to_docx_headline_is_bold(tmp_path: Path):
"""First paragraph should be the headline with bold formatting."""
out = tmp_path / "test.docx"
text_to_docx(SAMPLE_PR, out)
doc = Document(str(out))
headline_para = doc.paragraphs[0]
assert headline_para.runs[0].bold is True
assert "Acme Corp Expands" in headline_para.text
def test_text_to_docx_body_paragraphs(tmp_path: Path):
"""Body text should be split into multiple paragraphs."""
out = tmp_path / "test.docx"
text_to_docx(SAMPLE_PR, out)
doc = Document(str(out))
# Headline + 4 body paragraphs
assert len(doc.paragraphs) >= 4
def test_text_to_docx_empty_text(tmp_path: Path):
"""Empty input should still produce a valid .docx."""
out = tmp_path / "empty.docx"
text_to_docx("", out)
assert out.exists()
doc = Document(str(out))
assert len(doc.paragraphs) == 0
def test_text_to_docx_creates_parent_dirs(tmp_path: Path):
"""Should create parent directories if they don't exist."""
out = tmp_path / "sub" / "dir" / "test.docx"
text_to_docx(SAMPLE_PR, out)
assert out.exists()

View File

@ -0,0 +1,82 @@
"""Tests for the email client (mocked SMTP)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from cheddahbot.email import EmailClient
@patch("cheddahbot.email.smtplib.SMTP_SSL")
def test_send_plain_email(mock_smtp_cls):
"""Send a simple email without attachments."""
mock_server = MagicMock()
mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False)
client = EmailClient("smtp.gmail.com", 465, "bot@test.com", "secret")
client.send(to="user@corp.com", subject="Test", body="Hello")
mock_smtp_cls.assert_called_once_with("smtp.gmail.com", 465)
mock_server.login.assert_called_once_with("bot@test.com", "secret")
mock_server.send_message.assert_called_once()
# Verify the message contents
sent_msg = mock_server.send_message.call_args[0][0]
assert sent_msg["To"] == "user@corp.com"
assert sent_msg["Subject"] == "Test"
assert sent_msg["From"] == "bot@test.com"
@patch("cheddahbot.email.smtplib.SMTP_SSL")
def test_send_with_attachment(mock_smtp_cls, tmp_path: Path):
"""Send an email with a file attachment."""
mock_server = MagicMock()
mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False)
# Create a test file
test_file = tmp_path / "report.docx"
test_file.write_bytes(b"fake docx content")
client = EmailClient("smtp.gmail.com", 465, "bot@test.com", "secret")
client.send(
to="user@corp.com",
subject="Report",
body="See attached",
attachments=[test_file],
)
mock_server.send_message.assert_called_once()
sent_msg = mock_server.send_message.call_args[0][0]
# Should have 2 parts: body text + 1 attachment
payloads = sent_msg.get_payload()
assert len(payloads) == 2
assert payloads[1].get_filename() == "report.docx"
@patch("cheddahbot.email.smtplib.SMTP_SSL")
def test_send_with_multiple_attachments(mock_smtp_cls, tmp_path: Path):
"""Send an email with multiple attachments."""
mock_server = MagicMock()
mock_smtp_cls.return_value.__enter__ = MagicMock(return_value=mock_server)
mock_smtp_cls.return_value.__exit__ = MagicMock(return_value=False)
file_a = tmp_path / "a.docx"
file_b = tmp_path / "b.docx"
file_a.write_bytes(b"content a")
file_b.write_bytes(b"content b")
client = EmailClient("smtp.gmail.com", 465, "bot@test.com", "secret")
client.send(
to="user@corp.com",
subject="Two files",
body="Both attached",
attachments=[file_a, file_b],
)
sent_msg = mock_server.send_message.call_args[0][0]
payloads = sent_msg.get_payload()
assert len(payloads) == 3 # body + 2 attachments

117
uv.lock
View File

@ -327,6 +327,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "numpy" }, { name = "numpy" },
{ name = "openai" }, { name = "openai" },
{ name = "python-docx" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "sentence-transformers" }, { name = "sentence-transformers" },
@ -357,6 +358,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24" }, { name = "numpy", specifier = ">=1.24" },
{ name = "openai", specifier = ">=1.30" }, { name = "openai", specifier = ">=1.30" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "sentence-transformers", specifier = ">=3.0" }, { name = "sentence-transformers", specifier = ">=3.0" },
@ -986,6 +988,108 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
] ]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.0.0"
@ -1952,6 +2056,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-docx"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"