Initial implementation of gimme-job CLI

Complete Python package implementing all phases from the spec:
- Phase 0-1: Project scaffold, config, Pydantic/SQLAlchemy models, Typer CLI
- Phase 2: Runtime engine (BrowserManager, BaseAdapter/ManifestDrivenAdapter, orchestrator)
- Phase 3: Claude Code CLI integration (learn/repair modes with Jinja2 prompt templates)
- Phase 4: Ollama summarizer, KakaoTalk client, notification dispatcher
- Phase 5: Indeed adapter with manifest (sites/indeed.yaml)
- Phase 6: 36 unit tests (dates, hashing, dedupe, manifests, kakao, adapter)

gimme-job init/run/learn/repair/test/notify/status commands all wired up.
36/36 unit tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
commit 8028d5e566

@ -0,0 +1,15 @@
# KakaoTalk
KAKAO_REST_API_KEY=
KAKAO_REDIRECT_URI=https://localhost
KAKAO_ACCESS_TOKEN=
KAKAO_REFRESH_TOKEN=
KAKAO_USE_SELF_MEMO=true
# Ollama
OLLAMA_BASE_URL=http://127.0.0.1:11434
# Database
GIMME_JOB_DB_PATH=gimme_job.db
# Optional: override log level (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO

45
.gitignore vendored

@ -0,0 +1,45 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg
*.egg-info/
dist/
build/
wheels/
.eggs/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
.mypy_cache/
# Virtual environments
.venv/
venv/
env/
# uv
.python-version
# Environment
.env
# Database
*.db
*.sqlite
*.sqlite3
# Workspace artifacts (contain PII / large files)
workspace/chrome-profiles/
workspace/captures/
workspace/traces/
workspace/screenshots/
workspace/dom/
workspace/logs/
workspace/reports/
# macOS
.DS_Store

@ -0,0 +1,65 @@
# gimme-job Project Rules for Claude Code
## Core Principles
1. **gimme-job is an "AI-assisted learning, non-AI runtime" project.**
- During runtime scraping, only adapter code and manifest YAML files are used — no AI calls.
- Claude Code is invoked ONLY when learning a new site or repairing a broken adapter.
2. **Runtime must never require AI.**
- All adapters must work with pure Python + Playwright, reading from site manifests.
- Do not add AI/LLM calls inside adapter `prepare()`, `apply_search()`, `apply_filters()`, `collect_cards()`, `paginate()`, or `normalize()`.
3. **Use Chrome persistent profile `JobAgent` for browser automation.**
- Always use `playwright.chromium.launch_persistent_context()` with `user_data_dir` pointing to `workspace/chrome-profiles/JobAgent/`.
- Never use `browser.new_context()` or `playwright.chromium.launch()` directly.
4. **Do not introduce `storage_state` as a new strategy.**
- Chrome profile reuse is the only session persistence mechanism.
5. **Summarization uses Ollama `qwen3.5:9b` only.**
- No other LLM should be called during the run pipeline.
6. **Final notification target is KakaoTalk self-memo.**
- Fall back to local Markdown file only when KakaoTalk fails.
7. **When a site fails, set `repair_needed=True` — never silently ignore failures.**
- After 2 consecutive failures, mark the site as `repair_needed`.
- `repair_needed` sites are skipped during `run`, noted in the summary.
8. **Selector robustness rules:**
- Prefer `aria-label`, `data-*` attributes, and semantic HTML over CSS class names.
- Avoid brittle `nth-child` selectors unless no better option exists.
- Always define fallback selectors (list multiple selectors per field).
- Handle zero-result states explicitly — `ZERO_RESULTS_EXPECTED` is a normal exit.
## When Generating or Patching Adapters
Each site adapter must implement the `BaseJobSiteAdapter` protocol from `gimme_job/adapters/base.py`:
- `prepare(page, config)` — navigate to start URL, wait for page readiness
- `apply_search(page, query)` — inject keywords/location
- `apply_filters(page, query)` — apply date/type filters via UI
- `collect_cards(page, config)` — extract all visible job cards as `RawJobCard`
- `paginate(page, page_index, config)` — advance to next page, return `False` when done
- `normalize(raw)` — clean/normalize a `RawJobCard` into `JobPostingCandidate`
Each site also requires:
1. `sites/{site_id}.yaml` — manifest YAML
2. `gimme_job/adapters/{site_id}.py` — adapter Python file
3. `tests/adapters/test_{site_id}.py` — smoke test
4. `workspace/manifests/{site_id}.learning-report.md` — learning report
The smoke test must verify:
- The search page opens successfully
- Either result cards are detected OR a zero-result state is explicitly handled
- At least 2 fields can be extracted from a card (or zero-result confirmed)
## File Locations
- Site manifests: `sites/{site_id}.yaml`
- Adapters: `gimme_job/adapters/{site_id}.py`
- Adapter tests: `tests/adapters/test_{site_id}.py`
- Prompts: `gimme_job/prompts/`
- Templates: `gimme_job/templates/`
- Workspace artifacts: `workspace/` (captures, traces, screenshots, dom, manifests, generated)
- Database: configured via `GIMME_JOB_DB_PATH` env var (default: `gimme_job.db`)

@ -0,0 +1 @@
__version__ = "0.1.0"

@ -0,0 +1,158 @@
"""Base adapter protocol and manifest-driven default implementation."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from loguru import logger
if TYPE_CHECKING:
from playwright.sync_api import Page
from gimme_job.models.dto import JobPostingCandidate, RawJobCard, SearchQuery
from gimme_job.models.manifest import SiteManifest
@runtime_checkable
class BaseJobSiteAdapter(Protocol):
"""Protocol that every site adapter must implement."""
site_id: str
def prepare(self, page: "Page", config: SiteManifest) -> None:
"""Navigate to the start URL and wait for page readiness."""
...
def apply_search(self, page: "Page", query: SearchQuery) -> None:
"""Inject keywords/location into the search form."""
...
def apply_filters(self, page: "Page", query: SearchQuery) -> None:
"""Click date/type filters in the UI."""
...
def collect_cards(self, page: "Page", config: SiteManifest) -> list[RawJobCard]:
"""Extract all visible job cards from the current page."""
...
def paginate(self, page: "Page", page_index: int, config: SiteManifest) -> bool:
"""Advance to the next page. Return True if successful, False if no more pages."""
...
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
"""Clean and normalize a RawJobCard into a JobPostingCandidate."""
...
class ManifestDrivenAdapter:
"""Default adapter that reads all behavior from a SiteManifest YAML."""
def __init__(self, manifest: SiteManifest):
self.manifest = manifest
self.site_id = manifest.site_id
def prepare(self, page: "Page", config: SiteManifest) -> None:
"""Navigate to the start URL and wait for initial load."""
from gimme_job.models.dto import SearchQuery
# build a minimal query for URL construction
query = SearchQuery()
url = config.build_start_url(query)
logger.info(f"[{self.site_id}] Navigating to {url}")
page.goto(url, timeout=config.browser.__class__.__fields__ # will be set from config
if False else 30000)
if config.search.result_list_wait_selector:
try:
page.wait_for_selector(
config.search.result_list_wait_selector,
timeout=15000,
state="attached",
)
except Exception:
logger.warning(f"[{self.site_id}] Wait selector timed out")
def apply_search(self, page: "Page", query: SearchQuery) -> None:
"""For URL-param sites, navigation already included keywords — nothing to do."""
pass
def apply_filters(self, page: "Page", query: SearchQuery) -> None:
"""Click date filter if configured."""
search_cfg = self.manifest.search
if search_cfg.date_mode != "click_filter":
return
if not search_cfg.date_filter_text:
return
logger.debug(f"[{self.site_id}] Applying date filter: {search_cfg.date_filter_text}")
try:
# Try to find and click an element matching the filter text
page.get_by_text(search_cfg.date_filter_text, exact=False).first.click(timeout=5000)
page.wait_for_load_state("networkidle", timeout=10000)
except Exception as e:
logger.warning(f"[{self.site_id}] Date filter click failed: {e}")
def collect_cards(self, page: "Page", config: SiteManifest) -> list[RawJobCard]:
"""Extract job cards using container and field selectors from the manifest."""
from gimme_job.runtime.extractor import extract_cards_from_page
return extract_cards_from_page(page, config.extract)
def paginate(self, page: "Page", page_index: int, config: SiteManifest) -> bool:
"""Click the next-page button, or return False if no more pages."""
pagination = config.pagination
if pagination.mode == "none":
return False
if page_index >= pagination.max_pages - 1:
return False
if pagination.mode == "next_button":
for selector in pagination.next_button_selectors:
try:
btn = page.query_selector(selector)
if btn and btn.is_visible() and btn.is_enabled():
btn.click()
page.wait_for_load_state("networkidle", timeout=10000)
return True
except Exception:
continue
return False
if pagination.mode == "url_increment":
# Handled by adapter subclass
return False
return False
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
"""Normalize a RawJobCard into a JobPostingCandidate."""
from gimme_job.utils.dates import normalize_posted_text
from gimme_job.utils.hashing import compute_fingerprint
from gimme_job.utils.text import normalize_whitespace
title = normalize_whitespace(raw.title)
company = normalize_whitespace(raw.company)
location = normalize_whitespace(raw.location)
url = raw.url
fingerprint = compute_fingerprint(
site_id=self.site_id,
title=title,
company=company,
location=location,
url=url,
)
return JobPostingCandidate(
site_id=self.site_id,
external_job_id=raw.external_job_id,
title=title,
company=company or None,
location=location or None,
posted_text=normalize_whitespace(raw.posted_text) or None,
posted_at_normalized=normalize_posted_text(raw.posted_text),
job_url=url,
salary_text=normalize_whitespace(raw.salary_text) or None,
employment_type=normalize_whitespace(raw.employment_type) or None,
raw_text=raw.raw_text,
fingerprint=fingerprint,
)

@ -0,0 +1,167 @@
"""Indeed job site adapter."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from loguru import logger
from gimme_job.adapters.base import ManifestDrivenAdapter
from gimme_job.adapters.registry import register
from gimme_job.models.dto import JobPostingCandidate, RawJobCard, SearchQuery
if TYPE_CHECKING:
from playwright.sync_api import Page
from gimme_job.models.manifest import SiteManifest
@register("indeed")
class IndeedAdapter(ManifestDrivenAdapter):
"""Indeed-specific adapter with custom card extraction and URL normalization."""
def prepare(self, page: "Page", config: "SiteManifest") -> None:
"""Navigate to Indeed search URL with fromage=1 for last 24h."""
url = config.build_start_url(
type("Q", (), {
"keywords_urlencoded": page.__class__.__name__, # placeholder
})()
)
# Actually build URL using the query stored in the manifest
pass # URL is built in orchestrator before adapter.prepare() is called
def apply_search(self, page: "Page", query: SearchQuery) -> None:
"""Indeed uses URL params — no form input needed."""
pass
def apply_filters(self, page: "Page", query: SearchQuery) -> None:
"""Indeed date filter is already in the URL (fromage=1). Nothing to click."""
pass
def collect_cards(self, page: "Page", config: "SiteManifest") -> list[RawJobCard]:
"""Extract Indeed job cards."""
cards = []
# Try container selectors
container_selectors = config.extract.container_selectors or [
"div.job_seen_beacon",
"div[data-jk]",
"td.resultContent",
]
container_elements = []
for selector in container_selectors:
try:
elements = page.query_selector_all(selector)
if elements:
container_elements = elements
logger.debug(f"[indeed] Container: '{selector}' ({len(elements)} items)")
break
except Exception:
continue
if not container_elements:
logger.warning("[indeed] No job cards found")
return cards
for el in container_elements:
try:
card = self._extract_card(el)
if card and card.title:
cards.append(card)
except Exception as e:
logger.debug(f"[indeed] Card extraction error: {e}")
logger.info(f"[indeed] Extracted {len(cards)} cards")
return cards
def _extract_card(self, el) -> RawJobCard | None:
def get_text(*selectors: str) -> str | None:
for sel in selectors:
try:
child = el.query_selector(sel)
if child:
text = child.inner_text()
if text and text.strip():
return text.strip()
except Exception:
continue
return None
def get_attr(selector: str, attr: str) -> str | None:
try:
child = el.query_selector(selector)
if child:
val = child.get_attribute(attr)
return val.strip() if val else None
except Exception:
return None
title = get_text(
"h2.jobTitle a span[id^='jobTitle']",
"h2.jobTitle span",
"h2 a span",
"h2[class*='jobTitle'] a",
"h2",
)
if not title:
return None
company = get_text(
"span[data-testid='company-name']",
".company",
"span.companyName",
)
location = get_text(
"div[data-testid='text-location']",
".companyLocation",
)
posted_text = get_text(
"span[data-testid='myJobsStateDate']",
"span.date",
)
salary = get_text(
"div.salary-snippet-container",
"div[data-testid='attribute_snippet_testid']",
)
# URL: prefer the job card link
url = get_attr("h2.jobTitle a", "href") or get_attr("a[data-jk]", "href")
if url and url.startswith("/"):
url = "https://www.indeed.com" + url
if url:
url = self._clean_indeed_url(url)
raw_text = None
try:
raw_text = el.inner_text()
except Exception:
pass
return RawJobCard(
title=title,
company=company,
location=location,
posted_text=posted_text,
url=url,
salary_text=salary,
raw_text=raw_text,
)
def _clean_indeed_url(self, url: str) -> str:
"""Strip Indeed tracking parameters, keep the /viewjob?jk= part."""
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
try:
parsed = urlparse(url)
if parsed.path == "/rc/clk" or "/clk" in parsed.path:
# Extract jk parameter and build clean URL
params = parse_qs(parsed.query)
jk = params.get("jk", [None])[0]
if jk:
return f"https://www.indeed.com/viewjob?jk={jk}"
return url
except Exception:
return url
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
return super().normalize(raw)

@ -0,0 +1,44 @@
"""Adapter registry with decorator-based registration."""
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Type
if TYPE_CHECKING:
from gimme_job.models.manifest import SiteManifest
_REGISTRY: dict[str, Type] = {}
def register(site_id: str) -> Callable:
"""Decorator to register a custom adapter class for a site."""
def decorator(cls):
_REGISTRY[site_id] = cls
return cls
return decorator
def get_adapter(site_id: str, manifest: "SiteManifest"):
"""Return a custom adapter if registered, otherwise ManifestDrivenAdapter."""
from gimme_job.adapters.base import ManifestDrivenAdapter
if site_id in _REGISTRY:
return _REGISTRY[site_id](manifest)
return ManifestDrivenAdapter(manifest)
def list_adapters() -> list[str]:
return sorted(_REGISTRY.keys())
def _load_all_adapters() -> None:
"""Import all adapter modules so they register themselves."""
import importlib
import pkgutil
import gimme_job.adapters as pkg
for _, module_name, _ in pkgutil.iter_modules(pkg.__path__):
if module_name not in ("base", "registry"):
try:
importlib.import_module(f"gimme_job.adapters.{module_name}")
except Exception:
pass

