├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── labels.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── codeql.yaml │ ├── labels.yaml │ ├── linting.yaml │ ├── lock.yaml │ ├── pr-labels.yaml │ ├── release-drafter.yaml │ ├── release.yaml │ ├── requirements.txt │ ├── stale.yaml │ ├── tests.yaml │ └── typing.yaml ├── .gitignore ├── .nvmrc ├── .pre-commit-config.yaml ├── .prettierignore ├── .yamllint ├── LICENSE.md ├── README.md ├── examples ├── control.py ├── ruff.toml ├── stats.py └── status.py ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── sonar-project.properties ├── src └── adguardhome │ ├── __init__.py │ ├── adguardhome.py │ ├── exceptions.py │ ├── filtering.py │ ├── parental.py │ ├── py.typed │ ├── querylog.py │ ├── safebrowsing.py │ ├── safesearch.py │ └── stats.py └── tests ├── __init__.py ├── ruff.toml ├── test_adguardhome.py ├── test_filtering.py ├── test_parental.py ├── test_querylog.py ├── test_safebrowsing.py ├── test_safesearch.py └── test_stats.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerEnv": { 3 | "POETRY_VIRTUALENVS_IN_PROJECT": "true" 4 | }, 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": ["README.md", "src/adguardhome/adguardhome.py"] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python", 12 | "redhat.vscode-yaml", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-pull-request-github", 15 | "charliermarsh.ruff", 16 | "GitHub.vscode-github-actions", 17 | "ryanluker.vscode-coverage-gutters" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll": true, 23 | "source.organizeImports": true 24 | } 25 | }, 26 | "coverage-gutters.customizable.context-menu": true, 27 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 28 | "coverage-gutters.showGutterCoverage": false, 29 | "coverage-gutters.showLineCoverage": true, 30 | "coverage-gutters.xmlname": "coverage.xml", 31 | "python.analysis.extraPaths": ["${workspaceFolder}/src"], 32 | "python.defaultInterpreterPath": ".venv/bin/python", 33 | "python.formatting.provider": "ruff", 34 | "python.linting.enabled": true, 35 | "python.linting.mypyEnabled": true, 36 | "python.linting.pylintEnabled": true, 37 | "python.testing.cwd": "${workspaceFolder}", 38 | "python.testing.pytestArgs": ["--cov-report=xml"], 39 | "python.testing.pytestEnabled": true, 40 | "ruff.importStrategy": "fromEnvironment", 41 | "ruff.interpreter": [".venv/bin/python"], 42 | "terminal.integrated.defaultProfile.linux": "zsh" 43 | } 44 | } 45 | }, 46 | "features": { 47 | "ghcr.io/devcontainers-contrib/features/poetry:2": {}, 48 | "ghcr.io/devcontainers/features/github-cli:1": {}, 49 | "ghcr.io/devcontainers/features/node:1": {}, 50 | "ghcr.io/devcontainers/features/python:1": { 51 | "installTools": false 52 | } 53 | }, 54 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 55 | "name": "Asynchronous Python client for the AdGuard Home API", 56 | "updateContentCommand": ". ${NVM_DIR}/nvm.sh && nvm install && nvm use && npm install && poetry install && poetry run pre-commit install" 57 | } 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | ident_size = 4 10 | 11 | [*.md] 12 | ident_size = 2 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | ident_size = 2 17 | 18 | [{.gitignore,.gitkeep,.editorconfig}] 19 | ident_size = 2 20 | 21 | [Makefile] 22 | ident_style = tab 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.py whitespace=error 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | .github/* @frenck 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socioeconomic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | frenck@frenck.dev. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [mozilla coc]: https://github.com/mozilla/diversity 132 | [faq]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make via issue, email, or any other method with the owners of this repository 5 | before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions 8 | with the project. 9 | 10 | ## Issues and feature requests 11 | 12 | You've found a bug in the source code, a mistake in the documentation or maybe 13 | you'd like a new feature? You can help us by submitting an issue to our 14 | [GitHub Repository][github]. Before you create an issue, make sure you search 15 | the archive, maybe your question was already answered. 16 | 17 | Even better: You could submit a pull request with a fix / new feature! 18 | 19 | ## Pull request process 20 | 21 | 1. Search our repository for open or closed [pull requests][prs] that relates 22 | to your submission. You don't want to duplicate effort. 23 | 24 | 1. You may merge the pull request in once you have the sign-off of two other 25 | developers, or if you do not have permission to do that, you may request 26 | the second reviewer to merge it for you. 27 | 28 | [github]: https://github.com/frenck/python-adguardhome/issues 29 | [prs]: https://github.com/frenck/python-adguardhome/pulls 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: frenck 3 | patreon: frenck 4 | custom: https://frenck.dev/donate/ 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Problem/Motivation 2 | 3 | > (Why the issue was filed) 4 | 5 | ## Expected behavior 6 | 7 | > (What you expected to happen) 8 | 9 | ## Actual behavior 10 | 11 | > (What actually happened) 12 | 13 | ## Steps to reproduce 14 | 15 | > (How can someone else make/see it happen) 16 | 17 | ## Proposed changes 18 | 19 | > (If you have a proposed change, workaround or fix, 20 | > describe the rationale behind it) 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Proposed Changes 2 | 3 | > (Describe the changes and rationale behind them) 4 | 5 | ## Related Issues 6 | 7 | > ([Github link][autolink-references] to related issues or pull requests) 8 | 9 | [autolink-references]: https://help.github.com/articles/autolinked-references-and-urls/ 10 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementers." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "hacktoberfest" 61 | description: "Issues/PRs are participating in the Hacktoberfest." 62 | color: fbca04 63 | - name: "hacktoberfest-accepted" 64 | description: "Issues/PRs are participating in the Hacktoberfest." 65 | color: fbca04 66 | 67 | - name: "priority-critical" 68 | color: ee0701 69 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 70 | - name: "priority-high" 71 | color: b60205 72 | description: "After critical issues are fixed, these should be dealt with before any further issues." 73 | - name: "priority-medium" 74 | color: 0e8a16 75 | description: "This issue may be useful, and needs some attention." 76 | - name: "priority-low" 77 | color: e4ea8a 78 | description: "Nice addition, maybe... someday..." 79 | 80 | - name: "major" 81 | color: b60205 82 | description: "This PR causes a major version bump in the version number." 83 | - name: "minor" 84 | color: 0e8a16 85 | description: "This PR causes a minor version bump in the version number." 86 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | sort-direction: ascending 6 | 7 | categories: 8 | - title: "🚨 Breaking changes" 9 | labels: 10 | - "breaking-change" 11 | - title: "✨ New features" 12 | labels: 13 | - "new-feature" 14 | - title: "🐛 Bug fixes" 15 | labels: 16 | - "bugfix" 17 | - title: "🚀 Enhancements" 18 | labels: 19 | - "enhancement" 20 | - "refactor" 21 | - "performance" 22 | - title: "🧰 Maintenance" 23 | labels: 24 | - "maintenance" 25 | - "ci" 26 | - title: "📚 Documentation" 27 | labels: 28 | - "documentation" 29 | - title: "⬆️ Dependency updates" 30 | labels: 31 | - "dependencies" 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "major" 37 | - "breaking-change" 38 | minor: 39 | labels: 40 | - "minor" 41 | - "new-feature" 42 | patch: 43 | labels: 44 | - "bugfix" 45 | - "chore" 46 | - "ci" 47 | - "dependencies" 48 | - "documentation" 49 | - "enhancement" 50 | - "performance" 51 | - "refactor" 52 | default: patch 53 | 54 | template: | 55 | ## What’s changed 56 | 57 | $CHANGES 58 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "rebaseWhen": "behind-base-branch", 4 | "dependencyDashboard": true, 5 | "labels": ["dependencies", "no-stale"], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true 9 | }, 10 | "commitMessagePrefix": "⬆️", 11 | "packageRules": [ 12 | { 13 | "matchManagers": ["poetry"], 14 | "addLabels": ["python"] 15 | }, 16 | { 17 | "matchManagers": ["poetry"], 18 | "matchDepTypes": ["dev"], 19 | "rangeStrategy": "pin" 20 | }, 21 | { 22 | "matchManagers": ["poetry"], 23 | "matchUpdateTypes": ["minor", "patch"], 24 | "automerge": true 25 | }, 26 | { 27 | "matchManagers": ["npm", "nvm"], 28 | "addLabels": ["javascript"], 29 | "rangeStrategy": "pin" 30 | }, 31 | { 32 | "matchManagers": ["npm", "nvm"], 33 | "matchUpdateTypes": ["minor", "patch"], 34 | "automerge": true 35 | }, 36 | { 37 | "matchManagers": ["github-actions"], 38 | "addLabels": ["github_actions"], 39 | "rangeStrategy": "pin" 40 | }, 41 | { 42 | "matchManagers": ["github-actions"], 43 | "matchUpdateTypes": ["minor", "patch"], 44 | "automerge": true 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CodeQL" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | workflow_dispatch: 11 | schedule: 12 | - cron: "30 1 * * 0" 13 | 14 | jobs: 15 | codeql: 16 | name: Scanning 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⤵️ Check out code from GitHub 20 | uses: actions/checkout@v4.2.2 21 | - name: 🏗 Initialize CodeQL 22 | uses: github/codeql-action/init@v3.28.19 23 | - name: 🚀 Perform CodeQL Analysis 24 | uses: github/codeql-action/analyze@v3.28.19 25 | -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🚀 Run Label Syncer 21 | uses: micnncim/action-label-syncer@v1.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | codespell: 15 | name: codespell 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install Python dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Check code for common misspellings 35 | run: poetry run pre-commit run codespell --all-files 36 | 37 | ruff: 38 | name: Ruff 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: ⤵️ Check out code from GitHub 42 | uses: actions/checkout@v4.2.2 43 | - name: 🏗 Set up Poetry 44 | run: pipx install poetry 45 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 46 | id: python 47 | uses: actions/setup-python@v5.6.0 48 | with: 49 | python-version: ${{ env.DEFAULT_PYTHON }} 50 | cache: "poetry" 51 | - name: 🏗 Install workflow dependencies 52 | run: | 53 | poetry config virtualenvs.create true 54 | poetry config virtualenvs.in-project true 55 | - name: 🏗 Install Python dependencies 56 | run: poetry install --no-interaction 57 | - name: 🚀 Run ruff linter 58 | run: poetry run ruff check --output-format=github . 59 | - name: 🚀 Run ruff formatter 60 | run: poetry run ruff format --check . 61 | 62 | pre-commit-hooks: 63 | name: pre-commit-hooks 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: ⤵️ Check out code from GitHub 67 | uses: actions/checkout@v4.2.2 68 | - name: 🏗 Set up Poetry 69 | run: pipx install poetry 70 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 71 | id: python 72 | uses: actions/setup-python@v5.6.0 73 | with: 74 | python-version: ${{ env.DEFAULT_PYTHON }} 75 | cache: "poetry" 76 | - name: 🏗 Install workflow dependencies 77 | run: | 78 | poetry config virtualenvs.create true 79 | poetry config virtualenvs.in-project true 80 | - name: 🏗 Install Python dependencies 81 | run: poetry install --no-interaction 82 | - name: 🚀 Check Python AST 83 | run: poetry run pre-commit run check-ast --all-files 84 | - name: 🚀 Check for case conflicts 85 | run: poetry run pre-commit run check-case-conflict --all-files 86 | - name: 🚀 Check docstring is first 87 | run: poetry run pre-commit run check-docstring-first --all-files 88 | - name: 🚀 Check that executables have shebangs 89 | run: poetry run pre-commit run check-executables-have-shebangs --all-files 90 | - name: 🚀 Check JSON files 91 | run: poetry run pre-commit run check-json --all-files 92 | - name: 🚀 Check for merge conflicts 93 | run: poetry run pre-commit run check-merge-conflict --all-files 94 | - name: 🚀 Check for broken symlinks 95 | run: poetry run pre-commit run check-symlinks --all-files 96 | - name: 🚀 Check TOML files 97 | run: poetry run pre-commit run check-toml --all-files 98 | - name: 🚀 Check YAML files 99 | run: poetry run pre-commit run check-yaml --all-files 100 | - name: 🚀 Detect Private Keys 101 | run: poetry run pre-commit run detect-private-key --all-files 102 | - name: 🚀 Check End of Files 103 | run: poetry run pre-commit run end-of-file-fixer --all-files 104 | - name: 🚀 Trim Trailing Whitespace 105 | run: poetry run pre-commit run trailing-whitespace --all-files 106 | 107 | pylint: 108 | name: pylint 109 | runs-on: ubuntu-latest 110 | steps: 111 | - name: ⤵️ Check out code from GitHub 112 | uses: actions/checkout@v4.2.2 113 | - name: 🏗 Set up Poetry 114 | run: pipx install poetry 115 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 116 | id: python 117 | uses: actions/setup-python@v5.6.0 118 | with: 119 | python-version: ${{ env.DEFAULT_PYTHON }} 120 | cache: "poetry" 121 | - name: 🏗 Install workflow dependencies 122 | run: | 123 | poetry config virtualenvs.create true 124 | poetry config virtualenvs.in-project true 125 | - name: 🏗 Install Python dependencies 126 | run: poetry install --no-interaction 127 | - name: 🚀 Run pylint 128 | run: poetry run pre-commit run pylint --all-files 129 | 130 | yamllint: 131 | name: yamllint 132 | runs-on: ubuntu-latest 133 | steps: 134 | - name: ⤵️ Check out code from GitHub 135 | uses: actions/checkout@v4.2.2 136 | - name: 🏗 Set up Poetry 137 | run: pipx install poetry 138 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 139 | id: python 140 | uses: actions/setup-python@v5.6.0 141 | with: 142 | python-version: ${{ env.DEFAULT_PYTHON }} 143 | cache: "poetry" 144 | - name: 🏗 Install workflow dependencies 145 | run: | 146 | poetry config virtualenvs.create true 147 | poetry config virtualenvs.in-project true 148 | - name: 🏗 Install Python dependencies 149 | run: poetry install --no-interaction 150 | - name: 🚀 Run yamllint 151 | run: poetry run yamllint . 152 | 153 | prettier: 154 | name: Prettier 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: ⤵️ Check out code from GitHub 158 | uses: actions/checkout@v4.2.2 159 | - name: 🏗 Set up Poetry 160 | run: pipx install poetry 161 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 162 | id: python 163 | uses: actions/setup-python@v5.6.0 164 | with: 165 | python-version: ${{ env.DEFAULT_PYTHON }} 166 | cache: "poetry" 167 | - name: 🏗 Install workflow dependencies 168 | run: | 169 | poetry config virtualenvs.create true 170 | poetry config virtualenvs.in-project true 171 | - name: 🏗 Install Python dependencies 172 | run: poetry install --no-interaction 173 | - name: 🏗 Set up Node.js 174 | uses: actions/setup-node@v4.4.0 175 | with: 176 | node-version-file: ".nvmrc" 177 | cache: "npm" 178 | - name: 🏗 Install NPM dependencies 179 | run: npm install 180 | - name: 🚀 Run prettier 181 | run: poetry run pre-commit run prettier --all-files 182 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 9 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock closed issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - labeled 10 | - unlabeled 11 | - synchronize 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, documentation, enhancement, 26 | refactor, performance, new-feature, maintenance, ci, dependencies 27 | disable-reviews: true 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: ✏️ Draft release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | release: 15 | name: Releasing to PyPi 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: release 19 | url: https://pypi.org/p/adguardhome 20 | permissions: 21 | contents: write 22 | id-token: write 23 | steps: 24 | - name: ⤵️ Check out code from GitHub 25 | uses: actions/checkout@v4.2.2 26 | - name: 🏗 Set up Poetry 27 | run: pipx install poetry 28 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 29 | id: python 30 | uses: actions/setup-python@v5.6.0 31 | with: 32 | python-version: ${{ env.DEFAULT_PYTHON }} 33 | cache: "poetry" 34 | - name: 🏗 Install workflow dependencies 35 | run: | 36 | poetry config virtualenvs.create true 37 | poetry config virtualenvs.in-project true 38 | - name: 🏗 Install dependencies 39 | run: poetry install --no-interaction 40 | - name: 🏗 Set package version 41 | run: | 42 | version="${{ github.event.release.tag_name }}" 43 | version="${version,,}" 44 | version="${version#v}" 45 | poetry version --no-interaction "${version}" 46 | - name: 🏗 Build package 47 | run: poetry build --no-interaction 48 | - name: 🚀 Publish to PyPi 49 | uses: pypa/gh-action-pypi-publish@v1.12.4 50 | with: 51 | verbose: true 52 | print-hash: true 53 | - name: ✍️ Sign published artifacts 54 | uses: sigstore/gh-action-sigstore-python@v3.0.0 55 | with: 56 | inputs: ./dist/*.tar.gz ./dist/*.whl 57 | release-signing-artifacts: true 58 | -------------------------------------------------------------------------------- /.github/workflows/requirements.txt: -------------------------------------------------------------------------------- 1 | pip==25.1.1 2 | poetry==2.1.3 3 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9.1.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so we 26 | clean up some of the older and inactive issues. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment 👍 31 | 32 | This issue has now been marked as stale and will be closed if no 33 | further activity occurs. Thanks! 34 | stale-pr-label: "stale" 35 | exempt-pr-labels: "no-stale" 36 | stale-pr-message: > 37 | There hasn't been any activity on this pull request recently. This 38 | pull request has been automatically marked as stale because of that 39 | and will be closed if no further activity occurs within 7 days. 40 | Thank you for your contributions. 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | pytest: 15 | name: Python ${{ matrix.python }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python: ["3.11", "3.12"] 20 | steps: 21 | - name: ⤵️ Check out code from GitHub 22 | uses: actions/checkout@v4.2.2 23 | - name: 🏗 Set up Poetry 24 | run: pipx install poetry 25 | - name: 🏗 Set up Python ${{ matrix.python }} 26 | id: python 27 | uses: actions/setup-python@v5.6.0 28 | with: 29 | python-version: ${{ matrix.python }} 30 | cache: "poetry" 31 | - name: 🏗 Install workflow dependencies 32 | run: | 33 | poetry config virtualenvs.create true 34 | poetry config virtualenvs.in-project true 35 | - name: 🏗 Install dependencies 36 | run: poetry install --no-interaction 37 | - name: 🚀 Run pytest 38 | run: poetry run pytest --cov adguardhome tests 39 | - name: ⬆️ Upload coverage artifact 40 | uses: actions/upload-artifact@v4.6.2 41 | with: 42 | name: coverage-${{ matrix.python }} 43 | include-hidden-files: true 44 | path: .coverage 45 | 46 | coverage: 47 | runs-on: ubuntu-latest 48 | needs: pytest 49 | steps: 50 | - name: ⤵️ Check out code from GitHub 51 | uses: actions/checkout@v4.2.2 52 | with: 53 | fetch-depth: 0 54 | - name: ⬇️ Download coverage data 55 | uses: actions/download-artifact@v4.3.0 56 | - name: 🏗 Set up Poetry 57 | run: pipx install poetry 58 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 59 | id: python 60 | uses: actions/setup-python@v5.6.0 61 | with: 62 | python-version: ${{ env.DEFAULT_PYTHON }} 63 | cache: "poetry" 64 | - name: 🏗 Install workflow dependencies 65 | run: | 66 | poetry config virtualenvs.create true 67 | poetry config virtualenvs.in-project true 68 | - name: 🏗 Install dependencies 69 | run: poetry install --no-interaction 70 | - name: 🚀 Process coverage results 71 | run: | 72 | poetry run coverage combine coverage*/.coverage* 73 | poetry run coverage xml -i 74 | - name: 🚀 Upload coverage report 75 | uses: codecov/codecov-action@v5.4.3 76 | - name: SonarCloud Scan 77 | if: github.event.pull_request.head.repo.fork == false 78 | uses: SonarSource/sonarcloud-github-action@v5.0.0 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 82 | -------------------------------------------------------------------------------- /.github/workflows/typing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.11" 12 | 13 | jobs: 14 | mypy: 15 | name: mypy 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🏗 Set up Poetry 21 | run: pipx install poetry 22 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 23 | id: python 24 | uses: actions/setup-python@v5.6.0 25 | with: 26 | python-version: ${{ env.DEFAULT_PYTHON }} 27 | cache: "poetry" 28 | - name: 🏗 Install workflow dependencies 29 | run: | 30 | poetry config virtualenvs.create true 31 | poetry config virtualenvs.in-project true 32 | - name: 🏗 Install dependencies 33 | run: poetry install --no-interaction 34 | - name: 🚀 Run mypy 35 | run: poetry run mypy examples src tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # Visual Studio Code 95 | .vscode 96 | 97 | # IntelliJ Idea family of suites 98 | .idea 99 | *.iml 100 | 101 | ## File-based project format: 102 | *.ipr 103 | *.iws 104 | 105 | ## mpeltonen/sbt-idea plugin 106 | .idea_modules/ 107 | 108 | # PyBuilder 109 | target/ 110 | 111 | # Cookiecutter 112 | output/ 113 | python_boilerplate/ 114 | 115 | # Node 116 | node_modules/ 117 | 118 | # Deepcode AI 119 | .dccache 120 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: ruff-check 6 | name: 🐶 Ruff Linter 7 | language: system 8 | types: [python] 9 | entry: poetry run ruff check --fix 10 | require_serial: true 11 | stages: [commit, push, manual] 12 | - id: ruff-format 13 | name: 🐶 Ruff Formatter 14 | language: system 15 | types: [python] 16 | entry: poetry run ruff format 17 | require_serial: true 18 | stages: [commit, push, manual] 19 | - id: check-ast 20 | name: 🐍 Check Python AST 21 | language: system 22 | types: [python] 23 | entry: poetry run check-ast 24 | - id: check-case-conflict 25 | name: 🔠 Check for case conflicts 26 | language: system 27 | entry: poetry run check-case-conflict 28 | - id: check-docstring-first 29 | name: ℹ️ Check docstring is first 30 | language: system 31 | types: [python] 32 | entry: poetry run check-docstring-first 33 | - id: check-executables-have-shebangs 34 | name: 🧐 Check that executables have shebangs 35 | language: system 36 | types: [text, executable] 37 | entry: poetry run check-executables-have-shebangs 38 | stages: [commit, push, manual] 39 | - id: check-json 40 | name: { Check JSON files 41 | language: system 42 | types: [json] 43 | entry: poetry run check-json 44 | - id: check-merge-conflict 45 | name: 💥 Check for merge conflicts 46 | language: system 47 | types: [text] 48 | entry: poetry run check-merge-conflict 49 | - id: check-symlinks 50 | name: 🔗 Check for broken symlinks 51 | language: system 52 | types: [symlink] 53 | entry: poetry run check-symlinks 54 | - id: check-toml 55 | name: ✅ Check TOML files 56 | language: system 57 | types: [toml] 58 | entry: poetry run check-toml 59 | - id: check-xml 60 | name: ✅ Check XML files 61 | entry: check-xml 62 | language: system 63 | types: [xml] 64 | - id: check-yaml 65 | name: ✅ Check YAML files 66 | language: system 67 | types: [yaml] 68 | entry: poetry run check-yaml 69 | - id: codespell 70 | name: ✅ Check code for common misspellings 71 | language: system 72 | types: [text] 73 | exclude: ^poetry\.lock$ 74 | entry: poetry run codespell 75 | - id: detect-private-key 76 | name: 🕵️ Detect Private Keys 77 | language: system 78 | types: [text] 79 | entry: poetry run detect-private-key 80 | - id: end-of-file-fixer 81 | name: ⮐ Fix End of Files 82 | language: system 83 | types: [text] 84 | entry: poetry run end-of-file-fixer 85 | stages: [commit, push, manual] 86 | - id: mypy 87 | name: 🆎 Static type checking using mypy 88 | language: system 89 | types: [python] 90 | entry: poetry run mypy 91 | require_serial: true 92 | - id: no-commit-to-branch 93 | name: 🛑 Don't commit to main branch 94 | language: system 95 | entry: poetry run no-commit-to-branch 96 | pass_filenames: false 97 | always_run: true 98 | args: 99 | - --branch=main 100 | - id: poetry 101 | name: 📜 Check pyproject with Poetry 102 | language: system 103 | entry: poetry check 104 | pass_filenames: false 105 | always_run: true 106 | - id: prettier 107 | name: 💄 Ensuring files are prettier 108 | language: system 109 | types: [yaml, json, markdown] 110 | entry: npm run prettier 111 | pass_filenames: false 112 | - id: pylint 113 | name: 🌟 Starring code with pylint 114 | language: system 115 | types: [python] 116 | entry: poetry run pylint 117 | - id: pytest 118 | name: 🧪 Running tests and test coverage with pytest 119 | language: system 120 | types: [python] 121 | entry: poetry run pytest 122 | pass_filenames: false 123 | - id: trailing-whitespace 124 | name: ✄ Trim Trailing Whitespace 125 | language: system 126 | types: [text] 127 | entry: poetry run trailing-whitespace-fixer 128 | stages: [commit, push, manual] 129 | - id: yamllint 130 | name: 🎗 Check YAML files with yamllint 131 | language: system 132 | types: [yaml] 133 | entry: poetry run yamllint 134 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | braces: 4 | level: error 5 | min-spaces-inside: 0 6 | max-spaces-inside: 1 7 | min-spaces-inside-empty: -1 8 | max-spaces-inside-empty: -1 9 | brackets: 10 | level: error 11 | min-spaces-inside: 0 12 | max-spaces-inside: 0 13 | min-spaces-inside-empty: -1 14 | max-spaces-inside-empty: -1 15 | colons: 16 | level: error 17 | max-spaces-before: 0 18 | max-spaces-after: 1 19 | commas: 20 | level: error 21 | max-spaces-before: 0 22 | min-spaces-after: 1 23 | max-spaces-after: 1 24 | comments: 25 | level: error 26 | require-starting-space: true 27 | min-spaces-from-content: 2 28 | comments-indentation: 29 | level: error 30 | document-end: 31 | level: error 32 | present: false 33 | document-start: 34 | level: error 35 | present: true 36 | empty-lines: 37 | level: error 38 | max: 1 39 | max-start: 0 40 | max-end: 1 41 | hyphens: 42 | level: error 43 | max-spaces-after: 1 44 | indentation: 45 | level: error 46 | spaces: 2 47 | indent-sequences: true 48 | check-multi-line-strings: false 49 | key-duplicates: 50 | level: error 51 | line-length: 52 | level: warning 53 | max: 120 54 | allow-non-breakable-words: true 55 | allow-non-breakable-inline-mappings: true 56 | new-line-at-end-of-file: 57 | level: error 58 | new-lines: 59 | level: error 60 | type: unix 61 | trailing-spaces: 62 | level: error 63 | truthy: 64 | level: error 65 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019-2024 Franck Nijhof 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: AdGuard Home API Client 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![Python Versions][python-versions-shield]][pypi] 5 | ![Project Stage][project-stage-shield] 6 | ![Project Maintenance][maintenance-shield] 7 | [![License][license-shield]](LICENSE.md) 8 | 9 | [![Build Status][build-shield]][build] 10 | [![Code Coverage][codecov-shield]][codecov] 11 | [![Quality Gate Status][sonarcloud-shield]][sonarcloud] 12 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 13 | 14 | [![Sponsor Frenck via GitHub Sponsors][github-sponsors-shield]][github-sponsors] 15 | 16 | [![Support Frenck on Patreon][patreon-shield]][patreon] 17 | 18 | Asynchronous Python client for the AdGuard Home API. 19 | 20 | ## About 21 | 22 | This package allows you to control and monitor an AdGuard Home instance 23 | programmatically. It is mainly created to allow third-party programs to automate 24 | the behavior of AdGuard. 25 | 26 | An excellent example of this might be Home Assistant, which allows you to write 27 | automations, to turn on parental controls when the kids get home. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | pip install adguardhome 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```python 38 | from adguardhome import AdGuardHome 39 | 40 | import asyncio 41 | 42 | 43 | async def main(): 44 | """Show example how to get status of your AdGuard Home instance.""" 45 | async with AdGuardHome("192.168.1.2") as adguard: 46 | version = await adguard.version() 47 | print("AdGuard version:", version) 48 | 49 | active = await adguard.protection_enabled() 50 | active = "Yes" if active else "No" 51 | print("Protection enabled?", active) 52 | 53 | if not active: 54 | print("AdGuard Home protection disabled. Enabling...") 55 | await adguard.enable_protection() 56 | 57 | 58 | if __name__ == "__main__": 59 | asyncio.run(main()) 60 | ``` 61 | 62 | ## Changelog & Releases 63 | 64 | This repository keeps a change log using [GitHub's releases][releases] 65 | functionality. The format of the log is based on 66 | [Keep a Changelog][keepchangelog]. 67 | 68 | Releases are based on [Semantic Versioning][semver], and use the format 69 | of `MAJOR.MINOR.PATCH`. In a nutshell, the version will be incremented 70 | based on the following: 71 | 72 | - `MAJOR`: Incompatible or major changes. 73 | - `MINOR`: Backwards-compatible new features and enhancements. 74 | - `PATCH`: Backwards-compatible bugfixes and package updates. 75 | 76 | ## Contributing 77 | 78 | This is an active open-source project. We are always open to people who want to 79 | use the code or contribute to it. 80 | 81 | We've set up a separate document for our 82 | [contribution guidelines](CONTRIBUTING.md). 83 | 84 | Thank you for being involved! :heart_eyes: 85 | 86 | ## Setting up development environment 87 | 88 | This Python project is fully managed using the [Poetry][poetry] dependency 89 | manager. But also relies on the use of NodeJS for certain checks during 90 | development. 91 | 92 | You need at least: 93 | 94 | - Python 3.11+ 95 | - [Poetry][poetry-install] 96 | - NodeJS 20+ (including NPM) 97 | 98 | To install all packages, including all development requirements: 99 | 100 | ```bash 101 | npm install 102 | poetry install 103 | ``` 104 | 105 | As this repository uses the [pre-commit][pre-commit] framework, all changes 106 | are linted and tested with each commit. You can run all checks and tests 107 | manually, using the following command: 108 | 109 | ```bash 110 | poetry run pre-commit run --all-files 111 | ``` 112 | 113 | To run just the Python tests: 114 | 115 | ```bash 116 | poetry run pytest 117 | ``` 118 | 119 | ## Authors & contributors 120 | 121 | The original setup of this repository is by [Franck Nijhof][frenck]. 122 | 123 | For a full list of all authors and contributors, 124 | check [the contributor's page][contributors]. 125 | 126 | ## License 127 | 128 | MIT License 129 | 130 | Copyright (c) 2019-2024 Franck Nijhof 131 | 132 | Permission is hereby granted, free of charge, to any person obtaining a copy 133 | of this software and associated documentation files (the "Software"), to deal 134 | in the Software without restriction, including without limitation the rights 135 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 136 | copies of the Software, and to permit persons to whom the Software is 137 | furnished to do so, subject to the following conditions: 138 | 139 | The above copyright notice and this permission notice shall be included in all 140 | copies or substantial portions of the Software. 141 | 142 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 143 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 144 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 145 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 146 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 147 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 148 | SOFTWARE. 149 | 150 | [build-shield]: https://github.com/frenck/python-adguardhome/actions/workflows/tests.yaml/badge.svg 151 | [build]: https://github.com/frenck/python-adguardhome/actions/workflows/tests.yaml 152 | [codecov-shield]: https://codecov.io/gh/frenck/python-adguardhome/branch/main/graph/badge.svg 153 | [codecov]: https://codecov.io/gh/frenck/python-adguardhome 154 | [contributors]: https://github.com/frenck/python-adguardhome/graphs/contributors 155 | [devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode 156 | [devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/frenck/python-adguardhome 157 | [frenck]: https://github.com/frenck 158 | [github-sponsors-shield]: https://frenck.dev/wp-content/uploads/2019/12/github_sponsor.png 159 | [github-sponsors]: https://github.com/sponsors/frenck 160 | [keepchangelog]: http://keepachangelog.com/en/1.0.0/ 161 | [license-shield]: https://img.shields.io/github/license/frenck/python-adguardhome.svg 162 | [maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg 163 | [patreon-shield]: https://frenck.dev/wp-content/uploads/2019/12/patreon.png 164 | [patreon]: https://www.patreon.com/frenck 165 | [poetry-install]: https://python-poetry.org/docs/#installation 166 | [poetry]: https://python-poetry.org 167 | [pre-commit]: https://pre-commit.com/ 168 | [project-stage-shield]: https://img.shields.io/badge/project%20stage-production%20ready-brightgreen.svg 169 | [pypi]: https://pypi.org/project/adguardhome/ 170 | [python-versions-shield]: https://img.shields.io/pypi/pyversions/adguardhome 171 | [releases-shield]: https://img.shields.io/github/release/frenck/python-adguardhome.svg 172 | [releases]: https://github.com/frenck/python-adguardhome/releases 173 | [semver]: http://semver.org/spec/v2.0.0.html 174 | [sonarcloud-shield]: https://sonarcloud.io/api/project_badges/measure?project=frenck_python-adguardhome&metric=alert_status 175 | [sonarcloud]: https://sonarcloud.io/summary/new_code?id=frenck_python-adguardhome 176 | -------------------------------------------------------------------------------- /examples/control.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for the AdGuard Home API.""" 3 | 4 | import asyncio 5 | 6 | from adguardhome import AdGuardHome 7 | 8 | 9 | async def main() -> None: 10 | """Show example on controlling your AdGuard Home instance.""" 11 | async with AdGuardHome("192.168.1.2") as adguard: 12 | version = await adguard.version() 13 | print("AdGuard version:", version) 14 | 15 | print("Turning off protection...") 16 | await adguard.disable_protection() 17 | 18 | active = await adguard.protection_enabled() 19 | yes_no = "Yes" if active else "No" 20 | print("Protection enabled?", yes_no) 21 | 22 | print("Turning on protection") 23 | await adguard.enable_protection() 24 | 25 | active = await adguard.protection_enabled() 26 | yes_no = "Yes" if active else "No" 27 | print("Protection enabled?", yes_no) 28 | 29 | 30 | if __name__ == "__main__": 31 | asyncio.run(main()) 32 | -------------------------------------------------------------------------------- /examples/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for the examples 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-ignore = [ 5 | "T201", # Allow the use of print() in examples 6 | ] 7 | -------------------------------------------------------------------------------- /examples/stats.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for the AdGuard Home API.""" 3 | 4 | import asyncio 5 | 6 | from adguardhome import AdGuardHome 7 | 8 | 9 | async def main() -> None: 10 | """Show example on stats from your AdGuard Home instance.""" 11 | async with AdGuardHome("192.168.1.2") as adguard: 12 | version = await adguard.version() 13 | print("AdGuard version:", version) 14 | 15 | period = await adguard.stats.period() 16 | print("Stats period:", period) 17 | 18 | result = await adguard.stats.avg_processing_time() 19 | print("Average processing time per query in ms:", result) 20 | 21 | result = await adguard.stats.dns_queries() 22 | print("DNS queries:", result) 23 | 24 | result = await adguard.stats.blocked_filtering() 25 | print("Blocked DNS queries:", result) 26 | 27 | result = await adguard.stats.blocked_percentage() 28 | print("Blocked DNS queries ratio:", result) 29 | 30 | result = await adguard.stats.replaced_safebrowsing() 31 | print("Pages blocked by safe browsing:", result) 32 | 33 | result = await adguard.stats.replaced_parental() 34 | print("Pages blocked by parental control:", result) 35 | 36 | result = await adguard.stats.replaced_safesearch() 37 | print("Number of enforced safe searches:", result) 38 | 39 | result = await adguard.filtering.rules_count(allowlist=False) 40 | print("Total number of active rules:", result) 41 | 42 | 43 | if __name__ == "__main__": 44 | asyncio.run(main()) 45 | -------------------------------------------------------------------------------- /examples/status.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=W0621 2 | """Asynchronous Python client for the AdGuard Home API.""" 3 | 4 | import asyncio 5 | 6 | from adguardhome import AdGuardHome 7 | 8 | 9 | async def main() -> None: 10 | """Show example how to get status of your AdGuard Home instance.""" 11 | async with AdGuardHome(host="192.168.1.2") as adguard: 12 | version = await adguard.version() 13 | print("AdGuard version:", version) 14 | 15 | active = await adguard.protection_enabled() 16 | yes_no = "Yes" if active else "No" 17 | print("Protection enabled?", yes_no) 18 | 19 | active = await adguard.filtering.enabled() 20 | yes_no = "Yes" if active else "No" 21 | print("Filtering enabled?", yes_no) 22 | 23 | active = await adguard.parental.enabled() 24 | yes_no = "Yes" if active else "No" 25 | print("Parental control enabled?", yes_no) 26 | 27 | active = await adguard.safebrowsing.enabled() 28 | yes_no = "Yes" if active else "No" 29 | print("Safe browsing enabled?", yes_no) 30 | 31 | active = await adguard.safesearch.enabled() 32 | yes_no = "Yes" if active else "No" 33 | print("Enforce safe search enabled?", yes_no) 34 | 35 | 36 | if __name__ == "__main__": 37 | asyncio.run(main()) 38 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adguardhome", 3 | "version": "0.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "adguardhome", 9 | "version": "0.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | }, 15 | "node_modules/prettier": { 16 | "version": "3.5.3", 17 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 18 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 19 | "dev": true, 20 | "license": "MIT", 21 | "bin": { 22 | "prettier": "bin/prettier.cjs" 23 | }, 24 | "engines": { 25 | "node": ">=14" 26 | }, 27 | "funding": { 28 | "url": "https://github.com/prettier/prettier?sponsor=1" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adguardhome", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "AdGuard Home API Client", 6 | "scripts": { 7 | "prettier": "prettier --write **/*.{json,js,md,yml,yaml} *.{json,js,md,yml,yaml}" 8 | }, 9 | "author": "Franck Nijhof ", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "3.5.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "adguardhome" 3 | version = "0.0.0" 4 | description = "Asynchronous Python client for the AdGuard Home API." 5 | authors = ["Franck Nijhof "] 6 | maintainers = ["Franck Nijhof "] 7 | license = "MIT" 8 | readme = "README.md" 9 | homepage = "https://github.com/frenck/python-adguardhome" 10 | repository = "https://github.com/frenck/python-adguardhome" 11 | documentation = "https://github.com/frenck/python-adguardhome" 12 | keywords = ["adguard home", "adguard", "api", "async", "client"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Framework :: AsyncIO", 16 | "Intended Audience :: Developers", 17 | "Natural Language :: English", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | ] 23 | packages = [ 24 | { include = "adguardhome", from = "src" }, 25 | ] 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.11" 29 | aiohttp = ">=3.0.0" 30 | yarl = ">=1.6.0" 31 | 32 | [tool.poetry.urls] 33 | "Bug Tracker" = "https://github.com/frenck/python-adguardhome/issues" 34 | Changelog = "https://github.com/frenck/python-adguardhome/releases" 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | aresponses = "3.0.0" 38 | codespell = "2.4.1" 39 | covdefaults = "2.3.0" 40 | coverage = {version = "7.8.2", extras = ["toml"]} 41 | mypy = "1.16.0" 42 | pre-commit = "4.2.0" 43 | pre-commit-hooks = "5.0.0" 44 | pylint = "3.3.7" 45 | pytest = "8.4.0" 46 | pytest-asyncio = "1.0.0" 47 | pytest-cov = "6.1.1" 48 | ruff = "0.11.13" 49 | safety = "3.5.2" 50 | syrupy = "4.9.1" 51 | yamllint = "1.37.1" 52 | 53 | [tool.coverage.run] 54 | plugins = ["covdefaults"] 55 | source = ["adguardhome"] 56 | 57 | [tool.coverage.report] 58 | show_missing = true 59 | fail_under = 10 60 | 61 | [tool.mypy] 62 | # Specify the target platform details in config, so your developers are 63 | # free to run mypy on Windows, Linux, or macOS and get consistent 64 | # results. 65 | platform = "linux" 66 | python_version = "3.11" 67 | 68 | # show error messages from unrelated files 69 | follow_imports = "normal" 70 | 71 | # suppress errors about unsatisfied imports 72 | ignore_missing_imports = true 73 | 74 | # be strict 75 | check_untyped_defs = true 76 | disallow_any_generics = true 77 | disallow_incomplete_defs = true 78 | disallow_subclassing_any = true 79 | disallow_untyped_calls = true 80 | disallow_untyped_defs = true 81 | disallow_untyped_decorators = true 82 | no_implicit_optional = true 83 | no_implicit_reexport = true 84 | strict_optional = true 85 | warn_incomplete_stub = true 86 | warn_no_return = true 87 | warn_redundant_casts = true 88 | warn_return_any = false # Fix this later! 89 | warn_unused_configs = true 90 | warn_unused_ignores = true 91 | 92 | [tool.pylint.MASTER] 93 | ignore= [ 94 | "tests" 95 | ] 96 | 97 | [tool.pylint.BASIC] 98 | good-names = [ 99 | "_", 100 | "ex", 101 | "fp", 102 | "i", 103 | "id", 104 | "j", 105 | "k", 106 | "on", 107 | "Run", 108 | "T", 109 | ] 110 | 111 | [tool.pylint.DESIGN] 112 | max-attributes = 8 113 | 114 | [tool.pylint."MESSAGES CONTROL"] 115 | disable= [ 116 | "duplicate-code", 117 | "format", 118 | "unsubscriptable-object", 119 | ] 120 | 121 | [tool.pylint.SIMILARITIES] 122 | ignore-imports = true 123 | 124 | [tool.pylint.FORMAT] 125 | max-line-length=88 126 | 127 | [tool.pytest.ini_options] 128 | addopts = "--cov" 129 | asyncio_mode = "auto" 130 | 131 | [tool.ruff.lint] 132 | select = ["ALL"] 133 | ignore = [ 134 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 135 | "D203", # Conflicts with other rules 136 | "D213", # Conflicts with other rules 137 | "D417", # False positives in some occasions 138 | "PLR2004", # Just annoying, not really useful 139 | 140 | # Conflicts with the Ruff formatter 141 | "COM812", 142 | "ISC001", 143 | ] 144 | 145 | [tool.ruff.lint.flake8-pytest-style] 146 | mark-parentheses = false 147 | fixture-parentheses = false 148 | 149 | [tool.ruff.lint.isort] 150 | known-first-party = ["adguardhome"] 151 | 152 | [tool.ruff.lint.flake8-type-checking] 153 | runtime-evaluated-base-classes = [ 154 | "mashumaro.mixins.orjson.DataClassORJSONMixin", 155 | ] 156 | 157 | [tool.ruff.lint.mccabe] 158 | max-complexity = 25 159 | 160 | [build-system] 161 | requires = ["poetry-core>=1.0.0"] 162 | build-backend = "poetry.core.masonry.api" 163 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=frenck 2 | sonar.projectKey=frenck_python-adguardhome 3 | sonar.projectName=Asynchronous Python client for the AdGuard Home API 4 | sonar.projectVersion=1.0 5 | 6 | sonar.links.homepage=https://github.com/frenck/python-adguardhome 7 | sonar.links.ci=https://github.com/frenck/python-adguardhome/actions 8 | sonar.links.issue=https://github.com/frenck/python-adguardhome/issues 9 | sonar.links.scm=https://github.com/frenck/python-adguardhome/tree/main 10 | 11 | sonar.language=py 12 | sonar.sourceEncoding=UTF-8 13 | sonar.sources=src 14 | sonar.tests=tests 15 | 16 | sonar.python.version=3.11, 3.12 17 | sonar.python.coverage.reportPaths=coverage.xml 18 | -------------------------------------------------------------------------------- /src/adguardhome/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from .adguardhome import AdGuardHome 4 | from .exceptions import AdGuardHomeConnectionError, AdGuardHomeError 5 | 6 | __all__ = ["AdGuardHome", "AdGuardHomeConnectionError", "AdGuardHomeError"] 7 | -------------------------------------------------------------------------------- /src/adguardhome/adguardhome.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import json 7 | import socket 8 | from typing import TYPE_CHECKING, Any, Self 9 | 10 | import aiohttp 11 | from yarl import URL 12 | 13 | from .exceptions import AdGuardHomeConnectionError, AdGuardHomeError 14 | from .filtering import AdGuardHomeFiltering 15 | from .parental import AdGuardHomeParental 16 | from .querylog import AdGuardHomeQueryLog 17 | from .safebrowsing import AdGuardHomeSafeBrowsing 18 | from .safesearch import AdGuardHomeSafeSearch 19 | from .stats import AdGuardHomeStats 20 | 21 | if TYPE_CHECKING: 22 | from collections.abc import Mapping 23 | 24 | 25 | # pylint: disable=too-many-instance-attributes 26 | class AdGuardHome: 27 | """Main class for handling connections with AdGuard Home.""" 28 | 29 | # pylint: disable-next=too-many-arguments 30 | def __init__( # noqa: PLR0913 31 | self, 32 | host: str, 33 | *, 34 | base_path: str = "/control", 35 | password: str | None = None, 36 | port: int = 3000, 37 | request_timeout: int = 10, 38 | session: aiohttp.client.ClientSession | None = None, 39 | tls: bool = False, 40 | username: str | None = None, 41 | verify_ssl: bool = True, 42 | ) -> None: 43 | """Initialize connection with AdGuard Home. 44 | 45 | Class constructor for setting up an AdGuard Home object to 46 | communicate with an AdGuard Home instance. 47 | 48 | Args: 49 | ---- 50 | host: Hostname or IP address of the AdGuard Home instance. 51 | base_path: Base path of the API, usually `/control`, which is the default. 52 | password: Password for HTTP auth, if enabled. 53 | port: Port on which the API runs, usually 3000. 54 | request_timeout: Max timeout to wait for a response from the API. 55 | session: Optional, shared, aiohttp client session. 56 | tls: True, when TLS/SSL should be used. 57 | username: Username for HTTP auth, if enabled. 58 | verify_ssl: Can be set to false, when TLS with self-signed cert is used. 59 | 60 | """ 61 | self._session = session 62 | self._close_session = False 63 | 64 | self.base_path = base_path 65 | self.host = host 66 | self.password = password 67 | self.port = port 68 | self.request_timeout = request_timeout 69 | self.tls = tls 70 | self.username = username 71 | self.verify_ssl = verify_ssl 72 | 73 | if self.base_path[-1] != "/": 74 | self.base_path += "/" 75 | 76 | self.filtering = AdGuardHomeFiltering(self) 77 | self.parental = AdGuardHomeParental(self) 78 | self.querylog = AdGuardHomeQueryLog(self) 79 | self.safebrowsing = AdGuardHomeSafeBrowsing(self) 80 | self.safesearch = AdGuardHomeSafeSearch(self) 81 | self.stats = AdGuardHomeStats(self) 82 | 83 | # pylint: disable-next=too-many-arguments, too-many-locals, too-many-positional-arguments 84 | async def request( 85 | self, 86 | uri: str, 87 | method: str = "GET", 88 | data: Any | None = None, 89 | json_data: dict[str, Any] | None = None, 90 | params: Mapping[str, str] | None = None, 91 | ) -> dict[str, Any]: 92 | """Handle a request to the AdGuard Home instance. 93 | 94 | Make a request against the AdGuard Home API and handles the response. 95 | 96 | Args: 97 | ---- 98 | uri: The request URI on the AdGuard Home API to call. 99 | method: HTTP method to use for the request; e.g., GET, POST. 100 | data: RAW HTTP request data to send with the request. 101 | json_data: Dictionary of data to send as JSON with the request. 102 | params: Mapping of request parameters to send with the request. 103 | 104 | Returns: 105 | ------- 106 | The response from the API. In case the response is a JSON response, 107 | the method will return a decoded JSON response as a Python 108 | dictionary. In other cases, it will return the RAW text response. 109 | 110 | Raises: 111 | ------ 112 | AdGuardHomeConnectionError: An error occurred while communicating 113 | with the AdGuard Home instance (connection issues). 114 | AdGuardHomeError: An error occurred while processing the 115 | response from the AdGuard Home instance (invalid data). 116 | 117 | """ 118 | scheme = "https" if self.tls else "http" 119 | url = URL.build( 120 | scheme=scheme, host=self.host, port=self.port, path=self.base_path 121 | ).join(URL(uri)) 122 | 123 | auth = None 124 | if self.username and self.password: 125 | auth = aiohttp.BasicAuth(self.username, self.password) 126 | 127 | headers = { 128 | "Accept": "application/json, text/plain, */*", 129 | } 130 | 131 | if self._session is None: 132 | self._session = aiohttp.ClientSession() 133 | self._close_session = True 134 | 135 | skip_auto_headers = None 136 | if data is None and json_data is None: 137 | skip_auto_headers = {"Content-Type"} 138 | 139 | try: 140 | async with asyncio.timeout(self.request_timeout): 141 | response = await self._session.request( 142 | method, 143 | url, 144 | auth=auth, 145 | data=data, 146 | json=json_data, 147 | params=params, 148 | headers=headers, 149 | ssl=self.verify_ssl, 150 | skip_auto_headers=skip_auto_headers, 151 | ) 152 | except asyncio.TimeoutError as exception: 153 | msg = "Timeout occurred while connecting to AdGuard Home instance." 154 | raise AdGuardHomeConnectionError(msg) from exception 155 | except (aiohttp.ClientError, socket.gaierror) as exception: 156 | msg = "Error occurred while communicating with AdGuard Home." 157 | raise AdGuardHomeConnectionError(msg) from exception 158 | 159 | content_type = response.headers.get("Content-Type", "") 160 | if response.status // 100 in [4, 5]: 161 | contents = await response.read() 162 | response.close() 163 | 164 | if content_type == "application/json": 165 | raise AdGuardHomeError( 166 | response.status, json.loads(contents.decode("utf8")) 167 | ) 168 | raise AdGuardHomeError( 169 | response.status, {"message": contents.decode("utf8")} 170 | ) 171 | 172 | if "application/json" in content_type: 173 | return await response.json() 174 | 175 | text = await response.text() 176 | return {"message": text} 177 | 178 | async def protection_enabled(self) -> bool: 179 | """Return if AdGuard Home protection is enabled or not. 180 | 181 | Returns 182 | ------- 183 | The status of the protection of the AdGuard Home instance. 184 | 185 | """ 186 | response = await self.request("status") 187 | return response["protection_enabled"] 188 | 189 | async def enable_protection(self) -> None: 190 | """Enable AdGuard Home protection. 191 | 192 | Raises 193 | ------ 194 | AdGuardHomeError: Failed enabling AdGuard Home protection. 195 | 196 | """ 197 | try: 198 | await self.request( 199 | "dns_config", 200 | method="POST", 201 | json_data={"protection_enabled": True}, 202 | ) 203 | except AdGuardHomeError as exception: 204 | msg = "Failed enabling AdGuard Home protection" 205 | raise AdGuardHomeError(msg) from exception 206 | 207 | async def disable_protection(self) -> None: 208 | """Disable AdGuard Home protection. 209 | 210 | Raises 211 | ------ 212 | AdGuardHomeError: Failed disabling the AdGuard Home protection. 213 | 214 | """ 215 | try: 216 | await self.request( 217 | "dns_config", 218 | method="POST", 219 | json_data={"protection_enabled": False}, 220 | ) 221 | except AdGuardHomeError as exception: 222 | msg = "Failed disabling AdGuard Home protection" 223 | raise AdGuardHomeError(msg) from exception 224 | 225 | async def version(self) -> str: 226 | """Return the current version of the AdGuard Home instance. 227 | 228 | Returns 229 | ------- 230 | The version number of the connected AdGuard Home instance. 231 | 232 | """ 233 | response = await self.request("status") 234 | return response["version"] 235 | 236 | async def close(self) -> None: 237 | """Close open client session.""" 238 | if self._session and self._close_session: 239 | await self._session.close() 240 | 241 | async def __aenter__(self) -> Self: 242 | """Async enter. 243 | 244 | Returns 245 | ------- 246 | The AdGuard Home object. 247 | 248 | """ 249 | return self 250 | 251 | async def __aexit__(self, *_exc_info: object) -> None: 252 | """Async exit. 253 | 254 | Args: 255 | ---- 256 | _exc_info: Exec type. 257 | 258 | """ 259 | await self.close() 260 | -------------------------------------------------------------------------------- /src/adguardhome/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for AdGuard Home.""" 2 | 3 | 4 | class AdGuardHomeError(Exception): 5 | """Generic AdGuard Home exception.""" 6 | 7 | 8 | class AdGuardHomeConnectionError(AdGuardHomeError): 9 | """AdGuard Home connection exception.""" 10 | -------------------------------------------------------------------------------- /src/adguardhome/filtering.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeFiltering: 16 | """Controls AdGuard Home filtering. Blocks domains.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def _config( 21 | self, *, enabled: bool | None = None, interval: int | None = None 22 | ) -> None: 23 | """Configure filtering on AdGuard Home. 24 | 25 | Args: 26 | ---- 27 | enabled: Enable/Disable AdGuard Home filtering. 28 | interval: Number of days to keep data in the logs. 29 | 30 | """ 31 | if enabled is None: 32 | enabled = await self.enabled() 33 | if interval is None: 34 | interval = await self.interval() 35 | 36 | await self.adguard.request( 37 | "filtering/config", 38 | method="POST", 39 | json_data={"enabled": enabled, "interval": interval}, 40 | ) 41 | 42 | async def enabled(self) -> bool: 43 | """Return if AdGuard Home filtering is enabled or not. 44 | 45 | Returns 46 | ------- 47 | The current state of the AdGuard Home filtering. 48 | 49 | """ 50 | response = await self.adguard.request("filtering/status") 51 | return response["enabled"] 52 | 53 | async def enable(self) -> None: 54 | """Enable AdGuard Home filtering. 55 | 56 | Raises 57 | ------ 58 | AdGuardHomeError: If enabling the filtering didn't succeed. 59 | 60 | """ 61 | try: 62 | await self._config(enabled=True) 63 | except AdGuardHomeError as exception: 64 | msg = "Enabling AdGuard Home filtering failed" 65 | raise AdGuardHomeError(msg) from exception 66 | 67 | async def disable(self) -> None: 68 | """Disable AdGuard Home filtering. 69 | 70 | Raises 71 | ------ 72 | AdGuardHomeError: If disabling the filtering didn't succeed. 73 | 74 | """ 75 | try: 76 | await self._config(enabled=False) 77 | except AdGuardHomeError as exception: 78 | msg = "Disabling AdGuard Home filtering failed" 79 | raise AdGuardHomeError(msg) from exception 80 | 81 | async def interval(self, *, interval: int | None = None) -> int: 82 | """Return or set the time period to keep query log data. 83 | 84 | Args: 85 | ---- 86 | interval: Set the time period (in days) to keep query log data. 87 | 88 | Returns: 89 | ------- 90 | The current set time period to keep query log data. 91 | 92 | """ 93 | if interval: 94 | await self._config(interval=interval) 95 | return interval 96 | 97 | response = await self.adguard.request("filtering/status") 98 | return response["interval"] 99 | 100 | async def rules_count(self, *, allowlist: bool) -> int: 101 | """Return the number of rules loaded. 102 | 103 | Args: 104 | ---- 105 | allowlist: True to get the allowlists count, False for the blocklists. 106 | 107 | Returns: 108 | ------- 109 | The number of filtering rules currently loaded in the AdGuard 110 | Home instance. 111 | 112 | """ 113 | response = await self.adguard.request("filtering/status") 114 | 115 | count = "whitelist_filters" if allowlist else "filters" 116 | if not response.get(count): 117 | return 0 118 | 119 | return sum(fil["rules_count"] for fil in response[count]) 120 | 121 | async def add_url(self, *, allowlist: bool, name: str, url: str) -> None: 122 | """Add a new filter subscription to AdGuard Home. 123 | 124 | Args: 125 | ---- 126 | allowlist: True to add an allowlist, False for a blocklists. 127 | name: The name of the filter subscription. 128 | url: The URL of the filter list. 129 | 130 | Raises: 131 | ------ 132 | AdGuardHomeError: Failed adding the filter subscription. 133 | 134 | """ 135 | try: 136 | await self.adguard.request( 137 | "filtering/add_url", 138 | method="POST", 139 | json_data={"whitelist": allowlist, "name": name, "url": url}, 140 | ) 141 | except AdGuardHomeError as exception: 142 | msg = "Failed adding URL to AdGuard Home filter" 143 | raise AdGuardHomeError(msg) from exception 144 | 145 | async def remove_url(self, *, allowlist: bool, url: str) -> None: 146 | """Remove a new filter subscription from AdGuard Home. 147 | 148 | Args: 149 | ---- 150 | allowlist: True to remove an allowlist, False for a blocklists. 151 | url: Filter subscription URL to remove from AdGuard Home. 152 | 153 | Raises: 154 | ------ 155 | AdGuardHomeError: Failed removing the filter subscription. 156 | 157 | """ 158 | try: 159 | await self.adguard.request( 160 | "filtering/remove_url", 161 | method="POST", 162 | json_data={"whitelist": allowlist, "url": url}, 163 | ) 164 | except AdGuardHomeError as exception: 165 | msg = "Failed removing URL from AdGuard Home filter" 166 | raise AdGuardHomeError(msg) from exception 167 | 168 | async def enable_url(self, *, allowlist: bool, url: str) -> None: 169 | """Enable a filter subscription in AdGuard Home. 170 | 171 | Args: 172 | ---- 173 | allowlist: True to enable an allowlist, False for a blocklists. 174 | url: Filter subscription URL to enable on AdGuard Home. 175 | 176 | Raises: 177 | ------ 178 | AdGuardHomeError: Failed enabling filter subscription. 179 | 180 | """ 181 | response = await self.adguard.request("filtering/status") 182 | filter_type = "whitelist_filters" if allowlist else "filters" 183 | 184 | # Excluded from coverage: 185 | # https://github.com/nedbat/coveragepy/issues/515 186 | name = next( # pragma: no cover 187 | ( 188 | fil["name"] 189 | for fil in response[filter_type] 190 | if fil["url"].lower() == url.lower() 191 | ), 192 | "Unknown", 193 | ) 194 | 195 | try: 196 | await self.adguard.request( 197 | "filtering/set_url", 198 | method="POST", 199 | json_data={ 200 | "url": url, 201 | "whitelist": allowlist, 202 | "data": {"enabled": True, "name": name, "url": url}, 203 | }, 204 | ) 205 | except AdGuardHomeError as exception: 206 | msg = "Failed enabling URL on AdGuard Home filter" 207 | raise AdGuardHomeError(msg) from exception 208 | 209 | async def disable_url(self, *, allowlist: bool, url: str) -> None: 210 | """Disable a filter subscription in AdGuard Home. 211 | 212 | Args: 213 | ---- 214 | url: Filter subscription URL to disable on AdGuard Home. 215 | allowlist: True to update the allowlists, False for the blocklists. 216 | 217 | Raises: 218 | ------ 219 | AdGuardHomeError: Failed disabling filter subscription. 220 | 221 | """ 222 | response = await self.adguard.request("filtering/status") 223 | filter_type = "whitelist_filters" if allowlist else "filters" 224 | 225 | # Excluded from coverage: 226 | # https://github.com/nedbat/coveragepy/issues/515 227 | name = next( # pragma: no cover 228 | ( 229 | fil["name"] 230 | for fil in response[filter_type] 231 | if fil["url"].lower() == url.lower() 232 | ), 233 | "Unknown", 234 | ) 235 | 236 | try: 237 | await self.adguard.request( 238 | "filtering/set_url", 239 | method="POST", 240 | json_data={ 241 | "url": url, 242 | "whitelist": allowlist, 243 | "data": {"enabled": False, "name": name, "url": url}, 244 | }, 245 | ) 246 | except AdGuardHomeError as exception: 247 | msg = "Failed disabling URL on AdGuard Home filter" 248 | raise AdGuardHomeError(msg) from exception 249 | 250 | async def refresh(self, *, allowlist: bool, force: bool = False) -> None: 251 | """Reload filtering subscriptions from URLs specified in AdGuard Home. 252 | 253 | Args: 254 | ---- 255 | force: Force the reload of all filter subscriptions. 256 | allowlist: True to update the allowlists, False for the blocklists. 257 | 258 | Raises: 259 | ------ 260 | AdGuardHomeError: Failed to refresh filter subscriptions. 261 | 262 | """ 263 | force_value = "true" if force else "false" 264 | 265 | try: 266 | await self.adguard.request( 267 | "filtering/refresh", 268 | method="POST", 269 | json_data={"whitelist": allowlist}, 270 | params={"force": force_value}, 271 | ) 272 | except AdGuardHomeError as exception: 273 | msg = "Failed refreshing filter URLs in AdGuard Home" 274 | raise AdGuardHomeError(msg) from exception 275 | -------------------------------------------------------------------------------- /src/adguardhome/parental.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeParental: 16 | """Controls AdGuard Home parental control.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def enabled(self) -> bool: 21 | """Return if AdGuard Home parental control is enabled or not. 22 | 23 | Returns 24 | ------- 25 | The current state of the AdGuard Home parental control. 26 | 27 | """ 28 | response = await self.adguard.request("parental/status") 29 | return response["enabled"] 30 | 31 | async def enable(self) -> None: 32 | """Enable AdGuard Home parental control. 33 | 34 | Raises 35 | ------ 36 | AdGuardHomeError: If enabling parental control failed. 37 | 38 | """ 39 | try: 40 | await self.adguard.request("parental/enable", method="POST") 41 | except AdGuardHomeError as exception: 42 | msg = "Enabling AdGuard Home parental control failed" 43 | raise AdGuardHomeError(msg) from exception 44 | 45 | async def disable(self) -> None: 46 | """Disable AdGuard Home parental control. 47 | 48 | Raises 49 | ------ 50 | AdGuardHomeError: If disabling parental control failed. 51 | 52 | """ 53 | try: 54 | await self.adguard.request("parental/disable", method="POST") 55 | except AdGuardHomeError as exception: 56 | msg = "Disabling AdGuard Home parental control failed" 57 | raise AdGuardHomeError(msg) from exception 58 | -------------------------------------------------------------------------------- /src/adguardhome/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenck/python-adguardhome/40a89beaeb044b80950270cfd5c6147a184316d5/src/adguardhome/py.typed -------------------------------------------------------------------------------- /src/adguardhome/querylog.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeQueryLog: 16 | """Controls AdGuard Home query log.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def _config( 21 | self, enabled: bool | None = None, interval: int | None = None 22 | ) -> None: 23 | """Configure query log on AdGuard Home. 24 | 25 | Args: 26 | ---- 27 | enabled: Enable/disable AdGuard Home query log. 28 | interval: Number of day to keep data in the logs. 29 | 30 | """ 31 | if enabled is None: 32 | enabled = await self.enabled() 33 | if interval is None: 34 | interval = await self.interval() 35 | await self.adguard.request( 36 | "querylog_config", 37 | method="POST", 38 | json_data={"enabled": enabled, "interval": interval}, 39 | ) 40 | 41 | async def enabled(self) -> bool: 42 | """Return if AdGuard Home query log is enabled or not. 43 | 44 | Returns 45 | ------- 46 | The current state of the AdGuard Home query log. 47 | 48 | """ 49 | response = await self.adguard.request("querylog_info") 50 | return response["enabled"] 51 | 52 | async def enable(self) -> None: 53 | """Enable AdGuard Home query log. 54 | 55 | Raises 56 | ------ 57 | AdGuardHomeError: If enabling the query log didn't succeed. 58 | 59 | """ 60 | try: 61 | await self._config(enabled=True) 62 | except AdGuardHomeError as exception: 63 | msg = "Enabling AdGuard Home query log failed" 64 | raise AdGuardHomeError(msg) from exception 65 | 66 | async def interval(self, interval: int | None = None) -> int: 67 | """Return or set the time period to keep query log data. 68 | 69 | Args: 70 | ---- 71 | interval: Set the time period (in days) to keep query log data. 72 | 73 | Returns: 74 | ------- 75 | The current set time period to keep query log data. 76 | 77 | """ 78 | if interval: 79 | await self._config(interval=interval) 80 | return interval 81 | 82 | response = await self.adguard.request("querylog_info") 83 | return response["interval"] 84 | 85 | async def disable(self) -> None: 86 | """Disable AdGuard Home query log. 87 | 88 | Raises 89 | ------ 90 | AdGuardHomeError: If disabling the query filter log didn't succeed. 91 | 92 | """ 93 | try: 94 | await self._config(enabled=False) 95 | except AdGuardHomeError as exception: 96 | msg = "Disabling AdGuard Home query log failed" 97 | raise AdGuardHomeError(msg) from exception 98 | -------------------------------------------------------------------------------- /src/adguardhome/safebrowsing.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeSafeBrowsing: 16 | """Controls AdGuard Home browsing security.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def enabled(self) -> bool: 21 | """Return if AdGuard Home browsing security is enabled or not. 22 | 23 | Returns 24 | ------- 25 | The current state of the AdGuard safe browsing feature. 26 | 27 | """ 28 | response = await self.adguard.request("safebrowsing/status") 29 | return response["enabled"] 30 | 31 | async def enable(self) -> None: 32 | """Enable AdGuard Home browsing security. 33 | 34 | Raises 35 | ------ 36 | AdGuardHomeError: If enabling the safe browsing didn't succeed. 37 | 38 | """ 39 | try: 40 | await self.adguard.request("safebrowsing/enable", method="POST") 41 | except AdGuardHomeError as exception: 42 | msg = "Enabling AdGuard Home safe browsing failed" 43 | raise AdGuardHomeError(msg) from exception 44 | 45 | async def disable(self) -> None: 46 | """Disable AdGuard Home browsing security. 47 | 48 | Raises 49 | ------ 50 | AdGuardHomeError: If disabling the safe browsing didn't succeed. 51 | 52 | """ 53 | try: 54 | await self.adguard.request("safebrowsing/disable", method="POST") 55 | except AdGuardHomeError as exception: 56 | msg = "Disabling AdGuard Home safe browsing failed" 57 | raise AdGuardHomeError(msg) from exception 58 | -------------------------------------------------------------------------------- /src/adguardhome/safesearch.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeSafeSearch: 16 | """Controls AdGuard Home safe search enforcing.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def enabled(self) -> bool: 21 | """Return if AdGuard Home safe search enforcing is enabled or not. 22 | 23 | Returns 24 | ------- 25 | The current state of the AdGuard Home safe search. 26 | 27 | """ 28 | response = await self.adguard.request("safesearch/status") 29 | return response["enabled"] 30 | 31 | async def enable(self) -> None: 32 | """Enable AdGuard Home safe search enforcing. 33 | 34 | Raises 35 | ------ 36 | AdGuardHomeError: If enabling the safe search didn't succeed. 37 | 38 | """ 39 | try: 40 | await self.adguard.request("safesearch/enable", method="POST") 41 | except AdGuardHomeError as exception: 42 | msg = "Enabling AdGuard Home safe search failed" 43 | raise AdGuardHomeError(msg) from exception 44 | 45 | async def disable(self) -> None: 46 | """Disable AdGuard Home safe search enforcing. 47 | 48 | Raises 49 | ------ 50 | AdGuardHomeError: If disabling the safe search didn't succeed. 51 | 52 | """ 53 | try: 54 | await self.adguard.request("safesearch/disable", method="POST") 55 | except AdGuardHomeError as exception: 56 | msg = "Disabling AdGuard Home safe search failed" 57 | raise AdGuardHomeError(msg) from exception 58 | -------------------------------------------------------------------------------- /src/adguardhome/stats.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from .exceptions import AdGuardHomeError 9 | 10 | if TYPE_CHECKING: 11 | from . import AdGuardHome 12 | 13 | 14 | @dataclass 15 | class AdGuardHomeStats: 16 | """Provides stats of AdGuard Home.""" 17 | 18 | adguard: AdGuardHome 19 | 20 | async def dns_queries(self) -> int: 21 | """Return number of DNS queries. 22 | 23 | Returns 24 | ------- 25 | The number of DNS queries performed by the AdGuard Home instance. 26 | 27 | """ 28 | response = await self.adguard.request("stats") 29 | return response["num_dns_queries"] 30 | 31 | async def blocked_filtering(self) -> int: 32 | """Return number of blocked DNS queries. 33 | 34 | Returns 35 | ------- 36 | The number of DNS queries blocked by the AdGuard Home instance. 37 | 38 | """ 39 | response = await self.adguard.request("stats") 40 | return response["num_blocked_filtering"] 41 | 42 | async def blocked_percentage(self) -> float: 43 | """Return the blocked percentage ratio of DNS queries. 44 | 45 | Returns 46 | ------- 47 | The percentage ratio of blocked DNS queries by the AdGuard Home 48 | instance. 49 | 50 | """ 51 | response = await self.adguard.request("stats") 52 | if not response["num_dns_queries"]: 53 | return 0.0 54 | return (response["num_blocked_filtering"] / response["num_dns_queries"]) * 100.0 55 | 56 | async def replaced_safebrowsing(self) -> int: 57 | """Return number of blocked pages by safe browsing. 58 | 59 | Returns 60 | ------- 61 | The number of times a page was blocked by the safe 62 | browsing feature of the AdGuard Home instance. 63 | 64 | """ 65 | response = await self.adguard.request("stats") 66 | return response["num_replaced_safebrowsing"] 67 | 68 | async def replaced_parental(self) -> int: 69 | """Return number of blocked pages by parental control. 70 | 71 | Returns 72 | ------- 73 | The number of times a page was blocked by the parental control 74 | feature of the AdGuard Home instance. 75 | 76 | """ 77 | response = await self.adguard.request("stats") 78 | return response["num_replaced_parental"] 79 | 80 | async def replaced_safesearch(self) -> int: 81 | """Return number of enforced safe searches. 82 | 83 | Returns 84 | ------- 85 | The number of times a safe search was enforced by the 86 | AdGuard Home instance. 87 | 88 | """ 89 | response = await self.adguard.request("stats") 90 | return response["num_replaced_safesearch"] 91 | 92 | async def avg_processing_time(self) -> float: 93 | """Return average processing time of DNS queries (in ms). 94 | 95 | Returns 96 | ------- 97 | The averages processing time (in milliseconds) of DNS queries 98 | as performed by the AdGuard Home instance. 99 | 100 | """ 101 | response = await self.adguard.request("stats") 102 | return round(response["avg_processing_time"] * 1000, 2) 103 | 104 | async def period(self) -> int: 105 | """Return the time period to keep data (in days). 106 | 107 | Returns 108 | ------- 109 | The time period of data this AdGuard Home instance keeps. 110 | 111 | """ 112 | response = await self.adguard.request("stats_info") 113 | return response["interval"] 114 | 115 | async def reset(self) -> None: 116 | """Reset all stats. 117 | 118 | Raises 119 | ------ 120 | AdGuardHomeError: Restting the AdGuard Home stats did not succeed. 121 | 122 | """ 123 | try: 124 | await self.adguard.request("stats_reset", method="POST") 125 | except AdGuardHomeError as exception: 126 | msg = "Resetting AdGuard Home stats failed" 127 | raise AdGuardHomeError(msg) from exception 128 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for the AdGuard Home API.""" 2 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT", # Use @pytest.fixture without parentheses 6 | ] 7 | 8 | lint.extend-ignore = [ 9 | "S101", # Use of assert detected. As these are tests... 10 | "SLF001", # Tests will access private/protected members... 11 | "TC002", # pytest doesn't like this one... 12 | ] 13 | -------------------------------------------------------------------------------- /tests/test_adguardhome.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.adguardhome`.""" 2 | 3 | import asyncio 4 | from unittest.mock import patch 5 | 6 | import aiohttp 7 | import pytest 8 | from aresponses import Response, ResponsesMockServer 9 | 10 | from adguardhome import AdGuardHome 11 | from adguardhome.exceptions import AdGuardHomeConnectionError, AdGuardHomeError 12 | 13 | 14 | async def test_json_request(aresponses: ResponsesMockServer) -> None: 15 | """Test JSON response is handled correctly.""" 16 | aresponses.add( 17 | "example.com:3000", 18 | "/", 19 | "GET", 20 | aresponses.Response( 21 | status=200, 22 | headers={"Content-Type": "application/json"}, 23 | text='{"status": "ok"}', 24 | ), 25 | ) 26 | async with aiohttp.ClientSession() as session: 27 | adguard = AdGuardHome("example.com", session=session) 28 | response = await adguard.request("/") 29 | assert response["status"] == "ok" 30 | await adguard.close() 31 | 32 | 33 | async def test_authenticated_request(aresponses: ResponsesMockServer) -> None: 34 | """Test JSON response is handled correctly.""" 35 | aresponses.add( 36 | "example.com:3000", 37 | "/", 38 | "GET", 39 | aresponses.Response( 40 | status=200, 41 | headers={"Content-Type": "application/json"}, 42 | text='{"status": "ok"}', 43 | ), 44 | ) 45 | async with aiohttp.ClientSession() as session: 46 | adguard = AdGuardHome( 47 | "example.com", 48 | username="frenck", 49 | password="zerocool", # noqa: S106 50 | session=session, 51 | ) 52 | response = await adguard.request("/") 53 | assert response["status"] == "ok" 54 | 55 | 56 | async def test_text_request(aresponses: ResponsesMockServer) -> None: 57 | """Test non JSON response is handled correctly.""" 58 | aresponses.add( 59 | "example.com:3000", "/", "GET", aresponses.Response(status=200, text="OK") 60 | ) 61 | async with aiohttp.ClientSession() as session: 62 | adguard = AdGuardHome("example.com", session=session) 63 | response = await adguard.request("/") 64 | assert response == {"message": "OK"} 65 | 66 | 67 | async def test_internal_session(aresponses: ResponsesMockServer) -> None: 68 | """Test JSON response is handled correctly.""" 69 | aresponses.add( 70 | "example.com:3000", 71 | "/", 72 | "GET", 73 | aresponses.Response( 74 | status=200, 75 | headers={"Content-Type": "application/json"}, 76 | text='{"status": "ok"}', 77 | ), 78 | ) 79 | async with AdGuardHome("example.com") as adguard: 80 | response = await adguard.request("/") 81 | assert response["status"] == "ok" 82 | 83 | 84 | async def test_post_request(aresponses: ResponsesMockServer) -> None: 85 | """Test POST requests are handled correctly.""" 86 | aresponses.add( 87 | "example.com:3000", "/", "POST", aresponses.Response(status=200, text="OK") 88 | ) 89 | async with aiohttp.ClientSession() as session: 90 | adguard = AdGuardHome("example.com", session=session) 91 | response = await adguard.request("/", method="POST") 92 | assert response == {"message": "OK"} 93 | 94 | 95 | async def test_request_port(aresponses: ResponsesMockServer) -> None: 96 | """Test AdGuard Home running on non-standard port.""" 97 | aresponses.add( 98 | "example.com:3333", 99 | "/", 100 | "GET", 101 | aresponses.Response(text="OMG PUPPIES!", status=200), 102 | ) 103 | 104 | async with aiohttp.ClientSession() as session: 105 | adguard = AdGuardHome("example.com", port=3333, session=session) 106 | response = await adguard.request("/") 107 | assert response == {"message": "OMG PUPPIES!"} 108 | 109 | 110 | async def test_request_base_path(aresponses: ResponsesMockServer) -> None: 111 | """Test AdGuard Home running on different base path.""" 112 | aresponses.add( 113 | "example.com:3000", 114 | "/admin/status", 115 | "GET", 116 | aresponses.Response(text="OMG PUPPIES!", status=200), 117 | ) 118 | 119 | async with aiohttp.ClientSession() as session: 120 | adguard = AdGuardHome("example.com", base_path="/admin", session=session) 121 | response = await adguard.request("status") 122 | assert response == {"message": "OMG PUPPIES!"} 123 | 124 | 125 | async def test_timeout(aresponses: ResponsesMockServer) -> None: 126 | """Test request timeout from AdGuard Home.""" 127 | 128 | # Faking a timeout by sleeping 129 | async def response_handler(_: aiohttp.ClientResponse) -> Response: 130 | """Response handler for this test.""" 131 | await asyncio.sleep(2) 132 | return aresponses.Response(body="Goodmorning!") 133 | 134 | aresponses.add("example.com:3000", "/", "GET", response_handler) 135 | 136 | async with aiohttp.ClientSession() as session: 137 | adguard = AdGuardHome("example.com", session=session, request_timeout=1) 138 | with pytest.raises(AdGuardHomeConnectionError): 139 | assert await adguard.request("/") 140 | 141 | 142 | async def test_client_error() -> None: 143 | """Test request client error from AdGuard Home.""" 144 | # Faking a timeout by sleeping 145 | async with aiohttp.ClientSession() as session: 146 | adguard = AdGuardHome("example.com", session=session) 147 | with ( 148 | patch.object(session, "request", side_effect=aiohttp.ClientError), 149 | pytest.raises(AdGuardHomeConnectionError), 150 | ): 151 | assert await adguard.request("/") 152 | 153 | 154 | async def test_http_error400(aresponses: ResponsesMockServer) -> None: 155 | """Test HTTP 404 response handling.""" 156 | aresponses.add( 157 | "example.com:3000", 158 | "/", 159 | "GET", 160 | aresponses.Response(text="OMG PUPPIES!", status=404), 161 | ) 162 | 163 | async with aiohttp.ClientSession() as session: 164 | adguard = AdGuardHome("example.com", session=session) 165 | with pytest.raises(AdGuardHomeError): 166 | assert await adguard.request("/") 167 | 168 | 169 | async def test_http_error500(aresponses: ResponsesMockServer) -> None: 170 | """Test HTTP 500 response handling.""" 171 | aresponses.add( 172 | "example.com:3000", 173 | "/", 174 | "GET", 175 | aresponses.Response( 176 | body=b'{"status":"nok"}', 177 | status=500, 178 | headers={"Content-Type": "application/json"}, 179 | ), 180 | ) 181 | 182 | async with aiohttp.ClientSession() as session: 183 | adguard = AdGuardHome("example.com", session=session) 184 | with pytest.raises(AdGuardHomeError): 185 | assert await adguard.request("/") 186 | 187 | 188 | async def test_protection_enabled(aresponses: ResponsesMockServer) -> None: 189 | """Test request of current AdGuard Home protection status.""" 190 | aresponses.add( 191 | "example.com:3000", 192 | "/control/status", 193 | "GET", 194 | aresponses.Response( 195 | status=200, 196 | headers={"Content-Type": "application/json"}, 197 | text='{"protection_enabled": true}', 198 | ), 199 | ) 200 | aresponses.add( 201 | "example.com:3000", 202 | "/control/status", 203 | "GET", 204 | aresponses.Response( 205 | status=200, 206 | headers={"Content-Type": "application/json"}, 207 | text='{"protection_enabled": false}', 208 | ), 209 | ) 210 | async with aiohttp.ClientSession() as session: 211 | adguard = AdGuardHome("example.com", session=session) 212 | enabled = await adguard.protection_enabled() 213 | assert enabled 214 | enabled = await adguard.protection_enabled() 215 | assert not enabled 216 | 217 | 218 | async def test_enable_protection(aresponses: ResponsesMockServer) -> None: 219 | """Test enabling AdGuard Home protection.""" 220 | aresponses.add( 221 | "example.com:3000", 222 | "/control/dns_config", 223 | "POST", 224 | aresponses.Response(status=200), 225 | ) 226 | aresponses.add( 227 | "example.com:3000", 228 | "/control/dns_config", 229 | "POST", 230 | aresponses.Response(status=400), 231 | ) 232 | 233 | async with aiohttp.ClientSession() as session: 234 | adguard = AdGuardHome("example.com", session=session) 235 | await adguard.enable_protection() 236 | with pytest.raises(AdGuardHomeError): 237 | await adguard.enable_protection() 238 | 239 | 240 | async def test_disable_protection(aresponses: ResponsesMockServer) -> None: 241 | """Test disabling AdGuard Home protection.""" 242 | aresponses.add( 243 | "example.com:3000", 244 | "/control/dns_config", 245 | "POST", 246 | aresponses.Response(status=200), 247 | ) 248 | aresponses.add( 249 | "example.com:3000", 250 | "/control/dns_config", 251 | "POST", 252 | aresponses.Response(status=500), 253 | ) 254 | 255 | async with aiohttp.ClientSession() as session: 256 | adguard = AdGuardHome("example.com", session=session) 257 | await adguard.disable_protection() 258 | with pytest.raises(AdGuardHomeError): 259 | await adguard.disable_protection() 260 | 261 | 262 | async def test_verion(aresponses: ResponsesMockServer) -> None: 263 | """Test requesting AdGuard Home instance version.""" 264 | aresponses.add( 265 | "example.com:3000", 266 | "/control/status", 267 | "GET", 268 | aresponses.Response( 269 | status=200, 270 | headers={"Content-Type": "application/json"}, 271 | text='{"version": "1.1"}', 272 | ), 273 | ) 274 | async with aiohttp.ClientSession() as session: 275 | adguard = AdGuardHome("example.com", session=session) 276 | version = await adguard.version() 277 | assert version == "1.1" 278 | -------------------------------------------------------------------------------- /tests/test_filtering.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.filtering`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import Response, ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_enabled(aresponses: ResponsesMockServer) -> None: 12 | """Test request of current AdGuard Home filter status.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/filtering/status", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"enabled": true}', 21 | ), 22 | ) 23 | aresponses.add( 24 | "example.com:3000", 25 | "/control/filtering/status", 26 | "GET", 27 | aresponses.Response( 28 | status=200, 29 | headers={"Content-Type": "application/json"}, 30 | text='{"enabled": false}', 31 | ), 32 | ) 33 | async with aiohttp.ClientSession() as session: 34 | adguard = AdGuardHome("example.com", session=session) 35 | enabled = await adguard.filtering.enabled() 36 | assert enabled 37 | enabled = await adguard.filtering.enabled() 38 | assert not enabled 39 | 40 | 41 | async def test_enable(aresponses: ResponsesMockServer) -> None: 42 | """Test enabling AdGuard Home filtering.""" 43 | 44 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 45 | """Response handler for this test.""" 46 | data = await request.json() 47 | assert data == {"enabled": True, "interval": 1} 48 | return aresponses.Response(status=200) 49 | 50 | aresponses.add( 51 | "example.com:3000", 52 | "/control/filtering/status", 53 | "GET", 54 | aresponses.Response( 55 | status=200, 56 | headers={"Content-Type": "application/json"}, 57 | text='{"interval": 1}', 58 | ), 59 | ) 60 | aresponses.add( 61 | "example.com:3000", "/control/filtering/config", "POST", response_handler 62 | ) 63 | aresponses.add( 64 | "example.com:3000", 65 | "/control/filtering/status", 66 | "GET", 67 | aresponses.Response( 68 | status=200, 69 | headers={"Content-Type": "application/json"}, 70 | text='{"interval": 1}', 71 | ), 72 | ) 73 | aresponses.add( 74 | "example.com:3000", 75 | "/control/filtering/config", 76 | "POST", 77 | aresponses.Response(status=500), 78 | ) 79 | 80 | async with aiohttp.ClientSession() as session: 81 | adguard = AdGuardHome("example.com", session=session) 82 | await adguard.filtering.enable() 83 | with pytest.raises(AdGuardHomeError): 84 | await adguard.filtering.enable() 85 | 86 | 87 | async def test_disable(aresponses: ResponsesMockServer) -> None: 88 | """Test disabling AdGuard Home filtering.""" 89 | 90 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 91 | """Response handler for this test.""" 92 | data = await request.json() 93 | assert data == {"enabled": False, "interval": 1} 94 | return aresponses.Response(status=200) 95 | 96 | aresponses.add( 97 | "example.com:3000", 98 | "/control/filtering/status", 99 | "GET", 100 | aresponses.Response( 101 | status=200, 102 | headers={"Content-Type": "application/json"}, 103 | text='{"interval": 1}', 104 | ), 105 | ) 106 | aresponses.add( 107 | "example.com:3000", "/control/filtering/config", "POST", response_handler 108 | ) 109 | aresponses.add( 110 | "example.com:3000", 111 | "/control/filtering/status", 112 | "GET", 113 | aresponses.Response( 114 | status=200, 115 | headers={"Content-Type": "application/json"}, 116 | text='{"interval": 1}', 117 | ), 118 | ) 119 | aresponses.add( 120 | "example.com:3000", 121 | "/control/filtering/config", 122 | "POST", 123 | aresponses.Response(status=400), 124 | ) 125 | 126 | async with aiohttp.ClientSession() as session: 127 | adguard = AdGuardHome("example.com", session=session) 128 | await adguard.filtering.disable() 129 | with pytest.raises(AdGuardHomeError): 130 | await adguard.filtering.disable() 131 | 132 | 133 | async def test_interval(aresponses: ResponsesMockServer) -> None: 134 | """Test interval settings of the AdGuard Home filtering.""" 135 | 136 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 137 | """Response handler for this test.""" 138 | data = await request.json() 139 | assert data == {"enabled": True, "interval": 1} 140 | return aresponses.Response(status=200) 141 | 142 | aresponses.add( 143 | "example.com:3000", 144 | "/control/filtering/status", 145 | "GET", 146 | aresponses.Response( 147 | status=200, 148 | headers={"Content-Type": "application/json"}, 149 | text='{"interval": 7}', 150 | ), 151 | ) 152 | aresponses.add( 153 | "example.com:3000", 154 | "/control/filtering/status", 155 | "GET", 156 | aresponses.Response( 157 | status=200, 158 | headers={"Content-Type": "application/json"}, 159 | text='{"enabled": true}', 160 | ), 161 | ) 162 | aresponses.add( 163 | "example.com:3000", "/control/filtering/config", "POST", response_handler 164 | ) 165 | aresponses.add( 166 | "example.com:3000", 167 | "/control/filtering/status", 168 | "GET", 169 | aresponses.Response( 170 | status=200, 171 | headers={"Content-Type": "application/json"}, 172 | text='{"enabled": true}', 173 | ), 174 | ) 175 | aresponses.add( 176 | "example.com:3000", 177 | "/control/filtering/config", 178 | "POST", 179 | aresponses.Response(status=400), 180 | ) 181 | 182 | async with aiohttp.ClientSession() as session: 183 | adguard = AdGuardHome("example.com", session=session) 184 | interval = await adguard.filtering.interval() 185 | assert interval == 7 186 | interval = await adguard.filtering.interval(interval=1) 187 | assert interval == 1 188 | with pytest.raises(AdGuardHomeError): 189 | await adguard.filtering.interval(interval=1) 190 | 191 | 192 | async def test_rules_count(aresponses: ResponsesMockServer) -> None: 193 | """Test getting rules count of the AdGuard Home filtering.""" 194 | aresponses.add( 195 | "example.com:3000", 196 | "/control/filtering/status", 197 | "GET", 198 | aresponses.Response( 199 | status=200, 200 | headers={"Content-Type": "application/json"}, 201 | text='{"filters": [{"rules_count": 99}, {"rules_count": 1}]}', 202 | ), 203 | ) 204 | aresponses.add( 205 | "example.com:3000", 206 | "/control/filtering/status", 207 | "GET", 208 | aresponses.Response( 209 | status=200, 210 | headers={"Content-Type": "application/json"}, 211 | text='{"filters": []}', 212 | ), 213 | ) 214 | aresponses.add( 215 | "example.com:3000", 216 | "/control/filtering/status", 217 | "GET", 218 | aresponses.Response( 219 | status=200, 220 | headers={"Content-Type": "application/json"}, 221 | text='{"whitelist_filters": [{"rules_count": 98}, {"rules_count": 1}]}', 222 | ), 223 | ) 224 | aresponses.add( 225 | "example.com:3000", 226 | "/control/filtering/status", 227 | "GET", 228 | aresponses.Response( 229 | status=200, 230 | headers={"Content-Type": "application/json"}, 231 | text='{"whitelist_filters": null}', 232 | ), 233 | ) 234 | 235 | async with aiohttp.ClientSession() as session: 236 | adguard = AdGuardHome("example.com", session=session) 237 | result = await adguard.filtering.rules_count(allowlist=False) 238 | assert result == 100 239 | result = await adguard.filtering.rules_count(allowlist=False) 240 | assert result == 0 241 | result = await adguard.filtering.rules_count(allowlist=True) 242 | assert result == 99 243 | result = await adguard.filtering.rules_count(allowlist=True) 244 | assert result == 0 245 | 246 | 247 | async def test_add_url(aresponses: ResponsesMockServer) -> None: 248 | """Test add new filter subscription to AdGuard Home filtering.""" 249 | 250 | # Handle to run asserts on request in 251 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 252 | """Response handler for this test.""" 253 | data = await request.json() 254 | assert data == { 255 | "name": "Example", 256 | "url": "https://example.com/1.txt", 257 | "whitelist": False, 258 | } 259 | return aresponses.Response(status=200, text="OK 12345 filters added") 260 | 261 | aresponses.add( 262 | "example.com:3000", "/control/filtering/add_url", "POST", response_handler 263 | ) 264 | aresponses.add( 265 | "example.com:3000", 266 | "/control/filtering/add_url", 267 | "POST", 268 | aresponses.Response(status=400, text="Invalid URL"), 269 | ) 270 | 271 | async with aiohttp.ClientSession() as session: 272 | adguard = AdGuardHome("example.com", session=session) 273 | await adguard.filtering.add_url( 274 | name="Example", url="https://example.com/1.txt", allowlist=False 275 | ) 276 | with pytest.raises(AdGuardHomeError): 277 | await adguard.filtering.add_url( 278 | name="Example", url="https://example.com/1.txt", allowlist=False 279 | ) 280 | 281 | 282 | async def test_remove_url(aresponses: ResponsesMockServer) -> None: 283 | """Test remove filter subscription from AdGuard Home filtering.""" 284 | 285 | # Handle to run asserts on request in 286 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 287 | """Response handler for this test.""" 288 | data = await request.json() 289 | assert data == {"url": "https://example.com/1.txt", "whitelist": False} 290 | return aresponses.Response(status=200, text="OK") 291 | 292 | aresponses.add( 293 | "example.com:3000", "/control/filtering/remove_url", "POST", response_handler 294 | ) 295 | aresponses.add( 296 | "example.com:3000", 297 | "/control/filtering/remove_url", 298 | "POST", 299 | aresponses.Response(status=400, text="Invalid URL"), 300 | ) 301 | 302 | async with aiohttp.ClientSession() as session: 303 | adguard = AdGuardHome("example.com", session=session) 304 | await adguard.filtering.remove_url( 305 | allowlist=False, url="https://example.com/1.txt" 306 | ) 307 | with pytest.raises(AdGuardHomeError): 308 | await adguard.filtering.remove_url( 309 | allowlist=False, url="https://example.com/1.txt" 310 | ) 311 | 312 | 313 | async def test_enable_url(aresponses: ResponsesMockServer) -> None: 314 | """Test enabling filter subscription in AdGuard Home filtering.""" 315 | 316 | # Handle to run asserts on request in 317 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 318 | """Response handler for this test.""" 319 | data = await request.json() 320 | assert data == { 321 | "url": "https://example.com/1.txt", 322 | "whitelist": False, 323 | "data": { 324 | "enabled": True, 325 | "url": "https://example.com/1.txt", 326 | "name": "test", 327 | }, 328 | } 329 | return aresponses.Response(status=200, text="OK") 330 | 331 | aresponses.add( 332 | "example.com:3000", 333 | "/control/filtering/status", 334 | "GET", 335 | aresponses.Response( 336 | status=200, 337 | headers={"Content-Type": "application/json"}, 338 | text='{"filters": [{"url": "https://EXAMPLE.com/1.txt", "name": "test"}]}', 339 | ), 340 | ) 341 | aresponses.add( 342 | "example.com:3000", "/control/filtering/set_url", "POST", response_handler 343 | ) 344 | aresponses.add( 345 | "example.com:3000", 346 | "/control/filtering/status", 347 | "GET", 348 | aresponses.Response( 349 | status=200, 350 | headers={"Content-Type": "application/json"}, 351 | text='{"filters": [{"url": "https://EXAMPLE.com/1.txt", "name": "test"}]}', 352 | ), 353 | ) 354 | aresponses.add( 355 | "example.com:3000", 356 | "/control/filtering/set_url", 357 | "POST", 358 | aresponses.Response(status=400), 359 | ) 360 | 361 | async with aiohttp.ClientSession() as session: 362 | adguard = AdGuardHome("example.com", session=session) 363 | await adguard.filtering.enable_url( 364 | allowlist=False, url="https://example.com/1.txt" 365 | ) 366 | with pytest.raises(AdGuardHomeError): 367 | await adguard.filtering.enable_url( 368 | allowlist=False, url="https://example.com/1.txt" 369 | ) 370 | 371 | 372 | async def test_disable_url(aresponses: ResponsesMockServer) -> None: 373 | """Test enabling filter subscription in AdGuard Home filtering.""" 374 | 375 | # Handle to run asserts on request in 376 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 377 | """Response handler for this test.""" 378 | data = await request.json() 379 | assert data == { 380 | "url": "https://example.com/1.txt", 381 | "whitelist": False, 382 | "data": { 383 | "enabled": False, 384 | "name": "test", 385 | "url": "https://example.com/1.txt", 386 | }, 387 | } 388 | return aresponses.Response(status=200) 389 | 390 | aresponses.add( 391 | "example.com:3000", 392 | "/control/filtering/status", 393 | "GET", 394 | aresponses.Response( 395 | status=200, 396 | headers={"Content-Type": "application/json"}, 397 | text='{"filters": [{"url": "https://EXAMPLE.com/1.txt", "name": "test"}]}', 398 | ), 399 | ) 400 | aresponses.add( 401 | "example.com:3000", "/control/filtering/set_url", "POST", response_handler 402 | ) 403 | aresponses.add( 404 | "example.com:3000", 405 | "/control/filtering/status", 406 | "GET", 407 | aresponses.Response( 408 | status=200, 409 | headers={"Content-Type": "application/json"}, 410 | text='{"filters": [{"url": "https://example.com/1.txt", "name": "test"}]}', 411 | ), 412 | ) 413 | aresponses.add( 414 | "example.com:3000", 415 | "/control/filtering/set_url", 416 | "POST", 417 | aresponses.Response(status=400), 418 | ) 419 | 420 | async with aiohttp.ClientSession() as session: 421 | adguard = AdGuardHome("example.com", session=session) 422 | await adguard.filtering.disable_url( 423 | allowlist=False, url="https://example.com/1.txt" 424 | ) 425 | with pytest.raises(AdGuardHomeError): 426 | await adguard.filtering.disable_url( 427 | allowlist=False, url="https://example.com/1.txt" 428 | ) 429 | 430 | 431 | async def test_refresh(aresponses: ResponsesMockServer) -> None: 432 | """Test enabling filter subscription in AdGuard Home filtering.""" 433 | 434 | async def response_handler_whitelist(request: aiohttp.ClientResponse) -> Response: 435 | """Response handler for this test.""" 436 | data = await request.json() 437 | assert data == {"whitelist": True} 438 | return aresponses.Response(status=200) 439 | 440 | async def response_handler_blocklist(request: aiohttp.ClientResponse) -> Response: 441 | """Response handler for this test.""" 442 | data = await request.json() 443 | assert data == {"whitelist": False} 444 | return aresponses.Response(status=200) 445 | 446 | aresponses.add( 447 | "example.com:3000", 448 | "/control/filtering/refresh?force=false", 449 | "POST", 450 | response_handler_blocklist, 451 | match_querystring=True, 452 | ) 453 | aresponses.add( 454 | "example.com:3000", 455 | "/control/filtering/refresh?force=false", 456 | "POST", 457 | response_handler_whitelist, 458 | match_querystring=True, 459 | ) 460 | aresponses.add( 461 | "example.com:3000", 462 | "/control/filtering/refresh?force=true", 463 | "POST", 464 | aresponses.Response(status=200, text="OK"), 465 | match_querystring=True, 466 | ) 467 | aresponses.add( 468 | "example.com:3000", 469 | "/control/filtering/refresh?force=false", 470 | "POST", 471 | aresponses.Response(status=400, text="Not OK"), 472 | match_querystring=True, 473 | ) 474 | 475 | async with aiohttp.ClientSession() as session: 476 | adguard = AdGuardHome("example.com", session=session) 477 | await adguard.filtering.refresh(allowlist=False, force=False) 478 | await adguard.filtering.refresh(allowlist=True, force=False) 479 | await adguard.filtering.refresh(allowlist=False, force=True) 480 | with pytest.raises(AdGuardHomeError): 481 | await adguard.filtering.refresh(allowlist=False, force=False) 482 | -------------------------------------------------------------------------------- /tests/test_parental.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.parental`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_enabled(aresponses: ResponsesMockServer) -> None: 12 | """Test request of current AdGuard Home parental control status.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/parental/status", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"enabled": true}', 21 | ), 22 | ) 23 | aresponses.add( 24 | "example.com:3000", 25 | "/control/parental/status", 26 | "GET", 27 | aresponses.Response( 28 | status=200, 29 | headers={"Content-Type": "application/json"}, 30 | text='{"enabled": false}', 31 | ), 32 | ) 33 | async with aiohttp.ClientSession() as session: 34 | adguard = AdGuardHome("example.com", session=session) 35 | enabled = await adguard.parental.enabled() 36 | assert enabled 37 | enabled = await adguard.parental.enabled() 38 | assert not enabled 39 | 40 | 41 | async def test_enable(aresponses: ResponsesMockServer) -> None: 42 | """Test enabling AdGuard Home parental control.""" 43 | # Handle to run asserts on request in 44 | aresponses.add( 45 | "example.com:3000", 46 | "/control/parental/enable", 47 | "POST", 48 | aresponses.Response(status=200, text="OK"), 49 | ) 50 | aresponses.add( 51 | "example.com:3000", 52 | "/control/parental/enable", 53 | "POST", 54 | aresponses.Response(status=400, text="NOT OK"), 55 | ) 56 | 57 | async with aiohttp.ClientSession() as session: 58 | adguard = AdGuardHome("example.com", session=session) 59 | await adguard.parental.enable() 60 | with pytest.raises(AdGuardHomeError): 61 | await adguard.parental.enable() 62 | 63 | 64 | async def test_disable(aresponses: ResponsesMockServer) -> None: 65 | """Test disabling AdGuard Home parental control.""" 66 | aresponses.add( 67 | "example.com:3000", 68 | "/control/parental/disable", 69 | "POST", 70 | aresponses.Response(status=200, text="OK"), 71 | ) 72 | aresponses.add( 73 | "example.com:3000", 74 | "/control/parental/disable", 75 | "POST", 76 | aresponses.Response(status=400, text="NOT OK"), 77 | ) 78 | 79 | async with aiohttp.ClientSession() as session: 80 | adguard = AdGuardHome("example.com", session=session) 81 | await adguard.parental.disable() 82 | with pytest.raises(AdGuardHomeError): 83 | await adguard.parental.disable() 84 | -------------------------------------------------------------------------------- /tests/test_querylog.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.querylog`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import Response, ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_enabled(aresponses: ResponsesMockServer) -> None: 12 | """Test request of current AdGuard Home query log status.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/querylog_info", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"enabled": true,"interval": 1}', 21 | ), 22 | ) 23 | aresponses.add( 24 | "example.com:3000", 25 | "/control/querylog_info", 26 | "GET", 27 | aresponses.Response( 28 | status=200, 29 | headers={"Content-Type": "application/json"}, 30 | text='{"enabled": false,"interval": 1}', 31 | ), 32 | ) 33 | async with aiohttp.ClientSession() as session: 34 | adguard = AdGuardHome("example.com", session=session) 35 | enabled = await adguard.querylog.enabled() 36 | enabled = await adguard.querylog.enabled() 37 | assert not enabled 38 | 39 | 40 | async def test_enable(aresponses: ResponsesMockServer) -> None: 41 | """Test enabling AdGuard Home query log.""" 42 | 43 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 44 | """Response handler for this test.""" 45 | data = await request.json() 46 | assert data == {"enabled": True, "interval": 1} 47 | return aresponses.Response(status=200) 48 | 49 | aresponses.add( 50 | "example.com:3000", 51 | "/control/querylog_info", 52 | "GET", 53 | aresponses.Response( 54 | status=200, 55 | headers={"Content-Type": "application/json"}, 56 | text='{"interval": 1}', 57 | ), 58 | ) 59 | aresponses.add( 60 | "example.com:3000", "/control/querylog_config", "POST", response_handler 61 | ) 62 | aresponses.add( 63 | "example.com:3000", 64 | "/control/querylog_info", 65 | "GET", 66 | aresponses.Response( 67 | status=200, 68 | headers={"Content-Type": "application/json"}, 69 | text='{"interval": 1}', 70 | ), 71 | ) 72 | aresponses.add( 73 | "example.com:3000", 74 | "/control/querylog_config", 75 | "POST", 76 | aresponses.Response(status=500), 77 | ) 78 | 79 | async with aiohttp.ClientSession() as session: 80 | adguard = AdGuardHome("example.com", session=session) 81 | await adguard.querylog.enable() 82 | with pytest.raises(AdGuardHomeError): 83 | await adguard.querylog.enable() 84 | 85 | 86 | async def test_disable(aresponses: ResponsesMockServer) -> None: 87 | """Test disabling AdGuard Home query log.""" 88 | 89 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 90 | """Response handler for this test.""" 91 | data = await request.json() 92 | assert data == {"enabled": False, "interval": 1} 93 | return aresponses.Response(status=200) 94 | 95 | aresponses.add( 96 | "example.com:3000", 97 | "/control/querylog_info", 98 | "GET", 99 | aresponses.Response( 100 | status=200, 101 | headers={"Content-Type": "application/json"}, 102 | text='{"interval": 1}', 103 | ), 104 | ) 105 | aresponses.add( 106 | "example.com:3000", "/control/querylog_config", "POST", response_handler 107 | ) 108 | aresponses.add( 109 | "example.com:3000", 110 | "/control/querylog_info", 111 | "GET", 112 | aresponses.Response( 113 | status=200, 114 | headers={"Content-Type": "application/json"}, 115 | text='{"interval": 1}', 116 | ), 117 | ) 118 | aresponses.add( 119 | "example.com:3000", 120 | "/control/querylog_config", 121 | "POST", 122 | aresponses.Response(status=500), 123 | ) 124 | 125 | async with aiohttp.ClientSession() as session: 126 | adguard = AdGuardHome("example.com", session=session) 127 | await adguard.querylog.disable() 128 | with pytest.raises(AdGuardHomeError): 129 | await adguard.querylog.disable() 130 | 131 | 132 | async def test_interval(aresponses: ResponsesMockServer) -> None: 133 | """Test interval settings of the AdGuard Home filtering.""" 134 | 135 | async def response_handler(request: aiohttp.ClientResponse) -> Response: 136 | """Response handler for this test.""" 137 | data = await request.json() 138 | assert data == {"enabled": True, "interval": 1} 139 | return aresponses.Response(status=200) 140 | 141 | aresponses.add( 142 | "example.com:3000", 143 | "/control/querylog_info", 144 | "GET", 145 | aresponses.Response( 146 | status=200, 147 | headers={"Content-Type": "application/json"}, 148 | text='{"interval": 7}', 149 | ), 150 | ) 151 | aresponses.add( 152 | "example.com:3000", 153 | "/control/querylog_info", 154 | "GET", 155 | aresponses.Response( 156 | status=200, 157 | headers={"Content-Type": "application/json"}, 158 | text='{"enabled": true}', 159 | ), 160 | ) 161 | aresponses.add( 162 | "example.com:3000", "/control/querylog_config", "POST", response_handler 163 | ) 164 | aresponses.add( 165 | "example.com:3000", 166 | "/control/querylog_info", 167 | "GET", 168 | aresponses.Response( 169 | status=200, 170 | headers={"Content-Type": "application/json"}, 171 | text='{"enabled": true}', 172 | ), 173 | ) 174 | aresponses.add( 175 | "example.com:3000", 176 | "/control/querylog_config", 177 | "POST", 178 | aresponses.Response(status=400), 179 | ) 180 | 181 | async with aiohttp.ClientSession() as session: 182 | adguard = AdGuardHome("example.com", session=session) 183 | interval = await adguard.querylog.interval() 184 | assert interval == 7 185 | interval = await adguard.querylog.interval(interval=1) 186 | assert interval == 1 187 | with pytest.raises(AdGuardHomeError): 188 | await adguard.querylog.interval(interval=1) 189 | -------------------------------------------------------------------------------- /tests/test_safebrowsing.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.safebrowsing`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_enabled(aresponses: ResponsesMockServer) -> None: 12 | """Test request of current AdGuard Home browsing security status.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/safebrowsing/status", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"enabled": true}', 21 | ), 22 | ) 23 | aresponses.add( 24 | "example.com:3000", 25 | "/control/safebrowsing/status", 26 | "GET", 27 | aresponses.Response( 28 | status=200, 29 | headers={"Content-Type": "application/json"}, 30 | text='{"enabled": false}', 31 | ), 32 | ) 33 | async with aiohttp.ClientSession() as session: 34 | adguard = AdGuardHome("example.com", session=session) 35 | enabled = await adguard.safebrowsing.enabled() 36 | assert enabled 37 | enabled = await adguard.safebrowsing.enabled() 38 | assert not enabled 39 | 40 | 41 | async def test_enable(aresponses: ResponsesMockServer) -> None: 42 | """Test enabling AdGuard Home browsing security.""" 43 | aresponses.add( 44 | "example.com:3000", 45 | "/control/safebrowsing/enable", 46 | "POST", 47 | aresponses.Response(status=200, text="OK"), 48 | ) 49 | aresponses.add( 50 | "example.com:3000", 51 | "/control/safebrowsing/enable", 52 | "POST", 53 | aresponses.Response(status=400, text="NOT OK"), 54 | ) 55 | 56 | async with aiohttp.ClientSession() as session: 57 | adguard = AdGuardHome("example.com", session=session) 58 | await adguard.safebrowsing.enable() 59 | with pytest.raises(AdGuardHomeError): 60 | await adguard.safebrowsing.enable() 61 | 62 | 63 | async def test_disable(aresponses: ResponsesMockServer) -> None: 64 | """Test disabling AdGuard Home browsing security.""" 65 | aresponses.add( 66 | "example.com:3000", 67 | "/control/safebrowsing/disable", 68 | "POST", 69 | aresponses.Response(status=200, text="OK"), 70 | ) 71 | aresponses.add( 72 | "example.com:3000", 73 | "/control/safebrowsing/disable", 74 | "POST", 75 | aresponses.Response(status=400, text="NOT OK"), 76 | ) 77 | 78 | async with aiohttp.ClientSession() as session: 79 | adguard = AdGuardHome("example.com", session=session) 80 | await adguard.safebrowsing.disable() 81 | with pytest.raises(AdGuardHomeError): 82 | await adguard.safebrowsing.disable() 83 | -------------------------------------------------------------------------------- /tests/test_safesearch.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.safesearch`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_enabled(aresponses: ResponsesMockServer) -> None: 12 | """Test request of current AdGuard Home safe search enforcing status.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/safesearch/status", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"enabled": true}', 21 | ), 22 | ) 23 | aresponses.add( 24 | "example.com:3000", 25 | "/control/safesearch/status", 26 | "GET", 27 | aresponses.Response( 28 | status=200, 29 | headers={"Content-Type": "application/json"}, 30 | text='{"enabled": false}', 31 | ), 32 | ) 33 | async with aiohttp.ClientSession() as session: 34 | adguard = AdGuardHome("example.com", session=session) 35 | enabled = await adguard.safesearch.enabled() 36 | assert enabled 37 | enabled = await adguard.safesearch.enabled() 38 | assert not enabled 39 | 40 | 41 | async def test_enable(aresponses: ResponsesMockServer) -> None: 42 | """Test enabling AdGuard Home safe search enforcing.""" 43 | aresponses.add( 44 | "example.com:3000", 45 | "/control/safesearch/enable", 46 | "POST", 47 | aresponses.Response(status=200, text="OK"), 48 | ) 49 | aresponses.add( 50 | "example.com:3000", 51 | "/control/safesearch/enable", 52 | "POST", 53 | aresponses.Response(status=400, text="NOT OK"), 54 | ) 55 | 56 | async with aiohttp.ClientSession() as session: 57 | adguard = AdGuardHome("example.com", session=session) 58 | await adguard.safesearch.enable() 59 | with pytest.raises(AdGuardHomeError): 60 | await adguard.safesearch.enable() 61 | 62 | 63 | async def test_disable(aresponses: ResponsesMockServer) -> None: 64 | """Test disabling AdGuard Home safe search enforcing.""" 65 | aresponses.add( 66 | "example.com:3000", 67 | "/control/safesearch/disable", 68 | "POST", 69 | aresponses.Response(status=200, text="OK"), 70 | ) 71 | aresponses.add( 72 | "example.com:3000", 73 | "/control/safesearch/disable", 74 | "POST", 75 | aresponses.Response(status=400, text="NOT OK"), 76 | ) 77 | 78 | async with aiohttp.ClientSession() as session: 79 | adguard = AdGuardHome("example.com", session=session) 80 | await adguard.safesearch.disable() 81 | with pytest.raises(AdGuardHomeError): 82 | await adguard.safesearch.disable() 83 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | """Tests for `adguardhome.stats`.""" 2 | 3 | import aiohttp 4 | import pytest 5 | from aresponses import ResponsesMockServer 6 | 7 | from adguardhome import AdGuardHome 8 | from adguardhome.exceptions import AdGuardHomeError 9 | 10 | 11 | async def test_dns_queries(aresponses: ResponsesMockServer) -> None: 12 | """Test requesting AdGuard Home number of DNS query stats.""" 13 | aresponses.add( 14 | "example.com:3000", 15 | "/control/stats", 16 | "GET", 17 | aresponses.Response( 18 | status=200, 19 | headers={"Content-Type": "application/json"}, 20 | text='{"num_dns_queries": 666}', 21 | ), 22 | ) 23 | async with aiohttp.ClientSession() as session: 24 | adguard = AdGuardHome("example.com", session=session) 25 | result = await adguard.stats.dns_queries() 26 | assert result == 666 27 | 28 | 29 | async def test_blocked_filtering(aresponses: ResponsesMockServer) -> None: 30 | """Test requesting AdGuard Home filtering stats.""" 31 | aresponses.add( 32 | "example.com:3000", 33 | "/control/stats", 34 | "GET", 35 | aresponses.Response( 36 | status=200, 37 | headers={"Content-Type": "application/json"}, 38 | text='{"num_blocked_filtering": 1337}', 39 | ), 40 | ) 41 | async with aiohttp.ClientSession() as session: 42 | adguard = AdGuardHome("example.com", session=session) 43 | result = await adguard.stats.blocked_filtering() 44 | assert result == 1337 45 | 46 | 47 | async def test_blocked_percentage(aresponses: ResponsesMockServer) -> None: 48 | """Test requesting AdGuard Home filtering stats.""" 49 | aresponses.add( 50 | "example.com:3000", 51 | "/control/stats", 52 | "GET", 53 | aresponses.Response( 54 | status=200, 55 | headers={"Content-Type": "application/json"}, 56 | text='{"num_dns_queries": 100, "num_blocked_filtering": 25}', 57 | ), 58 | ) 59 | aresponses.add( 60 | "example.com:3000", 61 | "/control/stats", 62 | "GET", 63 | aresponses.Response( 64 | status=200, 65 | headers={"Content-Type": "application/json"}, 66 | text='{"num_dns_queries": 0, "num_blocked_filtering": 25}', 67 | ), 68 | ) 69 | aresponses.add( 70 | "example.com:3000", 71 | "/control/stats", 72 | "GET", 73 | aresponses.Response( 74 | status=200, 75 | headers={"Content-Type": "application/json"}, 76 | text='{"num_dns_queries": 100, "num_blocked_filtering": 0}', 77 | ), 78 | ) 79 | async with aiohttp.ClientSession() as session: 80 | adguard = AdGuardHome("example.com", session=session) 81 | result = await adguard.stats.blocked_percentage() 82 | assert result == 25.0 83 | result = await adguard.stats.blocked_percentage() 84 | assert result == 0.0 85 | result = await adguard.stats.blocked_percentage() 86 | assert result == 0.0 87 | 88 | 89 | async def test_replaced_safebrowsing(aresponses: ResponsesMockServer) -> None: 90 | """Test requesting AdGuard Home safebrowsing stats.""" 91 | aresponses.add( 92 | "example.com:3000", 93 | "/control/stats", 94 | "GET", 95 | aresponses.Response( 96 | status=200, 97 | headers={"Content-Type": "application/json"}, 98 | text='{"num_replaced_safebrowsing": 42}', 99 | ), 100 | ) 101 | async with aiohttp.ClientSession() as session: 102 | adguard = AdGuardHome("example.com", session=session) 103 | result = await adguard.stats.replaced_safebrowsing() 104 | assert result == 42 105 | 106 | 107 | async def test_replaced_parental(aresponses: ResponsesMockServer) -> None: 108 | """Test requesting AdGuard Home parental control stats.""" 109 | aresponses.add( 110 | "example.com:3000", 111 | "/control/stats", 112 | "GET", 113 | aresponses.Response( 114 | status=200, 115 | headers={"Content-Type": "application/json"}, 116 | text='{"num_replaced_parental": 13}', 117 | ), 118 | ) 119 | async with aiohttp.ClientSession() as session: 120 | adguard = AdGuardHome("example.com", session=session) 121 | result = await adguard.stats.replaced_parental() 122 | assert result == 13 123 | 124 | 125 | async def test_replaced_safesearch(aresponses: ResponsesMockServer) -> None: 126 | """Test requesting AdGuard Home safe search enforcement stats.""" 127 | aresponses.add( 128 | "example.com:3000", 129 | "/control/stats", 130 | "GET", 131 | aresponses.Response( 132 | status=200, 133 | headers={"Content-Type": "application/json"}, 134 | text='{"num_replaced_safesearch": 18}', 135 | ), 136 | ) 137 | async with aiohttp.ClientSession() as session: 138 | adguard = AdGuardHome("example.com", session=session) 139 | result = await adguard.stats.replaced_safesearch() 140 | assert result == 18 141 | 142 | 143 | async def test_avg_processing_time(aresponses: ResponsesMockServer) -> None: 144 | """Test requesting AdGuard Home DNS average processing time stats.""" 145 | aresponses.add( 146 | "example.com:3000", 147 | "/control/stats", 148 | "GET", 149 | aresponses.Response( 150 | status=200, 151 | headers={"Content-Type": "application/json"}, 152 | text='{"avg_processing_time": 0.03141}', 153 | ), 154 | ) 155 | async with aiohttp.ClientSession() as session: 156 | adguard = AdGuardHome("example.com", session=session) 157 | result = await adguard.stats.avg_processing_time() 158 | assert result == 31.41 159 | 160 | 161 | async def test_period(aresponses: ResponsesMockServer) -> None: 162 | """Test requesting AdGuard Home stats period.""" 163 | aresponses.add( 164 | "example.com:3000", 165 | "/control/stats_info", 166 | "GET", 167 | aresponses.Response( 168 | status=200, 169 | headers={"Content-Type": "application/json"}, 170 | text='{"interval": 7}', 171 | ), 172 | ) 173 | 174 | async with aiohttp.ClientSession() as session: 175 | adguard = AdGuardHome("example.com", session=session) 176 | result = await adguard.stats.period() 177 | assert result == 7 178 | 179 | 180 | async def test_reset(aresponses: ResponsesMockServer) -> None: 181 | """Test resetting all AdGuard Home stats.""" 182 | aresponses.add( 183 | "example.com:3000", 184 | "/control/stats_reset", 185 | "POST", 186 | aresponses.Response(status=200, text="OK"), 187 | ) 188 | aresponses.add( 189 | "example.com:3000", 190 | "/control/stats_reset", 191 | "POST", 192 | aresponses.Response(status=400, text="Not OK"), 193 | ) 194 | 195 | async with aiohttp.ClientSession() as session: 196 | adguard = AdGuardHome("example.com", session=session) 197 | await adguard.stats.reset() 198 | with pytest.raises(AdGuardHomeError): 199 | await adguard.stats.reset() 200 | --------------------------------------------------------------------------------