From bfd0957b055f85ef78606776ed27069a74094771 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 14:53:21 +0200 Subject: [PATCH 1/7] Add docstring --- hole/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hole/__init__.py b/hole/__init__.py index 68ab342..35a3a94 100644 --- a/hole/__init__.py +++ b/hole/__init__.py @@ -1,3 +1,5 @@ +"""Support for Pi-hole API v5 and v6.""" + from .exceptions import HoleError from .v5 import HoleV5 from .v6 import HoleV6 From 7c4eb3667070f9640c5d0d26d0fefc476e795eb0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 14:54:21 +0200 Subject: [PATCH 2/7] Remove async_timeout --- hole/v5.py | 6 ------ hole/v6.py | 16 +++++----------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/hole/v5.py b/hole/v5.py index 8995747..746ef05 100644 --- a/hole/v5.py +++ b/hole/v5.py @@ -3,15 +3,9 @@ import asyncio import logging import socket -import sys import aiohttp -if sys.version_info >= (3, 11): - import asyncio as async_timeout -else: - import async_timeout - from . import exceptions _LOGGER = logging.getLogger(__name__) diff --git a/hole/v6.py b/hole/v6.py index 61c1e43..ea5b1e5 100644 --- a/hole/v6.py +++ b/hole/v6.py @@ -4,17 +4,11 @@ import json import logging import socket -import sys import time from typing import Literal, Optional import aiohttp -if sys.version_info >= (3, 11): - import asyncio as async_timeout -else: - import async_timeout - from . import exceptions _LOGGER = logging.getLogger(__name__) @@ -85,7 +79,7 @@ async def authenticate(self): auth_url = f"{self.base_url}/api/auth" try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await self._session.post( auth_url, json={"password": str(self.password)}, ssl=self.verify_tls ) @@ -149,7 +143,7 @@ async def logout(self): headers = {"X-FTL-SID": self._session_id} try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): await self._session.delete( logout_url, headers=headers, ssl=self.verify_tls ) @@ -185,7 +179,7 @@ async def _fetch_data(self, endpoint: str, params=None) -> dict: headers["X-FTL-CSRF"] = self._csrf_token try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await self._session.get( url, params=params, headers=headers, ssl=self.verify_tls ) @@ -252,7 +246,7 @@ async def enable(self): payload = {"blocking": True, "timer": None} try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await self._session.post( url, json=payload, headers=headers, ssl=self.verify_tls ) @@ -297,7 +291,7 @@ async def disable(self, duration=0): payload = {"blocking": False, "timer": duration if duration > 0 else None} try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await self._session.post( url, json=payload, headers=headers, ssl=self.verify_tls ) From 9bf3a04b46f3520b2ae4544c8202bdf5e32c3596 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 14:59:30 +0200 Subject: [PATCH 3/7] Migrate to hatchling --- pyproject.toml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ddc53bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hole" +version = "0.9.1" +description = "Python API for interacting with *hole." +readme = "README.rst" +license = { text = "MIT" } +authors = [ + { name = "Fabian Affolter", email = "fabian@affolter-engineering.ch" }, +] + +requires-python = ">=3.13" + +dependencies = [ + "aiohttp<4", +] + +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Utilities", +] + +[project.urls] +Homepage = "https://github.com/home-assistant-ecosystem/python-hole" +Download = "https://github.com/home-assistant-ecosystem/python-hole/releases" \ No newline at end of file From df1bb2c7ac39bbf885944636353240fdf2b85b02 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 15:00:08 +0200 Subject: [PATCH 4/7] Update workflow --- .github/workflows/python.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 1ea06b8..d28225c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -16,13 +16,22 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + + - name: Install hatch + run: | + python -m pip install --upgrade pip + pip install hatch + + - name: Lint with ruff + run: | + pip install ruff + ruff check . + + - name: Run tests with hatch run: | - python -m pip install --upgrade pip setuptools wheel - python setup.py install - - name: Black Code Formatter - uses: jpetrucciani/black-check@22.12.0 + hatch run test:run From f1ac25c72192e299be4021c8963d0d3ed650cad4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 15:00:20 +0200 Subject: [PATCH 5/7] Remove files --- MANIFEST.in | 1 - setup.py | 41 ----------------------------------------- 2 files changed, 42 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 1aba38f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include LICENSE diff --git a/setup.py b/setup.py deleted file mode 100644 index 192a137..0000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""Setup file for the *hole API Python client.""" -import os -from setuptools import setup - -here = os.path.abspath(os.path.dirname(__file__)) - -# Get the long description from the relevant file -with open(os.path.join(here, "README.rst"), encoding="utf-8") as desc: - long_description = desc.read() - -setup( - name="hole", - version="0.9.0", - description="Python API for interacting with *hole.", - long_description=long_description, - url="https://github.com/home-assistant-ecosystem/python-hole", - download_url="https://github.com/home-assistant-ecosystem/python-hole/releases", - author="Fabian Affolter", - author_email="fabian@affolter-engineering.ch", - license="MIT", - install_requires=[ - "aiohttp<4", - 'async_timeout; python_version < "3.11"', - ], - packages=["hole"], - python_requires=">=3.11", - zip_safe=True, - classifiers=[ - "Development Status :: 3 - Alpha", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Utilities", - ], -) From f0e74db40b4d4a61185f0611b1376bff36fcf8e4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 17:27:10 +0200 Subject: [PATCH 6/7] Switch to asyncio.timeout --- hole/v5.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hole/v5.py b/hole/v5.py index 746ef05..8008ad1 100644 --- a/hole/v5.py +++ b/hole/v5.py @@ -42,7 +42,7 @@ async def get_data(self): """Get details of a *hole instance.""" params = "summaryRaw&auth={}".format(self.api_token) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): response = await self._session.get(self.base_url, params=params) _LOGGER.debug("Response from *hole: %s", response.status) @@ -58,7 +58,7 @@ async def get_versions(self): """Get version information of a *hole instance.""" params = "versions" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): response = await self._session.get(self.base_url, params=params) _LOGGER.debug("Response from *hole: %s", response.status) @@ -77,7 +77,7 @@ async def enable(self): return params = "enable=True&auth={}".format(self.api_token) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): response = await self._session.get(self.base_url, params=params) _LOGGER.debug("Response from *hole: %s", response.status) @@ -101,7 +101,7 @@ async def disable(self, duration=True): return params = "disable={}&auth={}".format(duration, self.api_token) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): response = await self._session.get(self.base_url, params=params) _LOGGER.debug("Response from *hole: %s", response.status) From 3bf60685c8f37986c0e188f94945fb1856e35d3e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 7 Apr 2026 17:28:01 +0200 Subject: [PATCH 7/7] Add basic tests --- pyproject.toml | 16 ++++++ tests/__init__.py | 1 + tests/test_hole.py | 133 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_hole.py diff --git a/pyproject.toml b/pyproject.toml index ddc53bc..ea8782a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,22 @@ classifiers = [ "Topic :: Utilities", ] +[project.optional-dependencies] +test = [ + "pytest>=9", + "pytest-asyncio>=0.21", +] + +[tool.hatch.envs.test] +dependencies = [ + "pytest>=9", + "pytest-asyncio>=0.21", + "aiohttp>=3.8.4,<4" +] + +[tool.hatch.envs.test.scripts] +run = "pytest" + [project.urls] Homepage = "https://github.com/home-assistant-ecosystem/python-hole" Download = "https://github.com/home-assistant-ecosystem/python-hole/releases" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3a3bd33 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test for the hole package.""" diff --git a/tests/test_hole.py b/tests/test_hole.py new file mode 100644 index 0000000..b0d2d8c --- /dev/null +++ b/tests/test_hole.py @@ -0,0 +1,133 @@ +# import pytest and asyncio for async tests +import pytest +from unittest.mock import AsyncMock, MagicMock + +import aiohttp + +from hole.v5 import HoleV5 +from hole.v6 import HoleV6 +from hole import exceptions + +# pytest-asyncio is required for async tests +pytestmark = pytest.mark.asyncio + + +class DummyResponse: + """Dummy response class for simulating aiohttp responses.""" + + def __init__(self, status=200, json_data=None, text_data=None): + """Initialize the dummy response.""" + self.status = status + self._json = json_data or {} + self._text = text_data or "{}" + + async def json(self): + """Return the JSON data.""" + return self._json + + async def text(self): + """Return the text data.""" + return self._text + + +@pytest.fixture +def aiohttp_session(): + """Fixture for creating a mock aiohttp session.""" + return MagicMock(spec=aiohttp.ClientSession) + + +@pytest.mark.asyncio +async def test_v5_get_data_success(aiohttp_session): + """Test successful data retrieval for HoleV5.""" + aiohttp_session.get = AsyncMock( + return_value=DummyResponse( + json_data={ + "status": "enabled", + "unique_clients": 2, + "unique_domains": 3, + "ads_blocked_today": 4, + "ads_percentage_today": 5.5, + "clients_ever_seen": 6, + "dns_queries_today": 7, + "domains_being_blocked": 8, + "queries_cached": 9, + "queries_forwarded": 10, + } + ) + ) + hole5 = HoleV5("localhost", aiohttp_session, api_token="token") + await hole5.get_data() + assert hole5.status == "enabled" + assert hole5.unique_clients == 2 + assert hole5.unique_domains == 3 + assert hole5.ads_blocked_today == 4 + assert hole5.ads_percentage_today == 5.5 + assert hole5.clients_ever_seen == 6 + assert hole5.dns_queries_today == 7 + assert hole5.domains_being_blocked == 8 + assert hole5.queries_cached == 9 + assert hole5.queries_forwarded == 10 + + +@pytest.mark.asyncio +async def test_v5_get_data_connection_error(aiohttp_session): + """Test connection error handling for HoleV5.""" + aiohttp_session.get = AsyncMock(side_effect=aiohttp.ClientError) + hole5 = HoleV5("localhost", aiohttp_session, api_token="token") + with pytest.raises(exceptions.HoleConnectionError): + await hole5.get_data() + + +@pytest.mark.asyncio +async def test_v6_get_data_success(aiohttp_session): + """Test successful data retrieval for HoleV6.""" + hole6 = HoleV6("localhost", aiohttp_session, password="pw") + hole6.ensure_auth = AsyncMock() + hole6._fetch_data = AsyncMock( + side_effect=[ + { + "queries": { + "blocked": 1, + "percent_blocked": 2.5, + "unique_domains": 3, + "total": 4, + "cached": 5, + "forwarded": 6, + "replies": {}, + }, + "clients": {"active": 2, "total": 3}, + "gravity": {"domains_being_blocked": 11}, + }, + {"domains": ["ad.com"]}, + {"domains": ["ok.com"]}, + {"clients": {"active": 2, "total": 3}}, + {"upstreams": ["8.8.8.8"]}, + {"blocking": "enabled"}, + { + "core": { + "local": {"version": "1", "hash": "a"}, + "remote": {"version": "2", "hash": "b"}, + }, + "web": { + "local": {"version": "1", "hash": "a"}, + "remote": {"version": "2", "hash": "b"}, + }, + "ftl": { + "local": {"version": "1", "hash": "a"}, + "remote": {"version": "2", "hash": "b"}, + }, + }, + ] + ) + await hole6.get_data() + assert hole6.ads_blocked_today == 1 + assert hole6.ads_percentage_today == 2.5 + assert hole6.unique_domains == 3 + assert hole6.dns_queries_today == 4 + assert hole6.queries_cached == 5 + assert hole6.queries_forwarded == 6 + assert hole6.top_ads == ["ad.com"] + assert hole6.top_queries == ["ok.com"] + assert hole6.unique_clients == 2 + assert hole6.clients_ever_seen == 3 + assert hole6.status == "enabled"