@ -0,0 +1,380 @@
"""gimme-job CLI entry point."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
app = typer.Typer(
name="gimme-job",
help="Local job posting aggregator — AI-assisted learning, non-AI runtime.",
add_completion=False,
)
console = Console()
# ── init ──────────────────────────────────────────────────────────────────────
@app.command()
def init(
force: bool = typer.Option(False, "--force", help="Re-initialize even if already set up"),
) -> None:
"""Initialize project directories, database, and config."""
from gimme_job.db.engine import init_db
from gimme_job.utils.paths import ensure_workspace_dirs, project_root, sites_dir
console.print(Panel("[bold cyan]gimme-job init[/bold cyan]", expand=False))
# Create workspace directories
ensure_workspace_dirs()
console.print("[green]✓[/green] Workspace directories created")
# Create sites/global.yaml if absent
global_yaml = sites_dir() / "global.yaml"
if not global_yaml.exists() or force:
_write_default_global_yaml(global_yaml)
console.print(f"[green]✓[/green] Created {global_yaml}")
else:
console.print(f"[dim] {global_yaml} already exists[/dim]")
# Copy .env.example -> .env if absent
env_example = project_root() / ".env.example"
env_file = project_root() / ".env"
if env_example.exists() and not env_file.exists():
import shutil
shutil.copy(env_example, env_file)
console.print(f"[green]✓[/green] Copied .env.example → .env (fill in your tokens)")
else:
console.print("[dim] .env already exists[/dim]")
# Initialize SQLite DB
init_db()
console.print("[green]✓[/green] SQLite database initialized")
# Preflight checks
_check_playwright()
_check_ollama()
_check_claude()
console.print(Panel("[bold green]Initialization complete.[/bold green]", expand=False))
def _write_default_global_yaml(path: Path) -> None:
from gimme_job.utils.json_io import write_yaml
data = {
"runtime": {
"timezone": "America/Phoenix",
"headless": True,
"profile_name": "JobAgent",
"slow_mo_ms": 0,
"default_timeout_ms": 15000,
"navigation_timeout_ms": 30000,
"max_pages_per_site": 3,
"min_delay_ms": 1200,
"max_delay_ms": 3500,
},
"search_defaults": {
"keywords": ["orthodontist"],
"location": "",
"remote": False,
"date_mode": "today_or_last_24h",
"sort": "relevance",
"max_items_per_site": 30,
},
"summarization": {
"ollama_base_url": "http://127.0.0.1:11434",
"model": "qwen3.5:9b",
"temperature": 0.1,
"max_input_items": 200,
},
"notification": {
"provider": "kakaotalk",
"fallback_markdown": True,
},
}
write_yaml(path, data)
def _check_playwright() -> None:
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
_ = p.chromium
console.print("[green]✓[/green] Playwright available")
except Exception as e:
console.print(f"[yellow]![/yellow] Playwright check failed: {e}")
console.print(" Run: [bold]uv run playwright install chromium[/bold]")
def _check_ollama() -> None:
try:
import httpx
from gimme_job.config import load_global_config
cfg = load_global_config()
r = httpx.get(f"{cfg.summarization.ollama_base_url}/api/tags", timeout=3.0)
if r.status_code == 200:
console.print("[green]✓[/green] Ollama reachable")
else:
console.print(f"[yellow]![/yellow] Ollama returned {r.status_code}")
except Exception as e:
console.print(f"[yellow]![/yellow] Ollama not reachable: {e}")
console.print(" Make sure Ollama is running: [bold]ollama serve[/bold]")
def _check_claude() -> None:
import subprocess
try:
result = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
console.print(f"[green]✓[/green] Claude Code CLI: {result.stdout.strip()}")
else:
console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)")
except FileNotFoundError:
console.print("[yellow]![/yellow] Claude Code CLI not found (needed for learn/repair)")
# ── run ───────────────────────────────────────────────────────────────────────
@app.command()
def run(
site: Optional[str] = typer.Option(None, "--site", help="Run a single site only"),
dry_run: bool = typer.Option(False, "--dry-run", help="Extract but do not save or notify"),
skip_notify: bool = typer.Option(False, "--skip-notify", help="Skip KakaoTalk notification"),
today_only: bool = typer.Option(False, "--today-only", help="Only process today's new items"),
limit_sites: Optional[int] = typer.Option(None, "--limit-sites", help="Max sites to run"),
) -> None:
"""Collect job postings from all enabled sites."""
from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.runtime.orchestrator import RunOrchestrator
cfg = load_global_config()
engine = get_engine()
factory = get_session_factory(engine)
orchestrator = RunOrchestrator(global_config=cfg, session_factory=factory)
summary = orchestrator.run_all(
site_filter=site,
dry_run=dry_run,
skip_notify=skip_notify,
limit_sites=limit_sites,
)
# Print summary table
table = Table(title="Run Summary", show_header=True)
table.add_column("Site", style="cyan")
table.add_column("Status")
table.add_column("Found", justify="right")
table.add_column("New", justify="right")
for r in summary.site_results:
status_color = {
"success": "green",
"partial": "yellow",
"failed": "red",
"repair_needed": "red",
}.get(r.status.value, "white")
table.add_row(
r.site_id,
f"[{status_color}]{r.status.value}[/{status_color}]",
str(r.items_found),
str(r.new_items),
)
console.print(table)
console.print(f"\nTotal: [bold]{summary.total_found}[/bold] found, [bold green]{summary.total_new}[/bold green] new")
if summary.summary_text:
console.print("\n[bold]Summary sent.[/bold]")
# ── learn ─────────────────────────────────────────────────────────────────────
@app.command()
def learn(
site_id: str = typer.Option(..., "--site-id", help="Site identifier (e.g. linkedin)"),
url: str = typer.Option(..., "--url", help="Job search results URL to learn from"),
profile_name: str = typer.Option("JobAgent", "--profile-name"),
headed: bool = typer.Option(True, "--headed/--headless"),
timeout_seconds: int = typer.Option(180, "--timeout-seconds"),
) -> None:
"""Learn a new job site adapter using Claude Code."""
from gimme_job.config import load_global_config
from gimme_job.runtime.claude_cli import ClaudeCodeClient
from gimme_job.runtime.learn import LearnMode
cfg = load_global_config()
client = ClaudeCodeClient()
mode = LearnMode(global_config=cfg, claude_client=client)
console.print(Panel(f"[bold cyan]Learning site: {site_id}[/bold cyan]", expand=False))
success = mode.learn_site(
site_id=site_id,
url=url,
profile_name=profile_name,
headed=headed,
timeout=timeout_seconds,
)
if success:
console.print(f"[green]✓[/green] Successfully learned [bold]{site_id}[/bold]")
else:
console.print(f"[red]✗[/red] Failed to learn [bold]{site_id}[/bold]")
raise typer.Exit(1)
# ── repair ────────────────────────────────────────────────────────────────────
@app.command()
def repair(
site: Optional[str] = typer.Argument(None, help="Site ID to repair"),
all_: bool = typer.Option(False, "--all", help="Repair all repair_needed sites"),
) -> None:
"""Repair a broken site adapter using Claude Code."""
from gimme_job.config import load_global_config, list_all_sites
from gimme_job.runtime.claude_cli import ClaudeCodeClient
from gimme_job.runtime.repair import RepairMode
if not site and not all_:
console.print("[red]Provide a site ID or --all[/red]")
raise typer.Exit(1)
cfg = load_global_config()
client = ClaudeCodeClient()
mode = RepairMode(global_config=cfg, claude_client=client)
sites_to_repair = list_all_sites() if all_ else [site]
for s in sites_to_repair:
console.print(f"[cyan]Repairing {s}...[/cyan]")
success = mode.repair_site(s)
if success:
console.print(f"[green]✓[/green] {s} repaired")
else:
console.print(f"[red]✗[/red] {s} repair failed")
# ── test ──────────────────────────────────────────────────────────────────────
@app.command()
def test(
site: str = typer.Argument(..., help="Site ID to test"),
) -> None:
"""Run smoke test for a site adapter."""
import subprocess
console.print(Panel(f"[bold cyan]Testing: {site}[/bold cyan]", expand=False))
result = subprocess.run(
["uv", "run", "pytest", f"tests/adapters/test_{site}.py", "-v"],
cwd=str(Path(__file__).parent.parent),
)
raise typer.Exit(result.returncode)
# ── notify ────────────────────────────────────────────────────────────────────
@app.command()
def notify(
today: bool = typer.Option(True, "--today/--no-today", help="Re-send today's digest"),
) -> None:
"""Re-send today's job summary via KakaoTalk."""
from datetime import date
from gimme_job.config import load_global_config
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import JobPostingRepo, SummaryRepo
from gimme_job.runtime.notifier import NotificationDispatcher
cfg = load_global_config()
engine = get_engine()
factory = get_session_factory(engine)
run_date = date.today()
with factory() as session:
summary_repo = SummaryRepo()
summary = summary_repo.get_latest(session, run_date)
if summary:
text = summary.content
else:
# Build summary from today's postings
postings = JobPostingRepo().get_today_new(session, run_date)
if not postings:
console.print("[yellow]No new postings found for today.[/yellow]")
raise typer.Exit(0)
from gimme_job.runtime.summarizer import OllamaSummarizer
summarizer = OllamaSummarizer(
base_url=cfg.summarization.ollama_base_url,
model=cfg.summarization.model,
temperature=cfg.summarization.temperature,
)
text = summarizer.summarize(postings)
dispatcher = NotificationDispatcher(global_config=cfg, session_factory=factory)
ok = dispatcher.send(text, run_date)
if ok:
console.print("[green]✓[/green] Notification sent")
else:
console.print("[yellow]![/yellow] Notification failed — saved as markdown fallback")
# ── status ────────────────────────────────────────────────────────────────────
@app.command()
def status() -> None:
"""Show health status of all configured sites."""
from gimme_job.config import list_all_sites, load_site_manifest
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import SiteConfigRepo, SiteRunRepo
engine = get_engine()
factory = get_session_factory(engine)
table = Table(title="Site Status", show_header=True)
table.add_column("Site", style="cyan")
table.add_column("Enabled")
table.add_column("Repair?")
table.add_column("Last Run")
table.add_column("Last Status")
table.add_column("Failures", justify="right")
run_repo = SiteRunRepo()
config_repo = SiteConfigRepo()
with factory() as session:
for site_id in list_all_sites():
try:
manifest = load_site_manifest(site_id)
except Exception:
continue
config = session.get(__import__("gimme_job.models.db", fromlist=["SiteConfig"]).SiteConfig, site_id)
recent = run_repo.get_recent_runs(session, site_id, limit=1)
enabled = "[green]yes[/green]" if manifest.enabled else "[dim]no[/dim]"
repair = "[red]YES[/red]" if manifest.repair_needed else "[green]no[/green]"
last_run = recent[0].started_at.strftime("%m-%d %H:%M") if recent else "[dim]never[/dim]"
last_status = recent[0].status if recent else "[dim]-[/dim]"
failures = str(config.consecutive_failures) if config else "0"
table.add_row(site_id, enabled, repair, last_run, last_status, failures)
console.print(table)
if __name__ == "__main__":
app()

@ -0,0 +1,165 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Sub-config models ──────────────────────────────────────────────────────────
class RuntimeConfig(BaseModel):
timezone: str = "America/Phoenix"
headless: bool = True
profile_name: str = "JobAgent"
slow_mo_ms: int = 0
default_timeout_ms: int = 15000
navigation_timeout_ms: int = 30000
max_pages_per_site: int = 3
min_delay_ms: int = 1200
max_delay_ms: int = 3500
class SearchDefaults(BaseModel):
keywords: list[str] = Field(default_factory=lambda: ["orthodontist"])
location: str = ""
remote: bool = False
date_mode: str = "today_or_last_24h"
sort: str = "relevance"
max_items_per_site: int = 30
class SummarizationConfig(BaseModel):
ollama_base_url: str = "http://127.0.0.1:11434"
model: str = "qwen3.5:9b"
temperature: float = 0.1
max_input_items: int = 200
class NotificationConfig(BaseModel):
provider: str = "kakaotalk"
fallback_markdown: bool = True
# ── Main global config ─────────────────────────────────────────────────────────
class GlobalConfig(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_nested_delimiter="__",
extra="ignore",
)
runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
search_defaults: SearchDefaults = Field(default_factory=SearchDefaults)
summarization: SummarizationConfig = Field(default_factory=SummarizationConfig)
notification: NotificationConfig = Field(default_factory=NotificationConfig)
# ── Loader functions ──────────────────────────────────────────────────────────
def load_global_config() -> GlobalConfig:
"""Load global config from sites/global.yaml, with env var overrides."""
from gimme_job.utils.json_io import read_yaml
from gimme_job.utils.paths import sites_dir
yaml_path = sites_dir() / "global.yaml"
if yaml_path.exists():
data = read_yaml(yaml_path)
return GlobalConfig.model_validate(data)
return GlobalConfig()
def load_site_manifest(site_id: str):
"""Load a site manifest from sites/{site_id}.yaml."""
from gimme_job.models.manifest import SiteManifest
from gimme_job.utils.paths import sites_dir
path = sites_dir() / f"{site_id}.yaml"
if not path.exists():
raise FileNotFoundError(f"Site manifest not found: {path}")
return SiteManifest.from_yaml(path)
def list_enabled_sites() -> list[str]:
"""Return site_ids of all enabled (non-repair_needed) sites from sites/*.yaml."""
from gimme_job.models.manifest import SiteManifest
from gimme_job.utils.paths import sites_dir
enabled = []
for yaml_path in sorted(sites_dir().glob("*.yaml")):
if yaml_path.stem == "global":
continue
try:
manifest = SiteManifest.from_yaml(yaml_path)
if manifest.enabled and not manifest.repair_needed:
enabled.append(manifest.site_id)
except Exception:
pass
return enabled
def list_all_sites() -> list[str]:
"""Return all site_ids from sites/*.yaml (including disabled/repair_needed)."""
from gimme_job.models.manifest import SiteManifest
from gimme_job.utils.paths import sites_dir
sites = []
for yaml_path in sorted(sites_dir().glob("*.yaml")):
if yaml_path.stem == "global":
continue
try:
manifest = SiteManifest.from_yaml(yaml_path)
sites.append(manifest.site_id)
except Exception:
pass
return sites
def merge_query(
global_config: GlobalConfig,
manifest,
cli_overrides: Optional[dict] = None,
):
"""Merge search query with priority: CLI > site override > global defaults."""
from gimme_job.models.dto import SearchQuery
# Start from global defaults
base = global_config.search_defaults
merged = {
"keywords": list(base.keywords),
"location": base.location,
"remote": base.remote,
"date_mode": base.date_mode,
"sort": base.sort,
"max_items": base.max_items_per_site,
}
# Apply site-level overrides
override = manifest.search_override
if override.keywords is not None:
merged["keywords"] = override.keywords
if override.location is not None:
merged["location"] = override.location
if override.remote is not None:
merged["remote"] = override.remote
if override.date_mode is not None:
merged["date_mode"] = override.date_mode
if override.sort is not None:
merged["sort"] = override.sort
if override.max_items is not None:
merged["max_items"] = override.max_items
# Apply CLI overrides
if cli_overrides:
for k, v in cli_overrides.items():
if v is not None and k in merged:
merged[k] = v
return SearchQuery(**merged)

