From 590ef42977d4a3d188eee388eb77f83e6b5609f9 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Tue, 9 Dec 2025 11:22:16 -0500 Subject: [PATCH] feat: add dns-lookup CLI tool Professional DNS lookup CLI with beautiful Rich terminal output. Features: - Multi-record queries (A, AAAA, MX, NS, TXT, CNAME, SOA) - Reverse DNS lookup - DNS trace (visual resolution path from root to authoritative) - Batch lookups with async concurrency - WHOIS integration - JSON export for scripting Includes PyPI publish workflow for automated releases. --- .github/workflows/publish-dns-lookup.yml | 48 ++ PROJECTS/dns-lookup/.style.yapf | 47 ++ PROJECTS/dns-lookup/README.md | 174 +++++++ PROJECTS/dns-lookup/dnslookup/__init__.py | 7 + PROJECTS/dns-lookup/dnslookup/__main__.py | 11 + PROJECTS/dns-lookup/dnslookup/cli.py | 425 ++++++++++++++++++ PROJECTS/dns-lookup/dnslookup/output.py | 388 ++++++++++++++++ PROJECTS/dns-lookup/dnslookup/resolver.py | 404 +++++++++++++++++ PROJECTS/dns-lookup/dnslookup/whois_lookup.py | 225 ++++++++++ PROJECTS/dns-lookup/justfile | 137 ++++++ PROJECTS/dns-lookup/pyproject.toml | 93 ++++ PROJECTS/dns-lookup/tests/__init__.py | 5 + PROJECTS/dns-lookup/tests/test_resolver.py | 183 ++++++++ PROJECTS/dns-lookup/uv.lock | 418 +++++++++++++++++ 14 files changed, 2565 insertions(+) create mode 100644 .github/workflows/publish-dns-lookup.yml create mode 100755 PROJECTS/dns-lookup/.style.yapf create mode 100644 PROJECTS/dns-lookup/README.md create mode 100644 PROJECTS/dns-lookup/dnslookup/__init__.py create mode 100644 PROJECTS/dns-lookup/dnslookup/__main__.py create mode 100644 PROJECTS/dns-lookup/dnslookup/cli.py create mode 100644 PROJECTS/dns-lookup/dnslookup/output.py create mode 100644 PROJECTS/dns-lookup/dnslookup/resolver.py create mode 100644 PROJECTS/dns-lookup/dnslookup/whois_lookup.py create mode 100644 PROJECTS/dns-lookup/justfile create mode 100644 PROJECTS/dns-lookup/pyproject.toml create mode 100644 PROJECTS/dns-lookup/tests/__init__.py create mode 100644 PROJECTS/dns-lookup/tests/test_resolver.py create mode 100644 PROJECTS/dns-lookup/uv.lock diff --git a/.github/workflows/publish-dns-lookup.yml b/.github/workflows/publish-dns-lookup.yml new file mode 100644 index 00000000..423fb752 --- /dev/null +++ b/.github/workflows/publish-dns-lookup.yml @@ -0,0 +1,48 @@ +# ============================================================================= +# AngelaMos | 2025 +# publish-dns-lookup.yml +# ============================================================================= + +name: Publish dns-lookup to PyPI + +on: + push: + branches: + - main + paths: + - 'PROJECTS/dns-lookup/**' + +permissions: + contents: read + +jobs: + pypi-publish: + name: Upload dns-lookup to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/dnslookup-cli + permissions: + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + working-directory: PROJECTS/dns-lookup + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: PROJECTS/dns-lookup/dist/ diff --git a/PROJECTS/dns-lookup/.style.yapf b/PROJECTS/dns-lookup/.style.yapf new file mode 100755 index 00000000..981e93ac --- /dev/null +++ b/PROJECTS/dns-lookup/.style.yapf @@ -0,0 +1,47 @@ +# β’ΈAngelaMos | 2025 | CarterPerez-dev +[style] +based_on_style = pep8 +column_limit = 70 +indent_width = 4 +continuation_indent_width = 4 +indent_closing_brackets = false +dedent_closing_brackets = true +indent_blank_lines = false +spaces_before_comment = 2 +spaces_around_power_operator = false +spaces_around_default_or_named_assign = true +space_between_ending_comma_and_closing_bracket = false +space_inside_brackets = false +spaces_around_subscript_colon = true +blank_line_before_nested_class_or_def = false +blank_line_before_class_docstring = false +blank_lines_around_top_level_definition = 2 +blank_lines_between_top_level_imports_and_variables = 2 +blank_line_before_module_docstring = false +split_before_logical_operator = true +split_before_first_argument = true +split_before_named_assigns = true +split_complex_comprehension = true +split_before_expression_after_opening_paren = false +split_before_closing_bracket = true +split_all_comma_separated_values = true +split_all_top_level_comma_separated_values = false +coalesce_brackets = false +each_dict_entry_on_separate_line = true +allow_multiline_lambdas = false +allow_multiline_dictionary_keys = false +split_penalty_import_names = 0 +join_multiple_lines = false +align_closing_bracket_with_visual_indent = true +arithmetic_precedence_indication = false +split_penalty_for_added_line_split = 275 +use_tabs = false +split_before_dot = false +split_arguments_when_comma_terminated = true +i18n_function_call = ['_', 'N_', 'gettext', 'ngettext'] +i18n_comment = ['# Translators:', '# i18n:'] +split_penalty_comprehension = 80 +split_penalty_after_opening_bracket = 280 +split_penalty_before_if_expr = 0 +split_penalty_bitwise_operator = 290 +split_penalty_logical_operator = 0 diff --git a/PROJECTS/dns-lookup/README.md b/PROJECTS/dns-lookup/README.md new file mode 100644 index 00000000..f3fd40ce --- /dev/null +++ b/PROJECTS/dns-lookup/README.md @@ -0,0 +1,174 @@ +# DNS Lookup Tool + +Professional DNS lookup CLI with beautiful Rich terminal output. Query DNS records, perform reverse lookups, trace resolution paths, and retrieve WHOIS information. + +## Features + +- **Multi-Record Queries**: Query A, AAAA, MX, NS, TXT, CNAME, SOA records +- **Reverse DNS**: IP to hostname resolution +- **DNS Trace**: Visualize the resolution path from root to authoritative servers +- **Batch Lookups**: Query multiple domains concurrently +- **WHOIS Integration**: Domain registration information +- **JSON Export**: Machine-readable output for scripting +- **Beautiful Output**: Color-coded tables, spinners, and tree visualizations + +## Installation + +```bash +# Clone the repository +git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git +cd PROJECTS/dns-lookup + +# Install with uv +uv sync + +# Or install with pip + +python -m venv .venv + +source .venv/bin/activate + +pip install -e . +``` + +## Usage + +### Basic DNS Query + +```bash +# Query all record types +dnslookup query example.com + +# Query specific record types +dnslookup query example.com --type A,MX,TXT + +# Use custom DNS server +dnslookup query example.com --server 8.8.8.8 + +# Output as JSON +dnslookup query example.com --json +``` + +### Reverse DNS Lookup + +```bash +# IPv4 +dnslookup reverse 8.8.8.8 + +# IPv6 +dnslookup reverse 2606:4700:4700::1111 +``` + +### DNS Trace + +Trace the resolution path from root servers to authoritative nameservers: + +```bash +dnslookup trace example.com +dnslookup trace example.com --type MX +``` + +### Batch Lookups + +Query multiple domains from a file: + +```bash +# Create a file with domains (one per line) +echo -e "google.com\ngithub.com\ncloudflare.com" > domains.txt + +# Run batch lookup +dnslookup batch domains.txt + +# Save results to JSON +dnslookup batch domains.txt --output results.json +``` + +### WHOIS Lookup + +```bash +dnslookup whois example.com +dnslookup whois google.com --json +``` + +## Example Output + +### DNS Query +``` +🌐 DNS Lookup: example.com +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DNS Records β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Type β”‚ Value β”‚ TTL β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ A β”‚ 93.184.216.34 β”‚ 1h β”‚ +β”‚ AAAA β”‚ 2606:2800:220:1:... β”‚ 1h β”‚ +β”‚ MX β”‚ mail.example.com (10) β”‚ 1d β”‚ +β”‚ NS β”‚ ns1.example.com β”‚ 2d β”‚ +β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +βœ“ Found 8 records in 45ms +``` + +### DNS Trace +``` +πŸ” DNS Trace: example.com +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +🌍 DNS Resolution Path +β”œβ”€β”€ [.] Root +β”‚ └── β†’ a.root-servers.net (198.41.0.4) +β”‚ └── Referred to com. servers +β”œβ”€β”€ [com.] TLD +β”‚ └── β†’ a.gtld-servers.net (192.5.6.30) +β”‚ └── Referred to example.com NS +└── [example.com.] Authoritative + └── β†’ ns1.example.com (93.184.216.34) + └── A: 93.184.216.34 + +βœ“ Resolution complete: 93.184.216.34 +``` + +## Development + +```bash +# Install dev dependencies +just install-dev + +# Run the tool +just run query example.com + +# Run tests +just test + +# Lint code +just lint + +# Format code +just ruff-fix + +# Run all checks +just ci +``` + +## Available Commands + +| Command | Description | +|---------|-------------| +| `just run *ARGS` | Run the CLI tool | +| `just test` | Run test suite | +| `just lint` | Run ruff linter | +| `just ruff-fix` | Auto-fix and format | +| `just mypy` | Type checking | +| `just ci` | Run all checks | +| `just clean` | Remove cache files | + + +## License + +MIT License - See LICENSE for details. + +## Author + +CarterPerez-dev diff --git a/PROJECTS/dns-lookup/dnslookup/__init__.py b/PROJECTS/dns-lookup/dnslookup/__init__.py new file mode 100644 index 00000000..6ff2919a --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/__init__.py @@ -0,0 +1,7 @@ +""" +CarterPerez-dev | 2025 +__init__.py +""" + +__version__ = "0.1.0" +__author__ = "CarterPerez-dev" diff --git a/PROJECTS/dns-lookup/dnslookup/__main__.py b/PROJECTS/dns-lookup/dnslookup/__main__.py new file mode 100644 index 00000000..5c2d813c --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/__main__.py @@ -0,0 +1,11 @@ +""" +CarterPerez-dev | 2025 +__main__.py + +Entry point for python -m dnslookup +""" + +from dnslookup.cli import app + +if __name__ == "__main__": + app() diff --git a/PROJECTS/dns-lookup/dnslookup/cli.py b/PROJECTS/dns-lookup/dnslookup/cli.py new file mode 100644 index 00000000..cf10a701 --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/cli.py @@ -0,0 +1,425 @@ +""" +CarterPerez-dev | 2025 +cli.py +Typer CLI application for DNS lookups +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Annotated + +import typer +from rich.progress import ( + Progress, + SpinnerColumn, + TextColumn, +) + +from dnslookup import __version__ +from dnslookup.output import ( + console, + print_batch_progress_header, + print_batch_results, + print_errors, + print_header, + print_results_table, + print_reverse_result, + print_summary, + print_trace_result, + results_to_json, + trace_to_json, +) +from dnslookup.resolver import ( + ALL_RECORD_TYPES, + RecordType, + batch_lookup, + lookup, + reverse_lookup, + trace_dns, +) +from dnslookup.whois_lookup import ( + lookup_whois, + print_whois_result, + whois_to_json, +) + +app = typer.Typer( + name = "dnslookup", + help = + "[bold green]DNS Lookup Tool[/bold green] - Professional DNS query CLI with clean output", + rich_markup_mode = "rich", + no_args_is_help = True, +) + + +def version_callback(value: bool) -> None: + """ + Display version and exit + """ + if value: + console.print( + f"[bold cyan]dnslookup[/bold cyan] version [green]{__version__}[/green]" + ) + raise typer.Exit() + + +def parse_record_types(types_str: str) -> list[RecordType]: + """ + Parse comma separated record types string + """ + if types_str.upper() == "ALL": + return list(ALL_RECORD_TYPES) + + types = [] + for t in types_str.upper().split(","): + t = t.strip() + try: + types.append(RecordType(t)) + except ValueError: + console.print( + f"[yellow]Warning:[/yellow] Unknown record type '{t}', skipping" + ) + + return types if types else list(ALL_RECORD_TYPES) + + +@app.callback() +def main( + version: Annotated[ + bool | None, + typer.Option( + "--version", + "-v", + help = "Show version and exit", + callback = version_callback, + is_eager = True, + ), + ] = None, +) -> None: + """ + [bold green]DNS Lookup Tool[/bold green] + + Query DNS records, perform reverse lookups, trace resolution paths, + and retrieve WHOIS information with nice terminal output. + """ + pass + + +@app.command() +def query( + domain: Annotated[str, + typer.Argument(help = "Domain name to query")], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = + "Record types to query (A,AAAA,MX,NS,TXT,CNAME,SOA or ALL)", + ), + ] = "ALL", + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use (e.g., 8.8.8.8)", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Query DNS records[/bold cyan] for a domain. + + Examples: + dnslookup query example.com + dnslookup query example.com --type A,MX + dnslookup query example.com --server 8.8.8.8 --json + """ + record_types = parse_record_types(record_type) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Querying {domain}...", total = None) + result = asyncio.run( + lookup(domain, + record_types, + server, + timeout) + ) + + if json_output: + console.print(results_to_json(result)) + else: + print_header(domain) + print_results_table(result) + print_errors(result) + print_summary(result) + + +@app.command() +def reverse( + ip: Annotated[ + str, + typer.Argument(help = "IP address for reverse lookup")], + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Reverse DNS lookup[/bold cyan] for an IP address. + + Examples: + dnslookup reverse 8.8.8.8 + dnslookup reverse 2606:4700:4700::1111 + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Resolving {ip}...", total = None) + result = asyncio.run(reverse_lookup(ip, server, timeout)) + + if json_output: + console.print(results_to_json(result)) + else: + print_reverse_result(result) + + +@app.command() +def trace( + domain: Annotated[str, + typer.Argument(help = "Domain to trace")], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = "Record type to trace (default: A)", + ), + ] = "A", + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]Trace DNS resolution path[/bold cyan] from root to authoritative servers. + + Shows the complete resolution chain including root servers, TLD servers, + and authoritative nameservers. + + Examples: + dnslookup trace example.com + dnslookup trace example.com --type MX + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task(f"Tracing {domain}...", total = None) + result = trace_dns(domain, record_type.upper()) + + if json_output: + console.print(trace_to_json(result)) + else: + print_trace_result(result) + + +@app.command() +def batch( + file: Annotated[ + Path, + typer. + Argument(help = "File containing domains (one per line)"), + ], + record_type: Annotated[ + str, + typer.Option( + "--type", + "-t", + help = "Record types to query", + ), + ] = "A,MX,NS", + server: Annotated[ + str | None, + typer.Option( + "--server", + "-s", + help = "DNS server to use", + ), + ] = None, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help = "Query timeout in seconds", + ), + ] = 5.0, + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help = "Output file for JSON results", + ), + ] = None, + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON to stdout", + ), + ] = False, +) -> None: + """ + [bold cyan]Batch DNS lookup[/bold cyan] for multiple domains from a file. + + The file should contain one domain per line. Empty lines and lines + starting with # are ignored. + + Examples: + dnslookup batch domains.txt + dnslookup batch domains.txt --type A,MX --output results.json + """ + if not file.exists(): + console.print(f"[red]Error:[/red] File not found: {file}") + raise typer.Exit(1) + + domains = [] + with open(file) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + domains.append(line) + + if not domains: + console.print( + "[yellow]Warning:[/yellow] No domains found in file" + ) + raise typer.Exit(0) + + record_types = parse_record_types(record_type) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task( + f"Querying {len(domains)} domains...", + total = None + ) + results = asyncio.run( + batch_lookup(domains, + record_types, + server, + timeout) + ) + + if json_output: + console.print(results_to_json(results)) + elif output: + with open(output, "w") as f: + f.write(results_to_json(results)) + console.print( + f"[green]:heavy_check_mark:[/green] Results saved to {output}" + ) + else: + print_batch_progress_header(len(domains)) + print_batch_results(results) + + +@app.command() +def whois( + domain: Annotated[str, + typer.Argument(help = "Domain to lookup")], + json_output: Annotated[ + bool, + typer.Option( + "--json", + "-j", + help = "Output results as JSON", + ), + ] = False, +) -> None: + """ + [bold cyan]WHOIS lookup[/bold cyan] for domain registration information. + + Shows registrar, creation date, expiration date, name servers, + and other registration details. + + Examples: + dnslookup whois example.com + dnslookup whois google.com --json + """ + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console = console, + transient = True, + ) as progress: + progress.add_task( + f"Looking up WHOIS for {domain}...", + total = None + ) + result = lookup_whois(domain) + + if json_output: + console.print(whois_to_json(result)) + else: + print_whois_result(result) + + +if __name__ == "__main__": + app() diff --git a/PROJECTS/dns-lookup/dnslookup/output.py b/PROJECTS/dns-lookup/dnslookup/output.py new file mode 100644 index 00000000..d837dd49 --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/output.py @@ -0,0 +1,388 @@ +""" +CarterPerez-dev | 2025 +output.py +Rich terminal output formatting for DNS results +""" + +from __future__ import annotations + +import json +from typing import Any + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.tree import Tree + +from dnslookup.resolver import ( + DNSResult, + RecordType, + TraceResult, +) + +console = Console() + + +RECORD_COLORS: dict[RecordType, + str] = { + RecordType.A: "green", + RecordType.AAAA: "blue", + RecordType.MX: "magenta", + RecordType.NS: "cyan", + RecordType.TXT: "yellow", + RecordType.CNAME: "red", + RecordType.SOA: "white", + RecordType.PTR: "bright_cyan", + } + + +def get_record_color(record_type: RecordType) -> str: + """ + Get the color for a record type + """ + return RECORD_COLORS.get(record_type, "white") + + +def format_ttl(ttl: int) -> str: + """ + Format TTL in human-readable format + """ + if ttl >= 86400: + days = ttl // 86400 + return f"{days}d" + elif ttl >= 3600: + hours = ttl // 3600 + return f"{hours}h" + elif ttl >= 60: + minutes = ttl // 60 + return f"{minutes}m" + return f"{ttl}s" + + +def print_header( + domain: str, + icon: str = ":globe_showing_americas:" +) -> None: + """ + Print a styled header for DNS lookup + """ + console.print() + console.print( + f"{icon} [bold cyan]DNS Lookup:[/bold cyan] [bold white]{domain}[/bold white]" + ) + console.rule(style = "blue") + + +def print_results_table(result: DNSResult) -> None: + """ + Display DNS results in a nice table + """ + if not result.records: + console.print( + Panel( + f"[yellow]No records found for {result.domain}[/yellow]", + title = "[yellow]Warning[/yellow]", + border_style = "yellow", + expand = False, + ) + ) + return + + table = Table( + title = "[bold]DNS Records[/bold]", + box = box.ROUNDED, + border_style = "blue", + row_styles = ["", + "dim"], + show_header = True, + header_style = "bold cyan", + ) + + table.add_column("Type", width = 8, no_wrap = True) + table.add_column("Value", style = "green", min_width = 30) + table.add_column( + "TTL", + justify = "right", + style = "dim", + width = 8 + ) + + for record in result.records: + color = get_record_color(record.record_type) + value = record.value + + if record.priority is not None: + value = f"{value} [dim](priority: {record.priority})[/dim]" + + table.add_row( + f"[{color}]{record.record_type}[/{color}]", + value, + format_ttl(record.ttl), + ) + + console.print(table) + + +def print_summary(result: DNSResult) -> None: + """ + Print summary line after results + """ + record_count = len(result.records) + time_str = f"{result.query_time_ms:.0f}ms" + + if record_count > 0: + console.print( + f"\n[green]:heavy_check_mark:[/green] Found [bold]{record_count}[/bold] record(s) in [cyan]{time_str}[/cyan]" + ) + else: + console.print( + f"\n[yellow]:warning:[/yellow] No records found ({time_str})" + ) + + if result.nameserver: + console.print(f"[dim]Nameserver: {result.nameserver}[/dim]") + + console.print() + + +def print_errors(result: DNSResult) -> None: + """ + Print any errors that occurred + """ + if result.errors: + for error in result.errors: + console.print(f"[red]:x:[/red] {error}") + + +def print_reverse_result(result: DNSResult) -> None: + """ + Display reverse DNS lookup results + """ + console.print() + console.print( + f":mag: [bold cyan]Reverse Lookup:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.records: + table = Table( + title = "[bold]PTR Records[/bold]", + box = box.ROUNDED, + border_style = "blue", + ) + table.add_column("IP Address", style = "cyan") + table.add_column("Hostname", style = "green") + table.add_column( + "TTL", + justify = "right", + style = "dim", + width = 8 + ) + + for record in result.records: + table.add_row( + result.domain, + record.value, + format_ttl(record.ttl), + ) + + console.print(table) + print_summary(result) + else: + print_errors(result) + console.print( + Panel( + f"[yellow]No PTR record found for {result.domain}[/yellow]", + border_style = "yellow", + expand = False, + ) + ) + console.print() + + +def print_trace_result(result: TraceResult) -> None: + """ + Display DNS trace as a tree visualization + """ + console.print() + console.print( + f":mag_right: [bold cyan]DNS Trace:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.error: + console.print(f"[red]:x:[/red] {result.error}") + console.print() + return + + tree = Tree( + "[bold blue]:globe_showing_americas: DNS Resolution Path[/bold blue]", + guide_style = "blue", + ) + + zone_nodes: dict[str, Any] = {} + + for hop in result.hops: + if hop.zone not in zone_nodes: + if hop.zone == ".": + zone_display = "[bold yellow][.] Root[/bold yellow]" + elif hop.zone.endswith("."): + zone_display = f"[bold yellow][{hop.zone}] TLD[/bold yellow]" + else: + zone_display = f"[bold yellow][{hop.zone}.] Authoritative[/bold yellow]" + + zone_node = tree.add(zone_display) + zone_nodes[hop.zone] = zone_node + else: + zone_node = zone_nodes[hop.zone] + + server_style = "green" if hop.is_authoritative else "cyan" + server_branch = zone_node.add( + f"[{server_style}]:arrow_right: {hop.server}[/{server_style}] [dim]({hop.server_ip})[/dim]" + ) + server_branch.add(f"[dim]{hop.response}[/dim]") + + console.print(tree) + + if result.final_answer: + console.print( + f"\n[green]:heavy_check_mark:[/green] Resolution complete: [bold green]{result.final_answer}[/bold green]" + ) + + hop_count = len(result.hops) + console.print(f"[dim]Total hops: {hop_count}[/dim]") + console.print() + + +def print_batch_progress_header(total: int) -> None: + """ + Print header for batch operations + """ + console.print() + console.print( + f":package: [bold cyan]Batch Lookup:[/bold cyan] [bold white]{total} domains[/bold white]" + ) + console.rule(style = "blue") + + +def print_batch_results(results: list[DNSResult]) -> None: + """ + Display batch lookup results in a summary table + """ + table = Table( + title = "[bold]Batch Results[/bold]", + box = box.ROUNDED, + border_style = "blue", + row_styles = ["", + "dim"], + ) + + table.add_column("Domain", style = "cyan", min_width = 25) + table.add_column("A", justify = "center", width = 15) + table.add_column("MX", justify = "center", width = 5) + table.add_column("NS", justify = "center", width = 5) + table.add_column( + "Time", + justify = "right", + style = "dim", + width = 8 + ) + + for result in results: + a_records = [ + r for r in result.records if r.record_type == RecordType.A + ] + mx_count = len( + [ + r for r in result.records + if r.record_type == RecordType.MX + ] + ) + ns_count = len( + [ + r for r in result.records + if r.record_type == RecordType.NS + ] + ) + + a_value = a_records[0].value if a_records else "[dim]-[/dim]" + mx_value = str(mx_count) if mx_count else "[dim]-[/dim]" + ns_value = str(ns_count) if ns_count else "[dim]-[/dim]" + + table.add_row( + result.domain, + a_value, + mx_value, + ns_value, + f"{result.query_time_ms:.0f}ms", + ) + + console.print(table) + + total_records = sum(len(r.records) for r in results) + total_time = sum(r.query_time_ms for r in results) + console.print( + f"\n[green]:heavy_check_mark:[/green] {len(results)} domains, {total_records} total records in {total_time:.0f}ms" + ) + console.print() + + +def results_to_json(results: list[DNSResult] | DNSResult) -> str: + """ + Convert results to JSON string + """ + if isinstance(results, DNSResult): + results = [results] + + data = [] + for result in results: + record_data = [ + { + "type": r.record_type.value, + "value": r.value, + "ttl": r.ttl, + "priority": r.priority, + } for r in result.records + ] + + data.append( + { + "domain": result.domain, + "records": record_data, + "errors": result.errors, + "query_time_ms": round(result.query_time_ms, + 2), + "nameserver": result.nameserver, + } + ) + + if len(data) == 1: + return json.dumps(data[0], indent = 2) + + return json.dumps(data, indent = 2) + + +def trace_to_json(result: TraceResult) -> str: + """ + Convert trace result to JSON string + """ + data = { + "domain": + result.domain, + "hops": [ + { + "zone": hop.zone, + "server": hop.server, + "server_ip": hop.server_ip, + "response": hop.response, + "is_authoritative": hop.is_authoritative, + } for hop in result.hops + ], + "final_answer": + result.final_answer, + "error": + result.error, + } + + return json.dumps(data, indent = 2) diff --git a/PROJECTS/dns-lookup/dnslookup/resolver.py b/PROJECTS/dns-lookup/dnslookup/resolver.py new file mode 100644 index 00000000..060f21bd --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/resolver.py @@ -0,0 +1,404 @@ +""" +CarterPerez-dev | 2025 +resolver.py +Async DNS resolution with record type support +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import ( + dataclass, + field, +) +from enum import StrEnum +from typing import Any + +import dns.asyncresolver +import dns.exception +import dns.message +import dns.name +import dns.query +import dns.rcode +import dns.rdatatype +import dns.resolver +import dns.reversename + + +class RecordType(StrEnum): + """ + Supported DNS record types + """ + A = "A" + AAAA = "AAAA" + MX = "MX" + NS = "NS" + TXT = "TXT" + CNAME = "CNAME" + SOA = "SOA" + PTR = "PTR" + + +ALL_RECORD_TYPES = [ + RecordType.A, + RecordType.AAAA, + RecordType.MX, + RecordType.NS, + RecordType.TXT, + RecordType.CNAME, + RecordType.SOA, +] + + +@dataclass +class DNSRecord: + """ + Represents a single DNS record + """ + record_type: RecordType + value: str + ttl: int + priority: int | None = None + + +@dataclass +class DNSResult: + """ + Result of a DNS lookup + """ + domain: str + records: list[DNSRecord] = field(default_factory = list) + errors: list[str] = field(default_factory = list) + query_time_ms: float = 0.0 + nameserver: str | None = None + + +@dataclass +class TraceHop: + """ + Represents a single hop in DNS resolution trace + """ + zone: str + server: str + server_ip: str + response: str + is_authoritative: bool = False + + +@dataclass +class TraceResult: + """ + Result of a DNS trace + """ + domain: str + hops: list[TraceHop] = field(default_factory = list) + final_answer: str | None = None + error: str | None = None + + +def create_resolver( + nameserver: str | None = None, + timeout: float = 5.0, +) -> dns.asyncresolver.Resolver: + """ + Create a configured async DNS resolver + """ + resolver = dns.asyncresolver.Resolver() + resolver.timeout = timeout + resolver.lifetime = timeout * 2 + + if nameserver: + resolver.nameservers = [nameserver] + + return resolver + + +def extract_record_value(rdata: Any, + record_type: RecordType + ) -> tuple[str, + int | None]: + """ + Extract value and priority from rdata based on record type + """ + priority = None + + if record_type == RecordType.A or record_type == RecordType.AAAA: + value = rdata.address + elif record_type == RecordType.MX: + value = str(rdata.exchange).rstrip(".") + priority = rdata.preference + elif record_type in (RecordType.NS, + RecordType.CNAME, + RecordType.PTR): + value = str(rdata.target).rstrip(".") + elif record_type == RecordType.TXT: + value = rdata.to_text() + elif record_type == RecordType.SOA: + value = f"NS: {str(rdata.mname).rstrip('.')}, Serial: {rdata.serial}" + else: + value = rdata.to_text() + + return value, priority + + +async def query_record_type( + domain: str, + record_type: RecordType, + resolver: dns.asyncresolver.Resolver, +) -> list[DNSRecord]: + """ + Query a single record type for a domain + """ + records = [] + + try: + answers = await resolver.resolve(domain, record_type.value) + + for rdata in answers: + value, priority = extract_record_value(rdata, record_type) + records.append( + DNSRecord( + record_type = record_type, + value = value, + ttl = answers.rrset.ttl, + priority = priority, + ) + ) + except (dns.resolver.NXDOMAIN, + dns.resolver.NoAnswer, + dns.resolver.NoNameservers): + pass + except dns.exception.Timeout: + pass + + return records + + +async def lookup( + domain: str, + record_types: list[RecordType] | None = None, + nameserver: str | None = None, + timeout: float = 5.0, +) -> DNSResult: + """ + Perform DNS lookup for specified record types + """ + if record_types is None: + record_types = ALL_RECORD_TYPES + + resolver = create_resolver(nameserver, timeout) + result = DNSResult(domain = domain, nameserver = nameserver) + + start_time = time.perf_counter() + + tasks = [ + query_record_type(domain, + rt, + resolver) for rt in record_types + ] + query_results = await asyncio.gather( + *tasks, + return_exceptions = True + ) + + for i, query_result in enumerate(query_results): + if isinstance(query_result, Exception): + result.errors.append(f"{record_types[i]}: {query_result}") + else: + result.records.extend(query_result) + + result.query_time_ms = (time.perf_counter() - start_time) * 1000 + + return result + + +async def reverse_lookup( + ip_address: str, + nameserver: str | None = None, + timeout: float = 5.0, +) -> DNSResult: + """ + Perform reverse DNS lookup for an IP address + """ + resolver = create_resolver(nameserver, timeout) + result = DNSResult(domain = ip_address, nameserver = nameserver) + + start_time = time.perf_counter() + + try: + answers = await resolver.resolve_address(ip_address) + for rdata in answers: + result.records.append( + DNSRecord( + record_type = RecordType.PTR, + value = str(rdata.target).rstrip("."), + ttl = answers.rrset.ttl, + ) + ) + except dns.resolver.NXDOMAIN: + result.errors.append("No PTR record found") + except dns.resolver.NoAnswer: + result.errors.append("No answer from nameserver") + except dns.resolver.NoNameservers: + result.errors.append("No nameservers available") + except dns.exception.Timeout: + result.errors.append("Query timed out") + except dns.exception.DNSException as e: + result.errors.append(str(e)) + + result.query_time_ms = (time.perf_counter() - start_time) * 1000 + + return result + + +def trace_dns(domain: str, record_type: str = "A") -> TraceResult: + """ + Trace DNS resolution path from root to authoritative servers + """ + result = TraceResult(domain = domain) + + try: + name = dns.name.from_text(domain) + rdtype = dns.rdatatype.from_text(record_type) + + root_servers = [ + ("a.root-servers.net", + "198.41.0.4"), + ("b.root-servers.net", + "170.247.170.2"), + ("c.root-servers.net", + "192.33.4.12"), + ] + + current_servers = root_servers + current_zone = "." + + while True: + server_name, server_ip = current_servers[0] + + try: + query = dns.message.make_query(name, rdtype) + response = dns.query.udp( + query, + server_ip, + timeout = 3.0 + ) + + rcode = response.rcode() + + if rcode != dns.rcode.NOERROR: + result.error = f"DNS error: {dns.rcode.to_text(rcode)}" + break + + if response.answer: + for rrset in response.answer: + for rdata in rrset: + result.final_answer = str(rdata) + break + + result.hops.append( + TraceHop( + zone = current_zone, + server = server_name, + server_ip = server_ip, + response = + f"{record_type}: {result.final_answer}", + is_authoritative = True, + ) + ) + break + + if response.authority: + ns_records = [] + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.NS: + for rdata in rrset: + ns_name = str(rdata.target + ).rstrip(".") + ns_records.append(ns_name) + new_zone = str(rrset.name).rstrip(".") + if not new_zone: + new_zone = "." + + if ns_records: + referral_msg = f"Referred to {new_zone or 'next'} servers" + result.hops.append( + TraceHop( + zone = current_zone, + server = server_name, + server_ip = server_ip, + response = referral_msg, + ) + ) + + glue_ips = {} + if response.additional: + for rrset in response.additional: + if rrset.rdtype == dns.rdatatype.A: + for rdata in rrset: + glue_ips[str( + rrset.name + ).rstrip(".")] = rdata.address + + new_servers = [] + for ns in ns_records: + if ns in glue_ips: + new_servers.append((ns, glue_ips[ns])) + else: + try: + answers = dns.resolver.resolve( + ns, + "A" + ) + for rdata in answers: + new_servers.append( + (ns, + rdata.address) + ) + break + except dns.exception.DNSException: + continue + + if new_servers: + current_servers = new_servers + current_zone = new_zone + else: + result.error = "Could not resolve nameserver IPs" + break + else: + result.error = "No NS records in authority section" + break + else: + result.error = "No answer or authority in response" + break + + except dns.exception.Timeout: + result.error = f"Timeout querying {server_name}" + break + except dns.exception.DNSException as e: + result.error = str(e) + break + + except Exception as e: + result.error = str(e) + + return result + + +async def batch_lookup( + domains: list[str], + record_types: list[RecordType] | None = None, + nameserver: str | None = None, + timeout: float = 5.0, +) -> list[DNSResult]: + """ + Perform DNS lookups for multiple domains concurrently + """ + tasks = [ + lookup(domain, + record_types, + nameserver, + timeout) for domain in domains + ] + return await asyncio.gather(*tasks) diff --git a/PROJECTS/dns-lookup/dnslookup/whois_lookup.py b/PROJECTS/dns-lookup/dnslookup/whois_lookup.py new file mode 100644 index 00000000..2987de77 --- /dev/null +++ b/PROJECTS/dns-lookup/dnslookup/whois_lookup.py @@ -0,0 +1,225 @@ +""" +CarterPerez-dev | 2025 +whois_lookup.py +WHOIS domain information lookup and display +""" + +from __future__ import annotations + +import json +from dataclasses import ( + dataclass, + field, +) +from datetime import datetime +from typing import Any + +import whois +from rich import box +from rich.console import Console +from rich.table import Table + +console = Console() + + +@dataclass +class WhoisResult: + """ + WHOIS lookup result + """ + domain: str + registrar: str | None = None + creation_date: datetime | None = None + expiration_date: datetime | None = None + updated_date: datetime | None = None + status: list[str] = field(default_factory = list) + name_servers: list[str] = field(default_factory = list) + registrant: str | None = None + registrant_country: str | None = None + dnssec: str | None = None + error: str | None = None + + +def format_date(dt: datetime | list | None) -> str: + """ + Format datetime for display + """ + if dt is None: + return "[dim]-[/dim]" + + if isinstance(dt, list): + dt = dt[0] if dt else None + + if dt is None: + return "[dim]-[/dim]" + + if isinstance(dt, datetime): + return dt.strftime("%Y-%m-%d") + + return str(dt) + + +def lookup_whois(domain: str) -> WhoisResult: + """ + Perform WHOIS lookup for a domain + """ + result = WhoisResult(domain = domain) + + try: + w = whois.whois(domain) + + if w is None or (hasattr(w, + "domain_name") + and w.domain_name is None): + result.error = "Domain not found or WHOIS data unavailable" + return result + + result.registrar = w.registrar if hasattr( + w, + "registrar" + ) else None + result.creation_date = w.creation_date if hasattr( + w, + "creation_date" + ) else None + result.expiration_date = w.expiration_date if hasattr( + w, + "expiration_date" + ) else None + result.updated_date = w.updated_date if hasattr( + w, + "updated_date" + ) else None + + if hasattr(w, "status"): + status = w.status + if isinstance(status, str): + result.status = [status] + elif isinstance(status, list): + result.status = status + else: + result.status = [] + + if hasattr(w, "name_servers") and w.name_servers: + ns = w.name_servers + if isinstance(ns, str): + result.name_servers = [ns.lower()] + elif isinstance(ns, list): + result.name_servers = [n.lower() for n in ns if n] + else: + result.name_servers = [] + + if hasattr(w, "org"): + result.registrant = w.org + elif hasattr(w, "name"): + result.registrant = w.name + + if hasattr(w, "country"): + result.registrant_country = w.country + + if hasattr(w, "dnssec"): + result.dnssec = str(w.dnssec) if w.dnssec else None + + except Exception as e: + result.error = str(e) + + return result + + +def print_whois_result(result: WhoisResult) -> None: + """ + Display WHOIS result in a nice table + """ + console.print() + console.print( + f":clipboard: [bold cyan]WHOIS:[/bold cyan] [bold white]{result.domain}[/bold white]" + ) + console.rule(style = "blue") + + if result.error: + console.print(f"[red]:x:[/red] {result.error}") + console.print() + return + + table = Table( + box = box.ROUNDED, + border_style = "blue", + show_header = False, + padding = (0, + 1), + ) + + table.add_column("Field", style = "cyan", width = 15) + table.add_column("Value", style = "green") + + if result.registrar: + table.add_row("Registrar", result.registrar) + + if result.registrant: + table.add_row("Registrant", result.registrant) + + if result.registrant_country: + table.add_row("Country", result.registrant_country) + + table.add_row("Created", format_date(result.creation_date)) + table.add_row("Expires", format_date(result.expiration_date)) + table.add_row("Updated", format_date(result.updated_date)) + + if result.status: + status_display = result.status[0] if len( + result.status + ) == 1 else f"{len(result.status)} statuses" + table.add_row("Status", status_display) + + if result.name_servers: + for i, ns in enumerate(result.name_servers[: 4]): + label = "Name Servers" if i == 0 else "" + table.add_row(label, ns) + + if len(result.name_servers) > 4: + table.add_row( + "", + f"[dim]... and {len(result.name_servers) - 4} more[/dim]" + ) + + if result.dnssec: + table.add_row("DNSSEC", result.dnssec) + + console.print(table) + console.print() + + +def whois_to_json(result: WhoisResult) -> str: + """ + Convert WHOIS result to JSON string + """ + def serialize_date(dt: datetime | list | None) -> str | None: + if dt is None: + return None + if isinstance(dt, list): + dt = dt[0] if dt else None + if isinstance(dt, datetime): + return dt.isoformat() + return str(dt) if dt else None + + data: dict[str, + Any] = { + "domain": result.domain, + "registrar": result.registrar, + "creation_date": + serialize_date(result.creation_date), + "expiration_date": serialize_date( + result.expiration_date + ), + "updated_date": serialize_date( + result.updated_date + ), + "status": result.status, + "name_servers": result.name_servers, + "registrant": result.registrant, + "registrant_country": result.registrant_country, + "dnssec": result.dnssec, + "error": result.error, + } + + return json.dumps(data, indent = 2) diff --git a/PROJECTS/dns-lookup/justfile b/PROJECTS/dns-lookup/justfile new file mode 100644 index 00000000..0c7d396d --- /dev/null +++ b/PROJECTS/dns-lookup/justfile @@ -0,0 +1,137 @@ +# ============================================================================= +# AngelaMos | 2025 +# justfile +# ============================================================================= + +set dotenv-load +set export +set shell := ["bash", "-uc"] +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Run +# ============================================================================= + +[group('run')] +run *ARGS: + uv run dnslookup {{ARGS}} + +[group('run')] +lookup domain *ARGS: + uv run dnslookup {{domain}} {{ARGS}} + +[group('run')] +reverse ip: + uv run dnslookup reverse {{ip}} + +[group('run')] +trace domain: + uv run dnslookup trace {{domain}} + +[group('run')] +batch file *ARGS: + uv run dnslookup batch {{file}} {{ARGS}} + +[group('run')] +whois domain: + uv run dnslookup whois {{domain}} + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +ruff *ARGS: + uv run ruff check dnslookup/ {{ARGS}} + +[group('lint')] +ruff-fix: + uv run ruff check dnslookup/ --fix + uv run ruff format dnslookup/ + +[group('lint')] +ruff-format: + uv run ruff format dnslookup/ + +[group('lint')] +lint: ruff + +# ============================================================================= +# Type Checking +# ============================================================================= + +[group('types')] +mypy *ARGS: + uv run mypy dnslookup/ {{ARGS}} + +[group('types')] +typecheck: mypy + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + uv run pytest {{ARGS}} + +[group('test')] +test-cov: + uv run pytest --cov=dnslookup --cov-report=term-missing --cov-report=html + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint typecheck test + +[group('ci')] +check: ruff mypy + +# ============================================================================= +# Setup +# ============================================================================= + +[group('setup')] +setup: + uv sync --all-extras + +[group('setup')] +install: + uv sync + +[group('setup')] +install-dev: + uv sync --all-extras + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "OS: {{os()}} ({{arch()}})" + +[group('util')] +clean: + -rm -rf .mypy_cache + -rm -rf .pytest_cache + -rm -rf .ruff_cache + -rm -rf htmlcov + -rm -rf .coverage + -rm -rf dist + -rm -rf *.egg-info + @echo "Cache directories cleaned" diff --git a/PROJECTS/dns-lookup/pyproject.toml b/PROJECTS/dns-lookup/pyproject.toml new file mode 100644 index 00000000..c9c3f9f1 --- /dev/null +++ b/PROJECTS/dns-lookup/pyproject.toml @@ -0,0 +1,93 @@ +[project] +name = "dnslookup-cli" +version = "0.1.0" +description = "Professional DNS lookup CLI with beautiful Rich output" +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +authors = [{ name = "CarterPerez-dev", email = "support@certgames.com" }] +keywords = ["dns", "lookup", "cli", "networking", "security"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: Name Service (DNS)", + "Topic :: System :: Networking", + "Topic :: Utilities", +] +dependencies = [ + "dnspython>=2.8.0", + "rich>=14.2.0", + "typer>=0.20.0", + "python-whois>=0.9.6", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", + "ruff>=0.9.0", + "mypy>=1.15.0", +] + +[project.urls] +Homepage = "https://pypi.org/project/dnslookup-cli/" +Documentation = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/dns-lookup/README.md" +Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects" +Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues" + + +[project.scripts] +dnslookup = "dnslookup.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["dnslookup"] + +[tool.ruff] +target-version = "py313" +line-length = 95 + +[tool.ruff.lint] +select = ["E", "F", "W", "B", "C4", "UP", "SIM", "I"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +python_version = "3.13" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +strict_optional = true +disallow_untyped_defs = false +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["dns.*", "whois.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["dnslookup.resolver"] +disable_error_code = ["union-attr", "attr-defined", "arg-type"] + +[tool.pylint] +ignore-paths = ["dnslookup", "tests"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = "-v --tb=short" diff --git a/PROJECTS/dns-lookup/tests/__init__.py b/PROJECTS/dns-lookup/tests/__init__.py new file mode 100644 index 00000000..1a28672a --- /dev/null +++ b/PROJECTS/dns-lookup/tests/__init__.py @@ -0,0 +1,5 @@ +""" +CarterPerez-dev | 2025 +__init__.py +Test suite for DNS Lookup CLI +""" diff --git a/PROJECTS/dns-lookup/tests/test_resolver.py b/PROJECTS/dns-lookup/tests/test_resolver.py new file mode 100644 index 00000000..ddb34ae0 --- /dev/null +++ b/PROJECTS/dns-lookup/tests/test_resolver.py @@ -0,0 +1,183 @@ +""" +CarterPerez-dev | 2025 +test_resolver.py +Tests for DNS resolver functionality +""" + +from __future__ import annotations + +import pytest + +from dnslookup.resolver import ( + ALL_RECORD_TYPES, + DNSRecord, + DNSResult, + RecordType, + TraceResult, + batch_lookup, + create_resolver, + lookup, + reverse_lookup, + trace_dns, +) + + +class TestRecordType: + """ + Tests for RecordType enum + """ + def test_all_record_types_exist(self) -> None: + assert RecordType.A == "A" + assert RecordType.AAAA == "AAAA" + assert RecordType.MX == "MX" + assert RecordType.NS == "NS" + assert RecordType.TXT == "TXT" + assert RecordType.CNAME == "CNAME" + assert RecordType.SOA == "SOA" + assert RecordType.PTR == "PTR" + + def test_all_record_types_list(self) -> None: + assert len(ALL_RECORD_TYPES) == 7 + assert RecordType.PTR not in ALL_RECORD_TYPES + + +class TestDNSRecord: + """ + Tests for DNSRecord dataclass + """ + def test_create_basic_record(self) -> None: + record = DNSRecord( + record_type = RecordType.A, + value = "93.184.216.34", + ttl = 3600, + ) + assert record.record_type == RecordType.A + assert record.value == "93.184.216.34" + assert record.ttl == 3600 + assert record.priority is None + + def test_create_mx_record_with_priority(self) -> None: + record = DNSRecord( + record_type = RecordType.MX, + value = "mail.example.com", + ttl = 86400, + priority = 10, + ) + assert record.record_type == RecordType.MX + assert record.priority == 10 + + +class TestDNSResult: + """ + Tests for DNSResult dataclass + """ + def test_create_empty_result(self) -> None: + result = DNSResult(domain = "example.com") + assert result.domain == "example.com" + assert result.records == [] + assert result.errors == [] + assert result.query_time_ms == 0.0 + assert result.nameserver is None + + def test_result_with_records(self) -> None: + record = DNSRecord(RecordType.A, "1.2.3.4", 3600) + result = DNSResult( + domain = "example.com", + records = [record], + query_time_ms = 45.5, + ) + assert len(result.records) == 1 + assert result.query_time_ms == 45.5 + + +class TestCreateResolver: + """ + Tests for create_resolver function + """ + def test_default_resolver(self) -> None: + resolver = create_resolver() + assert resolver.timeout == 5.0 + assert resolver.lifetime == 10.0 + + def test_custom_nameserver(self) -> None: + resolver = create_resolver(nameserver = "8.8.8.8") + assert "8.8.8.8" in resolver.nameservers + + def test_custom_timeout(self) -> None: + resolver = create_resolver(timeout = 10.0) + assert resolver.timeout == 10.0 + assert resolver.lifetime == 20.0 + + +class TestLookup: + """ + Integration tests for DNS lookup + """ + @pytest.mark.asyncio + async def test_lookup_real_domain(self) -> None: + result = await lookup("example.com", [RecordType.A]) + assert result.domain == "example.com" + assert result.query_time_ms > 0 + + @pytest.mark.asyncio + async def test_lookup_nonexistent_domain(self) -> None: + result = await lookup( + "this-domain-definitely-does-not-exist-12345.com", + [RecordType.A] + ) + assert result.domain == "this-domain-definitely-does-not-exist-12345.com" + assert len(result.records) == 0 + + @pytest.mark.asyncio + async def test_lookup_with_custom_server(self) -> None: + result = await lookup( + "example.com", + [RecordType.A], + nameserver = "8.8.8.8" + ) + assert result.nameserver == "8.8.8.8" + + +class TestReverseLookup: + """ + Tests for reverse DNS lookup + """ + @pytest.mark.asyncio + async def test_reverse_lookup_google_dns(self) -> None: + result = await reverse_lookup("8.8.8.8") + assert result.domain == "8.8.8.8" + assert result.query_time_ms > 0 + + +class TestTraceDNS: + """ + Tests for DNS trace functionality + """ + def test_trace_result_structure(self) -> None: + result = TraceResult(domain = "example.com") + assert result.domain == "example.com" + assert result.hops == [] + assert result.final_answer is None + assert result.error is None + + def test_trace_real_domain(self) -> None: + result = trace_dns("example.com") + assert result.domain == "example.com" + + +class TestBatchLookup: + """ + Tests for batch DNS lookups + """ + @pytest.mark.asyncio + async def test_batch_lookup_multiple_domains(self) -> None: + domains = ["example.com", "example.org"] + results = await batch_lookup(domains, [RecordType.A]) + assert len(results) == 2 + assert results[0].domain == "example.com" + assert results[1].domain == "example.org" + + @pytest.mark.asyncio + async def test_batch_lookup_empty_list(self) -> None: + results = await batch_lookup([], [RecordType.A]) + assert results == [] diff --git a/PROJECTS/dns-lookup/uv.lock b/PROJECTS/dns-lookup/uv.lock new file mode 100644 index 00000000..8c6134bd --- /dev/null +++ b/PROJECTS/dns-lookup/uv.lock @@ -0,0 +1,418 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + +[[package]] +name = "dnslookup-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dnspython" }, + { name = "python-whois" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "dnspython", specifier = ">=2.8.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "python-whois", specifier = ">=0.9.6" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, + { name = "typer", specifier = ">=0.20.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/d9/6f3d3fcf5e5543ed8a60cc70fa7d50508ed60b8a10e9af6d2058159ab54e/librt-0.7.3.tar.gz", hash = "sha256:3ec50cf65235ff5c02c5b747748d9222e564ad48597122a361269dd3aa808798", size = 144549, upload-time = "2025-12-06T19:04:45.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/7d/e0ce1837dfb452427db556e6d4c5301ba3b22fe8de318379fbd0593759b9/librt-0.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56f2a47beda8409061bc1c865bef2d4bd9ff9255219402c0817e68ab5ad89aed", size = 55742, upload-time = "2025-12-06T19:03:52.459Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/3564262301e507e1d5cf31c7d84cb12addf0d35e05ba53312494a2eba9a4/librt-0.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14569ac5dd38cfccf0a14597a88038fb16811a6fede25c67b79c6d50fc2c8fdc", size = 57163, upload-time = "2025-12-06T19:03:53.516Z" }, + { url = "https://files.pythonhosted.org/packages/be/ac/245e72b7e443d24a562f6047563c7f59833384053073ef9410476f68505b/librt-0.7.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6038ccbd5968325a5d6fd393cf6e00b622a8de545f0994b89dd0f748dcf3e19e", size = 165840, upload-time = "2025-12-06T19:03:54.918Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/587e4491f40adba066ba39a450c66bad794c8d92094f936a201bfc7c2b5f/librt-0.7.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d39079379a9a28e74f4d57dc6357fa310a1977b51ff12239d7271ec7e71d67f5", size = 174827, upload-time = "2025-12-06T19:03:56.082Z" }, + { url = "https://files.pythonhosted.org/packages/78/21/5b8c60ea208bc83dd00421022a3874330685d7e856404128dc3728d5d1af/librt-0.7.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8837d5a52a2d7aa9f4c3220a8484013aed1d8ad75240d9a75ede63709ef89055", size = 189612, upload-time = "2025-12-06T19:03:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/8b819169ef696421fb81cd04c6cdf225f6e96f197366001e9d45180d7e9e/librt-0.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:399bbd7bcc1633c3e356ae274a1deb8781c7bf84d9c7962cc1ae0c6e87837292", size = 184584, upload-time = "2025-12-06T19:03:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/af9d225a9395b77bd7678362cb055d0b8139c2018c37665de110ca388022/librt-0.7.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d8cf653e798ee4c4e654062b633db36984a1572f68c3aa25e364a0ddfbbb910", size = 178269, upload-time = "2025-12-06T19:03:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d8/7b4fa1683b772966749d5683aa3fd605813defffe157833a8fa69cc89207/librt-0.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f03484b54bf4ae80ab2e504a8d99d20d551bfe64a7ec91e218010b467d77093", size = 199852, upload-time = "2025-12-06T19:04:00.901Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/4598413aece46ca38d9260ef6c51534bd5f34b5c21474fcf210ce3a02123/librt-0.7.3-cp313-cp313-win32.whl", hash = "sha256:44b3689b040df57f492e02cd4f0bacd1b42c5400e4b8048160c9d5e866de8abe", size = 47936, upload-time = "2025-12-06T19:04:02.054Z" }, + { url = "https://files.pythonhosted.org/packages/af/80/ac0e92d5ef8c6791b3e2c62373863827a279265e0935acdf807901353b0e/librt-0.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:6b407c23f16ccc36614c136251d6b32bf30de7a57f8e782378f1107be008ddb0", size = 54965, upload-time = "2025-12-06T19:04:03.224Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/042f823fcbff25c1449bb4203a29919891ca74141b68d3a5f6612c4ce283/librt-0.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:abfc57cab3c53c4546aee31859ef06753bfc136c9d208129bad23e2eca39155a", size = 48350, upload-time = "2025-12-06T19:04:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ae/c6ecc7bb97134a71b5241e8855d39964c0e5f4d96558f0d60593892806d2/librt-0.7.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:120dd21d46ff875e849f1aae19346223cf15656be489242fe884036b23d39e93", size = 55175, upload-time = "2025-12-06T19:04:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/2cc0cb0ab787b39aa5c7645cd792433c875982bdf12dccca558b89624594/librt-0.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1617bea5ab31266e152871208502ee943cb349c224846928a1173c864261375e", size = 56881, upload-time = "2025-12-06T19:04:06.674Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/397417a386190b70f5bf26fcedbaa1515f19dce33366e2684c6b7ee83086/librt-0.7.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93b2a1f325fefa1482516ced160c8c7b4b8d53226763fa6c93d151fa25164207", size = 163710, upload-time = "2025-12-06T19:04:08.437Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/7338f85b80e8a17525d941211451199845093ca242b32efbf01df8531e72/librt-0.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d4801db8354436fd3936531e7f0e4feb411f62433a6b6cb32bb416e20b529f", size = 172471, upload-time = "2025-12-06T19:04:10.124Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/741704edabbfae2c852fedc1b40d9ed5a783c70ed3ed8e4fe98f84b25d13/librt-0.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11ad45122bbed42cfc8b0597450660126ef28fd2d9ae1a219bc5af8406f95678", size = 186804, upload-time = "2025-12-06T19:04:11.586Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d1/0a82129d6ba242f3be9af34815be089f35051bc79619f5c27d2c449ecef6/librt-0.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6b4e7bff1d76dd2b46443078519dc75df1b5e01562345f0bb740cea5266d8218", size = 181817, upload-time = "2025-12-06T19:04:12.802Z" }, + { url = "https://files.pythonhosted.org/packages/4f/32/704f80bcf9979c68d4357c46f2af788fbf9d5edda9e7de5786ed2255e911/librt-0.7.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d86f94743a11873317094326456b23f8a5788bad9161fd2f0e52088c33564620", size = 175602, upload-time = "2025-12-06T19:04:14.004Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6d/4355cfa0fae0c062ba72f541d13db5bc575770125a7ad3d4f46f4109d305/librt-0.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:754a0d09997095ad764ccef050dd5bf26cbf457aab9effcba5890dad081d879e", size = 196497, upload-time = "2025-12-06T19:04:15.487Z" }, + { url = "https://files.pythonhosted.org/packages/2e/eb/ac6d8517d44209e5a712fde46f26d0055e3e8969f24d715f70bd36056230/librt-0.7.3-cp314-cp314-win32.whl", hash = "sha256:fbd7351d43b80d9c64c3cfcb50008f786cc82cba0450e8599fdd64f264320bd3", size = 44678, upload-time = "2025-12-06T19:04:16.688Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/238f026d141faf9958da588c761a0812a1a21c98cc54a76f3608454e4e59/librt-0.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:d376a35c6561e81d2590506804b428fc1075fcc6298fc5bb49b771534c0ba010", size = 51689, upload-time = "2025-12-06T19:04:17.726Z" }, + { url = "https://files.pythonhosted.org/packages/52/44/43f462ad9dcf9ed7d3172fe2e30d77b980956250bd90e9889a9cca93df2a/librt-0.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:cbdb3f337c88b43c3b49ca377731912c101178be91cb5071aac48faa898e6f8e", size = 44662, upload-time = "2025-12-06T19:04:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/1d/35/fed6348915f96b7323241de97f26e2af481e95183b34991df12fd5ce31b1/librt-0.7.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9f0e0927efe87cd42ad600628e595a1a0aa1c64f6d0b55f7e6059079a428641a", size = 57347, upload-time = "2025-12-06T19:04:19.812Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f2/045383ccc83e3fea4fba1b761796584bc26817b6b2efb6b8a6731431d16f/librt-0.7.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:020c6db391268bcc8ce75105cb572df8cb659a43fd347366aaa407c366e5117a", size = 59223, upload-time = "2025-12-06T19:04:20.862Z" }, + { url = "https://files.pythonhosted.org/packages/77/3f/c081f8455ab1d7f4a10dbe58463ff97119272ff32494f21839c3b9029c2c/librt-0.7.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7af7785f5edd1f418da09a8cdb9ec84b0213e23d597413e06525340bcce1ea4f", size = 183861, upload-time = "2025-12-06T19:04:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f5/73c5093c22c31fbeaebc25168837f05ebfd8bf26ce00855ef97a5308f36f/librt-0.7.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ccadf260bb46a61b9c7e89e2218f6efea9f3eeaaab4e3d1f58571890e54858e", size = 194594, upload-time = "2025-12-06T19:04:23.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/b8/d5f17d4afe16612a4a94abfded94c16c5a033f183074fb130dfe56fc1a42/librt-0.7.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9883b2d819ce83f87ba82a746c81d14ada78784db431e57cc9719179847376e", size = 206759, upload-time = "2025-12-06T19:04:24.328Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/021765c1be85ee23ffd5b5b968bb4cba7526a4db2a0fc27dcafbdfc32da7/librt-0.7.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:59cb0470612d21fa1efddfa0dd710756b50d9c7fb6c1236bbf8ef8529331dc70", size = 203210, upload-time = "2025-12-06T19:04:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/9923656e42da4fd18c594bd08cf6d7e152d4158f8b808e210d967f0dcceb/librt-0.7.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1fe603877e1865b5fd047a5e40379509a4a60204aa7aa0f72b16f7a41c3f0712", size = 196708, upload-time = "2025-12-06T19:04:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0b/0708b886ac760e64d6fbe7e16024e4be3ad1a3629d19489a97e9cf4c3431/librt-0.7.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5460d99ed30f043595bbdc888f542bad2caeb6226b01c33cda3ae444e8f82d42", size = 217212, upload-time = "2025-12-06T19:04:27.892Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7f/12a73ff17bca4351e73d585dd9ebf46723c4a8622c4af7fe11a2e2d011ff/librt-0.7.3-cp314-cp314t-win32.whl", hash = "sha256:d09f677693328503c9e492e33e9601464297c01f9ebd966ea8fc5308f3069bfd", size = 45586, upload-time = "2025-12-06T19:04:29.116Z" }, + { url = "https://files.pythonhosted.org/packages/e2/df/8decd032ac9b995e4f5606cde783711a71094128d88d97a52e397daf2c89/librt-0.7.3-cp314-cp314t-win_amd64.whl", hash = "sha256:25711f364c64cab2c910a0247e90b51421e45dbc8910ceeb4eac97a9e132fc6f", size = 53002, upload-time = "2025-12-06T19:04:30.173Z" }, + { url = "https://files.pythonhosted.org/packages/de/0c/6605b6199de8178afe7efc77ca1d8e6db00453bc1d3349d27605c0f42104/librt-0.7.3-cp314-cp314t-win_arm64.whl", hash = "sha256:a9f9b661f82693eb56beb0605156c7fca57f535704ab91837405913417d6990b", size = 45647, upload-time = "2025-12-06T19:04:31.302Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "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 = "mypy" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" }, + { url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" }, + { url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" }, + { url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236, upload-time = "2025-11-28T15:45:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902, upload-time = "2025-11-28T15:46:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600, upload-time = "2025-11-28T15:44:22.521Z" }, + { url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639, upload-time = "2025-11-28T15:48:08.55Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132, upload-time = "2025-11-28T15:47:06.032Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832, upload-time = "2025-11-28T15:47:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-whois" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/0c/537914eca91ee5ff281309a5ca71da23c0c975cd6658668a44d3fdcf1cc4/python_whois-0.9.6.tar.gz", hash = "sha256:2e6de7b6d70e305a85f4859cd17781ee3f0da3a02a8e94f23cb4cdcd2e400bfa", size = 125107, upload-time = "2025-10-07T04:36:14.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/53/d0ceb3ae30da8e8ec2d9af11050178f3b4114d5aa6a7f7074199db3c806f/python_whois-0.9.6-py3-none-any.whl", hash = "sha256:153261941a4d238b1278a4ca9b5b5e0590ed3b4d0c534ba111c4434d5d339410", size = 116976, upload-time = "2025-10-07T04:36:12.328Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" }, + { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" }, + { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" }, + { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" }, + { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" }, + { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" }, + { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, +] + +[[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 = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]