vhi-python 0.1.3__tar.gz → 0.1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vhi-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A Python client library for the Vhi API, handling authentication, MFA callbacks, claims retrieval, and document downloads.
5
5
  Author-email: Agent <agent@example.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vhi-python"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "A Python client library for the Vhi API, handling authentication, MFA callbacks, claims retrieval, and document downloads."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -7,5 +7,5 @@ __all__ = [
7
7
  "VhiAuthenticationError",
8
8
  "VhiMfaRequiredError",
9
9
  "VhiApiError",
10
- "ClaimStatement"
10
+ "ClaimStatement",
11
11
  ]
@@ -1,45 +1,104 @@
1
1
  import requests
2
- import os
3
2
  import uuid
4
3
  import json
4
+ import pickle
5
+ import hashlib
6
+ from pathlib import Path
5
7
  from typing import Callable, Optional, List
6
8
  from .exceptions import VhiAuthenticationError, VhiMfaRequiredError, VhiApiError
7
9
  from .models import ClaimStatement
8
10
 
11
+
9
12
  class VhiClient:
10
13
  """
11
14
  A client library for interacting with the Vhi API.
12
15
  Handles authentication, MFA callbacks, claims retrieval, and document downloads.
13
16
  """
14
- DEFAULT_CONFIG_URL = "https://www.vhi.ie/myvhi/myclaimstatements" # Usually embedded here
15
-
16
- def __init__(self, username: str, password: str, mfa_callback: Optional[Callable[[], str]] = None):
17
+
18
+ DEFAULT_CONFIG_URL = (
19
+ "https://www.vhi.ie/myvhi/myclaimstatements" # Usually embedded here
20
+ )
21
+
22
+ def __init__(
23
+ self,
24
+ username: str,
25
+ password: str,
26
+ mfa_callback: Optional[Callable[[], str]] = None,
27
+ cache_session: bool = True,
28
+ ):
17
29
  """
18
30
  Initialize the VhiClient.
19
-
31
+
20
32
  Args:
21
33
  username: User's email address.
22
34
  password: User's password.
23
35
  mfa_callback: A callable that returns the 2FA OTP string when invoked.
36
+ cache_session: Whether to cache session cookies locally to prevent re-authentication.
24
37
  """
25
38
  self.username = username
26
39
  self.password = password
27
40
  self.mfa_callback = mfa_callback
28
-
41
+ self.cache_session = cache_session
42
+
29
43
  # State management
30
44
  self.session = requests.Session()
31
-
45
+
32
46
  # Default Browser Headers to mimic browser and avoid WAF
33
- self.session.headers.update({
34
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
35
- "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
36
- "sec-ch-ua-mobile": "?0",
37
- "sec-ch-ua-platform": '"Windows"',
38
- })
39
-
47
+ self.session.headers.update(
48
+ {
49
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
50
+ "sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
51
+ "sec-ch-ua-mobile": "?0",
52
+ "sec-ch-ua-platform": '"Windows"',
53
+ }
54
+ )
55
+
40
56
  self.is_authenticated = False
41
- self.apis_base_url = "https://apis.vhi.ie" # Will be updated during discovery if necessary
42
-
57
+ self.apis_base_url = (
58
+ "https://apis.vhi.ie" # Will be updated during discovery if necessary
59
+ )
60
+
61
+ def _get_cache_path(self) -> Path:
62
+ user_hash = hashlib.md5(self.username.encode("utf-8")).hexdigest()
63
+ cache_dir = Path.home() / ".vhi"
64
+ cache_dir.mkdir(exist_ok=True)
65
+ return cache_dir / f"session_{user_hash}.pkl"
66
+
67
+ def _save_session(self):
68
+ if not self.cache_session:
69
+ return
70
+ with open(self._get_cache_path(), "wb") as f:
71
+ pickle.dump(self.session.cookies, f)
72
+
73
+ def _load_session(self) -> bool:
74
+ if not self.cache_session:
75
+ return False
76
+ cache_path = self._get_cache_path()
77
+ if cache_path.exists():
78
+ try:
79
+ with open(cache_path, "rb") as f:
80
+ self.session.cookies = pickle.load(f)
81
+ return True
82
+ except Exception:
83
+ pass
84
+ return False
85
+
86
+ def _is_session_valid(self) -> bool:
87
+ try:
88
+ url = f"{self.apis_base_url}/claims/v1/statements"
89
+ headers = {
90
+ "Accept": "application/json",
91
+ "Origin": "https://app.vhi.ie",
92
+ "Referer": "https://app.vhi.ie/",
93
+ "Sec-Fetch-Mode": "cors",
94
+ "Sec-Fetch-Site": "same-site",
95
+ }
96
+ # Only test to see if 401 is returned
97
+ response = self.session.get(url, headers=headers, timeout=5)
98
+ return response.status_code == 200
99
+ except Exception:
100
+ return False
101
+
43
102
  def _fetch_environment_config(self):