@ -0,0 +1,25 @@
from enum import Enum
APP_NAME = "gimme-job"
DEFAULT_PROFILE_NAME = "JobAgent"
DEFAULT_TIMEZONE = "America/Phoenix"
MAX_PAGES_PER_SITE = 3
CONSECUTIVE_FAILURES_THRESHOLD = 2 # mark repair_needed after this many consecutive failures
class FailureClassification(str, Enum):
LOGIN_REQUIRED = "LOGIN_REQUIRED"
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
ZERO_RESULTS_EXPECTED = "ZERO_RESULTS_EXPECTED"
ZERO_RESULTS_SUSPICIOUS = "ZERO_RESULTS_SUSPICIOUS"
DATE_FILTER_FAILED = "DATE_FILTER_FAILED"
ANTI_BOT_SUSPECTED = "ANTI_BOT_SUSPECTED"
NAVIGATION_TIMEOUT = "NAVIGATION_TIMEOUT"
EXTRACTION_PARTIAL = "EXTRACTION_PARTIAL"
class RunStatus(str, Enum):
SUCCESS = "success"
PARTIAL = "partial"
FAILED = "failed"
REPAIR_NEEDED = "repair_needed"

@ -0,0 +1,52 @@
from contextlib import contextmanager
from pathlib import Path
from typing import Generator
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import Session, sessionmaker
from gimme_job.models.db import Base
def get_engine(db_path: Path | None = None):
from gimme_job.utils.paths import db_path as default_db_path
path = db_path or default_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(f"sqlite:///{path}", echo=False)
# Enable WAL mode and foreign keys
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, _connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
def get_session_factory(engine=None):
if engine is None:
engine = get_engine()
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def init_db(engine=None) -> None:
if engine is None:
engine = get_engine()
Base.metadata.create_all(engine)
@contextmanager
def db_session(engine=None) -> Generator[Session, None, None]:
factory = get_session_factory(engine)
session: Session = factory()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

@ -0,0 +1,221 @@
from datetime import date, datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session
from gimme_job.constants import CONSECUTIVE_FAILURES_THRESHOLD
from gimme_job.models.db import (
JobPosting,
NotificationLog,
SiteConfig,
SiteManifestVersion,
SiteRun,
Summary,
)
from gimme_job.models.dto import JobPostingCandidate
from gimme_job.models.runtime import SiteRunResult
class JobPostingRepo:
def upsert_candidates(
self,
session: Session,
candidates: list[JobPostingCandidate],
run_date: date,
) -> tuple[int, int]:
"""Upsert a list of candidates. Returns (total_processed, new_count)."""
if not candidates:
return 0, 0
now = datetime.utcnow()
new_count = 0
for c in candidates:
if not c.fingerprint:
continue
existing = session.execute(
select(JobPosting).where(JobPosting.fingerprint == c.fingerprint)
).scalar_one_or_none()
if existing:
existing.last_seen_at = now
existing.is_active = True
existing.is_new = False
else:
posting = JobPosting(
site_id=c.site_id,
external_job_id=c.external_job_id,
title=c.title,
company=c.company,
location=c.location,
posted_text=c.posted_text,
posted_at_normalized=c.posted_at_normalized,
job_url=c.job_url,
salary_text=c.salary_text,
employment_type=c.employment_type,
raw_text=c.raw_text,
fingerprint=c.fingerprint,
run_date=run_date,
first_seen_at=now,
last_seen_at=now,
is_new=True,
is_active=True,
)
session.add(posting)
new_count += 1
session.flush()
return len(candidates), new_count
def get_today_new(self, session: Session, run_date: date) -> list[JobPosting]:
return list(
session.execute(
select(JobPosting)
.where(JobPosting.run_date == run_date, JobPosting.is_new == True)
.order_by(JobPosting.site_id, JobPosting.first_seen_at)
).scalars().all()
)
def get_by_site(self, session: Session, site_id: str) -> list[JobPosting]:
return list(
session.execute(
select(JobPosting)
.where(JobPosting.site_id == site_id)
.order_by(JobPosting.first_seen_at.desc())
).scalars().all()
)
class SiteRunRepo:
def record_run(self, session: Session, result: SiteRunResult) -> SiteRun:
run = SiteRun(
site_id=result.site_id,
started_at=result.started_at,
ended_at=result.ended_at or datetime.utcnow(),
status=result.status.value,
items_found=result.items_found,
new_items=result.new_items,
error_summary=result.error_summary,
failure_classification=(
result.failure_classification.value if result.failure_classification else None
),
trace_path=str(result.trace_path) if result.trace_path else None,
screenshot_path=str(result.screenshot_path) if result.screenshot_path else None,
dom_snapshot_path=(
str(result.dom_snapshot_path) if result.dom_snapshot_path else None
),
)
session.add(run)
session.flush()
return run
def get_recent_runs(
self, session: Session, site_id: str, limit: int = 10
) -> list[SiteRun]:
return list(
session.execute(
select(SiteRun)
.where(SiteRun.site_id == site_id)
.order_by(SiteRun.started_at.desc())
.limit(limit)
).scalars().all()
)
def get_consecutive_failures(self, session: Session, site_id: str) -> int:
config = session.get(SiteConfig, site_id)
return config.consecutive_failures if config else 0
class SiteConfigRepo:
def _get_or_create(self, session: Session, site_id: str) -> SiteConfig:
config = session.get(SiteConfig, site_id)
if config is None:
config = SiteConfig(site_id=site_id)
session.add(config)
session.flush()
return config
def get_all_enabled(self, session: Session) -> list[SiteConfig]:
return list(
session.execute(
select(SiteConfig).where(SiteConfig.enabled == True)
).scalars().all()
)
def set_repair_needed(self, session: Session, site_id: str, value: bool) -> None:
config = self._get_or_create(session, site_id)
config.repair_needed = value
session.flush()
def increment_failure(self, session: Session, site_id: str) -> int:
config = self._get_or_create(session, site_id)
config.consecutive_failures += 1
config.last_failure_at = datetime.utcnow()
if config.consecutive_failures >= CONSECUTIVE_FAILURES_THRESHOLD:
config.repair_needed = True
session.flush()
return config.consecutive_failures
def reset_failures(self, session: Session, site_id: str) -> None:
config = self._get_or_create(session, site_id)
config.consecutive_failures = 0
config.repair_needed = False
config.last_success_at = datetime.utcnow()
session.flush()
def record_success(self, session: Session, site_id: str) -> None:
config = self._get_or_create(session, site_id)
config.consecutive_failures = 0
config.last_success_at = datetime.utcnow()
session.flush()
class NotificationLogRepo:
def log_notification(
self,
session: Session,
run_date: date,
provider: str,
status: str,
error_message: Optional[str] = None,
) -> NotificationLog:
log = NotificationLog(
run_date=run_date,
provider=provider,
status=status,
error_message=error_message,
sent_at=datetime.utcnow(),
)
session.add(log)
session.flush()
return log
class SummaryRepo:
def save_summary(
self,
session: Session,
run_date: date,
content: str,
model_used: str,
) -> Summary:
summary = Summary(
run_date=run_date,
content=content,
model_used=model_used,
created_at=datetime.utcnow(),
)
session.add(summary)
session.flush()
return summary
def get_latest(self, session: Session, run_date: date) -> Optional[Summary]:
return session.execute(
select(Summary)
.where(Summary.run_date == run_date)
.order_by(Summary.created_at.desc())
.limit(1)
).scalar_one_or_none()

