pihole6api 0.1.8__tar.gz → 0.2.0__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.2.0}/PKG-INFO +9 -1
  2. {pihole6api-0.1.8 → pihole6api-0.2.0}/README.md +8 -0
  3. {pihole6api-0.1.8 → pihole6api-0.2.0}/pyproject.toml +1 -1
  4. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/client.py +16 -1
  5. pihole6api-0.2.0/src/pihole6api/conn.py +231 -0
  6. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/domain_management.py +15 -0
  7. {pihole6api-0.1.8 → pihole6api-0.2.0/src/pihole6api.egg-info}/PKG-INFO +9 -1
  8. pihole6api-0.1.8/src/pihole6api/conn.py +0 -161
  9. {pihole6api-0.1.8 → pihole6api-0.2.0}/LICENSE +0 -0
  10. {pihole6api-0.1.8 → pihole6api-0.2.0}/setup.cfg +0 -0
  11. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/__init__.py +0 -0
  12. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/actions.py +0 -0
  13. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/client_management.py +0 -0
  14. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/config.py +0 -0
  15. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/dhcp.py +0 -0
  16. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/dns_control.py +0 -0
  17. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/ftl_info.py +0 -0
  18. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/group_management.py +0 -0
  19. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/list_management.py +0 -0
  20. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/metrics.py +0 -0
  21. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api/network_info.py +0 -0
  22. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api.egg-info/SOURCES.txt +0 -0
  23. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api.egg-info/dependency_links.txt +0 -0
  24. {pihole6api-0.1.8 → pihole6api-0.2.0}/src/pihole6api.egg-info/requires.txt +0 -0
  25. {pihole6api-0.1.8 → pihole6api-0.2.0}/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.2.0
4
4
  Summary: Python API Client for Pi-hole 6
5
5
  Author-email: Shane Barbetta <shane@barbetta.me>
6
6
  License: MIT
@@ -132,6 +132,14 @@ client.actions.restart_dns()
132
132
  | `network_info` | View network devices, interfaces, routes |
133
133
  | `actions` | Flush logs, restart services |
134
134
 