44
103
  """
45
104
  Optional: Dynamically discovers the API base URL.
@@ -56,6 +115,13 @@ class VhiClient:
56
115
  """
57
116
  Performs the login flow. Handles the MFA challenge if required.
58
117
  """
118
+ if self._load_session():
119
+ if self._is_session_valid():
120
+ self.is_authenticated = True
121
+ return
122
+ else:
123
+ self.session.cookies.clear()
124
+
59
125
  # Set proper fetch headers mimicking trace exactly
60
126
  headers = {
61
127
  "accept": "application/json, text/plain, */*",
@@ -70,96 +136,129 @@ class VhiClient:
70
136
  "sec-fetch-dest": "empty",
71
137
  "sec-fetch-mode": "cors",
72
138
  "sec-fetch-site": "same-site",
73
- "user-session-id": str(uuid.uuid4())
74
- }
75
-
76
- payload = {
77
- "username": self.username,
78
- "usercred": self.password
139
+ "user-session-id": str(uuid.uuid4()),
79
140
  }
80
-
141
+
142
+ payload = {"username": self.username, "usercred": self.password}
143
+
81
144
  # Format strictly without spaces to bypass strict WAF parsers
82
- raw_payload = json.dumps(payload, separators=(',', ':'))
83
-
145
+ raw_payload = json.dumps(payload, separators=(",", ":"))
146
+
84
147
  # 1. Primary Authentication
85
148
  login_url = f"{self.apis_base_url}/api/myvhilogin/login"
86
-
149
+
87
150
  response = self.session.post(login_url, data=raw_payload, headers=headers)
88
-
151
+
89
152
  if response.status_code == 200:
90
153
  try:
91
154
  data = response.json()
92
155
  except ValueError:
93
- raise VhiApiError("Login response was not valid JSON", status_code=response.status_code)
94
-
156
+ raise VhiApiError(
157
+ "Login response was not valid JSON",
158
+ status_code=response.status_code,
159
+ )
160
+
95
161
  # The HAR trace uses a deeply nested response structure or a flattened one depending on Gateway
96
162
  status_val = data.get("status") or data.get("data", {}).get("status")
97
-
163
+
98
164
  if status_val == "MFA_REQUIRED":
99
- state_token = data.get("stateToken") or data.get("data", {}).get("stateToken")
100
-
165
+ state_token = data.get("stateToken") or data.get("data", {}).get(
166
+ "stateToken"
167
+ )
168
+
101
169
  # Attempt to extract the verify url from the first factor
102
170
  try:
103
- factors = data.get("factors") or data.get("data", {}).get("_embedded", {}).get("factors", [])
171
+ factors = data.get("factors") or data.get("data", {}).get(
172
+ "_embedded", {}
173
+ ).get("factors", [])
104
174
  verify_url = factors[0]["_links"]["verify"]["href"]
105
175
  except (IndexError, KeyError):
106
176
  # Fallback URL if extraction fails
107
- verify_url = f"https://admin-digital.vhi.ie/api/v1/authn/factors/verify"
108
-
177
+ verify_url = (
178
+ "https://admin-digital.vhi.ie/api/v1/authn/factors/verify"
179
+ )
180
+
109
181
  if not state_token:
110
- raise VhiApiError("MFA required but no state_token provided in response")
111
-
182
+ raise VhiApiError(
183
+ "MFA required but no state_token provided in response"
184
+ )
185
+
112
186
  self._handle_mfa_challenge(state_token, verify_url, headers)
113
187
  else:
114
188
  self.is_authenticated = True
189
+ self._save_session()
115
190
  return
116
-
191
+
117
192
  elif response.status_code == 401:
118
193
  raise VhiAuthenticationError("Invalid credentials provided.")
119
194
  else:
120
- raise VhiApiError(f"Login failed with status {response.status_code}: {response.text}", status_code=response.status_code, response=response)
121
-
122
- def _handle_mfa_challenge(self, state_token: str, verify_url: str, base_headers: dict):
195
+ raise VhiApiError(
196
+ f"Login failed with status {response.status_code}: {response.text}",
197
+ status_code=response.status_code,
198
+ response=response,
199
+ )
200
+
201
+ def _handle_mfa_challenge(
202
+ self, state_token: str, verify_url: str, base_headers: dict
203
+ ):
123
204
  """