@ -0,0 +1,93 @@
from datetime import date, datetime
from typing import Optional
from sqlalchemy import Boolean, Date, DateTime, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class JobPosting(Base):
__tablename__ = "job_postings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
site_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_job_id: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
title: Mapped[str] = mapped_column(String(512), nullable=False)
company: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
location: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
posted_text: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
posted_at_normalized: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
job_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
salary_text: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
employment_type: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
raw_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
run_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
first_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
is_new: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
class SiteRun(Base):
__tablename__ = "site_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
site_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
started_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
ended_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False)
items_found: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
new_items: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
failure_classification: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
trace_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
screenshot_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
dom_snapshot_path: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class SiteConfig(Base):
__tablename__ = "site_configs"
site_id: Mapped[str] = mapped_column(String(64), primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
repair_needed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_success_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
last_failure_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class SiteManifestVersion(Base):
__tablename__ = "site_manifest_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
site_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
generated_by: Mapped[str] = mapped_column(String(64), nullable=False, default="manual")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class Summary(Base):
__tablename__ = "summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
model_used: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class NotificationLog(Base):
__tablename__ = "notification_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

@ -0,0 +1,63 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class RawJobCard(BaseModel):
"""Raw extracted data from a single job card on a page."""
title: str
company: Optional[str] = None
location: Optional[str] = None
posted_text: Optional[str] = None
url: Optional[str] = None
salary_text: Optional[str] = None
employment_type: Optional[str] = None
raw_text: Optional[str] = None
external_job_id: Optional[str] = None
class JobPostingCandidate(BaseModel):
"""Normalized job posting ready for DB upsert."""
site_id: str
external_job_id: Optional[str] = None
title: str
company: Optional[str] = None
location: Optional[str] = None
posted_text: Optional[str] = None
posted_at_normalized: Optional[datetime] = None
job_url: Optional[str] = None
salary_text: Optional[str] = None
employment_type: Optional[str] = None
raw_text: Optional[str] = None
fingerprint: str = ""
def is_valid(self) -> bool:
return bool(self.title and self.title.strip())
class SearchQuery(BaseModel):
"""Merged search parameters for a single site run."""
keywords: list[str] = Field(default_factory=lambda: ["orthodontist"])
location: str = ""
remote: bool = False
date_mode: str = "today_or_last_24h"
sort: str = "relevance"
max_items: int = 30
@property
def keywords_str(self) -> str:
return " ".join(self.keywords)
@property
def keywords_urlencoded(self) -> str:
from urllib.parse import quote_plus
return quote_plus(self.keywords_str)
@property
def location_urlencoded(self) -> str:
from urllib.parse import quote_plus
return quote_plus(self.location)

@ -0,0 +1,116 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field
class SiteIdentity(BaseModel):
label: str
category: str = "aggregator"
base_url: str
start_url_template: str
class BrowserConfig(BaseModel):
profile_mode: str = "persistent_chrome_profile"
profile_name: str = "JobAgent"
headed_on_learn: bool = True
headless_on_run: bool = True
class SearchConfig(BaseModel):
keyword_mode: str = "url_param" # url_param | input_field | none
location_mode: str = "url_param" # url_param | input_field | none
sort_mode: str = "none"
date_mode: str = "none" # none | url_param | click_filter
date_filter_text: Optional[str] = None
result_list_wait_selector: Optional[str] = None
class PaginationConfig(BaseModel):
mode: str = "none" # none | next_button | url_increment | scroll
next_button_selectors: list[str] = Field(default_factory=list)
max_pages: int = 3
class AttrSelector(BaseModel):
selector: str
name: str # attribute name, e.g. "href"
class FieldSelector(BaseModel):
text: Optional[list[str]] = None # CSS selectors to try for inner_text()
attr: Optional[AttrSelector] = None # CSS selector + attribute name
class ExtractConfig(BaseModel):
container_selectors: list[str] = Field(default_factory=list)
fields: dict[str, FieldSelector] = Field(default_factory=dict)
class PostFilterConfig(BaseModel):
include_posted_text: list[str] = Field(default_factory=list)
class HealthcheckConfig(BaseModel):
min_items_expected: int = 0
required_fields: list[str] = Field(default_factory=lambda: ["title", "url"])
fail_if_zero_when_known_active: bool = False
class SearchOverride(BaseModel):
"""Site-level search parameter overrides."""
keywords: Optional[list[str]] = None
location: Optional[str] = None
remote: Optional[bool] = None
date_mode: Optional[str] = None
sort: Optional[str] = None
max_items: Optional[int] = None
class LearnMeta(BaseModel):
last_learned_at: Optional[datetime] = None
source_url: Optional[str] = None
class SiteManifest(BaseModel):
site_id: str
enabled: bool = True
repair_needed: bool = False
identity: SiteIdentity
browser: BrowserConfig = Field(default_factory=BrowserConfig)
search: SearchConfig = Field(default_factory=SearchConfig)
search_override: SearchOverride = Field(default_factory=SearchOverride)
pagination: PaginationConfig = Field(default_factory=PaginationConfig)
extract: ExtractConfig = Field(default_factory=ExtractConfig)
post_filters: PostFilterConfig = Field(default_factory=PostFilterConfig)
healthcheck: HealthcheckConfig = Field(default_factory=HealthcheckConfig)
learn: LearnMeta = Field(default_factory=LearnMeta)
@classmethod
def from_yaml(cls, path: Path) -> "SiteManifest":
from gimme_job.utils.json_io import read_yaml
data = read_yaml(path)
return cls.model_validate(data)
def to_yaml(self, path: Path) -> None:
from gimme_job.utils.json_io import write_yaml
write_yaml(path, self.model_dump(mode="json"))
def build_start_url(self, query: Any) -> str:
"""Render the start URL template with query parameters."""
template = self.identity.start_url_template
try:
return template.format(
keywords_urlencoded=query.keywords_urlencoded,
keywords=query.keywords_str,
location_urlencoded=query.location_urlencoded,
location=query.location,
)
except KeyError:
return template

@ -0,0 +1,46 @@
from datetime import datetime
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
from gimme_job.constants import FailureClassification, RunStatus
class SiteRunResult(BaseModel):
site_id: str
status: RunStatus = RunStatus.SUCCESS
items_found: int = 0
new_items: int = 0
error_summary: Optional[str] = None
failure_classification: Optional[FailureClassification] = None
trace_path: Optional[Path] = None
screenshot_path: Optional[Path] = None
dom_snapshot_path: Optional[Path] = None
started_at: datetime = Field(default_factory=datetime.utcnow)
ended_at: Optional[datetime] = None
def mark_done(self) -> None:
self.ended_at = datetime.utcnow()
class RunSummary(BaseModel):
run_date: datetime = Field(default_factory=datetime.utcnow)
site_results: list[SiteRunResult] = Field(default_factory=list)
total_new: int = 0
total_found: int = 0
summary_text: Optional[str] = None
def add_result(self, result: SiteRunResult) -> None:
self.site_results.append(result)
self.total_found += result.items_found
self.total_new += result.new_items
class ClaudeRunResult(BaseModel):
success: bool
session_id: Optional[str] = None
output: str = ""
cost_usd: Optional[float] = None
duration_ms: Optional[int] = None
error: Optional[str] = None

@ -0,0 +1,109 @@
# gimme-job Learn Mode — System Instructions
You are working inside the **gimme-job** codebase.
## Your Goal
Create or update a job site adapter so that `gimme-job run --site {site_id}` works without any AI assistance at runtime.
## Core Rules
1. **Runtime must not require AI.** The adapter must work with pure Python + Playwright.
2. **Use Chrome persistent profile `JobAgent`** — never `browser.new_context()` directly.
3. **No storage_state** — Chrome profile reuse is the only session persistence mechanism.
4. **Prefer robust selectors**: `aria-label`, `data-*` attributes, semantic HTML. Avoid brittle `nth-child`.
5. **Always define fallback selectors** (list multiple per field in the manifest).
6. **Handle zero-result states explicitly**`ZERO_RESULTS_EXPECTED` is a normal exit.
7. **No detail page visits by default** — extract from card list only.
8. **If a date filter exists in the UI, use it.**
## Files to Generate
For site_id `{site_id}`:
1. `sites/{site_id}.yaml` — Site manifest YAML
2. `gimme_job/adapters/{site_id}.py` — Adapter Python file
3. `tests/adapters/test_{site_id}.py` — Smoke test
4. `workspace/manifests/{site_id}.learning-report.md` — Learning report
## Adapter Interface
The adapter must implement `BaseJobSiteAdapter` from `gimme_job/adapters/base.py`:
```python
class BaseJobSiteAdapter(Protocol):
site_id: str
def prepare(self, page: Page, config: SiteManifest) -> None: ...
def apply_search(self, page: Page, query: SearchQuery) -> None: ...
def apply_filters(self, page: Page, query: SearchQuery) -> None: ...
def collect_cards(self, page: Page, config: SiteManifest) -> list[RawJobCard]: ...
def paginate(self, page: Page, page_index: int, config: SiteManifest) -> bool: ...
def normalize(self, raw: RawJobCard) -> JobPostingCandidate: ...
```
Use `@register("{site_id}")` from `gimme_job/adapters/registry.py` to register the adapter.
## Manifest Schema
Key YAML fields (see `gimme_job/models/manifest.py` for full Pydantic schema):
```yaml
site_id: {site_id}
enabled: true
repair_needed: false
identity:
label: "Site Name"
base_url: "https://..."
start_url_template: "https://...?q={keywords_urlencoded}"
browser:
profile_mode: persistent_chrome_profile
profile_name: JobAgent
headed_on_learn: true
headless_on_run: true
search:
keyword_mode: url_param # url_param | input_field | none
date_mode: none # none | url_param | click_filter
date_filter_text: null
result_list_wait_selector: null
pagination:
mode: none # none | next_button | url_increment
next_button_selectors: []
max_pages: 3
extract:
container_selectors:
- "CSS selector for each job card container"
fields:
title:
text: ["h2 a", "h3"]
company:
text: [".company-name"]
location:
text: [".location"]
posted_text:
text: ["time", ".date"]
url:
attr:
selector: "a[href*='/jobs/']"
name: href
post_filters:
include_posted_text:
- "today"
- "1 day ago"
```
## Smoke Test Requirements
The smoke test in `tests/adapters/test_{site_id}.py` must:
1. Open the job search page
2. Either detect result cards OR confirm zero-result state
3. Extract at least 2 fields from a card (title + one other) OR confirm zero results
4. Pass with `pytest tests/adapters/test_{site_id}.py`
## Acceptance Criteria
Learning is successful when ALL of these are true:
- `sites/{site_id}.yaml` exists and is valid
- `gimme_job/adapters/{site_id}.py` exists with no syntax errors
- `tests/adapters/test_{site_id}.py` exists
- `gimme-job test {site_id}` passes (smoke test)
- At least 1 field extraction logic is verified

@ -0,0 +1,54 @@
You are working inside the gimme-job codebase. Create a complete site adapter for **{{ site_id }}**.
## Site Information
- **site_id**: `{{ site_id }}`
- **URL**: `{{ site_url }}`
## Captured Artifacts
{% if html_dump_path %}
- HTML dump: `{{ html_dump_path }}`
{% endif %}
{% if dom_snapshot_path %}
- DOM snapshot: `{{ dom_snapshot_path }}`
{% endif %}
{% if screenshot_path %}
- Screenshot: `{{ screenshot_path }}`
{% endif %}
## Global Search Defaults
```yaml
{{ global_config_yaml }}
```
## Base Adapter Interface (from gimme_job/adapters/base.py)
```python
{{ base_adapter_source }}
```
## Task
1. Read the HTML dump and/or DOM snapshot to understand the page structure.
2. Identify job card containers and field selectors.
3. Generate all 4 required files:
- `sites/{{ site_id }}.yaml`
- `gimme_job/adapters/{{ site_id }}.py`
- `tests/adapters/test_{{ site_id }}.py`
- `workspace/manifests/{{ site_id }}.learning-report.md`
4. Run `uv run pytest tests/adapters/test_{{ site_id }}.py -v` to verify.
5. If the test fails, fix the adapter and re-run once.
## Constraints
- Runtime must work WITHOUT AI.
- Use `ManifestDrivenAdapter` from `gimme_job/adapters/base.py` as the base class if the site follows standard patterns. Only subclass for custom logic.
- Register with `@register("{{ site_id }}")` from `gimme_job/adapters/registry.py`.
- Avoid `nth-child` selectors unless unavoidable.
- Prefer `aria-label`, `data-*`, semantic HTML.
- Handle zero results as `ZERO_RESULTS_EXPECTED` (not an error).
- Do NOT visit detail pages.
When done, write a brief learning report to `workspace/manifests/{{ site_id }}.learning-report.md`.

@ -0,0 +1,49 @@
You are working inside the gimme-job codebase. Repair the broken adapter for **{{ site_id }}**.
## Failure Information
- **site_id**: `{{ site_id }}`
- **Failure classification**: `{{ failure_classification or "UNKNOWN" }}`
- **Error summary**: {{ error_summary or "No error details" }}
## Recent Run History
```
{{ recent_run_history }}
```
## Artifacts from Last Failed Run
{% if last_run_dom_snapshot_path %}
- DOM snapshot: `{{ last_run_dom_snapshot_path }}`
{% endif %}
{% if last_run_screenshot_path %}
- Screenshot: `{{ last_run_screenshot_path }}`
{% endif %}
{% if last_run_trace_path %}
- Playwright trace: `{{ last_run_trace_path }}`
{% endif %}
## Current Files
- Manifest: `{{ current_manifest_path }}`
- Adapter: `{{ current_adapter_path }}`
## Task
1. Read the DOM snapshot to understand what the page currently looks like.
2. Compare with the current adapter selectors.
3. Identify what changed (selectors, page structure, auth requirement, etc.).
4. Patch `{{ current_manifest_path }}` and/or `{{ current_adapter_path }}` to fix the issue.
5. Run `uv run pytest tests/adapters/test_{{ site_id }}.py -v` to verify the fix.
6. If the test passes, the repair is complete.
## Constraints
- Runtime must work WITHOUT AI.
- Do NOT introduce new auth mechanisms beyond Chrome profile reuse.
- Preserve existing YAML structure and Python interface.
- If selectors changed, update the manifest YAML (preferred) before changing Python code.
- Handle zero results explicitly as `ZERO_RESULTS_EXPECTED`.
The repair is successful when `gimme-job test {{ site_id }}` passes.

@ -0,0 +1,88 @@
"""BrowserManager: manages Playwright persistent Chrome profile contexts."""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from loguru import logger
class BrowserManager:
"""Manages Playwright browser contexts using persistent Chrome profiles."""
def __init__(self, profile_name: str = "JobAgent", headless: bool = True, slow_mo: int = 0):
self.profile_name = profile_name
self.headless = headless
self.slow_mo = slow_mo
self._playwright = None
self._context = None
def open_context(
self,
profile_name: Optional[str] = None,
headless: Optional[bool] = None,
slow_mo: Optional[int] = None,
):
"""Open a persistent browser context. Returns the BrowserContext."""
from playwright.sync_api import sync_playwright
from gimme_job.utils.paths import chrome_profile_dir
name = profile_name or self.profile_name
hl = headless if headless is not None else self.headless
sm = slow_mo if slow_mo is not None else self.slow_mo
profile_path = chrome_profile_dir(name)
profile_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Opening browser context: profile={name}, headless={hl}")
self._playwright = sync_playwright().start()
self._context = self._playwright.chromium.launch_persistent_context(
user_data_dir=str(profile_path),
headless=hl,
slow_mo=sm,
channel="chrome",
args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
viewport={"width": 1280, "height": 900},
locale="en-US",
)
return self._context
def close(self) -> None:
if self._context:
try:
self._context.close()
except Exception:
pass
self._context = None
if self._playwright:
try:
self._playwright.stop()
except Exception:
pass
self._playwright = None
def save_trace(self, trace_path: Path) -> None:
if self._context:
try:
self._context.tracing.stop(path=str(trace_path))
logger.debug(f"Trace saved: {trace_path}")
except Exception as e:
logger.warning(f"Failed to save trace: {e}")
def start_tracing(self) -> None:
if self._context:
try:
self._context.tracing.start(screenshots=True, snapshots=True)
except Exception as e:
logger.warning(f"Failed to start tracing: {e}")
def __enter__(self):
return self
def __exit__(self, *args):
self.close()

@ -0,0 +1,136 @@
"""Claude Code CLI subprocess wrapper."""
from __future__ import annotations
import json
import subprocess
import time
from pathlib import Path
from typing import Optional
from loguru import logger
from gimme_job.models.runtime import ClaudeRunResult
DEFAULT_LEARN_TOOLS = ["Read", "Edit", "Write", "Bash", "Grep", "Glob"]
DEFAULT_REPAIR_TOOLS = ["Read", "Edit", "Write", "Bash", "Grep", "Glob"]
DEFAULT_TIMEOUT_SECONDS = 600
class ClaudeCodeClient:
def __init__(self, cwd: Optional[Path] = None, timeout: int = DEFAULT_TIMEOUT_SECONDS):
from gimme_job.utils.paths import project_root
self.cwd = str(cwd or project_root())
self.timeout = timeout
def run_prompt(
self,
prompt: str,
cwd: Optional[Path] = None,
allowed_tools: Optional[list[str]] = None,
max_turns: int = 30,
) -> ClaudeRunResult:
"""Run a prompt via `claude -p`. Returns parsed ClaudeRunResult."""
tools = allowed_tools or DEFAULT_LEARN_TOOLS
cmd = [
"claude",
"-p", prompt,
"--output-format", "json",
"--max-turns", str(max_turns),
"--allowedTools", ",".join(tools),
]
work_dir = str(cwd) if cwd else self.cwd
start = time.time()
logger.info(f"Calling Claude Code CLI (cwd={work_dir})")
logger.debug(f"Command: {' '.join(cmd[:4])}...")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=work_dir,
timeout=self.timeout,
)
duration_ms = int((time.time() - start) * 1000)
if result.returncode != 0:
logger.error(f"Claude Code returned non-zero: {result.returncode}")
logger.debug(f"stderr: {result.stderr[:500]}")
return ClaudeRunResult(
success=False,
output=result.stdout,
error=result.stderr[:500],
duration_ms=duration_ms,
)
# Parse JSON output
parsed = self._parse_output(result.stdout)
return ClaudeRunResult(
success=True,
session_id=parsed.get("session_id"),
output=parsed.get("result", result.stdout),
cost_usd=parsed.get("cost_usd"),
duration_ms=duration_ms,
)
except subprocess.TimeoutExpired:
logger.error(f"Claude Code timed out after {self.timeout}s")
return ClaudeRunResult(
success=False,
error=f"Timed out after {self.timeout}s",
)
except FileNotFoundError:
logger.error("Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code")
return ClaudeRunResult(success=False, error="claude CLI not found")
def resume(
self,
session_id: str,
prompt: str,
cwd: Optional[Path] = None,
) -> ClaudeRunResult:
"""Resume a previous session via `--resume`."""
cmd = [
"claude",
"-p", prompt,
"--resume", session_id,
"--output-format", "json",
]
work_dir = str(cwd) if cwd else self.cwd
start = time.time()
try:
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=work_dir, timeout=self.timeout
)
duration_ms = int((time.time() - start) * 1000)
parsed = self._parse_output(result.stdout)
return ClaudeRunResult(
success=result.returncode == 0,
session_id=parsed.get("session_id"),
output=parsed.get("result", result.stdout),
duration_ms=duration_ms,
error=result.stderr[:500] if result.returncode != 0 else None,
)
except Exception as e:
return ClaudeRunResult(success=False, error=str(e))
def _parse_output(self, raw: str) -> dict:
"""Parse Claude Code JSON output, handling streaming line format."""
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Try last non-empty line (streaming format)
lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
for line in reversed(lines):
try:
return json.loads(line)
except json.JSONDecodeError:
continue
return {"result": raw}

