From f6658d5ac071a7a5921d39c20624c8fbcf5b4a01 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sat, 17 Jan 2026 20:29:17 +0100 Subject: [PATCH 01/13] Add parallel API request support with --concurrency option - Add --concurrency CLI option (range 1-20, default 5) - Rewrite _perform_domain_operations() to use ThreadPoolExecutor - Add progress bar for parallel mode using click.progressbar() - Preserve verbose per-domain output in sequential mode (--concurrency 1) - Handle rate limits gracefully by stopping new submissions and reporting skipped domains - Print summary (completed/failed/skipped) after parallel operations Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/nextdnsctl.py | 114 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index e483d68..4308b3c 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -1,3 +1,6 @@ +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + import click import requests @@ -15,6 +18,7 @@ ) __version__ = "0.2.0" +DEFAULT_CONCURRENCY = 5 # Helper function to perform operations on a list of domains @@ -29,7 +33,36 @@ def _perform_domain_operations( Iterates over a list of items (e.g., domains) and performs an operation on each. Returns True if all non-critical operations were successful, False otherwise. Exits script if RateLimitStillActiveError is encountered. + + Supports parallel execution when concurrency > 1. """ + concurrency = ctx.obj.get("concurrency", DEFAULT_CONCURRENCY) + + # Sequential mode (concurrency == 1): preserve original verbose behavior + if concurrency == 1: + return _perform_domain_operations_sequential( + ctx, domains_to_process, operation_callable, item_name_singular, action_verb + ) + + # Parallel mode + return _perform_domain_operations_parallel( + ctx, + domains_to_process, + operation_callable, + item_name_singular, + action_verb, + concurrency, + ) + + +def _perform_domain_operations_sequential( + ctx, + domains_to_process, + operation_callable, + item_name_singular, + action_verb, +): + """Sequential execution with verbose per-domain output (original behavior).""" all_successful = True failure_count = 0 for item_value in domains_to_process: @@ -61,6 +94,77 @@ def _perform_domain_operations( return all_successful +def _perform_domain_operations_parallel( + ctx, + domains_to_process, + operation_callable, + item_name_singular, + action_verb, + concurrency, +): + """Parallel execution with progress bar and summary output.""" + rate_limit_hit = threading.Event() + results = {"success": 0, "failed": 0, "skipped": 0} + errors = [] # Collect errors to print after progress bar + rate_limit_aborted = False + + total_domains = len(domains_to_process) + + with ThreadPoolExecutor(max_workers=concurrency) as executor: + futures = {} + for domain in domains_to_process: + if rate_limit_hit.is_set(): + results["skipped"] += 1 + continue + futures[executor.submit(operation_callable, domain)] = domain + + submitted_count = len(futures) + + with click.progressbar( + length=submitted_count, + label=f"Processing {item_name_singular}s", + show_pos=True, + ) as bar: + for future in as_completed(futures): + domain = futures[future] + try: + future.result() + results["success"] += 1 + except RateLimitStillActiveError as e: + rate_limit_hit.set() + rate_limit_aborted = True + results["failed"] += 1 + errors.append( + f"CRITICAL: '{domain}' - persistent rate limiting: {e}" + ) + except Exception as e: + results["failed"] += 1 + errors.append(f"Failed to {action_verb} '{domain}': {e}") + bar.update(1) + + # Print any errors that occurred + for error in errors: + click.echo(error, err=True) + + # Print summary + click.echo( + f"\nCompleted: {results['success']}, " + f"Failed: {results['failed']}, " + f"Skipped: {results['skipped']} " + f"(of {total_domains} total)" + ) + + if rate_limit_aborted: + click.echo( + "Operation aborted due to persistent rate limiting. " + f"{results['skipped']} {item_name_singular}(s) were not attempted.", + err=True, + ) + ctx.exit(1) + + return results["failed"] == 0 + + @click.group() @click.version_option(__version__) @click.option( @@ -84,13 +188,21 @@ def _perform_domain_operations( help=f"Request timeout (in seconds) for API calls. Default: {DEFAULT_TIMEOUT}", show_default=True, ) +@click.option( + "--concurrency", + type=click.IntRange(1, 20), + default=DEFAULT_CONCURRENCY, + help=f"Number of concurrent API requests. Default: {DEFAULT_CONCURRENCY}", + show_default=True, +) @click.pass_context -def cli(ctx, retry_attempts, retry_delay, timeout): +def cli(ctx, retry_attempts, retry_delay, timeout, concurrency): """nextdnsctl: A CLI tool for managing NextDNS profiles.""" ctx.obj = { "retry_attempts": retry_attempts, "retry_delay": retry_delay, "timeout": timeout, + "concurrency": concurrency, } From 92309054231267424cc331ab8da913e06b0813bf Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sat, 17 Jan 2026 20:32:17 +0100 Subject: [PATCH 02/13] Add list command for denylist and allowlist - Add `denylist list ` command to show all denylist entries - Add `allowlist list ` command to show all allowlist entries - Support --active-only and --inactive-only filters - Show inactive entries with "(inactive)" suffix - Display total count in summary Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/nextdnsctl.py | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 4308b3c..b244b1a 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -7,8 +7,10 @@ from .config import save_api_key, load_api_key from .api import ( get_profiles, + get_denylist, add_to_denylist, remove_from_denylist, + get_allowlist, add_to_allowlist, remove_from_allowlist, DEFAULT_RETRIES, @@ -246,6 +248,46 @@ def denylist(): """Manage the NextDNS denylist.""" +@denylist.command("list") +@click.argument("profile_id") +@click.option("--active-only", is_flag=True, help="Show only active entries") +@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") +@click.pass_context +def denylist_list(ctx, profile_id, active_only, inactive_only): + """List all domains in the NextDNS denylist.""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_denylist(profile_id, **api_params) + if not entries: + click.echo("Denylist is empty.") + return + + # Filter by active status if requested + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries found.") + return + + for entry in entries: + domain = entry.get("id", "unknown") + active = entry.get("active", True) + status = "" if active else " (inactive)" + click.echo(f"{domain}{status}") + + click.echo(f"\nTotal: {len(entries)} entries", err=True) + except Exception as e: + click.echo(f"Error fetching denylist: {e}", err=True) + raise click.Abort() + + @denylist.command("add") @click.argument("profile_id") @click.argument("domains", nargs=-1) @@ -361,6 +403,46 @@ def allowlist(): """Manage the NextDNS allowlist.""" +@allowlist.command("list") +@click.argument("profile_id") +@click.option("--active-only", is_flag=True, help="Show only active entries") +@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") +@click.pass_context +def allowlist_list(ctx, profile_id, active_only, inactive_only): + """List all domains in the NextDNS allowlist.""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_allowlist(profile_id, **api_params) + if not entries: + click.echo("Allowlist is empty.") + return + + # Filter by active status if requested + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries found.") + return + + for entry in entries: + domain = entry.get("id", "unknown") + active = entry.get("active", True) + status = "" if active else " (inactive)" + click.echo(f"{domain}{status}") + + click.echo(f"\nTotal: {len(entries)} entries", err=True) + except Exception as e: + click.echo(f"Error fetching allowlist: {e}", err=True) + raise click.Abort() + + @allowlist.command("add") @click.argument("profile_id") @click.argument("domains", nargs=-1) From 9c2b8f53708a8c9e7d7c8ae105be9ef8aa3eb084 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sat, 17 Jan 2026 20:39:24 +0100 Subject: [PATCH 03/13] Add export, clear commands and --dry-run flag Export command: - Add `denylist export` and `allowlist export` commands - Export to file or stdout (with -) - Support --active-only and --inactive-only filters Clear command: - Add `denylist clear` and `allowlist clear` commands - Remove all entries from a list - Require confirmation (skip with --yes/-y) Dry-run flag: - Add global --dry-run flag to show what would be done - Works with add, remove, import, and clear commands - Skips confirmation prompts in dry-run mode Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/nextdnsctl.py | 215 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 1 deletion(-) diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index b244b1a..767f961 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -37,9 +37,17 @@ def _perform_domain_operations( Exits script if RateLimitStillActiveError is encountered. Supports parallel execution when concurrency > 1. + Supports dry-run mode to show what would be done without making changes. """ + dry_run = ctx.obj.get("dry_run", False) concurrency = ctx.obj.get("concurrency", DEFAULT_CONCURRENCY) + # Dry-run mode: just show what would be done + if dry_run: + return _perform_domain_operations_dry_run( + domains_to_process, item_name_singular, action_verb + ) + # Sequential mode (concurrency == 1): preserve original verbose behavior if concurrency == 1: return _perform_domain_operations_sequential( @@ -57,6 +65,19 @@ def _perform_domain_operations( ) +def _perform_domain_operations_dry_run( + domains_to_process, + item_name_singular, + action_verb, +): + """Dry-run mode: show what would be done without making changes.""" + click.echo(f"[DRY-RUN] Would {action_verb} {len(domains_to_process)} {item_name_singular}(s):") + for domain in domains_to_process: + click.echo(f" - {domain}") + click.echo("\n[DRY-RUN] No changes made.", err=True) + return True + + def _perform_domain_operations_sequential( ctx, domains_to_process, @@ -197,14 +218,20 @@ def _perform_domain_operations_parallel( help=f"Number of concurrent API requests. Default: {DEFAULT_CONCURRENCY}", show_default=True, ) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be done without making changes", +) @click.pass_context -def cli(ctx, retry_attempts, retry_delay, timeout, concurrency): +def cli(ctx, retry_attempts, retry_delay, timeout, concurrency, dry_run): """nextdnsctl: A CLI tool for managing NextDNS profiles.""" ctx.obj = { "retry_attempts": retry_attempts, "retry_delay": retry_delay, "timeout": timeout, "concurrency": concurrency, + "dry_run": dry_run, } @@ -385,6 +412,99 @@ def operation(domain_name): ctx.exit(1) +@denylist.command("export") +@click.argument("profile_id") +@click.argument("output", type=click.Path(), default="-") +@click.option("--active-only", is_flag=True, help="Export only active entries") +@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") +@click.pass_context +def denylist_export(ctx, profile_id, output, active_only, inactive_only): + """Export denylist domains to a file (or stdout with -).""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_denylist(profile_id, **api_params) + if not entries: + click.echo("Denylist is empty, nothing to export.", err=True) + return + + # Filter by active status if requested + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries to export.", err=True) + return + + domains = [entry.get("id", "") for entry in entries if entry.get("id")] + content = "\n".join(domains) + "\n" + + if output == "-": + click.echo(content, nl=False) + else: + with open(output, "w") as f: + f.write(content) + click.echo(f"Exported {len(domains)} domains to {output}", err=True) + except Exception as e: + click.echo(f"Error exporting denylist: {e}", err=True) + raise click.Abort() + + +@denylist.command("clear") +@click.argument("profile_id") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def denylist_clear(ctx, profile_id, yes): + """Remove all domains from the denylist.""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_denylist(profile_id, **api_params) + if not entries: + click.echo("Denylist is already empty.") + return + + domains = [entry.get("id") for entry in entries if entry.get("id")] + if not domains: + click.echo("Denylist is already empty.") + return + + dry_run = ctx.obj.get("dry_run", False) + if not yes and not dry_run: + click.confirm( + f"This will remove {len(domains)} domains from the denylist. Continue?", + abort=True, + ) + + def operation(domain_name): + return remove_from_denylist( + profile_id, + domain_name, + retries=ctx.obj["retry_attempts"], + delay=ctx.obj["retry_delay"], + timeout=ctx.obj["timeout"], + ) + + success = _perform_domain_operations( + ctx, domains, operation, item_name_singular="domain", action_verb="remove" + ) + if not success: + ctx.exit(1) + except click.Abort: + raise + except Exception as e: + click.echo(f"Error clearing denylist: {e}", err=True) + raise click.Abort() + + def read_source(source): """Read content from a file or URL.""" if source.startswith("http://") or source.startswith("https://"): @@ -540,5 +660,98 @@ def operation(domain_name): ctx.exit(1) +@allowlist.command("export") +@click.argument("profile_id") +@click.argument("output", type=click.Path(), default="-") +@click.option("--active-only", is_flag=True, help="Export only active entries") +@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") +@click.pass_context +def allowlist_export(ctx, profile_id, output, active_only, inactive_only): + """Export allowlist domains to a file (or stdout with -).""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_allowlist(profile_id, **api_params) + if not entries: + click.echo("Allowlist is empty, nothing to export.", err=True) + return + + # Filter by active status if requested + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries to export.", err=True) + return + + domains = [entry.get("id", "") for entry in entries if entry.get("id")] + content = "\n".join(domains) + "\n" + + if output == "-": + click.echo(content, nl=False) + else: + with open(output, "w") as f: + f.write(content) + click.echo(f"Exported {len(domains)} domains to {output}", err=True) + except Exception as e: + click.echo(f"Error exporting allowlist: {e}", err=True) + raise click.Abort() + + +@allowlist.command("clear") +@click.argument("profile_id") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def allowlist_clear(ctx, profile_id, yes): + """Remove all domains from the allowlist.""" + try: + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_allowlist(profile_id, **api_params) + if not entries: + click.echo("Allowlist is already empty.") + return + + domains = [entry.get("id") for entry in entries if entry.get("id")] + if not domains: + click.echo("Allowlist is already empty.") + return + + dry_run = ctx.obj.get("dry_run", False) + if not yes and not dry_run: + click.confirm( + f"This will remove {len(domains)} domains from the allowlist. Continue?", + abort=True, + ) + + def operation(domain_name): + return remove_from_allowlist( + profile_id, + domain_name, + retries=ctx.obj["retry_attempts"], + delay=ctx.obj["retry_delay"], + timeout=ctx.obj["timeout"], + ) + + success = _perform_domain_operations( + ctx, domains, operation, item_name_singular="domain", action_verb="remove" + ) + if not success: + ctx.exit(1) + except click.Abort: + raise + except Exception as e: + click.echo(f"Error clearing allowlist: {e}", err=True) + raise click.Abort() + + if __name__ == "__main__": cli() From aa4a6449368e8ce824dd1c5e6783c092958b3b03 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 10:56:47 +0100 Subject: [PATCH 04/13] Add support for profile names in addition to IDs - Add _resolve_profile_id() helper that resolves profile name or ID - Support case-insensitive profile name matching - Cache profiles list in ctx.obj to avoid repeated API calls - Show helpful error with available profiles when not found - Update all commands to accept either profile name or ID Example usage: nextdnsctl denylist list "My Profile" nextdnsctl denylist list abc123 Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/nextdnsctl.py | 109 ++++++++++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 767f961..26ebf79 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -23,6 +23,49 @@ DEFAULT_CONCURRENCY = 5 +def _resolve_profile_id(ctx, profile_identifier): + """ + Resolve a profile identifier (ID or name) to a profile ID. + + If the identifier matches an existing profile ID, return it directly. + Otherwise, search for a profile with a matching name. + Caches the profiles list in ctx.obj to avoid repeated API calls. + """ + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + + # Get or fetch profiles (cache in ctx.obj) + if "profiles_cache" not in ctx.obj: + try: + ctx.obj["profiles_cache"] = get_profiles(**api_params) + except Exception as e: + raise click.ClickException(f"Failed to fetch profiles: {e}") + + profiles = ctx.obj["profiles_cache"] + + # First, check if it's a direct ID match + for profile in profiles: + if profile.get("id") == profile_identifier: + return profile_identifier + + # Otherwise, search by name (case-insensitive) + for profile in profiles: + if profile.get("name", "").lower() == profile_identifier.lower(): + return profile["id"] + + # No match found + available = ", ".join( + f"'{p.get('name')}' ({p.get('id')})" for p in profiles + ) + raise click.ClickException( + f"Profile '{profile_identifier}' not found. " + f"Available profiles: {available}" + ) + + # Helper function to perform operations on a list of domains def _perform_domain_operations( ctx, @@ -276,13 +319,14 @@ def denylist(): @denylist.command("list") -@click.argument("profile_id") +@click.argument("profile") @click.option("--active-only", is_flag=True, help="Show only active entries") @click.option("--inactive-only", is_flag=True, help="Show only inactive entries") @click.pass_context -def denylist_list(ctx, profile_id, active_only, inactive_only): +def denylist_list(ctx, profile, active_only, inactive_only): """List all domains in the NextDNS denylist.""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], @@ -316,16 +360,18 @@ def denylist_list(ctx, profile_id, active_only, inactive_only): @denylist.command("add") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") @click.pass_context -def denylist_add(ctx, profile_id, domains, inactive): +def denylist_add(ctx, profile, domains, inactive): """Add domains to the NextDNS denylist.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): return add_to_denylist( profile_id, @@ -344,15 +390,17 @@ def operation(domain_name): @denylist.command("remove") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.pass_context -def denylist_remove(ctx, profile_id, domains): +def denylist_remove(ctx, profile, domains): """Remove domains from the NextDNS denylist.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): return remove_from_denylist( profile_id, @@ -370,12 +418,14 @@ def operation(domain_name): @denylist.command("import") -@click.argument("profile_id") +@click.argument("profile") @click.argument("source") @click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") @click.pass_context -def denylist_import(ctx, profile_id, source, inactive): +def denylist_import(ctx, profile, source, inactive): """Import domains from a file or URL to the NextDNS denylist.""" + profile_id = _resolve_profile_id(ctx, profile) + try: content = read_source(source) except Exception as e: @@ -413,14 +463,15 @@ def operation(domain_name): @denylist.command("export") -@click.argument("profile_id") +@click.argument("profile") @click.argument("output", type=click.Path(), default="-") @click.option("--active-only", is_flag=True, help="Export only active entries") @click.option("--inactive-only", is_flag=True, help="Export only inactive entries") @click.pass_context -def denylist_export(ctx, profile_id, output, active_only, inactive_only): +def denylist_export(ctx, profile, output, active_only, inactive_only): """Export denylist domains to a file (or stdout with -).""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], @@ -456,12 +507,13 @@ def denylist_export(ctx, profile_id, output, active_only, inactive_only): @denylist.command("clear") -@click.argument("profile_id") +@click.argument("profile") @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") @click.pass_context -def denylist_clear(ctx, profile_id, yes): +def denylist_clear(ctx, profile, yes): """Remove all domains from the denylist.""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], @@ -524,13 +576,14 @@ def allowlist(): @allowlist.command("list") -@click.argument("profile_id") +@click.argument("profile") @click.option("--active-only", is_flag=True, help="Show only active entries") @click.option("--inactive-only", is_flag=True, help="Show only inactive entries") @click.pass_context -def allowlist_list(ctx, profile_id, active_only, inactive_only): +def allowlist_list(ctx, profile, active_only, inactive_only): """List all domains in the NextDNS allowlist.""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], @@ -564,16 +617,18 @@ def allowlist_list(ctx, profile_id, active_only, inactive_only): @allowlist.command("add") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.option("--inactive", is_flag=True, help="Add domains as inactive (not allowed)") @click.pass_context -def allowlist_add(ctx, profile_id, domains, inactive): +def allowlist_add(ctx, profile, domains, inactive): """Add domains to the NextDNS allowlist.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): return add_to_allowlist( profile_id, @@ -592,15 +647,17 @@ def operation(domain_name): @allowlist.command("remove") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.pass_context -def allowlist_remove(ctx, profile_id, domains): +def allowlist_remove(ctx, profile, domains): """Remove domains from the NextDNS allowlist.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): return remove_from_allowlist( profile_id, @@ -618,12 +675,14 @@ def operation(domain_name): @allowlist.command("import") -@click.argument("profile_id") +@click.argument("profile") @click.argument("source") @click.option("--inactive", is_flag=True, help="Add domains as inactive (not allowed)") @click.pass_context -def allowlist_import(ctx, profile_id, source, inactive): +def allowlist_import(ctx, profile, source, inactive): """Import domains from a file or URL to the NextDNS allowlist.""" + profile_id = _resolve_profile_id(ctx, profile) + try: content = read_source(source) except Exception as e: @@ -661,14 +720,15 @@ def operation(domain_name): @allowlist.command("export") -@click.argument("profile_id") +@click.argument("profile") @click.argument("output", type=click.Path(), default="-") @click.option("--active-only", is_flag=True, help="Export only active entries") @click.option("--inactive-only", is_flag=True, help="Export only inactive entries") @click.pass_context -def allowlist_export(ctx, profile_id, output, active_only, inactive_only): +def allowlist_export(ctx, profile, output, active_only, inactive_only): """Export allowlist domains to a file (or stdout with -).""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], @@ -704,12 +764,13 @@ def allowlist_export(ctx, profile_id, output, active_only, inactive_only): @allowlist.command("clear") -@click.argument("profile_id") +@click.argument("profile") @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") @click.pass_context -def allowlist_clear(ctx, profile_id, yes): +def allowlist_clear(ctx, profile, yes): """Remove all domains from the allowlist.""" try: + profile_id = _resolve_profile_id(ctx, profile) api_params = { "retries": ctx.obj["retry_attempts"], "delay": ctx.obj["retry_delay"], From 60435415573fdaa0bacf1cd69604dfda6a352752 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 10:59:52 +0100 Subject: [PATCH 05/13] Prepare v0.3.0 release - Bump version to 0.3.0 - Update README with comprehensive documentation for new features: - Parallel API requests with --concurrency option - list, export, clear commands for denylist/allowlist - --dry-run flag for previewing changes - Profile name support (in addition to IDs) - Global options documentation - Improved command reference Co-Authored-By: Claude Opus 4.5 --- README.md | 199 ++++++++++++++++++++++++++------------- nextdnsctl/nextdnsctl.py | 2 +- setup.py | 2 +- 3 files changed, 135 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 7523b95..e202945 100644 --- a/README.md +++ b/README.md @@ -7,79 +7,146 @@ A community-driven CLI tool for managing NextDNS profiles declaratively. **Disclaimer**: This is an unofficial tool, not affiliated with NextDNS. Built by a user, for users. -> ⚠️ **Note**: While `nextdnsctl` now handles API rate limiting and retries, it is **not recommended for importing very large blocklists**. For large-scale filtering, prefer using NextDNS’s built-in curated blocklists under the **Privacy** tab, and use the `denylist` feature for specific overrides or fine-tuning. +> **Note**: While `nextdnsctl` handles API rate limiting and retries, it is **not recommended for importing very large blocklists**. For large-scale filtering, prefer using NextDNS's built-in curated blocklists under the **Privacy** tab, and use the `denylist` feature for specific overrides or fine-tuning. ## Features -- Bulk add/remove domains to the NextDNS denylist and allowlist. -- Import domains from a file or URL to the denylist and allowlist. -- List all profiles to find their IDs. -- More to come: full config sync, etc. + +- Bulk add/remove domains to the NextDNS denylist and allowlist +- Import domains from a file or URL +- Export current list to a file for backup +- List and clear all entries in a list +- Parallel API requests for faster bulk operations +- Dry-run mode to preview changes before applying +- Use profile names or IDs interchangeably ## Installation -1. Install Python 3.6+. -2. Clone or install: - ```bash - pip install nextdnsctl - ``` - -## Usage -1. Set up your API key (find it at https://my.nextdns.io/account): - ```bash - nextdnsctl auth - ``` -2. List profiles: - ```bash - nextdnsctl profile-list - ``` - -### Denylist Management -3. Add domains to denylist: - ```bash - nextdnsctl denylist add bad.com evil.com - ``` -4. Remove domains from denylist: - ```bash - nextdnsctl denylist remove bad.com - ``` -5. Import domains from a file or URL: - - From a file: - ```bash - nextdnsctl denylist import /path/to/blocklist.txt - ``` - - From a URL: - ```bash - nextdnsctl denylist import https://example.com/blocklist.txt - ``` - - Use `--inactive` to add domains as inactive (not blocked): - ```bash - nextdnsctl denylist import blocklist.txt --inactive - ``` - -### Allowlist Management -6. Add domains to allowlist: - ```bash - nextdnsctl allowlist add good.com trusted.com - ``` -7. Remove domains from allowlist: - ```bash - nextdnsctl allowlist remove good.com - ``` -8. Import domains from a file or URL: - - From a file: - ```bash - nextdnsctl allowlist import /path/to/allowlist.txt - ``` - - From a URL: - ```bash - nextdnsctl allowlist import https://example.com/allowlist.txt - ``` - - Use `--inactive` to add domains as inactive (not allowed): - ```bash - nextdnsctl allowlist import allowlist.txt --inactive - ``` + +```bash +pip install nextdnsctl +``` + +Requires Python 3.6+. + +## Quick Start + +```bash +# Authenticate (find your API key at https://my.nextdns.io/account) +nextdnsctl auth + +# List your profiles +nextdnsctl profile-list + +# Add domains to denylist (using profile name or ID) +nextdnsctl denylist add "My Profile" bad.com evil.com + +# Preview changes without applying them +nextdnsctl --dry-run denylist import myprofile blocklist.txt +``` + +## Global Options + +| Option | Description | +|--------|-------------| +| `--concurrency N` | Number of parallel API requests (1-20, default: 5) | +| `--dry-run` | Show what would be done without making changes | +| `--retry-attempts N` | Number of retry attempts for API calls (default: 4) | +| `--retry-delay N` | Initial delay between retries in seconds (default: 1) | +| `--timeout N` | Request timeout in seconds (default: 10) | + +## Profile Identification + +All commands accept either a **profile ID** or **profile name** (case-insensitive): + +```bash +# Using profile ID +nextdnsctl denylist list abc123 + +# Using profile name +nextdnsctl denylist list "My Profile" +``` + +## Denylist Commands + +### List entries +```bash +nextdnsctl denylist list +nextdnsctl denylist list --active-only +nextdnsctl denylist list --inactive-only +``` + +### Add domains +```bash +nextdnsctl denylist add domain1.com domain2.com +nextdnsctl denylist add domain.com --inactive +``` + +### Remove domains +```bash +nextdnsctl denylist remove domain1.com domain2.com +``` + +### Import from file or URL +```bash +nextdnsctl denylist import /path/to/blocklist.txt +nextdnsctl denylist import https://example.com/blocklist.txt +nextdnsctl denylist import blocklist.txt --inactive +``` + +### Export to file +```bash +nextdnsctl denylist export backup.txt +nextdnsctl denylist export # outputs to stdout +nextdnsctl denylist export --active-only > active.txt +``` + +### Clear all entries +```bash +nextdnsctl denylist clear # asks for confirmation +nextdnsctl denylist clear --yes # skip confirmation +``` + +## Allowlist Commands + +All denylist commands are available for allowlist with the same syntax: + +```bash +nextdnsctl allowlist list +nextdnsctl allowlist add good.com trusted.com +nextdnsctl allowlist remove domain.com +nextdnsctl allowlist import allowlist.txt +nextdnsctl allowlist export backup.txt +nextdnsctl allowlist clear --yes +``` + +## Parallel Requests + +By default, bulk operations run 5 concurrent API requests. Adjust with `--concurrency`: + +```bash +# Faster (more concurrent requests) +nextdnsctl --concurrency 10 denylist import myprofile blocklist.txt + +# Sequential mode (verbose per-domain output, like v0.2.0) +nextdnsctl --concurrency 1 denylist import myprofile blocklist.txt +``` + +## Dry-Run Mode + +Preview changes before applying them: + +```bash +$ nextdnsctl --dry-run denylist add myprofile bad.com evil.com +[DRY-RUN] Would add 2 domain(s): + - bad.com + - evil.com + +[DRY-RUN] No changes made. +``` ## Contributing + Pull requests welcome! See [docs/contributing.md](docs/contributing.md) for details. ## License + MIT License - see [LICENSE](LICENSE). diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 26ebf79..499385a 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -19,7 +19,7 @@ RateLimitStillActiveError, ) -__version__ = "0.2.0" +__version__ = "0.3.0" DEFAULT_CONCURRENCY = 5 diff --git a/setup.py b/setup.py index 4a9da46..fffcb6d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="nextdnsctl", - version="0.2.0", + version="0.3.0", packages=find_packages(), install_requires=[ "requests", From a04e507474d81b589dafcd115c99302d4a4b50a6 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 11:44:09 +0100 Subject: [PATCH 06/13] Refactor denylist/allowlist code to reduce duplication - Create generic API functions (get_domain_list, add_to_domain_list, remove_from_domain_list) that accept list_type parameter - Keep original functions as thin wrappers for backwards compatibility - Create shared command handlers (_handle_list_command, etc.) used by both denylist and allowlist commands - Fix USER_AGENT version mismatch (0.2.0 -> 0.3.0) - Net reduction of ~130 lines of duplicate code Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/api.py | 40 ++-- nextdnsctl/nextdnsctl.py | 381 ++++++++++++--------------------------- 2 files changed, 144 insertions(+), 277 deletions(-) diff --git a/nextdnsctl/api.py b/nextdnsctl/api.py index 84f8409..39f9edd 100644 --- a/nextdnsctl/api.py +++ b/nextdnsctl/api.py @@ -8,7 +8,7 @@ DEFAULT_RETRIES = 4 DEFAULT_DELAY = 1 # For general errors or Retry-After scenarios DEFAULT_TIMEOUT = 10 -USER_AGENT = "nextdnsctl/0.2.0" +USER_AGENT = "nextdnsctl/0.3.0" DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Added: Pause for unspecific 429s @@ -123,37 +123,51 @@ def get_profiles(**kwargs): return api_call("GET", "/profiles", **kwargs)["data"] +# Generic domain list functions +def get_domain_list(profile_id, list_type, **kwargs): + """Retrieve the current list (denylist/allowlist) for a profile.""" + return api_call("GET", f"/profiles/{profile_id}/{list_type}", **kwargs)["data"] + + +def add_to_domain_list(profile_id, list_type, domain, active=True, **kwargs): + """Add a domain to a list (denylist/allowlist).""" + data = {"id": domain, "active": active} + api_call("POST", f"/profiles/{profile_id}/{list_type}", data=data, **kwargs) + return f"Added {domain} as {'active' if active else 'inactive'}" + + +def remove_from_domain_list(profile_id, list_type, domain, **kwargs): + """Remove a domain from a list (denylist/allowlist).""" + api_call("DELETE", f"/profiles/{profile_id}/{list_type}/{domain}", **kwargs) + return f"Removed {domain}" + + +# Convenience wrappers for backwards compatibility def get_denylist(profile_id, **kwargs): """Retrieve the current denylist for a profile.""" - return api_call("GET", f"/profiles/{profile_id}/denylist", **kwargs)["data"] + return get_domain_list(profile_id, "denylist", **kwargs) def add_to_denylist(profile_id, domain, active=True, **kwargs): """Add a domain to the denylist.""" - data = {"id": domain, "active": active} - api_call("POST", f"/profiles/{profile_id}/denylist", data=data, **kwargs) - return f"Added {domain} as {'active' if active else 'inactive'}" + return add_to_domain_list(profile_id, "denylist", domain, active, **kwargs) def remove_from_denylist(profile_id, domain, **kwargs): """Remove a domain from the denylist.""" - api_call("DELETE", f"/profiles/{profile_id}/denylist/{domain}", **kwargs) - return f"Removed {domain}" + return remove_from_domain_list(profile_id, "denylist", domain, **kwargs) def get_allowlist(profile_id, **kwargs): """Retrieve the current allowlist for a profile.""" - return api_call("GET", f"/profiles/{profile_id}/allowlist", **kwargs)["data"] + return get_domain_list(profile_id, "allowlist", **kwargs) def add_to_allowlist(profile_id, domain, active=True, **kwargs): """Add a domain to the allowlist.""" - data = {"id": domain, "active": active} - api_call("POST", f"/profiles/{profile_id}/allowlist", data=data, **kwargs) - return f"Added {domain} as {'active' if active else 'inactive'}" + return add_to_domain_list(profile_id, "allowlist", domain, active, **kwargs) def remove_from_allowlist(profile_id, domain, **kwargs): """Remove a domain from the allowlist.""" - api_call("DELETE", f"/profiles/{profile_id}/allowlist/{domain}", **kwargs) - return f"Removed {domain}" + return remove_from_domain_list(profile_id, "allowlist", domain, **kwargs) diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 499385a..0d74b09 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -7,12 +7,9 @@ from .config import save_api_key, load_api_key from .api import ( get_profiles, - get_denylist, - add_to_denylist, - remove_from_denylist, - get_allowlist, - add_to_allowlist, - remove_from_allowlist, + get_domain_list, + add_to_domain_list, + remove_from_domain_list, DEFAULT_RETRIES, DEFAULT_DELAY, DEFAULT_TIMEOUT, @@ -313,18 +310,22 @@ def profile_list(ctx): raise click.Abort() -@cli.group("denylist") -def denylist(): - """Manage the NextDNS denylist.""" +def read_source(source): + """Read content from a file or URL.""" + if source.startswith("http://") or source.startswith("https://"): + response = requests.get( + source, timeout=DEFAULT_TIMEOUT + ) # Using global default timeout + response.raise_for_status() + return response.text + else: + with open(source, "r") as f: + return f.read() -@denylist.command("list") -@click.argument("profile") -@click.option("--active-only", is_flag=True, help="Show only active entries") -@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") -@click.pass_context -def denylist_list(ctx, profile, active_only, inactive_only): - """List all domains in the NextDNS denylist.""" +# Shared command handlers for denylist/allowlist +def _handle_list_command(ctx, profile, list_type, active_only, inactive_only): + """Shared handler for list commands.""" try: profile_id = _resolve_profile_id(ctx, profile) api_params = { @@ -332,12 +333,11 @@ def denylist_list(ctx, profile, active_only, inactive_only): "delay": ctx.obj["retry_delay"], "timeout": ctx.obj["timeout"], } - entries = get_denylist(profile_id, **api_params) + entries = get_domain_list(profile_id, list_type, **api_params) if not entries: - click.echo("Denylist is empty.") + click.echo(f"{list_type.capitalize()} is empty.") return - # Filter by active status if requested if active_only: entries = [e for e in entries if e.get("active", True)] elif inactive_only: @@ -355,17 +355,12 @@ def denylist_list(ctx, profile, active_only, inactive_only): click.echo(f"\nTotal: {len(entries)} entries", err=True) except Exception as e: - click.echo(f"Error fetching denylist: {e}", err=True) + click.echo(f"Error fetching {list_type}: {e}", err=True) raise click.Abort() -@denylist.command("add") -@click.argument("profile") -@click.argument("domains", nargs=-1) -@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") -@click.pass_context -def denylist_add(ctx, profile, domains, inactive): - """Add domains to the NextDNS denylist.""" +def _handle_add_command(ctx, profile, list_type, domains, inactive): + """Shared handler for add commands.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() @@ -373,8 +368,9 @@ def denylist_add(ctx, profile, domains, inactive): profile_id = _resolve_profile_id(ctx, profile) def operation(domain_name): - return add_to_denylist( + return add_to_domain_list( profile_id, + list_type, domain_name, active=not inactive, retries=ctx.obj["retry_attempts"], @@ -389,12 +385,8 @@ def operation(domain_name): ctx.exit(1) -@denylist.command("remove") -@click.argument("profile") -@click.argument("domains", nargs=-1) -@click.pass_context -def denylist_remove(ctx, profile, domains): - """Remove domains from the NextDNS denylist.""" +def _handle_remove_command(ctx, profile, list_type, domains): + """Shared handler for remove commands.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() @@ -402,8 +394,9 @@ def denylist_remove(ctx, profile, domains): profile_id = _resolve_profile_id(ctx, profile) def operation(domain_name): - return remove_from_denylist( + return remove_from_domain_list( profile_id, + list_type, domain_name, retries=ctx.obj["retry_attempts"], delay=ctx.obj["retry_delay"], @@ -417,13 +410,8 @@ def operation(domain_name): ctx.exit(1) -@denylist.command("import") -@click.argument("profile") -@click.argument("source") -@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") -@click.pass_context -def denylist_import(ctx, profile, source, inactive): - """Import domains from a file or URL to the NextDNS denylist.""" +def _handle_import_command(ctx, profile, list_type, source, inactive): + """Shared handler for import commands.""" profile_id = _resolve_profile_id(ctx, profile) try: @@ -442,8 +430,9 @@ def denylist_import(ctx, profile, source, inactive): return def operation(domain_name): - return add_to_denylist( + return add_to_domain_list( profile_id, + list_type, domain_name, active=not inactive, retries=ctx.obj["retry_attempts"], @@ -462,14 +451,8 @@ def operation(domain_name): ctx.exit(1) -@denylist.command("export") -@click.argument("profile") -@click.argument("output", type=click.Path(), default="-") -@click.option("--active-only", is_flag=True, help="Export only active entries") -@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") -@click.pass_context -def denylist_export(ctx, profile, output, active_only, inactive_only): - """Export denylist domains to a file (or stdout with -).""" +def _handle_export_command(ctx, profile, list_type, output, active_only, inactive_only): + """Shared handler for export commands.""" try: profile_id = _resolve_profile_id(ctx, profile) api_params = { @@ -477,12 +460,13 @@ def denylist_export(ctx, profile, output, active_only, inactive_only): "delay": ctx.obj["retry_delay"], "timeout": ctx.obj["timeout"], } - entries = get_denylist(profile_id, **api_params) + entries = get_domain_list(profile_id, list_type, **api_params) if not entries: - click.echo("Denylist is empty, nothing to export.", err=True) + click.echo( + f"{list_type.capitalize()} is empty, nothing to export.", err=True + ) return - # Filter by active status if requested if active_only: entries = [e for e in entries if e.get("active", True)] elif inactive_only: @@ -502,16 +486,12 @@ def denylist_export(ctx, profile, output, active_only, inactive_only): f.write(content) click.echo(f"Exported {len(domains)} domains to {output}", err=True) except Exception as e: - click.echo(f"Error exporting denylist: {e}", err=True) + click.echo(f"Error exporting {list_type}: {e}", err=True) raise click.Abort() -@denylist.command("clear") -@click.argument("profile") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.pass_context -def denylist_clear(ctx, profile, yes): - """Remove all domains from the denylist.""" +def _handle_clear_command(ctx, profile, list_type, yes): + """Shared handler for clear commands.""" try: profile_id = _resolve_profile_id(ctx, profile) api_params = { @@ -519,26 +499,28 @@ def denylist_clear(ctx, profile, yes): "delay": ctx.obj["retry_delay"], "timeout": ctx.obj["timeout"], } - entries = get_denylist(profile_id, **api_params) + entries = get_domain_list(profile_id, list_type, **api_params) if not entries: - click.echo("Denylist is already empty.") + click.echo(f"{list_type.capitalize()} is already empty.") return domains = [entry.get("id") for entry in entries if entry.get("id")] if not domains: - click.echo("Denylist is already empty.") + click.echo(f"{list_type.capitalize()} is already empty.") return dry_run = ctx.obj.get("dry_run", False) if not yes and not dry_run: click.confirm( - f"This will remove {len(domains)} domains from the denylist. Continue?", + f"This will remove {len(domains)} domains from the {list_type}. " + "Continue?", abort=True, ) def operation(domain_name): - return remove_from_denylist( + return remove_from_domain_list( profile_id, + list_type, domain_name, retries=ctx.obj["retry_attempts"], delay=ctx.obj["retry_delay"], @@ -553,21 +535,72 @@ def operation(domain_name): except click.Abort: raise except Exception as e: - click.echo(f"Error clearing denylist: {e}", err=True) + click.echo(f"Error clearing {list_type}: {e}", err=True) raise click.Abort() -def read_source(source): - """Read content from a file or URL.""" - if source.startswith("http://") or source.startswith("https://"): - response = requests.get( - source, timeout=DEFAULT_TIMEOUT - ) # Using global default timeout - response.raise_for_status() - return response.text - else: - with open(source, "r") as f: - return f.read() +@cli.group("denylist") +def denylist(): + """Manage the NextDNS denylist.""" + + +@denylist.command("list") +@click.argument("profile") +@click.option("--active-only", is_flag=True, help="Show only active entries") +@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") +@click.pass_context +def denylist_list(ctx, profile, active_only, inactive_only): + """List all domains in the NextDNS denylist.""" + _handle_list_command(ctx, profile, "denylist", active_only, inactive_only) + + +@denylist.command("add") +@click.argument("profile") +@click.argument("domains", nargs=-1) +@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") +@click.pass_context +def denylist_add(ctx, profile, domains, inactive): + """Add domains to the NextDNS denylist.""" + _handle_add_command(ctx, profile, "denylist", domains, inactive) + + +@denylist.command("remove") +@click.argument("profile") +@click.argument("domains", nargs=-1) +@click.pass_context +def denylist_remove(ctx, profile, domains): + """Remove domains from the NextDNS denylist.""" + _handle_remove_command(ctx, profile, "denylist", domains) + + +@denylist.command("import") +@click.argument("profile") +@click.argument("source") +@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") +@click.pass_context +def denylist_import(ctx, profile, source, inactive): + """Import domains from a file or URL to the NextDNS denylist.""" + _handle_import_command(ctx, profile, "denylist", source, inactive) + + +@denylist.command("export") +@click.argument("profile") +@click.argument("output", type=click.Path(), default="-") +@click.option("--active-only", is_flag=True, help="Export only active entries") +@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") +@click.pass_context +def denylist_export(ctx, profile, output, active_only, inactive_only): + """Export denylist domains to a file (or stdout with -).""" + _handle_export_command(ctx, profile, "denylist", output, active_only, inactive_only) + + +@denylist.command("clear") +@click.argument("profile") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def denylist_clear(ctx, profile, yes): + """Remove all domains from the denylist.""" + _handle_clear_command(ctx, profile, "denylist", yes) @cli.group("allowlist") @@ -582,38 +615,7 @@ def allowlist(): @click.pass_context def allowlist_list(ctx, profile, active_only, inactive_only): """List all domains in the NextDNS allowlist.""" - try: - profile_id = _resolve_profile_id(ctx, profile) - api_params = { - "retries": ctx.obj["retry_attempts"], - "delay": ctx.obj["retry_delay"], - "timeout": ctx.obj["timeout"], - } - entries = get_allowlist(profile_id, **api_params) - if not entries: - click.echo("Allowlist is empty.") - return - - # Filter by active status if requested - if active_only: - entries = [e for e in entries if e.get("active", True)] - elif inactive_only: - entries = [e for e in entries if not e.get("active", True)] - - if not entries: - click.echo("No matching entries found.") - return - - for entry in entries: - domain = entry.get("id", "unknown") - active = entry.get("active", True) - status = "" if active else " (inactive)" - click.echo(f"{domain}{status}") - - click.echo(f"\nTotal: {len(entries)} entries", err=True) - except Exception as e: - click.echo(f"Error fetching allowlist: {e}", err=True) - raise click.Abort() + _handle_list_command(ctx, profile, "allowlist", active_only, inactive_only) @allowlist.command("add") @@ -623,27 +625,7 @@ def allowlist_list(ctx, profile, active_only, inactive_only): @click.pass_context def allowlist_add(ctx, profile, domains, inactive): """Add domains to the NextDNS allowlist.""" - if not domains: - click.echo("No domains provided.", err=True) - raise click.Abort() - - profile_id = _resolve_profile_id(ctx, profile) - - def operation(domain_name): - return add_to_allowlist( - profile_id, - domain_name, - active=not inactive, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, domains, operation, item_name_singular="domain", action_verb="add" - ) - if not success: - ctx.exit(1) + _handle_add_command(ctx, profile, "allowlist", domains, inactive) @allowlist.command("remove") @@ -652,26 +634,7 @@ def operation(domain_name): @click.pass_context def allowlist_remove(ctx, profile, domains): """Remove domains from the NextDNS allowlist.""" - if not domains: - click.echo("No domains provided.", err=True) - raise click.Abort() - - profile_id = _resolve_profile_id(ctx, profile) - - def operation(domain_name): - return remove_from_allowlist( - profile_id, - domain_name, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, domains, operation, item_name_singular="domain", action_verb="remove" - ) - if not success: - ctx.exit(1) + _handle_remove_command(ctx, profile, "allowlist", domains) @allowlist.command("import") @@ -681,42 +644,7 @@ def operation(domain_name): @click.pass_context def allowlist_import(ctx, profile, source, inactive): """Import domains from a file or URL to the NextDNS allowlist.""" - profile_id = _resolve_profile_id(ctx, profile) - - try: - content = read_source(source) - except Exception as e: - click.echo(f"Error reading source: {e}", err=True) - raise click.Abort() - - domains_to_import = [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.strip().startswith("#") - ] - if not domains_to_import: - click.echo("No domains found in source.", err=True) - return - - def operation(domain_name): - return add_to_allowlist( - profile_id, - domain_name, - active=not inactive, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, - domains_to_import, - operation, - item_name_singular="domain", - action_verb="add", - ) - if not success: - ctx.exit(1) + _handle_import_command(ctx, profile, "allowlist", source, inactive) @allowlist.command("export") @@ -727,40 +655,7 @@ def operation(domain_name): @click.pass_context def allowlist_export(ctx, profile, output, active_only, inactive_only): """Export allowlist domains to a file (or stdout with -).""" - try: - profile_id = _resolve_profile_id(ctx, profile) - api_params = { - "retries": ctx.obj["retry_attempts"], - "delay": ctx.obj["retry_delay"], - "timeout": ctx.obj["timeout"], - } - entries = get_allowlist(profile_id, **api_params) - if not entries: - click.echo("Allowlist is empty, nothing to export.", err=True) - return - - # Filter by active status if requested - if active_only: - entries = [e for e in entries if e.get("active", True)] - elif inactive_only: - entries = [e for e in entries if not e.get("active", True)] - - if not entries: - click.echo("No matching entries to export.", err=True) - return - - domains = [entry.get("id", "") for entry in entries if entry.get("id")] - content = "\n".join(domains) + "\n" - - if output == "-": - click.echo(content, nl=False) - else: - with open(output, "w") as f: - f.write(content) - click.echo(f"Exported {len(domains)} domains to {output}", err=True) - except Exception as e: - click.echo(f"Error exporting allowlist: {e}", err=True) - raise click.Abort() + _handle_export_command(ctx, profile, "allowlist", output, active_only, inactive_only) @allowlist.command("clear") @@ -769,49 +664,7 @@ def allowlist_export(ctx, profile, output, active_only, inactive_only): @click.pass_context def allowlist_clear(ctx, profile, yes): """Remove all domains from the allowlist.""" - try: - profile_id = _resolve_profile_id(ctx, profile) - api_params = { - "retries": ctx.obj["retry_attempts"], - "delay": ctx.obj["retry_delay"], - "timeout": ctx.obj["timeout"], - } - entries = get_allowlist(profile_id, **api_params) - if not entries: - click.echo("Allowlist is already empty.") - return - - domains = [entry.get("id") for entry in entries if entry.get("id")] - if not domains: - click.echo("Allowlist is already empty.") - return - - dry_run = ctx.obj.get("dry_run", False) - if not yes and not dry_run: - click.confirm( - f"This will remove {len(domains)} domains from the allowlist. Continue?", - abort=True, - ) - - def operation(domain_name): - return remove_from_allowlist( - profile_id, - domain_name, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, domains, operation, item_name_singular="domain", action_verb="remove" - ) - if not success: - ctx.exit(1) - except click.Abort: - raise - except Exception as e: - click.echo(f"Error clearing allowlist: {e}", err=True) - raise click.Abort() + _handle_clear_command(ctx, profile, "allowlist", yes) if __name__ == "__main__": From 88a6974e785e3a33ea7e808bfc1de80a9b65dc8b Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 11:46:24 +0100 Subject: [PATCH 07/13] Add security improvements and streaming imports Security: - Support NEXTDNS_API_KEY environment variable (priority over config file) - Set secure file permissions (600) on config file - Set secure directory permissions (700) on config directory Import improvements: - Stream file/URL content for memory efficiency with large files - Support inline comments (e.g., "example.com # reason") - Better comment and whitespace handling Co-Authored-By: Claude Opus 4.5 --- README.md | 46 +++++++++++++++++++++++++++++++++------- nextdnsctl/config.py | 27 +++++++++++++++++++---- nextdnsctl/nextdnsctl.py | 43 +++++++++++++++++++++++++------------ 3 files changed, 91 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e202945..c52074a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ A community-driven CLI tool for managing NextDNS profiles declaratively. **Disclaimer**: This is an unofficial tool, not affiliated with NextDNS. Built by a user, for users. -> **Note**: While `nextdnsctl` handles API rate limiting and retries, it is **not recommended for importing very large blocklists**. For large-scale filtering, prefer using NextDNS's built-in curated blocklists under the **Privacy** tab, and use the `denylist` feature for specific overrides or fine-tuning. +> **Note**: While `nextdnsctl` handles API rate limiting and retries, it is **not recommended for importing very large +blocklists**. For large-scale filtering, prefer using NextDNS's built-in curated blocklists under the **Privacy** tab, +> and use the `denylist` feature for specific overrides or fine-tuning. ## Features @@ -43,15 +45,31 @@ nextdnsctl denylist add "My Profile" bad.com evil.com nextdnsctl --dry-run denylist import myprofile blocklist.txt ``` +## Authentication + +The API key can be provided in two ways (in order of priority): + +1. **Environment variable** (recommended for CI/CD): + ```bash + export NEXTDNS_API_KEY=your-api-key + nextdnsctl profile-list + ``` + +2. **Config file** (created by `auth` command): + ```bash + nextdnsctl auth + # Stored in ~/.nextdnsctl/config.json with secure permissions + ``` + ## Global Options -| Option | Description | -|--------|-------------| -| `--concurrency N` | Number of parallel API requests (1-20, default: 5) | -| `--dry-run` | Show what would be done without making changes | -| `--retry-attempts N` | Number of retry attempts for API calls (default: 4) | -| `--retry-delay N` | Initial delay between retries in seconds (default: 1) | -| `--timeout N` | Request timeout in seconds (default: 10) | +| Option | Description | +|----------------------|-------------------------------------------------------| +| `--concurrency N` | Number of parallel API requests (1-20, default: 5) | +| `--dry-run` | Show what would be done without making changes | +| `--retry-attempts N` | Number of retry attempts for API calls (default: 4) | +| `--retry-delay N` | Initial delay between retries in seconds (default: 1) | +| `--timeout N` | Request timeout in seconds (default: 10) | ## Profile Identification @@ -68,6 +86,7 @@ nextdnsctl denylist list "My Profile" ## Denylist Commands ### List entries + ```bash nextdnsctl denylist list nextdnsctl denylist list --active-only @@ -75,24 +94,34 @@ nextdnsctl denylist list --inactive-only ``` ### Add domains + ```bash nextdnsctl denylist add domain1.com domain2.com nextdnsctl denylist add domain.com --inactive ``` ### Remove domains + ```bash nextdnsctl denylist remove domain1.com domain2.com ``` ### Import from file or URL + ```bash nextdnsctl denylist import /path/to/blocklist.txt nextdnsctl denylist import https://example.com/blocklist.txt nextdnsctl denylist import blocklist.txt --inactive ``` +The import file format supports: +- One domain per line +- Comments starting with `#` +- Inline comments (e.g., `example.com # reason`) +- Empty lines (ignored) + ### Export to file + ```bash nextdnsctl denylist export backup.txt nextdnsctl denylist export # outputs to stdout @@ -100,6 +129,7 @@ nextdnsctl denylist export --active-only > active.txt ``` ### Clear all entries + ```bash nextdnsctl denylist clear # asks for confirmation nextdnsctl denylist clear --yes # skip confirmation diff --git a/nextdnsctl/config.py b/nextdnsctl/config.py index fc787a9..18f61f1 100644 --- a/nextdnsctl/config.py +++ b/nextdnsctl/config.py @@ -1,21 +1,40 @@ import json import os +import stat CONFIG_DIR = os.path.expanduser("~/.nextdnsctl") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") +ENV_VAR_NAME = "NEXTDNS_API_KEY" def save_api_key(api_key): - """Save the NextDNS API key to a local config file.""" - os.makedirs(CONFIG_DIR, exist_ok=True) + """Save the NextDNS API key to a local config file with secure permissions.""" + os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump({"api_key": api_key}, f) + # Set file permissions to read/write for owner only (600) + os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) def load_api_key(): - """Load the NextDNS API key from the config file.""" + """ + Load the NextDNS API key. + + Priority: + 1. NEXTDNS_API_KEY environment variable + 2. Config file (~/.nextdnsctl/config.json) + """ + # Check environment variable first + env_key = os.environ.get(ENV_VAR_NAME) + if env_key: + return env_key + + # Fall back to config file if not os.path.exists(CONFIG_FILE): - raise ValueError("No API key found. Run 'nextdnsctl auth ' first.") + raise ValueError( + f"No API key found. Set {ENV_VAR_NAME} environment variable " + "or run 'nextdnsctl auth '." + ) with open(CONFIG_FILE, "r") as f: config = json.load(f) if "api_key" not in config: diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 0d74b09..cce3d69 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -310,17 +310,37 @@ def profile_list(ctx): raise click.Abort() -def read_source(source): - """Read content from a file or URL.""" +def read_domains_from_source(source): + """ + Read domains from a file or URL, yielding one domain per line. + + Handles: + - Comment lines (starting with #) + - Inline comments (e.g., "example.com # bad site") + - Empty lines and whitespace + - Streaming for memory efficiency with large files + """ if source.startswith("http://") or source.startswith("https://"): - response = requests.get( - source, timeout=DEFAULT_TIMEOUT - ) # Using global default timeout + response = requests.get(source, stream=True, timeout=DEFAULT_TIMEOUT) response.raise_for_status() - return response.text + for line in response.iter_lines(decode_unicode=True): + if line: + domain = _parse_domain_line(line) + if domain: + yield domain else: with open(source, "r") as f: - return f.read() + for line in f: + domain = _parse_domain_line(line) + if domain: + yield domain + + +def _parse_domain_line(line): + """Parse a single line, handling comments and whitespace.""" + # Strip inline comments (e.g., "example.com # bad site" -> "example.com") + line = line.split("#")[0].strip() + return line if line else None # Shared command handlers for denylist/allowlist @@ -415,16 +435,13 @@ def _handle_import_command(ctx, profile, list_type, source, inactive): profile_id = _resolve_profile_id(ctx, profile) try: - content = read_source(source) + # Use generator to stream file/URL and collect domains + # This avoids loading raw file content into memory + domains_to_import = list(read_domains_from_source(source)) except Exception as e: click.echo(f"Error reading source: {e}", err=True) raise click.Abort() - domains_to_import = [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.strip().startswith("#") - ] if not domains_to_import: click.echo("No domains found in source.", err=True) return From f9a0ab4c63504c8f5bfe6b47169ac16105fcd119 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 11:49:16 +0100 Subject: [PATCH 08/13] Simplify .gitignore and remove tracked .idea files Co-Authored-By: Claude Opus 4.5 --- .gitignore | 286 ++---------------- .idea/.gitignore | 8 - .idea/inspectionProfiles/Project_Default.xml | 6 - .../inspectionProfiles/profiles_settings.xml | 6 - .idea/misc.xml | 6 - .idea/modules.xml | 8 - .idea/nextdnsctl.iml | 10 - .idea/vcs.xml | 6 - 8 files changed, 25 insertions(+), 311 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/nextdnsctl.iml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 7325ab7..4942ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,292 +1,56 @@ -# Created by https://www.toptal.com/developers/gitignore/api/pycharm,python -# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm,python - -# my stuff -.*.md - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format +# IDE +.idea/ +*.iml *.iws +*.ipr +.vscode/ -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# Cache file creation bug -# See https://youtrack.jetbrains.com/issue/JBR-2257 -.idea/$CACHE_FILE$ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - -# Azure Toolkit for IntelliJ plugin -# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij -.idea/**/azureSettings.xml - -### Python ### -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg *.egg +.eggs/ +.installed.cfg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ -# Unit test / coverage reports -htmlcov/ +# Testing / Coverage .tox/ .nox/ .coverage .coverage.* .cache +.pytest_cache/ +htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy +# Type checkers .mypy_cache/ .dmypy.json dmypy.json - -# Pyre type checker .pyre/ - -# pytype static type analyzer .pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -### Python Patch ### -# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration -poetry.toml - -# ruff .ruff_cache/ -# LSP config files -pyrightconfig.json +# Misc +*.log +*.pot +*.mo +.DS_Store -# End of https://www.toptal.com/developers/gitignore/api/pycharm,python +# Personal +.*.md diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 878d08e..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 4616d5e..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/nextdnsctl.iml b/.idea/nextdnsctl.iml deleted file mode 100644 index 762f8b7..0000000 --- a/.idea/nextdnsctl.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 0a6300a4acf882d98025c1c48f467a93059242a3 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 11:54:41 +0100 Subject: [PATCH 09/13] Add type hints and improve API URL handling - Add comprehensive type hints to all modules (api.py, config.py, nextdnsctl.py) - Use urllib.parse.urljoin for safer API URL construction - Import typing module for List, Dict, Optional, Callable, etc. - Improves IDE support and code documentation Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/api.py | 77 ++++++++++++++++---------- nextdnsctl/config.py | 10 ++-- nextdnsctl/nextdnsctl.py | 115 ++++++++++++++++++++++++++------------- 3 files changed, 129 insertions(+), 73 deletions(-) diff --git a/nextdnsctl/api.py b/nextdnsctl/api.py index 39f9edd..fb7d12f 100644 --- a/nextdnsctl/api.py +++ b/nextdnsctl/api.py @@ -1,34 +1,39 @@ -import requests import time +from typing import Any, Dict, List, Optional +from urllib.parse import urljoin + +import requests from requests.exceptions import RequestException from .config import load_api_key -API_BASE = "https://api.nextdns.io" +API_BASE = "https://api.nextdns.io/" DEFAULT_RETRIES = 4 DEFAULT_DELAY = 1 # For general errors or Retry-After scenarios DEFAULT_TIMEOUT = 10 USER_AGENT = "nextdnsctl/0.3.0" -DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Added: Pause for unspecific 429s +DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Pause for unspecific 429s + +class RateLimitStillActiveError(Exception): + """Raised when API rate limit persists after all retry attempts.""" -# Custom Exception for persistent rate limits -class RateLimitStillActiveError(Exception): # Added pass def api_call( - method, - endpoint, - data=None, - retries=DEFAULT_RETRIES, - delay=DEFAULT_DELAY, - timeout=DEFAULT_TIMEOUT, -): + method: str, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + retries: int = DEFAULT_RETRIES, + delay: float = DEFAULT_DELAY, + timeout: float = DEFAULT_TIMEOUT, +) -> Optional[Dict[str, Any]]: """Make an API request to NextDNS.""" api_key = load_api_key() headers = {"X-Api-Key": api_key, "User-Agent": USER_AGENT} - url = f"{API_BASE}{endpoint}" + # Use urljoin for safer URL construction + url = urljoin(API_BASE, endpoint.lstrip("/")) for attempt in range(retries + 1): try: @@ -72,7 +77,7 @@ def api_call( if response.status_code not in (200, 201, 204): # For server errors (5xx), retry with exponential backoff if retries are available if response.status_code >= 500 and attempt < retries: - current_delay = delay * (2 ** attempt) + current_delay = delay * (2**attempt) print( f"Server error ({response.status_code}). Retrying in {current_delay}s " f"(attempt {attempt + 1}/{retries + 1})..." @@ -104,7 +109,7 @@ def api_call( except RequestException as e: if attempt < retries: - current_delay = delay * (2 ** attempt) + current_delay = delay * (2**attempt) print( f"Network error ({e}). Retrying in {current_delay}s " f"(attempt {attempt + 1}/{retries + 1})..." @@ -118,56 +123,70 @@ def api_call( ) -def get_profiles(**kwargs): +def get_profiles(**kwargs: Any) -> List[Dict[str, Any]]: """Retrieve all NextDNS profiles.""" - return api_call("GET", "/profiles", **kwargs)["data"] + return api_call("GET", "profiles", **kwargs)["data"] # Generic domain list functions -def get_domain_list(profile_id, list_type, **kwargs): +def get_domain_list( + profile_id: str, list_type: str, **kwargs: Any +) -> List[Dict[str, Any]]: """Retrieve the current list (denylist/allowlist) for a profile.""" - return api_call("GET", f"/profiles/{profile_id}/{list_type}", **kwargs)["data"] + return api_call("GET", f"profiles/{profile_id}/{list_type}", **kwargs)["data"] -def add_to_domain_list(profile_id, list_type, domain, active=True, **kwargs): +def add_to_domain_list( + profile_id: str, + list_type: str, + domain: str, + active: bool = True, + **kwargs: Any, +) -> str: """Add a domain to a list (denylist/allowlist).""" data = {"id": domain, "active": active} - api_call("POST", f"/profiles/{profile_id}/{list_type}", data=data, **kwargs) + api_call("POST", f"profiles/{profile_id}/{list_type}", data=data, **kwargs) return f"Added {domain} as {'active' if active else 'inactive'}" -def remove_from_domain_list(profile_id, list_type, domain, **kwargs): +def remove_from_domain_list( + profile_id: str, list_type: str, domain: str, **kwargs: Any +) -> str: """Remove a domain from a list (denylist/allowlist).""" - api_call("DELETE", f"/profiles/{profile_id}/{list_type}/{domain}", **kwargs) + api_call("DELETE", f"profiles/{profile_id}/{list_type}/{domain}", **kwargs) return f"Removed {domain}" # Convenience wrappers for backwards compatibility -def get_denylist(profile_id, **kwargs): +def get_denylist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]: """Retrieve the current denylist for a profile.""" return get_domain_list(profile_id, "denylist", **kwargs) -def add_to_denylist(profile_id, domain, active=True, **kwargs): +def add_to_denylist( + profile_id: str, domain: str, active: bool = True, **kwargs: Any +) -> str: """Add a domain to the denylist.""" return add_to_domain_list(profile_id, "denylist", domain, active, **kwargs) -def remove_from_denylist(profile_id, domain, **kwargs): +def remove_from_denylist(profile_id: str, domain: str, **kwargs: Any) -> str: """Remove a domain from the denylist.""" return remove_from_domain_list(profile_id, "denylist", domain, **kwargs) -def get_allowlist(profile_id, **kwargs): +def get_allowlist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]: """Retrieve the current allowlist for a profile.""" return get_domain_list(profile_id, "allowlist", **kwargs) -def add_to_allowlist(profile_id, domain, active=True, **kwargs): +def add_to_allowlist( + profile_id: str, domain: str, active: bool = True, **kwargs: Any +) -> str: """Add a domain to the allowlist.""" return add_to_domain_list(profile_id, "allowlist", domain, active, **kwargs) -def remove_from_allowlist(profile_id, domain, **kwargs): +def remove_from_allowlist(profile_id: str, domain: str, **kwargs: Any) -> str: """Remove a domain from the allowlist.""" return remove_from_domain_list(profile_id, "allowlist", domain, **kwargs) diff --git a/nextdnsctl/config.py b/nextdnsctl/config.py index 18f61f1..d7b72a4 100644 --- a/nextdnsctl/config.py +++ b/nextdnsctl/config.py @@ -2,12 +2,12 @@ import os import stat -CONFIG_DIR = os.path.expanduser("~/.nextdnsctl") -CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") -ENV_VAR_NAME = "NEXTDNS_API_KEY" +CONFIG_DIR: str = os.path.expanduser("~/.nextdnsctl") +CONFIG_FILE: str = os.path.join(CONFIG_DIR, "config.json") +ENV_VAR_NAME: str = "NEXTDNS_API_KEY" -def save_api_key(api_key): +def save_api_key(api_key: str) -> None: """Save the NextDNS API key to a local config file with secure permissions.""" os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) with open(CONFIG_FILE, "w") as f: @@ -16,7 +16,7 @@ def save_api_key(api_key): os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) -def load_api_key(): +def load_api_key() -> str: """ Load the NextDNS API key. diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index cce3d69..63a92aa 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -1,5 +1,6 @@ import threading from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Callable, Iterator, List, Optional, Tuple import click import requests @@ -20,7 +21,7 @@ DEFAULT_CONCURRENCY = 5 -def _resolve_profile_id(ctx, profile_identifier): +def _resolve_profile_id(ctx: click.Context, profile_identifier: str) -> str: """ Resolve a profile identifier (ID or name) to a profile ID. @@ -54,23 +55,20 @@ def _resolve_profile_id(ctx, profile_identifier): return profile["id"] # No match found - available = ", ".join( - f"'{p.get('name')}' ({p.get('id')})" for p in profiles - ) + available = ", ".join(f"'{p.get('name')}' ({p.get('id')})" for p in profiles) raise click.ClickException( - f"Profile '{profile_identifier}' not found. " - f"Available profiles: {available}" + f"Profile '{profile_identifier}' not found. " f"Available profiles: {available}" ) # Helper function to perform operations on a list of domains def _perform_domain_operations( - ctx, - domains_to_process, - operation_callable, - item_name_singular="domain", - action_verb="process", -): + ctx: click.Context, + domains_to_process: List[str], + operation_callable: Callable[[str], str], + item_name_singular: str = "domain", + action_verb: str = "process", +) -> bool: """ Iterates over a list of items (e.g., domains) and performs an operation on each. Returns True if all non-critical operations were successful, False otherwise. @@ -106,12 +104,14 @@ def _perform_domain_operations( def _perform_domain_operations_dry_run( - domains_to_process, - item_name_singular, - action_verb, -): + domains_to_process: List[str], + item_name_singular: str, + action_verb: str, +) -> bool: """Dry-run mode: show what would be done without making changes.""" - click.echo(f"[DRY-RUN] Would {action_verb} {len(domains_to_process)} {item_name_singular}(s):") + click.echo( + f"[DRY-RUN] Would {action_verb} {len(domains_to_process)} {item_name_singular}(s):" + ) for domain in domains_to_process: click.echo(f" - {domain}") click.echo("\n[DRY-RUN] No changes made.", err=True) @@ -119,12 +119,12 @@ def _perform_domain_operations_dry_run( def _perform_domain_operations_sequential( - ctx, - domains_to_process, - operation_callable, - item_name_singular, - action_verb, -): + ctx: click.Context, + domains_to_process: List[str], + operation_callable: Callable[[str], str], + item_name_singular: str, + action_verb: str, +) -> bool: """Sequential execution with verbose per-domain output (original behavior).""" all_successful = True failure_count = 0 @@ -158,13 +158,13 @@ def _perform_domain_operations_sequential( def _perform_domain_operations_parallel( - ctx, - domains_to_process, - operation_callable, - item_name_singular, - action_verb, - concurrency, -): + ctx: click.Context, + domains_to_process: List[str], + operation_callable: Callable[[str], str], + item_name_singular: str, + action_verb: str, + concurrency: int, +) -> bool: """Parallel execution with progress bar and summary output.""" rate_limit_hit = threading.Event() results = {"success": 0, "failed": 0, "skipped": 0} @@ -310,7 +310,7 @@ def profile_list(ctx): raise click.Abort() -def read_domains_from_source(source): +def read_domains_from_source(source: str) -> Iterator[str]: """ Read domains from a file or URL, yielding one domain per line. @@ -336,7 +336,7 @@ def read_domains_from_source(source): yield domain -def _parse_domain_line(line): +def _parse_domain_line(line: str) -> Optional[str]: """Parse a single line, handling comments and whitespace.""" # Strip inline comments (e.g., "example.com # bad site" -> "example.com") line = line.split("#")[0].strip() @@ -344,7 +344,13 @@ def _parse_domain_line(line): # Shared command handlers for denylist/allowlist -def _handle_list_command(ctx, profile, list_type, active_only, inactive_only): +def _handle_list_command( + ctx: click.Context, + profile: str, + list_type: str, + active_only: bool, + inactive_only: bool, +) -> None: """Shared handler for list commands.""" try: profile_id = _resolve_profile_id(ctx, profile) @@ -379,7 +385,13 @@ def _handle_list_command(ctx, profile, list_type, active_only, inactive_only): raise click.Abort() -def _handle_add_command(ctx, profile, list_type, domains, inactive): +def _handle_add_command( + ctx: click.Context, + profile: str, + list_type: str, + domains: Tuple[str, ...], + inactive: bool, +) -> None: """Shared handler for add commands.""" if not domains: click.echo("No domains provided.", err=True) @@ -405,7 +417,12 @@ def operation(domain_name): ctx.exit(1) -def _handle_remove_command(ctx, profile, list_type, domains): +def _handle_remove_command( + ctx: click.Context, + profile: str, + list_type: str, + domains: Tuple[str, ...], +) -> None: """Shared handler for remove commands.""" if not domains: click.echo("No domains provided.", err=True) @@ -430,7 +447,13 @@ def operation(domain_name): ctx.exit(1) -def _handle_import_command(ctx, profile, list_type, source, inactive): +def _handle_import_command( + ctx: click.Context, + profile: str, + list_type: str, + source: str, + inactive: bool, +) -> None: """Shared handler for import commands.""" profile_id = _resolve_profile_id(ctx, profile) @@ -468,7 +491,14 @@ def operation(domain_name): ctx.exit(1) -def _handle_export_command(ctx, profile, list_type, output, active_only, inactive_only): +def _handle_export_command( + ctx: click.Context, + profile: str, + list_type: str, + output: str, + active_only: bool, + inactive_only: bool, +) -> None: """Shared handler for export commands.""" try: profile_id = _resolve_profile_id(ctx, profile) @@ -507,7 +537,12 @@ def _handle_export_command(ctx, profile, list_type, output, active_only, inactiv raise click.Abort() -def _handle_clear_command(ctx, profile, list_type, yes): +def _handle_clear_command( + ctx: click.Context, + profile: str, + list_type: str, + yes: bool, +) -> None: """Shared handler for clear commands.""" try: profile_id = _resolve_profile_id(ctx, profile) @@ -672,7 +707,9 @@ def allowlist_import(ctx, profile, source, inactive): @click.pass_context def allowlist_export(ctx, profile, output, active_only, inactive_only): """Export allowlist domains to a file (or stdout with -).""" - _handle_export_command(ctx, profile, "allowlist", output, active_only, inactive_only) + _handle_export_command( + ctx, profile, "allowlist", output, active_only, inactive_only + ) @allowlist.command("clear") From c2bd6cdcb4577689fe5b90adf044ae78504ed446 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 16:37:30 +0100 Subject: [PATCH 10/13] Add comprehensive test suite with CI integration Implement testing infrastructure based on test-strategy.md: - Add requirements-dev.txt for declarative dev dependencies - Create pytest fixtures in tests/conftest.py - Add unit tests for config and domain parsing - Add integration tests for CLI commands (profile, denylist, auth, import) - Add API resilience tests (retry, rate limiting, network errors) - Add concurrency validation tests - Replace lint.yml with test.yml (pytest + flake8, Python 3.10-3.12 matrix) 45 tests covering unit, integration, and edge case scenarios. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/lint.yml | 14 --- .github/workflows/test.yml | 34 ++++++ requirements-dev.txt | 13 ++ tests/__init__.py | 0 tests/conftest.py | 54 +++++++++ tests/test_api_resilience.py | 166 ++++++++++++++++++++++++++ tests/test_cli_integration.py | 218 ++++++++++++++++++++++++++++++++++ tests/test_concurrency.py | 70 +++++++++++ tests/test_config.py | 99 +++++++++++++++ tests/test_parsing.py | 41 +++++++ 10 files changed, 695 insertions(+), 14 deletions(-) delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api_resilience.py create mode 100644 tests/test_cli_integration.py create mode 100644 tests/test_concurrency.py create mode 100644 tests/test_config.py create mode 100644 tests/test_parsing.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ae2b665..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - run: pip install flake8 - - run: flake8 . --max-line-length=120 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4e3bcf2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Install package + run: pip install -e . + + - name: Run tests + run: pytest tests/ -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install flake8 + - run: flake8 . --max-line-length=120 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b54af68 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Development and testing dependencies +-r requirements.txt + +# Testing +pytest>=8.0 +pytest-mock>=3.12 +requests-mock>=1.11 + +# Linting (already in CI, but good to have locally) +flake8>=7.0 + +# Type checking (recommended in test strategy) +mypy>=1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e1faa3e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + """Provides a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_api_key(monkeypatch): + """Sets a fake API key via environment variable.""" + monkeypatch.setenv("NEXTDNS_API_KEY", "fake-key-123") + + +@pytest.fixture +def mock_profiles_response(): + """Standard mock response for the profiles endpoint.""" + return { + "data": [ + {"id": "abc1234", "name": "My Profile"}, + {"id": "xyz9876", "name": "Kids Profile"}, + ] + } + + +@pytest.fixture +def mock_denylist_response(): + """Standard mock response for a denylist.""" + return { + "data": [ + {"id": "bad-domain.com", "active": True}, + {"id": "inactive-domain.com", "active": False}, + {"id": "another-bad.net", "active": True}, + ] + } + + +@pytest.fixture +def mock_allowlist_response(): + """Standard mock response for an allowlist.""" + return { + "data": [ + {"id": "good-domain.com", "active": True}, + {"id": "trusted-site.org", "active": True}, + ] + } + + +@pytest.fixture +def mock_empty_list_response(): + """Mock response for an empty list.""" + return {"data": []} diff --git a/tests/test_api_resilience.py b/tests/test_api_resilience.py new file mode 100644 index 0000000..4bc645b --- /dev/null +++ b/tests/test_api_resilience.py @@ -0,0 +1,166 @@ +"""Tests for API error handling and resilience.""" + +import pytest +from unittest.mock import Mock + +from nextdnsctl.api import api_call, RateLimitStillActiveError + + +class TestRetryOn500: + """Tests for retry behavior on server errors.""" + + def test_retries_on_500_then_succeeds(self, mocker): + """Should retry on 500 errors and succeed when server recovers.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") # Don't actually sleep + + # First two calls fail with 500, third succeeds + mock_response_fail = Mock() + mock_response_fail.status_code = 500 + + mock_response_ok = Mock() + mock_response_ok.status_code = 200 + mock_response_ok.json.return_value = {"success": True} + + mock_req.side_effect = [mock_response_fail, mock_response_fail, mock_response_ok] + + result = api_call("GET", "test", retries=3) + + assert result == {"success": True} + assert mock_req.call_count == 3 + + def test_fails_after_exhausting_retries(self, mocker): + """Should raise exception after all retries exhausted.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.return_value = {"errors": [{"detail": "Internal error"}]} + mock_req.return_value = mock_response + + with pytest.raises(Exception, match="Internal error"): + api_call("GET", "test", retries=2) + + assert mock_req.call_count == 3 # Initial + 2 retries + + +class TestRateLimiting: + """Tests for rate limit (429) handling.""" + + def test_respects_retry_after_header(self, mocker): + """Should sleep for Retry-After seconds when rate limited.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mock_sleep = mocker.patch("nextdnsctl.api.time.sleep") + + # First call: 429 with Retry-After, second call: success + mock_rate_limited = Mock() + mock_rate_limited.status_code = 429 + mock_rate_limited.headers = {"Retry-After": "5"} + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"data": []} + + mock_req.side_effect = [mock_rate_limited, mock_ok] + + api_call("GET", "test", retries=1) + + mock_sleep.assert_called_with(5) + + def test_uses_default_pause_without_retry_after(self, mocker): + """Should use default pause when no Retry-After header.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mock_sleep = mocker.patch("nextdnsctl.api.time.sleep") + + mock_rate_limited = Mock() + mock_rate_limited.status_code = 429 + mock_rate_limited.headers = {} # No Retry-After + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"data": []} + + mock_req.side_effect = [mock_rate_limited, mock_ok] + + api_call("GET", "test", retries=1) + + # Default pause is 60 seconds (DEFAULT_PATIENT_RETRY_PAUSE_SECONDS) + mock_sleep.assert_called_with(60) + + def test_raises_rate_limit_error_after_exhaustion(self, mocker): + """Should raise RateLimitStillActiveError when rate limit persists.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_response = Mock() + mock_response.status_code = 429 + mock_response.headers = {} # No Retry-After + mock_req.return_value = mock_response + + with pytest.raises(RateLimitStillActiveError): + api_call("GET", "test", retries=2) + + +class TestNetworkErrors: + """Tests for network error handling.""" + + def test_retries_on_network_error(self, mocker): + """Should retry on network exceptions.""" + from requests.exceptions import ConnectionError + + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"success": True} + + # First two calls fail with network error, third succeeds + mock_req.side_effect = [ + ConnectionError("Connection refused"), + ConnectionError("Connection refused"), + mock_ok, + ] + + result = api_call("GET", "test", retries=3) + + assert result == {"success": True} + assert mock_req.call_count == 3 + + +class TestSuccessResponses: + """Tests for successful response handling.""" + + def test_handles_204_no_content(self, mocker): + """Should return None for 204 responses.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + + mock_response = Mock() + mock_response.status_code = 204 + mock_req.return_value = mock_response + + result = api_call("DELETE", "test/resource") + + assert result is None + + def test_handles_201_created(self, mocker): + """Should handle 201 Created responses.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "new-resource"} + mock_req.return_value = mock_response + + result = api_call("POST", "test") + + assert result == {"id": "new-resource"} diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py new file mode 100644 index 0000000..9ea63ed --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,218 @@ +"""Integration tests for CLI commands with mocked API.""" + +import requests_mock as rm + +from nextdnsctl.nextdnsctl import cli +from nextdnsctl.api import API_BASE + + +class TestProfileList: + """Tests for profile-list command.""" + + def test_lists_profiles(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["profile-list"]) + + assert result.exit_code == 0 + assert "abc1234: My Profile" in result.output + assert "xyz9876: Kids Profile" in result.output + + def test_empty_profiles(self, runner, mock_api_key): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json={"data": []}) + + result = runner.invoke(cli, ["profile-list"]) + + assert result.exit_code == 0 + assert "No profiles found" in result.output + + +class TestDenylistCommands: + """Tests for denylist subcommands.""" + + def test_denylist_list(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "abc1234"]) + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + assert "inactive-domain.com (inactive)" in result.output + + def test_denylist_list_by_name(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + """Profile resolution by name should work.""" + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "My Profile"]) + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + + def test_denylist_add(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "bad.com", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "add", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.called + assert adapter.last_request.json() == {"id": "bad.com", "active": True} + + def test_denylist_add_inactive(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "bad.com", "active": False}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "add", "abc1234", "--inactive", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.last_request.json() == {"id": "bad.com", "active": False} + + def test_denylist_remove(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.delete(f"{API_BASE}profiles/abc1234/denylist/bad.com", status_code=204) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "remove", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.called + + +class TestDryRun: + """Tests for --dry-run mode.""" + + def test_denylist_add_dry_run_makes_no_requests(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + # No POST mock - if it tries to POST, it will fail + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", status_code=500) + + result = runner.invoke(cli, ["--dry-run", "denylist", "add", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert "[DRY-RUN]" in result.output + assert "bad.com" in result.output + assert not adapter.called # No actual request made + + +class TestProfileResolution: + """Tests for profile ID/name resolution.""" + + def test_profile_not_found(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "list", "Nonexistent Profile"]) + + assert result.exit_code != 0 + assert "not found" in result.output + + def test_case_insensitive_name_match(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "my profile"]) # lowercase + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + + +class TestAuthCommand: + """Tests for auth command.""" + + def test_auth_saves_api_key(self, runner, tmp_path, monkeypatch): + """Auth command should save API key to config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + # Also patch in nextdnsctl.py since it imports from config + monkeypatch.setattr("nextdnsctl.nextdnsctl.save_api_key", + lambda k: __import__('nextdnsctl.config', fromlist=['save_api_key']).save_api_key(k)) + + result = runner.invoke(cli, ["auth", "my-test-key-123"]) + + assert result.exit_code == 0 + assert "saved successfully" in result.output + assert config_file.exists() + + def test_auth_without_key_fails(self, runner): + """Auth command should fail when no key provided.""" + result = runner.invoke(cli, ["auth"]) + + assert result.exit_code != 0 + assert "Missing argument" in result.output + + +class TestImportCommand: + """Tests for import command.""" + + def test_import_from_file(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import should read domains from a file and add them.""" + # Create a test file with domains + domains_file = tmp_path / "domains.txt" + domains_file.write_text("bad1.com\nbad2.com\n# comment line\nbad3.com # inline comment\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Should have made 3 POST requests (comment lines excluded) + assert adapter.call_count == 3 + # Verify the domains that were sent + requests_made = [req.json()["id"] for req in adapter.request_history] + assert "bad1.com" in requests_made + assert "bad2.com" in requests_made + assert "bad3.com" in requests_made + + def test_import_empty_file(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import should handle empty files gracefully.""" + domains_file = tmp_path / "empty.txt" + domains_file.write_text("# only comments\n\n \n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "import", "abc1234", str(domains_file)]) + + assert "No domains found" in result.output + + def test_import_nonexistent_file(self, runner, mock_api_key, mock_profiles_response): + """Import should fail gracefully for nonexistent files.""" + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "import", "abc1234", "/nonexistent/file.txt"]) + + assert result.exit_code != 0 + assert "Error reading source" in result.output + + def test_import_dry_run(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import with --dry-run should not make API calls.""" + domains_file = tmp_path / "domains.txt" + domains_file.write_text("bad1.com\nbad2.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", status_code=500) + + result = runner.invoke(cli, ["--dry-run", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + assert "[DRY-RUN]" in result.output + assert "bad1.com" in result.output + assert "bad2.com" in result.output + assert not adapter.called diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..d7ece4d --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,70 @@ +"""Tests for concurrency/parallelism validation.""" + +import requests_mock as rm + +from nextdnsctl.nextdnsctl import cli +from nextdnsctl.api import API_BASE + + +class TestConcurrency: + """Tests to verify parallel execution works correctly.""" + + def test_parallel_mode_uses_summary_output(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """ + Verify that with concurrency > 1, parallel mode is used. + + Parallel mode shows a summary with "Completed: X, Failed: Y, Skipped: Z" + while sequential mode shows per-domain output like "Added domain.com". + """ + domains_file = tmp_path / "domains.txt" + domains_file.write_text("d1.com\nd2.com\nd3.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "3", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Parallel mode shows summary format + assert "Completed:" in result.output + assert "Failed:" in result.output + # Should NOT show per-domain "Added" messages (that's sequential mode) + assert "Added d1.com" not in result.output + + def test_sequential_mode_shows_per_domain_output(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """ + Verify that with concurrency=1, sequential mode is used. + + Sequential mode shows per-domain output like "Added domain.com as active" + while parallel mode shows a summary format. + """ + domains_file = tmp_path / "domains.txt" + domains_file.write_text("d1.com\nd2.com\nd3.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Sequential mode shows per-domain messages + assert "Added d1.com" in result.output + assert "Added d2.com" in result.output + assert "Added d3.com" in result.output + # Should NOT show parallel summary format + assert "Completed:" not in result.output + + def test_concurrency_respects_max_limit(self, runner): + """Concurrency option should reject values > 20.""" + result = runner.invoke(cli, ["--concurrency", "21", "profile-list"]) + + assert result.exit_code != 0 + assert "21" in result.output or "range" in result.output.lower() + + def test_concurrency_respects_min_limit(self, runner): + """Concurrency option should reject values < 1.""" + result = runner.invoke(cli, ["--concurrency", "0", "profile-list"]) + + assert result.exit_code != 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a442da6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,99 @@ +"""Unit tests for configuration handling.""" + +import json +import os +import stat +import pytest + +from nextdnsctl.config import save_api_key, load_api_key, ENV_VAR_NAME + + +class TestSaveApiKey: + """Tests for save_api_key function.""" + + def test_creates_config_file(self, tmp_path, monkeypatch): + """Verify save_api_key creates the config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + + save_api_key("test-key-123") + + assert config_file.exists() + with open(config_file) as f: + data = json.load(f) + assert data["api_key"] == "test-key-123" + + def test_file_permissions_are_secure(self, tmp_path, monkeypatch): + """Verify config file has 600 permissions (owner read/write only).""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + + save_api_key("test-key-123") + + file_mode = os.stat(config_file).st_mode + # Check that only owner has read/write (600 = S_IRUSR | S_IWUSR) + assert file_mode & 0o777 == stat.S_IRUSR | stat.S_IWUSR + + +class TestLoadApiKey: + """Tests for load_api_key function.""" + + def test_env_var_takes_precedence(self, tmp_path, monkeypatch): + """Environment variable should override config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + # Create config file with one key + with open(config_file, "w") as f: + json.dump({"api_key": "file-key"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.setenv(ENV_VAR_NAME, "env-key") + + assert load_api_key() == "env-key" + + def test_falls_back_to_config_file(self, tmp_path, monkeypatch): + """Should use config file when env var is not set.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + with open(config_file, "w") as f: + json.dump({"api_key": "file-key"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + assert load_api_key() == "file-key" + + def test_raises_when_no_config_exists(self, tmp_path, monkeypatch): + """Should raise ValueError when no API key is found.""" + config_file = tmp_path / "nonexistent" / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + with pytest.raises(ValueError, match="No API key found"): + load_api_key() + + def test_raises_when_config_missing_api_key(self, tmp_path, monkeypatch): + """Should raise ValueError when config file lacks api_key.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + with open(config_file, "w") as f: + json.dump({"other_key": "value"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + with pytest.raises(ValueError, match="Invalid config file"): + load_api_key() diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..13a0a25 --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,41 @@ +"""Unit tests for domain parsing logic.""" + +from nextdnsctl.nextdnsctl import _parse_domain_line + + +class TestParseDomainLine: + """Tests for _parse_domain_line function.""" + + def test_simple_domain(self): + assert _parse_domain_line("example.com") == "example.com" + + def test_domain_with_inline_comment(self): + assert _parse_domain_line("example.com # comment") == "example.com" + + def test_domain_with_inline_comment_no_space(self): + assert _parse_domain_line("example.com#comment") == "example.com" + + def test_pure_comment(self): + assert _parse_domain_line("# pure comment") is None + + def test_comment_with_leading_spaces(self): + assert _parse_domain_line(" # comment") is None + + def test_empty_line(self): + assert _parse_domain_line("") is None + + def test_whitespace_only(self): + assert _parse_domain_line(" ") is None + + def test_domain_with_trailing_whitespace(self): + assert _parse_domain_line("example.com ") == "example.com" + + def test_domain_with_leading_whitespace(self): + assert _parse_domain_line(" example.com") == "example.com" + + def test_subdomain(self): + assert _parse_domain_line("sub.example.com") == "sub.example.com" + + def test_complex_inline_comment(self): + # Only the first # should trigger comment stripping + assert _parse_domain_line("domain.com # bad site # really bad") == "domain.com" From 24e3617bf56d4a9818ea5afa847e428a9edfc295 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 16:44:45 +0100 Subject: [PATCH 11/13] Fix mypy type checking errors - Add types-requests stub to requirements-dev.txt - Handle None returns from api_call() in get_profiles/get_domain_list - Change function signatures to accept Sequence[str] instead of List[str] - Add explicit type annotations for list comprehensions and progressbar Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/api.py | 10 ++++++++-- nextdnsctl/nextdnsctl.py | 17 +++++++++-------- requirements-dev.txt | 1 + 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/nextdnsctl/api.py b/nextdnsctl/api.py index fb7d12f..197874d 100644 --- a/nextdnsctl/api.py +++ b/nextdnsctl/api.py @@ -125,7 +125,10 @@ def api_call( def get_profiles(**kwargs: Any) -> List[Dict[str, Any]]: """Retrieve all NextDNS profiles.""" - return api_call("GET", "profiles", **kwargs)["data"] + response = api_call("GET", "profiles", **kwargs) + if response is None: + raise Exception("Unexpected empty response from profiles endpoint") + return response["data"] # Generic domain list functions @@ -133,7 +136,10 @@ def get_domain_list( profile_id: str, list_type: str, **kwargs: Any ) -> List[Dict[str, Any]]: """Retrieve the current list (denylist/allowlist) for a profile.""" - return api_call("GET", f"profiles/{profile_id}/{list_type}", **kwargs)["data"] + response = api_call("GET", f"profiles/{profile_id}/{list_type}", **kwargs) + if response is None: + raise Exception(f"Unexpected empty response from {list_type} endpoint") + return response["data"] def add_to_domain_list( diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 63a92aa..3cb1629 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -1,6 +1,6 @@ import threading from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Callable, Iterator, List, Optional, Tuple +from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple # noqa: F401 import click import requests @@ -64,7 +64,7 @@ def _resolve_profile_id(ctx: click.Context, profile_identifier: str) -> str: # Helper function to perform operations on a list of domains def _perform_domain_operations( ctx: click.Context, - domains_to_process: List[str], + domains_to_process: Sequence[str], operation_callable: Callable[[str], str], item_name_singular: str = "domain", action_verb: str = "process", @@ -104,7 +104,7 @@ def _perform_domain_operations( def _perform_domain_operations_dry_run( - domains_to_process: List[str], + domains_to_process: Sequence[str], item_name_singular: str, action_verb: str, ) -> bool: @@ -120,7 +120,7 @@ def _perform_domain_operations_dry_run( def _perform_domain_operations_sequential( ctx: click.Context, - domains_to_process: List[str], + domains_to_process: Sequence[str], operation_callable: Callable[[str], str], item_name_singular: str, action_verb: str, @@ -159,7 +159,7 @@ def _perform_domain_operations_sequential( def _perform_domain_operations_parallel( ctx: click.Context, - domains_to_process: List[str], + domains_to_process: Sequence[str], operation_callable: Callable[[str], str], item_name_singular: str, action_verb: str, @@ -183,11 +183,12 @@ def _perform_domain_operations_parallel( submitted_count = len(futures) - with click.progressbar( + progress_bar: Any = click.progressbar( length=submitted_count, label=f"Processing {item_name_singular}s", show_pos=True, - ) as bar: + ) + with progress_bar as bar: for future in as_completed(futures): domain = futures[future] try: @@ -556,7 +557,7 @@ def _handle_clear_command( click.echo(f"{list_type.capitalize()} is already empty.") return - domains = [entry.get("id") for entry in entries if entry.get("id")] + domains: List[str] = [entry["id"] for entry in entries if entry.get("id")] if not domains: click.echo(f"{list_type.capitalize()} is already empty.") return diff --git a/requirements-dev.txt b/requirements-dev.txt index b54af68..5f8ffcb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,3 +11,4 @@ flake8>=7.0 # Type checking (recommended in test strategy) mypy>=1.0 +types-requests>=2.31 From 9fb1e3f3857a97b444bca631b9f82365e638d8a9 Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 16:55:00 +0100 Subject: [PATCH 12/13] Centralize version to single source of truth for v1.0.0 - Define __version__ in nextdnsctl/__init__.py as canonical source - Import version in nextdnsctl.py and api.py instead of duplicating - Update setup.py to read version via regex (avoids import issues) - Update classifiers for 1.0: Production/Stable, Python 3.10+ Co-Authored-By: Claude Opus 4.5 --- nextdnsctl/__init__.py | 3 +++ nextdnsctl/api.py | 3 ++- nextdnsctl/nextdnsctl.py | 3 +-- setup.py | 21 ++++++++++++++------- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/nextdnsctl/__init__.py b/nextdnsctl/__init__.py index e69de29..2a6960a 100644 --- a/nextdnsctl/__init__.py +++ b/nextdnsctl/__init__.py @@ -0,0 +1,3 @@ +"""nextdnsctl - A CLI tool for managing NextDNS profiles.""" + +__version__ = "1.0.0" diff --git a/nextdnsctl/api.py b/nextdnsctl/api.py index 197874d..da1db1b 100644 --- a/nextdnsctl/api.py +++ b/nextdnsctl/api.py @@ -5,13 +5,14 @@ import requests from requests.exceptions import RequestException +from . import __version__ from .config import load_api_key API_BASE = "https://api.nextdns.io/" DEFAULT_RETRIES = 4 DEFAULT_DELAY = 1 # For general errors or Retry-After scenarios DEFAULT_TIMEOUT = 10 -USER_AGENT = "nextdnsctl/0.3.0" +USER_AGENT = f"nextdnsctl/{__version__}" DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Pause for unspecific 429s diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index 3cb1629..95453a7 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -5,6 +5,7 @@ import click import requests +from . import __version__ from .config import save_api_key, load_api_key from .api import ( get_profiles, @@ -16,8 +17,6 @@ DEFAULT_TIMEOUT, RateLimitStillActiveError, ) - -__version__ = "0.3.0" DEFAULT_CONCURRENCY = 5 diff --git a/setup.py b/setup.py index fffcb6d..ea46d87 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,19 @@ +import re from setuptools import setup, find_packages + +def get_version(): + """Read version from nextdnsctl/__init__.py without importing.""" + with open("nextdnsctl/__init__.py") as f: + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', f.read(), re.M) + if match: + return match.group(1) + raise RuntimeError("Version not found") + + setup( name="nextdnsctl", - version="0.3.0", + version=get_version(), packages=find_packages(), install_requires=[ "requests", @@ -21,15 +32,11 @@ license="MIT", url="https://github.com/danielmeint/nextdnsctl", keywords=["nextdns", "cli", "dns", "security", "networking"], - python_requires=">=3.6", + python_requires=">=3.10", classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From 1b4fb3e7998e8361b4ed160cec1f63c305fe17ce Mon Sep 17 00:00:00 2001 From: Daniel Meint Date: Sun, 18 Jan 2026 17:02:42 +0100 Subject: [PATCH 13/13] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c52074a..25e3a88 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ blocklists**. For large-scale filtering, prefer using NextDNS's built-in curated pip install nextdnsctl ``` -Requires Python 3.6+. +Requires Python 3.10+. ## Quick Start