vhi-python 0.1.2__tar.gz → 0.1.4__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.
- {vhi_python-0.1.2 → vhi_python-0.1.4}/PKG-INFO +1 -1
- {vhi_python-0.1.2 → vhi_python-0.1.4}/pyproject.toml +1 -1
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi/client.py +81 -13
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi_python.egg-info/PKG-INFO +1 -1
- {vhi_python-0.1.2 → vhi_python-0.1.4}/tests/test_client.py +5 -5
- {vhi_python-0.1.2 → vhi_python-0.1.4}/README.md +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/setup.cfg +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi/__init__.py +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi/exceptions.py +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi/models.py +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi_python.egg-info/SOURCES.txt +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi_python.egg-info/dependency_links.txt +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi_python.egg-info/requires.txt +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/src/vhi_python.egg-info/top_level.txt +0 -0
- {vhi_python-0.1.2 → vhi_python-0.1.4}/tests/test_integration.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vhi-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
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 = [
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import requests
|
|
2
2
|
import os
|
|
3
|
+
import uuid
|
|
4
|
+
import json
|
|
5
|
+
import pickle
|
|
6
|
+
import hashlib
|
|
7
|
+
from pathlib import Path
|
|
3
8
|
from typing import Callable, Optional, List
|
|
4
9
|
from .exceptions import VhiAuthenticationError, VhiMfaRequiredError, VhiApiError
|
|
5
10
|
from .models import ClaimStatement
|
|
@@ -11,7 +16,7 @@ class VhiClient:
|
|
|
11
16
|
"""
|
|
12
17
|
DEFAULT_CONFIG_URL = "https://www.vhi.ie/myvhi/myclaimstatements" # Usually embedded here
|
|
13
18
|
|
|
14
|
-
def __init__(self, username: str, password: str, mfa_callback: Optional[Callable[[], str]] = None):
|
|
19
|
+
def __init__(self, username: str, password: str, mfa_callback: Optional[Callable[[], str]] = None, cache_session: bool = True):
|
|
15
20
|
"""
|
|
16
21
|
Initialize the VhiClient.
|
|
17
22
|
|
|
@@ -19,23 +24,68 @@ class VhiClient:
|
|
|
19
24
|
username: User's email address.
|
|
20
25
|
password: User's password.
|
|
21
26
|
mfa_callback: A callable that returns the 2FA OTP string when invoked.
|
|
27
|
+
cache_session: Whether to cache session cookies locally to prevent re-authentication.
|
|
22
28
|
"""
|
|
23
29
|
self.username = username
|
|
24
30
|
self.password = password
|
|
25
31
|
self.mfa_callback = mfa_callback
|
|
32
|
+
self.cache_session = cache_session
|
|
26
33
|
|
|
27
34
|
# State management
|
|
28
35
|
self.session = requests.Session()
|
|
29
36
|
|
|
30
37
|
# Default Browser Headers to mimic browser and avoid WAF
|
|
31
38
|
self.session.headers.update({
|
|
32
|
-
"
|
|
33
|
-
"
|
|
39
|
+
"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",
|
|
40
|
+
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
|
|
41
|
+
"sec-ch-ua-mobile": "?0",
|
|
42
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
34
43
|
})
|
|
35
44
|
|
|
36
45
|
self.is_authenticated = False
|
|
37
46
|
self.apis_base_url = "https://apis.vhi.ie" # Will be updated during discovery if necessary
|
|
38
47
|
|
|
48
|
+
def _get_cache_path(self) -> Path:
|
|
49
|
+
user_hash = hashlib.md5(self.username.encode('utf-8')).hexdigest()
|
|
50
|
+
cache_dir = Path.home() / ".vhi"
|
|
51
|
+
cache_dir.mkdir(exist_ok=True)
|
|
52
|
+
return cache_dir / f"session_{user_hash}.pkl"
|
|
53
|
+
|
|
54
|
+
def _save_session(self):
|
|
55
|
+
if not self.cache_session:
|
|
56
|
+
return
|
|
57
|
+
with open(self._get_cache_path(), 'wb') as f:
|
|
58
|
+
pickle.dump(self.session.cookies, f)
|
|
59
|
+
|
|
60
|
+
def _load_session(self) -> bool:
|
|
61
|
+
if not self.cache_session:
|
|
62
|
+
return False
|
|
63
|
+
cache_path = self._get_cache_path()
|
|
64
|
+
if cache_path.exists():
|
|
65
|
+
try:
|
|
66
|
+
with open(cache_path, 'rb') as f:
|
|
67
|
+
self.session.cookies = pickle.load(f)
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def _is_session_valid(self) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
url = f"{self.apis_base_url}/claims/v1/statements"
|
|
76
|
+
headers = {
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
"Origin": "https://app.vhi.ie",
|
|
79
|
+
"Referer": "https://app.vhi.ie/",
|
|
80
|
+
"Sec-Fetch-Mode": "cors",
|
|
81
|
+
"Sec-Fetch-Site": "same-site",
|
|
82
|
+
}
|
|
83
|
+
# Only test to see if 401 is returned
|
|
84
|
+
response = self.session.get(url, headers=headers, timeout=5)
|
|
85
|
+
return response.status_code == 200
|
|
86
|
+
except Exception:
|
|
87
|
+
return False
|
|
88
|
+
|
|
39
89
|
def _fetch_environment_config(self):
|
|
40
90
|
"""
|
|
41
91
|
Optional: Dynamically discovers the API base URL.
|
|
@@ -52,14 +102,28 @@ class VhiClient:
|
|
|
52
102
|
"""
|
|
53
103
|
Performs the login flow. Handles the MFA challenge if required.
|
|
54
104
|
"""
|
|
55
|
-
|
|
105
|
+
if self._load_session():
|
|
106
|
+
if self._is_session_valid():
|
|
107
|
+
self.is_authenticated = True
|
|
108
|
+
return
|
|
109
|
+
else:
|
|
110
|
+
self.session.cookies.clear()
|
|
111
|
+
|
|
112
|
+
# Set proper fetch headers mimicking trace exactly
|
|
56
113
|
headers = {
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
114
|
+
"accept": "application/json, text/plain, */*",
|
|
115
|
+
"accept-language": "en-IE,en-US;q=0.9,en;q=0.8,ja;q=0.7,en-GB;q=0.6,de;q=0.5",
|
|
116
|
+
"cache-control": "no-cache",
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
"dnt": "1",
|
|
119
|
+
"origin": "https://app.vhi.ie",
|
|
120
|
+
"pragma": "no-cache",
|
|
121
|
+
"priority": "u=1, i",
|
|
122
|
+
"referer": "https://app.vhi.ie/",
|
|
123
|
+
"sec-fetch-dest": "empty",
|
|
124
|
+
"sec-fetch-mode": "cors",
|
|
125
|
+
"sec-fetch-site": "same-site",
|
|
126
|
+
"user-session-id": str(uuid.uuid4())
|
|
63
127
|
}
|
|
64
128
|
|
|
65
129
|
payload = {
|
|
@@ -67,10 +131,13 @@ class VhiClient:
|
|
|
67
131
|
"usercred": self.password
|
|
68
132
|
}
|
|
69
133
|
|
|
134
|
+
# Format strictly without spaces to bypass strict WAF parsers
|
|
135
|
+
raw_payload = json.dumps(payload, separators=(',', ':'))
|
|
136
|
+
|
|
70
137
|
# 1. Primary Authentication
|
|
71
138
|
login_url = f"{self.apis_base_url}/api/myvhilogin/login"
|
|
72
139
|
|
|
73
|
-
response = self.session.post(login_url,
|
|
140
|
+
response = self.session.post(login_url, data=raw_payload, headers=headers)
|
|
74
141
|
|
|
75
142
|
if response.status_code == 200:
|
|
76
143
|
try:
|
|
@@ -98,12 +165,13 @@ class VhiClient:
|
|
|
98
165
|
self._handle_mfa_challenge(state_token, verify_url, headers)
|
|
99
166
|
else:
|
|
100
167
|
self.is_authenticated = True
|
|
168
|
+
self._save_session()
|
|
101
169
|
return
|
|
102
170
|
|
|
103
171
|
elif response.status_code == 401:
|
|
104
172
|
raise VhiAuthenticationError("Invalid credentials provided.")
|
|
105
173
|
else:
|
|
106
|
-
raise VhiApiError(f"Login failed with status {response.status_code}", status_code=response.status_code, response=response)
|
|
174
|
+
raise VhiApiError(f"Login failed with status {response.status_code}: {response.text}", status_code=response.status_code, response=response)
|
|
107
175
|
|
|
108
176
|
def _handle_mfa_challenge(self, state_token: str, verify_url: str, base_headers: dict):
|
|
109
177
|
"""
|
|
@@ -127,7 +195,7 @@ class VhiClient:
|
|
|
127
195
|
|
|
128
196
|
if response.status_code == 200:
|
|
129
197
|
self.is_authenticated = True
|
|
130
|
-
|
|
198
|
+
self._save_session()
|
|
131
199
|
# The session cookie jar has now been populated with the authorized session
|
|
132
200
|
# Or we might need to extract a Bearer token. This implementation relies
|
|
133
201
|
# on cookies being correctly set via Set-Cookie headers by the API.
|
|
@@ -7,7 +7,7 @@ from vhi.exceptions import VhiAuthenticationError, VhiMfaRequiredError, VhiApiEr
|
|
|
7
7
|
|
|
8
8
|
@responses.activate
|
|
9
9
|
def test_login_success_no_mfa():
|
|
10
|
-
client = VhiClient("test@example.com", "password")
|
|
10
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
11
11
|
|
|
12
12
|
responses.add(
|
|
13
13
|
responses.POST,
|
|
@@ -24,7 +24,7 @@ def test_login_requires_mfa_success():
|
|
|
24
24
|
def mfa_callback():
|
|
25
25
|
return "123456"
|
|
26
26
|
|
|
27
|
-
client = VhiClient("test@example.com", "password", mfa_callback=mfa_callback)
|
|
27
|
+
client = VhiClient("test@example.com", "password", mfa_callback=mfa_callback, cache_session=False)
|
|
28
28
|
|
|
29
29
|
# Mock primary login returning 200 with MFA_REQUIRED
|
|
30
30
|
responses.add(
|
|
@@ -51,7 +51,7 @@ def test_login_requires_mfa_success():
|
|
|
51
51
|
|
|
52
52
|
@responses.activate
|
|
53
53
|
def test_login_invalid_credentials():
|
|
54
|
-
client = VhiClient("test@example.com", "wrong")
|
|
54
|
+
client = VhiClient("test@example.com", "wrong", cache_session=False)
|
|
55
55
|
|
|
56
56
|
responses.add(
|
|
57
57
|
responses.POST,
|
|
@@ -64,7 +64,7 @@ def test_login_invalid_credentials():
|
|
|
64
64
|
|
|
65
65
|
@responses.activate
|
|
66
66
|
def test_get_claims_success():
|
|
67
|
-
client = VhiClient("test@example.com", "password")
|
|
67
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
68
68
|
client.is_authenticated = True # Mock existing session
|
|
69
69
|
|
|
70
70
|
responses.add(
|
|
@@ -91,7 +91,7 @@ def test_get_claims_success():
|
|
|
91
91
|
|
|
92
92
|
@responses.activate
|
|
93
93
|
def test_download_document_success():
|
|
94
|
-
client = VhiClient("test@example.com", "password")
|
|
94
|
+
client = VhiClient("test@example.com", "password", cache_session=False)
|
|
95
95
|
client.is_authenticated = True
|
|
96
96
|
|
|
97
97
|
pdf_content = b"%PDF-1.4 mock pdf content"
|
|
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
|