pythonanywhere-clis 1.0.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.
pa_cli/config.py ADDED
@@ -0,0 +1,254 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import platform
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ CONFIG_PATH = Path.home() / ".pa-cli" / "config.json"
12
+
13
+
14
+ @dataclass
15
+ class AccountConfig:
16
+ username: str
17
+ token: str
18
+ host: str = "www.pythonanywhere.com"
19
+ password: str | None = None
20
+ password_enc: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class ConfigData:
25
+ accounts: list[AccountConfig]
26
+ default_account: str
27
+
28
+
29
+ def _validate_config(data: dict) -> ConfigData:
30
+ """Validate config data and return ConfigData. Raises ValueError on invalid data."""
31
+ if not isinstance(data, dict):
32
+ raise ValueError("Config must be a JSON object")
33
+
34
+ if "accounts" not in data:
35
+ raise ValueError("Config missing 'accounts' field")
36
+
37
+ if not isinstance(data["accounts"], list):
38
+ raise ValueError("'accounts' must be a list")
39
+
40
+ accounts = []
41
+ for i, acc in enumerate(data["accounts"]):
42
+ if not isinstance(acc, dict):
43
+ raise ValueError(f"Account {i} must be an object")
44
+
45
+ if "username" not in acc:
46
+ raise ValueError(f"Account {i} missing 'username' field")
47
+
48
+ if not isinstance(acc["username"], str) or not acc["username"]:
49
+ raise ValueError(f"Account {i} 'username' must be a non-empty string")
50
+
51
+ if "token" not in acc:
52
+ raise ValueError(f"Account {i} missing 'token' field")
53
+
54
+ if not isinstance(acc["token"], str):
55
+ raise ValueError(f"Account {i} 'token' must be a string")
56
+
57
+ host = acc.get("host", "www.pythonanywhere.com")
58
+ if not isinstance(host, str):
59
+ raise ValueError(f"Account {i} 'host' must be a string")
60
+
61
+ account_kwargs = {
62
+ "username": acc["username"],
63
+ "token": acc["token"],
64
+ "host": host,
65
+ }
66
+ if "password" in acc:
67
+ account_kwargs["password"] = acc["password"]
68
+ if "password_enc" in acc:
69
+ account_kwargs["password_enc"] = acc["password_enc"]
70
+ accounts.append(AccountConfig(**account_kwargs))
71
+
72
+ default_account = data.get("default_account", "")
73
+ if not isinstance(default_account, str):
74
+ raise ValueError("'default_account' must be a string")
75
+
76
+ return ConfigData(accounts=accounts, default_account=default_account)
77
+
78
+
79
+ def _get_machine_key() -> bytes:
80
+ """Generate encryption key from machine-specific info."""
81
+ import getpass
82
+ username = getpass.getuser()
83
+ hostname = platform.node()
84
+ seed = f"{username}-{hostname}"
85
+ return hashlib.sha256(seed.encode()).digest()
86
+
87
+
88
+ def _encrypt(plaintext: str) -> str:
89
+ """Encrypt a string using machine key. Returns base64-encoded ciphertext."""
90
+ key = _get_machine_key()
91
+ data = plaintext.encode("utf-8")
92
+ encrypted = bytes(a ^ b for a, b in zip(data, key * (len(data) // len(key) + 1)))
93
+ return base64.b64encode(encrypted).decode("ascii")
94
+
95
+
96
+ def _decrypt(ciphertext: str) -> str:
97
+ """Decrypt a base64-encoded ciphertext using machine key."""
98
+ key = _get_machine_key()
99
+ data = base64.b64decode(ciphertext)
100
+ decrypted = bytes(a ^ b for a, b in zip(data, key * (len(data) // len(key) + 1)))
101
+ return decrypted.decode("utf-8")
102
+
103
+
104
+ def _decrypt_account(account: dict) -> dict:
105
+ """Decrypt password in account dict. Handles both encrypted and legacy plaintext."""
106
+ account = dict(account)
107
+ if "password_enc" in account:
108
+ if account["password_enc"] is not None:
109
+ try:
110
+ account["password"] = _decrypt(account["password_enc"])
111
+ except Exception:
112
+ account["password"] = None
113
+ del account["password_enc"]
114
+ # Keep legacy plaintext password as-is (already in account["password"])
115
+ # Remove password field if it's None (backward compatibility)
116
+ if "password" in account and account["password"] is None:
117
+ del account["password"]
118
+ return account
119
+
120
+
121
+ class Config:
122
+ @staticmethod
123
+ def save(
124
+ username: str | None = None,
125
+ token: str | None = None,
126
+ host: str | None = None,
127
+ password: str | None = None,
128
+ ) -> None:
129
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
130
+
131
+ if CONFIG_PATH.exists():
132
+ try:
133
+ data = json.loads(CONFIG_PATH.read_text())
134
+ except json.JSONDecodeError:
135
+ data = {"accounts": [], "default_account": username or ""}
136
+ else:
137
+ data = {"accounts": [], "default_account": username or ""}
138
+
139
+ # If partial update (e.g. only password), load existing account
140
+ existing_account = None
141
+ target_username = username or data.get("default_account", "")
142
+ if target_username:
143
+ for a in data.get("accounts", []):
144
+ if a["username"] == target_username:
145
+ existing_account = a
146
+ break
147
+
148
+ if existing_account:
149
+ account = dict(existing_account)
150
+ if username is not None:
151
+ account["username"] = username
152
+ if token is not None:
153
+ account["token"] = token
154
+ if host is not None:
155
+ account["host"] = host
156
+ if password is not None:
157
+ account["password_enc"] = _encrypt(password)
158
+ account.pop("password", None)
159
+ else:
160
+ account = {
161
+ "username": target_username,
162
+ "token": token or "",
163
+ "host": host or "www.pythonanywhere.com",
164
+ }
165
+ if password is not None:
166
+ account["password_enc"] = _encrypt(password)
167
+
168
+ # Update existing or append new
169
+ existing = [i for i, a in enumerate(data["accounts"]) if a["username"] == target_username]
170
+ if existing:
171
+ data["accounts"][existing[0]] = account
172
+ else:
173
+ data["accounts"].append(account)
174
+
175
+ data["default_account"] = target_username
176
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
177
+
178
+ @staticmethod
179
+ def load(username: str | None = None, verbose: bool = False) -> dict:
180
+ if not CONFIG_PATH.exists():
181
+ raise FileNotFoundError(f"Config not found. Run 'pa init' first.")
182
+
183
+ try:
184
+ raw_data = json.loads(CONFIG_PATH.read_text())
185
+ except json.JSONDecodeError as e:
186
+ raise ValueError(f"Invalid JSON in config file: {e}")
187
+
188
+ try:
189
+ config = _validate_config(raw_data)
190
+ except ValueError as e:
191
+ raise ValueError(f"Invalid config format: {e}")
192
+
193
+ if username:
194
+ for account in config.accounts:
195
+ if account.username == username:
196
+ if verbose:
197
+ typer.echo(f"[account: {username}]")
198
+ return _decrypt_account(vars(account))
199
+ raise ValueError(f"Account '{username}' not found in config.")
200
+
201
+ # Return default account
202
+ for account in config.accounts:
203
+ if account.username == config.default_account:
204
+ if verbose:
205
+ typer.echo(f"[account: {config.default_account}]")
206
+ return _decrypt_account(vars(account))
207
+
208
+ raise ValueError("No default account configured.")
209
+
210
+ @staticmethod
211
+ def list_accounts() -> list[dict]:
212
+ if not CONFIG_PATH.exists():
213
+ return []
214
+ try:
215
+ data = json.loads(CONFIG_PATH.read_text())
216
+ except json.JSONDecodeError:
217
+ return []
218
+ return data.get("accounts", [])
219
+
220
+ @staticmethod
221
+ def set_default(username: str) -> None:
222
+ if not CONFIG_PATH.exists():
223
+ raise FileNotFoundError(f"Config not found. Run 'pa init' first.")
224
+ try:
225
+ data = json.loads(CONFIG_PATH.read_text())
226
+ except json.JSONDecodeError as e:
227
+ raise ValueError(f"Invalid JSON in config file: {e}")
228
+ found = any(a["username"] == username for a in data.get("accounts", []))
229
+ if not found:
230
+ raise ValueError(f"Account '{username}' not found in config.")
231
+ data["default_account"] = username
232
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
233
+
234
+ @staticmethod
235
+ def remove(username: str) -> str | None:
236
+ if not CONFIG_PATH.exists():
237
+ raise FileNotFoundError(f"Config not found. Run 'pa init' first.")
238
+ try:
239
+ data = json.loads(CONFIG_PATH.read_text())
240
+ except json.JSONDecodeError as e:
241
+ raise ValueError(f"Invalid JSON in config file: {e}")
242
+ found = [i for i, a in enumerate(data.get("accounts", [])) if a["username"] == username]
243
+ if not found:
244
+ raise ValueError(f"Account '{username}' not found in config.")
245
+ data["accounts"].pop(found[0])
246
+ new_default = None
247
+ if data["default_account"] == username:
248
+ if data["accounts"]:
249
+ data["default_account"] = data["accounts"][0]["username"]
250
+ new_default = data["default_account"]
251
+ else:
252
+ data["default_account"] = ""
253
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
254
+ return new_default
File without changes
@@ -0,0 +1,370 @@
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+
4
+ from pa_cli.config import Config
5
+ from pa_cli.exceptions import APIError, AuthError, NetworkError, NotFoundError
6
+
7
+
8
+ class AccountCrawler:
9
+ def __init__(self, username: str | None = None, host: str | None = None):
10
+ config = Config.load()
11
+ self.username = username or config["username"]
12
+ resolved_host = host or config.get("host", "www.pythonanywhere.com")
13
+ self.base_url = f"https://{resolved_host}"
14
+ self.session = requests.Session()
15
+ self.session.headers.update({
16
+ "Host": resolved_host,
17
+ "Origin": self.base_url,
18
+ "Referer": f"{self.base_url}/login/",
19
+ "Upgrade-Insecure-Requests": "1",
20
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
21
+ })
22
+
23
+ def register(self, username: str, email: str, password: str) -> bool:
24
+ register_url = f"{self.base_url}/registration/register/beginner/"
25
+
26
+ try:
27
+ register_page_resp = self.session.get(register_url)
28
+ register_page_resp.raise_for_status()
29
+ except requests.RequestException as e:
30
+ raise NetworkError(f"Failed to fetch registration page: {e}") from e
31
+
32
+ soup = BeautifulSoup(register_page_resp.text, "html.parser")
33
+ csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
34
+ if csrf_input is None:
35
+ raise APIError("CSRF token not found on registration page")
36
+
37
+ data = {
38
+ "csrfmiddlewaretoken": csrf_input["value"],
39
+ "username": username,
40
+ "email": email,
41
+ "password1": password,
42
+ "password2": password,
43
+ "tos": "on",
44
+ "recaptcha_response_token_v3": "",
45
+ }
46
+
47
+ headers = {
48
+ "Referer": register_url,
49
+ "Origin": self.base_url,
50
+ }
51
+
52
+ try:
53
+ register_resp = self.session.post(register_url, data=data, headers=headers)
54
+ register_resp.raise_for_status()
55
+ except requests.RequestException as e:
56
+ raise NetworkError(f"Registration request failed: {e}") from e
57
+
58
+ if "/registration/register/complete/" in register_resp.url:
59
+ return True
60
+
61
+ # Extract error messages from the response
62
+ soup2 = BeautifulSoup(register_resp.text, "html.parser")
63
+ errors = []
64
+ for elem in soup2.find_all(["li", "div", "span", "p"], class_=True):
65
+ classes = " ".join(elem.get("class", []))
66
+ if "error" in classes.lower():
67
+ text = elem.get_text(strip=True)
68
+ if text:
69
+ errors.append(text)
70
+ if errors:
71
+ raise AuthError(f"Registration failed: {'; '.join(errors)}")
72
+ raise AuthError("Registration failed. Please check your input.")
73
+
74
+ def login(self, password: str | None = None) -> bool:
75
+ if password is None:
76
+ config = Config.load()
77
+ password = config.get("password")
78
+ if not password:
79
+ raise AuthError(
80
+ "Password not found in config. Run 'pa account login' to store it."
81
+ )
82
+
83
+ login_url = f"{self.base_url}/login/"
84
+
85
+ try:
86
+ login_page_resp = self.session.get(login_url)
87
+ login_page_resp.raise_for_status()
88
+ except requests.RequestException as e:
89
+ raise NetworkError(f"Failed to fetch login page: {e}") from e
90
+
91
+ soup = BeautifulSoup(login_page_resp.text, "html.parser")
92
+ csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
93
+ if csrf_input is None:
94
+ raise APIError("CSRF token not found on login page")
95
+
96
+ data = {
97
+ "csrfmiddlewaretoken": csrf_input["value"],
98
+ "auth-username": self.username,
99
+ "auth-password": password,
100
+ "login_view-current_step": "auth",
101
+ }
102
+
103
+ headers = {
104
+ "Referer": login_url,
105
+ "Origin": self.base_url,
106
+ }
107
+
108
+ try:
109
+ login_resp = self.session.post(login_url, data=data, headers=headers)
110
+ login_resp.raise_for_status()
111
+ except requests.RequestException as e:
112
+ raise NetworkError(f"Login request failed: {e}") from e
113
+
114
+ if "/login/" in login_resp.url:
115
+ soup2 = BeautifulSoup(login_resp.text, "html.parser")
116
+ # Extract error message from <p> tags
117
+ for p in soup2.find_all("p"):
118
+ text = p.get_text(strip=True)
119
+ if "incorrect" in text.lower() or "invalid" in text.lower():
120
+ raise AuthError(f"Login failed: {text}")
121
+ raise AuthError("Login failed. Check your username and password.")
122
+
123
+ return True
124
+
125
+ def get_token(self, username: str | None = None) -> str:
126
+ resolved = username or self.username
127
+ account_url = f"{self.base_url}/user/{resolved}/account/"
128
+
129
+ try:
130
+ resp = self.session.get(account_url)
131
+ resp.raise_for_status()
132
+ except requests.RequestException as e:
133
+ raise NetworkError(f"Failed to fetch account page: {e}") from e
134
+
135
+ soup = BeautifulSoup(resp.text, "html.parser")
136
+
137
+ # Look for <code class="api_token"> element
138
+ token_elem = soup.find("code", class_="api_token")
139
+ if token_elem:
140
+ return token_elem.text.strip()
141
+
142
+ raise NotFoundError("API token not found on account page")
143
+
144
+ def create_token(self, username: str | None = None) -> str:
145
+ """Create a new API token via the account page form."""
146
+ resolved = username or self.username
147
+ account_url = f"{self.base_url}/user/{resolved}/account/"
148
+
149
+ try:
150
+ resp = self.session.get(account_url)
151
+ resp.raise_for_status()
152
+ except requests.RequestException as e:
153
+ raise NetworkError(f"Failed to fetch account page: {e}") from e
154
+
155
+ soup = BeautifulSoup(resp.text, "html.parser")
156
+ form = soup.find("form", action=lambda a: a and "new_token" in a)
157
+ if form is None:
158
+ raise NotFoundError("Token creation form not found on account page")
159
+
160
+ csrf_input = form.find("input", {"name": "csrfmiddlewaretoken"})
161
+ if csrf_input is None:
162
+ raise APIError("CSRF token not found in token creation form")
163
+
164
+ post_url = form["action"]
165
+ if not post_url.startswith("http"):
166
+ post_url = f"{self.base_url}{post_url}"
167
+
168
+ data = {"csrfmiddlewaretoken": csrf_input["value"]}
169
+ headers = {"Referer": account_url}
170
+
171
+ try:
172
+ post_resp = self.session.post(post_url, data=data, headers=headers)
173
+ except requests.RequestException as e:
174
+ raise NetworkError(f"Token creation request failed: {e}") from e
175
+
176
+ if post_resp.status_code not in (200, 302):
177
+ raise APIError(f"Token creation failed: HTTP {post_resp.status_code}")
178
+
179
+ # Re-fetch account page to read the newly created token
180
+ try:
181
+ resp2 = self.session.get(account_url)
182
+ resp2.raise_for_status()
183
+ except requests.RequestException as e:
184
+ raise NetworkError(f"Failed to fetch account page after token creation: {e}") from e
185
+
186
+ soup2 = BeautifulSoup(resp2.text, "html.parser")
187
+ token_elem = soup2.find("code", class_="api_token")
188
+ if token_elem:
189
+ return token_elem.text.strip()
190
+
191
+ raise APIError("Token created but could not be read from account page")
192
+
193
+ def extend_expiry(self, username: str | None = None) -> bool:
194
+ resolved = username or self.username
195
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
196
+
197
+ try:
198
+ resp = self.session.get(webapps_url)
199
+ if resp.status_code != 200:
200
+ raise APIError(f"Webapps page returned HTTP {resp.status_code}")
201
+ except requests.RequestException as e:
202
+ raise NetworkError(f"Failed to fetch webapps page: {e}") from e
203
+
204
+ soup = BeautifulSoup(resp.text, "html.parser")
205
+ extend_form = None
206
+ for form in soup.find_all("form"):
207
+ action = form.get("action", "")
208
+ if "extend" in action:
209
+ extend_form = form
210
+ break
211
+
212
+ if extend_form is None:
213
+ raise NotFoundError("Extend form not found on webapps page")
214
+
215
+ csrf_input = extend_form.find("input", {"name": "csrfmiddlewaretoken"})
216
+ if csrf_input is None:
217
+ raise APIError("CSRF token not found in extend form")
218
+
219
+ extend_url = extend_form["action"]
220
+ if not extend_url.startswith("http"):
221
+ extend_url = f"{self.base_url}{extend_url}"
222
+
223
+ data = {"csrfmiddlewaretoken": csrf_input["value"]}
224
+ headers = {"Referer": webapps_url}
225
+
226
+ try:
227
+ extend_resp = self.session.post(extend_url, data=data, headers=headers)
228
+ except requests.RequestException as e:
229
+ raise NetworkError(f"Extend request failed: {e}") from e
230
+
231
+ return extend_resp.status_code in (200, 302)
232
+
233
+ def get_expiry_date(self, username: str | None = None) -> str | None:
234
+ """Get account expiry date from webapps page. Returns text or None."""
235
+ resolved = username or self.username
236
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
237
+
238
+ try:
239
+ resp = self.session.get(webapps_url)
240
+ if resp.status_code != 200:
241
+ return None
242
+ except requests.RequestException:
243
+ return None
244
+
245
+ soup = BeautifulSoup(resp.text, "html.parser")
246
+ expiry_elem = soup.find("p", class_="webapp_expiry")
247
+ if expiry_elem:
248
+ return expiry_elem.get_text(strip=True)
249
+ return None
250
+
251
+ def reload_webapp(self, domain: str, username: str | None = None) -> bool:
252
+ resolved = username or self.username
253
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
254
+
255
+ try:
256
+ resp = self.session.get(webapps_url)
257
+ resp.raise_for_status()
258
+ except requests.RequestException as e:
259
+ raise NetworkError(f"Failed to fetch webapps page: {e}") from e
260
+
261
+ csrf_token = self.session.cookies.get("csrftoken")
262
+ if csrf_token is None:
263
+ raise APIError("CSRF token not found in cookies")
264
+
265
+ reload_url = f"{self.base_url}/user/{resolved}/webapps/{domain}/reload"
266
+ headers = {
267
+ "X-CSRFToken": csrf_token,
268
+ "X-Requested-With": "XMLHttpRequest",
269
+ "Referer": webapps_url,
270
+ "Origin": self.base_url,
271
+ }
272
+
273
+ try:
274
+ reload_resp = self.session.post(reload_url, headers=headers)
275
+ except requests.RequestException as e:
276
+ raise NetworkError(f"Reload request failed: {e}") from e
277
+
278
+ return reload_resp.text == "OK"
279
+
280
+ def get_hits(self, domain: str, username: str | None = None) -> dict:
281
+ resolved = username or self.username
282
+ hits_url = f"{self.base_url}/user/{resolved}/webapps/{domain}/hits_summary/"
283
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
284
+ headers = {
285
+ "X-Requested-With": "XMLHttpRequest",
286
+ "Referer": webapps_url,
287
+ }
288
+
289
+ try:
290
+ resp = self.session.get(hits_url, headers=headers)
291
+ resp.raise_for_status()
292
+ except requests.RequestException as e:
293
+ raise NetworkError(f"Failed to fetch hits: {e}") from e
294
+
295
+ return resp.json()
296
+
297
+ def enable_webapp(self, domain: str, username: str | None = None) -> bool:
298
+ """Enable a web app via web form."""
299
+ resolved = username or self.username
300
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
301
+
302
+ try:
303
+ resp = self.session.get(webapps_url)
304
+ resp.raise_for_status()
305
+ except requests.RequestException as e:
306
+ raise NetworkError(f"Failed to fetch webapps page: {e}") from e
307
+
308
+ soup = BeautifulSoup(resp.text, "html.parser")
309
+ enable_form = soup.find("form", action=lambda a: a and "enable" in a)
310
+ if enable_form is None:
311
+ raise NotFoundError("Enable form not found (webapp may already be enabled)")
312
+
313
+ csrf_input = enable_form.find("input", {"name": "csrfmiddlewaretoken"})
314
+ if csrf_input is None:
315
+ raise APIError("CSRF token not found in enable form")
316
+
317
+ enable_url = f"{self.base_url}{enable_form['action']}"
318
+ data = {"csrfmiddlewaretoken": csrf_input["value"]}
319
+ headers = {"Referer": webapps_url}
320
+
321
+ try:
322
+ enable_resp = self.session.post(enable_url, data=data, headers=headers)
323
+ except requests.RequestException as e:
324
+ raise NetworkError(f"Enable request failed: {e}") from e
325
+
326
+ return enable_resp.status_code in (200, 302)
327
+
328
+ def disable_webapp(self, domain: str, username: str | None = None) -> bool:
329
+ """Disable a web app via web form."""
330
+ resolved = username or self.username
331
+ webapps_url = f"{self.base_url}/user/{resolved}/webapps/"
332
+
333
+ try:
334
+ resp = self.session.get(webapps_url)
335
+ resp.raise_for_status()
336
+ except requests.RequestException as e:
337
+ raise NetworkError(f"Failed to fetch webapps page: {e}") from e
338
+
339
+ soup = BeautifulSoup(resp.text, "html.parser")
340
+ disable_form = soup.find("form", action=lambda a: a and "disable" in a)
341
+ if disable_form is None:
342
+ raise NotFoundError("Disable form not found (webapp may already be disabled)")
343
+
344
+ csrf_input = disable_form.find("input", {"name": "csrfmiddlewaretoken"})
345
+ if csrf_input is None:
346
+ raise APIError("CSRF token not found in disable form")
347
+
348
+ disable_url = f"{self.base_url}{disable_form['action']}"
349
+ data = {"csrfmiddlewaretoken": csrf_input["value"]}
350
+ headers = {"Referer": webapps_url}
351
+
352
+ try:
353
+ disable_resp = self.session.post(disable_url, data=data, headers=headers)
354
+ except requests.RequestException as e:
355
+ raise NetworkError(f"Disable request failed: {e}") from e
356
+
357
+ return disable_resp.status_code in (200, 302)
358
+
359
+ def get_disk_usage(self, username: str | None = None) -> dict:
360
+ """Get disk usage information."""
361
+ resolved = username or self.username
362
+ quota_url = f"{self.base_url}/user/{resolved}/quota_information/"
363
+
364
+ try:
365
+ resp = self.session.get(quota_url)
366
+ resp.raise_for_status()
367
+ except requests.RequestException as e:
368
+ raise NetworkError(f"Failed to fetch disk usage: {e}") from e
369
+
370
+ return resp.json()