@ -0,0 +1,34 @@
"""Fingerprint-based deduplication against the database."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from gimme_job.models.db import JobPosting
from gimme_job.models.dto import JobPostingCandidate
from gimme_job.utils.hashing import compute_fingerprint
def ensure_fingerprints(candidates: list[JobPostingCandidate]) -> list[JobPostingCandidate]:
"""Compute fingerprints for any candidates that don't have one."""
for c in candidates:
if not c.fingerprint:
c.fingerprint = compute_fingerprint(
site_id=c.site_id,
title=c.title,
company=c.company,
location=c.location,
url=c.job_url,
)
return candidates
def deduplicate_in_batch(candidates: list[JobPostingCandidate]) -> list[JobPostingCandidate]:
"""Remove duplicates within the current batch by fingerprint."""
seen: set[str] = set()
result = []
for c in candidates:
if c.fingerprint and c.fingerprint not in seen:
seen.add(c.fingerprint)
result.append(c)
return result

@ -0,0 +1,131 @@
"""Extraction helpers for pulling job card data from Playwright pages."""
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from loguru import logger
if TYPE_CHECKING:
from playwright.sync_api import ElementHandle, Page
from gimme_job.models.dto import RawJobCard
from gimme_job.models.manifest import ExtractConfig, FieldSelector, PostFilterConfig
def extract_field(element: "ElementHandle", field_config: FieldSelector) -> Optional[str]:
"""Extract a single field value from a card element."""
# Text extraction: try each selector in order
if field_config.text:
for selector in field_config.text:
try:
child = element.query_selector(selector)
if child:
text = child.inner_text()
if text and text.strip():
return text.strip()
except Exception:
continue
# Fallback: return the element's own inner_text if no child matched
# but only if text list was non-empty
return None
# Attribute extraction
if field_config.attr:
try:
child = element.query_selector(field_config.attr.selector)
if child:
val = child.get_attribute(field_config.attr.name)
return val.strip() if val else None
except Exception:
pass
return None
def extract_cards_from_page(page: "Page", extract_config: ExtractConfig) -> list[RawJobCard]:
"""Extract all job cards from the current page using the manifest config."""
cards: list[RawJobCard] = []
# Try each container selector until one yields elements
container_elements = []
for selector in extract_config.container_selectors:
try:
elements = page.query_selector_all(selector)
if elements:
container_elements = elements
logger.debug(f"Container selector matched: '{selector}' ({len(elements)} items)")
break
except Exception as e:
logger.debug(f"Container selector failed: '{selector}': {e}")
continue
if not container_elements:
logger.warning("No container elements found with any selector")
return cards
for element in container_elements:
try:
raw = _extract_one_card(element, extract_config)
if raw and raw.title:
cards.append(raw)
except Exception as e:
logger.debug(f"Card extraction error: {e}")
continue
logger.info(f"Extracted {len(cards)} cards from page")
return cards
def _extract_one_card(element: "ElementHandle", extract_config: ExtractConfig) -> Optional[RawJobCard]:
"""Extract a single job card from a container element."""
fields = extract_config.fields
def get(field_name: str) -> Optional[str]:
fc = fields.get(field_name)
if fc is None:
return None
return extract_field(element, fc)
title = get("title")
if not title:
return None
return RawJobCard(
title=title,
company=get("company"),
location=get("location"),
posted_text=get("posted_text"),
url=get("url"),
salary_text=get("salary_text"),
employment_type=get("employment_type"),
raw_text=_safe_inner_text(element),
)
def _safe_inner_text(element: "ElementHandle") -> Optional[str]:
try:
return element.inner_text()
except Exception:
return None
def apply_post_filters(
cards: list[RawJobCard], post_filter_config: PostFilterConfig
) -> list[RawJobCard]:
"""Filter cards based on posted_text include list."""
if not post_filter_config.include_posted_text:
return cards
include_lower = [t.lower() for t in post_filter_config.include_posted_text]
filtered = []
for card in cards:
if not card.posted_text:
filtered.append(card) # include if no date info (can't filter)
continue
pt = card.posted_text.lower()
if any(term in pt for term in include_lower):
filtered.append(card)
logger.debug(f"Post-filter: {len(cards)} -> {len(filtered)} cards")
return filtered

@ -0,0 +1,57 @@
"""Site health tracking and repair_needed logic."""
from __future__ import annotations
from typing import Optional
from loguru import logger
from sqlalchemy.orm import Session
from gimme_job.constants import CONSECUTIVE_FAILURES_THRESHOLD, FailureClassification, RunStatus
from gimme_job.models.manifest import SiteManifest
from gimme_job.models.runtime import SiteRunResult
ANTI_BOT_PATTERNS = [
"access denied",
"captcha",
"robot",
"are you human",
"unusual activity",
"blocked",
"403",
"cloudflare",
]
def classify_page_content(html: str) -> Optional[FailureClassification]:
"""Detect anti-bot or login-required signals in page HTML."""
html_lower = html.lower()
if any(p in html_lower for p in ANTI_BOT_PATTERNS):
return FailureClassification.ANTI_BOT_SUSPECTED
login_signals = ["sign in", "log in", "login required", "please log in"]
if any(p in html_lower for p in login_signals):
return FailureClassification.LOGIN_REQUIRED
return None
def update_site_status(
site_id: str,
result: SiteRunResult,
session: Session,
) -> None:
"""Update SiteConfig table based on run result."""
from gimme_job.db.repo import SiteConfigRepo
repo = SiteConfigRepo()
if result.status == RunStatus.SUCCESS:
repo.record_success(session, site_id)
logger.info(f"[{site_id}] Run success — failures reset")
elif result.status == RunStatus.FAILED:
count = repo.increment_failure(session, site_id)
logger.warning(f"[{site_id}] Run failed — consecutive failures: {count}")
if count >= CONSECUTIVE_FAILURES_THRESHOLD:
logger.error(f"[{site_id}] repair_needed set after {count} consecutive failures")
elif result.status == RunStatus.PARTIAL:
# Partial success — don't increment failures, but don't reset either
pass

@ -0,0 +1,136 @@
"""KakaoTalk self-memo notification client."""
from __future__ import annotations
import os
from typing import Optional
from loguru import logger
from tenacity import retry, stop_after_attempt, wait_exponential
KAKAO_MEMO_URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send"
KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
MAX_TEXT_LENGTH = 9000 # KakaoTalk text message limit
class KakaoTalkClient:
def __init__(
self,
rest_api_key: Optional[str] = None,
access_token: Optional[str] = None,
refresh_token: Optional[str] = None,
):
self.rest_api_key = rest_api_key or os.environ.get("KAKAO_REST_API_KEY", "")
self.access_token = access_token or os.environ.get("KAKAO_ACCESS_TOKEN", "")
self.refresh_token = refresh_token or os.environ.get("KAKAO_REFRESH_TOKEN", "")
def is_configured(self) -> bool:
return bool(self.rest_api_key and self.access_token)
def send_self_memo(self, text: str) -> bool:
"""Send a self-memo to KakaoTalk. Returns True on success."""
if not self.is_configured():
logger.warning("KakaoTalk not configured (missing tokens)")
return False
# Truncate if needed
if len(text) > MAX_TEXT_LENGTH:
text = text[: MAX_TEXT_LENGTH - 20] + "\n\n[...truncated]"
try:
return self._do_send(text)
except Exception as e:
logger.error(f"KakaoTalk send failed: {e}")
# Try token refresh once
if self.refresh_token:
try:
new_token = self.refresh_access_token()
if new_token:
self.access_token = new_token
return self._do_send(text)
except Exception as e2:
logger.error(f"Token refresh also failed: {e2}")
return False
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5))
def _do_send(self, text: str) -> bool:
import httpx
from jinja2 import Environment, FileSystemLoader
from gimme_job.utils.paths import project_root
import json
# Build template object
template_path = project_root() / "gimme_job" / "templates" / "kakao_default.json.j2"
if template_path.exists():
env = Environment(loader=FileSystemLoader(str(template_path.parent)))
tmpl = env.get_template("kakao_default.json.j2")
template_object_str = tmpl.render(text=text)
template_object = json.loads(template_object_str)
else:
template_object = {"object_type": "text", "text": text, "link": {}}
response = httpx.post(
KAKAO_MEMO_URL,
headers={"Authorization": f"Bearer {self.access_token}"},
data={"template_object": json.dumps(template_object, ensure_ascii=False)},
timeout=15.0,
)
if response.status_code == 200:
result = response.json()
if result.get("result_code") == 0:
logger.info("KakaoTalk self-memo sent successfully")
return True
else:
logger.warning(f"KakaoTalk API returned: {result}")
return False
elif response.status_code == 401:
raise Exception("Unauthorized — token may be expired")
else:
logger.error(f"KakaoTalk HTTP {response.status_code}: {response.text[:200]}")
return False
def refresh_access_token(self) -> Optional[str]:
"""Refresh the access token using the refresh token."""
import httpx
response = httpx.post(
KAKAO_TOKEN_URL,
data={
"grant_type": "refresh_token",
"client_id": self.rest_api_key,
"refresh_token": self.refresh_token,
},
timeout=15.0,
)
response.raise_for_status()
data = response.json()
new_token = data.get("access_token")
if new_token:
logger.info("KakaoTalk access token refreshed")
# Persist to env if possible
os.environ["KAKAO_ACCESS_TOKEN"] = new_token
self._update_env_file(new_token)
return new_token
def _update_env_file(self, new_access_token: str) -> None:
"""Update the .env file with the new access token."""
from gimme_job.utils.paths import project_root
env_path = project_root() / ".env"
if not env_path.exists():
return
try:
content = env_path.read_text()
lines = content.splitlines()
updated = []
found = False
for line in lines:
if line.startswith("KAKAO_ACCESS_TOKEN="):
updated.append(f"KAKAO_ACCESS_TOKEN={new_access_token}")
found = True
else:
updated.append(line)
if not found:
updated.append(f"KAKAO_ACCESS_TOKEN={new_access_token}")
env_path.write_text("\n".join(updated) + "\n")
except Exception as e:
logger.warning(f"Could not update .env with new token: {e}")

