diff --git a/.github/workflows/cli-cd.yaml b/.github/workflows/cli-cd.yaml new file mode 100644 index 0000000..49d2ca6 --- /dev/null +++ b/.github/workflows/cli-cd.yaml @@ -0,0 +1,75 @@ +name: CLI CD + +on: + workflow_dispatch: + push: + branches: + - actions/* + - main + paths: + - "cli/**" + +permissions: + contents: read + +jobs: + linter: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Install dependencies + run: uv sync --all-extras --dev + - name: Lint with ruff + run: uv run ruff check ./src + + pytest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Install dependencies + run: uv sync --all-extras --dev + - name: Test with pytest + run: uv run pytest + + build-and-publish: + needs: [linter, pytest] + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + environment: pypi + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Build distribution + run: uv build + - name: Publish to PyPI + run: uv publish + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/cli-ci.yaml b/.github/workflows/cli-ci.yaml new file mode 100644 index 0000000..5173166 --- /dev/null +++ b/.github/workflows/cli-ci.yaml @@ -0,0 +1,81 @@ +name: CLI CI + +on: + workflow_dispatch: + push: + branches: + - actions/* + - develop + paths: + - "cli/**" + pull_request: + branches: + - develop + - main + paths: + - "cli/**" + +permissions: + contents: read + +jobs: + linter: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Install dependencies + run: uv sync --all-extras --dev + - name: Lint with ruff + run: uv run ruff check ./src + + pytest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Install dependencies + run: uv sync --all-extras --dev + - name: Test with pytest + run: uv run pytest + + build: + needs: [linter, pytest] + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python 3.10 + run: uv python install 3.10 + - name: Build distribution + run: uv build + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: cli/dist/ diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index b3da3e0..50bcb24 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **CLAUDE.md is a living document. Update it when the CLI changes in ways that affect how you work here.** Update when: -- A new command group or handler structure is introduced +- A new command or sub-app is added +- The package structure changes - A new development command is added or removed - Packaging or install behavior changes - A new required environment variable is introduced @@ -30,7 +31,7 @@ uv tool install --force . uv sync # Show CLI help -make help +uv run python -m applika.main --help # Run linter with auto-fix make lint @@ -40,6 +41,9 @@ make format # Run tests make test + +# Build wheel for PyPI +uv build ``` --- @@ -60,13 +64,59 @@ Do not skip this unless the user explicitly asks you not to run validation or th --- -## Structure +## Package Structure + +All source code lives under `src/applika/` (importable as `applika`). + +``` +src/applika/ +├── main.py # Entry point: def main() -> None +├── app.py # Root Typer app + --api-base-url callback → AppConfig +├── config.py # AppConfig dataclass + resolve_api_base_url +├── skills/ +│ └── applika-cli/ # Bundled SKILL.md (included in wheel, symlinked/copied by skill install) +├── schemas/ +│ ├── enums.py # Enum types: Currency, SalaryPeriod, ExperienceLevel, +│ │ # WorkMode, ApplicationMode, ModeFilter, StatusFilter, OutputFormat, ClearField +│ ├── application.py # Pydantic models: ApplicationCreate, ApplicationUpdate, +│ │ # ApplicationCompany (vendored from backend DTOs) +│ └── supports.py # SupportSchema — platforms and companies from /supports +├── lib/ +│ ├── api.py # ApiClient (httpx + cookie auth), ApiError, AuthError, +│ │ # require_session, create_session_from_exchange +│ ├── session.py # SessionData, SessionStore (~/.config/applika/session.json) +│ └── loopback.py # LoopbackLoginServer for OAuth callback +├── utils/ +│ └── output.py # render_application_table, print_application_summary +└── commands/ + ├── auth.py # login + logout + whoami Typer commands + ├── skill.py # skill install Typer sub-app + └── applications/ + ├── __init__.py # applications_app Typer sub-app with default-to-list callback + ├── commands.py # list_applications, new_application, edit_application + ├── filter.py # filter_applications + └── api_resolve.py # resolve_platform_id, resolve_company_input +``` + +## Key Patterns + +- **Global state**: `AppConfig` (dataclass with `api_base_url` + `store`) lives on `ctx.obj`, set by the root `@app.callback()` in `app.py`. +- **Auth required**: Commands call `require_session(config.store)` → raises `AuthError` if no session. +- **Payload validation**: `ApplicationCreate`/`ApplicationUpdate` Pydantic models validate inputs before API calls in `new_application` and `edit_application`. +- **Schemas are vendored**: `schemas/enums.py` and `schemas/application.py` are standalone copies (no backend import). Keep in sync with `backend/app/core/enums.py` and `backend/app/application/dto/application.py` when the backend changes. + +## CLI Commands + +| Command | Description | +|---|---| +| `applika login` | GitHub OAuth login (opens browser) | +| `applika logout` | Log out and clear session | +| `applika whoami` | Show the currently authenticated user | +| `applika applications list` | List applications (filterable) | +| `applika applications new` | Create a new application | +| `applika applications edit ` | Edit an existing application | +| `applika skill install` | Install the AI skill (symlink/copy) for Claude, Gemini, or Codex | -- `src/main.py` keeps the entrypoint small -- `src/cli_parser.py` owns argparse setup -- `src/auth_commands.py` owns login/logout flows -- `src/applications/` owns application-specific command logic -- `src/session.py` owns local session persistence -- `src/api.py` owns HTTP client behavior +## Environment Variables -Keep new feature-specific logic grouped by feature instead of expanding `main.py`. +- `APPLIKA_API_BASE_URL`: Override the default API URL (`https://applika.dev/api`) diff --git a/cli/Makefile b/cli/Makefile index 6e73f4a..67f9806 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,18 +1,3 @@ -install: - uv tool install --force . - -install-dev: - uv sync - -install-linux: - $(MAKE) install - -install-macos: - $(MAKE) install - -install-windows: - $(MAKE) install - test: uv run --no-sync pytest tests -q @@ -21,9 +6,3 @@ lint: format: uv run --no-sync ruff format . - -help: - uv run --no-sync applika --help - -uninstall: - uv tool uninstall applika-cli diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..d911766 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,331 @@ +# applika-cli + +Command-line interface for [Applika.dev](https://applika.dev) — a structured job application tracker designed for active job seekers who want full control over their data without relying on a browser. + +Track every application you send, filter and review your pipeline from the terminal, and keep your AI coding assistant (Claude Code, Gemini, Codex) aware of the CLI through a bundled skill file. + +## Requirements + +- Python 3.10+ +- [uv](https://docs.astral.sh/uv/) (recommended) or pip + +## Installation + +```bash +# Using uv (recommended) +uv tool install applika-cli + +# Using pipx +pipx install applika-cli +``` + +This installs the `applika` binary globally. Verify: + +```bash +applika --help +``` + +To install from a local source checkout (useful during development): + +```bash +uv tool install --force . +``` + +--- + +## Authentication + +The CLI authenticates via GitHub OAuth. On `applika login`, your browser opens to GitHub's authorization page. Once you authorize, the callback is captured locally on a loopback port and the session (access + refresh tokens) is saved to `~/.config/applika/session.json`. The session refreshes automatically on expiry — you only need to log in once per device. + +```bash +# Open browser and complete GitHub OAuth +applika login + +# Verify the active session without making any other API call +applika whoami +# → Logged in as: username=luissoares name=Luis Soares email=luis@example.com + +# Revoke the session server-side and clear local storage +applika logout +``` + +> `applika whoami` is the recommended pre-flight check. Run it before any other command, especially in scripts or AI-assisted workflows, to confirm a valid session exists before hitting the API. + +--- + +## Commands + +### `applika applications list` + +Lists all job applications in the current cycle, sorted by date descending. Supports rich filtering and both human-readable table output and machine-readable JSON. + +```bash +# Default: all applications as a table +applika applications list + +# Narrow down by any combination of filters +applika applications list \ + --search "stripe" \ + --mode active \ + --status active \ + --platform LinkedIn \ + --from 2026-01-01 \ + --to 2026-06-30 + +# JSON output — useful for piping into jq or scripts +applika applications list --output-format json + +# Scope to a specific job-search cycle by snowflake ID +applika applications list --cycle-id +``` + +**Filter reference:** + +| Flag | Values | Default | Description | +|---|---|---|---| +| `--search TEXT` | any string | — | Case-insensitive substring match on company name or role title | +| `--mode` | `active` · `passive` · `all` | `all` | `active` = you applied; `passive` = recruiter reached out | +| `--status` | `active` · `finalized` · `all` | `all` | `finalized` applications have a recorded outcome | +| `--platform TEXT` | e.g. `LinkedIn` | — | Exact match on platform name | +| `--from YYYY-MM-DD` | date | — | Include applications from this date (inclusive) | +| `--to YYYY-MM-DD` | date | — | Include applications up to this date (inclusive) | +| `--output-format` | `table` · `json` | `table` | `json` returns the raw API response as a formatted array | +| `--cycle-id TEXT` | snowflake ID | — | Filter to a specific job-search cycle | + +--- + +### `applika applications new` + +Records a new job application. The CLI validates the payload locally with Pydantic before calling the API, so you get clear field-level error messages without a round-trip. + +```bash +# Minimal — required fields only +applika applications new \ + --company "Stripe" \ + --role "Backend Engineer" \ + --platform "LinkedIn" \ + --mode active \ + --date 2026-05-10 + +# With salary info (currency and period are always required together with any salary field) +applika applications new \ + --company "Cloudflare" \ + --role "Systems Engineer" \ + --platform "Email" \ + --mode passive \ + --date 2026-05-10 \ + --observation "Recruiter cold-messaged. Interesting stack." \ + --country "Brazil" \ + --work-mode remote \ + --salary-min 18000 \ + --salary-max 24000 \ + --currency BRL \ + --salary-period monthly +``` + +**Required flags:** + +| Flag | Description | +|---|---| +| `--company TEXT` | Company name. Matched against known companies; a new record is created if not found. | +| `--role TEXT` | Job title or role description | +| `--platform TEXT` | Platform where you found or were contacted about the role (e.g. `LinkedIn`, `Indeed`, `Email`) | +| `--mode` | `active` — you applied proactively · `passive` — inbound from a recruiter | +| `--date YYYY-MM-DD` | Date the application was submitted or the first contact occurred | + +**Optional flags:** + +| Flag | Description | +|---|---| +| `--company-url URL` | Company website | +| `--job-url URL` | Direct link to the job posting | +| `--observation TEXT` | Free-form notes about the role, process, or company | +| `--country TEXT` | Country where the role is based | +| `--work-mode` | `remote` · `hybrid` · `on_site` | +| `--experience-level` | `intern` · `junior` · `mid_level` · `senior` · `staff` · `lead` · `principal` · `specialist` | +| `--expected-salary FLOAT` | The salary you expect or asked for | +| `--salary-min FLOAT` | Lower bound of a posted salary range | +| `--salary-max FLOAT` | Upper bound of a posted salary range | +| `--currency` | `USD` · `BRL` · `EUR` · `GBP` · `CAD` · `AUD` · `JPY` · `CHF` · `INR` | +| `--salary-period` | `hourly` · `monthly` · `annual` | + +> **Salary rule:** if any salary amount is provided (`--expected-salary`, `--salary-min`, or `--salary-max`), both `--currency` and `--salary-period` become required. The CLI enforces this before making any API call. + +--- + +### `applika applications edit ` + +Updates an existing application. Only the flags you pass are changed — everything else keeps its current value. This makes partial updates safe: you can update just the role name or add salary info without touching anything else. + +```bash +# Update the role title +applika applications edit 42 --role "Staff Engineer" + +# Add salary info that wasn't captured at application time +applika applications edit 42 \ + --expected-salary 180000 \ + --currency USD \ + --salary-period annual + +# Clear fields you no longer want to track (--clear is repeatable) +applika applications edit 42 \ + --clear job_url \ + --clear observation \ + --clear country \ + --clear salary +``` + +Find the application `id` with: + +```bash +applika applications list --search "company name" --output-format json +``` + +**Clear flags** — pass `--clear ` (repeatable) to set a field back to null without affecting others: + +| Flag | Clears | +|---|---| +| `--clear observation` | Notes | +| `--clear job_url` | Job posting URL | +| `--clear country` | Country | +| `--clear experience_level` | Experience level | +| `--clear work_mode` | Work mode | +| `--clear expected_salary` | Expected salary amount | +| `--clear salary_min` | Minimum salary range | +| `--clear salary_max` | Maximum salary range | +| `--clear currency` | Salary currency | +| `--clear salary_period` | Salary period | +| `--clear salary` | All salary fields at once (`expected_salary`, `salary_min`, `salary_max`, `currency`, `salary_period`) | + +> Finalized applications (those with a recorded outcome) are read-only and cannot be edited. The CLI checks this before sending the request. + +--- + +### `applika skill` + +Installs the bundled AI skill into your assistant's skills directory. The skill teaches Claude Code, Gemini, or Codex how to use this CLI — what commands exist, how authentication works, required vs optional flags, and common workflows. + +The skill file is shipped inside the installed package (`applika/skills/applika-cli/SKILL.md`) so it stays in sync with the CLI version you have installed. By default the command creates a symlink so updates are reflected automatically; it falls back to a file copy if symlink creation fails (e.g. Windows without Developer Mode enabled). + +```bash +# Interactive — choose which tool(s) to install for +applika skill +# → 1. Claude (~/.claude/skills/applika-cli) +# → 2. Gemini (~/.gemini/skills/applika-cli) +# → 3. Codex (~/.codex/skills/applika-cli) +# → 4. All of the above + +# Install to the current project's .claude/skills/ (file copy, no prompt) +# Useful when you want the skill scoped to a single repo +applika skill --local + +# Install to any arbitrary directory (file copy, no prompt) +applika skill --dir /path/to/skills + +# Preview what would be installed without touching the filesystem +applika skill --dry-run + +# Overwrite an existing installation +applika skill --force +``` + +Once installed, the AI assistant automatically loads the skill context in every session and knows how to: +- check authentication with `applika whoami` before running commands +- construct valid `new` and `edit` payloads +- apply the correct filters on `list` +- recover from auth errors by prompting for `applika login` + +--- + +## Global options + +These flags apply to every command and are passed before the subcommand name: + +```bash +applika --api-base-url https://staging.applika.dev/api applications list +``` + +| Flag | Default | Env variable | +|---|---|---| +| `--api-base-url TEXT` | `https://applika.dev/api` | `APPLIKA_API_BASE_URL` | + +--- + +## Package structure + +``` +cli/ +├── pyproject.toml # Build config, dependencies, entry point +├── Makefile # Shortcuts: test, lint, format +├── README.md +├── CLAUDE.md # Guidance for AI assistants working in this repo +└── src/ + └── applika/ # Importable package (entry: applika.main:main) + ├── main.py # Entry point — calls app() + ├── app.py # Root Typer app, --api-base-url callback, AppConfig wiring + ├── config.py # AppConfig dataclass + resolve_api_base_url() + │ + ├── skills/ + │ └── applika-cli/ + │ └── SKILL.md # Bundled AI skill — installed via `applika skill` + │ + ├── schemas/ # Vendored Pydantic models (no backend import) + │ ├── enums.py # Enum types: Currency, SalaryPeriod, WorkMode, ClearField, etc. + │ ├── application.py # ApplicationCreate, ApplicationUpdate with validators + │ └── supports.py # SupportSchema — platforms and companies from /supports + │ + ├── lib/ # Infrastructure — no Typer dependency + │ ├── api.py # ApiClient (httpx + cookie auth), ApiError, AuthError + │ ├── session.py # SessionData, SessionStore (~/.config/applika/session.json) + │ └── loopback.py # LoopbackLoginServer for OAuth browser callback + │ + ├── utils/ # Pure helpers — no httpx, no Typer + │ └── output.py # render_application_table, print_application_summary + │ + └── commands/ + ├── auth.py # login, logout, whoami commands + ├── skill.py # skill command — installs the AI skill + └── applications/ + ├── __init__.py # applications_app Typer sub-app, default-to-list callback + ├── commands.py # list_applications, new_application, edit_application + ├── filter.py # filter_applications + └── api_resolve.py # resolve_platform_id, resolve_company_input +``` + +**Layer rules:** +- `lib/` — HTTP and session logic only. No Typer, no output formatting. +- `utils/` — Pure functions. No side effects, no I/O beyond what the function signature implies. +- `schemas/` — Vendored copies of backend DTOs. Keep in sync manually with `backend/app/application/dto/application.py` and `backend/app/core/enums.py` when the backend changes. +- `commands/` — Typer command functions only. Delegates to `lib/` and `utils/`. + +--- + +## Development + +```bash +# Install project + dev dependencies into a local virtualenv +uv sync + +# Run the test suite +make test + +# Auto-fix lint issues +make lint + +# Apply code formatter +make format + +# Build a wheel for distribution +uv build + +# Install the locally built CLI globally for manual testing +uv tool install --force . +``` + +Tests live in `tests/` and use `typer.testing.CliRunner` with fake `ApiClient` and `SessionStore` implementations defined in `tests/conftest.py`. No real network calls are made. + +--- + +## License + +MIT diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 870090a..4417e25 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -4,21 +4,38 @@ build-backend = "hatchling.build" [project] name = "applika-cli" -version = "0.1.0" -description = "CLI for Applika" -requires-python = ">=3.12" +version = "0.1.3" +description = "Job application tracker CLI for Applika.dev" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "Luis Eduardo Soares", email = "luisedu.soares@outlook.com.br" }, + { name = "David Alecrim", email = "dsalecrim@outlook.com" }, +] +keywords = ["cli", "job-tracker", "applika"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "License :: OSI Approved :: MIT License", + "Environment :: Console", + "Topic :: Utilities", +] dependencies = [ "httpx>=0.28.1", + "typer>=0.15.0", + "pydantic>=2.0", ] +[project.urls] +Homepage = "https://applika.dev" +Repository = "https://github.com/ApplikaDev/applika" + [project.scripts] -applika = "main:main" +applika = "applika.main:main" [dependency-groups] -dev = [ - "pytest>=8.3.5", - "ruff>=0.15.2", -] +dev = ["pytest>=8.3.5", "ruff>=0.15.2"] [tool.ruff] line-length = 80 diff --git a/cli/src/applications/__init__.py b/cli/src/applications/__init__.py deleted file mode 100644 index 54a7b5a..0000000 --- a/cli/src/applications/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .args import add_application_args -from .commands import ( - handle_applications_edit, - handle_applications_list, - handle_applications_new, -) - -__all__ = [ - 'add_application_args', - 'handle_applications_edit', - 'handle_applications_list', - 'handle_applications_new', -] diff --git a/cli/src/applications/args.py b/cli/src/applications/args.py deleted file mode 100644 index 0461588..0000000 --- a/cli/src/applications/args.py +++ /dev/null @@ -1,34 +0,0 @@ -import argparse - -from choices import ( - CURRENCY_CHOICES, - EXPERIENCE_CHOICES, - MODE_CHOICES, - SALARY_PERIOD_CHOICES, - WORK_MODE_CHOICES, -) - - -def add_application_args( - parser: argparse.ArgumentParser, - *, - require_all: bool, -) -> None: - parser.add_argument('--company', required=require_all) - parser.add_argument('--company-url') - parser.add_argument('--role', required=require_all) - parser.add_argument('--platform', required=require_all) - parser.add_argument( - '--mode', choices=MODE_CHOICES[:2], required=require_all - ) - parser.add_argument('--date', dest='application_date', required=require_all) - parser.add_argument('--job-url') - parser.add_argument('--observation') - parser.add_argument('--expected-salary', type=float) - parser.add_argument('--salary-min', type=float) - parser.add_argument('--salary-max', type=float) - parser.add_argument('--currency', choices=CURRENCY_CHOICES) - parser.add_argument('--salary-period', choices=SALARY_PERIOD_CHOICES) - parser.add_argument('--experience-level', choices=EXPERIENCE_CHOICES) - parser.add_argument('--work-mode', choices=WORK_MODE_CHOICES) - parser.add_argument('--country') diff --git a/cli/src/applications/commands.py b/cli/src/applications/commands.py deleted file mode 100644 index db7aed4..0000000 --- a/cli/src/applications/commands.py +++ /dev/null @@ -1,82 +0,0 @@ -import argparse -import json - -from api import ApiClient -from cli_context import CommandContext, require_session - -from .filter import filter_applications -from .payloads import build_application_payload -from .view import print_application_summary, render_application_table - - -def handle_applications_list( - args: argparse.Namespace, - context: CommandContext, -) -> int: - session = require_session(context.store) - client = ApiClient(session, context.store) - - try: - params = {'cycle_id': args.cycle_id} if args.cycle_id else None - applications = client.get_json('/applications', params=params) - supports = client.get_json('/supports') - filtered = filter_applications(applications, supports, args) - - if args.json: - print(json.dumps(filtered, indent=2, sort_keys=True)) - else: - render_application_table(filtered, supports) - - return 0 - finally: - client.close() - - -def handle_applications_new( - args: argparse.Namespace, - context: CommandContext, -) -> int: - session = require_session(context.store) - client = ApiClient(session, context.store) - - try: - payload = build_application_payload(client, args, existing=None) - created = client.post_json('/applications', payload) - - print_application_summary(created, 'Created application') - return 0 - finally: - client.close() - - -def handle_applications_edit( - args: argparse.Namespace, - context: CommandContext, -) -> int: - session = require_session(context.store) - client = ApiClient(session, context.store) - - try: - applications = client.get_json('/applications') - existing = next( - ( - application - for application in applications - if str(application['id']) == args.application_id - ), - None, - ) - if existing is None: - raise ValueError('Application not found in the current cycle') - if existing.get('finalized'): - raise ValueError('Finalized applications cannot be edited') - - payload = build_application_payload(client, args, existing=existing) - updated = client.put_json( - f'/applications/{args.application_id}', payload - ) - - print_application_summary(updated, 'Updated application') - return 0 - finally: - client.close() diff --git a/cli/src/applications/filter.py b/cli/src/applications/filter.py deleted file mode 100644 index 59d99b4..0000000 --- a/cli/src/applications/filter.py +++ /dev/null @@ -1,68 +0,0 @@ -import argparse -from typing import Any - -from cli_context import parse_date - - -def filter_applications( - applications: list[dict[str, Any]], - supports: dict[str, Any], - args: argparse.Namespace, -) -> list[dict[str, Any]]: - platform_id = None - if args.platform: - platform_id = resolve_platform_id(supports, args.platform) - - filtered = [] - search = (args.search or '').strip().lower() - from_date = parse_date(args.from_date) if args.from_date else None - to_date = parse_date(args.to_date) if args.to_date else None - - for application in applications: - if search: - company_name = (application.get('company_name') or '').lower() - role = (application.get('role') or '').lower() - if search not in company_name and search not in role: - continue - - if args.mode != 'all' and application.get('mode') != args.mode: - continue - - if args.status == 'active' and application.get('finalized'): - continue - - if args.status == 'finalized' and not application.get('finalized'): - continue - - if platform_id and str(application.get('platform_id')) != platform_id: - continue - - app_date = parse_date(application['application_date']) - if from_date and app_date < from_date: - continue - if to_date and app_date > to_date: - continue - - filtered.append(application) - - filtered.sort(key=lambda item: item['application_date'], reverse=True) - return filtered - - -def resolve_platform_id(supports: dict[str, Any], platform_name: str) -> str: - normalized_name = platform_name.strip().lower() - match = next( - ( - platform - for platform in supports['platforms'] - if platform['name'].strip().lower() == normalized_name - ), - None, - ) - if match is None: - valid = ', '.join( - sorted(platform['name'] for platform in supports['platforms']) - ) - raise ValueError(f'Unknown platform. Valid options: {valid}') - - return str(match['id']) diff --git a/cli/src/applications/payloads.py b/cli/src/applications/payloads.py deleted file mode 100644 index 5413734..0000000 --- a/cli/src/applications/payloads.py +++ /dev/null @@ -1,189 +0,0 @@ -import argparse -from typing import Any - -from api import ApiClient -from cli_context import ensure_date_string - -from .filter import resolve_platform_id - - -def build_application_payload( - client: ApiClient, - args: argparse.Namespace, - *, - existing: dict[str, Any] | None, -) -> dict[str, Any]: - supports = client.get_json('/supports') - company = resolve_company_input( - client, - company_name=args.company, - company_url=args.company_url, - existing=existing, - ) - platform_id = ( - resolve_platform_id(supports, args.platform) - if args.platform - else str(existing['platform_id']) - ) - application_date = ( - ensure_date_string(args.application_date) - if args.application_date - else existing['application_date'] - ) - - salary = build_salary_fields(args, existing) - - return { - 'company': company, - 'platform_id': platform_id, - 'role': args.role if args.role is not None else existing['role'], - 'mode': args.mode if args.mode is not None else existing['mode'], - 'application_date': application_date, - 'link_to_job': choose_optional_value( - provided=args.job_url, - existing=existing.get('link_to_job') if existing else None, - clear=getattr(args, 'clear_job_url', False), - ), - 'observation': choose_optional_value( - provided=args.observation, - existing=existing.get('observation') if existing else None, - clear=getattr(args, 'clear_observation', False), - ), - 'country': choose_optional_value( - provided=args.country, - existing=existing.get('country') if existing else None, - clear=getattr(args, 'clear_country', False), - ), - 'currency': salary['currency'], - 'salary_period': salary['salary_period'], - 'expected_salary': salary['expected_salary'], - 'salary_range_min': salary['salary_range_min'], - 'salary_range_max': salary['salary_range_max'], - 'experience_level': ( - args.experience_level - if args.experience_level is not None - else (existing.get('experience_level') if existing else None) - ), - 'work_mode': ( - args.work_mode - if args.work_mode is not None - else (existing.get('work_mode') if existing else None) - ), - } - - -def build_salary_fields( - args: argparse.Namespace, - existing: dict[str, Any] | None, -) -> dict[str, Any]: - if getattr(args, 'clear_salary', False): - return { - 'currency': None, - 'salary_period': None, - 'expected_salary': None, - 'salary_range_min': None, - 'salary_range_max': None, - } - - fields = { - 'expected_salary': choose_numeric_value( - args.expected_salary, - existing, - 'expected_salary', - ), - 'salary_range_min': choose_numeric_value( - args.salary_min, - existing, - 'salary_range_min', - ), - 'salary_range_max': choose_numeric_value( - args.salary_max, - existing, - 'salary_range_max', - ), - 'currency': choose_existing_value(args.currency, existing, 'currency'), - 'salary_period': choose_existing_value( - args.salary_period, - existing, - 'salary_period', - ), - } - has_salary = any( - fields[name] is not None - for name in ( - 'expected_salary', - 'salary_range_min', - 'salary_range_max', - ) - ) - if has_salary and ( - fields['currency'] is None or fields['salary_period'] is None - ): - raise ValueError( - 'Currency and salary period are required when salary is set' - ) - - return fields - - -def resolve_company_input( - client: ApiClient, - *, - company_name: str | None, - company_url: str | None, - existing: dict[str, Any] | None, -) -> str | dict[str, Any]: - if company_name is None: - if existing is None: - raise ValueError('Company is required') - if existing.get('company_id'): - return str(existing['company_id']) - return {'name': existing['company_name'], 'url': None} - - query = company_name.strip().lower() - matches = client.get_json('/companies', params={'name': query}) - exact_match = next( - ( - company - for company in matches - if company['name'].strip().lower() == query - ), - None, - ) - if exact_match: - return str(exact_match['id']) - - return {'name': company_name.strip(), 'url': company_url or None} - - -def choose_optional_value( - *, - provided: str | None, - existing: str | None, - clear: bool, -) -> str | None: - if clear: - return None - if provided is not None: - return provided - return existing - - -def choose_existing_value( - provided: str | None, - existing: dict[str, Any] | None, - field: str, -) -> str | None: - if provided is not None: - return provided - return existing.get(field) if existing else None - - -def choose_numeric_value( - provided: float | None, - existing: dict[str, Any] | None, - field: str, -) -> float | None: - if provided is not None: - return provided - return existing.get(field) if existing else None diff --git a/cli/src/applika/__init__.py b/cli/src/applika/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/applika/app.py b/cli/src/applika/app.py new file mode 100644 index 0000000..43fbcef --- /dev/null +++ b/cli/src/applika/app.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from importlib.metadata import version +from typing import Annotated + +import typer + +from applika.commands.applications import applications_app +from applika.commands.auth import login, logout, whoami +from applika.commands.skill import skill +from applika.config import AppConfig, resolve_api_base_url +from applika.lib.session import SessionStore + +app = typer.Typer( + name='applika', + help='Job application tracker CLI for Applika.dev.', + invoke_without_command=True, +) + +app.add_typer(applications_app, name='applications') +app.command('skill')(skill) +app.command('login')(login) +app.command('logout')(logout) +app.command('whoami')(whoami) + + +@app.callback() +def _root( + ctx: typer.Context, + api_base_url: Annotated[ + str | None, + typer.Option( + '--api-base-url', + help='Override the API base URL (default: https://applika.dev/api).', + envvar='APPLIKA_API_BASE_URL', + show_default=False, + ), + ] = None, + _version: Annotated[ + bool, + typer.Option( + '--version', + '-v', + help='Show the version and exit.', + is_eager=True, + ), + ] = False, +) -> None: + if _version: + typer.echo(version('applika-cli')) + raise typer.Exit() + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() + store = SessionStore() + ctx.ensure_object(dict) + ctx.obj = AppConfig( + api_base_url=resolve_api_base_url(api_base_url, store), + store=store, + ) diff --git a/cli/src/applika/commands/__init__.py b/cli/src/applika/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/applika/commands/applications/__init__.py b/cli/src/applika/commands/applications/__init__.py new file mode 100644 index 0000000..aeb5041 --- /dev/null +++ b/cli/src/applika/commands/applications/__init__.py @@ -0,0 +1,25 @@ +import typer + +from applika.commands.applications.commands import ( + edit_application, + list_applications, + new_application, +) + +applications_app = typer.Typer( + name='applications', + help='Manage job applications.', + no_args_is_help=True, + invoke_without_command=True, +) + + +@applications_app.callback() +def _default(ctx: typer.Context) -> None: + if ctx.invoked_subcommand is None: + ctx.invoke(list_applications) + + +applications_app.command('list')(list_applications) +applications_app.command('new')(new_application) +applications_app.command('edit')(edit_application) diff --git a/cli/src/applika/commands/applications/api_resolve.py b/cli/src/applika/commands/applications/api_resolve.py new file mode 100644 index 0000000..7f1a808 --- /dev/null +++ b/cli/src/applika/commands/applications/api_resolve.py @@ -0,0 +1,56 @@ +from applika.lib.api import ApiClient +from applika.schemas.application import ApplicationCompany +from applika.schemas.supports import Company, SupportSchema + + +def resolve_platform_id(client: ApiClient, platform_name: str) -> str: + """ + Resolve a platform name to its corresponding ID using + the supports endpoint. + """ + supports: SupportSchema = client.get_json('/supports') + + normalized_name = platform_name.strip().lower() + match = next( + ( + platform + for platform in supports['platforms'] + if platform['name'].strip().lower() == normalized_name + ), + None, + ) + if match is None: + valid = ', '.join( + sorted(platform['name'] for platform in supports['platforms']) + ) + raise ValueError(f'Unknown platform. Valid options: {valid}') + + return str(match['id']) + + +def resolve_company_input( + client: ApiClient, + company_name: str, + company_url: str | None, +) -> str | ApplicationCompany: + """ + Resolve a company name to its corresponding ID using the + companies endpoint. If no exact match is found, return a dict with + the provided name and URL for creation. + """ + query = company_name.strip().lower() + matches: list[Company] = client.get_json( + '/companies', params={'name': query} + ) + exact_match = next( + ( + company + for company in matches + if company['name'].strip().lower() == query + ), + None, + ) + if exact_match: + return str(exact_match['id']) + + return {'name': company_name.strip(), 'url': company_url or None} diff --git a/cli/src/applika/commands/applications/commands.py b/cli/src/applika/commands/applications/commands.py new file mode 100644 index 0000000..8fe81d9 --- /dev/null +++ b/cli/src/applika/commands/applications/commands.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import json +from typing import Annotated, Any + +import typer +from pydantic import ValidationError + +from applika.commands.applications.api_resolve import ( + resolve_company_input, + resolve_platform_id, +) +from applika.commands.applications.filter import filter_applications +from applika.config import AppConfig +from applika.lib.api import ApiClient, require_session +from applika.schemas.application import ( + ApplicationCreate, + ApplicationEntry, + ApplicationUpdate, +) +from applika.schemas.enums import ( + ApplicationMode, + ClearField, + Currency, + ExperienceLevel, + ModeFilter, + OutputFormat, + SalaryPeriod, + StatusFilter, + WorkMode, +) +from applika.schemas.supports import SupportSchema +from applika.utils.output import ( + print_application_summary, + render_application_table, +) + + +def list_applications( + ctx: typer.Context, + cycle_id: Annotated[ + str | None, + typer.Option('--cycle-id', help='Filter by cycle ID.'), + ] = None, + search: Annotated[ + str | None, + typer.Option( + '--search', + help='Search in company name or role (case-insensitive).', + ), + ] = None, + mode: Annotated[ + ModeFilter, + typer.Option('--mode', help='Filter by application mode.'), + ] = ModeFilter.ALL, + status: Annotated[ + StatusFilter, + typer.Option('--status', help='Filter by application status.'), + ] = StatusFilter.ALL, + platform: Annotated[ + str | None, + typer.Option( + '--platform', help='Filter by platform name (e.g. LinkedIn).' + ), + ] = None, + from_date: Annotated[ + str | None, + typer.Option( + '--from', + help='Include applications from this date (YYYY-MM-DD, inclusive).', + ), + ] = None, + to_date: Annotated[ + str | None, + typer.Option( + '--to', + help='Include applications up to this date (YYYY-MM-DD, inclusive).', + ), + ] = None, + output_format: Annotated[ + OutputFormat, + typer.Option( + '--output-format', help='Output format: table (default) or json.' + ), + ] = OutputFormat.TABLE, +) -> None: + """List job applications with optional filters, sorted by date descending.""" + config: AppConfig = ctx.obj + session = require_session(config.store) + client = ApiClient(session, config.store) + + try: + params = {'cycle_id': cycle_id} if cycle_id else None + supports: SupportSchema = client.get_json('/supports') + applications: list[ApplicationEntry] = client.get_json( + '/applications', params=params + ) + + filtered = filter_applications( + applications, + supports, + search=search, + mode=mode, + status=status, + platform=platform, + from_date=from_date, + to_date=to_date, + ) + + if output_format == OutputFormat.JSON: + print(json.dumps(filtered, indent=2, sort_keys=True)) + else: + render_application_table(filtered, supports) + finally: + client.close() + + +def new_application( + ctx: typer.Context, + company: Annotated[ + str, + typer.Option('--company', help='Company name.'), + ], + role: Annotated[ + str, + typer.Option('--role', help='Job title or role.'), + ], + platform: Annotated[ + str, + typer.Option( + '--platform', help='Platform name (e.g. LinkedIn, Indeed).' + ), + ], + mode: Annotated[ + ApplicationMode, + typer.Option('--mode', help='Application mode: active or passive.'), + ], + application_date: Annotated[ + str, + typer.Option('--date', help='Application date (YYYY-MM-DD).'), + ], + company_url: Annotated[ + str | None, + typer.Option('--company-url', help='Company website URL.'), + ] = None, + job_url: Annotated[ + str | None, + typer.Option('--job-url', help='Link to the job posting.'), + ] = None, + observation: Annotated[ + str | None, + typer.Option( + '--observation', help='Notes or observations about the application.' + ), + ] = None, + expected_salary: Annotated[ + float | None, + typer.Option('--expected-salary', help='Expected salary amount.'), + ] = None, + salary_min: Annotated[ + float | None, + typer.Option('--salary-min', help='Minimum salary range.'), + ] = None, + salary_max: Annotated[ + float | None, + typer.Option('--salary-max', help='Maximum salary range.'), + ] = None, + currency: Annotated[ + Currency | None, + typer.Option( + '--currency', help='Salary currency (required when salary is set).' + ), + ] = None, + salary_period: Annotated[ + SalaryPeriod | None, + typer.Option( + '--salary-period', + help='Salary period (required when salary is set).', + ), + ] = None, + experience_level: Annotated[ + ExperienceLevel | None, + typer.Option('--experience-level', help='Required experience level.'), + ] = None, + work_mode: Annotated[ + WorkMode | None, + typer.Option( + '--work-mode', help='Work mode: remote, hybrid, or on_site.' + ), + ] = None, + country: Annotated[ + str | None, + typer.Option('--country', help='Country where the job is located.'), + ] = None, +) -> None: + """Create a new job application.""" + config: AppConfig = ctx.obj + session = require_session(config.store) + client = ApiClient(session, config.store) + + try: + payload = ApplicationCreate( + company=resolve_company_input(client, company, company_url), + role=role, + mode=mode, + platform_id=resolve_platform_id(client, platform), + application_date=application_date, + link_to_job=job_url, + observation=observation, + expected_salary=expected_salary, + salary_range_min=salary_min, + salary_range_max=salary_max, + currency=currency, + salary_period=salary_period, + experience_level=experience_level, + work_mode=work_mode, + country=country, + ) + created = client.post_json( + '/applications', payload.model_dump(mode='json') + ) + print_application_summary(created, 'Created application') + except ValidationError as exc: + for err in exc.errors(): + field = '.'.join(str(loc) for loc in err['loc']) + typer.echo(f'Error [{field}]: {err["msg"]}', err=True) + raise typer.Exit(1) + finally: + client.close() + + +def edit_application( + ctx: typer.Context, + application_id: Annotated[ + str, + typer.Argument(help='ID of the application to edit.'), + ], + company: Annotated[ + str | None, + typer.Option('--company', help='New company name.'), + ] = None, + role: Annotated[ + str | None, + typer.Option('--role', help='New job title or role.'), + ] = None, + platform: Annotated[ + str | None, + typer.Option('--platform', help='New platform name.'), + ] = None, + mode: Annotated[ + ApplicationMode | None, + typer.Option('--mode', help='New application mode.'), + ] = None, + application_date: Annotated[ + str | None, + typer.Option('--date', help='New application date (YYYY-MM-DD).'), + ] = None, + company_url: Annotated[ + str | None, + typer.Option('--company-url', help='New company website URL.'), + ] = None, + job_url: Annotated[ + str | None, + typer.Option('--job-url', help='New link to the job posting.'), + ] = None, + observation: Annotated[ + str | None, + typer.Option('--observation', help='New notes or observations.'), + ] = None, + expected_salary: Annotated[ + float | None, + typer.Option('--expected-salary', help='New expected salary amount.'), + ] = None, + salary_min: Annotated[ + float | None, + typer.Option('--salary-min', help='New minimum salary range.'), + ] = None, + salary_max: Annotated[ + float | None, + typer.Option('--salary-max', help='New maximum salary range.'), + ] = None, + currency: Annotated[ + Currency | None, + typer.Option('--currency', help='New salary currency.'), + ] = None, + salary_period: Annotated[ + SalaryPeriod | None, + typer.Option('--salary-period', help='New salary period.'), + ] = None, + experience_level: Annotated[ + ExperienceLevel | None, + typer.Option( + '--experience-level', help='New required experience level.' + ), + ] = None, + work_mode: Annotated[ + WorkMode | None, + typer.Option('--work-mode', help='New work mode.'), + ] = None, + country: Annotated[ + str | None, + typer.Option('--country', help='New country where the job is located.'), + ] = None, + clear: Annotated[ + list[ClearField] | None, + typer.Option( + '--clear', + help=( + 'Field to set to null. Repeatable: --clear observation --clear job_url. ' + 'Valid: observation, job_url, country, experience_level, work_mode, ' + 'expected_salary, salary_min, salary_max, currency, salary_period, ' + 'salary (clears all salary fields at once).' + ), + ), + ] = None, +) -> None: + """Edit an existing job application. Unspecified fields keep their current values.""" + config: AppConfig = ctx.obj + session = require_session(config.store) + client = ApiClient(session, config.store) + + try: + applications: list[dict[str, Any]] = client.get_json('/applications') + existing = next( + (app for app in applications if str(app['id']) == application_id), + None, + ) + if existing is None: + typer.echo('Application not found in the current cycle', err=True) + raise typer.Exit(1) + if existing.get('finalized'): + typer.echo('Finalized applications cannot be edited', err=True) + raise typer.Exit(1) + + resolved_company = ( + resolve_company_input(client, company, company_url) + if company is not None + else str(existing['company_id']) + if existing.get('company_id') + else {'name': existing['company_name'], 'url': None} + ) + resolved_platform_id = ( + resolve_platform_id(client, platform) + if platform is not None + else str(existing['platform_id']) + ) + + clear_set: set[ClearField] = set(clear or []) + + def _clears(field: ClearField) -> bool: + return field in clear_set + + clear_all_salary = _clears(ClearField.SALARY) + + payload = ApplicationUpdate( + company=resolved_company, + role=role or existing['role'], + mode=mode or existing['mode'], + platform_id=resolved_platform_id, + application_date=(application_date or existing['application_date']), + link_to_job=( + None + if _clears(ClearField.JOB_URL) + else job_url or existing.get('link_to_job') + ), + observation=( + None + if _clears(ClearField.OBSERVATION) + else observation or existing.get('observation') + ), + country=( + None + if _clears(ClearField.COUNTRY) + else country or existing.get('country') + ), + expected_salary=( + None + if clear_all_salary or _clears(ClearField.EXPECTED_SALARY) + else ( + expected_salary + if expected_salary is not None + else existing.get('expected_salary') + ) + ), + salary_range_min=( + None + if clear_all_salary or _clears(ClearField.SALARY_MIN) + else ( + salary_min + if salary_min is not None + else existing.get('salary_range_min') + ) + ), + salary_range_max=( + None + if clear_all_salary or _clears(ClearField.SALARY_MAX) + else ( + salary_max + if salary_max is not None + else existing.get('salary_range_max') + ) + ), + currency=( + None + if clear_all_salary or _clears(ClearField.CURRENCY) + else currency or existing.get('currency') + ), + salary_period=( + None + if clear_all_salary or _clears(ClearField.SALARY_PERIOD) + else salary_period or existing.get('salary_period') + ), + experience_level=( + None + if _clears(ClearField.EXPERIENCE_LEVEL) + else experience_level or existing.get('experience_level') + ), + work_mode=( + None + if _clears(ClearField.WORK_MODE) + else work_mode or existing.get('work_mode') + ), + ) + updated = client.put_json( + f'/applications/{application_id}', + payload.model_dump(mode='json'), + ) + print_application_summary(updated, 'Updated application') + except ValidationError as exc: + for err in exc.errors(): + field = '.'.join(str(loc) for loc in err['loc']) + typer.echo(f'Error [{field}]: {err["msg"]}', err=True) + raise typer.Exit(1) + finally: + client.close() diff --git a/cli/src/applika/commands/applications/filter.py b/cli/src/applika/commands/applications/filter.py new file mode 100644 index 0000000..347a79e --- /dev/null +++ b/cli/src/applika/commands/applications/filter.py @@ -0,0 +1,73 @@ +from datetime import date +from typing import Any + +from applika.schemas.application import ApplicationEntry +from applika.schemas.enums import ModeFilter, StatusFilter + + +def filter_applications( + applications: list[ApplicationEntry], + supports: dict[str, Any], + search: str | None = None, + mode: ModeFilter = ModeFilter.ALL, + status: StatusFilter = StatusFilter.ALL, + platform: str | None = None, + from_date: str | None = None, + to_date: str | None = None, +) -> list[ApplicationEntry]: + platform_id = _resolve_platform_id(supports, platform) if platform else None + search_term = (search or '').strip().lower() + date_from = date.fromisoformat(from_date) if from_date else None + date_to = date.fromisoformat(to_date) if to_date else None + + def matches(app: ApplicationEntry) -> bool: + if search_term: + company_name = (app.get('company_name') or '').lower() + role = (app.get('role') or '').lower() + if search_term not in company_name and search_term not in role: + return False + + if mode != ModeFilter.ALL and app.get('mode') != mode: + return False + + if status == StatusFilter.ACTIVE and app.get('finalized'): + return False + + if status == StatusFilter.FINALIZED and not app.get('finalized'): + return False + + if platform_id and str(app.get('platform_id')) != platform_id: + return False + + app_date = date.fromisoformat(app['application_date']) + if date_from and app_date < date_from: + return False + if date_to and app_date > date_to: + return False + + return True + + return sorted( + (app for app in applications if matches(app)), + key=lambda app: app['application_date'], + reverse=True, + ) + + +def _resolve_platform_id(supports: dict[str, Any], platform_name: str) -> str: + normalized_name = platform_name.strip().lower() + match = next( + ( + platform + for platform in supports['platforms'] + if platform['name'].strip().lower() == normalized_name + ), + None, + ) + if match is None: + valid = ', '.join( + sorted(platform['name'] for platform in supports['platforms']) + ) + raise ValueError(f'Unknown platform. Valid options: {valid}') + + return str(match['id']) diff --git a/cli/src/applika/commands/auth.py b/cli/src/applika/commands/auth.py new file mode 100644 index 0000000..257e555 --- /dev/null +++ b/cli/src/applika/commands/auth.py @@ -0,0 +1,93 @@ +import secrets +import webbrowser + +import httpx +import typer + +from applika.config import AppConfig +from applika.lib.api import ( + ApiClient, + create_session_from_exchange, + require_session, +) +from applika.lib.loopback import LoopbackLoginServer + + +def login(ctx: typer.Context) -> None: + """Log in to Applika via GitHub OAuth. Opens your browser to authenticate.""" + config: AppConfig = ctx.obj + state = secrets.token_urlsafe(24) + server = LoopbackLoginServer(expected_state=state) + server.start() + + try: + response = httpx.post( + f'{config.api_base_url}/auth/cli/start', + json={ + 'callback_url': server.callback_url, + 'state': state, + }, + timeout=30, + ) + response.raise_for_status() + + data = response.json() + login_url = data['login_url'] + browser_opened = webbrowser.open(login_url) + if not browser_opened: + typer.echo(f'Open this URL to continue login:\n{login_url}') + + code = server.wait_for_code(timeout_seconds=300) + exchange = httpx.post( + f'{config.api_base_url}/auth/cli/exchange', + json={'code': code}, + timeout=30, + ) + exchange.raise_for_status() + + session = create_session_from_exchange( + config.api_base_url, + exchange.json(), + ) + config.store.save(session) + typer.echo('Login successful.') + except (RuntimeError, httpx.HTTPError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + finally: + server.close() + + +def logout(ctx: typer.Context) -> None: + """Log out and clear the local session.""" + config: AppConfig = ctx.obj + session = require_session(config.store) + client = ApiClient(session, config.store) + + try: + client.logout() + finally: + client.close() + + typer.echo('Logged out.') + + +def whoami(ctx: typer.Context) -> None: + """Show the currently authenticated user.""" + config: AppConfig = ctx.obj + session = require_session(config.store) + client = ApiClient(session, config.store) + + try: + user = client.get_json('/users/me') + name = ' '.join( + filter(None, [user.get('first_name'), user.get('last_name')]) + ) + parts = [f'username={user["username"]}'] + if name: + parts.append(f'name={name}') + if user.get('email'): + parts.append(f'email={user["email"]}') + typer.echo('Logged in as: ' + ' '.join(parts)) + finally: + client.close() diff --git a/cli/src/applika/commands/skill.py b/cli/src/applika/commands/skill.py new file mode 100644 index 0000000..7f29e63 --- /dev/null +++ b/cli/src/applika/commands/skill.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from typing import Annotated + +import typer + +SKILL_DIR_NAME = 'applika-cli' + +_TOOLS: list[tuple[str, Path]] = [ + ('Claude', Path.home() / '.claude' / 'skills'), + ('Gemini', Path.home() / '.gemini' / 'skills'), + ('Codex', Path.home() / '.codex' / 'skills'), +] + + +def _bundled_skill_dir() -> Path: + import applika + + path = Path(applika.__file__).parent / 'skills' / SKILL_DIR_NAME + if not path.is_dir(): + raise RuntimeError(f'Bundled skill directory not found: {path}') + return path + + +def _install_to( + skill_src: Path, + dest: Path, + *, + force: bool, + dry_run: bool, + copy: bool = False, +) -> None: + if dry_run: + method = 'copy' if copy else 'symlink (copy on failure)' + typer.echo(f' [dry-run] {method}: {dest}') + return + + if dest.exists() or dest.is_symlink(): + if not force: + typer.echo( + f' Skipped — already installed at {dest} (use --force to overwrite).' + ) + return + if dest.is_symlink(): + dest.unlink() + elif dest.is_dir(): + shutil.rmtree(dest) + else: + dest.unlink() + + dest.parent.mkdir(parents=True, exist_ok=True) + + if copy: + shutil.copytree(str(skill_src), str(dest)) + typer.echo(f' Copied → {dest}') + return + + try: + os.symlink(skill_src, dest) + typer.echo(f' Symlinked → {dest}') + except OSError: + shutil.copytree(str(skill_src), str(dest)) + typer.echo(f' Symlink failed, copied → {dest}') + + +def skill( + local: Annotated[ + bool, + typer.Option( + '--local', + help='Install to .claude/skills/ in the current directory (copy, no prompt).', + ), + ] = False, + target_dir: Annotated[ + str | None, + typer.Option( + '--dir', + help='Custom target skills directory (copy, no prompt).', + ), + ] = None, + force: Annotated[ + bool, + typer.Option('--force', help='Overwrite an existing installation.'), + ] = False, + dry_run: Annotated[ + bool, + typer.Option( + '--dry-run', help='Show what would happen without making changes.' + ), + ] = False, +) -> None: + """Install the applika-cli AI skill into your assistant's skills directory. + + By default, opens an interactive picker to choose Claude, Gemini, Codex, + or all of them. Symlinks the bundled skill directory; falls back to a file + copy automatically if symlink creation fails (e.g. Windows without + Developer Mode). + + Use --local or --dir to skip the picker and install as a file copy. + """ + try: + skill_src = _bundled_skill_dir() + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) + + # --local and --dir: skip prompt, always copy + if target_dir: + dest = Path(target_dir) / SKILL_DIR_NAME + typer.echo(f'Installing to {dest}:') + _install_to(skill_src, dest, force=force, dry_run=dry_run, copy=True) + return + + if local: + dest = Path.cwd() / '.claude' / 'skills' / SKILL_DIR_NAME + typer.echo(f'Installing to {dest}:') + _install_to(skill_src, dest, force=force, dry_run=dry_run, copy=True) + return + + # Interactive picker + typer.echo('Which AI tool(s) should the skill be installed for?\n') + for i, (name, skills_root) in enumerate(_TOOLS, start=1): + typer.echo(f' {i}. {name:<8} ({skills_root / SKILL_DIR_NAME})') + all_num = len(_TOOLS) + 1 + typer.echo(f' {all_num}. All of the above\n') + + raw = typer.prompt(f'Choice [1-{all_num}]') + + try: + choice = int(raw.strip()) + except ValueError: + typer.echo(f'Invalid choice: {raw!r}', err=True) + raise typer.Exit(1) + + if choice == all_num: + selected = _TOOLS[:] + elif 1 <= choice <= len(_TOOLS): + selected = [_TOOLS[choice - 1]] + else: + typer.echo(f'Invalid choice: {choice}', err=True) + raise typer.Exit(1) + + for name, skills_root in selected: + dest = skills_root / SKILL_DIR_NAME + typer.echo(f'\nInstalling for {name}:') + _install_to(skill_src, dest, force=force, dry_run=dry_run) diff --git a/cli/src/applika/config.py b/cli/src/applika/config.py new file mode 100644 index 0000000..65e048b --- /dev/null +++ b/cli/src/applika/config.py @@ -0,0 +1,27 @@ +import os +from dataclasses import dataclass + +from applika.lib.session import SessionStore + +DEFAULT_API_BASE_URL = 'https://applika.dev/api' + + +@dataclass +class AppConfig: + api_base_url: str + store: SessionStore + + +def resolve_api_base_url( + explicit_value: str | None, + store: SessionStore, +) -> str: + if explicit_value: + return explicit_value.rstrip('/') + env_value = os.getenv('APPLIKA_API_BASE_URL') + if env_value: + return env_value.rstrip('/') + existing = store.try_load() + if existing: + return existing.api_base_url.rstrip('/') + return DEFAULT_API_BASE_URL diff --git a/cli/src/applika/lib/__init__.py b/cli/src/applika/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/api.py b/cli/src/applika/lib/api.py similarity index 91% rename from cli/src/api.py rename to cli/src/applika/lib/api.py index e03adde..0c3e247 100644 --- a/cli/src/api.py +++ b/cli/src/applika/lib/api.py @@ -1,11 +1,15 @@ from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import urlparse import httpx -from session import SessionData, SessionStore, expiry_from_access_token +from applika.lib.session import ( + SessionData, + SessionStore, + expiry_from_access_token, +) class ApiError(RuntimeError): @@ -19,6 +23,13 @@ class AuthError(ApiError): pass +def require_session(store: SessionStore) -> SessionData: + session = store.try_load() + if not session: + raise AuthError('Please run `applika login` first.') + return session + + @dataclass class ApiClient: session: SessionData @@ -137,7 +148,7 @@ def create_session_from_exchange( api_base_url: str, payload: dict[str, Any], ) -> SessionData: - expires_at = datetime.now(UTC) + timedelta( + expires_at = datetime.now(timezone.utc) + timedelta( seconds=int(payload['access_expires_in']) ) return SessionData( diff --git a/cli/src/loopback.py b/cli/src/applika/lib/loopback.py similarity index 100% rename from cli/src/loopback.py rename to cli/src/applika/lib/loopback.py diff --git a/cli/src/session.py b/cli/src/applika/lib/session.py similarity index 74% rename from cli/src/session.py rename to cli/src/applika/lib/session.py index da98b86..b887a8c 100644 --- a/cli/src/session.py +++ b/cli/src/applika/lib/session.py @@ -3,11 +3,9 @@ import os import tempfile from dataclasses import asdict, dataclass -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path -DEFAULT_API_BASE_URL = 'https://applika.dev/api' - @dataclass class SessionData: @@ -53,24 +51,9 @@ def clear(self) -> None: self.path.unlink() -def resolve_api_base_url( - explicit_value: str | None, - store: SessionStore, -) -> str: - if explicit_value: - return explicit_value.rstrip('/') - env_value = os.getenv('APPLIKA_API_BASE_URL') - if env_value: - return env_value.rstrip('/') - existing = store.try_load() - if existing: - return existing.api_base_url.rstrip('/') - return DEFAULT_API_BASE_URL - - def expiry_from_access_token(token: str) -> str: payload_segment = token.split('.')[1] padding = '=' * (-len(payload_segment) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_segment + padding)) exp = payload['exp'] - return datetime.fromtimestamp(exp, tz=UTC).isoformat() + return datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() diff --git a/cli/src/applika/main.py b/cli/src/applika/main.py new file mode 100644 index 0000000..8987cc9 --- /dev/null +++ b/cli/src/applika/main.py @@ -0,0 +1,5 @@ +from applika.app import app + + +def main() -> None: + app() diff --git a/cli/src/applika/schemas/__init__.py b/cli/src/applika/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/applika/schemas/application.py b/cli/src/applika/schemas/application.py new file mode 100644 index 0000000..fc357d7 --- /dev/null +++ b/cli/src/applika/schemas/application.py @@ -0,0 +1,71 @@ +import datetime + +from pydantic import BaseModel, HttpUrl, model_validator +from typing_extensions import TypedDict + +from applika.schemas.enums import ( + ApplicationMode, + Currency, + ExperienceLevel, + SalaryPeriod, + WorkMode, +) + + +class ApplicationCompany(TypedDict): + name: str + url: HttpUrl | None + + +class ApplicationCreate(BaseModel): + company: str | ApplicationCompany + role: str + mode: ApplicationMode + platform_id: str + application_date: datetime.date + link_to_job: HttpUrl | None = None + observation: str | None = None + expected_salary: float | None = None + salary_range_min: float | None = None + salary_range_max: float | None = None + currency: Currency | None = None + salary_period: SalaryPeriod | None = None + experience_level: ExperienceLevel | None = None + work_mode: WorkMode | None = None + country: str | None = None + + @model_validator(mode='after') + def validate_salary(self) -> 'ApplicationCreate': + amounts = [ + self.expected_salary, + self.salary_range_min, + self.salary_range_max, + ] + if any(v is not None for v in amounts): + if self.currency is None or self.salary_period is None: + raise ValueError( + 'currency and salary-period are required when any salary field is set' + ) + return self + + +class ApplicationUpdate(ApplicationCreate): ... + + +class ApplicationEntry(TypedDict): + id: str + company: ApplicationCompany + role: str + mode: str + platform_id: str + application_date: str + link_to_job: str | None + observation: str | None + expected_salary: float | None + salary_range_min: float | None + salary_range_max: float | None + currency: str | None + salary_period: str | None + experience_level: str | None + work_mode: str | None + country: str | None diff --git a/cli/src/applika/schemas/enums.py b/cli/src/applika/schemas/enums.py new file mode 100644 index 0000000..b28ca56 --- /dev/null +++ b/cli/src/applika/schemas/enums.py @@ -0,0 +1,72 @@ +from enum import Enum + + +class Currency(str, Enum): + USD = 'USD' + BRL = 'BRL' + EUR = 'EUR' + GBP = 'GBP' + CAD = 'CAD' + AUD = 'AUD' + JPY = 'JPY' + CHF = 'CHF' + INR = 'INR' + + +class SalaryPeriod(str, Enum): + HOURLY = 'hourly' + MONTHLY = 'monthly' + ANNUAL = 'annual' + + +class ExperienceLevel(str, Enum): + INTERN = 'intern' + JUNIOR = 'junior' + MID_LEVEL = 'mid_level' + SENIOR = 'senior' + STAFF = 'staff' + LEAD = 'lead' + PRINCIPAL = 'principal' + SPECIALIST = 'specialist' + + +class WorkMode(str, Enum): + REMOTE = 'remote' + HYBRID = 'hybrid' + ON_SITE = 'on_site' + + +class ApplicationMode(str, Enum): + ACTIVE = 'active' + PASSIVE = 'passive' + + +class ModeFilter(str, Enum): + ACTIVE = 'active' + PASSIVE = 'passive' + ALL = 'all' + + +class StatusFilter(str, Enum): + ACTIVE = 'active' + FINALIZED = 'finalized' + ALL = 'all' + + +class OutputFormat(str, Enum): + TABLE = 'table' + JSON = 'json' + + +class ClearField(str, Enum): + OBSERVATION = 'observation' + JOB_URL = 'job_url' + COUNTRY = 'country' + EXPERIENCE_LEVEL = 'experience_level' + WORK_MODE = 'work_mode' + EXPECTED_SALARY = 'expected_salary' + SALARY_MIN = 'salary_min' + SALARY_MAX = 'salary_max' + CURRENCY = 'currency' + SALARY_PERIOD = 'salary_period' + SALARY = 'salary' diff --git a/cli/src/applika/schemas/supports.py b/cli/src/applika/schemas/supports.py new file mode 100644 index 0000000..85d2792 --- /dev/null +++ b/cli/src/applika/schemas/supports.py @@ -0,0 +1,32 @@ +from typing_extensions import TypedDict + + +class FeedbackDefinitionSchema(TypedDict): + id: str + name: str + color: str + + +class StepDefinitionSchema(TypedDict): + id: str + name: str + color: str + strict: bool + + +class PlatformSchema(TypedDict): + id: str + name: str + url: str + + +class SupportSchema(TypedDict): + feedbacks: list[FeedbackDefinitionSchema] + steps: list[StepDefinitionSchema] + platforms: list[PlatformSchema] + + +class Company(TypedDict): + id: str + name: str + url: str diff --git a/cli/src/applika/skills/applika-cli/SKILL.md b/cli/src/applika/skills/applika-cli/SKILL.md new file mode 100644 index 0000000..8ebae5e --- /dev/null +++ b/cli/src/applika/skills/applika-cli/SKILL.md @@ -0,0 +1,254 @@ +--- +name: applika-cli +description: > + How to use the applika-cli tool to track job applications via the Applika.dev API. + Use this skill whenever the user mentions "applika", wants to log, list, create, or + edit job applications via the CLI, asks about the applika login flow, or wants to + interact with the applika CLI in any way. Login is always the first step — verify + authentication with `applika whoami` before running any other command. +--- + +# applika-cli Usage Guide + +`applika-cli` is a terminal tool for tracking job applications on Applika.dev. +It uses GitHub OAuth for login and stores a session cookie locally. + +## 1. Pre-flight: Verify authentication first + +**Always run `applika whoami` before doing anything else.** + +```bash +applika whoami +# OK: Logged in as: username=luissoares name=Luis Soares email=luis@example.com +# Fail: Auth error: no session found +``` + +If `whoami` prints an auth error, run login before proceeding: + +```bash +applika login +``` + +`applika login` opens the user's browser to GitHub OAuth. The CLI listens on a +local loopback port for the OAuth callback — the user just clicks "Authorize" +in the browser and the session is saved automatically. + +**Important:** this is a browser-based flow. Claude cannot automate it. Tell +the user to complete the browser step and confirm when done, then re-run +`applika whoami` to verify the session is active. + +### Login failure recovery + +| Symptom | Cause | Action | +|---------|-------|--------| +| `Auth error: no session found` | Not logged in | Run `applika login` | +| `Login state mismatch` | Stale callback URL | Retry `applika login` | +| `401` on exchange | Code expired in browser | Retry `applika login` | +| Browser doesn't open | Headless environment | Copy the printed URL manually | + +### Logout + +```bash +applika logout +``` + +--- + +## 2. Listing Applications + +```bash +# All applications in the active cycle +applika applications list + +# With filters +applika applications list \ + --search "google" \ + --mode active \ + --status active \ + --platform LinkedIn \ + --from 2026-01-01 \ + --to 2026-06-30 + +# Machine-readable output for piping/parsing +applika applications list --output-format json + +# Filter to a specific job-search cycle +applika applications list --cycle-id +``` + +### Filter options + +| Flag | Values | Default | +|------|--------|---------| +| `--search TEXT` | Substring match on company name or role | — | +| `--mode` | `active` · `passive` · `all` | `all` | +| `--status` | `active` · `finalized` · `all` | `all` | +| `--platform TEXT` | Exact platform name (e.g. `LinkedIn`) | — | +| `--from YYYY-MM-DD` | Inclusive lower bound on application date | — | +| `--to YYYY-MM-DD` | Inclusive upper bound on application date | — | +| `--output-format` | `table` · `json` | `table` | +| `--cycle-id TEXT` | Filter to a specific cycle (snowflake ID — a large integer string from the API) | — | + +--- + +## 3. Creating an Application + +```bash +applika applications new \ + --company "Google" \ + --role "Senior Software Engineer" \ + --platform "LinkedIn" \ + --mode active \ + --date 2026-05-10 +``` + +### Required flags + +| Flag | Description | +|------|-------------| +| `--company TEXT` | Company name | +| `--role TEXT` | Job title | +| `--platform TEXT` | Platform name (e.g. `LinkedIn`, `Indeed`, `Glassdoor`) | +| `--mode` | `active` (you applied) · `passive` (recruiter reached out) | +| `--date YYYY-MM-DD` | Date of application | + +### Optional flags + +| Flag | Description | Notes | +|------|-------------|-------| +| `--company-url URL` | Company website | | +| `--job-url URL` | Link to job posting | | +| `--observation TEXT` | Free-form notes | | +| `--country TEXT` | Country of the job | | +| `--experience-level` | `intern` · `junior` · `mid_level` · `senior` · `staff` · `lead` · `principal` · `specialist` | | +| `--work-mode` | `remote` · `hybrid` · `on_site` | | +| `--expected-salary FLOAT` | Expected salary amount | Requires `--currency` + `--salary-period` | +| `--salary-min FLOAT` | Salary range minimum | Requires `--currency` + `--salary-period` | +| `--salary-max FLOAT` | Salary range maximum | Requires `--currency` + `--salary-period` | +| `--currency` | `USD` · `BRL` · `EUR` · `GBP` · `CAD` · `AUD` · `JPY` · `CHF` · `INR` | Required when any salary field is set | +| `--salary-period` | `hourly` · `monthly` · `annual` | Required when any salary field is set | + +**Salary cross-field rule:** if any salary amount is provided (`--expected-salary`, +`--salary-min`, or `--salary-max`), both `--currency` and `--salary-period` are +required. The CLI validates this before calling the API and prints a clear error. + +--- + +## 4. Editing an Application + +```bash +# Update specific fields — unspecified fields keep their current values +applika applications edit \ + --role "Staff Engineer" \ + --company "Acme" + +# Clear optional fields explicitly (--clear is repeatable) +applika applications edit \ + --clear job_url \ + --clear observation \ + --clear country \ + --clear salary +``` + +`application-id` is the snowflake `id` from the list output — a large integer +encoded as a string (e.g. `"1234567890123456789"`). Use +`applika applications list --output-format json` to find it. + +### --clear flag + +`--clear ` sets a nullable field to null. It is repeatable — pass it +once per field. + +| Value | Effect | +|-------|--------| +| `observation` | Set observation to null | +| `job_url` | Set job URL to null | +| `country` | Set country to null | +| `experience_level` | Set experience level to null | +| `work_mode` | Set work mode to null | +| `expected_salary` | Set expected salary to null | +| `salary_min` | Set salary range minimum to null | +| `salary_max` | Set salary range maximum to null | +| `currency` | Set currency to null | +| `salary_period` | Set salary period to null | +| `salary` | Null out all salary fields at once (shortcut for the five above) | + +Finalized applications cannot be edited — the CLI rejects them before calling the API. + +All `new` optional flags are also available on `edit`. + +--- + +## 5. Global Option + +```bash +applika --api-base-url https://staging.applika.dev/api applications list +``` + +| Flag | Default | Env override | +|------|---------|-------------| +| `--api-base-url TEXT` | `https://applika.dev/api` | `APPLIKA_API_BASE_URL` | + +--- + +## 6. Common Workflows + +### Log a job application right now + +```bash +applika whoami # confirm session first +applika applications new \ + --company "Stripe" \ + --role "Backend Engineer" \ + --platform "LinkedIn" \ + --mode active \ + --date "$(date +%Y-%m-%d)" +``` + +### Find an application's ID to edit it + +```bash +applika applications list --search "stripe" --output-format json +# returns JSON array — inspect the "id" field (a snowflake string like "1234567890123456789") +applika applications edit 1234567890123456789 --role "Staff Engineer" +``` + +### Log a passive lead (recruiter reached out) + +```bash +applika applications new \ + --company "Cloudflare" \ + --role "Systems Engineer" \ + --platform "Email" \ + --mode passive \ + --date 2026-05-11 \ + --observation "Recruiter cold-messaged on LinkedIn" +``` + +### Add salary info after receiving an offer + +```bash +applika applications edit 1234567890123456789 \ + --expected-salary 180000 \ + --currency USD \ + --salary-period annual +``` + +--- + +## 7. Installation (if not already installed) + +```bash +# Requires Python 3.10+ and uv +uv tool install applika-cli + +# Or from a local source checkout +uv tool install --force . +``` + +Verify: + +```bash +applika --help +applika whoami +``` diff --git a/cli/src/applika/utils/__init__.py b/cli/src/applika/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/src/applications/view.py b/cli/src/applika/utils/output.py similarity index 100% rename from cli/src/applications/view.py rename to cli/src/applika/utils/output.py diff --git a/cli/src/auth_commands.py b/cli/src/auth_commands.py deleted file mode 100644 index 0687ad8..0000000 --- a/cli/src/auth_commands.py +++ /dev/null @@ -1,64 +0,0 @@ -import argparse -import secrets -import webbrowser - -import httpx - -from api import ApiClient, create_session_from_exchange -from cli_context import CommandContext, require_session -from loopback import LoopbackLoginServer - - -def handle_login(args: argparse.Namespace, context: CommandContext) -> int: - state = secrets.token_urlsafe(24) - server = LoopbackLoginServer(expected_state=state) - server.start() - - try: - response = httpx.post( - f'{context.api_base_url}/auth/cli/start', - json={ - 'callback_url': server.callback_url, - 'state': state, - }, - timeout=30, - ) - response.raise_for_status() - - data = response.json() - login_url = data['login_url'] - browser_opened = webbrowser.open(login_url) - if not browser_opened: - print(f'Open this URL to continue login:\n{login_url}') - - code = server.wait_for_code(timeout_seconds=300) - exchange = httpx.post( - f'{context.api_base_url}/auth/cli/exchange', - json={'code': code}, - timeout=30, - ) - exchange.raise_for_status() - - session = create_session_from_exchange( - context.api_base_url, - exchange.json(), - ) - context.store.save(session) - - print('Login successful.') - return 0 - finally: - server.close() - - -def handle_logout(args: argparse.Namespace, context: CommandContext) -> int: - session = require_session(context.store) - client = ApiClient(session, context.store) - - try: - client.logout() - finally: - client.close() - - print('Logged out.') - return 0 diff --git a/cli/src/choices.py b/cli/src/choices.py deleted file mode 100644 index 98f2943..0000000 --- a/cli/src/choices.py +++ /dev/null @@ -1,25 +0,0 @@ -MODE_CHOICES = ('active', 'passive', 'all') -STATUS_CHOICES = ('active', 'finalized', 'all') -WORK_MODE_CHOICES = ('remote', 'hybrid', 'on_site') -EXPERIENCE_CHOICES = ( - 'intern', - 'junior', - 'mid_level', - 'senior', - 'staff', - 'lead', - 'principal', - 'specialist', -) -CURRENCY_CHOICES = ( - 'USD', - 'BRL', - 'EUR', - 'GBP', - 'CAD', - 'AUD', - 'JPY', - 'CHF', - 'INR', -) -SALARY_PERIOD_CHOICES = ('hourly', 'monthly', 'annual') diff --git a/cli/src/cli_context.py b/cli/src/cli_context.py deleted file mode 100644 index 2fec1ad..0000000 --- a/cli/src/cli_context.py +++ /dev/null @@ -1,47 +0,0 @@ -from dataclasses import dataclass -from datetime import date - -from api import AuthError -from session import SessionData, SessionStore - - -@dataclass -class CommandContext: - api_base_url: str - store: SessionStore - - -def normalize_argv(argv: list[str]) -> list[str]: - if not argv: - return argv - - if argv[0] != 'applications': - return argv - - if len(argv) == 1: - return ['applications', 'list'] - - if argv[1] == '-n': - return ['applications', 'new', *argv[2:]] - - if argv[1] not in {'list', 'new', 'edit'}: - return ['applications', 'list', *argv[1:]] - - return argv - - -def require_session(store: SessionStore) -> SessionData: - session = store.try_load() - if not session: - raise AuthError('Please run `applika login` first.') - - return session - - -def parse_date(value: str) -> date: - return date.fromisoformat(value) - - -def ensure_date_string(value: str) -> str: - parse_date(value) - return value diff --git a/cli/src/cli_parser.py b/cli/src/cli_parser.py deleted file mode 100644 index 28046bc..0000000 --- a/cli/src/cli_parser.py +++ /dev/null @@ -1,54 +0,0 @@ -import argparse - -from applications import ( - add_application_args, - handle_applications_edit, - handle_applications_list, - handle_applications_new, -) -from auth_commands import handle_login, handle_logout -from choices import MODE_CHOICES, STATUS_CHOICES - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog='applika') - parser.add_argument('--api-base-url') - subparsers = parser.add_subparsers(dest='command', required=True) - - login_parser = subparsers.add_parser('login') - login_parser.set_defaults(handler=handle_login) - - logout_parser = subparsers.add_parser('logout') - logout_parser.set_defaults(handler=handle_logout) - - applications_parser = subparsers.add_parser('applications') - applications_subparsers = applications_parser.add_subparsers( - dest='applications_command', - required=True, - ) - - list_parser = applications_subparsers.add_parser('list') - list_parser.add_argument('--cycle-id') - list_parser.add_argument('--search') - list_parser.add_argument('--mode', choices=MODE_CHOICES, default='all') - list_parser.add_argument('--status', choices=STATUS_CHOICES, default='all') - list_parser.add_argument('--platform') - list_parser.add_argument('--from', dest='from_date') - list_parser.add_argument('--to', dest='to_date') - list_parser.add_argument('--json', action='store_true') - list_parser.set_defaults(handler=handle_applications_list) - - new_parser = applications_subparsers.add_parser('new') - add_application_args(new_parser, require_all=True) - new_parser.set_defaults(handler=handle_applications_new) - - edit_parser = applications_subparsers.add_parser('edit') - edit_parser.add_argument('application_id') - add_application_args(edit_parser, require_all=False) - edit_parser.add_argument('--clear-job-url', action='store_true') - edit_parser.add_argument('--clear-observation', action='store_true') - edit_parser.add_argument('--clear-country', action='store_true') - edit_parser.add_argument('--clear-salary', action='store_true') - edit_parser.set_defaults(handler=handle_applications_edit) - - return parser diff --git a/cli/src/main.py b/cli/src/main.py deleted file mode 100644 index 63de396..0000000 --- a/cli/src/main.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys - -import httpx - -from api import ApiError, AuthError -from cli_context import CommandContext, normalize_argv -from cli_parser import build_parser -from session import SessionStore, resolve_api_base_url - - -def main(argv: list[str] | None = None) -> int: - argv = normalize_argv(list(argv or sys.argv[1:])) - parser = build_parser() - args = parser.parse_args(argv) - - store = SessionStore() - context = CommandContext( - api_base_url=resolve_api_base_url(args.api_base_url, store), - store=store, - ) - - try: - return args.handler(args, context) - except ( - ApiError, - AuthError, - RuntimeError, - ValueError, - httpx.HTTPError, - ) as error: - print(str(error), file=sys.stderr) - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..95bc141 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,89 @@ +import pytest +from typer.testing import CliRunner + +from applika.lib.session import SessionData + + +@pytest.fixture +def runner(): + return CliRunner() + + +def make_session() -> SessionData: + return SessionData( + api_base_url='http://127.0.0.1:8000/api', + access_token='access', + refresh_token='refresh', + access_expires_at='2026-05-08T10:00:00+00:00', + ) + + +class FakeStore: + def __init__(self, session: SessionData | None = None): + self.session = session + self.saved: SessionData | None = None + self.cleared = False + + def try_load(self) -> SessionData | None: + return self.session + + def save(self, session: SessionData) -> None: + self.saved = session + self.session = session + + def clear(self) -> None: + self.cleared = True + self.session = None + + +class FakeApiClient: + applications = [] + supports = {'platforms': []} + company_matches = [] + created_response = {} + updated_response = {} + whoami_response = {} + captured_post_payload = None + captured_put_payload = None + captured_application_params = None + + def __init__(self, session, store): + self.session = session + self.store = store + + def close(self) -> None: + return None + + def get_json(self, path, *, params=None): + if path == '/applications': + FakeApiClient.captured_application_params = params + return FakeApiClient.applications + if path == '/supports': + return FakeApiClient.supports + if path == '/companies': + return FakeApiClient.company_matches + if path == '/users/me': + return FakeApiClient.whoami_response + raise AssertionError(f'Unexpected GET path: {path}') + + def post_json(self, path, payload): + assert path == '/applications' + FakeApiClient.captured_post_payload = payload + return FakeApiClient.created_response + + def put_json(self, path, payload): + assert path.startswith('/applications/') + FakeApiClient.captured_put_payload = payload + return FakeApiClient.updated_response + + +def reset_fake_client(): + FakeApiClient.applications = [] + FakeApiClient.supports = {'platforms': []} + FakeApiClient.company_matches = [] + FakeApiClient.created_response = {} + FakeApiClient.updated_response = {} + FakeApiClient.whoami_response = {} + FakeApiClient.captured_post_payload = None + FakeApiClient.captured_put_payload = None + FakeApiClient.captured_application_params = None diff --git a/cli/tests/test_api.py b/cli/tests/test_api.py index 72d2df4..063a3a6 100644 --- a/cli/tests/test_api.py +++ b/cli/tests/test_api.py @@ -3,8 +3,8 @@ import httpx -from api import ApiClient -from session import SessionData, SessionStore +from applika.lib.api import ApiClient +from applika.lib.session import SessionData, SessionStore def _jwt_like(exp: int) -> str: diff --git a/cli/tests/test_commands.py b/cli/tests/test_commands.py new file mode 100644 index 0000000..12df44a --- /dev/null +++ b/cli/tests/test_commands.py @@ -0,0 +1,459 @@ +import json + +import httpx +from conftest import FakeApiClient, FakeStore, make_session, reset_fake_client + +import applika.app as cli_app +import applika.commands.applications.commands as applications_commands +import applika.commands.auth as auth_commands +import applika.commands.skill as skill_module +from applika.app import app + + +def _setup_auth(monkeypatch, session=None): + store = FakeStore(session or make_session()) + monkeypatch.setattr(cli_app, 'SessionStore', lambda: store) + reset_fake_client() + monkeypatch.setattr(auth_commands, 'ApiClient', FakeApiClient) + return store + + +def _response(status_code: int, *, json_data=None, url: str): + request = httpx.Request('POST', url) + return httpx.Response(status_code, json=json_data, request=request) + + +def _setup(monkeypatch, session=None): + store = FakeStore(session or make_session()) + monkeypatch.setattr(cli_app, 'SessionStore', lambda: store) + reset_fake_client() + monkeypatch.setattr(applications_commands, 'ApiClient', FakeApiClient) + return store + + +def test_applications_list_filters_and_outputs_json(monkeypatch, runner): + _setup(monkeypatch) + FakeApiClient.supports = { + 'platforms': [ + {'id': '10', 'name': 'LinkedIn'}, + {'id': '11', 'name': 'Indeed'}, + ] + } + FakeApiClient.applications = [ + { + 'id': '1', + 'application_date': '2026-05-08', + 'company_name': 'Acme', + 'role': 'Backend Engineer', + 'mode': 'active', + 'platform_id': '10', + 'finalized': False, + }, + { + 'id': '2', + 'application_date': '2026-05-07', + 'company_name': 'Other', + 'role': 'Designer', + 'mode': 'passive', + 'platform_id': '11', + 'finalized': True, + }, + ] + + result = runner.invoke( + app, + [ + '--api-base-url', + 'http://api.test/api', + 'applications', + 'list', + '--cycle-id', + '77', + '--search', + 'acme', + '--platform', + 'LinkedIn', + '--status', + 'active', + '--output-format', + 'json', + ], + ) + + assert result.exit_code == 0 + assert FakeApiClient.captured_application_params == {'cycle_id': '77'} + output = json.loads(result.output) + assert output == [FakeApiClient.applications[0]] + + +def test_applications_new_builds_matching_payload(monkeypatch, runner): + _setup(monkeypatch) + FakeApiClient.supports = {'platforms': [{'id': '10', 'name': 'LinkedIn'}]} + FakeApiClient.company_matches = [{'id': '55', 'name': 'Acme'}] + FakeApiClient.created_response = { + 'id': '42', + 'company_name': 'Acme', + 'role': 'Platform Engineer', + 'application_date': '2026-05-08', + } + + result = runner.invoke( + app, + [ + 'applications', + 'new', + '--company', + 'Acme', + '--role', + 'Platform Engineer', + '--platform', + 'LinkedIn', + '--mode', + 'active', + '--date', + '2026-05-08', + '--job-url', + 'https://jobs.example/acme', + '--country', + 'Brazil', + '--salary-min', + '1000', + '--salary-max', + '2000', + '--currency', + 'USD', + '--salary-period', + 'annual', + ], + ) + + assert result.exit_code == 0 + assert FakeApiClient.captured_post_payload == { + 'company': '55', + 'platform_id': '10', + 'role': 'Platform Engineer', + 'mode': 'active', + 'application_date': '2026-05-08', + 'link_to_job': 'https://jobs.example/acme', + 'observation': None, + 'country': 'Brazil', + 'currency': 'USD', + 'salary_period': 'annual', + 'expected_salary': None, + 'salary_range_min': 1000.0, + 'salary_range_max': 2000.0, + 'experience_level': None, + 'work_mode': None, + } + assert ( + 'Created application: id=42 company=Acme role=Platform Engineer' + in result.output + ) + + +def test_applications_new_salary_validation_fails_without_currency( + monkeypatch, runner +): + _setup(monkeypatch) + FakeApiClient.supports = {'platforms': [{'id': '10', 'name': 'LinkedIn'}]} + + result = runner.invoke( + app, + [ + 'applications', + 'new', + '--company', + 'Acme', + '--role', + 'Engineer', + '--platform', + 'LinkedIn', + '--mode', + 'active', + '--date', + '2026-05-08', + '--expected-salary', + '5000', + ], + ) + + assert result.exit_code == 1 + assert 'currency and salary-period are required' in result.output + + +def test_applications_edit_merges_existing_and_clear_flags(monkeypatch, runner): + _setup(monkeypatch) + FakeApiClient.supports = {'platforms': [{'id': '10', 'name': 'LinkedIn'}]} + FakeApiClient.company_matches = [{'id': '88', 'name': 'NewCo'}] + FakeApiClient.applications = [ + { + 'id': '99', + 'company_id': None, + 'company_name': 'OldCo', + 'platform_id': '10', + 'role': 'Backend Engineer', + 'mode': 'active', + 'application_date': '2026-05-01', + 'link_to_job': 'https://jobs.example/old', + 'observation': 'note', + 'country': 'Brazil', + 'currency': 'USD', + 'salary_period': 'annual', + 'expected_salary': 1500.0, + 'salary_range_min': 1000.0, + 'salary_range_max': 2000.0, + 'experience_level': 'senior', + 'work_mode': 'remote', + 'finalized': False, + } + ] + FakeApiClient.updated_response = { + 'id': '99', + 'company_name': 'NewCo', + 'role': 'Staff Engineer', + 'application_date': '2026-05-01', + } + + result = runner.invoke( + app, + [ + 'applications', + 'edit', + '99', + '--company', + 'NewCo', + '--role', + 'Staff Engineer', + '--clear', + 'job_url', + '--clear', + 'observation', + '--clear', + 'country', + '--clear', + 'salary', + ], + ) + + assert result.exit_code == 0 + assert FakeApiClient.captured_put_payload == { + 'company': '88', + 'platform_id': '10', + 'role': 'Staff Engineer', + 'mode': 'active', + 'application_date': '2026-05-01', + 'link_to_job': None, + 'observation': None, + 'country': None, + 'currency': None, + 'salary_period': None, + 'expected_salary': None, + 'salary_range_min': None, + 'salary_range_max': None, + 'experience_level': 'senior', + 'work_mode': 'remote', + } + assert ( + 'Updated application: id=99 company=NewCo role=Staff Engineer' + in result.output + ) + + +def test_applications_edit_rejects_finalized(monkeypatch, runner): + _setup(monkeypatch) + FakeApiClient.applications = [{'id': '1', 'finalized': True}] + + result = runner.invoke(app, ['applications', 'edit', '1']) + + assert result.exit_code == 1 + assert 'Finalized applications cannot be edited' in result.output + + +def test_login_error_on_state_mismatch(monkeypatch, runner): + store = FakeStore() + monkeypatch.setattr(cli_app, 'SessionStore', lambda: store) + + class FakeServer: + def __init__(self, expected_state): + self.callback_url = 'http://127.0.0.1:43129/callback' + + def start(self): + return None + + def wait_for_code(self, timeout_seconds): + raise RuntimeError('Login state mismatch') + + def close(self): + return None + + responses = [ + _response( + 201, + json_data={'login_url': 'https://example.com/login'}, + url='http://127.0.0.1:8000/api/auth/cli/start', + ) + ] + monkeypatch.setattr(auth_commands, 'LoopbackLoginServer', FakeServer) + monkeypatch.setattr(auth_commands.webbrowser, 'open', lambda url: True) + monkeypatch.setattr( + auth_commands.httpx, 'post', lambda *a, **kw: responses.pop(0) + ) + + result = runner.invoke(app, ['login']) + + assert result.exit_code == 1 + assert store.saved is None + assert 'Login state mismatch' in result.output + + +def test_login_error_when_exchange_fails(monkeypatch, runner): + store = FakeStore() + monkeypatch.setattr(cli_app, 'SessionStore', lambda: store) + + class FakeServer: + def __init__(self, expected_state): + self.callback_url = 'http://127.0.0.1:43129/callback' + + def start(self): + return None + + def wait_for_code(self, timeout_seconds): + return 'exchange-code' + + def close(self): + return None + + responses = [ + _response( + 201, + json_data={'login_url': 'https://example.com/login'}, + url='http://127.0.0.1:8000/api/auth/cli/start', + ), + _response( + 401, + json_data={'detail': 'Invalid or expired CLI exchange code'}, + url='http://127.0.0.1:8000/api/auth/cli/exchange', + ), + ] + monkeypatch.setattr(auth_commands, 'LoopbackLoginServer', FakeServer) + monkeypatch.setattr(auth_commands.webbrowser, 'open', lambda url: True) + monkeypatch.setattr( + auth_commands.httpx, 'post', lambda *a, **kw: responses.pop(0) + ) + + result = runner.invoke(app, ['login']) + + assert result.exit_code == 1 + assert store.saved is None + assert '401' in result.output + + +def test_whoami_prints_user_info(monkeypatch, runner): + _setup_auth(monkeypatch) + FakeApiClient.whoami_response = { + 'username': 'luissoares', + 'email': 'luis@example.com', + 'first_name': 'Luis', + 'last_name': 'Soares', + } + + result = runner.invoke(app, ['whoami']) + + assert result.exit_code == 0 + assert 'username=luissoares' in result.output + assert 'name=Luis Soares' in result.output + assert 'email=luis@example.com' in result.output + + +def test_whoami_works_without_name(monkeypatch, runner): + _setup_auth(monkeypatch) + FakeApiClient.whoami_response = { + 'username': 'ghost', + 'email': 'ghost@example.com', + } + + result = runner.invoke(app, ['whoami']) + + assert result.exit_code == 0 + assert 'username=ghost' in result.output + assert ' name=' not in result.output + + +def test_skill_dry_run(monkeypatch, runner, tmp_path): + tools = [('Claude', tmp_path / 'skills')] + monkeypatch.setattr(skill_module, '_TOOLS', tools) + + result = runner.invoke( + app, ['skill', '--dir', str(tmp_path / 'custom'), '--dry-run'] + ) + + assert result.exit_code == 0 + assert '[dry-run]' in result.output + assert not (tmp_path / 'custom').exists() + + +def test_skill_dir_copies(monkeypatch, runner, tmp_path): + result = runner.invoke(app, ['skill', '--dir', str(tmp_path / 'skills')]) + + assert result.exit_code == 0 + dest = tmp_path / 'skills' / 'applika-cli' + assert dest.is_dir() + assert (dest / 'SKILL.md').exists() + + +def test_skill_local_copies_to_cwd(monkeypatch, runner, tmp_path): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ['skill', '--local']) + + assert result.exit_code == 0 + dest = tmp_path / '.claude' / 'skills' / 'applika-cli' + assert dest.is_dir() + assert (dest / 'SKILL.md').exists() + + +def test_skill_interactive_single_tool(monkeypatch, runner, tmp_path): + tools = [('Claude', tmp_path / 'claude-skills')] + monkeypatch.setattr(skill_module, '_TOOLS', tools) + + result = runner.invoke(app, ['skill'], input='1\n') + + assert result.exit_code == 0 + dest = tmp_path / 'claude-skills' / 'applika-cli' + assert dest.is_dir() or dest.is_symlink() + + +def test_skill_interactive_all_tools(monkeypatch, runner, tmp_path): + tools = [ + ('Claude', tmp_path / 'claude'), + ('Gemini', tmp_path / 'gemini'), + ] + monkeypatch.setattr(skill_module, '_TOOLS', tools) + + result = runner.invoke(app, ['skill'], input='3\n') # "All" = len(tools)+1 + + assert result.exit_code == 0 + assert (tmp_path / 'claude' / 'applika-cli').exists() + assert (tmp_path / 'gemini' / 'applika-cli').exists() + + +def test_skill_skips_if_already_installed(monkeypatch, runner, tmp_path): + dest = tmp_path / 'skills' / 'applika-cli' + dest.mkdir(parents=True) + + result = runner.invoke(app, ['skill', '--dir', str(tmp_path / 'skills')]) + + assert result.exit_code == 0 + assert 'Skipped' in result.output + + +def test_skill_force_overwrites(monkeypatch, runner, tmp_path): + dest = tmp_path / 'skills' / 'applika-cli' + dest.mkdir(parents=True) + + result = runner.invoke( + app, ['skill', '--dir', str(tmp_path / 'skills'), '--force'] + ) + + assert result.exit_code == 0 + assert (dest / 'SKILL.md').exists() diff --git a/cli/tests/test_main.py b/cli/tests/test_main.py deleted file mode 100644 index f52b540..0000000 --- a/cli/tests/test_main.py +++ /dev/null @@ -1,417 +0,0 @@ -import json - -import httpx - -import applications.commands as applications_commands -import auth_commands -import main as cli_main -from session import SessionData - - -def _session() -> SessionData: - return SessionData( - api_base_url='http://127.0.0.1:8000/api', - access_token='access', - refresh_token='refresh', - access_expires_at='2026-05-08T10:00:00+00:00', - ) - - -class FakeStore: - def __init__(self, session: SessionData | None = None): - self.session = session - self.saved: SessionData | None = None - self.cleared = False - - def try_load(self) -> SessionData | None: - return self.session - - def save(self, session: SessionData) -> None: - self.saved = session - self.session = session - - def clear(self) -> None: - self.cleared = True - self.session = None - - -class FakeApiClient: - applications = [] - supports = {'platforms': []} - company_matches = [] - created_response = {} - updated_response = {} - captured_post_payload = None - captured_put_payload = None - captured_application_params = None - - def __init__(self, session, store): - self.session = session - self.store = store - - def close(self) -> None: - return None - - def get_json(self, path, *, params=None): - if path == '/applications': - FakeApiClient.captured_application_params = params - return FakeApiClient.applications - if path == '/supports': - return FakeApiClient.supports - if path == '/companies': - return FakeApiClient.company_matches - raise AssertionError(f'Unexpected GET path: {path}') - - def post_json(self, path, payload): - assert path == '/applications' - FakeApiClient.captured_post_payload = payload - return FakeApiClient.created_response - - def put_json(self, path, payload): - assert path.startswith('/applications/') - FakeApiClient.captured_put_payload = payload - return FakeApiClient.updated_response - - -def _install_fake_store(monkeypatch, store: FakeStore) -> None: - monkeypatch.setattr(cli_main, 'SessionStore', lambda: store) - - -def _install_fake_api_client(monkeypatch) -> None: - FakeApiClient.applications = [] - FakeApiClient.supports = {'platforms': []} - FakeApiClient.company_matches = [] - FakeApiClient.created_response = {} - FakeApiClient.updated_response = {} - FakeApiClient.captured_post_payload = None - FakeApiClient.captured_put_payload = None - FakeApiClient.captured_application_params = None - monkeypatch.setattr(applications_commands, 'ApiClient', FakeApiClient) - - -def _response( - status_code: int, - *, - json_data=None, - url: str, -): - request = httpx.Request('POST', url) - response = httpx.Response(status_code, json=json_data, request=request) - return response - - -def test_applications_list_filters_and_outputs_json( - monkeypatch, - capsys, -): - store = FakeStore(_session()) - _install_fake_store(monkeypatch, store) - _install_fake_api_client(monkeypatch) - - FakeApiClient.supports = { - 'platforms': [ - {'id': 10, 'name': 'LinkedIn'}, - {'id': 11, 'name': 'Indeed'}, - ] - } - FakeApiClient.applications = [ - { - 'id': 1, - 'application_date': '2026-05-08', - 'company_name': 'Acme', - 'role': 'Backend Engineer', - 'mode': 'active', - 'platform_id': 10, - 'finalized': False, - }, - { - 'id': 2, - 'application_date': '2026-05-07', - 'company_name': 'Other', - 'role': 'Designer', - 'mode': 'passive', - 'platform_id': 11, - 'finalized': True, - }, - ] - - exit_code = cli_main.main([ - '--api-base-url', - 'http://api.test/api', - 'applications', - 'list', - '--cycle-id', - '77', - '--search', - 'acme', - '--platform', - 'LinkedIn', - '--status', - 'active', - '--json', - ]) - - assert exit_code == 0 - assert FakeApiClient.captured_application_params == {'cycle_id': '77'} - output = json.loads(capsys.readouterr().out) - assert output == [FakeApiClient.applications[0]] - - -def test_applications_new_builds_ui_matching_payload(monkeypatch, capsys): - store = FakeStore(_session()) - _install_fake_store(monkeypatch, store) - _install_fake_api_client(monkeypatch) - - FakeApiClient.supports = {'platforms': [{'id': 10, 'name': 'LinkedIn'}]} - FakeApiClient.company_matches = [{'id': 55, 'name': 'Acme'}] - FakeApiClient.created_response = { - 'id': 42, - 'company_name': 'Acme', - 'role': 'Platform Engineer', - 'application_date': '2026-05-08', - } - - exit_code = cli_main.main([ - 'applications', - 'new', - '--company', - 'Acme', - '--role', - 'Platform Engineer', - '--platform', - 'LinkedIn', - '--mode', - 'active', - '--date', - '2026-05-08', - '--job-url', - 'https://jobs.example/acme', - '--country', - 'Brazil', - '--salary-min', - '1000', - '--salary-max', - '2000', - '--currency', - 'USD', - '--salary-period', - 'annual', - ]) - - assert exit_code == 0 - assert FakeApiClient.captured_post_payload == { - 'company': '55', - 'platform_id': '10', - 'role': 'Platform Engineer', - 'mode': 'active', - 'application_date': '2026-05-08', - 'link_to_job': 'https://jobs.example/acme', - 'observation': None, - 'country': 'Brazil', - 'currency': 'USD', - 'salary_period': 'annual', - 'expected_salary': None, - 'salary_range_min': 1000.0, - 'salary_range_max': 2000.0, - 'experience_level': None, - 'work_mode': None, - } - assert ( - 'Created application: id=42 company=Acme role=Platform Engineer' - in capsys.readouterr().out - ) - - -def test_applications_edit_merges_existing_values_and_clear_flags( - monkeypatch, - capsys, -): - store = FakeStore(_session()) - _install_fake_store(monkeypatch, store) - _install_fake_api_client(monkeypatch) - - FakeApiClient.supports = {'platforms': [{'id': 10, 'name': 'LinkedIn'}]} - FakeApiClient.company_matches = [{'id': 88, 'name': 'NewCo'}] - FakeApiClient.applications = [ - { - 'id': 99, - 'company_id': None, - 'company_name': 'OldCo', - 'platform_id': 10, - 'role': 'Backend Engineer', - 'mode': 'active', - 'application_date': '2026-05-01', - 'link_to_job': 'https://jobs.example/old', - 'observation': 'note', - 'country': 'Brazil', - 'currency': 'USD', - 'salary_period': 'annual', - 'expected_salary': 1500.0, - 'salary_range_min': 1000.0, - 'salary_range_max': 2000.0, - 'experience_level': 'senior', - 'work_mode': 'remote', - 'finalized': False, - } - ] - FakeApiClient.updated_response = { - 'id': 99, - 'company_name': 'NewCo', - 'role': 'Staff Engineer', - 'application_date': '2026-05-01', - } - - exit_code = cli_main.main([ - 'applications', - 'edit', - '99', - '--company', - 'NewCo', - '--role', - 'Staff Engineer', - '--clear-job-url', - '--clear-observation', - '--clear-country', - '--clear-salary', - ]) - - assert exit_code == 0 - assert FakeApiClient.captured_put_payload == { - 'company': '88', - 'platform_id': '10', - 'role': 'Staff Engineer', - 'mode': 'active', - 'application_date': '2026-05-01', - 'link_to_job': None, - 'observation': None, - 'country': None, - 'currency': None, - 'salary_period': None, - 'expected_salary': None, - 'salary_range_min': None, - 'salary_range_max': None, - 'experience_level': 'senior', - 'work_mode': 'remote', - } - assert ( - 'Updated application: id=99 company=NewCo role=Staff Engineer' - in capsys.readouterr().out - ) - - -def test_applications_edit_rejects_finalized(monkeypatch, capsys): - store = FakeStore(_session()) - _install_fake_store(monkeypatch, store) - _install_fake_api_client(monkeypatch) - - FakeApiClient.applications = [ - { - 'id': 1, - 'finalized': True, - } - ] - - exit_code = cli_main.main(['applications', 'edit', '1']) - - assert exit_code == 1 - assert 'Finalized applications cannot be edited' in capsys.readouterr().err - - -def test_login_returns_error_on_state_mismatch(monkeypatch, capsys): - saved_sessions = [] - - class FakeLoginStore(FakeStore): - def save(self, session: SessionData) -> None: - saved_sessions.append(session) - super().save(session) - - store = FakeLoginStore() - _install_fake_store(monkeypatch, store) - - class FakeServer: - def __init__(self, expected_state: str): - self.callback_url = 'http://127.0.0.1:43129/callback' - self.closed = False - - def start(self) -> None: - return None - - def wait_for_code(self, timeout_seconds: int) -> str: - raise RuntimeError('Login state mismatch') - - def close(self) -> None: - self.closed = True - - responses = [ - _response( - 201, - json_data={'login_url': 'https://example.com/login'}, - url='http://127.0.0.1:8000/api/auth/cli/start', - ) - ] - - monkeypatch.setattr(auth_commands, 'LoopbackLoginServer', FakeServer) - monkeypatch.setattr(auth_commands.webbrowser, 'open', lambda url: True) - monkeypatch.setattr( - auth_commands.httpx, - 'post', - lambda *args, **kwargs: responses.pop(0), - ) - - exit_code = cli_main.main(['login']) - - assert exit_code == 1 - assert not saved_sessions - assert 'Login state mismatch' in capsys.readouterr().err - - -def test_login_returns_error_when_exchange_fails(monkeypatch, capsys): - saved_sessions = [] - - class FakeLoginStore(FakeStore): - def save(self, session: SessionData) -> None: - saved_sessions.append(session) - super().save(session) - - store = FakeLoginStore() - _install_fake_store(monkeypatch, store) - - class FakeServer: - def __init__(self, expected_state: str): - self.callback_url = 'http://127.0.0.1:43129/callback' - - def start(self) -> None: - return None - - def wait_for_code(self, timeout_seconds: int) -> str: - return 'exchange-code' - - def close(self) -> None: - return None - - responses = [ - _response( - 201, - json_data={'login_url': 'https://example.com/login'}, - url='http://127.0.0.1:8000/api/auth/cli/start', - ), - _response( - 401, - json_data={'detail': 'Invalid or expired CLI exchange code'}, - url='http://127.0.0.1:8000/api/auth/cli/exchange', - ), - ] - - monkeypatch.setattr(auth_commands, 'LoopbackLoginServer', FakeServer) - monkeypatch.setattr(auth_commands.webbrowser, 'open', lambda url: True) - monkeypatch.setattr( - auth_commands.httpx, - 'post', - lambda *args, **kwargs: responses.pop(0), - ) - - exit_code = cli_main.main(['login']) - - assert exit_code == 1 - assert not saved_sessions - assert '401' in capsys.readouterr().err diff --git a/cli/tests/test_session.py b/cli/tests/test_session.py index 61f88d7..5db53e2 100644 --- a/cli/tests/test_session.py +++ b/cli/tests/test_session.py @@ -1,9 +1,16 @@ import stat +import sys from pathlib import Path -from session import SessionData, SessionStore +import pytest +from applika.lib.session import SessionData, SessionStore + +@pytest.mark.skipif( + sys.platform == 'win32', + reason='Unix file permissions not enforced on Windows', +) def test_session_store_writes_restricted_file(tmp_path: Path): store = SessionStore(tmp_path / 'session.json') session = SessionData( diff --git a/cli/uv.lock b/cli/uv.lock index bcf8240..6b4e392 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -1,12 +1,31 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] [[package]] name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -17,10 +36,12 @@ wheels = [ [[package]] name = "applika-cli" -version = "0.1.0" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "pydantic" }, + { name = "typer" }, ] [package.dev-dependencies] @@ -30,7 +51,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.28.1" }] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "typer", specifier = ">=0.15.0" }, +] [package.metadata.requires-dev] dev = [ @@ -47,6 +72,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -56,6 +93,18 @@ 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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -111,6 +160,27 @@ 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 = "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 = "packaging" version = "26.2" @@ -129,6 +199,137 @@ 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/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { 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" @@ -144,16 +345,31 @@ version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[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.12" @@ -179,6 +395,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] +[[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 = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -187,3 +481,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]