Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion MANIFEST.in

This file was deleted.

2 changes: 2 additions & 0 deletions hole/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Support for Pi-hole API v5 and v6."""

from .exceptions import HoleError
from .v5 import HoleV5
from .v6 import HoleV6
Expand Down
14 changes: 4 additions & 10 deletions hole/v5.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -48,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)
Expand All @@ -64,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)
Expand All @@ -83,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)

Expand All @@ -107,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)

Expand Down
16 changes: 5 additions & 11 deletions hole/v6.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down
52 changes: 52 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[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.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"
41 changes: 0 additions & 41 deletions setup.py

This file was deleted.

1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Test for the hole package."""
133 changes: 133 additions & 0 deletions tests/test_hole.py
Original file line number Diff line number Diff line change
@@ -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"