revnext 0.1.0__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 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
- from revnext.download_all_reports import download_all_reports
9
-
10
- __all__ = [
11
- "RevNextConfig",
12
- "get_revnext_base_url_from_env",
13
- "download_parts_by_bin_report",
14
- "download_parts_price_list_report",
15
- "download_all_reports",
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