scratchattach 2.1.13__py3-none-any.whl → 2.1.14__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.
- scratchattach/cloud/_base.py +12 -8
- scratchattach/cloud/cloud.py +19 -7
- scratchattach/editor/asset.py +59 -5
- scratchattach/editor/base.py +82 -31
- scratchattach/editor/block.py +86 -15
- scratchattach/editor/blockshape.py +8 -4
- scratchattach/editor/build_defaulting.py +6 -2
- scratchattach/editor/code_translation/__init__.py +0 -0
- scratchattach/editor/code_translation/parse.py +177 -0
- scratchattach/editor/comment.py +6 -0
- scratchattach/editor/commons.py +82 -19
- scratchattach/editor/extension.py +10 -3
- scratchattach/editor/field.py +9 -0
- scratchattach/editor/inputs.py +4 -1
- scratchattach/editor/meta.py +11 -3
- scratchattach/editor/monitor.py +46 -38
- scratchattach/editor/mutation.py +11 -4
- scratchattach/editor/pallete.py +24 -25
- scratchattach/editor/prim.py +2 -2
- scratchattach/editor/project.py +9 -3
- scratchattach/editor/sprite.py +19 -6
- scratchattach/editor/twconfig.py +2 -1
- scratchattach/editor/vlb.py +1 -1
- scratchattach/eventhandlers/_base.py +2 -2
- scratchattach/eventhandlers/cloud_events.py +2 -2
- scratchattach/eventhandlers/cloud_requests.py +3 -3
- scratchattach/eventhandlers/cloud_server.py +3 -3
- scratchattach/eventhandlers/message_events.py +1 -1
- scratchattach/other/other_apis.py +4 -4
- scratchattach/other/project_json_capabilities.py +3 -3
- scratchattach/site/_base.py +13 -12
- scratchattach/site/activity.py +11 -43
- scratchattach/site/alert.py +227 -0
- scratchattach/site/backpack_asset.py +2 -2
- scratchattach/site/browser_cookie3_stub.py +17 -0
- scratchattach/site/browser_cookies.py +27 -21
- scratchattach/site/classroom.py +51 -34
- scratchattach/site/cloud_activity.py +4 -4
- scratchattach/site/comment.py +30 -8
- scratchattach/site/forum.py +101 -69
- scratchattach/site/project.py +37 -17
- scratchattach/site/session.py +169 -79
- scratchattach/site/studio.py +4 -4
- scratchattach/site/user.py +179 -64
- scratchattach/utils/commons.py +35 -23
- scratchattach/utils/enums.py +44 -5
- scratchattach/utils/exceptions.py +10 -0
- scratchattach/utils/requests.py +57 -31
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.14.dist-info}/METADATA +8 -3
- scratchattach-2.1.14.dist-info/RECORD +66 -0
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.14.dist-info}/WHEEL +1 -1
- scratchattach/editor/sbuild.py +0 -2837
- scratchattach-2.1.13.dist-info/RECORD +0 -63
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.14.dist-info}/licenses/LICENSE +0 -0
- {scratchattach-2.1.13.dist-info → scratchattach-2.1.14.dist-info}/top_level.txt +0 -0
scratchattach/site/session.py
CHANGED
|
@@ -10,37 +10,34 @@ import random
|
|
|
10
10
|
import re
|
|
11
11
|
import time
|
|
12
12
|
import warnings
|
|
13
|
-
|
|
13
|
+
import zlib
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Optional, TypeVar, TYPE_CHECKING, overload, Any, Union
|
|
14
17
|
from contextlib import contextmanager
|
|
15
18
|
from threading import local
|
|
16
19
|
|
|
17
|
-
# import secrets
|
|
18
|
-
# import zipfile
|
|
19
|
-
# from typing import Type
|
|
20
20
|
Type = type
|
|
21
|
-
|
|
22
|
-
from warnings import deprecated
|
|
23
|
-
except ImportError:
|
|
24
|
-
deprecated = lambda x: (lambda y: y)
|
|
21
|
+
|
|
25
22
|
if TYPE_CHECKING:
|
|
26
23
|
from _typeshed import FileDescriptorOrPath, SupportsRead
|
|
27
|
-
from
|
|
24
|
+
from scratchattach.cloud._base import BaseCloud
|
|
28
25
|
T = TypeVar("T", bound=BaseCloud)
|
|
29
26
|
else:
|
|
30
27
|
T = TypeVar("T")
|
|
31
28
|
|
|
32
|
-
from bs4 import BeautifulSoup
|
|
29
|
+
from bs4 import BeautifulSoup, Tag
|
|
30
|
+
from typing_extensions import deprecated
|
|
33
31
|
|
|
34
|
-
from . import activity, classroom, forum, studio, user, project, backpack_asset
|
|
32
|
+
from . import activity, classroom, forum, studio, user, project, backpack_asset, alert
|
|
35
33
|
# noinspection PyProtectedMember
|
|
36
34
|
from ._base import BaseSiteComponent
|
|
37
|
-
from
|
|
38
|
-
from
|
|
39
|
-
from
|
|
40
|
-
from
|
|
41
|
-
from
|
|
42
|
-
from
|
|
43
|
-
from ..utils.requests import Requests as requests
|
|
35
|
+
from scratchattach.cloud import cloud, _base
|
|
36
|
+
from scratchattach.eventhandlers import message_events, filterbot
|
|
37
|
+
from scratchattach.other import project_json_capabilities
|
|
38
|
+
from scratchattach.utils import commons, exceptions
|
|
39
|
+
from scratchattach.utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
|
|
40
|
+
from scratchattach.utils.requests import requests
|
|
44
41
|
from .browser_cookies import Browser, ANY, cookies_from_browser
|
|
45
42
|
|
|
46
43
|
ratelimit_cache: dict[str, list[float]] = {}
|
|
@@ -63,6 +60,7 @@ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 6
|
|
|
63
60
|
|
|
64
61
|
C = TypeVar("C", bound=BaseSiteComponent)
|
|
65
62
|
|
|
63
|
+
@dataclass
|
|
66
64
|
class Session(BaseSiteComponent):
|
|
67
65
|
"""
|
|
68
66
|
Represents a Scratch log in / session. Stores authentication data (session id and xtoken).
|
|
@@ -76,27 +74,29 @@ class Session(BaseSiteComponent):
|
|
|
76
74
|
mute_status: Information about commenting restrictions of the associated account
|
|
77
75
|
banned: Returns True if the associated account is banned
|
|
78
76
|
"""
|
|
79
|
-
|
|
77
|
+
username: str = None
|
|
78
|
+
_user: user.User = field(repr=False, default=None)
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
id: str = field(repr=False, default=None)
|
|
81
|
+
session_string: str | None = field(repr=False, default=None)
|
|
82
|
+
xtoken: str = field(repr=False, default=None)
|
|
83
|
+
email: str = field(repr=False, default=None)
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
new_scratcher: bool = field(repr=False, default=None)
|
|
86
|
+
mute_status: Any = field(repr=False, default=None)
|
|
87
|
+
banned: bool = field(repr=False, default=None)
|
|
88
|
+
is_teacher: bool = field(repr=False, default=None)
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
self.username = None
|
|
92
|
-
self.xtoken = None
|
|
93
|
-
self.new_scratcher = None
|
|
90
|
+
time_created: datetime.datetime = None
|
|
91
|
+
language: str = field(repr=False, default="en")
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
return f"<Login for {self.username!r}>"
|
|
97
95
|
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
def __post_init__(self):
|
|
97
|
+
# Info on how the .update method has to fetch the data:
|
|
98
|
+
self.update_function = requests.post
|
|
99
|
+
self.update_api = "https://scratch.mit.edu/session"
|
|
100
100
|
|
|
101
101
|
# Set alternative attributes:
|
|
102
102
|
self._username = self.username # backwards compatibility with v1
|
|
@@ -111,6 +111,9 @@ class Session(BaseSiteComponent):
|
|
|
111
111
|
"Content-Type": "application/json",
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
if self.id:
|
|
115
|
+
self._process_session_id()
|
|
116
|
+
|
|
114
117
|
def _update_from_dict(self, data: dict):
|
|
115
118
|
# Note: there are a lot more things you can get from this data dict.
|
|
116
119
|
# Maybe it would be a good idea to also store the dict itself?
|
|
@@ -133,13 +136,34 @@ class Session(BaseSiteComponent):
|
|
|
133
136
|
self.banned = data["user"]["banned"]
|
|
134
137
|
|
|
135
138
|
if self.banned:
|
|
136
|
-
warnings.warn(f"Warning: The account {self.
|
|
139
|
+
warnings.warn(f"Warning: The account {self.username} you logged in to is BANNED. "
|
|
137
140
|
f"Some features may not work properly.")
|
|
138
141
|
if self.has_outstanding_email_confirmation:
|
|
139
|
-
warnings.warn(f"Warning: The account {self.
|
|
142
|
+
warnings.warn(f"Warning: The account {self.username} you logged is not email confirmed. "
|
|
140
143
|
f"Some features may not work properly.")
|
|
141
144
|
return True
|
|
142
145
|
|
|
146
|
+
def _process_session_id(self):
|
|
147
|
+
assert self.id
|
|
148
|
+
|
|
149
|
+
data, self.time_created = decode_session_id(self.id)
|
|
150
|
+
|
|
151
|
+
self.username = data["username"]
|
|
152
|
+
self._username = self.username
|
|
153
|
+
if self._user:
|
|
154
|
+
self._user.username = self.username
|
|
155
|
+
else:
|
|
156
|
+
self._user = user.User(_session=self, username=self.username)
|
|
157
|
+
|
|
158
|
+
self._user.id = data["_auth_user_id"]
|
|
159
|
+
self.xtoken = data["token"]
|
|
160
|
+
self._headers["X-Token"] = self.xtoken
|
|
161
|
+
|
|
162
|
+
# not saving the login ip because it is a security issue, and is not very helpful
|
|
163
|
+
|
|
164
|
+
self.language = data["_language"]
|
|
165
|
+
# self._cookies["scratchlanguage"] = self.language
|
|
166
|
+
|
|
143
167
|
def connect_linked_user(self) -> user.User:
|
|
144
168
|
"""
|
|
145
169
|
Gets the user associated with the login / session.
|
|
@@ -158,7 +182,7 @@ class Session(BaseSiteComponent):
|
|
|
158
182
|
self._user = self.connect_user(self._username)
|
|
159
183
|
return self._user
|
|
160
184
|
|
|
161
|
-
def get_linked_user(self) ->
|
|
185
|
+
def get_linked_user(self) -> user.User:
|
|
162
186
|
# backwards compatibility with v1
|
|
163
187
|
|
|
164
188
|
# To avoid inconsistencies with "connect" and "get", this function was renamed
|
|
@@ -183,16 +207,27 @@ class Session(BaseSiteComponent):
|
|
|
183
207
|
password (str): Password associated with the session (not stored)
|
|
184
208
|
"""
|
|
185
209
|
requests.post("https://scratch.mit.edu/accounts/email_change/",
|
|
186
|
-
data={"email_address": self.
|
|
210
|
+
data={"email_address": self.get_new_email_address(),
|
|
187
211
|
"password": password},
|
|
188
212
|
headers=self._headers, cookies=self._cookies)
|
|
189
213
|
|
|
190
214
|
@property
|
|
215
|
+
@deprecated("Use get_new_email_address instead.")
|
|
191
216
|
def new_email_address(self) -> str:
|
|
192
217
|
"""
|
|
193
218
|
Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
|
|
194
219
|
otherwise the current address.
|
|
195
220
|
|
|
221
|
+
Returns:
|
|
222
|
+
str: The email that this session wants to switch to
|
|
223
|
+
"""
|
|
224
|
+
return self.get_new_email_address()
|
|
225
|
+
|
|
226
|
+
def get_new_email_address(self) -> str:
|
|
227
|
+
"""
|
|
228
|
+
Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
|
|
229
|
+
otherwise the current address.
|
|
230
|
+
|
|
196
231
|
Returns:
|
|
197
232
|
str: The email that this session wants to switch to
|
|
198
233
|
"""
|
|
@@ -203,6 +238,10 @@ class Session(BaseSiteComponent):
|
|
|
203
238
|
|
|
204
239
|
email = None
|
|
205
240
|
for label_span in soup.find_all("span", {"class": "label"}):
|
|
241
|
+
if not isinstance(label_span, Tag):
|
|
242
|
+
continue
|
|
243
|
+
if not isinstance(label_span.parent, Tag):
|
|
244
|
+
continue
|
|
206
245
|
if label_span.contents[0] == "New Email Address":
|
|
207
246
|
return label_span.parent.contents[-1].text.strip("\n ")
|
|
208
247
|
|
|
@@ -252,6 +291,13 @@ class Session(BaseSiteComponent):
|
|
|
252
291
|
|
|
253
292
|
def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
|
|
254
293
|
page: Optional[int] = None):
|
|
294
|
+
"""
|
|
295
|
+
Load and parse admin alerts, optionally for a specific class, using https://scratch.mit.edu/site-api/classrooms/alerts/
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
list[alert.EducatorAlert]: A list of parsed EducatorAlert objects
|
|
299
|
+
"""
|
|
300
|
+
|
|
255
301
|
if isinstance(_classroom, classroom.Classroom):
|
|
256
302
|
_classroom = _classroom.id
|
|
257
303
|
|
|
@@ -266,7 +312,9 @@ class Session(BaseSiteComponent):
|
|
|
266
312
|
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
267
313
|
headers=self._headers, cookies=self._cookies).json()
|
|
268
314
|
|
|
269
|
-
|
|
315
|
+
alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data]
|
|
316
|
+
|
|
317
|
+
return alerts
|
|
270
318
|
|
|
271
319
|
def clear_messages(self):
|
|
272
320
|
"""
|
|
@@ -470,20 +518,22 @@ class Session(BaseSiteComponent):
|
|
|
470
518
|
|
|
471
519
|
def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
472
520
|
offset: int = 0) -> list[studio.Studio]:
|
|
473
|
-
if not query:
|
|
474
|
-
|
|
521
|
+
#if not query:
|
|
522
|
+
# raise ValueError("The query can't be empty for search")
|
|
523
|
+
query = f"&q={query}" if query else ""
|
|
475
524
|
response = commons.api_iterative(
|
|
476
525
|
f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
|
|
477
|
-
add_params=f"&language={language}&mode={mode}
|
|
526
|
+
add_params=f"&language={language}&mode={mode}{query}")
|
|
478
527
|
return commons.parse_object_list(response, studio.Studio, self)
|
|
479
528
|
|
|
480
529
|
def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
481
530
|
offset: int = 0) -> list[studio.Studio]:
|
|
482
|
-
if not query:
|
|
483
|
-
|
|
531
|
+
#if not query:
|
|
532
|
+
# raise ValueError("The query can't be empty for explore")
|
|
533
|
+
query = f"&q={query}" if query else ""
|
|
484
534
|
response = commons.api_iterative(
|
|
485
535
|
f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
|
|
486
|
-
add_params=f"&language={language}&mode={mode}
|
|
536
|
+
add_params=f"&language={language}&mode={mode}{query}")
|
|
487
537
|
return commons.parse_object_list(response, studio.Studio, self)
|
|
488
538
|
|
|
489
539
|
# --- Create project API ---
|
|
@@ -618,9 +668,10 @@ class Session(BaseSiteComponent):
|
|
|
618
668
|
ascsort = sort_by
|
|
619
669
|
descsort = ""
|
|
620
670
|
try:
|
|
671
|
+
params: dict[str, Union[str, int]] = {"page": page, "ascsort": ascsort, "descsort": descsort}
|
|
621
672
|
targets = requests.get(
|
|
622
673
|
f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/",
|
|
623
|
-
params=
|
|
674
|
+
params=params,
|
|
624
675
|
headers=headers,
|
|
625
676
|
cookies=self._cookies,
|
|
626
677
|
timeout=10
|
|
@@ -646,6 +697,9 @@ class Session(BaseSiteComponent):
|
|
|
646
697
|
raise exceptions.FetchError()
|
|
647
698
|
|
|
648
699
|
def mystuff_classes(self, mode: str = "Last created", page: Optional[int] = None) -> list[classroom.Classroom]:
|
|
700
|
+
if self.is_teacher is None:
|
|
701
|
+
self.update()
|
|
702
|
+
|
|
649
703
|
if not self.is_teacher:
|
|
650
704
|
raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes")
|
|
651
705
|
ascsort, descsort = get_class_sort_mode(mode)
|
|
@@ -849,6 +903,7 @@ class Session(BaseSiteComponent):
|
|
|
849
903
|
Returns:
|
|
850
904
|
scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
|
|
851
905
|
"""
|
|
906
|
+
# noinspection PyDeprecation
|
|
852
907
|
return self._make_linked_object("username", self.find_username_from_id(user_id), user.User,
|
|
853
908
|
exceptions.UserNotFound)
|
|
854
909
|
|
|
@@ -981,11 +1036,50 @@ sess
|
|
|
981
1036
|
def get_session_string(self) -> str:
|
|
982
1037
|
assert self.session_string
|
|
983
1038
|
return self.session_string
|
|
1039
|
+
|
|
1040
|
+
def get_headers(self) -> dict[str, str]:
|
|
1041
|
+
return self._headers
|
|
1042
|
+
|
|
1043
|
+
def get_cookies(self) -> dict[str, str]:
|
|
1044
|
+
return self._cookies
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
# ------ #
|
|
1048
|
+
|
|
1049
|
+
def decode_session_id(session_id: str) -> tuple[dict[str, str], datetime.datetime]:
|
|
1050
|
+
"""
|
|
1051
|
+
Extract the JSON data from the main part of a session ID string
|
|
1052
|
+
Session id is in the format:
|
|
1053
|
+
<p1: long base64 string>:<p2: short base64 string>:<p3: medium base64 string>
|
|
1054
|
+
|
|
1055
|
+
p1 contains a base64-zlib compressed JSON string
|
|
1056
|
+
p2 is a base 62 encoded timestamp
|
|
1057
|
+
p3 might be a `synchronous signature` for the first 2 parts (might be useless for us)
|
|
1058
|
+
|
|
1059
|
+
The dict has these attributes:
|
|
1060
|
+
- username
|
|
1061
|
+
- _auth_user_id
|
|
1062
|
+
- testcookie
|
|
1063
|
+
- _auth_user_backend
|
|
1064
|
+
- token
|
|
1065
|
+
- login-ip
|
|
1066
|
+
- _language
|
|
1067
|
+
- django_timezone
|
|
1068
|
+
- _auth_user_hash
|
|
1069
|
+
"""
|
|
1070
|
+
p1, p2, p3 = session_id.split(':')
|
|
1071
|
+
|
|
1072
|
+
return (
|
|
1073
|
+
json.loads(zlib.decompress(base64.urlsafe_b64decode(p1 + "=="))),
|
|
1074
|
+
datetime.datetime.fromtimestamp(commons.b62_decode(p2))
|
|
1075
|
+
)
|
|
1076
|
+
|
|
984
1077
|
|
|
985
1078
|
# ------ #
|
|
986
1079
|
|
|
987
1080
|
suppressed_login_warning = local()
|
|
988
1081
|
|
|
1082
|
+
|
|
989
1083
|
@contextmanager
|
|
990
1084
|
def suppress_login_warning():
|
|
991
1085
|
"""
|
|
@@ -998,6 +1092,7 @@ def suppress_login_warning():
|
|
|
998
1092
|
finally:
|
|
999
1093
|
suppressed_login_warning.suppressed -= 1
|
|
1000
1094
|
|
|
1095
|
+
|
|
1001
1096
|
def issue_login_warning() -> None:
|
|
1002
1097
|
"""
|
|
1003
1098
|
Issue a login data warning.
|
|
@@ -1012,6 +1107,7 @@ def issue_login_warning() -> None:
|
|
|
1012
1107
|
exceptions.LoginDataWarning
|
|
1013
1108
|
)
|
|
1014
1109
|
|
|
1110
|
+
|
|
1015
1111
|
def login_by_id(session_id: str, *, username: Optional[str] = None, password: Optional[str] = None, xtoken=None) -> Session:
|
|
1016
1112
|
"""
|
|
1017
1113
|
Creates a session / log in to the Scratch website with the specified session id.
|
|
@@ -1028,39 +1124,20 @@ def login_by_id(session_id: str, *, username: Optional[str] = None, password: Op
|
|
|
1028
1124
|
Returns:
|
|
1029
1125
|
scratchattach.session.Session: An object that represents the created login / session
|
|
1030
1126
|
"""
|
|
1031
|
-
# Removed this from docstring since it doesn't exist:
|
|
1032
|
-
# timeout (int): Optional, but recommended.
|
|
1033
|
-
# Specify this when the Python environment's IP address is blocked by Scratch's API,
|
|
1034
|
-
# but you still want to use cloud variables.
|
|
1035
|
-
|
|
1036
1127
|
# Generate session_string (a scratchattach-specific authentication method)
|
|
1128
|
+
# should this be changed to a @property?
|
|
1037
1129
|
issue_login_warning()
|
|
1038
1130
|
if password is not None:
|
|
1039
|
-
session_data = dict(
|
|
1131
|
+
session_data = dict(id=session_id, username=username, password=password)
|
|
1040
1132
|
session_string = base64.b64encode(json.dumps(session_data).encode()).decode()
|
|
1041
1133
|
else:
|
|
1042
1134
|
session_string = None
|
|
1043
|
-
_session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken)
|
|
1044
1135
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if status is not True:
|
|
1052
|
-
if _session.xtoken is None:
|
|
1053
|
-
if _session.username is None:
|
|
1054
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
|
|
1055
|
-
"Make sure the provided session id is valid. "
|
|
1056
|
-
"Setting cloud variables can still work if you provide a "
|
|
1057
|
-
"`username='username'` keyword argument to the sa.login_by_id function")
|
|
1058
|
-
else:
|
|
1059
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
|
|
1060
|
-
"Make sure the provided session id is valid.")
|
|
1061
|
-
else:
|
|
1062
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch session info. "
|
|
1063
|
-
"This won't affect any other features.")
|
|
1136
|
+
_session = Session(id=session_id, username=username, session_string=session_string)
|
|
1137
|
+
if xtoken is not None:
|
|
1138
|
+
# xtoken is retrievable from session id, so the most we can do is assert equality
|
|
1139
|
+
assert xtoken == _session.xtoken
|
|
1140
|
+
|
|
1064
1141
|
return _session
|
|
1065
1142
|
|
|
1066
1143
|
|
|
@@ -1087,14 +1164,16 @@ def login(username, password, *, timeout=10) -> Session:
|
|
|
1087
1164
|
# Post request to login API:
|
|
1088
1165
|
_headers = headers.copy()
|
|
1089
1166
|
_headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1167
|
+
with requests.no_error_handling():
|
|
1168
|
+
request = requests.post(
|
|
1169
|
+
"https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
|
|
1170
|
+
timeout=timeout
|
|
1171
|
+
)
|
|
1095
1172
|
try:
|
|
1096
|
-
|
|
1097
|
-
|
|
1173
|
+
result = re.search('"(.*)"', request.headers["Set-Cookie"])
|
|
1174
|
+
assert result is not None
|
|
1175
|
+
session_id = str(result.group())
|
|
1176
|
+
except Exception:
|
|
1098
1177
|
raise exceptions.LoginFailure(
|
|
1099
1178
|
"Either the provided authentication data is wrong or your network is banned from Scratch.\n\nIf you're using an online IDE (like replit.com) Scratch possibly banned its IP address. In this case, try logging in with your session id: https://github.com/TimMcCool/scratchattach/wiki#logging-in")
|
|
1100
1179
|
|
|
@@ -1102,6 +1181,7 @@ def login(username, password, *, timeout=10) -> Session:
|
|
|
1102
1181
|
with suppress_login_warning():
|
|
1103
1182
|
return login_by_id(session_id, username=username, password=password)
|
|
1104
1183
|
|
|
1184
|
+
|
|
1105
1185
|
def login_by_session_string(session_string: str) -> Session:
|
|
1106
1186
|
"""
|
|
1107
1187
|
Login using a session string.
|
|
@@ -1109,6 +1189,13 @@ def login_by_session_string(session_string: str) -> Session:
|
|
|
1109
1189
|
issue_login_warning()
|
|
1110
1190
|
session_string = base64.b64decode(session_string).decode() # unobfuscate
|
|
1111
1191
|
session_data = json.loads(session_string)
|
|
1192
|
+
try:
|
|
1193
|
+
assert session_data.get("id")
|
|
1194
|
+
with suppress_login_warning():
|
|
1195
|
+
return login_by_id(session_data["id"], username=session_data.get("username"),
|
|
1196
|
+
password=session_data.get("password"))
|
|
1197
|
+
except Exception:
|
|
1198
|
+
pass
|
|
1112
1199
|
try:
|
|
1113
1200
|
assert session_data.get("session_id")
|
|
1114
1201
|
with suppress_login_warning():
|
|
@@ -1124,6 +1211,7 @@ def login_by_session_string(session_string: str) -> Session:
|
|
|
1124
1211
|
pass
|
|
1125
1212
|
raise ValueError("Couldn't log in.")
|
|
1126
1213
|
|
|
1214
|
+
|
|
1127
1215
|
def login_by_io(file: SupportsRead[str]) -> Session:
|
|
1128
1216
|
"""
|
|
1129
1217
|
Login using a file object.
|
|
@@ -1131,6 +1219,7 @@ def login_by_io(file: SupportsRead[str]) -> Session:
|
|
|
1131
1219
|
with suppress_login_warning():
|
|
1132
1220
|
return login_by_session_string(file.read())
|
|
1133
1221
|
|
|
1222
|
+
|
|
1134
1223
|
def login_by_file(file: FileDescriptorOrPath) -> Session:
|
|
1135
1224
|
"""
|
|
1136
1225
|
Login using a path to a file.
|
|
@@ -1138,6 +1227,7 @@ def login_by_file(file: FileDescriptorOrPath) -> Session:
|
|
|
1138
1227
|
with suppress_login_warning(), open(file, encoding="utf-8") as f:
|
|
1139
1228
|
return login_by_io(f)
|
|
1140
1229
|
|
|
1230
|
+
|
|
1141
1231
|
def login_from_browser(browser: Browser = ANY):
|
|
1142
1232
|
"""
|
|
1143
1233
|
Login from a browser
|
scratchattach/site/studio.py
CHANGED
|
@@ -4,11 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
import json
|
|
5
5
|
import random
|
|
6
6
|
from . import user, comment, project, activity
|
|
7
|
-
from
|
|
8
|
-
from
|
|
7
|
+
from scratchattach.utils import exceptions, commons
|
|
8
|
+
from scratchattach.utils.commons import api_iterative, headers
|
|
9
9
|
from ._base import BaseSiteComponent
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from scratchattach.utils.requests import requests
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class Studio(BaseSiteComponent):
|
|
@@ -49,7 +49,7 @@ class Studio(BaseSiteComponent):
|
|
|
49
49
|
|
|
50
50
|
# Info on how the .update method has to fetch the data:
|
|
51
51
|
self.update_function = requests.get
|
|
52
|
-
self.
|
|
52
|
+
self.update_api = f"https://api.scratch.mit.edu/studios/{entries['id']}"
|
|
53
53
|
|
|
54
54
|
# Set attributes every Project object needs to have:
|
|
55
55
|
self._session = None
|