pihole6api 0.1.8__py3-none-any.whl → 0.2.0__py3-none-any.whl

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/client.py CHANGED
@@ -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
+ }
pihole6api/conn.py CHANGED
@@ -4,65 +4,113 @@ from urllib.parse import urljoin
4
4
  import warnings
5
5
  import time
6
6
  import json
7
+ import logging
8
+ from requests.adapters import HTTPAdapter
9
+ from urllib3.util.retry import Retry
7
10
 
8
11
  # Suppress InsecureRequestWarning
9
12
  urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
10
13
  warnings.simplefilter("ignore", category=urllib3.exceptions.InsecureRequestWarning)
11
14
 
15
+ # Configure logging
16
+ logger = logging.getLogger("pihole6api")
17
+
12
18
  class PiHole6Connection:
13
- def __init__(self, base_url, password):
19
+ def __init__(self, base_url, password, max_retries=3, retry_delay=1,
20
+ connection_timeout=10, disable_connection_pooling=False):
14
21
  """
15
22
  Initialize the Pi-hole connection client.
16
23
 
17
24
  :param base_url: The base URL of the Pi-hole API (e.g., "http://pi.hole/api/")
18
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
19
30
  """
20
31
  self.base_url = base_url.rstrip("/") + "/api/"
21
32
  self.password = password
22
33
  self.session_id = None
23
34
  self.csrf_token = None
24
35
  self.validity = None
25
-
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
+
26
66
  # Authenticate upon initialization
27
67
  self._authenticate()
28
68
 
29
69
  def _authenticate(self):
30
70
  """Authenticate with the Pi-hole API and store session ID and CSRF token.
31
71
 
32
- Retries up to three times (with a one-second pause between attempts)
33
- before raising an exception.
72
+ Retries up to max_retries times with exponential backoff before raising an exception.
34
73
  """
35
74
  auth_url = urljoin(self.base_url, "auth")
36
75
  payload = {"password": self.password}
37
- max_attempts = 3
38
76
  last_exception = None
39
77
 
40
- for attempt in range(1, max_attempts + 1):
41
- response = requests.post(auth_url, json=payload, verify=False)
78
+ for attempt in range(1, self.max_retries + 1):
42
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
+
43
83
  if response.status_code == 200:
44
84
  data = response.json()
45
- if "session" in data and data["session"]["valid"]:
85
+ if "session" in data and data["session"]["valid"] and data["session"]["validity"] > 0:
46
86
  self.session_id = data["session"]["sid"]
47
87
  self.csrf_token = data["session"]["csrf"]
48
88
  self.validity = data["session"]["validity"]
89
+ logger.debug("Authentication successful")
49
90
  return # Successful authentication
50
91
  else:
92
+ if data.get("session"):
93
+ logger.error(data["session"].get("message", "API failed without message"))
51
94
  last_exception = Exception("Authentication failed: Invalid session response")
52
95
  else:
53
96
  # Try to extract an error message from the response
54
97
  try:
55
- error_msg = response.json().get("error", {}).get("message", "Unknown error")
98
+ error_msg = response.json().get("session", {}).get("message", "Unknown error")
56
99
  except (json.decoder.JSONDecodeError, ValueError):
57
100
  error_msg = f"HTTP {response.status_code}: {response.reason}"
58
101
  last_exception = Exception(f"Authentication failed: {error_msg}")
59
102
  except Exception as e:
60
103
  last_exception = e
104
+ logger.warning(f"Authentication attempt {attempt} failed: {str(e)}")
61
105
 
62
- if attempt < max_attempts:
63
- time.sleep(1) # Pause before retrying
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)
64
111
 
65
112
  # All attempts failed; raise the last captured exception.
113
+ logger.error(f"All authentication attempts failed: {str(last_exception)}")
66
114
  raise last_exception
67
115
 
68
116
  def _get_headers(self):
@@ -84,21 +132,9 @@ class PiHole6Connection:
84
132
  request_data = None if files else data
85
133
  form_data = data if files else None # Ensure correct encoding
86
134
 
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(
135
+ try:
136
+ logger.debug(f"Sending {method} request to {url}")
137
+ response = self.session.request(
102
138
  method,
103
139
  url,
104
140
  headers=headers,
@@ -106,28 +142,55 @@ class PiHole6Connection:
106
142
  json=request_data,
107
143
  files=files,
108
144
  data=form_data,
109
- verify=False
145
+ verify=False,
146
+ timeout=self.connection_timeout
110
147
  )
111
148
 
112
- # Handle 4xx responses gracefully
113
- if 400 <= response.status_code < 500:
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
+
114
180
  try:
115
- return response.json()
181
+ return response.json() # Attempt to parse JSON
116
182
  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
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)}")
131
194
 