135
+ ## Contributing
136
+
137
+ Please check [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
138
+
139
+ ## Changelog
140
+
141
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
142
+
135
143
  ## License
136
144
 
137
145
  This project is license under the [MIT license](LICENSE).
@@ -106,6 +106,14 @@ client.actions.restart_dns()
106
106
  | `network_info` | View network devices, interfaces, routes |
107
107
  | `actions` | Flush logs, restart services |
108
108
 
109
+ ## Contributing
110
+
111
+ Please check [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
112
+
113
+ ## Changelog
114
+
115
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
116
+
109
117
  ## License
110
118
 
111
119
  This project is license under the [MIT license](LICENSE).
@@ -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.2.0"
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"}
@@ -10,6 +10,7 @@ from .config import PiHole6Configuration
10
10
  from .network_info import PiHole6NetworkInfo
11
11
  from .actions import PiHole6Actions
12
12
  from .dhcp import PiHole6Dhcp
13
+ import importlib.metadata
13
14
 
14
15
  class PiHole6Client:
15
16
  def __init__(self, base_url, password):
@@ -45,4 +46,18 @@ class PiHole6Client:
45
46
 
46
47
  def close_session(self):
47
48
  """Close the Pi-hole session by calling the exit method in the connection."""
48
- return self.connection.exit()
49
+ return self.connection.exit()
50
+
51
+ def version(self):
52
+ """
53
+ Get the project information from package metadata.
54
+
55
+ :return: Dictionary containing version, description, and project URL
56
+ """
57
+ metadata = importlib.metadata.metadata("pihole6api")
58
+
59
+ return {
60
+ "version": metadata["Version"],
61
+ "description": metadata["Summary"],
62
+ "project_url": metadata["Project-URL"]
63
+ }
@@ -0,0 +1,231 @@
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"] and data["session"]["validity"] > 0:
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
+ if data.get("session"):
93
+ logger.error(data["session"].get("message", "API failed without message"))
94
+ last_exception = Exception("Authentication failed: Invalid session response")
95
+ else:
96
+ # Try to extract an error message from the response
97
+ try:
98
+ error_msg = response.json().get("session", {}).get("message", "Unknown error")
99
+ except (json.decoder.JSONDecodeError, ValueError):
100
+ error_msg = f"HTTP {response.status_code}: {response.reason}"
101
+ last_exception = Exception(f"Authentication failed: {error_msg}")
102
+ except Exception as e:
103
+ last_exception = e
104
+ logger.warning(f"Authentication attempt {attempt} failed: {str(e)}")
105
+
106
+ if attempt < self.max_retries:
107
+ # Calculate exponential backoff delay
108
+ current_delay = self.retry_delay * (2 ** (attempt - 1))
109
+ logger.debug(f"Retrying authentication in {current_delay} seconds...")
110
+ time.sleep(current_delay)
111
+
112
+ # All attempts failed; raise the last captured exception.
113
+ logger.error(f"All authentication attempts failed: {str(last_exception)}")
114
+ raise last_exception
115
+
116
+ def _get_headers(self):
117
+ """Return headers including the authentication SID and CSRF token."""
118
+ if not self.session_id or not self.csrf_token:
119
+ self._authenticate()
120
+
121
+ return {
122
+ "X-FTL-SID": self.session_id,
123
+ "X-FTL-CSRF": self.csrf_token
124
+ }
125
+
126
+ def _do_call(self, method, endpoint, params=None, data=None, files=None, is_binary=False):
127
+ """Internal method to send an authenticated request to the Pi-hole API."""
128
+ url = f"{self.base_url}{endpoint}"
129
+ headers = self._get_headers()
130
+
131
+ # Convert dictionary to form-encoded string if sending multipart
132
+ request_data = None if files else data
133
+ form_data = data if files else None # Ensure correct encoding
134
+
135
+ try:
136
+ logger.debug(f"Sending {method} request to {url}")
137
+ response = self.session.request(
138
+ method,
139
+ url,
140
+ headers=headers,
141
+ params=params,
142
+ json=request_data,
143
+ files=files,
144
+ data=form_data,
145
+ verify=False,
146
+ timeout=self.connection_timeout
147
+ )
148
+
149
+ if response.status_code == 401:
150
+ logger.warning("Session expired, re-authenticating")
151
+ self._authenticate()
152
+ headers = self._get_headers()
153
+ response = self.session.request(
154
+ method,
155
+ url,
156
+ headers=headers,
157
+ params=params,
158
+ json=request_data,
159
+ files=files,
160
+ data=form_data,
161
+ verify=False,
162
+ timeout=self.connection_timeout
163
+ )
164
+
165
+ # Handle 4xx responses gracefully
166
+ if 400 <= response.status_code < 500:
167
+ try:
168
+ return response.json()
169
+ except requests.exceptions.JSONDecodeError:
170
+ return {"error": f"HTTP {response.status_code}: {response.reason}"}
171
+
172
+ response.raise_for_status()
173
+
174
+ if is_binary:
175
+ return response.content # Return raw binary content (e.g., for file exports)
176
+
177
+ if not response.content.strip():
178
+ return {} # Handle empty response
179
+
180
+ try:
181
+ return response.json() # Attempt to parse JSON
182
+ except requests.exceptions.JSONDecodeError:
183
+ return response.text # Return raw text as fallback
184
+
185
+ except requests.exceptions.ConnectionError as e:
186
+ logger.error(f"Connection error: {str(e)}")
187
+ raise Exception(f"Connection error: {str(e)}")
188
+ except requests.exceptions.Timeout as e:
189
+ logger.error(f"Request timed out: {str(e)}")
190
+ raise Exception(f"Request timed out: {str(e)}")
191
+ except requests.exceptions.RequestException as e:
192
+ logger.error(f"Request error: {str(e)}")
193
+ raise Exception(f"Request error: {str(e)}")
194
+
195
+ def get(self, endpoint, params=None, is_binary=False):
196
+ """Send a GET request."""
197
+ return self._do_call("GET", endpoint, params=params, is_binary=is_binary)
198
+
199
+ def post(self, endpoint, data=None, files=None):
200
+ """Send a POST request."""
201
+ return self._do_call("POST", endpoint, data=data, files=files)
202
+
203
+ def put(self, endpoint, data=None):
204
+ """Send a PUT request."""
205
+ return self._do_call("PUT", endpoint, data=data)
206
+
207
+ def delete(self, endpoint, params=None, data=None):
208
+ """Send a DELETE request."""
209
+ return self._do_call("DELETE", endpoint, params=params, data=data)
210
+
211
+ def patch(self, endpoint, data=None):
212
+ """Send a PATCH request."""
213
+ return self._do_call("PATCH", endpoint, data=data)
214
+
215
+ def exit(self):
216
+ """Delete the current session and close connections."""
217
+ try:
218
+ response = self.delete("auth")
219
+ except Exception as e:
220
+ logger.warning(f"Error during session exit: {str(e)}")
221
+ response = {"error": str(e)}
222
+ finally:
223
+ # Clear stored session info
224
+ self.session_id = None
225
+ self.csrf_token = None
226
+ self.validity = None
227
+
228
+ # Close the session to release connections
229
+ self.session.close()
230
+
231
+ return response
@@ -85,3 +85,18 @@ class PiHole6DomainManagement:
85
85
  :param kind: Kind of domain ("exact" or "regex")
86
86
  """
87
87
  return self.connection.delete(f"domains/{domain_type}/{kind}/{domain}")
88
+
89
+ def get_all_domains(self):
90
+ """
91
+ Get all white and blacklisted domains
92
+ """
93
+ try:
94
+ # Get whiteliste domains
95
+ whitelist = self.connection.get("domains/allow/exact")
96
+ # Get blackliste domains
97
+ blacklist = self.connection.get("domains/deny/exact")
98
+
99
+ return {"whitelist": whitelist, "blacklist": blacklist}
100
+ except Exception as e:
101
+ print(f"Error fetching domains: {e}")
102
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pihole6api
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Python API Client for Pi-hole 6
5
5
  Author-email: Shane Barbetta <shane@barbetta.me>
6
6
  License: MIT
@@ -132,6 +132,14 @@ client.actions.restart_dns()
132
132
  | `network_info` | View network devices, interfaces, routes |
133
133
  | `actions` | Flush logs, restart services |
134
134
 
135
+ ## Contributing
136
+
137
+ Please check [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
138
+
139
+ ## Changelog
140
+
141
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
142
+
135
143
  ## License
136
144
 
137
145
  This project is license under the [MIT license](LICENSE).
@@ -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