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.
This commit is contained in:
CarterPerez-dev 2025-12-09 11:22:16 -05:00
parent 1075336358
commit 590ef42977
14 changed files with 2565 additions and 0 deletions

View File

@ -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/

47
PROJECTS/dns-lookup/.style.yapf Executable file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
"""
CarterPerez-dev | 2025
__init__.py
"""
__version__ = "0.1.0"
__author__ = "CarterPerez-dev"

View File

@ -0,0 +1,11 @@
"""
CarterPerez-dev | 2025
__main__.py
Entry point for python -m dnslookup
"""
from dnslookup.cli import app
if __name__ == "__main__":
app()

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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"

View File

@ -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"

View File

@ -0,0 +1,5 @@
"""
CarterPerez-dev | 2025
__init__.py
Test suite for DNS Lookup CLI
"""

View File

@ -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 == []

418
PROJECTS/dns-lookup/uv.lock Normal file
View File

@ -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" },
]