revnext 0.1.1__py3-none-any.whl → 0.1.2__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.
- revnext/__init__.py +16 -18
- revnext/common.py +196 -196
- revnext/config.py +61 -61
- revnext/parts_by_bin_report.py +250 -250
- revnext/parts_price_list_report.py +254 -254
- revnext-0.1.2.dist-info/METADATA +126 -0
- revnext-0.1.2.dist-info/RECORD +9 -0
- revnext/download_all_reports.py +0 -49
- revnext-0.1.1.dist-info/METADATA +0 -24
- revnext-0.1.1.dist-info/RECORD +0 -10
- {revnext-0.1.1.dist-info → revnext-0.1.2.dist-info}/WHEEL +0 -0
- {revnext-0.1.1.dist-info → revnext-0.1.2.dist-info}/top_level.txt +0 -0
revnext/__init__.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Revolution Next (*.revolutionnext.com.au) report downloads via REST API.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from revnext.config import RevNextConfig, get_revnext_base_url_from_env
|
|
6
|
-
from revnext.parts_by_bin_report import download_parts_by_bin_report
|
|
7
|
-
from revnext.parts_price_list_report import download_parts_price_list_report
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
__version__ = "0.1.0"
|
|
1
|
+
"""
|
|
2
|
+
Revolution Next (*.revolutionnext.com.au) report downloads via REST API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from revnext.config import RevNextConfig, get_revnext_base_url_from_env
|
|
6
|
+
from revnext.parts_by_bin_report import download_parts_by_bin_report
|
|
7
|
+
from revnext.parts_price_list_report import download_parts_price_list_report
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RevNextConfig",
|
|
11
|
+
"get_revnext_base_url_from_env",
|
|
12
|
+
"download_parts_by_bin_report",
|
|
13
|
+
"download_parts_price_list_report",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
revnext/common.py
CHANGED
|
@@ -1,196 +1,196 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Shared utilities for Revolution Next (*.revolutionnext.com.au) report downloads.
|
|
3
|
-
Cookie loading, session creation, and generic submit → poll → loadData → download flow.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import time
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Callable
|
|
10
|
-
from urllib.parse import urlparse
|
|
11
|
-
|
|
12
|
-
import requests
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _common_headers(base_url: str) -> dict:
|
|
16
|
-
"""Build common request headers for the given base URL."""
|
|
17
|
-
return {
|
|
18
|
-
"accept": "*/*",
|
|
19
|
-
"accept-language": "en-AU,en-US;q=0.9,en-GB;q=0.8,en;q=0.7",
|
|
20
|
-
"content-type": "application/json; charset=UTF-8",
|
|
21
|
-
"origin": base_url,
|
|
22
|
-
"referrer": f"{base_url}/next/Fluid.html?useTabs",
|
|
23
|
-
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
|
|
24
|
-
"sec-ch-ua-mobile": "?0",
|
|
25
|
-
"sec-ch-ua-platform": '"Windows"',
|
|
26
|
-
"sec-fetch-dest": "empty",
|
|
27
|
-
"sec-fetch-mode": "cors",
|
|
28
|
-
"sec-fetch-site": "same-origin",
|
|
29
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def load_cookies_for_domain(cookies_path: Path, domain: str) -> list[tuple[str, str]]:
|
|
34
|
-
"""Load cookies from Chrome-export JSON and return (name, value) pairs for the domain."""
|
|
35
|
-
with open(cookies_path, encoding="utf-8") as f:
|
|
36
|
-
raw = json.load(f)
|
|
37
|
-
pairs = []
|
|
38
|
-
for c in raw:
|
|
39
|
-
d = c.get("domain", "")
|
|
40
|
-
if not d:
|
|
41
|
-
continue
|
|
42
|
-
if domain == d or domain.endswith(d.lstrip(".")):
|
|
43
|
-
pairs.append((c["name"], c["value"]))
|
|
44
|
-
return pairs
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def cookie_header(pairs: list[tuple[str, str]]) -> str:
|
|
48
|
-
"""Build Cookie header value from (name, value) pairs."""
|
|
49
|
-
return "; ".join(f"{name}={value}" for name, value in pairs)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def create_session(
|
|
53
|
-
cookies_path: Path,
|
|
54
|
-
service_object: str,
|
|
55
|
-
base_url: str,
|
|
56
|
-
) -> requests.Session:
|
|
57
|
-
"""Create a requests Session with cookies and common headers for the given base URL and service object."""
|
|
58
|
-
parsed = urlparse(base_url)
|
|
59
|
-
domain = parsed.netloc or parsed.path
|
|
60
|
-
if not domain:
|
|
61
|
-
raise ValueError(f"Invalid base_url: {base_url}")
|
|
62
|
-
cookie_pairs = load_cookies_for_domain(cookies_path, domain)
|
|
63
|
-
if not cookie_pairs:
|
|
64
|
-
raise ValueError(
|
|
65
|
-
f"No cookies found for {domain}. Export cookies for this domain (e.g. revnext-cookies.json)."
|
|
66
|
-
)
|
|
67
|
-
session = requests.Session()
|
|
68
|
-
session.headers.update(_common_headers(base_url))
|
|
69
|
-
session.headers["cookie"] = cookie_header(cookie_pairs)
|
|
70
|
-
session.headers["x-service-object"] = service_object
|
|
71
|
-
return session
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def extract_task_id(submit_response: dict) -> str | None:
|
|
75
|
-
"""Extract taskID from submitActivityTask response."""
|
|
76
|
-
for ds in submit_response.get("dataSets", []):
|
|
77
|
-
if ds.get("name") != "dsActivityTask":
|
|
78
|
-
continue
|
|
79
|
-
d = ds.get("dataSet", {}).get("dsActivityTask", {})
|
|
80
|
-
for row in d.get("ttActivityTask", []):
|
|
81
|
-
tid = row.get("taskID")
|
|
82
|
-
if tid:
|
|
83
|
-
return tid
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def is_poll_done(poll_response: dict) -> bool:
|
|
88
|
-
"""True when report is ready (autoPollResponse returns -1)."""
|
|
89
|
-
for cp in poll_response.get("ctrlProp", []):
|
|
90
|
-
if cp.get("name") == "button.autoPollResponse" and cp.get("value") == "-1":
|
|
91
|
-
return True
|
|
92
|
-
return False
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_response_url_from_load_data(load_data: dict) -> str | None:
|
|
96
|
-
"""Extract responseUrl from loadData response (ttActivityTaskResponse)."""
|
|
97
|
-
for ds in load_data.get("dataSets", []):
|
|
98
|
-
if ds.get("name") != "dsActivityTask":
|
|
99
|
-
continue
|
|
100
|
-
d = ds.get("dataSet", {}).get("dsActivityTask", {})
|
|
101
|
-
for row in d.get("ttActivityTaskResponse", []):
|
|
102
|
-
url = row.get("responseUrl")
|
|
103
|
-
if url:
|
|
104
|
-
return url
|
|
105
|
-
return None
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def run_report_flow(
|
|
109
|
-
session: requests.Session,
|
|
110
|
-
service_object: str,
|
|
111
|
-
activity_tab_id: str,
|
|
112
|
-
get_submit_body: Callable[[], dict],
|
|
113
|
-
output_path: Path,
|
|
114
|
-
base_url: str,
|
|
115
|
-
post_submit_hook: Callable[[requests.Session], None] | None = None,
|
|
116
|
-
max_polls: int = 60,
|
|
117
|
-
poll_interval: float = 2,
|
|
118
|
-
) -> Path:
|
|
119
|
-
"""
|
|
120
|
-
Submit report task, poll until ready, loadData for download URL, then download CSV.
|
|
121
|
-
Optionally call post_submit_hook(session) after submit (e.g. onChoose_btn_closesubmit).
|
|
122
|
-
Returns the path where the file was saved.
|
|
123
|
-
"""
|
|
124
|
-
submit_url = f"{base_url}/next/rest/si/static/submitActivityTask"
|
|
125
|
-
r = session.post(submit_url, json=get_submit_body())
|
|
126
|
-
r.raise_for_status()
|
|
127
|
-
submit_data = r.json()
|
|
128
|
-
if not submit_data.get("submittedSuccess"):
|
|
129
|
-
raise RuntimeError("submitActivityTask did not report success.")
|
|
130
|
-
|
|
131
|
-
task_id = extract_task_id(submit_data)
|
|
132
|
-
if not task_id:
|
|
133
|
-
raise RuntimeError("Could not get taskID from submit response.")
|
|
134
|
-
print(f"Task submitted: {task_id}")
|
|
135
|
-
|
|
136
|
-
if post_submit_hook:
|
|
137
|
-
post_submit_hook(session)
|
|
138
|
-
|
|
139
|
-
poll_url = f"{base_url}/next/rest/si/presenter/autoPollResponse"
|
|
140
|
-
poll_body = {
|
|
141
|
-
"_userContext_vg_coid": "03",
|
|
142
|
-
"_userContext_vg_divid": "1",
|
|
143
|
-
"_userContext_vg_dftdpt": "570",
|
|
144
|
-
"activityTabId": activity_tab_id,
|
|
145
|
-
"ctrlProp": [
|
|
146
|
-
{"name": "ttActivityTask.taskID", "prop": "SCREENVALUE", "value": task_id}
|
|
147
|
-
],
|
|
148
|
-
"uiType": "ISC",
|
|
149
|
-
}
|
|
150
|
-
for i in range(max_polls):
|
|
151
|
-
time.sleep(poll_interval)
|
|
152
|
-
r = session.post(poll_url, json=poll_body)
|
|
153
|
-
r.raise_for_status()
|
|
154
|
-
poll_data = r.json()
|
|
155
|
-
if is_poll_done(poll_data):
|
|
156
|
-
print("Report generation complete.")
|
|
157
|
-
break
|
|
158
|
-
print(f" Poll {i + 1}: still generating...")
|
|
159
|
-
else:
|
|
160
|
-
raise RuntimeError("Timed out waiting for report.")
|
|
161
|
-
|
|
162
|
-
load_url = f"{base_url}/next/rest/si/static/loadData"
|
|
163
|
-
load_body = {
|
|
164
|
-
"taskID": task_id,
|
|
165
|
-
"ctrlProp": [{"name": "dummy", "prop": "LOADDATA", "value": "dummy"}],
|
|
166
|
-
"parentActivity": None,
|
|
167
|
-
"historyID": "self,dummy,dummy",
|
|
168
|
-
"tabID": "self",
|
|
169
|
-
"activityType": "dummy",
|
|
170
|
-
"fluidService": "dummy",
|
|
171
|
-
"uiType": "ISC",
|
|
172
|
-
"_userContext_vg_coid": "03",
|
|
173
|
-
"_userContext_vg_divid": "1",
|
|
174
|
-
"_userContext_vg_dftdpt": "570",
|
|
175
|
-
"activityTabId": activity_tab_id,
|
|
176
|
-
"loadMode": "EDIT",
|
|
177
|
-
"loadRowid": "dummy",
|
|
178
|
-
}
|
|
179
|
-
r = session.post(load_url, json=load_body)
|
|
180
|
-
r.raise_for_status()
|
|
181
|
-
load_data = r.json()
|
|
182
|
-
|
|
183
|
-
response_url = get_response_url_from_load_data(load_data)
|
|
184
|
-
if not response_url:
|
|
185
|
-
raise RuntimeError("Could not get responseUrl from loadData.")
|
|
186
|
-
|
|
187
|
-
if not response_url.startswith("http"):
|
|
188
|
-
response_url = f"{base_url}/next/{response_url.lstrip('/')}"
|
|
189
|
-
print(f"Download URL: {response_url}")
|
|
190
|
-
|
|
191
|
-
r = session.get(response_url)
|
|
192
|
-
r.raise_for_status()
|
|
193
|
-
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
-
output_path.write_bytes(r.content)
|
|
195
|
-
print(f"Saved: {output_path}")
|
|
196
|
-
return output_path
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for Revolution Next (*.revolutionnext.com.au) report downloads.
|
|
3
|
+
Cookie loading, session creation, and generic submit → poll → loadData → download flow.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _common_headers(base_url: str) -> dict:
|
|
16
|
+
"""Build common request headers for the given base URL."""
|
|
17
|
+
return {
|
|
18
|
+
"accept": "*/*",
|
|
19
|
+
"accept-language": "en-AU,en-US;q=0.9,en-GB;q=0.8,en;q=0.7",
|
|
20
|
+
"content-type": "application/json; charset=UTF-8",
|
|
21
|
+
"origin": base_url,
|
|
22
|
+
"referrer": f"{base_url}/next/Fluid.html?useTabs",
|
|
23
|
+
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
|
|
24
|
+
"sec-ch-ua-mobile": "?0",
|
|
25
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
26
|
+
"sec-fetch-dest": "empty",
|
|
27
|
+
"sec-fetch-mode": "cors",
|
|
28
|
+
"sec-fetch-site": "same-origin",
|
|
29
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_cookies_for_domain(cookies_path: Path, domain: str) -> list[tuple[str, str]]:
|
|
34
|
+
"""Load cookies from Chrome-export JSON and return (name, value) pairs for the domain."""
|
|
35
|
+
with open(cookies_path, encoding="utf-8") as f:
|
|
36
|
+
raw = json.load(f)
|
|
37
|
+
pairs = []
|
|
38
|
+
for c in raw:
|
|
39
|
+
d = c.get("domain", "")
|
|
40
|
+
if not d:
|
|
41
|
+
continue
|
|
42
|
+
if domain == d or domain.endswith(d.lstrip(".")):
|
|
43
|
+
pairs.append((c["name"], c["value"]))
|
|
44
|
+
return pairs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cookie_header(pairs: list[tuple[str, str]]) -> str:
|
|
48
|
+
"""Build Cookie header value from (name, value) pairs."""
|
|
49
|
+
return "; ".join(f"{name}={value}" for name, value in pairs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_session(
|
|
53
|
+
cookies_path: Path,
|
|
54
|
+
service_object: str,
|
|
55
|
+
base_url: str,
|
|
56
|
+
) -> requests.Session:
|
|
57
|
+
"""Create a requests Session with cookies and common headers for the given base URL and service object."""
|
|
58
|
+
parsed = urlparse(base_url)
|
|
59
|
+
domain = parsed.netloc or parsed.path
|
|
60
|
+
if not domain:
|
|
61
|
+
raise ValueError(f"Invalid base_url: {base_url}")
|
|
62
|
+
cookie_pairs = load_cookies_for_domain(cookies_path, domain)
|
|
63
|
+
if not cookie_pairs:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"No cookies found for {domain}. Export cookies for this domain (e.g. revnext-cookies.json)."
|
|
66
|
+
)
|
|
67
|
+
session = requests.Session()
|
|
68
|
+
session.headers.update(_common_headers(base_url))
|
|
69
|
+
session.headers["cookie"] = cookie_header(cookie_pairs)
|
|
70
|
+
session.headers["x-service-object"] = service_object
|
|
71
|
+
return session
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_task_id(submit_response: dict) -> str | None:
|
|
75
|
+
"""Extract taskID from submitActivityTask response."""
|
|
76
|
+
for ds in submit_response.get("dataSets", []):
|
|
77
|
+
if ds.get("name") != "dsActivityTask":
|
|
78
|
+
continue
|
|
79
|
+
d = ds.get("dataSet", {}).get("dsActivityTask", {})
|
|
80
|
+
for row in d.get("ttActivityTask", []):
|
|
81
|
+
tid = row.get("taskID")
|
|
82
|
+
if tid:
|
|
83
|
+
return tid
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_poll_done(poll_response: dict) -> bool:
|
|
88
|
+
"""True when report is ready (autoPollResponse returns -1)."""
|
|
89
|
+
for cp in poll_response.get("ctrlProp", []):
|
|
90
|
+
if cp.get("name") == "button.autoPollResponse" and cp.get("value") == "-1":
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_response_url_from_load_data(load_data: dict) -> str | None:
|
|
96
|
+
"""Extract responseUrl from loadData response (ttActivityTaskResponse)."""
|
|
97
|
+
for ds in load_data.get("dataSets", []):
|
|
98
|
+
if ds.get("name") != "dsActivityTask":
|
|
99
|
+
continue
|
|
100
|
+
d = ds.get("dataSet", {}).get("dsActivityTask", {})
|
|
101
|
+
for row in d.get("ttActivityTaskResponse", []):
|
|
102
|
+
url = row.get("responseUrl")
|
|
103
|
+
if url:
|
|
104
|
+
return url
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_report_flow(
|
|
109
|
+
session: requests.Session,
|
|
110
|
+
service_object: str,
|
|
111
|
+
activity_tab_id: str,
|
|
112
|
+
get_submit_body: Callable[[], dict],
|
|
113
|
+
output_path: Path,
|
|
114
|
+
base_url: str,
|
|
115
|
+
post_submit_hook: Callable[[requests.Session], None] | None = None,
|
|
116
|
+
max_polls: int = 60,
|
|
117
|
+
poll_interval: float = 2,
|
|
118
|
+
) -> Path:
|
|
119
|
+
"""
|
|
120
|
+
Submit report task, poll until ready, loadData for download URL, then download CSV.
|
|
121
|
+
Optionally call post_submit_hook(session) after submit (e.g. onChoose_btn_closesubmit).
|
|
122
|
+
Returns the path where the file was saved.
|
|
123
|
+
"""
|
|
124
|
+
submit_url = f"{base_url}/next/rest/si/static/submitActivityTask"
|
|
125
|
+
r = session.post(submit_url, json=get_submit_body())
|
|
126
|
+
r.raise_for_status()
|
|
127
|
+
submit_data = r.json()
|
|
128
|
+
if not submit_data.get("submittedSuccess"):
|
|
129
|
+
raise RuntimeError("submitActivityTask did not report success.")
|
|
130
|
+
|
|
131
|
+
task_id = extract_task_id(submit_data)
|
|
132
|
+
if not task_id:
|
|
133
|
+
raise RuntimeError("Could not get taskID from submit response.")
|
|
134
|
+
print(f"Task submitted: {task_id}")
|
|
135
|
+
|
|
136
|
+
if post_submit_hook:
|
|
137
|
+
post_submit_hook(session)
|
|
138
|
+
|
|
139
|
+
poll_url = f"{base_url}/next/rest/si/presenter/autoPollResponse"
|
|
140
|
+
poll_body = {
|
|
141
|
+
"_userContext_vg_coid": "03",
|
|
142
|
+
"_userContext_vg_divid": "1",
|
|
143
|
+
"_userContext_vg_dftdpt": "570",
|
|
144
|
+
"activityTabId": activity_tab_id,
|
|
145
|
+
"ctrlProp": [
|
|
146
|
+
{"name": "ttActivityTask.taskID", "prop": "SCREENVALUE", "value": task_id}
|
|
147
|
+
],
|
|
148
|
+
"uiType": "ISC",
|
|
149
|
+
}
|
|
150
|
+
for i in range(max_polls):
|
|
151
|
+
time.sleep(poll_interval)
|
|
152
|
+
r = session.post(poll_url, json=poll_body)
|
|
153
|
+
r.raise_for_status()
|
|
154
|
+
poll_data = r.json()
|
|
155
|
+
if is_poll_done(poll_data):
|
|
156
|
+
print("Report generation complete.")
|
|
157
|
+
break
|
|
158
|
+
print(f" Poll {i + 1}: still generating...")
|
|
159
|
+
else:
|
|
160
|
+
raise RuntimeError("Timed out waiting for report.")
|
|
161
|
+
|
|
162
|
+
load_url = f"{base_url}/next/rest/si/static/loadData"
|
|
163
|
+
load_body = {
|
|
164
|
+
"taskID": task_id,
|
|
165
|
+
"ctrlProp": [{"name": "dummy", "prop": "LOADDATA", "value": "dummy"}],
|
|
166
|
+
"parentActivity": None,
|
|
167
|
+
"historyID": "self,dummy,dummy",
|
|
168
|
+
"tabID": "self",
|
|
169
|
+
"activityType": "dummy",
|
|
170
|
+
"fluidService": "dummy",
|
|
171
|
+
"uiType": "ISC",
|
|
172
|
+
"_userContext_vg_coid": "03",
|
|
173
|
+
"_userContext_vg_divid": "1",
|
|
174
|
+
"_userContext_vg_dftdpt": "570",
|
|
175
|
+
"activityTabId": activity_tab_id,
|
|
176
|
+
"loadMode": "EDIT",
|
|
177
|
+
"loadRowid": "dummy",
|
|
178
|
+
}
|
|
179
|
+
r = session.post(load_url, json=load_body)
|
|
180
|
+
r.raise_for_status()
|
|
181
|
+
load_data = r.json()
|
|
182
|
+
|
|
183
|
+
response_url = get_response_url_from_load_data(load_data)
|
|
184
|
+
if not response_url:
|
|
185
|
+
raise RuntimeError("Could not get responseUrl from loadData.")
|
|
186
|
+
|
|
187
|
+
if not response_url.startswith("http"):
|
|
188
|
+
response_url = f"{base_url}/next/{response_url.lstrip('/')}"
|
|
189
|
+
print(f"Download URL: {response_url}")
|
|
190
|
+
|
|
191
|
+
r = session.get(response_url)
|
|
192
|
+
r.raise_for_status()
|
|
193
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
output_path.write_bytes(r.content)
|
|
195
|
+
print(f"Saved: {output_path}")
|
|
196
|
+
return output_path
|
revnext/config.py
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Configuration for Revolution Next (*.revolutionnext.com.au) report downloads.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Optional
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _load_dotenv_if_available() -> None:
|
|
12
|
-
try:
|
|
13
|
-
from dotenv import load_dotenv
|
|
14
|
-
load_dotenv()
|
|
15
|
-
except ImportError:
|
|
16
|
-
pass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@dataclass(frozen=True)
|
|
20
|
-
class RevNextConfig:
|
|
21
|
-
"""Configuration for Revolution Next (*.revolutionnext.com.au) API / report downloads."""
|
|
22
|
-
|
|
23
|
-
base_url: str
|
|
24
|
-
cookies_path: Optional[Path] = None
|
|
25
|
-
username: Optional[str] = None
|
|
26
|
-
password: Optional[str] = None
|
|
27
|
-
|
|
28
|
-
@classmethod
|
|
29
|
-
def from_env(
|
|
30
|
-
cls,
|
|
31
|
-
*,
|
|
32
|
-
base_url: Optional[str] = None,
|
|
33
|
-
cookies_path: Optional[Path] = None,
|
|
34
|
-
username: Optional[str] = None,
|
|
35
|
-
password: Optional[str] = None,
|
|
36
|
-
load_dotenv: bool = True,
|
|
37
|
-
) -> "RevNextConfig":
|
|
38
|
-
"""Build config from environment variables. Override any field by passing it explicitly."""
|
|
39
|
-
if load_dotenv:
|
|
40
|
-
_load_dotenv_if_available()
|
|
41
|
-
url = base_url or os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
|
|
42
|
-
if url and not url.startswith(("http://", "https://")):
|
|
43
|
-
url = "https://" + url
|
|
44
|
-
cp = cookies_path
|
|
45
|
-
if cp is None and os.getenv("REVOLUTIONNEXT_COOKIES_PATH"):
|
|
46
|
-
cp = Path(os.getenv("REVOLUTIONNEXT_COOKIES_PATH"))
|
|
47
|
-
return cls(
|
|
48
|
-
base_url=url,
|
|
49
|
-
cookies_path=cp,
|
|
50
|
-
username=username or os.getenv("REVOLUTIONNEXT_USERNAME"),
|
|
51
|
-
password=password or os.getenv("REVOLUTIONNEXT_PASSWORD"),
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def get_revnext_base_url_from_env() -> str:
|
|
56
|
-
"""Return RevNext base URL from environment (REVOLUTIONNEXT_URL)."""
|
|
57
|
-
_load_dotenv_if_available()
|
|
58
|
-
url = os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
|
|
59
|
-
if not url.startswith(("http://", "https://")):
|
|
60
|
-
url = "https://" + url
|
|
61
|
-
return url
|
|
1
|
+
"""
|
|
2
|
+
Configuration for Revolution Next (*.revolutionnext.com.au) report downloads.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_dotenv_if_available() -> None:
|
|
12
|
+
try:
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
load_dotenv()
|
|
15
|
+
except ImportError:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class RevNextConfig:
|
|
21
|
+
"""Configuration for Revolution Next (*.revolutionnext.com.au) API / report downloads."""
|
|
22
|
+
|
|
23
|
+
base_url: str
|
|
24
|
+
cookies_path: Optional[Path] = None
|
|
25
|
+
username: Optional[str] = None
|
|
26
|
+
password: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(
|
|
30
|
+
cls,
|
|
31
|
+
*,
|
|
32
|
+
base_url: Optional[str] = None,
|
|
33
|
+
cookies_path: Optional[Path] = None,
|
|
34
|
+
username: Optional[str] = None,
|
|
35
|
+
password: Optional[str] = None,
|
|
36
|
+
load_dotenv: bool = True,
|
|
37
|
+
) -> "RevNextConfig":
|
|
38
|
+
"""Build config from environment variables. Override any field by passing it explicitly."""
|
|
39
|
+
if load_dotenv:
|
|
40
|
+
_load_dotenv_if_available()
|
|
41
|
+
url = base_url or os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
|
|
42
|
+
if url and not url.startswith(("http://", "https://")):
|
|
43
|
+
url = "https://" + url
|
|
44
|
+
cp = cookies_path
|
|
45
|
+
if cp is None and os.getenv("REVOLUTIONNEXT_COOKIES_PATH"):
|
|
46
|
+
cp = Path(os.getenv("REVOLUTIONNEXT_COOKIES_PATH"))
|
|
47
|
+
return cls(
|
|
48
|
+
base_url=url,
|
|
49
|
+
cookies_path=cp,
|
|
50
|
+
username=username or os.getenv("REVOLUTIONNEXT_USERNAME"),
|
|
51
|
+
password=password or os.getenv("REVOLUTIONNEXT_PASSWORD"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_revnext_base_url_from_env() -> str:
|
|
56
|
+
"""Return RevNext base URL from environment (REVOLUTIONNEXT_URL)."""
|
|
57
|
+
_load_dotenv_if_available()
|
|
58
|
+
url = os.getenv("REVOLUTIONNEXT_URL") or "https://mikecarney.revolutionnext.com.au"
|
|
59
|
+
if not url.startswith(("http://", "https://")):
|
|
60
|
+
url = "https://" + url
|
|
61
|
+
return url
|