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/.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
diff --git a/README.md b/README.md
index 7523b95..25e3a88 100644
--- a/README.md
+++ b/README.md
@@ -7,79 +7,176 @@ 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
+pip install nextdnsctl
+```
+
+Requires Python 3.10+.
+
+## 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
+```
+
+## 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
```
-### 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:
+2. **Config file** (created by `auth` command):
```bash
- nextdnsctl allowlist remove good.com
+ nextdnsctl auth
+ # Stored in ~/.nextdnsctl/config.json with secure permissions
```
-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
- ```
+
+## 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
+```
+
+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
+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/__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 84f8409..da1db1b 100644
--- a/nextdnsctl/api.py
+++ b/nextdnsctl/api.py
@@ -1,34 +1,40 @@
-import requests
import time
+from typing import Any, Dict, List, Optional
+from urllib.parse import urljoin
+
+import requests
from requests.exceptions import RequestException
+from . import __version__
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.2.0"
-DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Added: Pause for unspecific 429s
+USER_AGENT = f"nextdnsctl/{__version__}"
+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 +78,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 +110,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,42 +124,76 @@ 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"]
+ 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
+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."""
+ 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(
+ 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)
+ return f"Added {domain} as {'active' if active else 'inactive'}"
-def get_denylist(profile_id, **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)
+ return f"Removed {domain}"
+
+
+# Convenience wrappers for backwards compatibility
+def get_denylist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]:
"""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):
+def add_to_denylist(
+ profile_id: str, domain: str, active: bool = True, **kwargs: Any
+) -> str:
"""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):
+def remove_from_denylist(profile_id: str, domain: str, **kwargs: Any) -> str:
"""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):
+def get_allowlist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]:
"""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):
+def add_to_allowlist(
+ profile_id: str, domain: str, active: bool = True, **kwargs: Any
+) -> str:
"""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):
+def remove_from_allowlist(profile_id: str, domain: str, **kwargs: Any) -> str:
"""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/config.py b/nextdnsctl/config.py
index fc787a9..d7b72a4 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")
+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):
- """Save the NextDNS API key to a local config file."""
- os.makedirs(CONFIG_DIR, exist_ok=True)
+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:
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."""
+def load_api_key() -> str:
+ """
+ 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 e483d68..95453a7 100644
--- a/nextdnsctl/nextdnsctl.py
+++ b/nextdnsctl/nextdnsctl.py
@@ -1,35 +1,130 @@
+import threading
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple # noqa: F401
+
import click
import requests
+from . import __version__
from .config import save_api_key, load_api_key
from .api import (
get_profiles,
- add_to_denylist,
- remove_from_denylist,
- add_to_allowlist,
- remove_from_allowlist,
+ get_domain_list,
+ add_to_domain_list,
+ remove_from_domain_list,
DEFAULT_RETRIES,
DEFAULT_DELAY,
DEFAULT_TIMEOUT,
RateLimitStillActiveError,
)
+DEFAULT_CONCURRENCY = 5
+
+
+def _resolve_profile_id(ctx: click.Context, profile_identifier: str) -> str:
+ """
+ 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"],
+ }
-__version__ = "0.2.0"
+ # 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,
- domains_to_process,
- operation_callable,
- item_name_singular="domain",
- action_verb="process",
-):
+ ctx: click.Context,
+ domains_to_process: Sequence[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.
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(
+ 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_dry_run(
+ domains_to_process: Sequence[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):"
+ )
+ 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: click.Context,
+ domains_to_process: Sequence[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
for item_value in domains_to_process:
@@ -61,6 +156,78 @@ def _perform_domain_operations(
return all_successful
+def _perform_domain_operations_parallel(
+ ctx: click.Context,
+ domains_to_process: Sequence[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}
+ 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)
+
+ progress_bar: Any = click.progressbar(
+ length=submitted_count,
+ label=f"Processing {item_name_singular}s",
+ show_pos=True,
+ )
+ with progress_bar 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 +251,27 @@ 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.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):
+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,
}
@@ -129,25 +310,99 @@ def profile_list(ctx):
raise click.Abort()
-@cli.group("denylist")
-def denylist():
- """Manage the NextDNS denylist."""
+def read_domains_from_source(source: str) -> Iterator[str]:
+ """
+ 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, stream=True, timeout=DEFAULT_TIMEOUT)
+ response.raise_for_status()
+ 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:
+ for line in f:
+ domain = _parse_domain_line(line)
+ if domain:
+ yield domain
+
+
+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()
+ return line if line else None
+
+
+# Shared command handlers for denylist/allowlist
+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)
+ api_params = {
+ "retries": ctx.obj["retry_attempts"],
+ "delay": ctx.obj["retry_delay"],
+ "timeout": ctx.obj["timeout"],
+ }
+ entries = get_domain_list(profile_id, list_type, **api_params)
+ if not entries:
+ click.echo(f"{list_type.capitalize()} is empty.")
+ return
-@denylist.command("add")
-@click.argument("profile_id")
-@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):
- """Add domains to the NextDNS denylist."""
+ 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 {list_type}: {e}", err=True)
+ raise click.Abort()
+
+
+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)
raise click.Abort()
+ 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"],
@@ -162,19 +417,23 @@ def operation(domain_name):
ctx.exit(1)
-@denylist.command("remove")
-@click.argument("profile_id")
-@click.argument("domains", nargs=-1)
-@click.pass_context
-def denylist_remove(ctx, profile_id, domains):
- """Remove domains from the NextDNS denylist."""
+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)
raise click.Abort()
+ 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"],
@@ -188,31 +447,32 @@ def operation(domain_name):
ctx.exit(1)
-@denylist.command("import")
-@click.argument("profile_id")
-@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):
- """Import domains from a file or URL to the NextDNS denylist."""
+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)
+
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
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"],
@@ -231,17 +491,168 @@ def operation(domain_name):
ctx.exit(1)
-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()
+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)
+ api_params = {
+ "retries": ctx.obj["retry_attempts"],
+ "delay": ctx.obj["retry_delay"],
+ "timeout": ctx.obj["timeout"],
+ }
+ entries = get_domain_list(profile_id, list_type, **api_params)
+ if not entries:
+ click.echo(
+ f"{list_type.capitalize()} is empty, nothing to export.", err=True
+ )
+ return
+
+ 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 {list_type}: {e}", err=True)
+ raise click.Abort()
+
+
+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)
+ api_params = {
+ "retries": ctx.obj["retry_attempts"],
+ "delay": ctx.obj["retry_delay"],
+ "timeout": ctx.obj["timeout"],
+ }
+ entries = get_domain_list(profile_id, list_type, **api_params)
+ if not entries:
+ click.echo(f"{list_type.capitalize()} is already empty.")
+ return
+
+ 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
+
+ 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 {list_type}. "
+ "Continue?",
+ abort=True,
+ )
+
+ def operation(domain_name):
+ return remove_from_domain_list(
+ profile_id,
+ list_type,
+ 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 {list_type}: {e}", err=True)
+ raise click.Abort()
+
+
+@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")
@@ -249,101 +660,65 @@ def allowlist():
"""Manage the NextDNS allowlist."""
+@allowlist.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 allowlist_list(ctx, profile, active_only, inactive_only):
+ """List all domains in the NextDNS allowlist."""
+ _handle_list_command(ctx, profile, "allowlist", 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()
-
- 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")
-@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()
-
- 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")
-@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."""
- try:
- content = read_source(source)
- except Exception as e:
- click.echo(f"Error reading source: {e}", err=True)
- raise click.Abort()
+ _handle_import_command(ctx, profile, "allowlist", source, inactive)
- 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",
+@allowlist.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 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
)
- if not success:
- ctx.exit(1)
+
+
+@allowlist.command("clear")
+@click.argument("profile")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
+@click.pass_context
+def allowlist_clear(ctx, profile, yes):
+ """Remove all domains from the allowlist."""
+ _handle_clear_command(ctx, profile, "allowlist", yes)
if __name__ == "__main__":
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..5f8ffcb
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,14 @@
+# 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
+types-requests>=2.31
diff --git a/setup.py b/setup.py
index 4a9da46..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.2.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",
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"