124
205
  Internal method to submit the MFA code.
125
206
  """
126
207
  if not self.mfa_callback:
127
- raise VhiMfaRequiredError("MFA is required but no mfa_callback was provided to VhiClient.")
128
-
208
+ raise VhiMfaRequiredError(
209
+ "MFA is required but no mfa_callback was provided to VhiClient."
210
+ )
211
+
212
+ # 1. Trigger the SMS / challenge
213
+ trigger_payload = {"stateToken": state_token}
214
+ trigger_raw_payload = json.dumps(trigger_payload, separators=(",", ":"))
215
+ trigger_response = self.session.post(
216
+ verify_url, data=trigger_raw_payload, headers=base_headers
217
+ )
218
+
219
+ if trigger_response.status_code != 200:
220
+ raise VhiApiError(
221
+ f"Failed to trigger MFA challenge. Status: {trigger_response.status_code} - {trigger_response.text}"
222
+ )
223
+
129
224
  # Pause execution and invoke the user-defined callback to get the OTP
130
225
  otp_code = self.mfa_callback()
131
-
226
+
132
227
  if not otp_code:
133
- raise VhiAuthenticationError("MFA callback did not return a valid OTP code.")
134
-
135
- payload = {
136
- "passCode": otp_code,
137
- "stateToken": state_token
138
- }
139
-
140
- response = self.session.post(verify_url, json=payload, headers=base_headers)
141
-
228
+ raise VhiAuthenticationError(
229
+ "MFA callback did not return a valid OTP code."
230
+ )
231
+
232
+ payload = {"passCode": otp_code, "stateToken": state_token}
233
+
234
+ raw_payload = json.dumps(payload, separators=(",", ":"))
235
+ response = self.session.post(verify_url, data=raw_payload, headers=base_headers)
236
+
142
237
  if response.status_code == 200:
143
238
  self.is_authenticated = True
144
-
239
+ self._save_session()
145
240
  # The session cookie jar has now been populated with the authorized session
146
241
  # Or we might need to extract a Bearer token. This implementation relies
147
242
  # on cookies being correctly set via Set-Cookie headers by the API.
148
243
  else:
149
- raise VhiAuthenticationError(f"MFA verification failed. Status: {response.status_code}")
244
+ raise VhiAuthenticationError(
245
+ f"MFA verification failed. Status: {response.status_code} - {response.text}"
246
+ )
150
247
 
151
248
  def get_claims(self) -> List[ClaimStatement]:
152
249
  """
153
250
  Retrieves the list of claim statements.
154
-
251
+
155
252
  Returns:
156
253
  List of ClaimStatement objects.
