|
2 | 2 |
|
3 | 3 | import hashlib |
4 | 4 | import json |
| 5 | +import os |
5 | 6 | import uuid |
6 | | -from typing import NamedTuple |
| 7 | +import warnings |
| 8 | +from contextlib import ExitStack |
| 9 | +from functools import cache |
| 10 | +from pathlib import Path |
| 11 | +from typing import TYPE_CHECKING, Any, Generator, Iterable |
7 | 12 |
|
8 | 13 | import requests |
| 14 | +from platformdirs import user_data_path |
9 | 15 |
|
| 16 | +if TYPE_CHECKING: |
| 17 | + from pylint.message import Message |
10 | 18 |
|
11 | | -def errors_to_dict(errors: list[NamedTuple]) -> dict[str, list[str]]: |
12 | | - """Convert PyTA errors from MessageSet format to a json format Dictionary.""" |
| 19 | +UPLOAD_TIMEOUT_SECONDS = 5 |
| 20 | +PYTHON_TA_DATA_DIR_ENV_VAR = "PYTHON_TA_DATA_DIR" |
| 21 | + |
| 22 | + |
| 23 | +def errors_to_dict(errors: Iterable[list[Message]]) -> dict[str, list[dict[str, Any]]]: |
| 24 | + """Convert PyTA errors to a JSON-compatible dictionary.""" |
13 | 25 | error_info = ["msg_id", "msg", "symbol", "module", "category", "line"] |
14 | | - error_types = ["code", "style"] |
15 | 26 | err_as_dict = {} |
16 | | - for msg_set in errors: # This iterates over the (filename, code, style) MessageSets |
17 | | - for error_type in error_types: # This iterates over the code and style attributes |
18 | | - current_type = getattr(msg_set, error_type) # Gets either the code or style dictionary |
19 | | - for key in current_type.keys(): # Iterates over the error id's of caught errors |
20 | | - err_as_dict[key] = [] |
21 | | - info_set = current_type.get(key) |
22 | | - for ( |
23 | | - msg |
24 | | - ) in ( |
25 | | - info_set.messages |
26 | | - ): # Iterates over the messages for each error of the given code |
27 | | - err_as_dict[key].append({k: getattr(msg, k) for k in error_info}) |
| 27 | + for msg in _iter_error_messages(errors): |
| 28 | + err_as_dict.setdefault(msg.msg_id, []).append( |
| 29 | + {field: getattr(msg, field) for field in error_info} |
| 30 | + ) |
28 | 31 | return err_as_dict |
29 | 32 |
|
30 | 33 |
|
31 | 34 | def upload_to_server( |
32 | | - errors: list[NamedTuple], paths: list[str], config: dict[str, str], url: str, version: str |
| 35 | + errors: Iterable[list[Message]], |
| 36 | + paths: list[str], |
| 37 | + config: dict[str, Any], |
| 38 | + url: str, |
| 39 | + version: str, |
33 | 40 | ) -> None: |
34 | 41 | """Send POST request to server with formatted data.""" |
35 | | - unique_id = get_hashed_id() # Generates a device-specific ID |
36 | | - files = [] |
37 | | - for path in paths: |
38 | | - f = open(path) |
39 | | - files.append(f) |
40 | | - upload = {str(i): f for i, f in enumerate(files)} # requests.post() requires passing a dict |
41 | | - # 'upload' is an empty dict in the case that 'files' is empty |
| 42 | + unique_id = _get_anonymous_id() |
42 | 43 | errors_dict = errors_to_dict(errors) |
43 | 44 | to_json = {"errors": errors_dict} |
44 | 45 | if config: # 'config' is an empty dictionary if the default was used |
45 | 46 | to_json["cfg"] = config |
46 | | - payload = json.dumps(to_json) |
| 47 | + payload = json.dumps(to_json, default=str) |
| 48 | + |
47 | 49 | try: |
48 | | - response = requests.post( |
49 | | - url=url, files=upload, data={"id": unique_id, "version": version, "payload": payload} |
50 | | - ) |
51 | | - for f in files: |
52 | | - f.close() |
| 50 | + with ExitStack() as stack: |
| 51 | + upload = {str(i): stack.enter_context(open(path, "rb")) for i, path in enumerate(paths)} |
| 52 | + response = requests.post( |
| 53 | + url=url, |
| 54 | + files=upload, |
| 55 | + data={"id": unique_id, "version": version, "payload": payload}, |
| 56 | + timeout=UPLOAD_TIMEOUT_SECONDS, |
| 57 | + ) |
53 | 58 | response.raise_for_status() |
54 | 59 | print("[INFO] Upload successful") |
55 | 60 | except requests.HTTPError as e: |
56 | 61 | print("[ERROR] Upload failed") |
57 | | - if e.response.status_code == 400: |
| 62 | + status_code = e.response.status_code if e.response is not None else None |
| 63 | + if status_code == 400: |
58 | 64 | print( |
59 | 65 | "[ERROR] HTTP Response Status 400: Client-side error, likely due to improper syntax. " |
60 | 66 | "Please report this to your instructor (and attach the code that caused the error)." |
61 | 67 | ) |
62 | | - elif e.response.status_code == 403: |
| 68 | + elif status_code == 403: |
63 | 69 | print( |
64 | 70 | "[ERROR] HTTP Response Status 403: Authorization is currently required for submission." |
65 | 71 | ) |
66 | | - elif e.response.status_code == 500: |
| 72 | + elif status_code == 500: |
67 | 73 | print( |
68 | 74 | "[ERROR] HTTP Response Status 500: The server ran into a situation it doesn't know how to handle. " |
69 | 75 | ) |
70 | 76 | print( |
71 | 77 | "Please report this to your instructor (and attach the code that caused the error)." |
72 | 78 | ) |
73 | | - elif e.response.status_code == 503: |
| 79 | + elif status_code == 503: |
74 | 80 | print( |
75 | 81 | "[ERROR] HTTP Response Status 503: The server is not ready to handle your request. " |
76 | 82 | ) |
77 | 83 | print("It may be down for maintenance.") |
78 | 84 | else: |
79 | 85 | print('[ERROR] Error message: "{}"'.format(e)) |
80 | | - |
81 | | - except requests.ConnectionError as e: |
| 86 | + except requests.Timeout: |
| 87 | + print("[ERROR] Upload failed") |
| 88 | + print("[ERROR] Error message: Connection timed out. The server may be temporarily down.") |
| 89 | + except requests.ConnectionError: |
82 | 90 | print("[ERROR] Upload failed") |
83 | 91 | print( |
84 | | - "[ERROR] Error message: Connection timed out. This may be caused by your firewall, or the server may be " |
| 92 | + "[ERROR] Error message: Could not connect. This may be caused by your firewall, or the server may be " |
85 | 93 | "temporarily down." |
86 | 94 | ) |
| 95 | + except requests.RequestException as e: |
| 96 | + print("[ERROR] Upload failed") |
| 97 | + print('[ERROR] Error message: "{}"'.format(e)) |
| 98 | + except OSError as e: |
| 99 | + print("[ERROR] Upload failed") |
| 100 | + print(f'[ERROR] Could not read a file selected for upload: "{e}"') |
87 | 101 |
|
88 | 102 |
|
89 | | -def get_hashed_id() -> str: |
90 | | - """ |
91 | | - Generates a unique ID by hashing the user's mac-address. |
| 103 | +def _get_anonymous_id() -> str: |
| 104 | + """Return an anonymous ID for opt-in data uploads. |
| 105 | +
|
| 106 | + This is a hash of a random local ID so multiple opt-in uploads can be |
| 107 | + grouped without deriving an identifier from hardware information. |
92 | 108 | """ |
93 | | - mac = str(uuid.uuid1())[24:] |
94 | | - hash_gen = hashlib.sha512() |
95 | | - encoded = mac.encode("utf-8") |
96 | | - hash_gen.update(encoded) |
97 | | - return hash_gen.hexdigest() |
| 109 | + local_anonymous_id = _get_or_create_local_anonymous_id(_get_anonymous_id_path()) |
| 110 | + return hashlib.sha512(local_anonymous_id.encode("utf-8")).hexdigest() |
| 111 | + |
| 112 | + |
| 113 | +def get_hashed_id() -> str: |
| 114 | + """Return the anonymous upload ID.""" |
| 115 | + warnings.warn( |
| 116 | + "get_hashed_id is deprecated and should not be called directly.", |
| 117 | + DeprecationWarning, |
| 118 | + stacklevel=2, |
| 119 | + ) |
| 120 | + return _get_anonymous_id() |
| 121 | + |
| 122 | + |
| 123 | +@cache |
| 124 | +def _get_or_create_local_anonymous_id(anonymous_id_path: Path) -> str: |
| 125 | + """Return the random local ID used as input for the anonymous upload ID.""" |
| 126 | + try: |
| 127 | + anonymous_id = anonymous_id_path.read_text(encoding="utf-8").strip() |
| 128 | + uuid.UUID(anonymous_id) |
| 129 | + return anonymous_id |
| 130 | + except (OSError, ValueError): |
| 131 | + anonymous_id = str(uuid.uuid4()) |
| 132 | + |
| 133 | + try: |
| 134 | + anonymous_id_path.parent.mkdir(parents=True, exist_ok=True) |
| 135 | + anonymous_id_path.write_text(anonymous_id + "\n", encoding="utf-8") |
| 136 | + print(f"[INFO] Saved anonymous ID to {anonymous_id_path}") |
| 137 | + except OSError: |
| 138 | + pass |
| 139 | + return anonymous_id |
| 140 | + |
| 141 | + |
| 142 | +def _iter_error_messages(errors: Iterable[list[Message]]) -> Generator[Message, None, None]: |
| 143 | + """Yield individual messages from current reporter upload data.""" |
| 144 | + for error_group in errors: |
| 145 | + yield from error_group |
| 146 | + |
| 147 | + |
| 148 | +def _get_anonymous_id_path() -> Path: |
| 149 | + """Return the local path used to store the anonymous upload ID.""" |
| 150 | + if PYTHON_TA_DATA_DIR_ENV_VAR in os.environ: |
| 151 | + return Path(os.environ[PYTHON_TA_DATA_DIR_ENV_VAR]).expanduser() / "anonymous_id" |
| 152 | + |
| 153 | + return user_data_path("PythonTA", appauthor=False) / "anonymous_id" |
0 commit comments