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
parent
7864ec6f17
commit
8f6e218221
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}."
|
||||
|
|
@ -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 <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:
|
||||
"""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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ dependencies = [
|
|||
"beautifulsoup4>=4.12",
|
||||
"croniter>=2.0",
|
||||
"edge-tts>=6.1",
|
||||
"python-docx>=1.2.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
117
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue