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.
Files changed (25) hide show
  1. {pihole6api-0.1.8/src/pihole6api.egg-info → pihole6api-0.1.9}/PKG-INFO +1 -1
  2. {pihole6api-0.1.8 → pihole6api-0.1.9}/pyproject.toml +1 -1
  3. pihole6api-0.1.9/src/pihole6api/conn.py +229 -0
  4. {pihole6api-0.1.8 → pihole6api-0.1.9/src/pihole6api.egg-info}/PKG-INFO +1 -1
  5. pihole6api-0.1.8/src/pihole6api/conn.py +0 -161
  6. {pihole6api-0.1.8 → pihole6api-0.1.9}/LICENSE +0 -0
  7. {pihole6api-0.1.8 → pihole6api-0.1.9}/README.md +0 -0
  8. {pihole6api-0.1.8 → pihole6api-0.1.9}/setup.cfg +0 -0
  9. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/__init__.py +0 -0
  10. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/actions.py +0 -0
  11. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/client.py +0 -0
  12. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/client_management.py +0 -0
  13. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/config.py +0 -0
  14. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/dhcp.py +0 -0
  15. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/dns_control.py +0 -0
  16. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/domain_management.py +0 -0
  17. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/ftl_info.py +0 -0
  18. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/group_management.py +0 -0
  19. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/list_management.py +0 -0
  20. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/metrics.py +0 -0
  21. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api/network_info.py +0 -0
  22. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/SOURCES.txt +0 -0
  23. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/dependency_links.txt +0 -0
  24. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/requires.txt +0 -0
  25. {pihole6api-0.1.8 → pihole6api-0.1.9}/src/pihole6api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pihole6api
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Python API Client for Pi-hole 6
5
5
  Author-email: Shane Barbetta <shane@barbetta.me>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pihole6api"
7
- version = "0.1.8"
7
+ version = "0.1.9"
8
8
  description = "Python API Client for Pi-hole 6"
9
9
  authors = [{name = "Shane Barbetta", email = "shane@barbetta.me"}]
10
10
  license = {text = "MIT"}
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pihole6api
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Python API Client for Pi-hole 6
5
5
  Author-email: Shane Barbetta <shane@barbetta.me>
6
6
  License: MIT
@@ -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