pihole6api 0.1.8__tar.gz → 0.1.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pihole6api-0.1.8/src/pihole6api.egg-info → pihole6api-0.1.9}/PKG-INFO +1 -1
- {pihole6api-0.1.8 → pihole6api-0.1.9}/pyproject.toml +1 -1
- pihole6api-0.1.9/src/pihole6api/conn.py +229 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9/src/pihole6api.egg-info}/PKG-INFO +1 -1
- pihole6api-0.1.8/src/pihole6api/conn.py +0 -161
- {pihole6api-0.1.8 → pihole6api-0.1.9}/LICENSE +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/README.md +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/setup.cfg +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/__init__.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/actions.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/client.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/client_management.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/config.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/dhcp.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/dns_control.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/domain_management.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/ftl_info.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/group_management.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/list_management.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/metrics.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/network_info.py +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/SOURCES.txt +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/dependency_links.txt +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/requires.txt +0 -0
- {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/top_level.txt +0 -0
@@ -0,0 +1,229 @@
|
|
1
|
+
import requests
|
2
|
+
import urllib3
|
3
|
+
from urllib.parse import urljoin
|
4
|
+
import warnings
|
5
|
+
import time
|
6
|
+
import json
|
7
|
+
import logging
|
8
|
+
from requests.adapters import HTTPAdapter
|
9
|
+
from urllib3.util.retry import Retry
|
10
|
+
|
11
|
+
# Suppress InsecureRequestWarning
|
12
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
13
|
+
warnings.simplefilter("ignore", category=urllib3.exceptions.InsecureRequestWarning)
|
14
|
+
|
15
|
+
# Configure logging
|
16
|
+
logger = logging.getLogger("pihole6api")
|
17
|
+
|
18
|
+
class PiHole6Connection:
|
19
|
+
def __init__(self, base_url, password, max_retries=3, retry_delay=1,
|
20
|
+
connection_timeout=10, disable_connection_pooling=False):
|
21
|
+
"""
|
22
|
+
Initialize the Pi-hole connection client.
|
23
|
+
|
24
|
+
:param base_url: The base URL of the Pi-hole API (e.g., "http://pi.hole/api/")
|
25
|
+
:param password: The password for authentication (or an application password)
|
26
|
+
:param max_retries: Maximum number of retry attempts for failed requests
|
27
|
+
:param retry_delay: Base delay in seconds between retries (will use exponential backoff)
|
28
|
+
:param connection_timeout: Connection timeout in seconds
|
29
|
+
:param disable_connection_pooling: If True, disable connection pooling to prevent connection reuse issues
|
30
|
+
"""
|
31
|
+
self.base_url = base_url.rstrip("/") + "/api/"
|
32
|
+
self.password = password
|
33
|
+
self.session_id = None
|
34
|
+
self.csrf_token = None
|
35
|
+
self.validity = None
|
36
|
+
self.max_retries = max_retries
|
37
|
+
self.retry_delay = retry_delay
|
38
|
+
self.connection_timeout = connection_timeout
|
39
|
+
self.disable_connection_pooling = disable_connection_pooling
|
40
|
+
|
41
|
+
# Create a session for connection reuse
|
42
|
+
self.session = requests.Session()
|
43
|
+
|
44
|
+
# Configure retry strategy
|
45
|
+
retry_strategy = Retry(
|
46
|
+
total=max_retries,
|
47
|
+
backoff_factor=retry_delay,
|
48
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
49
|
+
allowed_methods=["GET", "POST", "PUT", "DELETE", "PATCH"]
|
50
|
+
)
|
51
|
+
|
52
|
+
# Configure adapter with retry strategy
|
53
|
+
adapter = HTTPAdapter(
|
54
|
+
max_retries=retry_strategy,
|
55
|
+
pool_connections=1 if disable_connection_pooling else 10,
|
56
|
+
pool_maxsize=1 if disable_connection_pooling else 10
|
57
|
+
)
|
58
|
+
|
59
|
+
# Mount the adapter to both HTTP and HTTPS
|
60
|
+
self.session.mount("http://", adapter)
|
61
|
+
self.session.mount("https://", adapter)
|
62
|
+
|
63
|
+
# Set timeout
|
64
|
+
self.session.timeout = connection_timeout
|
65
|
+
|
66
|
+
# Authenticate upon initialization
|
67
|
+
self._authenticate()
|
68
|
+
|
69
|
+
def _authenticate(self):
|
70
|
+
"""Authenticate with the Pi-hole API and store session ID and CSRF token.
|
71
|
+
|
72
|
+
Retries up to max_retries times with exponential backoff before raising an exception.
|
73
|
+
"""
|
74
|
+
auth_url = urljoin(self.base_url, "auth")
|
75
|
+
payload = {"password": self.password}
|
76
|
+
last_exception = None
|
77
|
+
|
78
|
+
for attempt in range(1, self.max_retries + 1):
|
79
|
+
try:
|
80
|
+
logger.debug(f"Authentication attempt {attempt}/{self.max_retries}")
|
81
|
+
response = self.session.post(auth_url, json=payload, verify=False, timeout=self.connection_timeout)
|
82
|
+
|
83
|
+
if response.status_code == 200:
|
84
|
+
data = response.json()
|
85
|
+
if "session" in data and data["session"]["valid"]:
|
86
|
+
self.session_id = data["session"]["sid"]
|
87
|
+
self.csrf_token = data["session"]["csrf"]
|
88
|
+
self.validity = data["session"]["validity"]
|
89
|
+
logger.debug("Authentication successful")
|
90
|
+
return # Successful authentication
|
91
|
+
else:
|
92
|
+
last_exception = Exception("Authentication failed: Invalid session response")
|
93
|
+
else:
|
94
|
+
# Try to extract an error message from the response
|
95
|
+
try:
|
96
|
+
error_msg = response.json().get("error", {}).get("message", "Unknown error")
|
97
|
+
except (json.decoder.JSONDecodeError, ValueError):
|
98
|
+
error_msg = f"HTTP {response.status_code}: {response.reason}"
|
99
|
+
last_exception = Exception(f"Authentication failed: {error_msg}")
|
100
|
+
except Exception as e:
|
101
|
+
last_exception = e
|
102
|
+
logger.warning(f"Authentication attempt {attempt} failed: {str(e)}")
|
103
|
+
|
104
|
+
if attempt < self.max_retries:
|
105
|
+
# Calculate exponential backoff delay
|
106
|
+
current_delay = self.retry_delay * (2 ** (attempt - 1))
|
107
|
+
logger.debug(f"Retrying authentication in {current_delay} seconds...")
|
108
|
+
time.sleep(current_delay)
|
109
|
+
|
110
|
+
# All attempts failed; raise the last captured exception.
|
111
|
+
logger.error(f"All authentication attempts failed: {str(last_exception)}")
|
112
|
+
raise last_exception
|
113
|
+
|
114
|
+
def _get_headers(self):
|
115
|
+
"""Return headers including the authentication SID and CSRF token."""
|
116
|
+
if not self.session_id or not self.csrf_token:
|
117
|
+
self._authenticate()
|
118
|
+
|
119
|
+
return {
|
120
|
+
"X-FTL-SID": self.session_id,
|
121
|
+
"X-FTL-CSRF": self.csrf_token
|
122
|
+
}
|
123
|
+
|
124
|
+
def _do_call(self, method, endpoint, params=None, data=None, files=None, is_binary=False):
|
125
|
+
"""Internal method to send an authenticated request to the Pi-hole API."""
|
126
|
+
url = f"{self.base_url}{endpoint}"
|
127
|
+
headers = self._get_headers()
|
128
|
+
|
129
|
+
# Convert dictionary to form-encoded string if sending multipart
|
130
|
+
request_data = None if files else data
|
131
|
+
form_data = data if files else None # Ensure correct encoding
|
132
|
+
|
133
|
+
try:
|
134
|
+
logger.debug(f"Sending {method} request to {url}")
|
135
|
+
response = self.session.request(
|
136
|
+
method,
|
137
|
+
url,
|
138
|
+
headers=headers,
|
139
|
+
params=params,
|
140
|
+
json=request_data,
|
141
|
+
files=files,
|
142
|
+
data=form_data,
|
143
|
+
verify=False,
|
144
|
+
timeout=self.connection_timeout
|
145
|
+
)
|
146
|
+
|
147
|
+
if response.status_code == 401:
|
148
|
+
logger.warning("Session expired, re-authenticating")
|
149
|
+
self._authenticate()
|
150
|
+
headers = self._get_headers()
|
151
|
+
response = self.session.request(
|
152
|
+
method,
|
153
|
+
url,
|
154
|
+
headers=headers,
|
155
|
+
params=params,
|
156
|
+
json=request_data,
|
157
|
+
files=files,
|
158
|
+
data=form_data,
|
159
|
+
verify=False,
|
160
|
+
timeout=self.connection_timeout
|
161
|
+
)
|
162
|
+
|
163
|
+
# Handle 4xx responses gracefully
|
164
|
+
if 400 <= response.status_code < 500:
|
165
|
+
try:
|
166
|
+
return response.json()
|
167
|
+
except requests.exceptions.JSONDecodeError:
|
168
|
+
return {"error": f"HTTP {response.status_code}: {response.reason}"}
|
169
|
+
|
170
|
+
response.raise_for_status()
|
171
|
+
|
172
|
+
if is_binary:
|
173
|
+
return response.content # Return raw binary content (e.g., for file exports)
|
174
|
+
|
175
|
+
if not response.content.strip():
|
176
|
+
return {} # Handle empty response
|
177
|
+
|
178
|
+
try:
|
179
|
+
return response.json() # Attempt to parse JSON
|
180
|
+
except requests.exceptions.JSONDecodeError:
|
181
|
+
return response.text # Return raw text as fallback
|
182
|
+
|
183
|
+
except requests.exceptions.ConnectionError as e:
|
184
|
+
logger.error(f"Connection error: {str(e)}")
|
185
|
+
raise Exception(f"Connection error: {str(e)}")
|
186
|
+
except requests.exceptions.Timeout as e:
|
187
|
+
logger.error(f"Request timed out: {str(e)}")
|
188
|
+
raise Exception(f"Request timed out: {str(e)}")
|
189
|
+
except requests.exceptions.RequestException as e:
|
190
|
+
logger.error(f"Request error: {str(e)}")
|
191
|
+
raise Exception(f"Request error: {str(e)}")
|
192
|
+
|
193
|
+
def get(self, endpoint, params=None, is_binary=False):
|
194
|
+
"""Send a GET request."""
|
195
|
+
return self._do_call("GET", endpoint, params=params, is_binary=is_binary)
|
196
|
+
|
197
|
+
def post(self, endpoint, data=None, files=None):
|
198
|
+
"""Send a POST request."""
|
199
|
+
return self._do_call("POST", endpoint, data=data, files=files)
|
200
|
+
|
201
|
+
def put(self, endpoint, data=None):
|
202
|
+
"""Send a PUT request."""
|
203
|
+
return self._do_call("PUT", endpoint, data=data)
|
204
|
+
|
205
|
+
def delete(self, endpoint, params=None, data=None):
|
206
|
+
"""Send a DELETE request."""
|
207
|
+
return self._do_call("DELETE", endpoint, params=params, data=data)
|
208
|
+
|
209
|
+
def patch(self, endpoint, data=None):
|
210
|
+
"""Send a PATCH request."""
|
211
|
+
return self._do_call("PATCH", endpoint, data=data)
|
212
|
+
|
213
|
+
def exit(self):
|
214
|
+
"""Delete the current session and close connections."""
|
215
|
+
try:
|
216
|
+
response = self.delete("auth")
|
217
|
+
except Exception as e:
|
218
|
+
logger.warning(f"Error during session exit: {str(e)}")
|
219
|
+
response = {"error": str(e)}
|
220
|
+
finally:
|
221
|
+
# Clear stored session info
|
222
|
+
self.session_id = None
|
223
|
+
self.csrf_token = None
|
224
|
+
self.validity = None
|
225
|
+
|
226
|
+
# Close the session to release connections
|
227
|
+
self.session.close()
|
228
|
+
|
229
|
+
return response
|
@@ -1,161 +0,0 @@
|
|
1
|
-
import requests
|
2
|
-
import urllib3
|
3
|
-
from urllib.parse import urljoin
|
4
|
-
import warnings
|
5
|
-
import time
|
6
|
-
import json
|
7
|
-
|
8
|
-
# Suppress InsecureRequestWarning
|
9
|
-
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
10
|
-
warnings.simplefilter("ignore", category=urllib3.exceptions.InsecureRequestWarning)
|
11
|
-
|
12
|
-
class PiHole6Connection:
|
13
|
-
def __init__(self, base_url, password):
|
14
|
-
"""
|
15
|
-
Initialize the Pi-hole connection client.
|
16
|
-
|
17
|
-
:param base_url: The base URL of the Pi-hole API (e.g., "http://pi.hole/api/")
|
18
|
-
:param password: The password for authentication (or an application password)
|
19
|
-
"""
|
20
|
-
self.base_url = base_url.rstrip("/") + "/api/"
|
21
|
-
self.password = password
|
22
|
-
self.session_id = None
|
23
|
-
self.csrf_token = None
|
24
|
-
self.validity = None
|
25
|
-
|
26
|
-
# Authenticate upon initialization
|
27
|
-
self._authenticate()
|
28
|
-
|
29
|
-
def _authenticate(self):
|
30
|
-
"""Authenticate with the Pi-hole API and store session ID and CSRF token.
|
31
|
-
|
32
|
-
Retries up to three times (with a one-second pause between attempts)
|
33
|
-
before raising an exception.
|
34
|
-
"""
|
35
|
-
auth_url = urljoin(self.base_url, "auth")
|
36
|
-
payload = {"password": self.password}
|
37
|
-
max_attempts = 3
|
38
|
-
last_exception = None
|
39
|
-
|
40
|
-
for attempt in range(1, max_attempts + 1):
|
41
|
-
response = requests.post(auth_url, json=payload, verify=False)
|
42
|
-
try:
|
43
|
-
if response.status_code == 200:
|
44
|
-
data = response.json()
|
45
|
-
if "session" in data and data["session"]["valid"]:
|
46
|
-
self.session_id = data["session"]["sid"]
|
47
|
-
self.csrf_token = data["session"]["csrf"]
|
48
|
-
self.validity = data["session"]["validity"]
|
49
|
-
return # Successful authentication
|
50
|
-
else:
|
51
|
-
last_exception = Exception("Authentication failed: Invalid session response")
|
52
|
-
else:
|
53
|
-
# Try to extract an error message from the response
|
54
|
-
try:
|
55
|
-
error_msg = response.json().get("error", {}).get("message", "Unknown error")
|
56
|
-
except (json.decoder.JSONDecodeError, ValueError):
|
57
|
-
error_msg = f"HTTP {response.status_code}: {response.reason}"
|
58
|
-
last_exception = Exception(f"Authentication failed: {error_msg}")
|
59
|
-
except Exception as e:
|
60
|
-
last_exception = e
|
61
|
-
|
62
|
-
if attempt < max_attempts:
|
63
|
-
time.sleep(1) # Pause before retrying
|
64
|
-
|
65
|
-
# All attempts failed; raise the last captured exception.
|
66
|
-
raise last_exception
|
67
|
-
|
68
|
-
def _get_headers(self):
|
69
|
-
"""Return headers including the authentication SID and CSRF token."""
|
70
|
-
if not self.session_id or not self.csrf_token:
|
71
|
-
self._authenticate()
|
72
|
-
|
73
|
-
return {
|
74
|
-
"X-FTL-SID": self.session_id,
|
75
|
-
"X-FTL-CSRF": self.csrf_token
|
76
|
-
}
|
77
|
-
|
78
|
-
def _do_call(self, method, endpoint, params=None, data=None, files=None, is_binary=False):
|
79
|
-
"""Internal method to send an authenticated request to the Pi-hole API."""
|
80
|
-
url = f"{self.base_url}{endpoint}"
|
81
|
-
headers = self._get_headers()
|
82
|
-
|
83
|
-
# Convert dictionary to form-encoded string if sending multipart
|
84
|
-
request_data = None if files else data
|
85
|
-
form_data = data if files else None # Ensure correct encoding
|
86
|
-
|
87
|
-
response = requests.request(
|
88
|
-
method,
|
89
|
-
url,
|
90
|
-
headers=headers,
|
91
|
-
params=params,
|
92
|
-
json=request_data,
|
93
|
-
files=files,
|
94
|
-
data=form_data,
|
95
|
-
verify=False
|
96
|
-
)
|
97
|
-
|
98
|
-
if response.status_code == 401:
|
99
|
-
self._authenticate()
|
100
|
-
headers = self._get_headers()
|
101
|
-
response = requests.request(
|
102
|
-
method,
|
103
|
-
url,
|
104
|
-
headers=headers,
|
105
|
-
params=params,
|
106
|
-
json=request_data,
|
107
|
-
files=files,
|
108
|
-
data=form_data,
|
109
|
-
verify=False
|
110
|
-
)
|
111
|
-
|
112
|
-
# Handle 4xx responses gracefully
|
113
|
-
if 400 <= response.status_code < 500:
|
114
|
-
try:
|
115
|
-
return response.json()
|
116
|
-
except requests.exceptions.JSONDecodeError:
|
117
|
-
return {"error": f"HTTP {response.status_code}: {response.reason}"}
|
118
|
-
|
119
|
-
response.raise_for_status()
|
120
|
-
|
121
|
-
if is_binary:
|
122
|
-
return response.content # Return raw binary content (e.g., for file exports)
|
123
|
-
|
124
|
-
if not response.content.strip():
|
125
|
-
return {} # Handle empty response
|
126
|
-
|
127
|
-
try:
|
128
|
-
return response.json() # Attempt to parse JSON
|
129
|
-
except requests.exceptions.JSONDecodeError:
|
130
|
-
return response.text # Return raw text as fallback
|
131
|
-
|
132
|
-
def get(self, endpoint, params=None, is_binary=False):
|
133
|
-
"""Send a GET request."""
|
134
|
-
return self._do_call("GET", endpoint, params=params, is_binary=is_binary)
|
135
|
-
|
136
|
-
def post(self, endpoint, data=None, files=None):
|
137
|
-
"""Send a POST request."""
|
138
|
-
return self._do_call("POST", endpoint, data=data, files=files)
|
139
|
-
|
140
|
-
def put(self, endpoint, data=None):
|
141
|
-
"""Send a PUT request."""
|
142
|
-
return self._do_call("PUT", endpoint, data=data)
|
143
|
-
|
144
|
-
def delete(self, endpoint, params=None, data=None):
|
145
|
-
"""Send a DELETE request."""
|
146
|
-
return self._do_call("DELETE", endpoint, params=params, data=data)
|
147
|
-
|
148
|
-
def patch(self, endpoint, data=None):
|
149
|
-
"""Send a PATCH request."""
|
150
|
-
return self._do_call("PATCH", endpoint, data=data)
|
151
|
-
|
152
|
-
def exit(self):
|
153
|
-
"""Delete the current session."""
|
154
|
-
response = self.delete("auth")
|
155
|
-
|
156
|
-
# Clear stored session info
|
157
|
-
self.session_id = None
|
158
|
-
self.csrf_token = None
|
159
|
-
self.validity = None
|
160
|
-
|
161
|
-
return response
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|