@ -0,0 +1,240 @@
"""Learn mode: capture page artifacts and invoke Claude Code to generate adapters."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from loguru import logger
from gimme_job.config import GlobalConfig
from gimme_job.runtime.claude_cli import ClaudeCodeClient, DEFAULT_LEARN_TOOLS
class LearnMode:
def __init__(self, global_config: GlobalConfig, claude_client: ClaudeCodeClient):
self.cfg = global_config
self.claude = claude_client
def learn_site(
self,
site_id: str,
url: str,
profile_name: str = "JobAgent",
headed: bool = True,
timeout: int = 180,
) -> bool:
from gimme_job.utils.paths import (
a11y_snapshot_path, html_dump_path, learn_screenshot_path,
manifests_dir, project_root, sites_dir,
)
from rich.console import Console
console = Console()
logger.info(f"[learn] Starting learn mode for {site_id}")
# Step 1: Open browser and capture artifacts
console.print(f"[cyan]Opening browser for {site_id}...[/cyan]")
artifacts = self._capture_artifacts(
site_id=site_id,
url=url,
profile_name=profile_name,
headed=headed,
timeout=timeout,
)
if not artifacts:
logger.error("[learn] Failed to capture artifacts")
return False
html_path, a11y_path, ss_path = artifacts
# Step 2: Build the learn prompt
prompt = self._build_prompt(
site_id=site_id,
url=url,
html_path=html_path,
a11y_path=a11y_path,
ss_path=ss_path,
)
# Step 3: Call Claude Code
console.print("[cyan]Calling Claude Code...[/cyan]")
result = self.claude.run_prompt(
prompt=prompt,
cwd=project_root(),
allowed_tools=DEFAULT_LEARN_TOOLS,
)
if not result.success:
logger.error(f"[learn] Claude Code failed: {result.error}")
return False
logger.info(f"[learn] Claude Code succeeded (session={result.session_id})")
# Step 4: Verify generated files
required_files = [
sites_dir() / f"{site_id}.yaml",
project_root() / "gimme_job" / "adapters" / f"{site_id}.py",
project_root() / "tests" / "adapters" / f"test_{site_id}.py",
]
missing = [f for f in required_files if not f.exists()]
if missing:
logger.error(f"[learn] Missing generated files: {missing}")
# One retry
console.print("[yellow]Some files missing — retrying...[/yellow]")
retry_prompt = f"The following files were not created: {missing}. Please create them now.\n\n" + prompt
result2 = self.claude.run_prompt(
prompt=retry_prompt,
cwd=project_root(),
allowed_tools=DEFAULT_LEARN_TOOLS,
)
if not result2.success:
return False
missing = [f for f in required_files if not f.exists()]
if missing:
logger.error(f"[learn] Still missing after retry: {missing}")
return False
# Step 5: Run smoke test
console.print(f"[cyan]Running smoke test for {site_id}...[/cyan]")
import subprocess
test_result = subprocess.run(
["uv", "run", "pytest", f"tests/adapters/test_{site_id}.py", "-v", "--tb=short"],
cwd=str(project_root()),
capture_output=True,
text=True,
timeout=120,
)
if test_result.returncode != 0:
logger.warning(f"[learn] Smoke test failed:\n{test_result.stdout[-1000:]}")
# One repair attempt
console.print("[yellow]Smoke test failed — asking Claude to fix...[/yellow]")
fix_prompt = (
f"The smoke test for {site_id} failed. Here is the output:\n\n"
f"```\n{test_result.stdout[-2000:]}\n```\n\n"
"Please fix the adapter and/or manifest so the test passes."
)
self.claude.run_prompt(
prompt=fix_prompt,
cwd=project_root(),
allowed_tools=DEFAULT_LEARN_TOOLS,
)
# Re-run test
test_result2 = subprocess.run(
["uv", "run", "pytest", f"tests/adapters/test_{site_id}.py", "-v"],
cwd=str(project_root()),
capture_output=True,
text=True,
timeout=120,
)
if test_result2.returncode != 0:
logger.error("[learn] Smoke test still failing after retry")
return False
logger.info(f"[learn] {site_id} learned successfully")
return True
def _capture_artifacts(
self,
site_id: str,
url: str,
profile_name: str,
headed: bool,
timeout: int,
) -> Optional[tuple[Path, Path, Path]]:
"""Open browser, navigate to URL, wait for user login, capture artifacts."""
from gimme_job.runtime.browser import BrowserManager
from gimme_job.utils.paths import (
a11y_snapshot_path, html_dump_path, learn_screenshot_path,
)
from gimme_job.utils.json_io import write_json
bm = BrowserManager(profile_name=profile_name, headless=not headed)
try:
context = bm.open_context(headless=not headed)
page = context.new_page()
page.set_default_timeout(timeout * 1000)
page.goto(url, timeout=timeout * 1000)
# Prompt user to login / handle cookie banners
import typer
print(f"\n Browser opened at: {url}")
print(" Please:")
print(" 1. Log in if required")
print(" 2. Dismiss cookie banners")
print(" 3. Make sure job results are visible")
typer.confirm(" Press Enter when the page is ready", default=True)
# Capture artifacts
html_path = html_dump_path(site_id)
html_path.parent.mkdir(parents=True, exist_ok=True)
html_path.write_text(page.content(), encoding="utf-8")
logger.info(f"HTML dump saved: {html_path}")
a11y_path = a11y_snapshot_path(site_id)
try:
a11y = page.accessibility.snapshot()
write_json(a11y_path, a11y or {})
logger.info(f"A11y snapshot saved: {a11y_path}")
except Exception as e:
logger.warning(f"A11y snapshot failed: {e}")
write_json(a11y_path, {})
ss_path = learn_screenshot_path(site_id)
ss_path.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(ss_path), full_page=False)
logger.info(f"Screenshot saved: {ss_path}")
return html_path, a11y_path, ss_path
except Exception as e:
logger.error(f"[learn] Artifact capture failed: {e}")
return None
finally:
bm.close()
def _build_prompt(
self,
site_id: str,
url: str,
html_path: Path,
a11y_path: Path,
ss_path: Path,
) -> str:
from jinja2 import Environment, FileSystemLoader
from gimme_job.utils.paths import project_root
from gimme_job.utils.json_io import write_yaml
prompts_dir = project_root() / "gimme_job" / "prompts"
# Read system prompt
system_md = (prompts_dir / "learn_system.md").read_text()
# Build global config yaml snippet
import yaml
from gimme_job.config import load_global_config
cfg = load_global_config()
global_cfg_yaml = yaml.dump(
{"keywords": cfg.search_defaults.keywords, "date_mode": cfg.search_defaults.date_mode},
allow_unicode=True,
)
# Read base adapter source for context
base_src_path = project_root() / "gimme_job" / "adapters" / "base.py"
base_src = base_src_path.read_text() if base_src_path.exists() else ""
env = Environment(loader=FileSystemLoader(str(prompts_dir)))
template = env.get_template("learn_user.md.j2")
user_prompt = template.render(
site_id=site_id,
site_url=url,
html_dump_path=str(html_path),
dom_snapshot_path=str(html_path),
screenshot_path=str(ss_path),
global_config_yaml=global_cfg_yaml,
base_adapter_source=base_src[:3000], # truncate for prompt size
)
return f"{system_md}\n\n---\n\n{user_prompt}"

@ -0,0 +1,68 @@
"""Notification dispatcher: KakaoTalk with Markdown fallback."""
from __future__ import annotations
from datetime import date
from loguru import logger
from gimme_job.config import GlobalConfig
class NotificationDispatcher:
def __init__(self, global_config: GlobalConfig, session_factory):
self.cfg = global_config
self.session_factory = session_factory
def send(self, summary: str, run_date: date) -> bool:
"""Send the summary. Returns True if any method succeeded."""
from gimme_job.db.repo import NotificationLogRepo
from gimme_job.runtime.kakao import KakaoTalkClient
client = KakaoTalkClient()
success = False
if client.is_configured():
ok = client.send_self_memo(summary)
if ok:
success = True
self._log_notification(run_date, "kakaotalk", "success")
else:
logger.warning("KakaoTalk failed — writing markdown fallback")
self._log_notification(run_date, "kakaotalk", "failed", "send_self_memo returned False")
else:
logger.warning("KakaoTalk not configured — writing markdown fallback only")
self._log_notification(run_date, "kakaotalk", "skipped", "not configured")
# Always save markdown fallback if configured
if self.cfg.notification.fallback_markdown:
path = self.send_markdown_fallback(summary, run_date)
logger.info(f"Markdown saved: {path}")
if not success:
self._log_notification(run_date, "markdown", "success")
success = True
return success
def send_markdown_fallback(self, summary: str, run_date: date):
"""Save summary to workspace/reports/YYYY-MM-DD.md."""
from gimme_job.utils.paths import reports_dir
path = reports_dir() / f"{run_date.strftime('%Y-%m-%d')}.md"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(summary, encoding="utf-8")
return path
def _log_notification(
self,
run_date: date,
provider: str,
status: str,
error_message: str | None = None,
) -> None:
try:
with self.session_factory() as session:
from gimme_job.db.repo import NotificationLogRepo
NotificationLogRepo().log_notification(
session, run_date, provider, status, error_message
)
except Exception as e:
logger.warning(f"Failed to log notification: {e}")

@ -0,0 +1,275 @@
"""Run orchestrator: coordinates all sites, browser, extraction, DB, and notification."""
from __future__ import annotations
import random
import time
import uuid
from datetime import date, datetime
from pathlib import Path
from typing import Optional
from loguru import logger
from gimme_job.config import GlobalConfig, list_enabled_sites, load_site_manifest, merge_query
from gimme_job.constants import FailureClassification, RunStatus
from gimme_job.models.dto import SearchQuery
from gimme_job.models.manifest import SiteManifest
from gimme_job.models.runtime import RunSummary, SiteRunResult
class RunOrchestrator:
def __init__(self, global_config: GlobalConfig, session_factory):
self.cfg = global_config
self.session_factory = session_factory
def run_all(
self,
site_filter: Optional[str] = None,
dry_run: bool = False,
skip_notify: bool = False,
limit_sites: Optional[int] = None,
) -> RunSummary:
from gimme_job.adapters.registry import _load_all_adapters
_load_all_adapters()
run_date = date.today()
summary = RunSummary(run_date=datetime.utcnow())
# Determine sites to run
if site_filter:
site_ids = [site_filter]
else:
site_ids = list_enabled_sites()
if limit_sites:
site_ids = site_ids[:limit_sites]
if not site_ids:
logger.warning("No enabled sites found")
return summary
logger.info(f"Running {len(site_ids)} sites: {site_ids}")
for i, site_id in enumerate(site_ids):
result = self._run_one_site(site_id, run_date, dry_run)
summary.add_result(result)
# Record to DB (unless dry run)
if not dry_run:
with self.session_factory() as session:
from gimme_job.db.repo import SiteRunRepo
from gimme_job.runtime.health import update_site_status
SiteRunRepo().record_run(session, result)
update_site_status(site_id, result, session)
# Inter-site delay (skip after last site)
if i < len(site_ids) - 1:
delay = random.randint(
self.cfg.runtime.min_delay_ms,
self.cfg.runtime.max_delay_ms,
) / 1000.0
logger.debug(f"Sleeping {delay:.1f}s before next site")
time.sleep(delay)
# Summarize and notify
if not dry_run and not skip_notify and summary.total_new > 0:
self._summarize_and_notify(summary, run_date)
return summary
def _run_one_site(
self, site_id: str, run_date: date, dry_run: bool
) -> SiteRunResult:
result = SiteRunResult(site_id=site_id, started_at=datetime.utcnow())
try:
manifest = load_site_manifest(site_id)
except FileNotFoundError:
logger.error(f"[{site_id}] Manifest not found")
result.status = RunStatus.FAILED
result.error_summary = "Manifest file not found"
result.mark_done()
return result
query = merge_query(self.cfg, manifest)
run_id = str(uuid.uuid4())[:8]
from gimme_job.utils.paths import screenshot_path, dom_snapshot_path, trace_path
try:
candidates = self._scrape_site(site_id, manifest, query, run_id, result)
except Exception as e:
logger.error(f"[{site_id}] Scrape error: {e}")
result.status = RunStatus.FAILED
result.error_summary = str(e)
result.mark_done()
return result
result.items_found = len(candidates)
if not dry_run and candidates:
with self.session_factory() as session:
from gimme_job.db.repo import JobPostingRepo
total, new_count = JobPostingRepo().upsert_candidates(session, candidates, run_date)
result.new_items = new_count
elif dry_run:
result.new_items = len(candidates) # treat all as new in dry run
if result.status != RunStatus.FAILED:
result.status = RunStatus.SUCCESS
result.mark_done()
logger.info(
f"[{site_id}] Done: {result.items_found} found, {result.new_items} new"
)
return result
def _scrape_site(
self,
site_id: str,
manifest: SiteManifest,
query: SearchQuery,
run_id: str,
result: SiteRunResult,
):
from gimme_job.adapters.registry import get_adapter
from gimme_job.runtime.browser import BrowserManager
from gimme_job.runtime.dedupe import deduplicate_in_batch, ensure_fingerprints
from gimme_job.runtime.extractor import apply_post_filters
from gimme_job.utils.paths import dom_snapshot_path, screenshot_path, trace_path
headless = manifest.browser.headless_on_run
profile = manifest.browser.profile_name or self.cfg.runtime.profile_name
bm = BrowserManager(profile_name=profile, headless=headless, slow_mo=self.cfg.runtime.slow_mo_ms)
all_cards = []
try:
context = bm.open_context()
bm.start_tracing()
page = context.new_page()
# Set timeouts
page.set_default_timeout(self.cfg.runtime.default_timeout_ms)
page.set_default_navigation_timeout(self.cfg.runtime.navigation_timeout_ms)
adapter = get_adapter(site_id, manifest)
# Navigate
url = manifest.build_start_url(query)
logger.info(f"[{site_id}] Navigating to {url}")
page.goto(url, timeout=self.cfg.runtime.navigation_timeout_ms)
# Apply search (for input-field based search)
adapter.apply_search(page, query)
# Apply filters (date filter etc.)
adapter.apply_filters(page, query)
# Wait for content
if manifest.search.result_list_wait_selector:
try:
page.wait_for_selector(
manifest.search.result_list_wait_selector,
timeout=self.cfg.runtime.default_timeout_ms,
state="attached",
)
except Exception:
logger.warning(f"[{site_id}] Wait selector timed out — continuing anyway")
# Collect cards across pages
max_pages = min(
manifest.pagination.max_pages,
self.cfg.runtime.max_pages_per_site,
)
for page_idx in range(max_pages):
page_cards = adapter.collect_cards(page, manifest)
all_cards.extend(page_cards)
logger.debug(f"[{site_id}] Page {page_idx+1}: {len(page_cards)} cards")
if page_idx < max_pages - 1:
if not adapter.paginate(page, page_idx, manifest):
break
time.sleep(0.5)
# Take screenshot
try:
ss_path = screenshot_path(site_id, run_id)
page.screenshot(path=str(ss_path), full_page=False)
result.screenshot_path = ss_path
except Exception as e:
logger.debug(f"[{site_id}] Screenshot failed: {e}")
# Save DOM snapshot
try:
dom_path = dom_snapshot_path(site_id, run_id)
dom_path.parent.mkdir(parents=True, exist_ok=True)
dom_path.write_text(page.content(), encoding="utf-8")
result.dom_snapshot_path = dom_path
except Exception as e:
logger.debug(f"[{site_id}] DOM snapshot failed: {e}")
finally:
try:
t_path = trace_path(site_id, run_id)
bm.save_trace(t_path)
result.trace_path = t_path
except Exception:
pass
bm.close()
# Normalize cards
adapter = get_adapter(site_id, manifest) # re-instantiate (stateless)
candidates = []
for card in all_cards:
try:
candidate = adapter.normalize(card)
if candidate.is_valid():
candidates.append(candidate)
except Exception as e:
logger.debug(f"[{site_id}] Normalize error: {e}")
# Apply post-filters
if manifest.post_filters.include_posted_text:
from gimme_job.models.dto import RawJobCard
# Re-filter candidates based on posted_text
include_lower = [t.lower() for t in manifest.post_filters.include_posted_text]
candidates = [
c for c in candidates
if not c.posted_text or any(t in (c.posted_text or "").lower() for t in include_lower)
]
# Ensure fingerprints and deduplicate within batch
candidates = ensure_fingerprints(candidates)
candidates = deduplicate_in_batch(candidates)
return candidates
def _summarize_and_notify(self, summary: RunSummary, run_date: date) -> None:
try:
with self.session_factory() as session:
from gimme_job.db.repo import JobPostingRepo, SummaryRepo
postings = JobPostingRepo().get_today_new(session, run_date)
if not postings:
return
from gimme_job.runtime.summarizer import OllamaSummarizer
summarizer = OllamaSummarizer(
base_url=self.cfg.summarization.ollama_base_url,
model=self.cfg.summarization.model,
temperature=self.cfg.summarization.temperature,
)
text = summarizer.summarize(postings)
summary.summary_text = text
SummaryRepo().save_summary(session, run_date, text, self.cfg.summarization.model)
from gimme_job.runtime.notifier import NotificationDispatcher
dispatcher = NotificationDispatcher(
global_config=self.cfg, session_factory=self.session_factory
)
dispatcher.send(text, run_date)
except Exception as e:
logger.error(f"Summarize/notify failed: {e}")

@ -0,0 +1,145 @@
"""Repair mode: use Claude Code to fix a broken site adapter."""
from __future__ import annotations
from pathlib import Path
from loguru import logger
from gimme_job.config import GlobalConfig
from gimme_job.runtime.claude_cli import ClaudeCodeClient, DEFAULT_REPAIR_TOOLS
class RepairMode:
def __init__(self, global_config: GlobalConfig, claude_client: ClaudeCodeClient):
self.cfg = global_config
self.claude = claude_client
def repair_site(self, site_id: str) -> bool:
from gimme_job.db.engine import get_engine, get_session_factory
from gimme_job.db.repo import SiteConfigRepo, SiteRunRepo
from gimme_job.utils.paths import project_root, sites_dir
from rich.console import Console
console = Console()
logger.info(f"[repair] Starting repair for {site_id}")
engine = get_engine()
factory = get_session_factory(engine)
# Load recent failure artifacts
with factory() as session:
recent_runs = SiteRunRepo().get_recent_runs(session, site_id, limit=5)
# Find last failure with artifacts
last_run = None
for run in recent_runs:
if run.status in ("failed", "repair_needed"):
last_run = run
break
error_summary = last_run.error_summary if last_run else "No recent failure details"
failure_classification = last_run.failure_classification if last_run else None
dom_path = last_run.dom_snapshot_path if last_run else None
ss_path = last_run.screenshot_path if last_run else None
trace_path = last_run.trace_path if last_run else None
# Format recent run history
run_history = "\n".join(
f"- {r.started_at.strftime('%Y-%m-%d %H:%M')} | {r.status} | items={r.items_found}"
for r in recent_runs[:5]
) or "No run history"
manifest_path = sites_dir() / f"{site_id}.yaml"
adapter_path = project_root() / "gimme_job" / "adapters" / f"{site_id}.py"
if not manifest_path.exists():
logger.error(f"[repair] Manifest not found: {manifest_path}")
return False
# Build repair prompt
prompt = self._build_prompt(
site_id=site_id,
error_summary=error_summary,
failure_classification=failure_classification,
run_history=run_history,
dom_path=dom_path,
ss_path=ss_path,
trace_path=trace_path,
manifest_path=manifest_path,
adapter_path=adapter_path,
)
console.print("[cyan]Calling Claude Code for repair...[/cyan]")
result = self.claude.run_prompt(
prompt=prompt,
cwd=project_root(),
allowed_tools=DEFAULT_REPAIR_TOOLS,
)
if not result.success:
logger.error(f"[repair] Claude Code failed: {result.error}")
return False
# Run smoke test
console.print(f"[cyan]Running smoke test...[/cyan]")
import subprocess
test_result = subprocess.run(
["uv", "run", "pytest", f"tests/adapters/test_{site_id}.py", "-v", "--tb=short"],
cwd=str(project_root()),
capture_output=True,
text=True,
timeout=120,
)
if test_result.returncode != 0:
logger.error(f"[repair] Smoke test failed:\n{test_result.stdout[-500:]}")
return False
# Clear repair_needed flag
with factory() as session:
SiteConfigRepo().set_repair_needed(session, site_id, False)
SiteConfigRepo().reset_failures(session, site_id)
# Also update the YAML manifest
try:
from gimme_job.config import load_site_manifest
manifest = load_site_manifest(site_id)
manifest.repair_needed = False
manifest.to_yaml(manifest_path)
except Exception as e:
logger.warning(f"[repair] Could not update manifest YAML: {e}")
logger.info(f"[repair] {site_id} repaired successfully")
return True
def _build_prompt(
self,
site_id: str,
error_summary: str,
failure_classification: str | None,
run_history: str,
dom_path: str | None,
ss_path: str | None,
trace_path: str | None,
manifest_path: Path,
adapter_path: Path,
) -> str:
from jinja2 import Environment, FileSystemLoader
from gimme_job.utils.paths import project_root
prompts_dir = project_root() / "gimme_job" / "prompts"
env = Environment(loader=FileSystemLoader(str(prompts_dir)))
template = env.get_template("repair_user.md.j2")
return template.render(
site_id=site_id,
error_summary=error_summary,
failure_classification=failure_classification,
recent_run_history=run_history,
last_run_dom_snapshot_path=dom_path,
last_run_screenshot_path=ss_path,
last_run_trace_path=trace_path,
current_manifest_path=str(manifest_path),
current_adapter_path=str(adapter_path),
)

@ -0,0 +1,117 @@
"""Ollama/Qwen summarizer for job postings."""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
from tenacity import retry, stop_after_attempt, wait_exponential
if TYPE_CHECKING:
from gimme_job.models.db import JobPosting
class OllamaSummarizer:
def __init__(
self,
base_url: str = "http://127.0.0.1:11434",
model: str = "qwen3.5:9b",
temperature: float = 0.1,
):
self.base_url = base_url.rstrip("/")
self.model = model
self.temperature = temperature
def summarize(self, postings: list["JobPosting"]) -> str:
"""Generate a Korean-language summary of job postings."""
if not postings:
return "오늘 신규 채용 공고가 없습니다."
prompt = self._build_prompt(postings)
try:
return self._call_ollama(prompt)
except Exception as e:
logger.error(f"Ollama summarization failed: {e}")
return self._fallback_summary(postings)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def _call_ollama(self, prompt: str) -> str:
import httpx
response = httpx.post(
f"{self.base_url}/api/generate",
json={
"model": self.model,
"prompt": prompt,
"temperature": self.temperature,
"stream": False,
"options": {"num_predict": 2048},
},
timeout=120.0,
)
response.raise_for_status()
data = response.json()
return data.get("response", "").strip()
def _build_prompt(self, postings: list["JobPosting"]) -> str:
from jinja2 import Environment, FileSystemLoader
from gimme_job.utils.paths import project_root
template_path = project_root() / "gimme_job" / "templates" / "digest.md.j2"
if template_path.exists():
env = Environment(loader=FileSystemLoader(str(template_path.parent)))
template = env.get_template("digest.md.j2")
return template.render(postings=postings, count=len(postings))
# Inline fallback
lines = []
for p in postings[:100]:
parts = [f"[{p.site_id}]", p.title]
if p.company:
parts.append(p.company)
if p.location:
parts.append(p.location)
if p.posted_text:
parts.append(p.posted_text)
if p.job_url:
parts.append(p.job_url)
lines.append(" / ".join(parts))
postings_text = "\n".join(f"- {l}" for l in lines)
return (
"다음은 오늘 수집된 신규 채용 공고 목록입니다. "
"한국어로 bullet point 요약을 작성하고, 추천 우선순위 3개를 제시하세요. "
"원본 데이터를 절대 변조하지 마세요.\n\n"
f"{postings_text}\n\n"
"형식:\n"
"# 오늘의 신규 채용 공고\n"
"- [사이트] 직무 / 회사 / 위치 / 게시일 / URL\n\n"
"## 추천 우선순위\n"
"1. ...\n"
"2. ...\n"
"3. ..."
)
def _fallback_summary(self, postings: list["JobPosting"]) -> str:
"""Plain-text fallback when Ollama is unavailable."""
lines = ["# 오늘의 신규 채용 공고\n"]
for p in postings:
parts = [f"[{p.site_id}]", p.title]
if p.company:
parts.append(p.company)
if p.location:
parts.append(p.location)
if p.posted_text:
parts.append(p.posted_text)
if p.job_url:
parts.append(p.job_url)
lines.append("- " + " / ".join(parts))
return "\n".join(lines)
def is_available(self) -> bool:
try:
import httpx
r = httpx.get(f"{self.base_url}/api/tags", timeout=3.0)
return r.status_code == 200
except Exception:
return False

@ -0,0 +1,19 @@
다음은 오늘 수집된 신규 채용 공고 {{ count }}건입니다.
한국어로 간결하게 요약하고, 추천 우선순위 3개를 제시하세요.
데이터를 임의로 추가하거나 변조하지 마세요. 원본 그대로 사용하세요.
채용 공고 목록:
{% for p in postings %}
- [{{ p.site_id }}] {{ p.title }}{% if p.company %} / {{ p.company }}{% endif %}{% if p.location %} / {{ p.location }}{% endif %}{% if p.posted_text %} / {{ p.posted_text }}{% endif %}{% if p.job_url %} / {{ p.job_url }}{% endif %}
{% endfor %}
출력 형식:
# 오늘의 신규 채용 공고
- [사이트] 직무 / 회사 / 위치 / 게시일 / URL
...
## 추천 우선순위
1. (이유 포함)
2. (이유 포함)
3. (이유 포함)

@ -0,0 +1,8 @@
{
"object_type": "text",
"text": {{ text | tojson }},
"link": {
"web_url": "",
"mobile_web_url": ""
}
}

@ -0,0 +1,64 @@
import re
from datetime import datetime, timezone
def normalize_posted_text(text: str | None) -> datetime | None:
"""Parse relative posted-date strings into UTC datetimes.
Returns None if the text cannot be parsed.
"""
if not text:
return None
t = text.strip().lower()
now = datetime.now(timezone.utc)
# Exact matches
if t in ("today", "just posted", "posted today", "new", "1d", "0d"):
return now.replace(hour=0, minute=0, second=0, microsecond=0)
# "X hours ago" / "X hour ago"
m = re.search(r"(\d+)\s*hours?\s*ago", t)
if m:
from datetime import timedelta
return now - timedelta(hours=int(m.group(1)))
# "1 day ago" / "2 days ago"
m = re.search(r"(\d+)\s*days?\s*ago", t)
if m:
from datetime import timedelta
days = int(m.group(1))
if days <= 1:
return now.replace(hour=0, minute=0, second=0, microsecond=0)
return now - timedelta(days=days)
# "yesterday"
if "yesterday" in t:
from datetime import timedelta
return now - timedelta(days=1)
# "X minutes ago"
m = re.search(r"(\d+)\s*minutes?\s*ago", t)
if m:
from datetime import timedelta
return now - timedelta(minutes=int(m.group(1)))
# ISO date
m = re.match(r"(\d{4}-\d{2}-\d{2})", t)
if m:
try:
return datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
pass
return None
def is_recent(text: str | None, max_days: int = 1) -> bool:
"""Return True if posted_text represents a posting within max_days days."""
dt = normalize_posted_text(text)
if dt is None:
return False
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=max_days)
return dt >= cutoff

@ -0,0 +1,48 @@
import hashlib
import re
def normalize_for_fingerprint(text: str | None) -> str:
if not text:
return ""
# lowercase, strip, collapse whitespace, remove punctuation variation
t = text.lower().strip()
t = re.sub(r"\s+", " ", t)
return t
def compute_fingerprint(
site_id: str,
title: str | None,
company: str | None,
location: str | None,
url: str | None,
) -> str:
"""Compute a SHA256 fingerprint for deduplication.
URL is canonical-ized (query params stripped) if present.
Falls back to title+company+location when URL is absent.
"""
canonical_url = _canonical_url(url) if url else ""
parts = [
normalize_for_fingerprint(site_id),
normalize_for_fingerprint(title),
normalize_for_fingerprint(company),
normalize_for_fingerprint(location),
canonical_url,
]
raw = "|".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _canonical_url(url: str) -> str:
"""Strip tracking/session query parameters, keep path."""
from urllib.parse import urlparse, urlunparse
try:
parsed = urlparse(url)
# Drop query string entirely for canonicalization
canonical = urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", "", ""))
return canonical.lower().rstrip("/")
except Exception:
return url.lower().strip()

@ -0,0 +1,27 @@
import json
from pathlib import Path
from typing import Any
import yaml
def read_json(path: Path) -> Any:
with open(path, encoding="utf-8") as f:
return json.load(f)
def write_json(path: Path, data: Any, indent: int = 2) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=indent)
def read_yaml(path: Path) -> Any:
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
def write_yaml(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)

@ -0,0 +1,100 @@
from pathlib import Path
def project_root() -> Path:
"""Returns the project root (parent of the gimme_job package directory)."""
return Path(__file__).parent.parent.parent
def workspace_dir() -> Path:
return project_root() / "workspace"
def sites_dir() -> Path:
return project_root() / "sites"
def db_path() -> Path:
import os
db = os.environ.get("GIMME_JOB_DB_PATH", "gimme_job.db")
p = Path(db)
if not p.is_absolute():
p = project_root() / p
return p
def chrome_profile_dir(name: str = "JobAgent") -> Path:
return workspace_dir() / "chrome-profiles" / name
def captures_dir() -> Path:
return workspace_dir() / "captures"
def traces_dir() -> Path:
return workspace_dir() / "traces"
def screenshots_dir() -> Path:
return workspace_dir() / "screenshots"
def dom_dir() -> Path:
return workspace_dir() / "dom"
def manifests_dir() -> Path:
return workspace_dir() / "manifests"
def generated_dir() -> Path:
return workspace_dir() / "generated"
def logs_dir() -> Path:
return workspace_dir() / "logs"
def reports_dir() -> Path:
return workspace_dir() / "reports"
def trace_path(site_id: str, run_id: str) -> Path:
return traces_dir() / f"{site_id}_{run_id}.zip"
def screenshot_path(site_id: str, run_id: str) -> Path:
return screenshots_dir() / f"{site_id}_{run_id}.png"
def dom_snapshot_path(site_id: str, run_id: str) -> Path:
return dom_dir() / f"{site_id}_{run_id}.html"
def html_dump_path(site_id: str, suffix: str = "learn") -> Path:
return dom_dir() / f"{site_id}_{suffix}.html"
def a11y_snapshot_path(site_id: str, suffix: str = "learn") -> Path:
return dom_dir() / f"{site_id}_{suffix}_a11y.json"
def learn_screenshot_path(site_id: str) -> Path:
return screenshots_dir() / f"{site_id}_learn.png"
def ensure_workspace_dirs() -> None:
"""Create all workspace subdirectories."""
for d in [
workspace_dir(),
chrome_profile_dir(),
captures_dir(),
traces_dir(),
screenshots_dir(),
dom_dir(),
manifests_dir(),
generated_dir(),
logs_dir(),
reports_dir(),
]:
d.mkdir(parents=True, exist_ok=True)

@ -0,0 +1,31 @@
import re
import html
def normalize_whitespace(s: str | None) -> str:
if not s:
return ""
return re.sub(r"\s+", " ", s).strip()
def truncate(s: str, max_len: int, ellipsis: str = "...") -> str:
if len(s) <= max_len:
return s
return s[: max_len - len(ellipsis)] + ellipsis
def strip_html_tags(s: str | None) -> str:
if not s:
return ""
# Unescape HTML entities first
s = html.unescape(s)
# Remove tags
s = re.sub(r"<[^>]+>", " ", s)
return normalize_whitespace(s)
def slugify(s: str) -> str:
s = s.lower().strip()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_-]+", "-", s)
return s

@ -0,0 +1,50 @@
[project]
name = "gimme-job"
version = "0.1.0"
description = "Local job posting aggregator for macOS"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"typer[all]>=0.12",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"sqlalchemy>=2.0",
"playwright>=1.40",
"httpx>=0.27",
"tenacity>=8.0",
"loguru>=0.7",
"rich>=13.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"python-dotenv>=1.0",
]
[project.scripts]
gimme-job = "gimme_job.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["gimme_job"]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.4",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"network: marks tests that require a real network connection (deselect with -m 'not network')",
]

@ -0,0 +1,29 @@
runtime:
timezone: America/Phoenix
headless: true
profile_name: JobAgent
slow_mo_ms: 0
default_timeout_ms: 15000
navigation_timeout_ms: 30000
max_pages_per_site: 3
min_delay_ms: 1200
max_delay_ms: 3500
search_defaults:
keywords:
- orthodontist
location: ""
remote: false
date_mode: today_or_last_24h
sort: relevance
max_items_per_site: 30
summarization:
ollama_base_url: http://127.0.0.1:11434
model: qwen3.5:9b
temperature: 0.1
max_input_items: 200
notification:
provider: kakaotalk
fallback_markdown: true

@ -0,0 +1,89 @@
site_id: indeed
enabled: true
repair_needed: false
identity:
label: Indeed Jobs
category: aggregator
base_url: https://www.indeed.com
start_url_template: "https://www.indeed.com/jobs?q={keywords_urlencoded}&fromage=1&sort=date"
browser:
profile_mode: persistent_chrome_profile
profile_name: JobAgent
headed_on_learn: true
headless_on_run: true
search:
keyword_mode: url_param
location_mode: url_param
sort_mode: url_param
date_mode: url_param
result_list_wait_selector: "div.job_seen_beacon, td.resultContent, div[data-jk]"
search_override:
keywords: null
location: null
pagination:
mode: next_button
next_button_selectors:
- "a[data-testid='pagination-page-next']"
- "a[aria-label='Next Page']"
- "a[aria-label*='next']"
max_pages: 3
extract:
container_selectors:
- "div.job_seen_beacon"
- "div[data-jk]"
- "td.resultContent"
fields:
title:
text:
- "h2.jobTitle a span[id^='jobTitle']"
- "h2.jobTitle span"
- "h2 a span"
- "h2[class*='jobTitle'] a"
company:
text:
- "span[data-testid='company-name']"
- ".company"
- "span.companyName"
- "[class*='companyName']"
location:
text:
- "div[data-testid='text-location']"
- ".companyLocation"
- "div[class*='companyLocation']"
posted_text:
text:
- "span[data-testid='myJobsStateDate']"
- "span.date"
- "span[class*='date']"
url:
attr:
selector: "h2.jobTitle a, h2 a[data-jk]"
name: href
salary_text:
text:
- "div.salary-snippet-container"
- "div[data-testid='attribute_snippet_testid']"
- ".salary-snippet"
post_filters:
include_posted_text:
- "today"
- "just posted"
- "1 day"
- "active"
healthcheck:
min_items_expected: 0
required_fields:
- title
fail_if_zero_when_known_active: false
learn:
last_learned_at: null
source_url: null

@ -0,0 +1,86 @@
"""Smoke test for the Indeed adapter.
This test opens the Indeed search page and verifies that:
1. The page loads without error
2. Job cards are detected OR zero-result state is handled
3. Card data can be extracted
NOTE: This test requires a network connection and Chrome browser.
Mark with @pytest.mark.network to allow skipping in CI.
"""
import pytest
@pytest.fixture
def indeed_manifest():
from gimme_job.config import load_site_manifest
try:
return load_site_manifest("indeed")
except FileNotFoundError:
pytest.skip("indeed.yaml not found")
@pytest.fixture
def search_query():
from gimme_job.models.dto import SearchQuery
return SearchQuery(keywords=["orthodontist"], max_items=10)
def test_indeed_manifest_valid(indeed_manifest):
"""Manifest parses and has required fields."""
assert indeed_manifest.site_id == "indeed"
assert indeed_manifest.identity.base_url
assert indeed_manifest.identity.start_url_template
assert indeed_manifest.extract.container_selectors
def test_indeed_adapter_registered():
"""IndeedAdapter is registered in the registry."""
from gimme_job.adapters.registry import _load_all_adapters, _REGISTRY
_load_all_adapters()
assert "indeed" in _REGISTRY
def test_indeed_url_construction(indeed_manifest, search_query):
"""Start URL is constructed correctly with keyword encoding."""
url = indeed_manifest.build_start_url(search_query)
assert "orthodontist" in url.lower()
assert "indeed.com" in url
@pytest.mark.network
def test_indeed_smoke(indeed_manifest, search_query):
"""Open Indeed search and verify cards or zero-result are detected."""
from gimme_job.adapters.indeed import IndeedAdapter
from gimme_job.runtime.browser import BrowserManager
adapter = IndeedAdapter(indeed_manifest)
bm = BrowserManager(headless=True)
try:
context = bm.open_context(headless=True)
page = context.new_page()
page.set_default_timeout(15000)
url = indeed_manifest.build_start_url(search_query)
page.goto(url, timeout=30000)
# Allow page to settle
import time
time.sleep(2)
# Collect cards
cards = adapter.collect_cards(page, indeed_manifest)
# Either cards found OR page loaded cleanly (zero results is ok)
assert isinstance(cards, list), "collect_cards must return a list"
if cards:
# Validate at least 2 fields on first card
card = cards[0]
assert card.title and card.title.strip(), "Card must have a title"
has_second_field = any([card.company, card.location, card.posted_text, card.url])
assert has_second_field, "Card must have at least one additional field"
finally:
bm.close()

@ -0,0 +1,61 @@
"""Tests for date text normalization."""
from datetime import datetime, timezone
import pytest
from gimme_job.utils.dates import is_recent, normalize_posted_text
def test_today():
dt = normalize_posted_text("today")
assert dt is not None
assert dt.date() == datetime.now(timezone.utc).date()
def test_just_posted():
dt = normalize_posted_text("Just posted")
assert dt is not None
def test_1_day_ago():
dt = normalize_posted_text("1 day ago")
assert dt is not None
assert dt.date() == datetime.now(timezone.utc).date()
def test_2_days_ago():
dt = normalize_posted_text("2 days ago")
assert dt is not None
def test_hours_ago():
dt = normalize_posted_text("3 hours ago")
assert dt is not None
now = datetime.now(timezone.utc)
diff = now - dt
assert 2.9 * 3600 < diff.total_seconds() < 3.1 * 3600
def test_yesterday():
dt = normalize_posted_text("yesterday")
assert dt is not None
def test_none_input():
assert normalize_posted_text(None) is None
def test_empty_input():
assert normalize_posted_text("") is None
def test_unparseable():
assert normalize_posted_text("some random text") is None
def test_is_recent_today():
assert is_recent("today") is True
def test_is_recent_old():
assert is_recent("5 days ago", max_days=1) is False

@ -0,0 +1,60 @@
"""Tests for deduplication logic."""
from gimme_job.models.dto import JobPostingCandidate
from gimme_job.runtime.dedupe import deduplicate_in_batch, ensure_fingerprints
from gimme_job.utils.hashing import compute_fingerprint
def _make_candidate(title: str, company: str = "ABC", url: str = None) -> JobPostingCandidate:
fp = compute_fingerprint("indeed", title, company, "AZ", url)
return JobPostingCandidate(
site_id="indeed",
title=title,
company=company,
location="AZ",
job_url=url,
fingerprint=fp,
)
def test_deduplicate_removes_duplicates():
c1 = _make_candidate("Orthodontist", url="https://example.com/1")
c2 = _make_candidate("Orthodontist", url="https://example.com/1") # same fingerprint
c3 = _make_candidate("Dentist", url="https://example.com/2")
result = deduplicate_in_batch([c1, c2, c3])
assert len(result) == 2
titles = {r.title for r in result}
assert "Orthodontist" in titles
assert "Dentist" in titles
def test_deduplicate_empty_list():
assert deduplicate_in_batch([]) == []
def test_ensure_fingerprints():
c = JobPostingCandidate(
site_id="indeed",
title="Orthodontist",
company="ABC",
location="AZ",
job_url="https://example.com/1",
fingerprint="", # empty
)
result = ensure_fingerprints([c])
assert result[0].fingerprint != ""
assert len(result[0].fingerprint) == 64
def test_fingerprint_not_overwritten():
fp = compute_fingerprint("indeed", "Orthodontist", "ABC", "AZ", "https://example.com/1")
c = JobPostingCandidate(
site_id="indeed",
title="Orthodontist",
company="ABC",
location="AZ",
job_url="https://example.com/1",
fingerprint=fp,
)
result = ensure_fingerprints([c])
assert result[0].fingerprint == fp

@ -0,0 +1,44 @@
"""Tests for fingerprint hashing."""
from gimme_job.utils.hashing import compute_fingerprint, normalize_for_fingerprint
def test_fingerprint_consistent():
"""Same inputs always produce the same fingerprint."""
fp1 = compute_fingerprint("indeed", "Orthodontist", "ABC Dental", "Phoenix AZ", "https://example.com/job/1")
fp2 = compute_fingerprint("indeed", "Orthodontist", "ABC Dental", "Phoenix AZ", "https://example.com/job/1")
assert fp1 == fp2
def test_fingerprint_different_sites():
"""Different site_id produces different fingerprint."""
fp1 = compute_fingerprint("indeed", "Orthodontist", "ABC Dental", "Phoenix AZ", "https://example.com/job/1")
fp2 = compute_fingerprint("linkedin", "Orthodontist", "ABC Dental", "Phoenix AZ", "https://example.com/job/1")
assert fp1 != fp2
def test_fingerprint_case_insensitive():
"""Fingerprint is case-insensitive for title/company/location."""
fp1 = compute_fingerprint("indeed", "Orthodontist", "ABC Dental", "Phoenix, AZ", None)
fp2 = compute_fingerprint("indeed", "orthodontist", "abc dental", "phoenix, az", None)
assert fp1 == fp2
def test_fingerprint_url_strips_query():
"""URL query params are stripped for canonicalization."""
fp1 = compute_fingerprint("indeed", "Orthodontist", "ABC", "AZ", "https://example.com/job/1?ref=abc")
fp2 = compute_fingerprint("indeed", "Orthodontist", "ABC", "AZ", "https://example.com/job/1?ref=xyz")
assert fp1 == fp2
def test_fingerprint_no_url():
"""Fingerprint works without a URL."""
fp = compute_fingerprint("indeed", "Orthodontist", "ABC Dental", "Phoenix AZ", None)
assert len(fp) == 64 # SHA256 hex digest
def test_normalize_for_fingerprint_whitespace():
assert normalize_for_fingerprint(" hello world ") == "hello world"
def test_normalize_for_fingerprint_none():
assert normalize_for_fingerprint(None) == ""

@ -0,0 +1,59 @@
"""Tests for KakaoTalk client."""
from unittest.mock import MagicMock, patch
import pytest
from gimme_job.runtime.kakao import KakaoTalkClient, MAX_TEXT_LENGTH
def test_not_configured_without_tokens():
client = KakaoTalkClient(rest_api_key="", access_token="", refresh_token="")
assert not client.is_configured()
def test_configured_with_tokens():
client = KakaoTalkClient(rest_api_key="key123", access_token="token123")
assert client.is_configured()
def test_send_returns_false_when_not_configured():
client = KakaoTalkClient(rest_api_key="", access_token="")
result = client.send_self_memo("test message")
assert result is False
def test_text_truncation():
"""Messages over MAX_TEXT_LENGTH are truncated before sending."""
long_text = "x" * (MAX_TEXT_LENGTH + 1000)
client = KakaoTalkClient(rest_api_key="key", access_token="token")
with patch.object(client, "_do_send", return_value=True) as mock_send:
client.send_self_memo(long_text)
sent_text = mock_send.call_args[0][0]
assert len(sent_text) <= MAX_TEXT_LENGTH
assert "[...truncated]" in sent_text
@patch("httpx.post")
def test_send_self_memo_success(mock_post):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result_code": 0}
mock_post.return_value = mock_response
client = KakaoTalkClient(rest_api_key="key", access_token="token")
result = client._do_send("Hello KakaoTalk!")
assert result is True
@patch("httpx.post")
def test_send_self_memo_auth_failure(mock_post):
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
mock_post.return_value = mock_response
client = KakaoTalkClient(rest_api_key="key", access_token="expired_token")
# tenacity wraps the exception in RetryError after exhausting attempts
with pytest.raises(Exception):
client._do_send("test")

@ -0,0 +1,61 @@
"""Tests for site manifest parsing and config merging."""
import pytest
from pathlib import Path
def test_indeed_manifest_loads():
"""indeed.yaml parses into SiteManifest without errors."""
from gimme_job.config import load_site_manifest
manifest = load_site_manifest("indeed")
assert manifest.site_id == "indeed"
assert manifest.enabled is True
assert manifest.identity.base_url == "https://www.indeed.com"
assert len(manifest.extract.container_selectors) > 0
def test_manifest_start_url_template():
"""build_start_url substitutes keyword correctly."""
from gimme_job.config import load_site_manifest
from gimme_job.models.dto import SearchQuery
manifest = load_site_manifest("indeed")
query = SearchQuery(keywords=["orthodontist"])
url = manifest.build_start_url(query)
assert "orthodontist" in url.lower()
def test_merge_query_global_defaults():
"""merge_query returns global defaults when no overrides."""
from gimme_job.config import load_global_config, load_site_manifest, merge_query
cfg = load_global_config()
manifest = load_site_manifest("indeed")
query = merge_query(cfg, manifest)
assert query.keywords == cfg.search_defaults.keywords
def test_merge_query_site_override():
"""Site search_override takes precedence over global defaults."""
from gimme_job.config import load_global_config, merge_query
from gimme_job.models.manifest import SiteManifest, SiteIdentity, SearchOverride
# Build a minimal manifest with keyword override
manifest = SiteManifest(
site_id="test",
identity=SiteIdentity(
label="Test",
base_url="https://test.com",
start_url_template="https://test.com/jobs?q={keywords_urlencoded}",
),
search_override=SearchOverride(keywords=["dentist"]),
)
cfg = load_global_config()
query = merge_query(cfg, manifest)
assert query.keywords == ["dentist"]
def test_merge_query_cli_override():
"""CLI override takes precedence over site and global."""
from gimme_job.config import load_global_config, load_site_manifest, merge_query
cfg = load_global_config()
manifest = load_site_manifest("indeed")
query = merge_query(cfg, manifest, cli_overrides={"keywords": ["pediatric dentist"]})
assert query.keywords == ["pediatric dentist"]

@ -0,0 +1,794 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[[package]]
name = "gimme-job"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "jinja2" },
{ name = "loguru" },
{ name = "playwright" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "sqlalchemy" },
{ name = "tenacity" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "loguru", specifier = ">=0.7" },
{ name = "playwright", specifier = ">=1.40" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-settings", specifier = ">=2.0" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.0" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "tenacity", specifier = ">=8.0" },
{ name = "typer", extras = ["all"], specifier = ">=0.12" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.4" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "playwright"
version = "1.58.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
version = "0.15.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" },
{ url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" },
{ url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" },
{ url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" },
{ url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" },
{ url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" },
{ url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" },
{ url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" },
{ url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" },
{ url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" },
{ url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" },
{ url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" },
{ url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
{ url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]
Loading…
Cancel
Save