From 8f6e218221785e99edbbd5ba798fe1c7ccde6dd3 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Mon, 16 Feb 2026 17:00:54 -0600 Subject: [PATCH] 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 --- cheddahbot/config.py | 39 +++++ cheddahbot/docx_export.py | 77 ++++++++++ cheddahbot/email.py | 56 ++++++++ cheddahbot/tools/email_tool.py | 73 ++++++++++ cheddahbot/tools/press_release.py | 231 +++++++++++++++++++++++++++++- config.yaml | 5 + pyproject.toml | 1 + tests/test_docx_export.py | 74 ++++++++++ tests/test_email.py | 82 +++++++++++ uv.lock | 117 +++++++++++++++ 10 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 cheddahbot/docx_export.py create mode 100644 cheddahbot/email.py create mode 100644 cheddahbot/tools/email_tool.py create mode 100644 tests/test_docx_export.py create mode 100644 tests/test_email.py diff --git a/cheddahbot/config.py b/cheddahbot/config.py index 6173f79..7694ec6 100644 --- a/cheddahbot/config.py +++ b/cheddahbot/config.py @@ -48,6 +48,22 @@ class ClickUpConfig: 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 class Config: chat_model: str = "openai/gpt-4o-mini" @@ -61,6 +77,8 @@ class Config: scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) shell: ShellConfig = field(default_factory=ShellConfig) clickup: ClickUpConfig = field(default_factory=ClickUpConfig) + press_advantage: PressAdvantageConfig = field(default_factory=PressAdvantageConfig) + email: EmailConfig = field(default_factory=EmailConfig) # Derived paths root_dir: Path = field(default_factory=lambda: ROOT_DIR) @@ -99,6 +117,14 @@ def load_config() -> Config: for k, v in data["clickup"].items(): if hasattr(cfg.clickup, k): 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) cfg.openrouter_api_key = os.getenv("OPENROUTER_API_KEY", "") @@ -121,6 +147,19 @@ def load_config() -> Config: # Auto-enable if token is present 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 cfg.data_dir.mkdir(parents=True, exist_ok=True) (cfg.data_dir / "uploads").mkdir(exist_ok=True) diff --git a/cheddahbot/docx_export.py b/cheddahbot/docx_export.py new file mode 100644 index 0000000..b2c48b5 --- /dev/null +++ b/cheddahbot/docx_export.py @@ -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 diff --git a/cheddahbot/email.py b/cheddahbot/email.py new file mode 100644 index 0000000..e4d3e0c --- /dev/null +++ b/cheddahbot/email.py @@ -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) diff --git a/cheddahbot/tools/email_tool.py b/cheddahbot/tools/email_tool.py new file mode 100644 index 0000000..73c607d --- /dev/null +++ b/cheddahbot/tools/email_tool.py @@ -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}." diff --git a/cheddahbot/tools/press_release.py b/cheddahbot/tools/press_release.py index ffd92d5..2c66dbe 100644 --- a/cheddahbot/tools/press_release.py +++ b/cheddahbot/tools/press_release.py @@ -17,6 +17,8 @@ import time from datetime import datetime from pathlib import Path +from ..docx_export import text_to_docx +from ..press_advantage import PressAdvantageClient from . import tool log = logging.getLogger(__name__) @@ -370,6 +372,7 @@ def write_press_releases( log.info("[PR Pipeline] Step 3/4: Writing 2 press releases...") pr_texts: list[str] = [] pr_files: list[str] = [] + docx_files: list[str] = [] for i, headline in enumerate(winners): 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]}...") @@ -403,6 +406,11 @@ def write_press_releases( filepath.write_text(clean_result, encoding="utf-8") 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) ─────────── log.info("[PR Pipeline] Step 4/4: Generating 2 JSON-LD schemas...") schema_texts: list[str] = [] @@ -455,7 +463,8 @@ def write_press_releases( wc = _word_count(pr_texts[i]) output_parts.append(f"## Press Release {label}: {winners[i]}") 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("\n---\n") output_parts.append(f"### Schema {label}") @@ -474,6 +483,85 @@ def write_press_releases( 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 tag. + + Returns: + HTML string with

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'{anchor}' + 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'(?)(https?://\S+)', + r'\1', + para, + ) + + html_parts.append(f"

{para}

") + + return "\n".join(html_parts) + + def _extract_json(text: str) -> str | None: """Try to pull a JSON object out of LLM output (strip fences, prose, etc).""" stripped = text.strip() @@ -505,4 +593,143 @@ def _extract_json(text: str) -> str | None: except json.JSONDecodeError: 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." + ) diff --git a/config.yaml b/config.yaml index 8f89db2..ba2dd99 100644 --- a/config.yaml +++ b/config.yaml @@ -34,6 +34,11 @@ shell: - ":(){:|:&};:" 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: poll_interval_minutes: 20 # 3x per hour diff --git a/pyproject.toml b/pyproject.toml index 66cdc06..56a1571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "beautifulsoup4>=4.12", "croniter>=2.0", "edge-tts>=6.1", + "python-docx>=1.2.0", ] [build-system] diff --git a/tests/test_docx_export.py b/tests/test_docx_export.py new file mode 100644 index 0000000..f4ab958 --- /dev/null +++ b/tests/test_docx_export.py @@ -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() diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..655e04a --- /dev/null +++ b/tests/test_email.py @@ -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 diff --git a/uv.lock b/uv.lock index 42c286d..244534c 100644 --- a/uv.lock +++ b/uv.lock @@ -327,6 +327,7 @@ dependencies = [ { name = "httpx" }, { name = "numpy" }, { name = "openai" }, + { name = "python-docx" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "sentence-transformers" }, @@ -357,6 +358,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "numpy", specifier = ">=1.24" }, { name = "openai", specifier = ">=1.30" }, + { name = "python-docx", specifier = ">=1.2.0" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "pyyaml", specifier = ">=6.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" }, ] +[[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]] name = "markdown-it-py" 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" }, ] +[[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]] name = "python-dotenv" version = "1.2.1"