132
195
  def get(self, endpoint, params=None, is_binary=False):
133
196
  """Send a GET request."""
@@ -150,12 +213,19 @@ class PiHole6Connection:
150
213
  return self._do_call("PATCH", endpoint, data=data)
151
214
 
152
215
  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
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()
160
230
 
161
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,19 +1,19 @@
1
1
  pihole6api/__init__.py,sha256=OKDAH2I6UjXcBmcj6rn5aNg5J60GCUBVFJ-_t83GiVQ,898
2
2
  pihole6api/actions.py,sha256=8CBkr8nYfT8yfdCO6F9M9nompaYcFdsaYGiEa1eVDCw,693
3
- pihole6api/client.py,sha256=HYdRh3CSZJ0srbkpjIVnLo-iy1avqKDUne5ji2Aq394,2013
3
+ pihole6api/client.py,sha256=8sco9wFCAmF1WTRG72W5Af4fZIoTjvCXXmj-O51MY88,2472
4
4
  pihole6api/client_management.py,sha256=n8Ra_6pRL3Q9jlIK3kIK9bmh74MTfxgYnOHYaUwYGNs,2590
5
5
  pihole6api/config.py,sha256=NdBHOudz147oIs5YVR3U4WLvqk3hU3HlZHnshy1NK4g,4680
6
- pihole6api/conn.py,sha256=60Q9paDCD6Bwmdg_G9mOUr86Hzj9G-dhMwL7F3NvLB8,5837
6
+ pihole6api/conn.py,sha256=2JYPgHUmhPSuNdrMaWkEtf2lnqDJ8--g05CYntOBJvQ,9523
7
7
  pihole6api/dhcp.py,sha256=1A3z-3q9x51-6MOC3JMl7yR_5pHmRxZtMWtPqzWxYm0,629
8
8
  pihole6api/dns_control.py,sha256=mxV3AIuGCsx0-1ibpMXor9QUGd_fDFfeaUENPhIK_TY,853
9
- pihole6api/domain_management.py,sha256=vxhQSG5F8EFDGqtiNkF0H_KOWFMerXaAuJZT0nMa8ec,3492
9
+ pihole6api/domain_management.py,sha256=5oJebojAAEEvZtJgxeuK4FazcMxmiB1NpAgStqdUv-E,3988
10
10
  pihole6api/ftl_info.py,sha256=FINHFotI1sQgkL0OPaNqW-rk4h1ua6QHnRh5gFXXRIE,3210
11
11
  pihole6api/group_management.py,sha256=Iip2Na4pUYgC5K9cwOWv_OPD6qdXRzWCmlS-fJlBMjU,2372
12
12
  pihole6api/list_management.py,sha256=CooTeF4EmNPFwnzGoLbHFMhCePJ-ojvRGx7Sxzayy-U,4170
13
13
  pihole6api/metrics.py,sha256=QWqV_7SytMPGEBEZOxsIfxTa6lM9Ksrvlf2BXjDKZmA,7991
14
14
  pihole6api/network_info.py,sha256=E1qQ7DsCb7qplt0oQIyKhUY12ExNF6bv6thm9rbNqwY,1953
15
- pihole6api-0.1.8.dist-info/licenses/LICENSE,sha256=hpO6J6J9O1VZxZeHQTxKMTmuobaHbApiZxp279I4xNU,1062
16
- pihole6api-0.1.8.dist-info/METADATA,sha256=LqrKT8Vpxgqtithi4sPa80xMEIBWmpKGUrAYN7G3nyQ,3790
17
- pihole6api-0.1.8.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
18
- pihole6api-0.1.8.dist-info/top_level.txt,sha256=Qrh46lxEC54rBR8T53em-tuZLWbmi1SDwL1rOhsgrME,11
19
- pihole6api-0.1.8.dist-info/RECORD,,
15
+ pihole6api-0.2.0.dist-info/licenses/LICENSE,sha256=hpO6J6J9O1VZxZeHQTxKMTmuobaHbApiZxp279I4xNU,1062
16
+ pihole6api-0.2.0.dist-info/METADATA,sha256=oL9701CfySKOOVdWglV7rEmpVBAMMRAr_KSckcTFyGw,3956
17
+ pihole6api-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
+ pihole6api-0.2.0.dist-info/top_level.txt,sha256=Qrh46lxEC54rBR8T53em-tuZLWbmi1SDwL1rOhsgrME,11
19
+ pihole6api-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5