scratchattach 2.1.12__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 +87 -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 +3 -3
- scratchattach/eventhandlers/cloud_events.py +2 -2
- scratchattach/eventhandlers/cloud_requests.py +4 -7
- scratchattach/eventhandlers/cloud_server.py +3 -3
- scratchattach/eventhandlers/combine.py +2 -2
- 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 +177 -83
- scratchattach/site/studio.py +4 -4
- scratchattach/site/user.py +184 -62
- 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.12.dist-info → scratchattach-2.1.14.dist-info}/METADATA +9 -3
- scratchattach-2.1.14.dist-info/RECORD +66 -0
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/WHEEL +1 -1
- scratchattach/editor/sbuild.py +0 -2837
- scratchattach-2.1.12.dist-info/RECORD +0 -63
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/licenses/LICENSE +0 -0
- {scratchattach-2.1.12.dist-info → scratchattach-2.1.14.dist-info}/top_level.txt +0 -0
scratchattach/site/project.py
CHANGED
|
@@ -5,15 +5,19 @@ import json
|
|
|
5
5
|
import random
|
|
6
6
|
import base64
|
|
7
7
|
import time
|
|
8
|
+
import zipfile
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
8
12
|
from . import user, comment, studio
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
13
|
+
from scratchattach.utils import exceptions
|
|
14
|
+
from scratchattach.utils import commons
|
|
15
|
+
from scratchattach.utils.commons import empty_project_json, headers
|
|
12
16
|
from ._base import BaseSiteComponent
|
|
13
|
-
from
|
|
14
|
-
from
|
|
17
|
+
from scratchattach.other.project_json_capabilities import ProjectBody
|
|
18
|
+
from scratchattach.utils.requests import requests
|
|
15
19
|
|
|
16
|
-
CREATE_PROJECT_USES = []
|
|
20
|
+
CREATE_PROJECT_USES: list[float] = []
|
|
17
21
|
|
|
18
22
|
class PartialProject(BaseSiteComponent):
|
|
19
23
|
"""
|
|
@@ -27,7 +31,7 @@ class PartialProject(BaseSiteComponent):
|
|
|
27
31
|
|
|
28
32
|
# Info on how the .update method has to fetch the data:
|
|
29
33
|
self.update_function = requests.get
|
|
30
|
-
self.
|
|
34
|
+
self.update_api = f"https://api.scratch.mit.edu/projects/{entries['id']}"
|
|
31
35
|
|
|
32
36
|
# Set attributes every Project object needs to have:
|
|
33
37
|
self._session = None
|
|
@@ -126,6 +130,9 @@ class PartialProject(BaseSiteComponent):
|
|
|
126
130
|
p = get_project(self.id)
|
|
127
131
|
return isinstance(p, Project)
|
|
128
132
|
|
|
133
|
+
def raw_json_or_empty(self) -> dict[str, Any]:
|
|
134
|
+
return empty_project_json
|
|
135
|
+
|
|
129
136
|
def create_remix(self, *, title=None, project_json=None): # not working
|
|
130
137
|
"""
|
|
131
138
|
Creates a project on the Scratch website.
|
|
@@ -142,10 +149,7 @@ class PartialProject(BaseSiteComponent):
|
|
|
142
149
|
else:
|
|
143
150
|
title = " remix"
|
|
144
151
|
if project_json is None:
|
|
145
|
-
|
|
146
|
-
project_json = self.raw_json()
|
|
147
|
-
else:
|
|
148
|
-
project_json = empty_project_json
|
|
152
|
+
project_json = self.raw_json_or_empty()
|
|
149
153
|
|
|
150
154
|
if len(CREATE_PROJECT_USES) < 5:
|
|
151
155
|
CREATE_PROJECT_USES.insert(0, time.time())
|
|
@@ -306,16 +310,32 @@ class Project(PartialProject):
|
|
|
306
310
|
"""
|
|
307
311
|
try:
|
|
308
312
|
self.update()
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
timeout=10,
|
|
312
|
-
).json()
|
|
313
|
-
except Exception:
|
|
313
|
+
|
|
314
|
+
except Exception as e:
|
|
314
315
|
raise (
|
|
315
316
|
exceptions.FetchError(
|
|
316
|
-
"
|
|
317
|
+
f"You're not authorized for accessing {self}.\nException: {e}"
|
|
317
318
|
)
|
|
318
319
|
)
|
|
320
|
+
|
|
321
|
+
with requests.no_error_handling():
|
|
322
|
+
resp = requests.get(
|
|
323
|
+
f"https://projects.scratch.mit.edu/{self.id}?token={self.project_token}",
|
|
324
|
+
timeout=10,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
return resp.json()
|
|
329
|
+
except json.JSONDecodeError:
|
|
330
|
+
# I am not aware of any cases where this will not be a zip file
|
|
331
|
+
# in the future, cache a projectbody object here and just return the json
|
|
332
|
+
# that is fetched from there to not waste existing asset data from this zip file
|
|
333
|
+
|
|
334
|
+
with zipfile.ZipFile(BytesIO(resp.content)) as zipf:
|
|
335
|
+
return json.load(zipf.open("project.json"))
|
|
336
|
+
|
|
337
|
+
def raw_json_or_empty(self):
|
|
338
|
+
return self.raw_json()
|
|
319
339
|
|
|
320
340
|
def creator_agent(self):
|
|
321
341
|
"""
|
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,26 +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
|
"""
|
|
77
|
+
username: str = None
|
|
78
|
+
_user: user.User = field(repr=False, default=None)
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
self.username = None
|
|
91
|
-
self.xtoken = None
|
|
92
|
-
self.new_scratcher = None
|
|
90
|
+
time_created: datetime.datetime = None
|
|
91
|
+
language: str = field(repr=False, default="en")
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
return f"<Login for {self.username!r}>"
|
|
96
95
|
|
|
97
|
-
|
|
98
|
-
|
|
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"
|
|
99
100
|
|
|
100
101
|
# Set alternative attributes:
|
|
101
102
|
self._username = self.username # backwards compatibility with v1
|
|
@@ -110,6 +111,9 @@ class Session(BaseSiteComponent):
|
|
|
110
111
|
"Content-Type": "application/json",
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
if self.id:
|
|
115
|
+
self._process_session_id()
|
|
116
|
+
|
|
113
117
|
def _update_from_dict(self, data: dict):
|
|
114
118
|
# Note: there are a lot more things you can get from this data dict.
|
|
115
119
|
# Maybe it would be a good idea to also store the dict itself?
|
|
@@ -132,13 +136,34 @@ class Session(BaseSiteComponent):
|
|
|
132
136
|
self.banned = data["user"]["banned"]
|
|
133
137
|
|
|
134
138
|
if self.banned:
|
|
135
|
-
warnings.warn(f"Warning: The account {self.
|
|
139
|
+
warnings.warn(f"Warning: The account {self.username} you logged in to is BANNED. "
|
|
136
140
|
f"Some features may not work properly.")
|
|
137
141
|
if self.has_outstanding_email_confirmation:
|
|
138
|
-
warnings.warn(f"Warning: The account {self.
|
|
142
|
+
warnings.warn(f"Warning: The account {self.username} you logged is not email confirmed. "
|
|
139
143
|
f"Some features may not work properly.")
|
|
140
144
|
return True
|
|
141
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
|
+
|
|
142
167
|
def connect_linked_user(self) -> user.User:
|
|
143
168
|
"""
|
|
144
169
|
Gets the user associated with the login / session.
|
|
@@ -157,7 +182,7 @@ class Session(BaseSiteComponent):
|
|
|
157
182
|
self._user = self.connect_user(self._username)
|
|
158
183
|
return self._user
|
|
159
184
|
|
|
160
|
-
def get_linked_user(self) ->
|
|
185
|
+
def get_linked_user(self) -> user.User:
|
|
161
186
|
# backwards compatibility with v1
|
|
162
187
|
|
|
163
188
|
# To avoid inconsistencies with "connect" and "get", this function was renamed
|
|
@@ -182,16 +207,27 @@ class Session(BaseSiteComponent):
|
|
|
182
207
|
password (str): Password associated with the session (not stored)
|
|
183
208
|
"""
|
|
184
209
|
requests.post("https://scratch.mit.edu/accounts/email_change/",
|
|
185
|
-
data={"email_address": self.
|
|
210
|
+
data={"email_address": self.get_new_email_address(),
|
|
186
211
|
"password": password},
|
|
187
212
|
headers=self._headers, cookies=self._cookies)
|
|
188
213
|
|
|
189
214
|
@property
|
|
215
|
+
@deprecated("Use get_new_email_address instead.")
|
|
190
216
|
def new_email_address(self) -> str:
|
|
191
217
|
"""
|
|
192
218
|
Gets the (unconfirmed) email address that this session has requested to transfer to, if any,
|
|
193
219
|
otherwise the current address.
|
|
194
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
|
+
|
|
195
231
|
Returns:
|
|
196
232
|
str: The email that this session wants to switch to
|
|
197
233
|
"""
|
|
@@ -202,6 +238,10 @@ class Session(BaseSiteComponent):
|
|
|
202
238
|
|
|
203
239
|
email = None
|
|
204
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
|
|
205
245
|
if label_span.contents[0] == "New Email Address":
|
|
206
246
|
return label_span.parent.contents[-1].text.strip("\n ")
|
|
207
247
|
|
|
@@ -251,6 +291,13 @@ class Session(BaseSiteComponent):
|
|
|
251
291
|
|
|
252
292
|
def classroom_alerts(self, _classroom: Optional[classroom.Classroom | int] = None, mode: str = "Last created",
|
|
253
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
|
+
|
|
254
301
|
if isinstance(_classroom, classroom.Classroom):
|
|
255
302
|
_classroom = _classroom.id
|
|
256
303
|
|
|
@@ -265,7 +312,9 @@ class Session(BaseSiteComponent):
|
|
|
265
312
|
params={"page": page, "ascsort": ascsort, "descsort": descsort},
|
|
266
313
|
headers=self._headers, cookies=self._cookies).json()
|
|
267
314
|
|
|
268
|
-
|
|
315
|
+
alerts = [alert.EducatorAlert.from_json(alert_data, self) for alert_data in data]
|
|
316
|
+
|
|
317
|
+
return alerts
|
|
269
318
|
|
|
270
319
|
def clear_messages(self):
|
|
271
320
|
"""
|
|
@@ -469,20 +518,22 @@ class Session(BaseSiteComponent):
|
|
|
469
518
|
|
|
470
519
|
def search_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
471
520
|
offset: int = 0) -> list[studio.Studio]:
|
|
472
|
-
if not query:
|
|
473
|
-
|
|
521
|
+
#if not query:
|
|
522
|
+
# raise ValueError("The query can't be empty for search")
|
|
523
|
+
query = f"&q={query}" if query else ""
|
|
474
524
|
response = commons.api_iterative(
|
|
475
525
|
f"https://api.scratch.mit.edu/search/studios", limit=limit, offset=offset,
|
|
476
|
-
add_params=f"&language={language}&mode={mode}
|
|
526
|
+
add_params=f"&language={language}&mode={mode}{query}")
|
|
477
527
|
return commons.parse_object_list(response, studio.Studio, self)
|
|
478
528
|
|
|
479
529
|
def explore_studios(self, *, query: str = "", mode: str = "trending", language: str = "en", limit: int = 40,
|
|
480
530
|
offset: int = 0) -> list[studio.Studio]:
|
|
481
|
-
if not query:
|
|
482
|
-
|
|
531
|
+
#if not query:
|
|
532
|
+
# raise ValueError("The query can't be empty for explore")
|
|
533
|
+
query = f"&q={query}" if query else ""
|
|
483
534
|
response = commons.api_iterative(
|
|
484
535
|
f"https://api.scratch.mit.edu/explore/studios", limit=limit, offset=offset,
|
|
485
|
-
add_params=f"&language={language}&mode={mode}
|
|
536
|
+
add_params=f"&language={language}&mode={mode}{query}")
|
|
486
537
|
return commons.parse_object_list(response, studio.Studio, self)
|
|
487
538
|
|
|
488
539
|
# --- Create project API ---
|
|
@@ -617,9 +668,10 @@ class Session(BaseSiteComponent):
|
|
|
617
668
|
ascsort = sort_by
|
|
618
669
|
descsort = ""
|
|
619
670
|
try:
|
|
671
|
+
params: dict[str, Union[str, int]] = {"page": page, "ascsort": ascsort, "descsort": descsort}
|
|
620
672
|
targets = requests.get(
|
|
621
673
|
f"https://scratch.mit.edu/site-api/galleries/{filter_arg}/",
|
|
622
|
-
params=
|
|
674
|
+
params=params,
|
|
623
675
|
headers=headers,
|
|
624
676
|
cookies=self._cookies,
|
|
625
677
|
timeout=10
|
|
@@ -645,6 +697,9 @@ class Session(BaseSiteComponent):
|
|
|
645
697
|
raise exceptions.FetchError()
|
|
646
698
|
|
|
647
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
|
+
|
|
648
703
|
if not self.is_teacher:
|
|
649
704
|
raise exceptions.Unauthorized(f"{self.username} is not a teacher; can't have classes")
|
|
650
705
|
ascsort, descsort = get_class_sort_mode(mode)
|
|
@@ -848,6 +903,7 @@ class Session(BaseSiteComponent):
|
|
|
848
903
|
Returns:
|
|
849
904
|
scratchattach.user.User: An object that represents the requested user and allows you to perform actions on the user (like user.follow)
|
|
850
905
|
"""
|
|
906
|
+
# noinspection PyDeprecation
|
|
851
907
|
return self._make_linked_object("username", self.find_username_from_id(user_id), user.User,
|
|
852
908
|
exceptions.UserNotFound)
|
|
853
909
|
|
|
@@ -977,11 +1033,53 @@ sess
|
|
|
977
1033
|
def connect_filterbot(self, *, log_deletions=True) -> filterbot.Filterbot:
|
|
978
1034
|
return filterbot.Filterbot(user.User(username=self.username, _session=self), log_deletions=log_deletions)
|
|
979
1035
|
|
|
1036
|
+
def get_session_string(self) -> str:
|
|
1037
|
+
assert self.session_string
|
|
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
|
+
|
|
980
1077
|
|
|
981
1078
|
# ------ #
|
|
982
1079
|
|
|
983
1080
|
suppressed_login_warning = local()
|
|
984
1081
|
|
|
1082
|
+
|
|
985
1083
|
@contextmanager
|
|
986
1084
|
def suppress_login_warning():
|
|
987
1085
|
"""
|
|
@@ -994,6 +1092,7 @@ def suppress_login_warning():
|
|
|
994
1092
|
finally:
|
|
995
1093
|
suppressed_login_warning.suppressed -= 1
|
|
996
1094
|
|
|
1095
|
+
|
|
997
1096
|
def issue_login_warning() -> None:
|
|
998
1097
|
"""
|
|
999
1098
|
Issue a login data warning.
|
|
@@ -1001,13 +1100,14 @@ def issue_login_warning() -> None:
|
|
|
1001
1100
|
if getattr(suppressed_login_warning, "suppressed", 0):
|
|
1002
1101
|
return
|
|
1003
1102
|
warnings.warn(
|
|
1004
|
-
"IMPORTANT: If you included login credentials directly in your code (e.g. session_id, session_string, ...),
|
|
1005
|
-
then make sure to EITHER instead load them from environment variables or files OR remember to remove them before
|
|
1006
|
-
you share your code with anyone else. If you want to remove this warning,
|
|
1007
|
-
use `warnings.filterwarnings('ignore', category=scratchattach.LoginDataWarning)`",
|
|
1103
|
+
"IMPORTANT: If you included login credentials directly in your code (e.g. session_id, session_string, ...), "
|
|
1104
|
+
"then make sure to EITHER instead load them from environment variables or files OR remember to remove them before "
|
|
1105
|
+
"you share your code with anyone else. If you want to remove this warning, "
|
|
1106
|
+
"use `warnings.filterwarnings('ignore', category=scratchattach.LoginDataWarning)`",
|
|
1008
1107
|
exceptions.LoginDataWarning
|
|
1009
1108
|
)
|
|
1010
1109
|
|
|
1110
|
+
|
|
1011
1111
|
def login_by_id(session_id: str, *, username: Optional[str] = None, password: Optional[str] = None, xtoken=None) -> Session:
|
|
1012
1112
|
"""
|
|
1013
1113
|
Creates a session / log in to the Scratch website with the specified session id.
|
|
@@ -1024,39 +1124,20 @@ def login_by_id(session_id: str, *, username: Optional[str] = None, password: Op
|
|
|
1024
1124
|
Returns:
|
|
1025
1125
|
scratchattach.session.Session: An object that represents the created login / session
|
|
1026
1126
|
"""
|
|
1027
|
-
# Removed this from docstring since it doesn't exist:
|
|
1028
|
-
# timeout (int): Optional, but recommended.
|
|
1029
|
-
# Specify this when the Python environment's IP address is blocked by Scratch's API,
|
|
1030
|
-
# but you still want to use cloud variables.
|
|
1031
|
-
|
|
1032
1127
|
# Generate session_string (a scratchattach-specific authentication method)
|
|
1128
|
+
# should this be changed to a @property?
|
|
1033
1129
|
issue_login_warning()
|
|
1034
1130
|
if password is not None:
|
|
1035
|
-
session_data = dict(
|
|
1036
|
-
session_string = base64.b64encode(json.dumps(session_data).encode())
|
|
1131
|
+
session_data = dict(id=session_id, username=username, password=password)
|
|
1132
|
+
session_string = base64.b64encode(json.dumps(session_data).encode()).decode()
|
|
1037
1133
|
else:
|
|
1038
1134
|
session_string = None
|
|
1039
|
-
_session = Session(id=session_id, username=username, session_string=session_string, xtoken=xtoken)
|
|
1040
1135
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
if status is not True:
|
|
1048
|
-
if _session.xtoken is None:
|
|
1049
|
-
if _session.username is None:
|
|
1050
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
|
|
1051
|
-
"Make sure the provided session id is valid. "
|
|
1052
|
-
"Setting cloud variables can still work if you provide a "
|
|
1053
|
-
"`username='username'` keyword argument to the sa.login_by_id function")
|
|
1054
|
-
else:
|
|
1055
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch XToken. "
|
|
1056
|
-
"Make sure the provided session id is valid.")
|
|
1057
|
-
else:
|
|
1058
|
-
warnings.warn("Warning: Logged in by id, but couldn't fetch session info. "
|
|
1059
|
-
"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
|
+
|
|
1060
1141
|
return _session
|
|
1061
1142
|
|
|
1062
1143
|
|
|
@@ -1083,14 +1164,16 @@ def login(username, password, *, timeout=10) -> Session:
|
|
|
1083
1164
|
# Post request to login API:
|
|
1084
1165
|
_headers = headers.copy()
|
|
1085
1166
|
_headers["Cookie"] = "scratchcsrftoken=a;scratchlanguage=en;"
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
+
)
|
|
1091
1172
|
try:
|
|
1092
|
-
|
|
1093
|
-
|
|
1173
|
+
result = re.search('"(.*)"', request.headers["Set-Cookie"])
|
|
1174
|
+
assert result is not None
|
|
1175
|
+
session_id = str(result.group())
|
|
1176
|
+
except Exception:
|
|
1094
1177
|
raise exceptions.LoginFailure(
|
|
1095
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")
|
|
1096
1179
|
|
|
@@ -1098,6 +1181,7 @@ def login(username, password, *, timeout=10) -> Session:
|
|
|
1098
1181
|
with suppress_login_warning():
|
|
1099
1182
|
return login_by_id(session_id, username=username, password=password)
|
|
1100
1183
|
|
|
1184
|
+
|
|
1101
1185
|
def login_by_session_string(session_string: str) -> Session:
|
|
1102
1186
|
"""
|
|
1103
1187
|
Login using a session string.
|
|
@@ -1105,6 +1189,13 @@ def login_by_session_string(session_string: str) -> Session:
|
|
|
1105
1189
|
issue_login_warning()
|
|
1106
1190
|
session_string = base64.b64decode(session_string).decode() # unobfuscate
|
|
1107
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
|
|
1108
1199
|
try:
|
|
1109
1200
|
assert session_data.get("session_id")
|
|
1110
1201
|
with suppress_login_warning():
|
|
@@ -1120,6 +1211,7 @@ def login_by_session_string(session_string: str) -> Session:
|
|
|
1120
1211
|
pass
|
|
1121
1212
|
raise ValueError("Couldn't log in.")
|
|
1122
1213
|
|
|
1214
|
+
|
|
1123
1215
|
def login_by_io(file: SupportsRead[str]) -> Session:
|
|
1124
1216
|
"""
|
|
1125
1217
|
Login using a file object.
|
|
@@ -1127,6 +1219,7 @@ def login_by_io(file: SupportsRead[str]) -> Session:
|
|
|
1127
1219
|
with suppress_login_warning():
|
|
1128
1220
|
return login_by_session_string(file.read())
|
|
1129
1221
|
|
|
1222
|
+
|
|
1130
1223
|
def login_by_file(file: FileDescriptorOrPath) -> Session:
|
|
1131
1224
|
"""
|
|
1132
1225
|
Login using a path to a file.
|
|
@@ -1134,6 +1227,7 @@ def login_by_file(file: FileDescriptorOrPath) -> Session:
|
|
|
1134
1227
|
with suppress_login_warning(), open(file, encoding="utf-8") as f:
|
|
1135
1228
|
return login_by_io(f)
|
|
1136
1229
|
|
|
1230
|
+
|
|
1137
1231
|
def login_from_browser(browser: Browser = ANY):
|
|
1138
1232
|
"""
|
|
1139
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
|