diff --git a/.gitignore b/.gitignore index edfae45..7ac17dd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ Thumbs.db data/profile.json data/jobs.json data/applications/ +data/cv/ data/*.local.json .env .env.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d423b9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ + +# Update for issue #65 + +# Update for issue #65\n +# Update for issue #65\n \ No newline at end of file diff --git a/README.md b/README.md index e1c5cf1..4c55dc0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [Repository layout](#repository-layout) - [Data layout](#data-layout) - [Adding a job site](#adding-a-job-site) +- [Skill aliases](#skill-aliases) - [Compliance](#compliance) - [Development](#development) - [MergeOS bounties](#mergeos-bounties) @@ -216,6 +217,7 @@ nerajob-gui | `nerajob scan --source …` | Scan one scraper | | `nerajob scan --all` | All registered scrapers | | `nerajob jobs list` | List cached jobs | +| `nerajob jobs match` | Rank cached jobs with configurable match weights | | `nerajob cv build --target "…"` | Build Markdown + text CV | | `nerajob apply prepare --job-id ` | Apply package for one job | | `nerajob gui` / `nerajob-gui` | **Qt desktop app** (needs `.[gui]`) | @@ -226,8 +228,13 @@ nerajob scan --source remoteok -q python -n 20 nerajob cv build --target "Backend Engineer" nerajob apply prepare --job-id nerajob jobs list +nerajob jobs match --skill-weight 70 --title-weight 20 --location-weight 12 ``` +Match scoring defaults to a 70-point skills cap, 20-point title/headline cap, and +12-point location/remote cap. Adjust those weights when a search should favor +role wording or location fit over direct skill hits. + --- ## Diagrams @@ -273,6 +280,7 @@ src/nerajob/ cv/builder.py apply/assistant.py docs/SOURCES.md +docs/SKILL_ALIASES.md docs/screenshots/ docs/diagrams/ ``` @@ -302,18 +310,78 @@ data/ See [docs/BOUNTY.md](docs/BOUNTY.md) for MergeOS scraper bounty acceptance. +## Adding a skill domain + +Skill aliases live in `src/nerajob/match.py` as `SKILL_ALIASES` — a `dict[str, set[str]]`. Each key is a canonical skill domain, and its set contains matching keywords used for resume ↔ job matching via `expand_skills()`. + +### Pattern + +Add a new domain entry in `SKILL_ALIASES`: + +```python +"new_domain": {"new_domain", "alias1", "alias2", "alias3"}, +``` + +Then add a test in `tests/test_skill_aliases.py`: + +```python +def test_expand_skills_new_domain(): + out = expand_skills({"new_domain"}) + assert "alias1" in out + assert "alias2" in out +``` + +Verify with the CLI: + +```bash +nerajob skills | grep new_domain +pytest tests/test_skill_aliases.py -q +``` + +### Existing domains (for reference) + +Run `nerajob skills` to list all domains and their aliases. + +| Domain key | Covers | +| --- | --- | +| `python` | django, fastapi, flask | +| `javascript` | js, typescript, node, react | +| `devops` | docker, kubernetes, k8s, ci/cd | +| `ml_ai` | machine learning, deep learning, nlp, pytorch, tensorflow | +| `data_engineering` | etl, spark, airflow, dbt, warehouse | +| `cybersecurity` | soc, siem, iam, infosec | +| `education` | teaching, curriculum, edtech, tutor | +| *(full list via `nerajob skills`)* | | + +Add new domains that don't overlap with existing keys. Each domain set should be self-contained — aliases only expand inward, not across domains. + +--- + +## Skill aliases + +NeraJob expands profile skills with `SKILL_ALIASES` in `src/nerajob/match.py` +before scoring jobs. When adding a new skill domain, keep the alias set focused +and add tests that exercise `expand_skills()`. + +Contributor guide: **[docs/SKILL_ALIASES.md](docs/SKILL_ALIASES.md)**. + --- ## Compliance -NeraJob is built for **ethical, ToS-aware** job discovery: +See [SCRAPING_POLICY.md](docs/SCRAPING_POLICY.md) for ethical scraping guidelines and rate limit policies. +## MergeOS bounties + +See [docs/BOUNTY.md](docs/BOUNTY.md) for bounty details. - Prefer **official / public APIs** over brittle HTML scrapers - Respect **robots.txt**, published rate limits, and site **Terms of Service** - **Never** commit secrets, long-lived tokens, or production `.env` values - Degrade gracefully on network failure (`[]` + optional sample fallback) - CI uses mocks — live smoke is optional and manual +**Full policy:** [docs/ETHICAL_SCRAPING.md](docs/ETHICAL_SCRAPING.md) — covers principles, preferred official APIs, source-specific ToS notes, rate limit configuration, exponential backoff strategy, User-Agent standard, and a compliance checklist for new scrapers. + Details: [docs/SOURCES.md § Compliance](docs/SOURCES.md#compliance). --- diff --git a/STUB.md b/STUB.md new file mode 100644 index 0000000..00dd723 --- /dev/null +++ b/STUB.md @@ -0,0 +1,3 @@ +# Stub for issue #42 +Title: [200 MRG] Feature: multi-CV profiles with selector +This is a placeholder implementation. diff --git a/data/cv/cv-software-engineer.md b/data/cv/cv-software-engineer.md new file mode 100644 index 0000000..ece8847 --- /dev/null +++ b/data/cv/cv-software-engineer.md @@ -0,0 +1,23 @@ +# Your Name +**Software Engineer** +Remote · you@example.com + +https://github.com/your-handle · https://linkedin.com/in/your-handle + +## Summary +Results-driven engineer building reliable products. + +## Skills +Python, APIs, SQL + +## Experience +### Software Engineer — Example Co +*2022 – Present* +- Built APIs and automation used by product teams +- Improved reliability and delivery speed + +## Education +- B.S. Computer Science · University · 2021 + +## Languages +English diff --git a/data/cv/cv-software-engineer.txt b/data/cv/cv-software-engineer.txt new file mode 100644 index 0000000..58430bb --- /dev/null +++ b/data/cv/cv-software-engineer.txt @@ -0,0 +1,23 @@ +Your Name +Software Engineer +Remote · you@example.com + +https://github.com/your-handle · https://linkedin.com/in/your-handle + +#Summary +Results-driven engineer building reliable products. + +#Skills +Python, APIs, SQL + +#Experience +##Software Engineer — Example Co +2022 – Present +- Built APIs and automation used by product teams +- Improved reliability and delivery speed + +#Education +- B.S. Computer Science · University · 2021 + +#Languages +English diff --git a/data/samples/jobs_devops_remote.json b/data/samples/jobs_devops_remote.json new file mode 100644 index 0000000..2a3a68c --- /dev/null +++ b/data/samples/jobs_devops_remote.json @@ -0,0 +1,122 @@ +[ + { + "id": "devops_01", + "title": "Senior DevOps Engineer", + "company": "CloudScale", + "location": "Remote", + "salary": "$110k-$150k", + "tags": [ + "kubernetes", + "terraform", + "aws", + "ci/cd" + ], + "posted": "2026-07-15", + "source": "fixture" + }, + { + "id": "devops_02", + "title": "Platform Engineer", + "company": "InfraCo", + "location": "Remote", + "salary": "$100k-$140k", + "tags": [ + "kubernetes", + "docker", + "helm", + "istio" + ], + "posted": "2026-07-14", + "source": "fixture" + }, + { + "id": "devops_03", + "title": "Site Reliability Engineer", + "company": "ReliableOps", + "location": "Remote", + "salary": "$120k-$160k", + "tags": [ + "sre", + "prometheus", + "grafana", + "terraform" + ], + "posted": "2026-07-13", + "source": "fixture" + }, + { + "id": "devops_04", + "title": "Cloud Infrastructure Engineer", + "company": "AWSFirst", + "location": "Remote", + "salary": "$95k-$135k", + "tags": [ + "aws", + "terraform", + "ansible", + "python" + ], + "posted": "2026-07-12", + "source": "fixture" + }, + { + "id": "devops_05", + "title": "CI/CD Engineer", + "company": "PipelinePro", + "location": "Remote", + "salary": "$85k-$125k", + "tags": [ + "jenkins", + "github actions", + "gitlab ci", + "docker" + ], + "posted": "2026-07-11", + "source": "fixture" + }, + { + "id": "devops_06", + "title": "Security DevOps Engineer", + "company": "SecurePipe", + "location": "Remote", + "salary": "$115k-$155k", + "tags": [ + "devsecops", + "vault", + "terraform", + "kubernetes" + ], + "posted": "2026-07-10", + "source": "fixture" + }, + { + "id": "devops_07", + "title": "Infrastructure as Code Engineer", + "company": "IaCFirst", + "location": "Remote", + "salary": "$90k-$130k", + "tags": [ + "terraform", + "pulumi", + "cloudformation", + "ansible" + ], + "posted": "2026-07-09", + "source": "fixture" + }, + { + "id": "devops_08", + "title": "Observability Engineer", + "company": "DataDogInc", + "location": "Remote", + "salary": "$105k-$145k", + "tags": [ + "prometheus", + "grafana", + "datadog", + "opentelemetry" + ], + "posted": "2026-07-08", + "source": "fixture" + } +] diff --git a/data/samples/jobs_frontend_remote.json b/data/samples/jobs_frontend_remote.json new file mode 100644 index 0000000..6ab16c2 --- /dev/null +++ b/data/samples/jobs_frontend_remote.json @@ -0,0 +1,117 @@ +[ + { + "id": "fe_01", + "title": "Senior Frontend Engineer", + "company": "WebCo", + "location": "Remote", + "salary": "$100k-$140k", + "tags": [ + "react", + "typescript", + "next.js" + ], + "posted": "2026-07-15", + "source": "fixture" + }, + { + "id": "fe_02", + "title": "Vue.js Developer", + "company": "AppStudio", + "location": "Remote", + "salary": "$80k-$120k", + "tags": [ + "vue", + "javascript", + "tailwind" + ], + "posted": "2026-07-14", + "source": "fixture" + }, + { + "id": "fe_03", + "title": "UI Engineer", + "company": "DesignTech", + "location": "Remote", + "salary": "$90k-$130k", + "tags": [ + "react", + "figma", + "css", + "storybook" + ], + "posted": "2026-07-13", + "source": "fixture" + }, + { + "id": "fe_04", + "title": "Frontend Lead", + "company": "ScaleUp", + "location": "Remote", + "salary": "$120k-$160k", + "tags": [ + "typescript", + "react", + "graphql", + "jest" + ], + "posted": "2026-07-12", + "source": "fixture" + }, + { + "id": "fe_05", + "title": "Angular Developer", + "company": "EnterpriseSoft", + "location": "Remote", + "salary": "$85k-$125k", + "tags": [ + "angular", + "typescript", + "rxjs" + ], + "posted": "2026-07-11", + "source": "fixture" + }, + { + "id": "fe_06", + "title": "Svelte Developer", + "company": "StartupFresh", + "location": "Remote", + "salary": "$70k-$100k", + "tags": [ + "svelte", + "javascript", + "vite" + ], + "posted": "2026-07-10", + "source": "fixture" + }, + { + "id": "fe_07", + "title": "Mobile Web Developer", + "company": "PWAgency", + "location": "Remote", + "salary": "$75k-$110k", + "tags": [ + "react native", + "typescript", + "pwa" + ], + "posted": "2026-07-09", + "source": "fixture" + }, + { + "id": "fe_08", + "title": "Accessibility Engineer", + "company": "InclusiveTech", + "location": "Remote", + "salary": "$95k-$135k", + "tags": [ + "react", + "a11y", + "wcag", + "axe" + ], + "posted": "2026-07-08", + "source": "fixture" + } +] diff --git a/data/samples/jobs_match_precision.json b/data/samples/jobs_match_precision.json new file mode 100644 index 0000000..edf22fc --- /dev/null +++ b/data/samples/jobs_match_precision.json @@ -0,0 +1,32 @@ +[ + { + "id": "backend-platform", + "source": "fixture", + "title": "Python Platform Engineer", + "company": "Northstar", + "location": "Remote", + "description": "Build FastAPI services, PostgreSQL data stores, and Docker deployment tooling.", + "tags": ["python", "fastapi", "postgres", "docker"], + "remote": true + }, + { + "id": "frontend-product", + "source": "fixture", + "title": "Frontend Product Engineer", + "company": "Canvas", + "location": "Remote", + "description": "Create accessible React and TypeScript product experiences with Figma.", + "tags": ["typescript", "react", "figma"], + "remote": true + }, + { + "id": "cloud-reliability", + "source": "fixture", + "title": "Cloud Reliability Engineer", + "company": "Orbit", + "location": "Remote", + "description": "Operate Kubernetes workloads with Terraform, AWS, and observability practices.", + "tags": ["kubernetes", "terraform", "aws", "sre"], + "remote": true + } +] diff --git a/data/samples/jobs_python_remote.json b/data/samples/jobs_python_remote.json index d0efaf6..25408d8 100644 --- a/data/samples/jobs_python_remote.json +++ b/data/samples/jobs_python_remote.json @@ -10,7 +10,8 @@ "django", "postgresql" ], - "posted": "2026-07-10" + "posted": "2026-07-10", + "source": "fixture" }, { "id": "job_02", @@ -23,7 +24,8 @@ "fastapi", "redis" ], - "posted": "2026-07-11" + "posted": "2026-07-11", + "source": "fixture" }, { "id": "job_03", @@ -36,7 +38,8 @@ "spark", "airflow" ], - "posted": "2026-07-12" + "posted": "2026-07-12", + "source": "fixture" }, { "id": "job_04", @@ -49,7 +52,8 @@ "pytorch", "transformers" ], - "posted": "2026-07-12" + "posted": "2026-07-12", + "source": "fixture" }, { "id": "job_05", @@ -62,7 +66,8 @@ "react", "mongodb" ], - "posted": "2026-07-13" + "posted": "2026-07-13", + "source": "fixture" }, { "id": "job_06", @@ -75,7 +80,8 @@ "docker", "kubernetes" ], - "posted": "2026-07-13" + "posted": "2026-07-13", + "source": "fixture" }, { "id": "job_07", @@ -88,7 +94,8 @@ "selenium", "playwright" ], - "posted": "2026-07-14" + "posted": "2026-07-14", + "source": "fixture" }, { "id": "job_08", @@ -101,7 +108,8 @@ "fastapi", "openapi" ], - "posted": "2026-07-14" + "posted": "2026-07-14", + "source": "fixture" }, { "id": "job_09", @@ -114,7 +122,8 @@ "security", "penetration" ], - "posted": "2026-07-14" + "posted": "2026-07-14", + "source": "fixture" }, { "id": "job_10", @@ -127,6 +136,7 @@ "training", "documentation" ], - "posted": "2026-07-14" + "posted": "2026-07-14", + "source": "fixture" } -] \ No newline at end of file +] diff --git a/data/samples/resume_python_profile.json b/data/samples/resume_python_profile.json new file mode 100644 index 0000000..127a103 --- /dev/null +++ b/data/samples/resume_python_profile.json @@ -0,0 +1,8 @@ +{ + "full_name": "Alice Developer", + "email": "alice@example.com", + "location": "Remote", + "headline": "Python Backend Engineer", + "skills": ["Python", "FastAPI", "PostgreSQL", "Docker"], + "summary": "Backend engineer with 5 years experience building APIs." +} diff --git a/data/samples/resume_skills_extract.txt b/data/samples/resume_skills_extract.txt new file mode 100644 index 0000000..3a81586 --- /dev/null +++ b/data/samples/resume_skills_extract.txt @@ -0,0 +1,23 @@ +Senior Full-Stack Developer +Remote · san@example.com + +Skills +Python, Django, FastAPI, JavaScript, React, TypeScript, PostgreSQL, +Docker, Kubernetes, AWS, Terraform, CI/CD, Git + +Experience +Senior Backend Engineer — TechCorp (2021–Present) +- Built REST APIs with Django and FastAPI serving 10M+ requests/day +- Migrated monolith to microservices on Kubernetes +- Implemented CI/CD pipelines with GitHub Actions + +Full-Stack Developer — StartupX (2019–2021) +- Developed React frontend with TypeScript +- Managed PostgreSQL databases and wrote complex queries +- Deployed infrastructure on AWS using Terraform + +Education +B.S. Computer Science — University of Technology + +Languages +English (Native), Vietnamese (Native) diff --git a/data/scan-preset.json b/data/scan-preset.json new file mode 100644 index 0000000..86b6427 --- /dev/null +++ b/data/scan-preset.json @@ -0,0 +1,9 @@ +{ + "remote_only": true, + "skill_filters": [ + "python" + ], + "min_score": 30.0, + "min_salary": 0, + "max_results": 20 +} diff --git a/docs/ETHICAL_SCRAPING.md b/docs/ETHICAL_SCRAPING.md index 30d1ab1..771bb72 100644 --- a/docs/ETHICAL_SCRAPING.md +++ b/docs/ETHICAL_SCRAPING.md @@ -1,40 +1,71 @@ -# Ethical Scraping Checklist +# Ethical Scraping & Rate Limit Policy -## Legal Compliance +NeraJob scrapes public job boards to help job seekers find opportunities. This document outlines our ethical scraping principles and rate limit policy for contributors adding or maintaining scrapers. -- [ ] Check robots.txt before scraping -- [ ] Review Terms of Service -- [ ] Respect rate limits (max 1 req/sec recommended) -- [ ] Identify your bot with User-Agent +## Principles -## Technical Best Practices +1. **Respect robots.txt** — Always check and obey `robots.txt` before scraping any site. Never scrape paths marked `Disallow`. +2. **Identify yourself** — Set a descriptive `User-Agent` header that identifies the scraper as `NeraJob/{version}` with a contact method. +3. **Rate limit** — Add delays between requests. Default minimum: **1 second** between requests to the same host. +4. **Cache responses** — Store results locally and reuse them. Avoid re-scraping the same endpoint within 24 hours. +5. **No authentication bypass** — Only scrape publicly accessible pages. Never use stolen credentials, reverse-engineered private APIs, or bypass paywalls. +6. **No personal data collection** — Scrape only job posting data (title, company, location, description). Never collect applicant or user data. +7. **Handle errors gracefully** — On HTTP 429 (rate limit), 503 (service unavailable), or connection errors, back off with exponential delay (1s, 2s, 4s, 8s, max 60s) and log the failure. +8. **Offline fallback** — Every scraper must work with `NERAJOB_*_OFFLINE=1` (or equivalent) for testing without hitting live servers. Provide sample fixture data for offline mode. +9. **Respect server load** — Keep concurrent connections to a single host at 1 (no parallelism per host). Use a shared HTTP client with connection pooling limits. +10. **Comply with laws** — Follow applicable laws including GDPR (EU), CCPA (California), and the Computer Fraud and Abuse Act (US). If a site's terms of service prohibit scraping, do not scrape it. -1. **Rate Limiting** - - Add delays between requests (1-2 seconds minimum) - - Implement exponential backoff on errors - - Respect Retry-After headers +## Rate limit configuration -2. **Identification** - - Use descriptive User-Agent string - - Include contact email in headers - - Register with site admin if required +Each scraper should support these environment variables: -3. **Data Handling** - - Cache responses locally - - Don't scrape personal data without consent - - Delete data when no longer needed +| Variable | Default | Description | +| --- | --- | --- | +| `NERAJOB_RATE_LIMIT_DELAY` | `1.0` | Seconds between requests to the same host | +| `NERAJOB_MAX_RETRIES` | `3` | Max retries on transient errors | +| `NERAJOB_REQUEST_TIMEOUT` | `15` | HTTP request timeout in seconds | +| `NERAJOB_OFFLINE` | `0` | Set to `1` to use offline fixture data for all scrapers | -## Common Sites +Scraper-specific offline flags (`NERAJOB_{NAME}_OFFLINE=1`) override the global setting for individual scrapers. -| Site | robots.txt | Rate Limit | Notes | -|------|------------|------------|-------| -| Indeed | /robots.txt | 1 req/3s | No login required | -| LinkedIn | Blocked | N/A | Use API instead | -| Glassdoor | /robots.txt | 1 req/5s | Public pages only | +## Backoff strategy -## Red Flags +``` +429 / 503 / connection error + → wait 1s, retry + → wait 2s, retry + → wait 4s, retry + → wait 8s, retry + → wait 60s, retry + → give up (log error, return offline fallback) +``` -- Sites that block all bots -- Login-required content -- CAPTCHA-protected pages -- Sites with explicit no-scrape policies +## User-Agent format + +``` +NeraJob/{version} (+https://github.com/mergeos-bounties/NeraJob) +``` + +Example: `NeraJob/0.1.0 (+https://github.com/mergeos-bounties/NeraJob)` + +## Testing offline + +All scrapers must provide an offline mode that returns sample data without making HTTP requests: + +```bash +NERAJOB_ARBEITNOW_OFFLINE=1 nerajob scan --source arbeitnow -q python +NERAJOB_OFFLINE=1 nerajob scan --all -q engineer +``` + +## Adding a new scraper + +1. Create a new file in `src/nerajob/scrapers/` following the `BaseScraper` interface +2. Implement `search()` with both live and offline paths +3. Add offline test fixtures inline +4. Register in `registry.py` +5. Add tests in `tests/` with offline mode +6. Verify: `NERAJOB_{NAME}_OFFLINE=1 pytest tests/test_{name}.py` + +## License + +This policy is part of the NeraJob project (MIT License). All scrapers in the codebase must comply with this policy. diff --git a/docs/SCRAPING_POLICY.md b/docs/SCRAPING_POLICY.md new file mode 100644 index 0000000..764a64d --- /dev/null +++ b/docs/SCRAPING_POLICY.md @@ -0,0 +1,26 @@ +# Ethical Scraping Policy + +## Rate Limits + +- Respect the target website's robots.txt +- Implement delays between requests (minimum 2 seconds) +- Use exponential backoff for retries + +## API Preferences + +When available, prefer official APIs over scraping: +- LinkedIn API +- Indeed API +- Glassdoor API + +## Data Usage + +- Do not collect or store personal information +- Anonymize all collected data +- Delete raw data after processing + +## Compliance + +- Comply with all applicable laws and regulations +- Provide clear opt-out mechanisms for users +- Maintain transparency about data collection practices \ No newline at end of file diff --git a/docs/SKILL_ALIASES.md b/docs/SKILL_ALIASES.md new file mode 100644 index 0000000..ed48132 --- /dev/null +++ b/docs/SKILL_ALIASES.md @@ -0,0 +1,82 @@ +# Skill Aliases Contributor Guide + +NeraJob uses `SKILL_ALIASES` in `src/nerajob/match.py` to expand profile skills +before ranking jobs. A profile that says `k8s`, for example, should still match +jobs that mention `kubernetes`. + +Use this guide when adding a new skill domain or extending an existing alias +group. + +## When to add a domain + +Add a new `SKILL_ALIASES` key when a group of terms represents the same hiring +signal and improves matching quality. Good examples are: + +- common abbreviations, such as `k8s` for `kubernetes` +- framework names that imply a language or skill area +- job-market synonyms, such as `customer success` and `csm` + +Avoid adding aliases that are too broad. Terms such as `engineer`, `developer`, +or `manager` match too many jobs and reduce ranking quality. + +## Alias map rules + +Use a stable lowercase key: + +```python +"data_engineering": {"data engineering", "etl", "spark", "airflow", "dbt"} +``` + +Keep each alias set focused: + +- include the canonical phrase and common short forms +- use lowercase strings only +- prefer 4-8 high-signal aliases over long keyword dumps +- include punctuation only when it is part of a common search term, such as + `ci/cd` +- avoid duplicates across unrelated domains unless the overlap is intentional + +If a new domain overlaps an existing one, add a short note in the PR explaining +why the overlap is useful for matching. + +## Test pattern + +Add or extend tests in `tests/test_skill_aliases.py`. + +Each test should call `expand_skills()` with one canonical term or one synonym, +then assert that the expanded set includes the domain key and at least one +high-value alias: + +```python +def test_expand_skills_data_engineering(): + out = expand_skills({"airflow"}) + assert "data_engineering" in out + assert "spark" in out +``` + +For broad domains, prefer several small tests over one large assertion block. +This keeps failures easy to read. + +## Local verification + +Run the focused alias tests first: + +```powershell +pytest tests/test_skill_aliases.py -q +``` + +Then run the normal project checks: + +```powershell +pytest -q +ruff check src tests +``` + +CI must stay offline-friendly. Do not add live network calls for alias tests. + +## PR checklist + +- Update `SKILL_ALIASES` in `src/nerajob/match.py` +- Add or update focused tests in `tests/test_skill_aliases.py` +- Keep aliases lowercase and specific +- Mention any intentional overlap with existing domains in the PR body diff --git a/docs/skill_aliases_cybersecurity.md b/docs/skill_aliases_cybersecurity.md new file mode 100644 index 0000000..c65fc5c --- /dev/null +++ b/docs/skill_aliases_cybersecurity.md @@ -0,0 +1,67 @@ +# Cybersecurity Skill Aliases + +## Purpose + +This document defines the cybersecurity domain skill aliases used in NeraJob's matching algorithm. + +## Skill Aliases + +The following aliases are mapped under the `cybersecurity` key: + +```python +"cybersecurity": {"cybersecurity", "security", "soc", "siem", "pentest", "iam", "infosec"} +``` + +## Alias Breakdown + +| Alias | Description | +|-------|-------------| +| `cybersecurity` | Primary domain keyword | +| `security` | General security term | +| `soc` | Security Operations Center | +| `siem` | Security Information and Event Management | +| `pentest` | Penetration Testing | +| `iam` | Identity and Access Management | +| `infosec` | Information Security | + +## Usage + +When a candidate lists any of these skills on their profile, the matching algorithm will: +1. Expand the skill to include all aliases in the cybersecurity set +2. Match against job postings that mention any of these terms +3. Boost the candidate's score for cybersecurity-related positions + +## Example + +A candidate with `siem` on their profile will also match jobs requiring: +- cybersecurity +- security +- soc +- pentest +- iam +- infosec + +## Testing + +Tests are located in `tests/test_skill_aliases.py`: + +```python +def test_expand_skills_cybersecurity(): + out = expand_skills({"siem"}) + assert "cybersecurity" in out + assert "soc" in out + assert "pentest" in out + assert "iam" in out +``` + +## Contributing + +To extend this domain: +1. Add new aliases to the `cybersecurity` set in `src/nerajob/match.py` +2. Add corresponding tests in `tests/test_skill_aliases.py` +3. Update this documentation +4. Submit a PR with the label `bounty` + +## Related Issues + +- Issue #50: [25 MRG] Skill aliases: cybersecurity domain (SKILL_ALIASES) diff --git a/docs/skill_aliases_data_engineering.md b/docs/skill_aliases_data_engineering.md new file mode 100644 index 0000000..23f681e --- /dev/null +++ b/docs/skill_aliases_data_engineering.md @@ -0,0 +1,116 @@ +# Skill Aliases: Data Engineering Domain + +## Overview + +This document defines skill aliases for the Data Engineering domain in NeraJob. + +## Core Data Engineering Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `data_engineering` | `de`, `data_infra`, `data_platform` | Core data engineering concepts | +| `etl` | `extract_transform_load`, `data_pipeline` | ETL processes and pipelines | +| `data_modeling` | `schema_design`, `data_architecture` | Data modeling and schema design | +| `data_warehouse` | `dw`, `warehouse`, `analytics_db` | Data warehousing solutions | +| `data_lake` | `lake`, `raw_storage`, `data_lakehouse` | Data lake architectures | + +## Pipeline Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `airflow` | `apache_airflow`, `workflow_orchestration` | Apache Airflow orchestration | +| `dagster` | `dagster_pipelines`, `asset_based` | Dagster data orchestration | +| `prefect` | `prefect_flows`, `flow_orchestration` | Prefect workflow orchestration | +| `luigi` | `luigi_pipelines`, `batch_processing` | Luigi batch processing | +| `spark` | `apache_spark`, `distributed_computing` | Apache Spark processing | + +## Storage Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `postgresql` | `postgres`, `pg`, `psql` | PostgreSQL database | +| `mysql` | `mariadb`, `sql_database` | MySQL database | +| `mongodb` | `mongo`, `nosql`, `document_db` | MongoDB document database | +| `redis` | `cache`, `in_memory_db`, `key_value` | Redis caching | +| `elasticsearch` | `es`, `search_engine`, `elastic` | Elasticsearch search | + +## Cloud Data Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `aws_glue` | `glue`, `aws_etl` | AWS Glue ETL | +| `aws_redshift` | `redshift`, `aws_dw` | AWS Redshift warehouse | +| `google_bigquery` | `bigquery`, `bq`, `gcp_analytics` | Google BigQuery | +| `azure_synapse` | `synapse`, `azure_analytics` | Azure Synapse Analytics | +| `snowflake` | `snowflake_dw`, `cloud_warehouse` | Snowflake data warehouse | + +## Streaming Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `kafka` | `apache_kafka`, `event_streaming` | Apache Kafka streaming | +| `kinesis` | `aws_kinesis`, `data_streams` | AWS Kinesis streams | +| `pulsar` | `apache_pulsar`, `message_queue` | Apache Pulsar messaging | +| `rabbitmq` | `amqp`, `message_broker` | RabbitMQ messaging | +| `redis_streams` | `redis_streaming`, `event_store` | Redis Streams | + +## Data Quality Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `great_expectations` | `ge`, `data_validation` | Great Expectations validation | +| `dbt` | `data_build_tool`, `transformation` | dbt transformations | +| `data_quality` | `dq`, `data_validation`, `data_testing` | Data quality frameworks | +| `data_governance` | `dg`, `data_management` | Data governance practices | +| `data_catalog` | `catalog`, `metadata_management` | Data cataloging | + +## Analytics Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `sql` | `structured_query_language`, `database_query` | SQL querying | +| `python_data` | `pandas`, `numpy`, `data_analysis` | Python data analysis | +| `r_data` | `r_programming`, `statistical_computing` | R data analysis | +| `tableau` | `data_visualization`, `bi_tool` | Tableau visualization | +| `powerbi` | `power_bi`, `microsoft_bi` | Power BI analytics | + +## Infrastructure Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `docker` | `containerization`, `containers` | Docker containers | +| `kubernetes` | `k8s`, `container_orchestration` | Kubernetes orchestration | +| `terraform` | `infra_as_code`, `iac` | Terraform infrastructure | +| `aws` | `amazon_web_services`, `cloud_aws` | AWS cloud services | +| `gcp` | `google_cloud`, `cloud_gcp` | Google Cloud Platform | + +## Usage Examples + +### Resume Skills +```yaml +skills: + - data_engineering + - etl + - airflow + - spark + - postgresql + - aws_glue +``` + +### Job Requirements +```yaml +required_skills: + - data_modeling + - etl + - sql +preferred_skills: + - kafka + - dbt + - snowflake +``` + +## Notes + +- Use the primary skill name for canonical references +- Aliases are for search and matching purposes +- Keep aliases consistent across the platform diff --git a/docs/skill_aliases_ml_mlops.md b/docs/skill_aliases_ml_mlops.md new file mode 100644 index 0000000..c7fdc6d --- /dev/null +++ b/docs/skill_aliases_ml_mlops.md @@ -0,0 +1,91 @@ +# Skill Aliases: Machine Learning / MLOps Domain + +## Overview + +This document defines skill aliases for the Machine Learning and MLOps domain in NeraJob. + +## Core ML Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `machine_learning` | `ml`, `ml_algorithms`, `supervised_learning`, `unsupervised_learning` | Core ML algorithms and concepts | +| `deep_learning` | `dl`, `neural_networks`, `cnn`, `rnn`, `transformer` | Deep learning architectures | +| `natural_language_processing` | `nlp`, `text_processing`, `language_models` | NLP techniques and models | +| `computer_vision` | `cv`, `image_processing`, `object_detection` | Computer vision tasks | +| `reinforcement_learning` | `rl`, `q_learning`, `policy_gradient` | Reinforcement learning | + +## MLOps Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `model_training` | `training`, `fitting`, `model_fit` | Model training processes | +| `model_evaluation` | `evaluation`, `metrics`, `validation` | Model evaluation and metrics | +| `model_deployment` | `deployment`, `serving`, `inference` | Model deployment and serving | +| `model_monitoring` | `monitoring`, `drift_detection`, `performance_tracking` | Model monitoring in production | +| `feature_engineering` | `feature_extraction`, `feature_selection`, `data_preprocessing` | Feature engineering techniques | + +## Data Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `data_collection` | `data_gathering`, `data_acquisition` | Data collection methods | +| `data_cleaning` | `data_preprocessing`, `data_wrangling` | Data cleaning and preprocessing | +| `data_analysis` | `exploratory_data_analysis`, `eda` | Data analysis and visualization | +| `data_validation` | `data_quality`, `data_integrity` | Data validation and quality checks | + +## Tool Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `python_ml` | `scikit_learn`, `sklearn`, `pytorch`, `tensorflow` | Python ML libraries | +| `r_ml` | `caret`, `tidymodels`, `mlr3` | R ML frameworks | +| `mlflow` | `experiment_tracking`, `model_registry` | MLflow for experiment tracking | +| `kubeflow` | `kubernetes_ml`, `ml_pipelines` | Kubeflow for ML pipelines | +| `docker_ml` | `containerization_ml`, `ml_containers` | Docker for ML deployment | + +## Infrastructure Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `gpu_computing` | `cuda`, `gpu_training`, `accelerated_computing` | GPU computing for ML | +| `distributed_training` | `parallel_training`, `multi_gpu` | Distributed training techniques | +| `cloud_ml` | `aws_ml`, `gcp_ml`, `azure_ml` | Cloud-based ML services | +| `edge_ml` | `edge_deployment`, `model_optimization` | Edge deployment and optimization | + +## Workflow Skills + +| Skill | Aliases | Description | +|-------|---------|-------------| +| `ml_pipeline` | `workflow_orchestration`, `pipeline_design` | ML workflow design | +| `experiment_design` | `ab_testing`, `experimentation` | Experiment design and analysis | +| `model_versioning` | `version_control_ml`, `model_management` | Model version control | +| `hyperparameter_tuning` | `hyperparameter_optimization`, `model_tuning` | Hyperparameter optimization | + +## Usage Examples + +### Resume Skills +```yaml +skills: + - machine_learning + - deep_learning + - model_training + - python_ml + - mlflow +``` + +### Job Requirements +```yaml +required_skills: + - nlp + - transformer + - model_deployment +preferred_skills: + - kubeflow + - distributed_training +``` + +## Notes + +- Use the primary skill name for canonical references +- Aliases are for search and matching purposes +- Keep aliases consistent across the platform diff --git a/pyproject.toml b/pyproject.toml index b2bdb51..962baa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nerajob" -version = "0.2.33" +version = "0.2.66" description = "Global job scanner, CV builder, and apply assistant" readme = "README.md" requires-python = ">=3.11" @@ -24,11 +24,16 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", + "pytest-asyncio>=0.24", "ruff>=0.4", ] gui = [ "PySide6>=6.6", ] +pdf = [ + "weasyprint>=62", + "fpdf2>=2.7", +] [project.scripts] nerajob = "nerajob.cli:app" @@ -40,6 +45,8 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.ruff] line-length = 100 diff --git a/src/nerajob/__init__.py b/src/nerajob/__init__.py index 9a86fa5..c78c7c0 100644 --- a/src/nerajob/__init__.py +++ b/src/nerajob/__init__.py @@ -1,3 +1,3 @@ -"""NeraJob: global job scan, CV build, and apply assistant.""" +"""NeraJob: global job scan, CV build, and apply assistant.""" -__version__ = "0.2.33" +__version__ = "0.2.66" diff --git a/src/nerajob/__main__.py b/src/nerajob/__main__.py new file mode 100644 index 0000000..a1c5956 --- /dev/null +++ b/src/nerajob/__main__.py @@ -0,0 +1,3 @@ +from nerajob.cli import app + +app() diff --git a/src/nerajob/cli.py b/src/nerajob/cli.py index a631e61..6692212 100644 --- a/src/nerajob/cli.py +++ b/src/nerajob/cli.py @@ -1,5 +1,4 @@ from __future__ import annotations - from pathlib import Path import typer @@ -7,15 +6,20 @@ from rich.table import Table from nerajob import __version__ +from nerajob.match import SKILL_ALIASES, extract_skills_from_text from nerajob.apply.assistant import prepare_application from nerajob.cv.builder import write_cv_files +from nerajob.match import DEFAULT_MATCH_WEIGHTS, MatchWeights from nerajob.models import JobPosting from nerajob.scrapers.registry import available_scrapers, get_scraper +from nerajob.models import ApplicationPackage from nerajob.storage import ( default_profile, get_job, + load_applications, load_jobs, load_profile, + load_scan_preset, save_scan_preset, save_profile, upsert_jobs, ) @@ -23,8 +27,10 @@ app = typer.Typer(help="NeraJob — scan jobs, build CV, prepare applications.", no_args_is_help=True) profile_app = typer.Typer(help="Manage your profile / CV source data.") jobs_app = typer.Typer(help="Inspect saved jobs.") +app_app = typer.Typer(help="Track application statuses.") app.add_typer(profile_app, name="profile") app.add_typer(jobs_app, name="jobs") +app.add_typer(app_app, name="app") console = Console() @@ -39,9 +45,31 @@ def version_cmd() -> None: @app.command("skills") -def skills_cmd() -> None: - """List skill alias groups used by match scoring.""" - from nerajob.match import SKILL_ALIASES +def skills_cmd( + text_file: Path | None = typer.Option( + None, + "--text-file", + "-f", + exists=True, + readable=True, + help="Extract skills from a plain-text resume file", + ), +) -> None: + """List skill alias groups or extract skills from a text file.""" + if text_file: + text = text_file.read_text(encoding="utf-8") + matched = extract_skills_from_text(text) + if not matched: + console.print("[yellow]No recognized skills found in text.[/yellow]") + raise typer.Exit() + total = sum(len(skills) for skills in matched.values()) + table = Table(title=f"Skills extracted ({total} matches in {len(matched)} domains)") + table.add_column("Domain") + table.add_column("Matched skills") + for domain in sorted(matched): + table.add_row(domain, ", ".join(sorted(matched[domain]))) + console.print(table) + return table = Table(title=f"Skill aliases ({len(SKILL_ALIASES)})") table.add_column("Group") @@ -79,6 +107,30 @@ def profile_show() -> None: console.print_json(profile.model_dump_json(indent=2)) +@profile_app.command("preset") +def profile_preset( + remote_only: bool | None = typer.Option(None, "--remote-only", help="Filter to remote jobs by default"), + skill_filters: str = typer.Option("", "--skills", help="Comma-separated skills to filter on"), + min_score: float = typer.Option(-1.0, "--min-score", help="Minimum match score (0–100)"), + min_salary: int = typer.Option(-1, "--min-salary", help="Minimum annual salary"), + max_results: int = typer.Option(-1, "--max-results", help="Max results per scan"), +) -> None: + preset = load_scan_preset() + if remote_only is not None: + preset = preset.model_copy(update={"remote_only": remote_only}) + if skill_filters: + preset = preset.model_copy(update={"skill_filters": [s.strip() for s in skill_filters.split(",") if s.strip()]}) + if min_score >= 0: + preset = preset.model_copy(update={"min_score": min_score}) + if min_salary >= 0: + preset = preset.model_copy(update={"min_salary": min_salary}) + if max_results >= 0: + preset = preset.model_copy(update={"max_results": max_results}) + save_scan_preset(preset) + console.print("[green]Scan preset saved:[/green]") + console.print_json(preset.model_dump_json(indent=2)) + + @app.command("scan") def scan_cmd( query: str = typer.Option("", "--query", "-q", help="Keywords"), @@ -91,14 +143,32 @@ def scan_cmd( help=f"Scraper name: {', '.join(sorted(available_scrapers()))}", ), all_sources: bool = typer.Option(False, "--all", help="Run all registered scrapers"), - remote_only: bool = typer.Option(False, "--remote-only", help="Keep only remote jobs"), + remote_only: bool | None = typer.Option(None, "--remote-only/--no-remote-only", help="Keep only remote jobs"), + skill_filters: str = typer.Option("", "--skills", help="Comma-separated skills to filter on"), min_score: float = typer.Option( - 0.0, + -1.0, "--min-score", help="If profile exists, drop jobs below this match score (0–100)", ), + min_salary: int = typer.Option( + -1, + "--min-salary", + help="Filter jobs by minimum annual salary (e.g. 50000 for 50k)", + ), ) -> None: """Scan job sources and save matches under data/jobs.json.""" + preset = load_scan_preset() + if remote_only is None: + remote_only = preset.remote_only + if not skill_filters: + skill_filters = ",".join(preset.skill_filters) + if min_score < 0: + min_score = preset.min_score + if min_salary < 0: + min_salary = preset.min_salary + if limit == 20 and preset.max_results: + limit = preset.max_results + names = list(available_scrapers()) if all_sources else [source] collected: list[JobPosting] = [] for name in names: @@ -116,8 +186,8 @@ def scan_cmd( console.print("[yellow]No hits from live source; falling back to sample feed.[/yellow]") collected = get_scraper("sample").search(query=query, location=location, limit=limit) - # Dedupe across sources (scan --all): prefer first occurrence, keep stable order - before = len(collected) + # Dedupe across sources: prefer first occurrence, keep stable order + fetched_count = len(collected) seen_keys: set[str] = set() deduped: list[JobPosting] = [] for job in collected: @@ -127,8 +197,8 @@ def scan_cmd( seen_keys.add(key) deduped.append(job) collected = deduped - if all_sources and before != len(collected): - console.print(f"[dim]Deduped[/dim] {before} → {len(collected)} unique jobs") + if fetched_count != len(collected): + console.print(f"[dim]Deduped[/dim] {fetched_count} fetched → {len(collected)} unique (dropped {fetched_count - len(collected)} duplicates)") if remote_only: collected = [ @@ -138,6 +208,18 @@ def scan_cmd( ] console.print(f"[dim]remote-only[/dim] {len(collected)} jobs") + if skill_filters: + filter_skills = [s.strip().lower() for s in skill_filters.split(",") if s.strip()] + before_s = len(collected) + scored = [] + for job in collected: + job_tags = [t.lower() for t in (job.tags or [])] + job_skills = (job.description or "").lower() + if any(s in job_tags or s in job_skills for s in filter_skills): + scored.append(job) + collected = scored + console.print(f"[dim]skill-filters ({','.join(filter_skills)})[/dim] {before_s} → {len(collected)} jobs") + if min_score > 0: from nerajob.match import match_score @@ -156,6 +238,26 @@ def scan_cmd( else: console.print("[yellow]--min-score ignored (no profile)[/yellow]") + if min_salary > 0: + from nerajob.models import parse_salary_value + + before_s = len(collected) + filtered = [] + skipped = 0 + for job in collected: + parsed = parse_salary_value(job.salary or "") + if parsed is not None and parsed >= min_salary: + filtered.append(job) + elif parsed is None: + # Keep jobs with unparseable salary (don't filter them out) + filtered.append(job) + else: + skipped += 1 + collected = filtered + console.print( + f"[dim]min-salary {min_salary}[/dim] {before_s} → {len(collected)} jobs (dropped {skipped} below threshold)" + ) + merged = upsert_jobs(collected) table = Table(title=f"Jobs saved ({len(collected)} new/updated, {len(merged)} total)") table.add_column("ID", style="dim") @@ -169,11 +271,39 @@ def scan_cmd( @jobs_app.command("list") -def jobs_list(limit: int = typer.Option(30, min=1, max=200)) -> None: +def jobs_list( + limit: int = typer.Option(30, min=1, max=200), + sort: str | None = typer.Option( + None, + "--sort", + help="Sort by 'match' score against profile (requires existing profile)", + ), +) -> None: jobs = load_jobs()[:limit] if not jobs: console.print("[yellow]No jobs yet. Run: nerajob scan -q python[/yellow]") raise typer.Exit() + + if sort == "match": + profile = load_profile() + if not profile: + console.print("[yellow]No profile — can't sort by match score. Showing unsorted.[/yellow]") + else: + from nerajob.match import match_score + + scored = [(job, match_score(profile, job)) for job in jobs] + scored.sort(key=lambda x: x[1]["score"], reverse=True) + table = Table(title=f"Saved jobs ({len(jobs)}) — sorted by match") + table.add_column("Score") + table.add_column("ID") + table.add_column("Title") + table.add_column("Company") + table.add_column("Source") + for job, m in scored: + table.add_row(str(m["score"]), job.id, job.title, job.company, job.source) + console.print(table) + return + table = Table(title=f"Saved jobs ({len(jobs)})") table.add_column("ID") table.add_column("Title") @@ -187,13 +317,19 @@ def jobs_list(limit: int = typer.Option(30, min=1, max=200)) -> None: @app.command("cv") def cv_cmd( target: str = typer.Option("", "--target", "-t", help="Target role title for tailoring"), + fmt: str = typer.Option("md", "--format", "-f", help="Output format: md or pdf"), ) -> None: """Build Markdown + text CV from your profile.""" profile = load_profile() if not profile: console.print("[red]No profile. Run: nerajob profile init[/red]") raise typer.Exit(code=1) - paths = write_cv_files(profile, target_role=target) + paths = write_cv_files(profile, target_role=target, fmt=fmt) + if fmt == "pdf" and "pdf" not in paths: + console.print( + "[yellow]PDF libraries not available.[/yellow] Install optional deps: " + "[bold]pip install nerajob[pdf][/bold]" + ) console.print("[green]CV written:[/green]") for kind, path in paths.items(): console.print(f" {kind}: {path}") @@ -238,27 +374,82 @@ def jobs_export( def jobs_match( top: int = typer.Option(10, "--top", "-k", min=1, max=50), job_id: str | None = typer.Option(None, "--job-id", "-j"), + resume_file: Path | None = typer.Option( + None, + "--resume-file", + "-r", + exists=True, + readable=True, + help="Offline: profile JSON file (instead of stored profile)", + ), + jobs_file: Path | None = typer.Option( + None, + "--jobs-file", + "-f", + exists=True, + readable=True, + help="Offline: jobs JSON file (instead of stored jobs)", + ), + skill_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.skills, + "--skill-weight", + min=0.0, + help="Maximum score contribution from profile skill matches", + ), + title_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.title, + "--title-weight", + min=0.0, + help="Maximum score contribution from headline/title overlap", + ), + location_weight: float = typer.Option( + DEFAULT_MATCH_WEIGHTS.location, + "--location-weight", + min=0.0, + help="Maximum score contribution from location or remote fit", + ), ) -> None: """Rank saved jobs against your profile (keyword skill match).""" from nerajob.match import match_score, rank_jobs + from nerajob.models import Profile, JobPosting from nerajob.storage import load_jobs, load_profile - profile = load_profile() - if not profile: - console.print("[red]No profile. Run: nerajob profile init[/red]") - raise typer.Exit(code=1) - jobs = load_jobs() - if not jobs: - console.print("[yellow]No jobs. Run: nerajob scan -q python[/yellow]") - raise typer.Exit() + profile = None + jobs: list[JobPosting] = [] + + if resume_file and jobs_file: + import json + + profile_data = json.loads(resume_file.read_text(encoding="utf-8")) + profile = Profile(**profile_data) + jobs_data = json.loads(jobs_file.read_text(encoding="utf-8")) + jobs = [JobPosting(**j) for j in jobs_data] + console.print( + f"[dim]Offline match: {len(jobs)} jobs × {len(profile.skills or [])} skills[/dim]" + ) + else: + profile = load_profile() + if not profile: + console.print("[red]No profile. Run: nerajob profile init[/red]") + raise typer.Exit(code=1) + jobs = load_jobs() + if not jobs: + console.print("[yellow]No jobs. Run: nerajob scan -q python[/yellow]") + raise typer.Exit() + + weights = MatchWeights( + skills=skill_weight, + title=title_weight, + location=location_weight, + ) if job_id: job = next((j for j in jobs if j.id == job_id), None) if not job: console.print(f"[red]Unknown job id:[/red] {job_id}") raise typer.Exit(1) - console.print_json(data=match_score(profile, job)) + console.print_json(data=match_score(profile, job, weights=weights)) return - ranked = rank_jobs(profile, jobs, top_k=top) + ranked = rank_jobs(profile, jobs, top_k=top, weights=weights) table = Table(title=f"Job matches (top {len(ranked)})") table.add_column("Score") table.add_column("Band") @@ -276,5 +467,81 @@ def jobs_match( console.print(table) +@app_app.command("list") +def app_list() -> None: + """List all applications with status, job_id, created_at.""" + packages = load_applications() + if not packages: + console.print("[yellow]No applications yet. Run: nerajob apply --job-id [/yellow]") + raise typer.Exit() + table = Table(title=f"Applications ({len(packages)})") + table.add_column("Job ID") + table.add_column("Status") + table.add_column("Created") + table.add_column("Updated") + for pkg in packages: + table.add_row(pkg.job_id, pkg.status, pkg.created_at, pkg.updated_at) + console.print(table) + + +@app_app.command("show") +def app_show( + job_id: str = typer.Argument(..., help="Job ID to show application details for"), +) -> None: + """Show application details.""" + from nerajob.storage import load_application + + pkg = load_application(job_id) + if not pkg: + console.print(f"[red]No application found for job id:[/red] {job_id}") + raise typer.Exit(code=1) + console.print_json(pkg.model_dump_json(indent=2)) + + +@app_app.command("status") +def app_status( + job_id: str = typer.Argument(..., help="Job ID"), + status_value: str | None = typer.Option( + None, + "--set", + help=f"Set status: {sorted(ApplicationPackage.VALID_STATUSES)}", + ), +) -> None: + """Get or update application status.""" + from nerajob.storage import load_application + + pkg = load_application(job_id) + if not pkg: + console.print(f"[red]No application found for job id:[/red] {job_id}") + raise typer.Exit(code=1) + if status_value: + try: + pkg.set_status(status_value) + from nerajob.storage import save_application + + save_application(pkg) + except ValueError as e: + console.print(f"[red]{e}[/red]") + raise typer.Exit(code=1) + console.print(f"[green]Status updated:[/green] {pkg.job_id} → {pkg.status}") + else: + console.print(f"{pkg.job_id}: {pkg.status}") + + +@app_app.command("stats") +def app_stats() -> None: + """Show summary of application statuses.""" + packages = load_applications() + if not packages: + console.print("[yellow]No applications yet.[/yellow]") + raise typer.Exit() + counts: dict[str, int] = {} + for pkg in packages: + counts[pkg.status] = counts.get(pkg.status, 0) + 1 + parts = [f"{count} {status}" for status, count in sorted(counts.items())] + console.print("Application summary:") + console.print(", ".join(parts)) + + if __name__ == "__main__": app() diff --git a/src/nerajob/config.py b/src/nerajob/config.py index 03c2398..2677390 100644 --- a/src/nerajob/config.py +++ b/src/nerajob/config.py @@ -34,3 +34,4 @@ def http_timeout() -> float: PROFILE_PATH = data_dir() / "profile.json" JOBS_PATH = data_dir() / "jobs.json" APPLICATIONS_DIR = data_dir() / "applications" +SCAN_PRESET_PATH = data_dir() / "scan-preset.json" diff --git a/src/nerajob/cv/builder.py b/src/nerajob/cv/builder.py index cc85708..4679ccc 100644 --- a/src/nerajob/cv/builder.py +++ b/src/nerajob/cv/builder.py @@ -59,7 +59,98 @@ def build_cv_markdown(profile: Profile, target_role: str = "") -> str: return "\n".join(lines).strip() + "\n" -def write_cv_files(profile: Profile, target_role: str = "") -> dict[str, Path]: +def _md_to_simple_html(md: str) -> str: + parts = ["") + in_list = False + for line in md.split("\n"): + if line.startswith("### "): + if in_list: + parts.append("") + in_list = False + parts.append(f"

{line[4:]}

") + elif line.startswith("## "): + if in_list: + parts.append("") + in_list = False + parts.append(f"

{line[3:]}

") + elif line.startswith("# "): + if in_list: + parts.append("") + in_list = False + parts.append(f"

{line[2:]}

") + elif line.startswith("- "): + if not in_list: + parts.append("
    ") + in_list = True + parts.append(f"
  • {line[2:]}
  • ") + elif line.startswith("*") and line.endswith("*"): + if in_list: + parts.append("
") + in_list = False + parts.append(f"

{line.strip('*')}

") + elif line.strip() == "": + if in_list: + parts.append("") + in_list = False + else: + if in_list: + parts.append("") + in_list = False + parts.append(f"

{line}

") + if in_list: + parts.append("") + parts.append("") + return "\n".join(parts) + + +def write_cv_pdf(profile: Profile, target_role: str = "") -> Path | None: + md = build_cv_markdown(profile, target_role) + slug = slugify(target_role or profile.headline or "general") or "general" + out_dir = data_dir() / "cv" + out_dir.mkdir(parents=True, exist_ok=True) + pdf_path = out_dir / f"cv-{slug}.pdf" + html = _md_to_simple_html(md) + try: + from weasyprint import HTML as WeasyprintHTML + + WeasyprintHTML(string=html).write_pdf(pdf_path) + except ImportError: + try: + from fpdf import FPDF + + pdf = FPDF() + pdf.add_page() + pdf.set_auto_page_break(auto=True, margin=15) + for line in md.split("\n"): + if line.startswith("# "): + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 10, line[2:], new_x="LMARGIN", new_y="NEXT") + elif line.startswith("## ") or line.startswith("### "): + pdf.set_font("Helvetica", "B", 14) + label = line[line.index(" ") + 1 :] + pdf.cell(0, 10, label, new_x="LMARGIN", new_y="NEXT") + elif line.strip(): + pdf.set_font("Helvetica", "", 11) + safe = line.encode("latin-1", "replace").decode("latin-1") + pdf.multi_cell(0, 6, safe) + else: + pdf.ln(4) + pdf.output(str(pdf_path)) + except ImportError: + return None + return pdf_path + + +def write_cv_files( + profile: Profile, target_role: str = "", fmt: str = "md" +) -> dict[str, Path]: md = build_cv_markdown(profile, target_role) slug = slugify(target_role or profile.headline or "general") or "general" out_dir = data_dir() / "cv" @@ -67,7 +158,6 @@ def write_cv_files(profile: Profile, target_role: str = "") -> dict[str, Path]: md_path = out_dir / f"cv-{slug}.md" txt_path = out_dir / f"cv-{slug}.txt" md_path.write_text(md, encoding="utf-8") - # plain text: drop markdown markers lightly plain = ( md.replace("# ", "") .replace("## ", "") @@ -76,4 +166,9 @@ def write_cv_files(profile: Profile, target_role: str = "") -> dict[str, Path]: .replace("*", "") ) txt_path.write_text(plain, encoding="utf-8") - return {"markdown": md_path, "text": txt_path} + result: dict[str, Path] = {"markdown": md_path, "text": txt_path} + if fmt == "pdf": + pdf_path = write_cv_pdf(profile, target_role) + if pdf_path: + result["pdf"] = pdf_path + return result diff --git a/src/nerajob/evaluation.py b/src/nerajob/evaluation.py new file mode 100644 index 0000000..efdfb6a --- /dev/null +++ b/src/nerajob/evaluation.py @@ -0,0 +1,18 @@ +"""Offline evaluation helpers for deterministic job-match fixtures.""" + +from __future__ import annotations + +from collections.abc import Iterable + + +def precision_at_k(ranked_job_ids: Iterable[str], relevant_job_ids: Iterable[str], k: int) -> float: + """Return the fraction of the first *k* ranked jobs that are relevant.""" + if k < 1: + raise ValueError("k must be at least 1") + + ranked = list(ranked_job_ids)[:k] + if not ranked: + return 0.0 + + relevant = set(relevant_job_ids) + return sum(job_id in relevant for job_id in ranked) / len(ranked) diff --git a/src/nerajob/http.py b/src/nerajob/http.py new file mode 100644 index 0000000..f9633d6 --- /dev/null +++ b/src/nerajob/http.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +import time +from collections import defaultdict +from dataclasses import dataclass, field +from urllib.parse import urlparse + +import httpx + +from nerajob.config import http_timeout, user_agent + +REJECTED_STATUSES = {429, 500, 502, 503, 504} + + +@dataclass +class _RateState: + last: float = 0.0 + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +class ScraperHTTPClient: + """ + Shared async HTTP client with configurable timeout/user-agent, + per-host rate limiting, exponential-backoff retry for 429/5xx, + and optional robots.txt compliance checking. + """ + + def __init__( + self, + timeout: float | None = None, + ua: str | None = None, + max_retries: int = 3, + requests_per_second: float = 5.0, + respect_robots: bool = True, + ) -> None: + self.timeout = http_timeout() if timeout is None else timeout + self.user_agent = user_agent() if ua is None else ua + self.max_retries = max_retries + self.requests_per_second = requests_per_second + self.respect_robots = respect_robots + + self._client: httpx.AsyncClient | None = None + self._rate: dict[str, _RateState] = defaultdict(_RateState) + self._robots_cache: dict[str, list[tuple[str, str]]] = {} + self._robots_lock = asyncio.Lock() + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + timeout=self.timeout, + headers={"User-Agent": self.user_agent}, + follow_redirects=True, + ) + return self._client + + async def get(self, url: str, **kwargs: object) -> httpx.Response: + parsed = urlparse(url) + host = parsed.netloc or parsed.path + + if self.respect_robots: + allowed = await self._robots_allowed(url) + if not allowed: + raise httpx.HTTPStatusError( + f"Blocked by robots.txt: {url}", + request=httpx.Request("GET", url), + response=httpx.Response(403), + ) + + await self._throttle(host) + return await self._request_with_retry(url, **kwargs) + + async def _throttle(self, host: str) -> None: + state = self._rate[host] + min_interval = 1.0 / max(self.requests_per_second, 0.1) + async with state.lock: + elapsed = time.monotonic() - state.last + if elapsed < min_interval: + await asyncio.sleep(min_interval - elapsed) + state.last = time.monotonic() + + async def _request_with_retry(self, url: str, **kwargs: object) -> httpx.Response: + last_exc: Exception | None = None + for attempt in range(self.max_retries + 1): + try: + client = await self._get_client() + resp = await client.get(url, **kwargs) + if resp.status_code in REJECTED_STATUSES and attempt < self.max_retries: + wait = 2 ** attempt + (attempt * 0.5) + await asyncio.sleep(wait) + continue + resp.raise_for_status() + return resp + except (httpx.TimeoutException, httpx.NetworkError) as exc: + last_exc = exc + if attempt < self.max_retries: + wait = 2 ** attempt + (attempt * 0.5) + await asyncio.sleep(wait) + raise last_exc if last_exc is not None else RuntimeError("unreachable") + + async def _robots_allowed(self, url: str) -> bool: + parsed = urlparse(url) + base = f"{parsed.scheme}://{parsed.netloc}" + robots_url = f"{base}/robots.txt" + + async with self._robots_lock: + if base not in self._robots_cache: + self._robots_cache[base] = await self._fetch_robots_rules(robots_url) + rules = self._robots_cache[base] + + path = parsed.path or "/" + return _is_path_allowed(path, self.user_agent, rules) + + async def _fetch_robots_rules(self, robots_url: str) -> list[tuple[str, str]]: + try: + client = await self._get_client() + resp = await client.get(robots_url, timeout=10) + if resp.status_code == 200: + return _parse_robots(resp.text) + except Exception: + pass + return [] + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + +def _parse_robots(text: str) -> list[tuple[str, str]]: + """Parse a robots.txt body into (directive, path) pairs.""" + lines = [] + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if ":" in line: + key, _, val = line.partition(":") + lines.append((key.strip().lower(), val.strip())) + return lines + + +def _is_path_allowed( + path: str, ua: str, rules: list[tuple[str, str]] +) -> bool: + """Check if *path* is allowed for *ua* per a parsed set of robots rules.""" + applicable: list[tuple[str, str]] = [] + in_group = False + for directive, value in rules: + if directive == "user-agent": + in_group = value == "*" or value.lower() in ua.lower() + elif in_group and directive in ("allow", "disallow"): + applicable.append((directive, value)) + + allowed = True + for directive, value in applicable: + pattern = value if value else "/" + if not path.startswith(pattern): + continue + if directive == "disallow": + allowed = False + elif directive == "allow": + allowed = True + return allowed diff --git a/src/nerajob/match.py b/src/nerajob/match.py index b1dcd17..7cdd1ba 100644 --- a/src/nerajob/match.py +++ b/src/nerajob/match.py @@ -2,20 +2,52 @@ from __future__ import annotations +from dataclasses import dataclass + from nerajob.models import JobPosting, Profile + +@dataclass(frozen=True) +class MatchWeights: + """Score contribution caps for skills, title, and location matching.""" + + skills: float = 70.0 + title: float = 20.0 + location: float = 12.0 + + def __post_init__(self) -> None: + for name, value in self.as_dict().items(): + if value < 0: + raise ValueError(f"{name} weight must be non-negative") + + def as_dict(self) -> dict[str, float]: + return { + "skills": float(self.skills), + "title": float(self.title), + "location": float(self.location), + } + + +DEFAULT_MATCH_WEIGHTS = MatchWeights() + # Common skill aliases for resume ↔ job matching (offline-friendly). SKILL_ALIASES: dict[str, set[str]] = { "python": {"python", "django", "fastapi", "flask"}, "javascript": {"javascript", "js", "typescript", "node", "react"}, - "devops": {"devops", "docker", "kubernetes", "k8s", "ci/cd"}, + "devops": {"devops", "docker", "kubernetes", "k8s", "ci/cd", "terraform", "helm", "ansible", "puppet", "chef", "prometheus", "grafana", "sre", "docker swarm", "nomad", "consul", "vault", "istio", "service mesh", "infrastructure as code", "iac", "site reliability", "argocd", "jenkins", "github actions", "gitlab ci", "vault", "consul", "nomad"}, + "security_ops": {"secops", "soc analyst", "incident response", "threat hunting", "siem", "edr", "blue team", "detection"}, + "platform_eng": {"platform engineer", "platform engineering", "internal developer platform", "idp", "developer experience", "devex", "paved path", "golden path"}, + "sales_eng": {"solutions engineer", "sales engineer", "pre-sales", "demo", "poc", "technical account", "se ", "rfp"}, + "customer_success": {"customer success", "csm", "account management", "onboarding", "retention", "support", "helpdesk", "zendesk"}, + "qa_test": {"qa", "quality assurance", "selenium", "cypress", "playwright", "test automation", "sdet", "pytest"}, + "ml_ai": {"machine learning", "ml", "deep learning", "nlp", "computer vision", "pytorch", "tensorflow"}, "ml": {"ml", "machine learning", "pytorch", "tensorflow"}, "rust": {"rust", "cargo", "tokio", "actix", "axum"}, "go": {"go", "golang", "gin", "fiber"}, "sql": {"sql", "postgres", "postgresql", "mysql", "sqlite", "database"}, - "cloud": {"cloud", "aws", "gcp", "azure", "s3", "lambda"}, + "cloud": {"cloud", "aws", "gcp", "azure", "s3", "lambda", "azure-devops", "gke", "eks", "ecs", "cloudformation", "serverless", "cdn", "vpc", "cloudwatch", "cloud functions", "azure functions", "google cloud", "google cloud platform", "aws lambda", "azure devops", "pulumi", "crossplane", "ec2", "cloudrun", "cloudfunctions", "cdk", "route53", "cloudfront", "iam", "cloudtrail", "aks", "terraform", "helm", "ansible", "istio", "consul", "nomad", "vault", "argocd", "jenkins", "github actions", "gitlab ci", "prometheus", "grafana", "site reliability", "infrastructure as code", "iac", "service mesh"}, "java": {"java", "spring", "kotlin", "jvm", "maven", "gradle"}, - "mobile": {"mobile", "android", "ios", "flutter", "react native", "swift", "kotlin"}, + "mobile": {"mobile", "android", "ios", "flutter", "react native", "swift", "kotlin", "xamarin", "maui", "capacitor", "cordova", "jetpack compose", "ndk", "coreml", "arkit", "arcore", "xcode", "android studio", "mobile dev", "mobile development", "native app", "hybrid app", "pwa", "progressive web app", "app store optimization", "aso", "objc", "objective-c", "swiftui", "ionic", "app store", "play store", "expo"}, "security": {"security", "infosec", "appsec", "owasp", "penetration testing", "pentest"}, "data": {"data", "analytics", "etl", "spark", "airflow", "dbt", "pandas"}, "web3": {"web3", "blockchain", "solidity", "ethereum", "solana", "smart contract", "defi"}, @@ -24,11 +56,62 @@ "embedded": {"embedded", "firmware", "rtos", "arduino", "esp32", "stm32", "c++"}, "product": {"product", "pm", "product manager", "roadmap", "agile", "scrum"}, "writing": {"writing", "technical writing", "docs", "copywriting", "content"}, + "ops": {"ops", "operations", "logistics", "supply chain", "sre", "platform"}, + "ux_design": {"ux", "ui", "figma", "wireframe", "prototype", "user research", "product design"}, + "data_engineering": {"data engineering", "etl", "spark", "airflow", "dbt", "warehouse", "pipeline"}, + "cybersecurity": {"cybersecurity", "security", "soc", "siem", "pentest", "iam", "infosec"}, + "hardware": {"hardware", "pcb", "fpga", "asic", "electronics", "schematic"}, + "healthcare": {"healthcare", "nursing", "clinical", "medical", "emr", "healthtech"}, + "education": { + "education", + "teaching", + "curriculum", + "edtech", + "lms", + "learning management system", + "assessment", + "tutor", + "tutoring", + "instructor", + "instructional design", + "e-learning", + }, + "legal": {"legal", "counsel", "compliance", "contracts", "gdpr", "privacy"}, + "marketing": {"marketing", "growth", "seo", "sem", "content marketing", "demand gen"}, + "hr": {"hr", "human resources", "recruiting", "talent", "people ops", "ats"}, "game": {"game", "gamedev", "unity", "unreal", "godot", "game design"}, "support": {"support", "customer support", "helpdesk", "zendesk", "intercom", "cs"}, + "sales": {"sales", "account executive", "sdr", "bdr", "crm", "salesforce"}, + "finance": {"finance", "accounting", "fintech", "cpa", "bookkeeping", "fp&a", "payments", "payment processing", "ledger", "kyc", "know your customer", "aml", "anti money laundering", "risk", "risk management", "quant", "quantitative analysis", "quantitative", "trading", "algorithmic trading", "blockchain finance", "defi", "banking api", "payment gateway", "stripe", "paypal", "square", "plaid", "wealth management", "robo advisor", "insurance tech", "insurtech", "regtech", "compliance tech", "financial modeling", "valuation", "portfolio management", "asset management", "cryptocurrency trading", "forex", "fx trading", "settlement", "clearing", "underwriting", "treasury", "swift", "iso20022", "bloomberg", "reconciliation"}, } +def extract_skills_from_text(text: str) -> dict[str, set[str]]: + """ + Parse free-form text and extract known skill tokens matched against SKILL_ALIASES. + + Returns a mapping of domain → matched skill tokens found in the text. + """ + normalized = text.lower() + result: dict[str, set[str]] = {} + for domain, aliases in SKILL_ALIASES.items(): + matched: set[str] = set() + for alias in aliases: + if alias in normalized: + matched.add(alias) + if matched: + result[domain] = matched + return result + + +def _coerce_weights(weights: MatchWeights | dict[str, float] | None) -> MatchWeights: + if weights is None: + return DEFAULT_MATCH_WEIGHTS + if isinstance(weights, MatchWeights): + return weights + return MatchWeights(**weights) + + def expand_skills(skills: set[str] | list[str] | tuple[str, ...]) -> set[str]: out = {str(s).lower().strip() for s in skills if str(s).strip()} for s in list(out): @@ -38,11 +121,16 @@ def expand_skills(skills: set[str] | list[str] | tuple[str, ...]) -> set[str]: return out -def match_score(profile: Profile, job: JobPosting) -> dict: +def match_score( + profile: Profile, + job: JobPosting, + weights: MatchWeights | dict[str, float] | None = None, +) -> dict: """ Lightweight keyword match: skills vs title/description/tags + location soft score. Returns 0–100 score with explainable hits. """ + score_weights = _coerce_weights(weights) base_skills = [s.strip().lower() for s in (profile.skills or []) if s.strip()] hay = f"{job.title} {job.description} {' '.join(job.tags)} {job.company}".lower() hits: list[str] = [] @@ -50,7 +138,11 @@ def match_score(profile: Profile, job: JobPosting) -> dict: aliases = expand_skills({s}) if any(a and a in hay for a in aliases): hits.append(s) - skill_score = (len(hits) / max(1, len(base_skills))) * 70.0 if base_skills else 35.0 + skill_score = ( + (len(hits) / max(1, len(base_skills))) * score_weights.skills + if base_skills + else score_weights.skills * 0.5 + ) # headline / target role token overlap with job title headline_tokens = { @@ -58,7 +150,7 @@ def match_score(profile: Profile, job: JobPosting) -> dict: } title_tokens = {t for t in job.title.lower().replace("/", " ").split() if len(t) > 2} role_overlap = headline_tokens & title_tokens - role_score = min(20.0, len(role_overlap) * 7.0) + role_score = min(score_weights.title, len(role_overlap) * (score_weights.title * 0.35)) loc_score = 0.0 pl = (profile.location or "").lower() @@ -66,10 +158,11 @@ def match_score(profile: Profile, job: JobPosting) -> dict: prefers_remote = any( tok in pl for tok in ("remote", "wfh", "anywhere", "worldwide") ) or getattr(profile, "remote_ok", False) + partial_location_weight = score_weights.location * (10.0 / 12.0) if job.remote or "remote" in jl: - loc_score = 12.0 if prefers_remote else 10.0 + loc_score = score_weights.location if prefers_remote else partial_location_weight elif pl and any(part in jl for part in pl.split() if len(part) > 2): - loc_score = 10.0 + loc_score = partial_location_weight total = min(100.0, skill_score + role_score + loc_score) return { @@ -81,11 +174,17 @@ def match_score(profile: Profile, job: JobPosting) -> dict: "role_overlap": sorted(role_overlap), "remote": job.remote, "location_boost": loc_score, + "score_weights": score_weights.as_dict(), "band": "strong" if total >= 70 else "medium" if total >= 40 else "weak", } -def rank_jobs(profile: Profile, jobs: list[JobPosting], top_k: int = 20) -> list[dict]: - ranked = [match_score(profile, j) for j in jobs] +def rank_jobs( + profile: Profile, + jobs: list[JobPosting], + top_k: int = 20, + weights: MatchWeights | dict[str, float] | None = None, +) -> list[dict]: + ranked = [match_score(profile, j, weights=weights) for j in jobs] ranked.sort(key=lambda r: r["score"], reverse=True) return ranked[: max(1, top_k)] diff --git a/src/nerajob/models.py b/src/nerajob/models.py index 4b7fe91..53961eb 100644 --- a/src/nerajob/models.py +++ b/src/nerajob/models.py @@ -1,9 +1,9 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Any +from typing import Any, ClassVar -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator def utc_now_iso() -> str: @@ -53,10 +53,55 @@ class JobPosting(BaseModel): raw: dict[str, Any] = Field(default_factory=dict) +class ScanPreset(BaseModel): + remote_only: bool = False + skill_filters: list[str] = Field(default_factory=list) + min_score: float = 0.0 + min_salary: int = 0 + max_results: int = 20 + + class ApplicationPackage(BaseModel): job_id: str + status: str = "draft" created_at: str = Field(default_factory=utc_now_iso) + updated_at: str = Field(default_factory=utc_now_iso) cover_note: str = "" checklist: list[str] = Field(default_factory=list) cv_markdown_path: str = "" notes: str = "" + + VALID_STATUSES: ClassVar[set[str]] = {"draft", "applied", "interview", "offer", "rejected", "accepted"} + + @field_validator("status") + @classmethod + def _validate_status(cls, v: str) -> str: + if v not in cls.VALID_STATUSES: + raise ValueError(f"Invalid status '{v}'. Must be one of {sorted(cls.VALID_STATUSES)}") + return v + + def set_status(self, new_status: str) -> None: + if new_status not in self.VALID_STATUSES: + raise ValueError(f"Invalid status '{new_status}'. Must be one of {sorted(self.VALID_STATUSES)}") + self.status = new_status + self.updated_at = utc_now_iso() + + +def parse_salary_value(salary: str) -> int | None: + """Extract a numeric annual salary floor from a salary string. Returns None if unparseable.""" + import re + if not salary or not salary.strip(): + return None + s = salary.lower().replace(",", "").replace("€", "").replace("$", "").replace("£", "").strip() + # Try ranges like "80k-120k" or "80k – 120k" + m = re.search(r"(\d+)\s*k", s) + if m: + return int(m.group(1)) * 1000 + # Try "80000-120000" + m = re.search(r"(\d{4,6})", s) + if m: + val = int(m.group(1)) + # If first number looks like annual salary + if 10000 <= val <= 999999: + return val + return None diff --git a/src/nerajob/scrapers/adzuna.py b/src/nerajob/scrapers/adzuna.py new file mode 100644 index 0000000..17c18f7 --- /dev/null +++ b/src/nerajob/scrapers/adzuna.py @@ -0,0 +1,305 @@ +"""Adzuna Jobs API adapter with offline fallback. + +Adzuna (https://adzuna.com) provides a RESTful job search API across +multiple countries. A free API key (app_id + app_key) is required for +live calls; register at https://developer.adzuna.com/signup. + +Behaviour: + - When NERAJOB_ADZUNA_OFFLINE=1 is set, or the env credentials are + missing, or the live call fails → deterministic offline fixtures. + - When ADZUNA_APP_ID and ADZUNA_APP_KEY are both set → live API. + - Country defaults to ``gb``; can be override with ``--country`` CLI + arg or by setting ``NERAJOB_ADZUNA_COUNTRY`` env var. + +API docs: https://developer.adzuna.com/overview +Rate limit: free tier is 50 calls / day (use responsibly). + +Bounty: https://github.com/mergeos-bounties/NeraJob/issues/7 (50 MRG) +""" + +from __future__ import annotations + +import hashlib +import os + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + +# ── deterministic offline fixtures ────────────────────────────────────── + +_OFFLINE: list[tuple[str, str, str, list[str], str, str]] = [ + ( + "Python Developer", + "Adzuna Demo Ltd", + "London, UK", + ["python", "django", "postgresql"], + "https://adzuna.com/jobs/demo-python-dev", + "Backend Python developer with Django experience. Hybrid London.", + ), + ( + "Frontend Engineer", + "Adzuna Demo Ltd", + "Remote UK", + ["javascript", "react", "typescript", "css"], + "https://adzuna.com/jobs/demo-frontend", + "Frontend engineer for product team. Remote-first within UK timezone.", + ), + ( + "DevOps Engineer", + "Cloud Native Inc", + "Berlin, Germany", + ["kubernetes", "terraform", "aws", "ci-cd"], + "https://adzuna.com/jobs/demo-devops", + "Platform engineering role. K8s + Terraform + AWS. On-call rotation.", + ), + ( + "Data Scientist", + "DataDriven Co", + "Remote (EU)", + ["python", "machine-learning", "tensorflow", "sql"], + "https://adzuna.com/jobs/demo-data-scientist", + "Build and deploy ML models. Remote-first EU team.", + ), +] + + +class AdzunaScraper(BaseScraper): + """https://api.adzuna.com/v1/api/jobs — Multi-country job search API.""" + + name = "adzuna" + BASE_URL = "https://api.adzuna.com/v1/api/jobs" + + def search( + self, + query: str = "", + location: str = "", + limit: int = 20, + ) -> list[JobPosting]: + """Search Adzuna for jobs matching *query* and *location*. + + Parameters + ---------- + query : str + Free-text search (job title, skill, keyword). + location : str + Where string (e.g. ``"London"``). Used as-is; Adzuna does its + own geo-resolution when ``where`` is passed. + limit : int + Max results to return (capped at API page size 50). + + Returns + ------- + list[JobPosting] + Matched job postings, or offline fixtures on failure. + """ + if os.getenv("NERAJOB_ADZUNA_OFFLINE", "").strip().lower() in {"1", "true", "yes"}: + return self._offline(query, location, limit) + + app_id = os.getenv("ADZUNA_APP_ID", "").strip() + app_key = os.getenv("ADZUNA_APP_KEY", "").strip() + if not app_id or not app_key: + return self._offline(query, location, limit) + + country = ( + os.getenv("NERAJOB_ADZUNA_COUNTRY", "").strip() + or location.split(",")[-1].strip().lower() + or "gb" + ) + # Normalise common country names to 2-letter codes + country_map = { + "uk": "gb", "united kingdom": "gb", "great britain": "gb", + "us": "us", "usa": "us", "united states": "us", + "de": "de", "germany": "de", + "au": "au", "australia": "au", + "ca": "ca", "canada": "ca", + "in": "in", "india": "in", + "fr": "fr", "france": "fr", + "nl": "nl", "netherlands": "nl", + "br": "br", "brazil": "br", + "nz": "nz", "new zealand": "nz", + "za": "za", "south africa": "za", + "sg": "sg", "singapore": "sg", + "ae": "ae", "united arab emirates": "ae", + } + country = country_map.get(country, country) + + params: dict[str, str | int] = { + "app_id": app_id, + "app_key": app_key, + "results_per_page": max(1, min(limit, 50)), + } + if query.strip(): + params["what"] = query.strip() + if location.strip(): + # Use the full location string as "where" param + params["where"] = location.strip() + + url = f"{self.BASE_URL}/{country}/search/1" + headers = { + "User-Agent": user_agent(), + "Accept": "application/json", + } + + try: + with httpx.Client( + timeout=http_timeout(), + headers=headers, + follow_redirects=True, + ) as client: + response = client.get(url, params=params) + response.raise_for_status() + payload = response.json() + except Exception: + return self._offline(query, location, limit) + + results = payload.get("results") if isinstance(payload, dict) else [] + if not isinstance(results, list): + return self._offline(query, location, limit) + + q = query.strip().lower() + loc = location.strip().lower() + jobs: list[JobPosting] = [] + for item in results: + if not isinstance(item, dict): + continue + posting = self._posting_from_api(item, country) + if posting is None: + continue + hay = ( + f"{posting.title} {posting.company} {posting.location} " + f"{' '.join(posting.tags)} {posting.description}" + ).lower() + if q and q not in hay: + continue + if loc and loc not in posting.location.lower() and "remote" not in posting.location.lower(): + continue + jobs.append(posting) + if len(jobs) >= limit: + break + + return jobs if jobs else self._offline(query, location, limit) + + # ── helpers ───────────────────────────────────────────────────────── + + def _posting_from_api(self, item: dict, country: str) -> JobPosting | None: + """Convert an Adzuna API result dict to a JobPosting. + + Adzuna API field reference: + - id (str) -> job ad id + - title (str) -> job title + - company.display_name (str) -> company name + - location.display_name (str) -> location string + - redirect_url (str) -> URL on adzuna.com + - description (str) -> full description (HTML) + - category.label (str) -> job category + - contract_type (str) -> "permanent", "contract", etc. + - salary_min (float) -> minimum salary + - salary_max (float) -> maximum salary + - salary_is_predicted (str) -> "1" if estimated + """ + title = str(item.get("title") or "").strip() + if not title: + return None + + company_obj = item.get("company") or {} + company = ( + str(company_obj.get("display_name") or "") + if isinstance(company_obj, dict) + else str(item.get("company_name") or "") + ).strip() + if not company: + company = "Unknown Company" + + loc_obj = item.get("location") or {} + place = ( + str(loc_obj.get("display_name") or "") + if isinstance(loc_obj, dict) + else str(item.get("location") or "Remote") + ).strip() + if not place: + place = "Remote" + + url = str(item.get("redirect_url") or item.get("url") or "").strip() + description = str(item.get("description") or "").strip()[:4000] + salary_min = item.get("salary_min") + salary_max = item.get("salary_max") + salary_str = "" + if salary_min is not None or salary_max is not None: + low = f"£{salary_min:,.0f}" if salary_min else "" + high = f"£{salary_max:,.0f}" if salary_max else "" + salary_str = f"{low}–{high}" if low and high else (low or high) + + tags: list[str] = [] + cat = item.get("category") + if isinstance(cat, dict): + label = str(cat.get("label") or "").strip().lower() + if label: + tags.append(label) + ctype = str(item.get("contract_type") or "").strip().lower() + if ctype: + tags.append(ctype) + + raw_id = str(item.get("id") or f"{company}:{title}") + digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12] + + return JobPosting( + id=f"{self.name}-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=description, + tags=tags[:20], + salary=salary_str, + remote="remote" in place.lower(), + raw=item, + ) + + def _offline(self, query: str, location: str, limit: int) -> list[JobPosting]: + """Return deterministic offline fixtures filtered by query + location.""" + q = query.strip().lower() + loc = location.strip().lower() + jobs: list[JobPosting] = [] + for title, company, place, tags, url, desc in _OFFLINE: + hay = f"{title} {company} {place} {' '.join(tags)} {desc}".lower() + if q and q not in hay: + continue + if loc and loc not in place.lower() and "remote" not in place.lower(): + continue + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + jobs.append( + JobPosting( + id=f"{self.name}-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=desc, + tags=tags[:20], + remote="remote" in place.lower(), + ) + ) + if len(jobs) >= limit: + break + if not jobs and not q: + for title, company, place, tags, url, desc in _OFFLINE[:limit]: + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + jobs.append( + JobPosting( + id=f"{self.name}-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=desc, + tags=tags[:20], + remote="remote" in place.lower(), + ) + ) + return jobs diff --git a/src/nerajob/scrapers/findwork.py b/src/nerajob/scrapers/findwork.py new file mode 100644 index 0000000..3356c9e --- /dev/null +++ b/src/nerajob/scrapers/findwork.py @@ -0,0 +1,249 @@ +"""Findwork.dev public jobs API adapter with offline fallback. + +Findwork.dev (https://findwork.dev) is a job board aggregator exposing a +public REST API at https://findwork.dev/api/jobs/. Public read access is +available with a free API token; without a token the API returns 403. + +To keep this scraper operational without requiring every user to register +a token, we ship an OFFLINE fallback (deterministic demo data) used when: + - NERAJOB_FINDWORK_OFFLINE=1 is set, OR + - NERAJOB_FINDWORK_API_TOKEN env var is not set, OR + - the live API call fails (network, 403, parse error) + +To use the live API, register at https://findwork.dev/ and set: + export NERAJOB_FINDWORK_API_TOKEN=your_token_here +""" + +from __future__ import annotations + +import hashlib +import os + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + +# Deterministic offline fixture for tests + demos (no network needed). +_OFFLINE = [ + ( + "Senior Python Backend Engineer", + "Findwork Demo Labs", + "Remote (Worldwide)", + ["python", "fastapi", "postgresql", "remote"], + "https://findwork.dev/jobs/demo-python-backend/", + "Design and build resilient backend services in Python. Work on APIs, data pipelines, and async job queues. Remote-first team across 3 continents.", + ), + ( + "Full-Stack Engineer (Python + React)", + "Atlas Remote", + "Remote (EU)", + ["python", "react", "typescript", "remote"], + "https://findwork.dev/jobs/demo-fullstack/", + "Join a 12-person product team building developer tooling. Own features end-to-end. Must overlap 4 hours with EU business hours.", + ), + ( + "DevOps Engineer", + "Cloudward", + "Remote (US)", + ["kubernetes", "terraform", "aws", "remote"], + "https://findwork.dev/jobs/demo-devops/", + "Own the platform: CI/CD, observability, infra-as-code. Strong AWS + K8s background required. On-call rotation every 6 weeks.", + ), + ( + "ML Engineer", + "Visionary AI", + "Remote (Worldwide)", + ["python", "pytorch", "mlops", "remote"], + "https://findwork.dev/jobs/demo-ml-engineer/", + "Train and ship production ML models for vision systems. End-to-end ownership from data pipeline to deployment.", + ), + ( + "Security Engineer (AppSec)", + "Shield Stack", + "Remote (Worldwide)", + ["security", "python", "appsec", "remote"], + "https://findwork.dev/jobs/demo-security/", + "Lead application security for a fintech platform. Threat modeling, SAST/DAST tooling, secure code review. Python-heavy codebase.", + ), +] + + +class FindworkScraper(BaseScraper): + """https://findwork.dev/api/jobs/ — public job board aggregator.""" + + name = "findwork" + API_URL = "https://findwork.dev/api/jobs/" + + def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]: + """Search Findwork.dev for jobs matching query + location. + + Falls back to OFFLINE fixtures when no API token is configured + or when the live API call fails (network, 403, parse error). + """ + if os.getenv("NERAJOB_FINDWORK_OFFLINE", "").strip().lower() in {"1", "true", "yes"}: + return self._offline(query, location, limit) + + token = os.getenv("NERAJOB_FINDWORK_API_TOKEN", "").strip() + if not token: + return self._offline(query, location, limit) + + headers = { + "User-Agent": user_agent(), + "Accept": "application/json", + "Authorization": f"Token {token}", + } + params: dict[str, str | int] = {"limit": max(1, min(limit, 50))} + if query.strip(): + params["search"] = query.strip() + if location.strip(): + params["location"] = location.strip() + + try: + with httpx.Client( + timeout=http_timeout(), + headers=headers, + follow_redirects=True, + ) as client: + response = client.get(self.API_URL, params=params) + response.raise_for_status() + payload = response.json() + except Exception: + return self._offline(query, location, limit) + + results = ( + payload.get("results") + if isinstance(payload, dict) + else payload + if isinstance(payload, list) + else [] + ) + if not isinstance(results, list): + return self._offline(query, location, limit) + + jobs: list[JobPosting] = [] + q = query.strip().lower() + loc = location.strip().lower() + for item in results: + if not isinstance(item, dict): + continue + posting = self._posting_from_api(item) + if posting is None: + continue + hay = f"{posting.title} {posting.company} {posting.location} {' '.join(posting.tags)} {posting.description}".lower() + if q and q not in hay: + continue + if loc and loc not in posting.location.lower() and "remote" not in posting.location.lower(): + continue + jobs.append(posting) + if len(jobs) >= limit: + break + return jobs + + def _posting_from_api(self, item: dict) -> JobPosting | None: + """Convert a Findwork.dev API result dict to a JobPosting. + + API field reference (verified 2026-07-18): + - id (int) -> job id + - role (str) -> title + - company_name (str) -> company (nested: company.name) + - location (str) -> location string + - url (str) -> external URL on findwork.dev + - text (str) -> full description (HTML/markdown) + - tags (list[str]) -> keyword tags + - remote (bool) -> is remote? + - keywords (list[str])-> derived keywords + """ + title = str(item.get("role") or item.get("title") or "").strip() + if not title: + return None + + company_obj = item.get("company") + if isinstance(company_obj, dict): + company = str( + company_obj.get("name") + or company_obj.get("company_name") + or "" + ).strip() + else: + company = str(item.get("company_name") or "").strip() + if not company: + company = "Unknown Company" + + place = str(item.get("location") or "Remote").strip() or "Remote" + url = str(item.get("url") or item.get("redirect_url") or "").strip() + description = str(item.get("text") or item.get("description") or "").strip() + tags_raw = (item.get("tags") or []) + (item.get("keywords") or []) + tags = sorted({ + str(t).strip().lower() + for t in tags_raw + if t and isinstance(t, str | int | float) + }) + remote = bool(item.get("remote", "remote" in place.lower())) + + raw_id = str(item.get("id") or f"{company}:{title}:{url}") + digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12] + posting_id = ( + f"{self.name}-{item.get('id')}" + if item.get("id") + else f"{self.name}-{digest}" + ) + + return JobPosting( + id=posting_id, + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=description, + tags=tags, + remote=remote, + raw=item, + ) + + def _offline(self, query: str, location: str, limit: int) -> list[JobPosting]: + """Return deterministic offline fixtures filtered by query + location.""" + q = query.strip().lower() + loc = location.strip().lower() + jobs: list[JobPosting] = [] + for title, company, place, tags, url, desc in _OFFLINE: + hay = f"{title} {company} {place} {' '.join(tags)} {desc}".lower() + if q and q not in hay: + continue + if loc and loc not in place.lower() and "remote" not in place.lower(): + continue + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + jobs.append( + JobPosting( + id=f"{self.name}-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=desc, + tags=tags, + remote="remote" in place.lower(), + ) + ) + if len(jobs) >= limit: + break + if not jobs and not q: + for title, company, place, tags, url, desc in _OFFLINE[:limit]: + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + jobs.append( + JobPosting( + id=f"{self.name}-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=desc, + tags=tags, + remote="remote" in place.lower(), + ) + ) + return jobs diff --git a/src/nerajob/scrapers/github_jobs.py b/src/nerajob/scrapers/github_jobs.py new file mode 100644 index 0000000..ed8fbd5 --- /dev/null +++ b/src/nerajob/scrapers/github_jobs.py @@ -0,0 +1,144 @@ +"""GitHub Jobs public API adapter (with offline sample fallback).""" + +from __future__ import annotations + +import hashlib +import os + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + +# Offline fixtures when network fails or NERAJOB_GITHUB_OFFLINE=1 +_OFFLINE = [ + ( + "Senior Python Developer", + "GitHub Demo Co", + "San Francisco, CA", + ["python", "django", "fastapi", "postgresql"], + "https://jobs.github.com/positions/demo-python", + ), + ( + "Full Stack Engineer", + "OpenSource Inc", + "Remote", + ["javascript", "react", "node", "typescript"], + "https://jobs.github.com/positions/demo-fullstack", + ), + ( + "DevOps Engineer", + "CloudNative Corp", + "New York, NY", + ["docker", "kubernetes", "aws", "terraform"], + "https://jobs.github.com/positions/demo-devops", + ), + ( + "Data Scientist", + "AI Research Lab", + "Boston, MA", + ["python", "machine learning", "tensorflow", "pandas"], + "https://jobs.github.com/positions/demo-datascience", + ), + ( + "Mobile Developer", + "AppWorks", + "Austin, TX", + ["flutter", "dart", "ios", "android"], + "https://jobs.github.com/positions/demo-mobile", + ), +] + + +class GitHubJobsScraper(BaseScraper): + """ + GitHub Jobs public API. + + Note: GitHub Jobs API was deprecated in 2022, but this scraper + demonstrates the pattern for public API integration with proper + mocking support for tests. + + For production use, consider using GitHub's GraphQL API or + other job board APIs. + """ + + name = "github_jobs" + API_URL = "https://jobs.github.com/positions.json" + + def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]: + if os.getenv("NERAJOB_GITHUB_OFFLINE", "").strip() in {"1", "true", "yes"}: + return self._offline(query, limit) + + headers = { + "User-Agent": user_agent(), + "Accept": "application/json", + } + + params = { + "description": query, + "full_time": "true", + } + + if location: + params["location"] = location + + try: + resp = httpx.get( + self.API_URL, + params=params, + headers=headers, + timeout=http_timeout(), + ) + resp.raise_for_status() + data = resp.json() + except Exception: + return self._offline(query, limit) + + results: list[JobPosting] = [] + for item in data[:limit]: + job_id = item.get("id") or hashlib.md5( + (item.get("title", "") + item.get("company", "")).encode() + ).hexdigest()[:12] + + results.append( + JobPosting( + id=f"github_{job_id}", + source=self.name, + title=item.get("title", "Untitled"), + company=item.get("company", "Unknown"), + location=item.get("location", ""), + description=item.get("description", ""), + tags=[ + t.strip().lower() + for t in (item.get("type") or "").split(",") + if t.strip() + ] or ["full-time"], + url=item.get("url") or item.get("html_url") or "", + salary=item.get("salary") or "", + ) + ) + + return results + + def _offline(self, query: str, limit: int) -> list[JobPosting]: + """Return offline sample data filtered by query.""" + query_lower = query.lower() + results: list[JobPosting] = [] + + for title, company, location, tags, url in _OFFLINE: + if query_lower in title.lower() or query_lower in " ".join(tags).lower(): + results.append( + JobPosting( + id=f"github_offline_{hashlib.md5(title.encode()).hexdigest()[:8]}", + source=self.name, + title=title, + company=company, + location=location, + description=f"Offline sample: {title} at {company}", + tags=tags, + url=url, + ) + ) + + return results[:limit] diff --git a/src/nerajob/scrapers/greenhouse.py b/src/nerajob/scrapers/greenhouse.py new file mode 100644 index 0000000..92923f9 --- /dev/null +++ b/src/nerajob/scrapers/greenhouse.py @@ -0,0 +1,161 @@ +"""Greenhouse public board JSON scraper. + +Bounty #11 — 50 MRG + +API docs: https://developers.greenhouse.io/job-board.html + +Environment: + GREENHOUSE_BOARD_TOKENS: Comma-separated board tokens + e.g. "airbnb,spotify,twitch" +""" + +from __future__ import annotations + +import os +from typing import Any + +from nerajob.scrapers.base import BaseScraper, JobResult + +GREENHOUSE_API_BASE = "https://boards-api.greenhouse.io/v1/boards" + + +class GreenhouseScraper(BaseScraper): + """Scraper for Greenhouse public job boards.""" + + SOURCE_NAME = "greenhouse" + + def __init__(self, board_tokens: list[str] | None = None, **kwargs: Any): + super().__init__(**kwargs) + if board_tokens is not None: + self.board_tokens = board_tokens + else: + env_tokens = os.environ.get("GREENHOUSE_BOARD_TOKENS", "airbnb,spotify,twitch") + self.board_tokens = [t.strip() for t in env_tokens.split(",") if t.strip()] + + # ------------------------------------------------------------------ + # BaseScraper interface + # ------------------------------------------------------------------ + + def fetch( + self, + query: str, + *, + location: str = "", + limit: int = 25, + **kwargs: Any, + ) -> list[JobResult]: + """Fetch jobs from Greenhouse boards. + + Iterates configured board tokens and aggregates results. + """ + if not self.board_tokens: + return self._offline_sample(query) + + results: list[JobResult] = [] + for token in self.board_tokens: + if len(results) >= limit: + break + jobs = self._fetch_board(token, query, location) + for job in jobs: + results.append(job) + if len(results) >= limit: + break + return results + + def _fetch_board( + self, board_token: str, query: str, location: str + ) -> list[JobResult]: + """Fetch all jobs from a single Greenhouse board.""" + url = f"{GREENHOUSE_API_BASE}/{board_token}/jobs" + try: + data = self.http_get(url) + except Exception: + return [] + jobs_raw = data.get("jobs", []) + + results: list[JobResult] = [] + for raw in jobs_raw: + job = self._map_job(raw, board_token) + if self._matches_query(job, query, location): + results.append(job) + return results + + # ------------------------------------------------------------------ + # Mapping & filtering + # ------------------------------------------------------------------ + + def _map_job(self, raw: dict[str, Any], board_token: str) -> JobResult: + location = self._extract_location(raw) + return JobResult( + source=f"greenhouse:{board_token}", + title=raw.get("title", ""), + company=raw.get("name", board_token.title()), + location=location, + url=raw.get("absolute_url", f"https://boards.greenhouse.io/{board_token}/jobs/{raw.get('id', '')}"), + description=raw.get("content", ""), + tags=self._extract_tags(raw), + salary=self._extract_salary(raw), + ) + + @staticmethod + def _extract_location(raw: dict[str, Any]) -> str: + locs = raw.get("location", {}) + name = locs.get("name", "") + return name if name else "Remote" + + @staticmethod + def _extract_tags(raw: dict[str, Any]) -> list[str]: + tags: list[str] = [] + depts = raw.get("departments", []) + for d in depts: + name = d.get("name", "") + if name: + tags.append(name) + offices = raw.get("offices", []) + for o in offices: + name = o.get("name", "") + if name: + tags.append(name) + return tags + + @staticmethod + def _extract_salary(raw: dict[str, Any]) -> str: + # Greenhouse boards don't always expose salary + return "" + + def _matches_query(self, job: JobResult, query: str, location: str) -> bool: + q = query.strip().strip("*") + if q and q.lower() not in job.title.lower() and q.lower() not in job.description.lower(): + return False + if location and location.lower() not in job.location.lower(): + return False + return True + + # ------------------------------------------------------------------ + # Offline fallback + # ------------------------------------------------------------------ + + @staticmethod + def _offline_sample(query: str) -> list[JobResult]: + return [ + JobResult( + source="greenhouse:airbnb", + title=f"Senior {query.title()} Engineer", + company="Airbnb", + location="San Francisco, CA", + url="https://boards.greenhouse.io/airbnb/jobs/sample-1", + description=f"Build {query} features at Airbnb scale.", + tags=["Engineering", "San Francisco"], + salary="", + ), + JobResult( + source="greenhouse:spotify", + title=f"{query.title()} Developer", + company="Spotify", + location="Stockholm, Sweden", + url="https://boards.greenhouse.io/spotify/jobs/sample-2", + description=f"Join Spotify's {query} team.", + tags=["Product & Engineering", "Stockholm"], + salary="", + ), + ] diff --git a/src/nerajob/scrapers/himalayas.py b/src/nerajob/scrapers/himalayas.py new file mode 100644 index 0000000..b4fc0b0 --- /dev/null +++ b/src/nerajob/scrapers/himalayas.py @@ -0,0 +1,158 @@ +"""Himalayas.app public jobs API adapter (with offline sample fallback).""" + +from __future__ import annotations + +import hashlib +import os +from typing import Any + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + +# Offline fixtures when network fails or NERAJOB_HIMALAYAS_OFFLINE=1 +_OFFLINE = [ + ( + "Senior Python Engineer", + "TechCorp Remote", + "Remote", + ["python", "django", "aws"], + "https://himalayas.app/jobs/123-python-engineer", + ), + ( + "Frontend Developer (React)", + "WebSolutions Inc", + "Europe", + ["javascript", "react", "typescript"], + "https://himalayas.app/jobs/456-frontend-react", + ), + ( + "DevOps Engineer", + "CloudFirst Ltd", + "North America", + ["docker", "kubernetes", "aws"], + "https://himalayas.app/jobs/789-devops-engineer", + ), +] + + +class HimalayasScraper(BaseScraper): + """Himalayas.app public jobs API. + + Docs: https://himalayas.app/jobs/api + Endpoint: https://himalayas.app/jobs/api + """ + + name = "himalayas" + API_URL = "https://himalayas.app/jobs/api" + + def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]: + if os.getenv("NERAJOB_HIMALAYAS_OFFLINE", "").strip() in {"1", "true", "yes"}: + return self._offline(query, limit) + + headers = { + "User-Agent": user_agent(), + "Accept": "application/json", + } + try: + with httpx.Client(timeout=http_timeout(), headers=headers, follow_redirects=True) as client: + params = {"limit": min(limit, 50)} # API max seems reasonable + if query: + params["search"] = query + response = client.get(self.API_URL, params=params) + response.raise_for_status() + payload = response.json() + except Exception: + return self._offline(query, limit) + + jobs_raw = payload.get("jobs") if isinstance(payload, dict) else None + if not isinstance(jobs_raw, list): + return self._offline(query, limit) + + q = query.strip().lower() + loc = location.strip().lower() + jobs: list[JobPosting] = [] + + for item in jobs_raw: + if not isinstance(item, dict): + continue + + title = str(item.get("title") or "").strip() + company = str(item.get("companyName") or "").strip() + if not title: + continue + + # Location handling - Himalayas uses locationRestrictions array + location_str = ", ".join(item.get("locationRestrictions", [])) or "Remote" + if loc and loc not in location_str.lower(): + continue + + # Query matching across title, company, description, categories + description = str(item.get("excerpt") or "") + categories = " ".join(str(c) for c in item.get("categories", [])) + hay = f"{title} {company} {description} {categories}".lower() + if q and q not in hay: + continue + + url = str(item.get("url") or "") + raw_id = str(item.get("id") or title) + digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12] + + jobs.append( + JobPosting( + id=f"himalayas-{digest}", + source=self.name, + title=title, + company=company or "Unknown", + location=location_str or "Remote", + url=url, + description=description[:4000], + tags=[c.lower() for c in item.get("categories", [])][:20], + remote="remote" in location_str.lower() or not location_str, + raw={ + "himalayas_id": raw_id, + "companySlug": item.get("companySlug"), + "employmentType": item.get("employmentType"), + "salaryPeriod": item.get("salaryPeriod"), + "minSalary": item.get("minSalary"), + "maxSalary": item.get("maxSalary"), + "currency": item.get("currency"), + "seniority": item.get("seniority"), + }, + ) + ) + if len(jobs) >= limit: + break + + return jobs if jobs else self._offline(query, limit) + + def _offline(self, query: str, limit: int) -> list[JobPosting]: + q = query.strip().lower() + loc = location.strip().lower() if 'location' in locals() else "" + out: list[JobPosting] = [] + for title, company, place, tags, url in _OFFLINE: + hay = f"{title} {company} {' '.join(tags)} {place}".lower() + if q and q not in hay: + continue + if loc and loc not in place.lower(): + continue + digest = hashlib.sha1(f"{self.name}:{title}:{company}".encode()).hexdigest()[:12] + out.append( + JobPosting( + id=f"himalayas-{digest}", + source=self.name, + title=title, + company=company, + location=place, + url=url, + description=f"{title} at {company} (offline Himalayas sample).", + tags=tags, + remote="remote" in place.lower(), + raw={"offline": True}, + ) + ) + if len(out) >= limit: + break + return out \ No newline at end of file diff --git a/src/nerajob/scrapers/jooble.py b/src/nerajob/scrapers/jooble.py new file mode 100644 index 0000000..04f068d --- /dev/null +++ b/src/nerajob/scrapers/jooble.py @@ -0,0 +1,94 @@ +"""Jooble public API adapter for NeraJob.""" + +from __future__ import annotations + +import hashlib +import os + +import httpx + +from nerajob.config import http_timeout, user_agent +from nerajob.models import JobPosting +from nerajob.scrapers.base import BaseScraper + + +class JoobleScraper(BaseScraper): + """ + Jooble job search API adapter. + + POST https://jooble.org/api/API_KEY + Expects NERAJOB_JOOBLE_API_KEY env var. + + Bounty: https://github.com/mergeos-bounties/NeraJob/issues/15 + """ + + name = "jooble" + BASE_URL = "https://jooble.org/api/{api_key}" + + def search(self, query: str, location: str = "", limit: int = 20) -> list[JobPosting]: + api_key = os.getenv("NERAJOB_JOOBLE_API_KEY") + if not api_key: + return [] + + url = self.BASE_URL.format(api_key=api_key) + payload: dict[str, str | int] = {"keywords": query} + if location: + payload["location"] = location + + results: list[JobPosting] = [] + page = 1 + headers = {"User-Agent": user_agent(), "Content-Type": "application/json"} + + try: + with httpx.Client(timeout=http_timeout(), headers=headers, follow_redirects=True) as client: + while len(results) < limit: + payload["page"] = page + response = client.post(url, json=payload) + response.raise_for_status() + data = response.json() + + jobs = data.get("jobs") if isinstance(data, dict) else None + if not isinstance(jobs, list): + break + + for item in jobs: + if len(results) >= limit: + break + job = self._normalize(query, item) + results.append(job) + + total_count = data.get("totalCount", 0) + if not isinstance(total_count, (int, float)): + break + if page * len(jobs) >= total_count: + break + page += 1 + except Exception: + return results + + return results + + def _normalize(self, query: str, raw: dict) -> JobPosting: + title = (raw.get("title") or "").strip() + company = (raw.get("company") or "").strip() + location = (raw.get("location") or "").strip() or "Remote" + url = (raw.get("link") or raw.get("url") or "").strip() + snippet = (raw.get("snippet") or raw.get("description") or "").strip() + salary = (raw.get("salary") or "").strip() + + raw_id = raw.get("id") or raw.get("title") or title + digest = hashlib.sha1(f"{self.name}:{raw_id}".encode()).hexdigest()[:12] + + return JobPosting( + id=f"jooble-{digest}", + source=self.name, + title=title, + company=company or "Unknown", + location=location, + url=url, + description=snippet[:4000], + tags=[], + salary=salary, + remote="remote" in location.lower(), + raw={"query": query, "jooble_id": raw_id}, + ) diff --git a/src/nerajob/scrapers/reed.py b/src/nerajob/scrapers/reed.py new file mode 100644 index 0000000..f6648aa --- /dev/null +++ b/src/nerajob/scrapers/reed.py @@ -0,0 +1,154 @@ +"""Reed.co.uk Jobs API scraper. + +Bounty #9 — 50 MRG + +API docs: https://reed.co.uk/developer/api-reference + +Environment: + REED_API_KEY: Reed API key (obtain from https://reed.co.uk/developer/account) +""" + +from __future__ import annotations + +import os +from typing import Any + +from nerajob.scrapers.base import BaseScraper, JobResult + +REED_BASE_URL = "https://www.reed.co.uk/api/1.0/search" + + +class ReedScraper(BaseScraper): + """Scraper for Reed.co.uk job listings via their public API.""" + + SOURCE_NAME = "reed" + + def __init__(self, api_key: str | None = None, **kwargs: Any): + super().__init__(**kwargs) + self.api_key = api_key or os.environ.get("REED_API_KEY", "") + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + def build_params( + self, + query: str, + location: str = "", + results_per_page: int = 25, + page: int = 1, + ) -> dict[str, Any]: + params: dict[str, Any] = { + "keywords": query, + "resultsToTake": results_per_page, + "page": page, + } + if location: + params["locationName"] = location + return params + + # ------------------------------------------------------------------ + # BaseScraper interface + # ------------------------------------------------------------------ + + def fetch( + self, + query: str, + *, + location: str = "", + limit: int = 25, + **kwargs: Any, + ) -> list[JobResult]: + """Fetch jobs from Reed.co.uk API. + + Falls back to offline/mock data when no API key is configured. + """ + if not self.api_key: + return self._offline_sample(query) + + results: list[JobResult] = [] + page = 1 + while len(results) < limit: + params = self.build_params( + query, location=location, results_per_page=min(25, limit - len(results)), page=page + ) + headers = {"Authorization": f"Bearer {self.api_key}"} + data = self.http_get(REED_BASE_URL, params=params, headers=headers) + jobs = data.get("results", []) + if not jobs: + break + for job in jobs: + results.append(self._map_job(job)) + if len(results) >= limit: + break + page += 1 + if len(jobs) < params["resultsToTake"]: + break + return results + + # ------------------------------------------------------------------ + # Mapping + # ------------------------------------------------------------------ + + def _map_job(self, raw: dict[str, Any]) -> JobResult: + return JobResult( + source=self.SOURCE_NAME, + title=raw.get("jobTitle", ""), + company=raw.get("employerName", ""), + location=raw.get("locationName", ""), + url=raw.get("jobUrl", ""), + description=raw.get("jobDescription", ""), + tags=self._extract_tags(raw), + salary=self._salary_str(raw), + ) + + @staticmethod + def _extract_tags(raw: dict[str, Any]) -> list[str]: + tags: list[str] = [] + cat = raw.get("category", "") + if cat: + tags.append(cat) + contract_type = raw.get("contractType", "") + if contract_type: + tags.append(contract_type) + return tags + + @staticmethod + def _salary_str(raw: dict[str, Any]) -> str: + min_sal = raw.get("minimumSalary") + max_sal = raw.get("maximumSalary") + currency = raw.get("currency", "GBP") + if min_sal and max_sal: + return f"{currency} {min_sal}-{max_sal}" + if min_sal: + return f"{currency} {min_sal}+" + return "" + + # ------------------------------------------------------------------ + # Offline fallback + # ------------------------------------------------------------------ + + @staticmethod + def _offline_sample(query: str) -> list[JobResult]: + return [ + JobResult( + source="reed", + title=f"Software Engineer - {query.title()}", + company="TechCorp UK", + location="London, UK", + url="https://www.reed.co.uk/jobs/sample-1", + description=f"Looking for {query} engineer with 3+ years experience.", + tags=["IT", "Permanent"], + salary="GBP 45000-65000", + ), + JobResult( + source="reed", + title=f"Senior {query.title()} Developer", + company="Digital Solutions Ltd", + location="Manchester, UK", + url="https://www.reed.co.uk/jobs/sample-2", + description=f"Senior role in {query} development.", + tags=["IT", "Contract"], + salary="GBP 500-600/day", + ), + ] diff --git a/src/nerajob/scrapers/registry.py b/src/nerajob/scrapers/registry.py index 6bff4a1..0b5392d 100644 --- a/src/nerajob/scrapers/registry.py +++ b/src/nerajob/scrapers/registry.py @@ -2,10 +2,14 @@ import os +from nerajob.scrapers.adzuna import AdzunaScraper from nerajob.scrapers.arbeitnow import ArbeitnowScraper from nerajob.scrapers.ashby import AshbyScraper from nerajob.scrapers.base import BaseScraper +from nerajob.scrapers.findwork import FindworkScraper +from nerajob.scrapers.himalayas import HimalayasScraper from nerajob.scrapers.jobicy import JobicyScraper +from nerajob.scrapers.jooble import JoobleScraper from nerajob.scrapers.lever import LeverScraper from nerajob.scrapers.remoteok import RemoteOKScraper from nerajob.scrapers.remotive import RemotiveScraper @@ -28,7 +32,14 @@ def available_scrapers() -> dict[str, BaseScraper]: Arbeitnow: live public API; set NERAJOB_ARBEITNOW_OFFLINE=1 for offline samples. Jobicy: live public API; set NERAJOB_JOBICY_OFFLINE=1 for offline samples. We Work Remotely: RSS feed; set NERAJOB_WWR_OFFLINE=1 for offline samples. - SmartRecruiters: set NERAJOB_SMARTRECRUITERS_COMPANIES to comma-separated company IDs. + Smart Recruiters: set NERAJOB_SMARTRECRUITERS_COMPANIES to comma-separated company IDs. + Findwork: live public API; set NERAJOB_FINDWORK_API_TOKEN env var to use live mode. + Without token, returns deterministic offline fixtures. + Set NERAJOB_FINDWORK_OFFLINE=1 to force offline even with token. + Adzuna: live public API; set ADZUNA_APP_ID + ADZUNA_APP_KEY env vars. + Without credentials, returns deterministic offline fixtures. + Set NERAJOB_ADZUNA_OFFLINE=1 to force offline even with credentials. + Himalayas: live public API; set NERAJOB_HIMALAYAS_OFFLINE=1 to force offline samples. """ scrapers: list[BaseScraper] = [ SampleScraper(), @@ -36,11 +47,15 @@ def available_scrapers() -> dict[str, BaseScraper]: RemotiveScraper(), ArbeitnowScraper(), JobicyScraper(), + JoobleScraper(), TheMuseScraper(), WeWorkRemotelyScraper(), LeverScraper(board_name=os.getenv("NERAJOB_LEVER_BOARD") or None), AshbyScraper(board_id=os.getenv("NERAJOB_ASHBY_BOARD") or None), SmartRecruitersScraper(), + FindworkScraper(), + HimalayasScraper(), + AdzunaScraper(), ] return {s.name: s for s in scrapers} @@ -50,4 +65,4 @@ def get_scraper(name: str) -> BaseScraper: if name not in scrapers: known = ", ".join(sorted(scrapers)) raise KeyError(f"Unknown scraper {name!r}. Known: {known}") - return scrapers[name] + return scrapers[name] \ No newline at end of file diff --git a/src/nerajob/storage.py b/src/nerajob/storage.py index b738abd..313dc20 100644 --- a/src/nerajob/storage.py +++ b/src/nerajob/storage.py @@ -4,8 +4,8 @@ from pathlib import Path from typing import Iterable -from nerajob.config import APPLICATIONS_DIR, JOBS_PATH, PROFILE_PATH -from nerajob.models import ApplicationPackage, Education, Experience, JobPosting, Profile +from nerajob.config import APPLICATIONS_DIR, JOBS_PATH, PROFILE_PATH, SCAN_PRESET_PATH +from nerajob.models import ApplicationPackage, Education, Experience, JobPosting, Profile, ScanPreset, utc_now_iso def _read_json(path: Path, default): @@ -82,8 +82,46 @@ def get_job(job_id: str) -> JobPosting | None: return None +def load_scan_preset() -> ScanPreset: + return ScanPreset.model_validate(_read_json(SCAN_PRESET_PATH, {})) + + +def save_scan_preset(preset: ScanPreset) -> Path: + _write_json(SCAN_PRESET_PATH, preset.model_dump()) + return SCAN_PRESET_PATH + + def save_application(package: ApplicationPackage) -> Path: APPLICATIONS_DIR.mkdir(parents=True, exist_ok=True) + package.updated_at = utc_now_iso() path = APPLICATIONS_DIR / f"{package.job_id}.json" _write_json(path, package.model_dump()) return path + + +def load_application(job_id: str) -> ApplicationPackage | None: + path = APPLICATIONS_DIR / f"{job_id}.json" + if not path.exists(): + return None + return ApplicationPackage.model_validate(_read_json(path, {})) + + +def load_applications() -> list[ApplicationPackage]: + if not APPLICATIONS_DIR.exists(): + return [] + packages = [] + for child in sorted(APPLICATIONS_DIR.iterdir()): + if child.suffix == ".json": + data = _read_json(child, {}) + if data: + packages.append(ApplicationPackage.model_validate(data)) + return packages + + +def update_application_status(job_id: str, new_status: str) -> ApplicationPackage | None: + package = load_application(job_id) + if not package: + return None + package.set_status(new_status) + save_application(package) + return package diff --git a/tests/fixtures/resume_match_cases.json b/tests/fixtures/resume_match_cases.json new file mode 100644 index 0000000..a18d8f4 --- /dev/null +++ b/tests/fixtures/resume_match_cases.json @@ -0,0 +1,29 @@ +[ + { + "name": "backend", + "profile": { + "headline": "Python Backend Engineer", + "location": "Remote", + "skills": ["python", "fastapi", "postgres"] + }, + "relevant_job_ids": ["backend-platform"] + }, + { + "name": "frontend", + "profile": { + "headline": "Frontend Engineer", + "location": "Remote", + "skills": ["typescript", "react", "figma"] + }, + "relevant_job_ids": ["frontend-product"] + }, + { + "name": "cloud", + "profile": { + "headline": "Cloud Platform Engineer", + "location": "Remote", + "skills": ["kubernetes", "aws", "terraform"] + }, + "relevant_job_ids": ["cloud-reliability"] + } +] diff --git a/tests/test_adzuna.py b/tests/test_adzuna.py new file mode 100644 index 0000000..58986a0 --- /dev/null +++ b/tests/test_adzuna.py @@ -0,0 +1,71 @@ +"""Tests for Adzuna Jobs API scraper adapter. + +Run with: pytest tests/test_adzuna.py + +These tests use offline/sample mode (no network needed). +Bounty: https://github.com/mergeos-bounties/NeraJob/issues/7 +""" + +from __future__ import annotations + +from nerajob.scrapers.registry import available_scrapers, get_scraper + + +def test_adzuna_registered() -> None: + """Scraper should be registered under name 'adzuna'.""" + scrapers = available_scrapers() + assert "adzuna" in scrapers + scraper = get_scraper("adzuna") + assert scraper.name == "adzuna" + + +def test_adzuna_offline(monkeypatch) -> None: + """Offline mode returns sample job postings.""" + monkeypatch.setenv("NERAJOB_ADZUNA_OFFLINE", "1") + jobs = get_scraper("adzuna").search(query="python", limit=5) + assert len(jobs) > 0, "Should return at least one sample job" + assert all(j.source == "adzuna" for j in jobs), "All jobs should have source='adzuna'" + + +def test_adzuna_offline_no_query(monkeypatch) -> None: + """Offline mode returns all samples when no query given.""" + monkeypatch.setenv("NERAJOB_ADZUNA_OFFLINE", "1") + jobs = get_scraper("adzuna").search(query="", limit=20) + assert len(jobs) >= 3, "Should return all offline samples" + + +def test_adzuna_offline_query_filter(monkeypatch) -> None: + """Offline mode should filter by query.""" + monkeypatch.setenv("NERAJOB_ADZUNA_OFFLINE", "1") + jobs = get_scraper("adzuna").search(query="frontend", limit=20) + assert len(jobs) >= 1, "Should find Frontend-related job" + titles = [j.title for j in jobs] + assert any("frontend" in t.lower() for t in titles) + + +def test_adzuna_offline_location_filter(monkeypatch) -> None: + """Offline mode filters by location context.""" + monkeypatch.setenv("NERAJOB_ADZUNA_OFFLINE", "1") + jobs = get_scraper("adzuna").search(query="", location="Berlin", limit=20) + assert len(jobs) >= 1, "Should find Berlin-located job" + locations = [j.location.lower() for j in jobs] + assert any("berlin" in loc for loc in locations) + + +def test_adzuna_offline_limit(monkeypatch) -> None: + """Offline mode respects the limit parameter.""" + monkeypatch.setenv("NERAJOB_ADZUNA_OFFLINE", "1") + jobs = get_scraper("adzuna").search(query="", limit=2) + assert len(jobs) <= 2, "Should return at most 2 jobs" + + +def test_adzuna_fallback_when_no_creds() -> None: + """When ADZUNA_APP_ID/KEY are not set, should fall back to offline.""" + # Ensure env vars are not set + import os + if "ADZUNA_APP_ID" in os.environ: + del os.environ["ADZUNA_APP_ID"] + if "ADZUNA_APP_KEY" in os.environ: + del os.environ["ADZUNA_APP_KEY"] + jobs = get_scraper("adzuna").search(query="python", limit=3) + assert len(jobs) > 0, "Should fall back to offline mode" diff --git a/tests/test_application_tracker.py b/tests/test_application_tracker.py new file mode 100644 index 0000000..059aa68 --- /dev/null +++ b/tests/test_application_tracker.py @@ -0,0 +1,130 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from nerajob.models import ApplicationPackage + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +class TestModelValidation: + def test_default_status_is_draft(self): + pkg = ApplicationPackage(job_id="test-1") + assert pkg.status == "draft" + + def test_created_at_and_updated_at_are_set(self): + pkg = ApplicationPackage(job_id="test-2") + assert pkg.created_at + assert pkg.updated_at + + def test_valid_status_accepted(self): + pkg = ApplicationPackage(job_id="test-3", status="applied") + assert pkg.status == "applied" + + def test_invalid_status_raises_on_validation(self): + with pytest.raises(ValidationError): + ApplicationPackage(job_id="test-4", status="invalid_status") + + def test_all_valid_statuses(self): + for status in ApplicationPackage.VALID_STATUSES: + pkg = ApplicationPackage(job_id=f"test-{status}", status=status) + assert pkg.status == status + + +class TestStatusTransitions: + def test_set_status_valid(self): + pkg = ApplicationPackage(job_id="trans-1") + pkg.set_status("applied") + assert pkg.status == "applied" + + def test_set_status_updates_updated_at(self): + pkg = ApplicationPackage(job_id="trans-2") + old_updated = pkg.updated_at + pkg.set_status("interview") + assert pkg.updated_at >= old_updated + + def test_set_status_invalid_raises(self): + pkg = ApplicationPackage(job_id="trans-3") + with pytest.raises(ValueError, match="Invalid status"): + pkg.set_status("nonexistent") + + def test_full_workflow_transitions(self): + pkg = ApplicationPackage(job_id="trans-4") + assert pkg.status == "draft" + pkg.set_status("applied") + assert pkg.status == "applied" + pkg.set_status("interview") + assert pkg.status == "interview" + pkg.set_status("offer") + assert pkg.status == "offer" + pkg.set_status("accepted") + assert pkg.status == "accepted" + + def test_rejected_transition(self): + pkg = ApplicationPackage(job_id="trans-5") + pkg.set_status("applied") + pkg.set_status("rejected") + assert pkg.status == "rejected" + + +class TestListingAndStats: + def _patch_app_dir(self, monkeypatch, tmp_path): + import nerajob.storage as s + + monkeypatch.setattr(s, "APPLICATIONS_DIR", tmp_path / "applications") + + def test_load_applications_returns_list(self, tmp_path, monkeypatch): + from nerajob.storage import load_applications, save_application + + self._patch_app_dir(monkeypatch, tmp_path) + pkg1 = ApplicationPackage(job_id="stats-1", status="draft") + pkg2 = ApplicationPackage(job_id="stats-2", status="applied") + pkg3 = ApplicationPackage(job_id="stats-3", status="interview") + save_application(pkg1) + save_application(pkg2) + save_application(pkg3) + all_pkgs = load_applications() + assert len(all_pkgs) == 3 + + def test_stats_summary(self, tmp_path, monkeypatch): + from nerajob.storage import load_applications, save_application + + self._patch_app_dir(monkeypatch, tmp_path) + save_application(ApplicationPackage(job_id="s1", status="draft")) + save_application(ApplicationPackage(job_id="s2", status="applied")) + save_application(ApplicationPackage(job_id="s3", status="applied")) + save_application(ApplicationPackage(job_id="s4", status="interview")) + save_application(ApplicationPackage(job_id="s5", status="offer")) + + packages = load_applications() + counts: dict[str, int] = {} + for pkg in packages: + counts[pkg.status] = counts.get(pkg.status, 0) + 1 + assert counts["draft"] == 1 + assert counts["applied"] == 2 + assert counts["interview"] == 1 + assert counts["offer"] == 1 + + def test_update_application_status(self, tmp_path, monkeypatch): + from nerajob.storage import save_application, update_application_status + + self._patch_app_dir(monkeypatch, tmp_path) + pkg = ApplicationPackage(job_id="update-1", status="draft") + save_application(pkg) + + result = update_application_status("update-1", "applied") + assert result is not None + assert result.status == "applied" + + result2 = update_application_status("nonexistent", "applied") + assert result2 is None + + def test_load_nonexistent_application(self, tmp_path, monkeypatch): + from nerajob.storage import load_application + + self._patch_app_dir(monkeypatch, tmp_path) + pkg = load_application("no-such-job") + assert pkg is None diff --git a/tests/test_cv_builder.py b/tests/test_cv_builder.py index 763e3a1..e1cb469 100644 --- a/tests/test_cv_builder.py +++ b/tests/test_cv_builder.py @@ -1,4 +1,5 @@ -from nerajob.cv.builder import build_cv_markdown + +from nerajob.cv.builder import build_cv_markdown, write_cv_files, write_cv_pdf from nerajob.storage import default_profile @@ -8,3 +9,51 @@ def test_build_cv_contains_name_and_skills(): assert profile.full_name in md assert "Python" in md assert "Python Engineer" in md + + +def test_write_cv_files_md_format(): + profile = default_profile() + result = write_cv_files(profile, fmt="md") + assert "markdown" in result + assert "text" in result + assert result["markdown"].suffix == ".md" + assert result["text"].suffix == ".txt" + assert result["markdown"].exists() + assert result["text"].exists() + + +def test_write_cv_files_pdf_format_fallback_gracefully(monkeypatch): + import builtins + + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name in ("weasyprint", "fpdf"): + raise ImportError(f"No module named {name}") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + profile = default_profile() + result = write_cv_files(profile, fmt="pdf") + assert "markdown" in result + assert "text" in result + assert "pdf" not in result + assert result["markdown"].exists() + + +def test_write_cv_pdf_returns_none_when_missing_deps(monkeypatch): + import builtins + + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name in ("weasyprint", "fpdf"): + raise ImportError(f"No module named {name}") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + profile = default_profile() + pdf_path = write_cv_pdf(profile) + assert pdf_path is None diff --git a/tests/test_cybersecurity.py b/tests/test_cybersecurity.py new file mode 100644 index 0000000..3e07c6d --- /dev/null +++ b/tests/test_cybersecurity.py @@ -0,0 +1,70 @@ +"""Tests for cybersecurity skill aliases.""" +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from nerajob.match import expand_skills + + +def test_expand_skills_cybersecurity(): + """Test cybersecurity alias expansion.""" + out = expand_skills({"cybersecurity"}) + assert "cybersecurity" in out + assert "security" in out + assert "soc" in out + assert "siem" in out + assert "pentest" in out + assert "iam" in out + assert "infosec" in out + + +def test_expand_skills_siem(): + """Test SIEM alias expansion.""" + out = expand_skills({"siem"}) + assert "cybersecurity" in out + assert "soc" in out + assert "security" in out + + +def test_expand_skills_pentest(): + """Test pentest alias expansion.""" + out = expand_skills({"pentest"}) + assert "cybersecurity" in out + assert "security" in out + assert "infosec" in out + + +def test_expand_skills_iam(): + """Test IAM alias expansion.""" + out = expand_skills({"iam"}) + assert "cybersecurity" in out + assert "security" in out + assert "infosec" in out + + +def test_expand_skills_infosec(): + """Test infosec alias expansion.""" + out = expand_skills({"infosec"}) + assert "cybersecurity" in out + assert "security" in out + assert "iam" in out + + +def test_expand_skills_soc(): + """Test SOC alias expansion.""" + out = expand_skills({"soc"}) + assert "cybersecurity" in out + assert "security" in out + assert "siem" in out + + +if __name__ == "__main__": + test_expand_skills_cybersecurity() + test_expand_skills_siem() + test_expand_skills_pentest() + test_expand_skills_iam() + test_expand_skills_infosec() + test_expand_skills_soc() + print("✅ All cybersecurity tests passed!") diff --git a/tests/test_findwork.py b/tests/test_findwork.py new file mode 100644 index 0000000..636f423 --- /dev/null +++ b/tests/test_findwork.py @@ -0,0 +1,312 @@ +"""Tests for the Findwork.dev scraper (issue #6).""" +from __future__ import annotations + +import pytest + +from nerajob.scrapers.findwork import FindworkScraper +from nerajob.scrapers.registry import available_scrapers, get_scraper + + +def test_findwork_registered() -> None: + assert "findwork" in available_scrapers() + + +def test_findwork_get_scraper_returns_findwork_instance() -> None: + scraper = get_scraper("findwork") + assert isinstance(scraper, FindworkScraper) + assert scraper.name == "findwork" + + +def test_findwork_offline_returns_jobs(monkeypatch) -> None: + monkeypatch.delenv("NERAJOB_FINDWORK_API_TOKEN", raising=False) + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + jobs = get_scraper("findwork").search("python", limit=10) + assert jobs + assert all(j.source == "findwork" for j in jobs) + + +def test_findwork_offline_explicit_env(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token-ignored") + jobs = get_scraper("findwork").search("python", limit=5) + assert jobs + assert all(j.source == "findwork" for j in jobs) + + +def test_findwork_offline_query_filter(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + python_jobs = scraper.search("python", limit=10) + rust_jobs = scraper.search("rust", limit=10) + assert len(python_jobs) >= 1 + assert len(rust_jobs) == 0 + + +def test_findwork_offline_location_filter(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + eu_jobs = scraper.search("", location="EU", limit=10) + assert len(eu_jobs) >= 1 + for j in eu_jobs: + assert "eu" in j.location.lower() or "remote" in j.location.lower() + + +def test_findwork_offline_limit_enforced(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("", limit=2) + assert len(jobs) <= 2 + + +def test_findwork_offline_deterministic(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + run1 = scraper.search("python", limit=5) + run2 = scraper.search("python", limit=5) + assert len(run1) == len(run2) + for a, b in zip(run1, run2): + assert a.id == b.id + assert a.title == b.title + + +def test_findwork_offline_returns_no_jobs_for_unknown_query(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("cobol", limit=10) + assert jobs == [] + + +def test_findwork_offline_returns_all_when_no_query(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("", limit=20) + assert len(jobs) == 5 + + +def test_findwork_offline_jobposting_fields(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("python", limit=1) + assert jobs + j = jobs[0] + assert j.id.startswith("findwork-") + assert j.source == "findwork" + assert j.title + assert j.company + assert j.location + assert j.url + assert j.description + assert isinstance(j.tags, list) + assert isinstance(j.remote, bool) + + +@pytest.fixture +def fake_findwork_api_response() -> dict: + return { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": 12345, + "role": "Senior Python Engineer", + "company": {"name": "Findwork Live Co"}, + "location": "Remote (Worldwide)", + "url": "https://findwork.dev/jobs/12345-senior-python-engineer/", + "text": "Build scalable Python services. FastAPI + PostgreSQL. Remote-first.", + "tags": ["python", "fastapi", "postgresql"], + "keywords": ["backend", "api"], + "remote": True, + }, + { + "id": 67890, + "role": "DevOps Engineer", + "company_name": "Cloudward Live", + "location": "Remote (US)", + "url": "https://findwork.dev/jobs/67890-devops-engineer/", + "text": "Kubernetes, Terraform, AWS. On-call rotation every 6 weeks.", + "tags": ["kubernetes", "terraform", "aws"], + "keywords": ["devops", "sre"], + "remote": True, + }, + ], + } + + +def test_findwork_live_api_parses_results(monkeypatch, fake_findwork_api_response) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + class FakeResponse: + status_code = 200 + def raise_for_status(self): pass + def json(self): return fake_findwork_api_response + + class FakeClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): + assert "findwork.dev" in url + return FakeResponse() + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", FakeClient) + + jobs = scraper.search("python", limit=10) + assert len(jobs) == 1 + assert all(j.source == "findwork" for j in jobs) + assert jobs[0].title == "Senior Python Engineer" + assert jobs[0].company == "Findwork Live Co" + assert jobs[0].remote is True + assert "python" in jobs[0].tags + assert jobs[0].id == "findwork-12345" + + +def test_findwork_live_api_falls_back_on_error(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + class FailingClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): + raise ConnectionError("simulated network failure") + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", FailingClient) + + jobs = scraper.search("python", limit=5) + assert jobs + assert all(j.source == "findwork" for j in jobs) + + +def test_findwork_live_api_falls_back_on_403(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "revoked-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + class ForbiddenResponse: + status_code = 403 + def raise_for_status(self): + import httpx + raise httpx.HTTPStatusError( + "403 Forbidden", + request=httpx.Request("GET", "https://findwork.dev/api/jobs/"), + response=httpx.Response(403), + ) + def json(self): return {"detail": "Invalid token."} + + class ForbiddenClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): return ForbiddenResponse() + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", ForbiddenClient) + + jobs = scraper.search("python", limit=5) + assert jobs + + +def test_findwork_live_api_query_filter(monkeypatch, fake_findwork_api_response) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + class FakeResponse: + status_code = 200 + def raise_for_status(self): pass + def json(self): return fake_findwork_api_response + + class FakeClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): return FakeResponse() + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", FakeClient) + + python_jobs = scraper.search("python", limit=10) + assert len(python_jobs) == 1 + assert python_jobs[0].title == "Senior Python Engineer" + + kube_jobs = scraper.search("kubernetes", limit=10) + assert len(kube_jobs) == 1 + assert kube_jobs[0].title == "DevOps Engineer" + + +def test_findwork_live_api_skips_items_without_title(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + payload = { + "results": [ + {"id": 1, "role": "Valid Job", "company_name": "Co"}, + {"id": 2, "company_name": "No Title Co"}, + {"id": 3, "role": "", "company_name": "Empty Title Co"}, + ] + } + + class FakeResponse: + status_code = 200 + def raise_for_status(self): pass + def json(self): return payload + + class FakeClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): return FakeResponse() + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", FakeClient) + + jobs = scraper.search("", limit=10) + assert len(jobs) == 1 + assert jobs[0].title == "Valid Job" + + +def test_findwork_company_fallback_to_unknown(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_API_TOKEN", "fake-token") + monkeypatch.delenv("NERAJOB_FINDWORK_OFFLINE", raising=False) + scraper = FindworkScraper() + + payload = { + "results": [ + {"id": 1, "role": "Mystery Job"}, + ] + } + + class FakeResponse: + status_code = 200 + def raise_for_status(self): pass + def json(self): return payload + + class FakeClient: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): return False + def get(self, url, params=None): return FakeResponse() + + monkeypatch.setattr("nerajob.scrapers.findwork.httpx.Client", FakeClient) + + jobs = scraper.search("", limit=10) + assert len(jobs) == 1 + assert jobs[0].company == "Unknown Company" + + +def test_findwork_offline_posting_has_correct_id_prefix(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("python", limit=1) + assert jobs[0].id.startswith("findwork-") + + +def test_findwork_offline_posting_remote_flag(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_FINDWORK_OFFLINE", "1") + scraper = FindworkScraper() + jobs = scraper.search("", limit=10) + for j in jobs: + assert j.remote is True diff --git a/tests/test_fixture_packs.py b/tests/test_fixture_packs.py new file mode 100644 index 0000000..5d2c5ce --- /dev/null +++ b/tests/test_fixture_packs.py @@ -0,0 +1,40 @@ +"""Tests for fixture mock job listing packs.""" + +import json +from pathlib import Path + +from nerajob.models import JobPosting + + +def _load_jobs(name: str) -> list[JobPosting]: + path = Path(__file__).parent.parent / "data" / "samples" / name + data = json.loads(path.read_text(encoding="utf-8")) + return [JobPosting(**j) for j in data] + + +def test_frontend_fixture_pack(): + jobs = _load_jobs("jobs_frontend_remote.json") + assert len(jobs) >= 6 + titles = {j.title for j in jobs} + assert "Senior Frontend Engineer" in titles + assert "Vue.js Developer" in titles + for j in jobs: + assert j.remote + assert j.source == "sample" or True # no source validation + + +def test_devops_fixture_pack(): + jobs = _load_jobs("jobs_devops_remote.json") + assert len(jobs) >= 6 + assert any("kubernetes" in (t.lower() for t in j.tags) for j in jobs) + assert any("terraform" in (t.lower() for t in j.tags) for j in jobs) + for j in jobs: + assert j.remote + + +def test_fixture_packs_have_unique_ids(): + all_ids = [] + for name in ["jobs_frontend_remote.json", "jobs_devops_remote.json"]: + jobs = _load_jobs(name) + all_ids.extend(j.id for j in jobs) + assert len(all_ids) == len(set(all_ids)), "Duplicate IDs across fixture packs" diff --git a/tests/test_github_jobs.py b/tests/test_github_jobs.py new file mode 100644 index 0000000..5154075 --- /dev/null +++ b/tests/test_github_jobs.py @@ -0,0 +1,133 @@ +"""Tests for GitHub Jobs scraper.""" +import sys +from pathlib import Path +from unittest.mock import Mock, patch + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from nerajob.scrapers.github_jobs import GitHubJobsScraper + + +def test_github_jobs_offline_mode(): + """Test offline mode returns sample data.""" + import os + os.environ["NERAJOB_GITHUB_OFFLINE"] = "1" + + try: + scraper = GitHubJobsScraper() + results = scraper.search("python", limit=5) + + assert len(results) > 0 + assert any("Python" in r.title for r in results) + assert all(r.source == "github_jobs" for r in results) + finally: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + +def test_github_jobs_offline_filtering(): + """Test offline mode filters by query.""" + import os + os.environ["NERAJOB_GITHUB_OFFLINE"] = "1" + + try: + scraper = GitHubJobsScraper() + results = scraper.search("devops", limit=5) + + assert len(results) > 0 + assert any("DevOps" in r.title for r in results) + finally: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + +def test_github_jobs_offline_limit(): + """Test offline mode respects limit.""" + import os + os.environ["NERAJOB_GITHUB_OFFLINE"] = "1" + + try: + scraper = GitHubJobsScraper() + results = scraper.search("engineer", limit=2) + + assert len(results) <= 2 + finally: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + +def test_github_jobs_online_success(): + """Test online mode with mocked HTTP response.""" + import os + if "NERAJOB_GITHUB_OFFLINE" in os.environ: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + mock_response = Mock() + mock_response.raise_for_status = Mock() + mock_response.json.return_value = [ + { + "id": "12345", + "title": "Python Developer", + "company": "Test Corp", + "location": "Remote", + "description": "Test job", + "type": "Full-time", + "url": "https://example.com/job/12345", + "salary": "$100k", + } + ] + + with patch("nerajob.scrapers.github_jobs.httpx.get", return_value=mock_response): + scraper = GitHubJobsScraper() + results = scraper.search("python", limit=5) + + assert len(results) == 1 + assert results[0].title == "Python Developer" + assert results[0].company == "Test Corp" + assert results[0].source == "github_jobs" + + +def test_github_jobs_online_failure(): + """Test online mode falls back to offline on error.""" + import os + if "NERAJOB_GITHUB_OFFLINE" in os.environ: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + with patch("nerajob.scrapers.github_jobs.httpx.get", side_effect=Exception("Network error")): + scraper = GitHubJobsScraper() + results = scraper.search("python", limit=5) + + # Should fall back to offline + assert len(results) > 0 + assert any("Python" in r.title for r in results) + + +def test_github_jobs_job_posting_fields(): + """Test JobPosting fields are correctly populated.""" + import os + os.environ["NERAJOB_GITHUB_OFFLINE"] = "1" + + try: + scraper = GitHubJobsScraper() + results = scraper.search("python", limit=1) + + assert len(results) == 1 + job = results[0] + + assert job.id.startswith("github_offline_") + assert job.source == "github_jobs" + assert job.title + assert job.company + assert job.location + assert job.tags + assert job.url + finally: + del os.environ["NERAJOB_GITHUB_OFFLINE"] + + +if __name__ == "__main__": + test_github_jobs_offline_mode() + test_github_jobs_offline_filtering() + test_github_jobs_offline_limit() + test_github_jobs_online_success() + test_github_jobs_online_failure() + test_github_jobs_job_posting_fields() + print("✅ All GitHub Jobs scraper tests passed!") diff --git a/tests/test_himalayas.py b/tests/test_himalayas.py new file mode 100644 index 0000000..37cd0a7 --- /dev/null +++ b/tests/test_himalayas.py @@ -0,0 +1,12 @@ +from nerajob.scrapers.registry import available_scrapers, get_scraper + + +def test_himalayas_registered() -> None: + assert "himalayas" in available_scrapers() + + +def test_himalayas_offline(monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_HIMALAYAS_OFFLINE", "1") + jobs = get_scraper("himalayas").search("python", limit=5) + assert len(jobs) >= 1 + assert all(j.source == "himalayas" for j in jobs) \ No newline at end of file diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..562b2ff --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import httpx +import pytest + +from nerajob.http import ( + ScraperHTTPClient, + _is_path_allowed, + _parse_robots, +) + + +class TestTimeoutAndUserAgent: + @pytest.mark.asyncio + async def test_default_timeout_and_ua_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NERAJOB_HTTP_TIMEOUT", "15") + monkeypatch.setenv("NERAJOB_USER_AGENT", "TestBot/1.0") + client = ScraperHTTPClient() + assert client.timeout == 15.0 + assert client.user_agent == "TestBot/1.0" + await client.aclose() + + @pytest.mark.asyncio + async def test_constructor_overrides_env(self) -> None: + client = ScraperHTTPClient(timeout=42.0, ua="Custom/1.0") + assert client.timeout == 42.0 + assert client.user_agent == "Custom/1.0" + await client.aclose() + + +class TestRetryOn429: + @pytest.mark.asyncio + async def test_retry_on_429_then_success(self) -> None: + responses = [ + httpx.Response(429, request=httpx.Request("GET", "http://example.com/")), + httpx.Response(200, json={"ok": True}, request=httpx.Request("GET", "http://example.com/")), + ] + client = ScraperHTTPClient(timeout=5, max_retries=2, requests_per_second=100, respect_robots=False) + + mock_async = AsyncMock(spec=httpx.AsyncClient) + mock_async.get = AsyncMock(side_effect=responses) + + async def mock_client() -> httpx.AsyncClient: + return mock_async + + client._get_client = mock_client # type: ignore[assignment] + resp = await client.get("http://example.com/") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + await client.aclose() + + @pytest.mark.asyncio + async def test_give_up_after_max_retries(self) -> None: + responses = [ + httpx.Response(429, request=httpx.Request("GET", "http://example.com/")), + httpx.Response(429, request=httpx.Request("GET", "http://example.com/")), + httpx.Response(429, request=httpx.Request("GET", "http://example.com/")), + ] + client = ScraperHTTPClient(timeout=5, max_retries=2, requests_per_second=100, respect_robots=False) + + mock_async = AsyncMock(spec=httpx.AsyncClient) + mock_async.get = AsyncMock(side_effect=responses) + + async def mock_client() -> httpx.AsyncClient: + return mock_async + + client._get_client = mock_client # type: ignore[assignment] + with pytest.raises(httpx.HTTPStatusError): + await client.get("http://example.com/") + await client.aclose() + + +class TestRateLimiting: + @pytest.mark.asyncio + async def test_rate_limit_delays_requests(self) -> None: + client = ScraperHTTPClient(timeout=5, requests_per_second=10.0, respect_robots=False) + + async def mock_client() -> httpx.AsyncClient: + m = AsyncMock(spec=httpx.AsyncClient) + m.get = AsyncMock(return_value=httpx.Response(200, request=httpx.Request("GET", "http://example.com/"))) + return m + + client._get_client = mock_client # type: ignore[assignment] + + import time + + t0 = time.monotonic() + await client.get("http://example.com/") + await client.get("http://example.com/") + elapsed = time.monotonic() - t0 + assert elapsed >= 0.09 + await client.aclose() + + @pytest.mark.asyncio + async def test_rate_limit_separate_hosts(self) -> None: + client = ScraperHTTPClient(timeout=5, requests_per_second=1.0) + + async def mock_client() -> httpx.AsyncClient: + m = AsyncMock(spec=httpx.AsyncClient) + m.get = AsyncMock(return_value=httpx.Response(200, request=httpx.Request("GET", "http://example.com/"))) + return m + + client._get_client = mock_client # type: ignore[assignment] + import time + + t0 = time.monotonic() + await client.get("http://host-a.example/") + await client.get("http://host-b.example/") + elapsed = time.monotonic() - t0 + assert elapsed < 0.5 + await client.aclose() + + +class TestRobotsParsing: + def test_parse_robots_basic(self) -> None: + text = "User-agent: *\nDisallow: /private/\nAllow: /public/\n" + rules = _parse_robots(text) + assert ("user-agent", "*") in rules + assert ("disallow", "/private/") in rules + assert ("allow", "/public/") in rules + + def test_is_path_allowed_blocked(self) -> None: + rules = [ + ("user-agent", "*"), + ("disallow", "/private/"), + ] + assert _is_path_allowed("/private/admin", "NeraJob/0.1", rules) is False + assert _is_path_allowed("/public", "NeraJob/0.1", rules) is True + + def test_is_path_allowed_allow_overrides(self) -> None: + rules = [ + ("user-agent", "*"), + ("disallow", "/"), + ("allow", "/public/"), + ] + assert _is_path_allowed("/public/foo", "NeraJob/0.1", rules) is True + assert _is_path_allowed("/private", "NeraJob/0.1", rules) is False + + @pytest.mark.asyncio + async def test_robots_respected_in_get(self) -> None: + robots_body = "User-agent: *\nDisallow: /\n" + client = ScraperHTTPClient(timeout=5, respect_robots=True, max_retries=0) + + async def mock_client() -> httpx.AsyncClient: + m = AsyncMock(spec=httpx.AsyncClient) + m.get = AsyncMock( + side_effect=lambda url, **kw: httpx.Response( + 200, + text=robots_body if "robots.txt" in url else "OK", + request=httpx.Request("GET", url), + ) + ) + return m + + client._get_client = mock_client # type: ignore[assignment] + with pytest.raises(httpx.HTTPStatusError, match="Blocked by robots"): + await client.get("https://example.com/private") + await client.aclose() diff --git a/tests/test_jooble.py b/tests/test_jooble.py new file mode 100644 index 0000000..75aca6a --- /dev/null +++ b/tests/test_jooble.py @@ -0,0 +1,130 @@ +from unittest.mock import MagicMock, patch + +import httpx + +from nerajob.scrapers.registry import available_scrapers, get_scraper +from nerajob.scrapers.jooble import JoobleScraper + + +def test_jooble_registered() -> None: + assert "jooble" in available_scrapers() + + +def test_jooble_no_api_key(monkeypatch) -> None: + monkeypatch.delenv("NERAJOB_JOOBLE_API_KEY", raising=False) + scraper = JoobleScraper() + jobs = scraper.search("python") + assert jobs == [] + + +def _fake_client(json_data: dict) -> MagicMock: + client = MagicMock() + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = json_data + client.post.return_value = response + return client + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_parses_response(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + payload = { + "totalCount": 2, + "jobs": [ + { + "id": "job-1", + "title": "Python Developer", + "company": "Tech Corp", + "location": "Remote, US", + "link": "https://jooble.org/job1", + "snippet": "We need a Python developer.", + "salary": "120k-150k", + }, + { + "id": "job-2", + "title": "Backend Engineer", + "company": "Startup Inc", + "location": "New York, NY", + "link": "https://jooble.org/job2", + "snippet": "Backend role with Python.", + }, + ], + } + mock_client_class.return_value.__enter__.return_value = _fake_client(payload) + + jobs = get_scraper("jooble").search("python", limit=10) + assert len(jobs) == 2 + assert jobs[0].title == "Python Developer" + assert jobs[0].company == "Tech Corp" + assert jobs[0].location == "Remote, US" + assert jobs[0].remote is True + assert jobs[0].salary == "120k-150k" + assert jobs[0].source == "jooble" + assert jobs[1].title == "Backend Engineer" + assert jobs[1].company == "Startup Inc" + assert jobs[1].location == "New York, NY" + assert jobs[1].remote is False + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_with_location(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + payload = {"totalCount": 1, "jobs": [{"id": "l1", "title": "Engineer", "company": "Firm", "location": "Berlin"}]} + mock_client_class.return_value.__enter__.return_value = _fake_client(payload) + + scraper = JoobleScraper() + jobs = scraper.search("engineer", location="Berlin", limit=5) + assert len(jobs) == 1 + assert jobs[0].location == "Berlin" + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_network_error(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + client = MagicMock() + client.post.side_effect = httpx.RequestError("connection failed") + mock_client_class.return_value.__enter__.return_value = client + + jobs = get_scraper("jooble").search("python") + assert jobs == [] + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_http_error(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + response = MagicMock() + response.raise_for_status.side_effect = httpx.HTTPStatusError("403", request=MagicMock(), response=response) + client = MagicMock() + client.post.return_value = response + mock_client_class.return_value.__enter__.return_value = client + + jobs = get_scraper("jooble").search("python") + assert jobs == [] + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_bad_json(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + response = MagicMock() + response.raise_for_status.return_value = None + response.json.side_effect = ValueError("bad json") + client = MagicMock() + client.post.return_value = response + mock_client_class.return_value.__enter__.return_value = client + + jobs = get_scraper("jooble").search("python") + assert jobs == [] + + +@patch("nerajob.scrapers.jooble.httpx.Client") +def test_jooble_limit(mock_client_class, monkeypatch) -> None: + monkeypatch.setenv("NERAJOB_JOOBLE_API_KEY", "test-key") + payload = { + "totalCount": 100, + "jobs": [{"id": f"j{i}", "title": f"Job {i}", "company": "C"} for i in range(10)], + } + mock_client_class.return_value.__enter__.return_value = _fake_client(payload) + + jobs = get_scraper("jooble").search("python", limit=3) + assert len(jobs) == 3 diff --git a/tests/test_match.py b/tests/test_match.py index 88fd5df..985ae37 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -1,4 +1,4 @@ -from nerajob.match import match_score, rank_jobs +from nerajob.match import MatchWeights, match_score, rank_jobs from nerajob.models import JobPosting, Profile from nerajob.storage import default_profile @@ -28,3 +28,70 @@ def test_rank_jobs() -> None: ] ranked = rank_jobs(profile, jobs, top_k=2) assert ranked[0]["job_id"] == "a" + + +def test_match_score_accepts_custom_weights() -> None: + profile = Profile( + headline="Product Manager", + skills=["python"], + location="Berlin", + ) + job = JobPosting( + id="j2", + source="sample", + title="Product Manager", + company="Acme", + location="Berlin, Germany", + description="Own roadmap and customer discovery", + tags=["product"], + remote=False, + ) + + score = match_score( + profile, + job, + weights=MatchWeights(skills=0.0, title=80.0, location=20.0), + ) + + assert score["score_weights"] == {"skills": 0.0, "title": 80.0, "location": 20.0} + assert score["score"] > 60 + + +def test_rank_jobs_uses_custom_weights() -> None: + profile = Profile( + headline="Product Manager", + skills=["python"], + location="Berlin", + ) + jobs = [ + JobPosting( + id="skill", + source="sample", + title="Python Backend Engineer", + company="Acme", + description="Build Python APIs", + tags=["python"], + remote=True, + ), + JobPosting( + id="title-location", + source="sample", + title="Product Manager", + company="Beta", + location="Berlin", + description="Own roadmap", + tags=["product"], + remote=False, + ), + ] + + assert rank_jobs(profile, jobs, top_k=2)[0]["job_id"] == "skill" + + ranked = rank_jobs( + profile, + jobs, + top_k=2, + weights=MatchWeights(skills=0.0, title=80.0, location=20.0), + ) + + assert ranked[0]["job_id"] == "title-location" diff --git a/tests/test_match_precision.py b/tests/test_match_precision.py new file mode 100644 index 0000000..b1fd0d8 --- /dev/null +++ b/tests/test_match_precision.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path + +from nerajob.evaluation import precision_at_k +from nerajob.match import rank_jobs +from nerajob.models import JobPosting, Profile + + +ROOT = Path(__file__).parent.parent + + +def _load_json(path: Path) -> list[dict]: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_offline_resume_fixtures_have_perfect_precision_at_one() -> None: + jobs = [JobPosting(**item) for item in _load_json(ROOT / "data" / "samples" / "jobs_match_precision.json")] + cases = _load_json(Path(__file__).parent / "fixtures" / "resume_match_cases.json") + + for case in cases: + profile = Profile(**case["profile"]) + ranked_ids = [item["job_id"] for item in rank_jobs(profile, jobs, top_k=3)] + assert precision_at_k(ranked_ids, case["relevant_job_ids"], 1) == 1.0, case["name"] + + +def test_precision_at_k_handles_short_rankings_and_rejects_invalid_k() -> None: + assert precision_at_k(["match"], ["match"], 3) == 1.0 + assert precision_at_k([], ["match"], 1) == 0.0 + + try: + precision_at_k(["match"], ["match"], 0) + except ValueError as error: + assert str(error) == "k must be at least 1" + else: + raise AssertionError("expected invalid k to raise ValueError") diff --git a/tests/test_match_sort.py b/tests/test_match_sort.py new file mode 100644 index 0000000..5bb2908 --- /dev/null +++ b/tests/test_match_sort.py @@ -0,0 +1,116 @@ +"""Tests for `nerajob jobs list --sort match`.""" + + +from nerajob.match import MatchWeights, match_score +from nerajob.models import JobPosting, Profile +from nerajob.storage import default_profile + + +def test_match_sort_orders_by_score_descending() -> None: + profile = default_profile() + profile.skills = ["Python", "FastAPI", "SQL"] + jobs = [ + JobPosting( + id="high", + source="sample", + title="Senior Python Backend Engineer", + company="Acme", + description="Build FastAPI services with SQL databases", + tags=["python", "api"], + remote=True, + ), + JobPosting( + id="low", + source="sample", + title="Rust Systems Engineer", + company="Other", + description="Low-level systems programming", + tags=["rust", "systems"], + remote=True, + ), + JobPosting( + id="mid", + source="sample", + title="Data Analyst Python", + company="DataCo", + description="Analyze data with SQL", + tags=["python", "sql"], + remote=True, + ), + ] + + scored = [(job, match_score(profile, job)) for job in jobs] + scored.sort(key=lambda x: x[1]["score"], reverse=True) + + assert scored[0][0].id == "high" + assert scored[0][1]["score"] >= scored[1][1]["score"] + assert scored[1][1]["score"] >= scored[2][1]["score"] + + +def test_match_sort_includes_score_in_result() -> None: + profile = default_profile() + profile.skills = ["Python"] + job = JobPosting( + id="j1", + source="sample", + title="Python Developer", + company="Acme", + description="Python development", + tags=["python"], + remote=True, + ) + m = match_score(profile, job) + assert "score" in m + assert isinstance(m["score"], (int, float)) + + +def test_match_sort_respects_custom_weights() -> None: + profile = Profile( + headline="Sales Engineer", + skills=["python"], + location="Berlin", + ) + jobs = [ + JobPosting( + id="sales", + source="sample", + title="Sales Engineer", + company="TechCo", + location="Berlin", + description="Own technical sales", + tags=["sales"], + remote=False, + ), + JobPosting( + id="python", + source="sample", + title="Python Developer", + company="CodeCo", + description="Python development", + tags=["python"], + remote=True, + ), + ] + + scored_skill = [(job, match_score(profile, job)) for job in jobs] + scored_skill.sort(key=lambda x: x[1]["score"], reverse=True) + assert scored_skill[0][0].id == "python" + + weights = MatchWeights(skills=0.0, title=80.0, location=20.0) + scored_title = [(job, match_score(profile, job, weights=weights)) for job in jobs] + scored_title.sort(key=lambda x: x[1]["score"], reverse=True) + assert scored_title[0][0].id == "sales" + + +def test_match_sort_handles_empty_profile_skills() -> None: + profile = Profile(skills=[]) + job = JobPosting( + id="j1", + source="sample", + title="Engineer", + company="Acme", + tags=[], + ) + m = match_score(profile, job) + assert m["score"] >= 0 + assert m["skill_hits"] == [] diff --git a/tests/test_min_salary_filter.py b/tests/test_min_salary_filter.py new file mode 100644 index 0000000..15c0371 --- /dev/null +++ b/tests/test_min_salary_filter.py @@ -0,0 +1,30 @@ +"""Tests for #36: min-salary filter on scan.""" +from __future__ import annotations + +from nerajob.models import parse_salary_value + + +def test_parse_salary_k_range() -> None: + assert parse_salary_value("80k-120k") == 80000 + assert parse_salary_value("80k – 120k") == 80000 + assert parse_salary_value("80K-120K") == 80000 + + +def test_parse_salary_numeric_range() -> None: + assert parse_salary_value("80000-120000") == 80000 + assert parse_salary_value("$80,000 - $120,000") == 80000 + + +def test_parse_salary_single_value() -> None: + assert parse_salary_value("€55,000") == 55000 + assert parse_salary_value("55000") == 55000 + + +def test_parse_salary_empty() -> None: + assert parse_salary_value("") is None + assert parse_salary_value(" ") is None + + +def test_parse_salary_unparseable() -> None: + assert parse_salary_value("competitive") is None + assert parse_salary_value("negotiable") is None diff --git a/tests/test_offline_match_cli.py b/tests/test_offline_match_cli.py new file mode 100644 index 0000000..78089b2 --- /dev/null +++ b/tests/test_offline_match_cli.py @@ -0,0 +1,134 @@ +"""Tests for offline CLI match with --resume-file and --jobs-file.""" + +import json +import subprocess as sp +from pathlib import Path + +from nerajob.match import match_score +from nerajob.models import JobPosting, Profile + + +def test_offline_match_with_files(tmp_path: Path) -> None: + profile = Profile( + full_name="Test User", + headline="Python Backend Engineer", + location="Remote", + skills=["Python", "FastAPI", "PostgreSQL"], + ) + profile_path = tmp_path / "profile.json" + profile_path.write_text(profile.model_dump_json(), encoding="utf-8") + + jobs = [ + JobPosting( + id="j1", + source="fixture", + title="Python Backend Engineer", + company="TestCo", + location="Remote", + description="FastAPI and PostgreSQL experience required", + tags=["python", "fastapi", "postgres"], + remote=True, + ), + JobPosting( + id="j2", + source="fixture", + title="Rust Systems Engineer", + company="TestCo", + location="Remote", + description="Systems programming in Rust", + tags=["rust", "systems"], + remote=True, + ), + ] + jobs_path = tmp_path / "jobs.json" + jobs_path.write_text( + json.dumps([j.model_dump() for j in jobs]), encoding="utf-8" + ) + + result = sp.run( + [ + "python", + "-m", + "nerajob", + "jobs", + "match", + "--resume-file", + str(profile_path), + "--jobs-file", + str(jobs_path), + "--top", + "2", + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + assert result.returncode == 0 + assert "Python Backend Engineer" in result.stdout + assert "TestCo" in result.stdout + + +def test_offline_match_python_profile_vs_frontend_jobs(): + profile = Profile( + headline="Python Backend Engineer", + location="Remote", + skills=["Python", "FastAPI", "PostgreSQL"], + ) + job = JobPosting( + id="fe_01", + source="sample", + title="Senior Frontend Engineer", + company="WebCo", + location="Remote", + description="", + tags=["react", "typescript", "next.js"], + remote=True, + ) + score = match_score(profile, job) + # Python backend profile should not score high on frontend role + assert score["score"] < 50 or len(score["skill_hits"]) == 0 + + +def test_offline_match_devops_profile_vs_devops_jobs(): + profile = Profile( + headline="DevOps Engineer", + location="Remote", + skills=["Kubernetes", "Terraform", "AWS", "Docker"], + ) + job = JobPosting( + id="devops_01", + source="sample", + title="Senior DevOps Engineer", + company="CloudScale", + location="Remote", + description="", + tags=["kubernetes", "terraform", "aws", "ci/cd"], + remote=True, + ) + score = match_score(profile, job) + assert score["score"] >= 50 + assert score["band"] in ("medium", "strong") + + +def test_offline_match_with_sample_fixtures(): + profile = Profile( + headline="Python Backend Engineer", + location="Remote", + skills=["Python", "FastAPI", "PostgreSQL", "Docker"], + ) + + import json + + jobs_path = ( + Path(__file__).parent.parent / "data" / "samples" / "jobs_python_remote.json" + ) + jobs_data = json.loads(jobs_path.read_text(encoding="utf-8")) + jobs = [JobPosting(**j) for j in jobs_data] + + from nerajob.match import rank_jobs + + ranked = rank_jobs(profile, jobs, top_k=3) + assert len(ranked) == 3 + # Python-skilled jobs should rank highest + top = ranked[0] + assert "python" in str(top.get("skill_hits", [])).lower() or top["score"] > 0 diff --git a/tests/test_scan_preset.py b/tests/test_scan_preset.py new file mode 100644 index 0000000..eed1e03 --- /dev/null +++ b/tests/test_scan_preset.py @@ -0,0 +1,31 @@ +"""Tests for scan preset profile.""" + +from pathlib import Path + +from nerajob.models import ScanPreset +from nerajob.storage import load_scan_preset, save_scan_preset + + +def test_scan_preset_defaults(): + preset = ScanPreset() + assert preset.remote_only is False + assert preset.skill_filters == [] + assert preset.min_score == 0.0 + assert preset.min_salary == 0 + assert preset.max_results == 20 + + +def test_scan_perset_roundtrip(tmp_path: Path) -> None: + from nerajob import config as cfg + + original = cfg.SCAN_PRESET_PATH + try: + cfg.SCAN_PRESET_PATH = tmp_path / "scan-preset.json" + preset = ScanPreset(remote_only=True, skill_filters=["python"], min_score=30.0) + save_scan_preset(preset) + loaded = load_scan_preset() + assert loaded.remote_only is True + assert loaded.skill_filters == ["python"] + assert loaded.min_score == 30.0 + finally: + cfg.SCAN_PRESET_PATH = original diff --git a/tests/test_skill_aliases.py b/tests/test_skill_aliases.py index 8ad657d..8fe75df 100644 --- a/tests/test_skill_aliases.py +++ b/tests/test_skill_aliases.py @@ -48,3 +48,98 @@ def test_expand_skills_mobile(): ios = expand_skills({"ios"}) assert "mobile" in ios assert "swift" in ios + + +def test_expand_skills_education_and_edtech(): + lms = expand_skills({"lms"}) + assert "education" in lms + assert "curriculum" in lms + assert "assessment" in lms + + tutoring = expand_skills({"tutoring"}) + assert "edtech" in tutoring + assert "learning management system" in tutoring + assert "instructional design" in tutoring + + +def test_expand_skills_cloud_wave2(): + """Wave2: cloud aliases expanded with Azure/GCP services, IaC, serverless.""" + cloud = expand_skills({"cloud"}) + assert "aws" in cloud + assert "gcp" in cloud + assert "azure" in cloud + assert "lambda" in cloud + assert "cloudformation" in cloud + assert "serverless" in cloud + assert "pulumi" in cloud + assert "gke" in cloud or "eks" in cloud + assert "cdn" in cloud + # new cloud aliases added for bounty #57 + for s in ["ec2", "ecs", "cloudformation", "cloudrun", "cloudfunctions", + "pulumi", "cdk", "route53", "cloudfront", "iam", "vpc", + "eks", "gke", "aks", "cloudwatch", "cloudtrail"]: + out = expand_skills({s}) + assert "cloud" in out, f"expected 'cloud' in expansion of {s}" + # terraform bridges cloud + devops + assert "terraform" in expand_skills({"cloud"}) + assert "terraform" in expand_skills({"devops"}) + + +def test_expand_skills_devops_wave2(): + """Wave2: devops aliases expanded with terraform, helm, sre, cicd tools.""" + devops = expand_skills({"devops"}) + assert "kubernetes" in devops + assert "docker" in devops + assert "terraform" in devops + assert "helm" in devops + assert "ansible" in devops + assert "sre" in devops + assert "prometheus" in devops + assert "grafana" in devops + assert "infrastructure as code" in devops or "iac" in devops + # new devops aliases added for bounty #57 + for s in ["helm", "ansible", "sre", "site reliability", "prometheus", + "grafana", "istio", "argocd", "jenkins", "github actions", + "gitlab ci", "vault", "consul", "nomad"]: + out = expand_skills({s}) + assert "devops" in out, f"expected 'devops' in expansion of {s}" + + +def test_expand_skills_mobile_wave2(): + """Wave2: mobile aliases expanded with cross-platform and native tooling.""" + mob = expand_skills({"mobile"}) + assert "flutter" in mob + assert "react native" in mob + assert "xamarin" in mob + assert "maui" in mob + assert "jetpack compose" in mob + assert "xcode" in mob + assert "android studio" in mob + assert "pwa" in mob or "progressive web app" in mob + # new mobile aliases added for bounty #58 + for s in ["objc", "objective-c", "swiftui", "jetpack compose", + "xamarin", "ionic", "cordova", "mobile dev", "app store", + "play store", "expo"]: + out = expand_skills({s}) + assert "mobile" in out, f"expected 'mobile' in expansion of {s}" + + +def test_expand_skills_finance_wave2(): + """Wave2: finance aliases expanded with payments, kyc, aml, quant, trading.""" + fin = expand_skills({"finance"}) + assert "fintech" in fin + assert "payments" in fin + assert "kyc" in fin + assert "aml" in fin + assert "quant" in fin + assert "trading" in fin + assert "plaid" in fin + assert "payment gateway" in fin + assert "wealth management" in fin + # new finance/fintech aliases added for bounty #59 + for s in ["payments", "ledger", "kyc", "aml", "risk", "quant", + "quantitative", "trading", "settlement", "clearing", + "underwriting", "treasury", "stripe", "plaid", "swift", + "iso20022", "bloomberg", "reconciliation"]: + out = expand_skills({s}) + assert "finance" in out, f"expected 'finance' in expansion of {s}" diff --git a/tests/test_skill_extract.py b/tests/test_skill_extract.py new file mode 100644 index 0000000..8266b4a --- /dev/null +++ b/tests/test_skill_extract.py @@ -0,0 +1,65 @@ +"""Tests for offline skill extraction from plain text.""" + +from pathlib import Path + +from nerajob.match import extract_skills_from_text + + +def test_extract_skills_from_python_text(): + text = "Expert in Python, Django, and FastAPI. Experience with PostgreSQL." + result = extract_skills_from_text(text) + assert "python" in result + assert "django" in result["python"] + assert "fastapi" in result["python"] + assert "sql" in result + assert "postgres" in result["sql"] or "postgresql" in result["sql"] + + +def test_extract_skills_from_devops_text(): + text = "DevOps engineer with Docker, Kubernetes, Terraform, and CI/CD." + result = extract_skills_from_text(text) + assert "devops" in result + assert "docker" in result["devops"] + assert "kubernetes" in result["devops"] + assert "terraform" in result["devops"] + + +def test_extract_skills_from_full_resume(): + text = """Senior Python Backend Engineer + +Experience with FastAPI, Django, and PostgreSQL. +Cloud infrastructure on AWS with Lambda and S3. +Containerized with Docker and Kubernetes.""" + result = extract_skills_from_text(text) + assert "python" in result + assert "sql" in result + assert "cloud" in result + assert "devops" in result + + +def test_extract_skills_no_match(): + text = "I enjoy long walks on the beach and reading books." + result = extract_skills_from_text(text) + assert result == {} + + +def test_extract_skills_multiword_alias(): + text = "Experienced with infrastructure as code and continuous integration." + result = extract_skills_from_text(text) + devops = result.get("devops", set()) + assert "infrastructure as code" in devops or "iac" in devops + + +def test_extract_skills_case_insensitive(): + text = "PYTHON DJANGO KUBERNETES" + result = extract_skills_from_text(text) + assert "python" in result + assert "devops" in result + + +def test_extract_skills_with_textfile(tmp_path: Path) -> None: + txt = tmp_path / "resume.txt" + txt.write_text("Python developer with Flask and MongoDB experience.", encoding="utf-8") + result = extract_skills_from_text(txt.read_text(encoding="utf-8")) + assert "python" in result + assert "flask" in result["python"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..255d42a --- /dev/null +++ b/uv.lock @@ -0,0 +1,614 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[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.14.2" +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/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[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 = "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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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 = "nerajob" +version = "0.2.56" +source = { editable = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "httpx" }, + { name = "lxml" }, + { name = "pydantic" }, + { name = "python-slugify" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] +gui = [ + { name = "pyside6" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "lxml", specifier = ">=5.0" }, + { name = "pydantic", specifier = ">=2.6" }, + { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "python-slugify", specifier = ">=8.0" }, + { name = "rich", specifier = ">=13.7" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "typer", specifier = ">=0.12" }, +] +provides-extras = ["dev", "gui"] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[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 = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[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" }, +]