157
254
  """
158
255
  if not self.is_authenticated:
159
- raise VhiAuthenticationError("Client is not authenticated. Call login() first.")
160
-
256
+ raise VhiAuthenticationError(
257
+ "Client is not authenticated. Call login() first."
258
+ )
259
+
161
260
  url = f"{self.apis_base_url}/claims/v1/statements"
162
-
261
+
163
262
  headers = {
164
263
  "Accept": "application/json",
165
264
  "Origin": "https://www.vhi.ie",
@@ -167,36 +266,45 @@ class VhiClient:
167
266
  "Sec-Fetch-Mode": "cors",
168
267
  "Sec-Fetch-Site": "same-site",
169
268
  }
170
-
269
+
171
270
  response = self.session.get(url, headers=headers)
172
-
271
+
173
272
  if response.status_code == 200:
174
273
  try:
175
274
  data = response.json()
176
275
  except ValueError:
177
- raise VhiApiError("Invalid JSON received for claims statements.", status_code=response.status_code)
178
-
276
+ raise VhiApiError(
277
+ "Invalid JSON received for claims statements.",
278
+ status_code=response.status_code,
279
+ )
280
+
179
281
  claims_data = data.get("claims", [])
180
282
  return [ClaimStatement.from_dict(claim) for claim in claims_data]
181
283
  elif response.status_code == 401:
182
284
  self.is_authenticated = False
183
285
  raise VhiAuthenticationError("Session expired or unauthorized.")
184
286
  else:
185
- raise VhiApiError(f"Failed to fetch claims: {response.status_code}", status_code=response.status_code, response=response)
186
-
287
+ raise VhiApiError(
288
+ f"Failed to fetch claims: {response.status_code}",
289
+ status_code=response.status_code,
290
+ response=response,
291
+ )
292
+
187
293
  def download_document(self, document_id: str, dest_path: str):
188
294
  """
189
295
  Streams a claim PDF document to the local disk.
190
-
296
+
191
297
  Args:
192
298
  document_id: The ID of the document to download.
193
299
  dest_path: The file path where the PDF should be saved.
