scratchattach 2.1.13__py3-none-any.whl → 2.1.15b0__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.
Files changed (55) hide show
  1. scratchattach/cloud/_base.py +12 -8
  2. scratchattach/cloud/cloud.py +19 -7
  3. scratchattach/editor/asset.py +59 -5
  4. scratchattach/editor/base.py +82 -31
  5. scratchattach/editor/block.py +86 -15
  6. scratchattach/editor/blockshape.py +10 -6
  7. scratchattach/editor/build_defaulting.py +6 -2
  8. scratchattach/editor/code_translation/__init__.py +0 -0
  9. scratchattach/editor/code_translation/parse.py +177 -0
  10. scratchattach/editor/comment.py +6 -0
  11. scratchattach/editor/commons.py +49 -19
  12. scratchattach/editor/extension.py +10 -3
  13. scratchattach/editor/field.py +9 -0
  14. scratchattach/editor/inputs.py +4 -1
  15. scratchattach/editor/meta.py +11 -3
  16. scratchattach/editor/monitor.py +46 -38
  17. scratchattach/editor/mutation.py +11 -4
  18. scratchattach/editor/pallete.py +24 -25
  19. scratchattach/editor/prim.py +2 -2
  20. scratchattach/editor/project.py +9 -3
  21. scratchattach/editor/sprite.py +19 -6
  22. scratchattach/editor/twconfig.py +2 -1
  23. scratchattach/editor/vlb.py +1 -1
  24. scratchattach/eventhandlers/_base.py +2 -2
  25. scratchattach/eventhandlers/cloud_events.py +2 -2
  26. scratchattach/eventhandlers/cloud_requests.py +3 -3
  27. scratchattach/eventhandlers/cloud_server.py +3 -3
  28. scratchattach/eventhandlers/message_events.py +1 -1
  29. scratchattach/other/other_apis.py +4 -4
  30. scratchattach/other/project_json_capabilities.py +3 -3
  31. scratchattach/site/_base.py +13 -12
  32. scratchattach/site/activity.py +11 -43
  33. scratchattach/site/alert.py +227 -0
  34. scratchattach/site/backpack_asset.py +2 -2
  35. scratchattach/site/browser_cookie3_stub.py +17 -0
  36. scratchattach/site/browser_cookies.py +27 -21
  37. scratchattach/site/classroom.py +51 -34
  38. scratchattach/site/cloud_activity.py +4 -4
  39. scratchattach/site/comment.py +30 -8
  40. scratchattach/site/forum.py +101 -69
  41. scratchattach/site/project.py +42 -21
  42. scratchattach/site/session.py +170 -80
  43. scratchattach/site/studio.py +4 -4
  44. scratchattach/site/user.py +179 -64
  45. scratchattach/utils/commons.py +35 -23
  46. scratchattach/utils/enums.py +44 -5
  47. scratchattach/utils/exceptions.py +10 -0
  48. scratchattach/utils/requests.py +57 -31
  49. {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/METADATA +8 -3
  50. scratchattach-2.1.15b0.dist-info/RECORD +66 -0
  51. {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/WHEEL +1 -1
  52. scratchattach/editor/sbuild.py +0 -2837
  53. scratchattach-2.1.13.dist-info/RECORD +0 -63
  54. {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/licenses/LICENSE +0 -0
  55. {scratchattach-2.1.13.dist-info → scratchattach-2.1.15b0.dist-info}/top_level.txt +0 -0
@@ -10,37 +10,34 @@ import random
10
10
  import re
11
11
  import time
12
12
  import warnings
13
- from typing import Optional, TypeVar, TYPE_CHECKING, overload
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
- try:
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 ..cloud._base import BaseCloud
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 ..cloud import cloud, _base
38
- from ..eventhandlers import message_events, filterbot
39
- from ..other import project_json_capabilities
40
- from ..utils import commons
41
- from ..utils import exceptions
42
- from ..utils.commons import headers, empty_project_json, webscrape_count, get_class_sort_mode
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]] = {}
@@ -49,7 +46,7 @@ def enforce_ratelimit(__type: str, name: str, amount: int = 5, duration: int = 6
49
46
  cache = ratelimit_cache
50
47
  cache.setdefault(__type, [])
51
48
  uses = cache[__type]
52
- while uses[-1] < time.time() - duration:
49
+ while uses and uses[-1] < time.time() - duration:
53
50
  uses.pop()
54
51
  if len(uses) < amount:
55
52
  uses.insert(0, time.time())
@@ -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
- session_string: str | None = None
77
+ username: str = None
78
+ _user: user.User = field(repr=False, default=None)
80
79
 
81
- def __str__(self) -> str:
82
- return f"Login for account {self.username!r}"
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
- def __init__(self, **entries):
85
- # Info on how the .update method has to fetch the data:
86
- self.update_function = requests.post
87
- self.update_API = "https://scratch.mit.edu/session"
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
- # Set attributes every Session object needs to have:
90
- self.id = None
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
- # Set attributes that Session object may get
96
- self._user: user.User = None
93
+ def __str__(self) -> str:
94
+ return f"<Login for {self.username!r}>"
97
95
 
98
- # Update attributes from entries dict:
99
- self.__dict__.update(entries)
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._username} you logged in to is BANNED. "
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._username} you logged is not email confirmed. "
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) -> 'user.User':
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.new_email_address,
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
- return data
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
- raise ValueError("The query can't be empty for search")
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}&q={query}")
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
- raise ValueError("The query can't be empty for explore")
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}&q={query}")
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={"page": page, "ascsort": ascsort, "descsort": descsort},
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(session_id=session_id, username=username, password=password)
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
- try:
1046
- status = _session.update()
1047
- except Exception as e:
1048
- status = False
1049
- warnings.warn(f"Key error at key {e} when reading scratch.mit.edu/session API response")
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
- request = requests.post(
1091
- "https://scratch.mit.edu/login/", json={"username": username, "password": password}, headers=_headers,
1092
-
1093
- timeout=timeout, errorhandling = False
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
- session_id = str(re.search('"(.*)"', request.headers["Set-Cookie"]).group())
1097
- except (AttributeError, Exception):
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
@@ -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 ..utils import exceptions, commons
8
- from ..utils.commons import api_iterative, headers
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 ..utils.requests import Requests as requests
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.update_API = f"https://api.scratch.mit.edu/studios/{entries['id']}"
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