194
300
  """
195
301
  if not self.is_authenticated:
196
- raise VhiAuthenticationError("Client is not authenticated. Call login() first.")
197
-
302
+ raise VhiAuthenticationError(
303
+ "Client is not authenticated. Call login() first."
304
+ )
305
+
198
306
  url = f"{self.apis_base_url}/documents/v1/{document_id}/download"
199
-
307
+
200
308
  headers = {
201
309
  "Accept": "application/pdf",
202
310
  "Origin": "https://www.vhi.ie",
@@ -204,16 +312,22 @@ class VhiClient:
204
312
  "Sec-Fetch-Mode": "cors",
205
313
  "Sec-Fetch-Site": "same-site",
206
314
  }
207
-
315
+
208
316
  # Use stream=True to prevent loading large PDFs into memory
209
317
  with self.session.get(url, headers=headers, stream=True) as response:
210
318
  if response.status_code == 200:
211
- with open(dest_path, 'wb') as f:
319
+ with open(dest_path, "wb") as f:
212
320
  for chunk in response.iter_content(chunk_size=8192):
213
321
  if chunk:
214
322
  f.write(chunk)
215
323
  elif response.status_code == 401:
216
324
  self.is_authenticated = False
217
- raise VhiAuthenticationError("Session expired or unauthorized while downloading document.")
325
+ raise VhiAuthenticationError(
326
+ "Session expired or unauthorized while downloading document."
327
+ )
218
328
  else:
219
- raise VhiApiError(f"Failed to download document {document_id}: {response.status_code}", status_code=response.status_code, response=response)
329
+ raise VhiApiError(
330
+ f"Failed to download document {document_id}: {response.status_code}",
331
+ status_code=response.status_code,
332
+ response=response,
333
+ )
@@ -1,17 +1,24 @@
1
1
  class VhiError(Exception):
2
2
  """Base exception for Vhi Python Client"""
3
+
3
4
  pass
4
5
 
6
+
5
7
  class VhiAuthenticationError(VhiError):
6
8
  """Raised when authentication fails (invalid credentials)"""
9
+
7
10
  pass
8
11
 
12
+
9
13
  class VhiMfaRequiredError(VhiError):
10
14
  """Raised when MFA is required but no callback is provided or callback fails"""
15
+
11
16
  pass
12
17
 
18
+
13
19
  class VhiApiError(VhiError):
14
20
  """Raised when the Vhi API returns an error response"""
21
+
15
22
  def __init__(self, message, status_code=None, response=None):
16
23
  super().__init__(message)
17
24
  self.status_code = status_code
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass
2
- from typing import Optional
2
+
3
3
 
4
4
  @dataclass
5
5
  class ClaimStatement:
@@ -8,7 +8,7 @@ class ClaimStatement:
8
8
  practitioner: str
9
9
  document_id: str
10
10
  status: str
11
-
11
+
12
12
  @classmethod
13
13
  def from_dict(cls, data: dict) -> "ClaimStatement":
14
14
  return cls(
@@ -16,5 +16,5 @@ class ClaimStatement:
16
16
  date_of_service=data.get("dateOfService", ""),
17
17
  practitioner=data.get("practitioner", ""),
18
18
  document_id=data.get("documentId", ""),
19
- status=data.get("status", "")
19
+ status=data.get("status", ""),
20
20
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vhi-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A Python client library for the Vhi API, handling authentication, MFA callbacks, claims retrieval, and document downloads.
5
5
  Author-email: Agent <agent@example.com>
6
6
  License: MIT
@@ -3,70 +3,90 @@ import responses
3
3
  import os
4
4
  import tempfile
5
5
  from vhi.client import VhiClient
6
- from vhi.exceptions import VhiAuthenticationError, VhiMfaRequiredError, VhiApiError
6
+ from vhi.exceptions import VhiAuthenticationError
7
+
7
8
 
8
9
  @responses.activate
9
10
  def test_login_success_no_mfa():
10
- client = VhiClient("test@example.com", "password")
11
-
11
+ client = VhiClient("test@example.com", "password", cache_session=False)
12
+
12
13
  responses.add(
13
14
  responses.POST,
14
15
  "https://apis.vhi.ie/api/myvhilogin/login",
15
16
  json={"status": "SUCCESS", "sessionToken": "test-token"},
16
- status=200
17
+ status=200,
17
18
  )
18
-
19
+
19
20
  client.login()
20
21
  assert client.is_authenticated is True
21
22
 
23
+
22
24
  @responses.activate
23
25
  def test_login_requires_mfa_success():
24
26
  def mfa_callback():
25
27
  return "123456"
26
-
27
- client = VhiClient("test@example.com", "password", mfa_callback=mfa_callback)
28
-
28
+
29
+ client = VhiClient(
30
+ "test@example.com", "password", mfa_callback=mfa_callback, cache_session=False
31
+ )
32
+
29
33
  # Mock primary login returning 200 with MFA_REQUIRED
30
34
  responses.add(
31
35
  responses.POST,
32
36
  "https://apis.vhi.ie/api/myvhilogin/login",
33
37
  json={
34
- "status": "MFA_REQUIRED",
38
+ "status": "MFA_REQUIRED",
35
39
  "stateToken": "state-123",
36
- "factors": [{"_links": {"verify": {"href": "https://admin-digital.vhi.ie/api/v1/authn/factors/verify"}}}]
40
+ "factors": [
41
+ {
42
+ "_links": {
43
+ "verify": {
44
+ "href": "https://admin-digital.vhi.ie/api/v1/authn/factors/verify"
45
+ }
46
+ }
47
+ }
48
+ ],
37
49
  },
38
- status=200
50
+ status=200,
51
+ )
52
+
53
+ # Mock MFA trigger returning 200 CHALLENGE
54
+ responses.add(
55
+ responses.POST,
56
+ "https://admin-digital.vhi.ie/api/v1/authn/factors/verify",
57
+ json={"status": "MFA_CHALLENGE"},
58
+ status=200,
39
59
  )
40
-
60
+
41
61
  # Mock MFA verify returning 200 SUCCESS
42
62
  responses.add(
43
63
  responses.POST,
44
64
  "https://admin-digital.vhi.ie/api/v1/authn/factors/verify",
45
65
  json={"status": "SUCCESS", "sessionToken": "final-token"},
46
- status=200
66
+ status=200,
47
67
  )
48
-
68
+
49
69
  client.login()
50
70
  assert client.is_authenticated is True
51
71
 
72
+
52
73
  @responses.activate
53
74
  def test_login_invalid_credentials():
54
- client = VhiClient("test@example.com", "wrong")
55
-
75
+ client = VhiClient("test@example.com", "wrong", cache_session=False)
76
+
56
77
  responses.add(
57
- responses.POST,
58
- "https://apis.vhi.ie/api/myvhilogin/login",
59
- status=401
78
+ responses.POST, "https://apis.vhi.ie/api/myvhilogin/login", status=401
60
79
  )
61
-
80
+
62
81
  with pytest.raises(VhiAuthenticationError):
63
82
  client.login()
64
83
 
84
+
65
85
  @responses.activate
66
86
  def test_get_claims_success():
67
- client = VhiClient("test@example.com", "password")
68
- client.is_authenticated = True # Mock existing session
69
-
87
+ client = VhiClient("test@example.com", "password", cache_session=False)
88
+ client.is_authenticated = True # Mock existing session
89
+
70
90
  responses.add(
71
91
  responses.GET,
72
92
  "https://apis.vhi.ie/claims/v1/statements",
@@ -77,36 +97,37 @@ def test_get_claims_success():
77
97
  "dateOfService": "2026-03-15",
78
98
  "practitioner": "Dr. Smith",
79
99
  "documentId": "DOC-123",
80
- "status": "Processed"
100
+ "status": "Processed",
81
101
  }
82
102
  ]
83
103
  },
84
- status=200
104
+ status=200,
85
105
  )
86
-
106
+
87
107
  claims = client.get_claims()
88
108
  assert len(claims) == 1
89
109
  assert claims[0].claim_id == "CLM-123"
90
110
  assert claims[0].practitioner == "Dr. Smith"
91
111
 
112
+
92
113
  @responses.activate
93
114
  def test_download_document_success():
94
- client = VhiClient("test@example.com", "password")
115
+ client = VhiClient("test@example.com", "password", cache_session=False)
95
116
  client.is_authenticated = True
96
-
117
+
97
118
  pdf_content = b"%PDF-1.4 mock pdf content"
98
-
119
+
99
120
  responses.add(
100
121
  responses.GET,
101
122
  "https://apis.vhi.ie/documents/v1/DOC-123/download",
102
123
  body=pdf_content,
103
124
  status=200,
104
- content_type="application/pdf"
125
+ content_type="application/pdf",
105
126
  )
106
-
127
+
107
128
  with tempfile.NamedTemporaryFile(delete=False) as tmp:
108
129
  dest_path = tmp.name
109
-
130
+
110
131
  try:
111
132
  client.download_document("DOC-123", dest_path)
112
133
  with open(dest_path, "rb") as f:
@@ -3,9 +3,10 @@ import pytest
3
3
  from vhi.client import VhiClient
4
4
  from vhi.exceptions import VhiMfaRequiredError, VhiAuthenticationError
5
5
 
6
+
6
7
  @pytest.mark.skipif(
7
8
  not os.getenv("VHI_USERNAME") or not os.getenv("VHI_PASSWORD"),
8
- reason="Integration tests require VHI_USERNAME and VHI_PASSWORD environment variables"
9
+ reason="Integration tests require VHI_USERNAME and VHI_PASSWORD environment variables",
9
10
  )
10
11
  def test_live_login():
11
12
  """
@@ -14,11 +15,11 @@ def test_live_login():
14
15
  """
15
16
  username = os.environ["VHI_USERNAME"]
16
17
  password = os.environ["VHI_PASSWORD"]
17
-
18
+
18
19
  # We initialize without an mfa_callback to ensure it raises VhiMfaRequiredError
19
20
  # if MFA is prompted, which validates the primary credentials were correct.
20
21
  client = VhiClient(username, password)
21
-
22
+
22
23
  try:
23
24
  client.login()
24
25
  # If it succeeds without MFA, we are authenticated.
@@ -28,4 +29,4 @@ def test_live_login():
28
29
  # This confirms our primary login endpoint and payload reverse-engineering is correct.
29
30
  pass
30
31
  except VhiAuthenticationError as e:
31
- pytest.fail(f"Integration login failed - incorrect credentials: {e}")
32
+ pytest.fail(f"Integration login failed - incorrect credentials: {e}")
File without changes
File without changes