scratchattach 3.0.0b0__py3-none-any.whl → 3.0.0b1__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.
- cli/__about__.py +1 -0
- cli/__init__.py +26 -0
- cli/cmd/__init__.py +4 -0
- cli/cmd/group.py +127 -0
- cli/cmd/login.py +60 -0
- cli/cmd/profile.py +7 -0
- cli/cmd/sessions.py +5 -0
- cli/context.py +142 -0
- cli/db.py +66 -0
- cli/namespace.py +14 -0
- cloud/__init__.py +2 -0
- cloud/_base.py +483 -0
- cloud/cloud.py +183 -0
- editor/__init__.py +22 -0
- editor/asset.py +265 -0
- editor/backpack_json.py +115 -0
- editor/base.py +191 -0
- editor/block.py +584 -0
- editor/blockshape.py +357 -0
- editor/build_defaulting.py +51 -0
- editor/code_translation/__init__.py +0 -0
- editor/code_translation/parse.py +177 -0
- editor/comment.py +80 -0
- editor/commons.py +145 -0
- editor/extension.py +50 -0
- editor/field.py +99 -0
- editor/inputs.py +138 -0
- editor/meta.py +117 -0
- editor/monitor.py +185 -0
- editor/mutation.py +381 -0
- editor/pallete.py +88 -0
- editor/prim.py +174 -0
- editor/project.py +381 -0
- editor/sprite.py +609 -0
- editor/twconfig.py +114 -0
- editor/vlb.py +134 -0
- eventhandlers/__init__.py +0 -0
- eventhandlers/_base.py +101 -0
- eventhandlers/cloud_events.py +130 -0
- eventhandlers/cloud_recorder.py +26 -0
- eventhandlers/cloud_requests.py +544 -0
- eventhandlers/cloud_server.py +249 -0
- eventhandlers/cloud_storage.py +135 -0
- eventhandlers/combine.py +30 -0
- eventhandlers/filterbot.py +163 -0
- eventhandlers/message_events.py +42 -0
- other/__init__.py +0 -0
- other/other_apis.py +598 -0
- other/project_json_capabilities.py +475 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +1 -1
- scratchattach-3.0.0b1.dist-info/RECORD +79 -0
- scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
- site/__init__.py +0 -0
- site/_base.py +93 -0
- site/activity.py +426 -0
- site/alert.py +226 -0
- site/backpack_asset.py +119 -0
- site/browser_cookie3_stub.py +17 -0
- site/browser_cookies.py +61 -0
- site/classroom.py +454 -0
- site/cloud_activity.py +121 -0
- site/comment.py +228 -0
- site/forum.py +436 -0
- site/placeholder.py +132 -0
- site/project.py +932 -0
- site/session.py +1323 -0
- site/studio.py +704 -0
- site/typed_dicts.py +151 -0
- site/user.py +1252 -0
- utils/__init__.py +0 -0
- utils/commons.py +263 -0
- utils/encoder.py +161 -0
- utils/enums.py +237 -0
- utils/exceptions.py +277 -0
- utils/optional_async.py +154 -0
- utils/requests.py +306 -0
- scratchattach/__init__.py +0 -37
- scratchattach/__main__.py +0 -93
- scratchattach-3.0.0b0.dist-info/RECORD +0 -8
- scratchattach-3.0.0b0.dist-info/top_level.txt +0 -1
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
cloud/_base.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Optional, Union, TypeVar, Generic, TYPE_CHECKING, Any
|
|
8
|
+
from abc import ABC, abstractmethod, ABCMeta
|
|
9
|
+
from threading import Lock
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from _typeshed import SupportsRead
|
|
14
|
+
else:
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
class SupportsRead(ABC, Generic[T]):
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def read(self) -> T:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
import websocket
|
|
23
|
+
|
|
24
|
+
from scratchattach.site import session
|
|
25
|
+
from scratchattach.eventhandlers import cloud_recorder
|
|
26
|
+
from scratchattach.utils import exceptions
|
|
27
|
+
from scratchattach.eventhandlers.cloud_requests import CloudRequests, RespondOrder
|
|
28
|
+
from scratchattach.eventhandlers.cloud_events import CloudEvents
|
|
29
|
+
from scratchattach.eventhandlers.cloud_storage import CloudStorage
|
|
30
|
+
from scratchattach.site import cloud_activity
|
|
31
|
+
|
|
32
|
+
class SupportsClose(ABC):
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EventStream(SupportsRead[Iterator[dict[str, Any]]], SupportsClose):
|
|
41
|
+
"""
|
|
42
|
+
Allows you to stream events
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
class AnyCloud(ABC, Generic[T]):
|
|
46
|
+
"""
|
|
47
|
+
Represents a cloud that is not necessarily using a websocket.
|
|
48
|
+
"""
|
|
49
|
+
active_connection: bool
|
|
50
|
+
var_sets_since_first: int
|
|
51
|
+
_session: Optional[session.Session]
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def connect(self):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def disconnect(self):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def reconnect(self):
|
|
62
|
+
self.disconnect()
|
|
63
|
+
time.sleep(0.1)
|
|
64
|
+
self.connect()
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def _enforce_ratelimit(self, *, n: int) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def set_var(self, variable: str, value: T, *, max_retries : int = 2) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Sets a cloud variable.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
variable (str): The name of the cloud variable that should be set (provided without the cloud emoji)
|
|
77
|
+
value (Any): The value the cloud variable should be set to
|
|
78
|
+
|
|
79
|
+
Kwargs:
|
|
80
|
+
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def set_vars(self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries : int = 2):
|
|
85
|
+
"""
|
|
86
|
+
Sets multiple cloud variables at once (works for an unlimited amount of variables).
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
var_value_dict (dict): variable:value dictionary with the variables / values to set. The dict should like this: {"var1":"value1", "var2":"value2", ...}
|
|
90
|
+
|
|
91
|
+
Kwargs:
|
|
92
|
+
intelligent_waits (boolean): When enabled, the method will automatically decide how long to wait before performing this cloud variable set, to make sure no rate limits are triggered
|
|
93
|
+
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> dict[str, T]:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def events(self) -> CloudEvents:
|
|
105
|
+
return CloudEvents(self)
|
|
106
|
+
|
|
107
|
+
def requests(self, *, no_packet_loss: bool = False, used_cloud_vars: Optional[list[str]] = None,
|
|
108
|
+
respond_order=RespondOrder.RECEIVE, debug: bool = False) -> CloudRequests:
|
|
109
|
+
used_cloud_vars = used_cloud_vars or ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
110
|
+
return CloudRequests(self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss,
|
|
111
|
+
respond_order=respond_order, debug=debug)
|
|
112
|
+
|
|
113
|
+
def storage(self, *, no_packet_loss: bool = False, used_cloud_vars: Optional[list[str]] = None) -> CloudStorage:
|
|
114
|
+
used_cloud_vars = used_cloud_vars or ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
115
|
+
return CloudStorage(self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss)
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def create_event_stream(self) -> EventStream:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
class DummyCloud(AnyCloud[Any]):
|
|
122
|
+
class DummyEventStream(EventStream):
|
|
123
|
+
def read(self, length = ...):
|
|
124
|
+
return iter(())
|
|
125
|
+
|
|
126
|
+
def close(self):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def connect(self):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def disconnect(self):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def create_event_stream(self) -> EventStream:
|
|
136
|
+
return self.DummyEventStream()
|
|
137
|
+
|
|
138
|
+
def _enforce_ratelimit(self, *, n: int) -> None:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def set_var(self, variable: str, value: T, *, max_retries : int = 2) -> None:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
def set_vars(self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries : int = 2):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> Any:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WebSocketEventStream(EventStream):
|
|
155
|
+
packets_left: list[Union[str, bytes]]
|
|
156
|
+
source_cloud: BaseCloud
|
|
157
|
+
reading: Lock
|
|
158
|
+
def __init__(self, cloud: BaseCloud):
|
|
159
|
+
super().__init__()
|
|
160
|
+
self.source_cloud = type(cloud)(project_id=cloud.project_id)
|
|
161
|
+
self.source_cloud._session = cloud._session
|
|
162
|
+
self.source_cloud.cookie = cloud.cookie
|
|
163
|
+
self.source_cloud.header = cloud.header
|
|
164
|
+
self.source_cloud.origin = cloud.origin
|
|
165
|
+
self.source_cloud.username = cloud.username
|
|
166
|
+
self.source_cloud.ws_timeout = None # No timeout -> allows continous listening
|
|
167
|
+
self.reading = Lock()
|
|
168
|
+
try:
|
|
169
|
+
self.source_cloud.connect()
|
|
170
|
+
except exceptions.CloudConnectionError:
|
|
171
|
+
warnings.warn("Initial cloud connection attempt failed, retrying...", exceptions.UnexpectedWebsocketEventWarning)
|
|
172
|
+
self.packets_left = []
|
|
173
|
+
|
|
174
|
+
def receive_new(self, non_blocking: bool = False):
|
|
175
|
+
if non_blocking:
|
|
176
|
+
self.source_cloud.websocket.settimeout(0)
|
|
177
|
+
try:
|
|
178
|
+
received = self.source_cloud.websocket.recv().splitlines()
|
|
179
|
+
self.packets_left.extend(received)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
return
|
|
183
|
+
self.source_cloud.websocket.settimeout(None)
|
|
184
|
+
received = self.source_cloud.websocket.recv().splitlines()
|
|
185
|
+
self.packets_left.extend(received)
|
|
186
|
+
|
|
187
|
+
def read(self, amount: int = -1) -> Iterator[dict[str, Any]]:
|
|
188
|
+
i = 0
|
|
189
|
+
with self.reading:
|
|
190
|
+
try:
|
|
191
|
+
self.receive_new(amount != -1)
|
|
192
|
+
while (self.packets_left and amount == -1) or (amount != -1 and i < amount):
|
|
193
|
+
if not self.packets_left and amount != -1:
|
|
194
|
+
self.receive_new()
|
|
195
|
+
yield json.loads(self.packets_left.pop(0))
|
|
196
|
+
i += 1
|
|
197
|
+
except Exception:
|
|
198
|
+
self.source_cloud.reconnect()
|
|
199
|
+
self.receive_new(amount != -1)
|
|
200
|
+
while (self.packets_left and amount == -1) or (amount != -1 and i < amount):
|
|
201
|
+
if not self.packets_left and amount != -1:
|
|
202
|
+
self.receive_new()
|
|
203
|
+
yield json.loads(self.packets_left.pop(0))
|
|
204
|
+
i += 1
|
|
205
|
+
|
|
206
|
+
def close(self) -> None:
|
|
207
|
+
self.source_cloud.disconnect()
|
|
208
|
+
|
|
209
|
+
class BaseCloud(AnyCloud[Union[str, int]]):
|
|
210
|
+
"""
|
|
211
|
+
Base class for a project's cloud variables. Represents a cloud.
|
|
212
|
+
|
|
213
|
+
When inheriting from this class, the __init__ function of the inherited class:
|
|
214
|
+
- must first call the constructor of the super class: super().__init__()
|
|
215
|
+
- must then set some attributes
|
|
216
|
+
|
|
217
|
+
Attributes that must be specified in the __init__ function of a class inheriting from this one:
|
|
218
|
+
project_id: Project id of the cloud variables
|
|
219
|
+
|
|
220
|
+
cloud_host: URL of the websocket server ("wss://..." or "ws://...")
|
|
221
|
+
|
|
222
|
+
Attributes that can, but don't have to be specified in the __init__ function:
|
|
223
|
+
|
|
224
|
+
_session: Either None or a scratchattach.site.session.Session object. Defaults to None.
|
|
225
|
+
|
|
226
|
+
ws_shortterm_ratelimit: The wait time between cloud variable sets. Defaults to 0.1
|
|
227
|
+
|
|
228
|
+
ws_longterm_ratelimit: The amount of cloud variable set that can be performed long-term without ever getting ratelimited
|
|
229
|
+
|
|
230
|
+
allow_non_numeric: Whether non-numeric cloud variable values are allowed. Defaults to False
|
|
231
|
+
|
|
232
|
+
length_limit: Length limit for cloud variable values. Defaults to 100000
|
|
233
|
+
|
|
234
|
+
username: The username to send during handshake. Defaults to "scratchattach"
|
|
235
|
+
|
|
236
|
+
header: The header to send. Defaults to None
|
|
237
|
+
|
|
238
|
+
cookie: The cookie to send. Defaults to None
|
|
239
|
+
|
|
240
|
+
origin: The origin to send. Defaults to None
|
|
241
|
+
|
|
242
|
+
print_connect_messages: Whether to print a message on every connect to the cloud server. Defaults to False.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
_PACKET_FAILURE_SLEEPDURATIONS = (0.1, 0.2, 1.5)
|
|
246
|
+
|
|
247
|
+
project_id: Optional[Union[str, int]]
|
|
248
|
+
cloud_host: str
|
|
249
|
+
ws_shortterm_ratelimit: float
|
|
250
|
+
ws_longterm_ratelimit: float
|
|
251
|
+
allow_non_numeric: bool
|
|
252
|
+
length_limit: int
|
|
253
|
+
username: str
|
|
254
|
+
header: Optional[dict]
|
|
255
|
+
cookie: Optional[dict]
|
|
256
|
+
origin: Optional[str]
|
|
257
|
+
print_connect_message: bool
|
|
258
|
+
ws_timeout: Optional[int]
|
|
259
|
+
websocket: websocket.WebSocket
|
|
260
|
+
event_stream: Optional[EventStream] = None
|
|
261
|
+
|
|
262
|
+
recorder: Optional[cloud_recorder.CloudRecorder]
|
|
263
|
+
|
|
264
|
+
first_var_set: float
|
|
265
|
+
last_var_set: float
|
|
266
|
+
var_sets_since_first: int
|
|
267
|
+
|
|
268
|
+
def __init__(self, *, project_id: Optional[Union[int, str]] = None, _session=None):
|
|
269
|
+
|
|
270
|
+
# Required internal attributes that every object representing a cloud needs to have (no matter what cloud is represented):
|
|
271
|
+
self._session = _session
|
|
272
|
+
self.active_connection = False #whether a connection to a cloud variable server is currently established
|
|
273
|
+
|
|
274
|
+
self.websocket = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE})
|
|
275
|
+
self.recorder = None # A CloudRecorder object that records cloud activity for the values to be retrieved later,
|
|
276
|
+
# which will be saved in this attribute as soon as .get_var is called
|
|
277
|
+
self.first_var_set = 0.0
|
|
278
|
+
self.last_var_set = 0.0
|
|
279
|
+
self.var_sets_since_first = 0
|
|
280
|
+
|
|
281
|
+
# Set default values for attributes that save configurations specific to the represented cloud:
|
|
282
|
+
# (These attributes can be specifically in the constructors of classes inheriting from this base class)
|
|
283
|
+
self.ws_shortterm_ratelimit = 0.06667
|
|
284
|
+
self.ws_longterm_ratelimit = 0.1
|
|
285
|
+
self.ws_timeout = 3 # Timeout for send operations (after the timeout,
|
|
286
|
+
# the connection will be renewed and the operation will be retried 3 times)
|
|
287
|
+
self.allow_non_numeric = False
|
|
288
|
+
self.length_limit = 100000
|
|
289
|
+
self.username = "scratchattach"
|
|
290
|
+
self.header = None
|
|
291
|
+
self.cookie = None
|
|
292
|
+
self.origin = None
|
|
293
|
+
self.print_connect_message = False
|
|
294
|
+
|
|
295
|
+
self.project_id = project_id
|
|
296
|
+
|
|
297
|
+
def _assert_auth(self):
|
|
298
|
+
if self._session is None:
|
|
299
|
+
raise exceptions.Unauthenticated(
|
|
300
|
+
"You need to use session.connect_cloud (NOT get_cloud) in order to perform this operation.")
|
|
301
|
+
|
|
302
|
+
def _send_recursive(self, data : str, *, current_depth: int = 0, max_depth=0):
|
|
303
|
+
try:
|
|
304
|
+
self.websocket.send(data)
|
|
305
|
+
except Exception:
|
|
306
|
+
if current_depth < max_depth:
|
|
307
|
+
sleep_duration = self._PACKET_FAILURE_SLEEPDURATIONS[min(current_depth, len(self._PACKET_FAILURE_SLEEPDURATIONS)-1)]
|
|
308
|
+
time.sleep(sleep_duration)
|
|
309
|
+
self.connect()
|
|
310
|
+
time.sleep(sleep_duration)
|
|
311
|
+
self._send_recursive(data, current_depth=current_depth+1, max_depth=max_depth)
|
|
312
|
+
else:
|
|
313
|
+
self.active_connection = False
|
|
314
|
+
raise exceptions.CloudConnectionError(f"Sending packet failed {max_depth+1} tries: {data}")
|
|
315
|
+
|
|
316
|
+
def _send_packet(self, packet, *, max_retries=2):
|
|
317
|
+
self._send_recursive(json.dumps(packet) + "\n", max_depth=max_retries)
|
|
318
|
+
|
|
319
|
+
def _send_packet_list(self, packet_list, *, max_retries=2):
|
|
320
|
+
packet_string = "".join([json.dumps(packet) + "\n" for packet in packet_list])
|
|
321
|
+
self._send_recursive(packet_string, max_depth=max_retries)
|
|
322
|
+
|
|
323
|
+
def _handshake(self):
|
|
324
|
+
packet = {"method": "handshake", "user": self.username, "project_id": self.project_id}
|
|
325
|
+
self._send_packet(packet)
|
|
326
|
+
|
|
327
|
+
def connect(self):
|
|
328
|
+
self.websocket = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE})
|
|
329
|
+
self.websocket.connect(
|
|
330
|
+
self.cloud_host,
|
|
331
|
+
cookie=self.cookie,
|
|
332
|
+
origin=self.origin,
|
|
333
|
+
enable_multithread=True,
|
|
334
|
+
timeout=self.ws_timeout,
|
|
335
|
+
header=self.header
|
|
336
|
+
)
|
|
337
|
+
self._handshake()
|
|
338
|
+
self.active_connection = True
|
|
339
|
+
if self.print_connect_message:
|
|
340
|
+
print("Connected to cloud server ", self.cloud_host)
|
|
341
|
+
|
|
342
|
+
def disconnect(self):
|
|
343
|
+
self.active_connection = False
|
|
344
|
+
if self.recorder is not None:
|
|
345
|
+
self.recorder.stop()
|
|
346
|
+
self.recorder.disconnect()
|
|
347
|
+
self.recorder = None
|
|
348
|
+
try:
|
|
349
|
+
self.websocket.close()
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
def _assert_valid_value(self, value):
|
|
354
|
+
if not (value in [True, False, float('inf'), -float('inf')]):
|
|
355
|
+
value = str(value)
|
|
356
|
+
if len(value) > self.length_limit:
|
|
357
|
+
raise (exceptions.InvalidCloudValue(
|
|
358
|
+
f"Value exceeds length limit: {str(value)}"
|
|
359
|
+
))
|
|
360
|
+
if not self.allow_non_numeric:
|
|
361
|
+
x = value.replace(".", "")
|
|
362
|
+
x = x.replace("-", "")
|
|
363
|
+
if not (x.isnumeric() or x == ""):
|
|
364
|
+
raise (exceptions.InvalidCloudValue(
|
|
365
|
+
"Value not numeric"
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
def _enforce_ratelimit(self, *, n):
|
|
369
|
+
# n is the amount of variables being set
|
|
370
|
+
if (time.time() - self.first_var_set) / (
|
|
371
|
+
self.var_sets_since_first + 1) > self.ws_longterm_ratelimit: # if the average delay between cloud variable sets has been bigger than the long-term rate-limit, cloud variables can be set fast (wait time smaller than long-term rate limit) again
|
|
372
|
+
self.var_sets_since_first = 0
|
|
373
|
+
self.first_var_set = time.time()
|
|
374
|
+
|
|
375
|
+
wait_time = self.ws_shortterm_ratelimit * n
|
|
376
|
+
if time.time() - self.first_var_set > 25: # if cloud variables have been continously set fast (wait time smaller than long-term rate limit) for 25 seconds, they should be set slow now (wait time = long-term rate limit) to avoid getting rate-limited
|
|
377
|
+
wait_time = self.ws_longterm_ratelimit * n
|
|
378
|
+
sleep_time = self.last_var_set + wait_time - time.time()
|
|
379
|
+
if sleep_time > 0:
|
|
380
|
+
time.sleep(sleep_time)
|
|
381
|
+
|
|
382
|
+
def set_var(self, variable, value, *, max_retries: int = 2):
|
|
383
|
+
"""
|
|
384
|
+
Sets a cloud variable.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
variable (str): The name of the cloud variable that should be set (provided without the cloud emoji)
|
|
388
|
+
value (str): The value the cloud variable should be set to
|
|
389
|
+
|
|
390
|
+
Kwargs:
|
|
391
|
+
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
|
|
392
|
+
"""
|
|
393
|
+
self._assert_valid_value(value)
|
|
394
|
+
if not isinstance(variable, str):
|
|
395
|
+
raise ValueError("cloud var name must be a string")
|
|
396
|
+
variable = variable.removeprefix("☁ ")
|
|
397
|
+
if not self.active_connection:
|
|
398
|
+
self.connect()
|
|
399
|
+
self._enforce_ratelimit(n=1)
|
|
400
|
+
|
|
401
|
+
self.var_sets_since_first += 1
|
|
402
|
+
|
|
403
|
+
packet = {
|
|
404
|
+
"method": "set",
|
|
405
|
+
"name": "☁ " + variable,
|
|
406
|
+
"value": value,
|
|
407
|
+
"user": self.username,
|
|
408
|
+
"project_id": self.project_id,
|
|
409
|
+
}
|
|
410
|
+
self._send_packet(packet, max_retries=max_retries)
|
|
411
|
+
self.last_var_set = time.time()
|
|
412
|
+
|
|
413
|
+
def set_vars(self, var_value_dict, *, intelligent_waits=True, max_retries: int = 2):
|
|
414
|
+
"""
|
|
415
|
+
Sets multiple cloud variables at once (works for an unlimited amount of variables).
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
var_value_dict (dict): variable:value dictionary with the variables / values to set. The dict should like this: {"var1":"value1", "var2":"value2", ...}
|
|
419
|
+
|
|
420
|
+
Kwargs:
|
|
421
|
+
intelligent_waits (boolean): When enabled, the method will automatically decide how long to wait before performing this cloud variable set, to make sure no rate limits are triggered
|
|
422
|
+
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
|
|
423
|
+
"""
|
|
424
|
+
if not self.active_connection:
|
|
425
|
+
self.connect()
|
|
426
|
+
if intelligent_waits:
|
|
427
|
+
self._enforce_ratelimit(n=len(var_value_dict))
|
|
428
|
+
|
|
429
|
+
self.var_sets_since_first += len(var_value_dict)
|
|
430
|
+
|
|
431
|
+
packet_list = []
|
|
432
|
+
for variable in var_value_dict:
|
|
433
|
+
value = var_value_dict[variable]
|
|
434
|
+
variable = variable.removeprefix("☁ ")
|
|
435
|
+
self._assert_valid_value(value)
|
|
436
|
+
if not isinstance(variable, str):
|
|
437
|
+
raise ValueError("cloud var name must be a string")
|
|
438
|
+
packet = {
|
|
439
|
+
"method": "set",
|
|
440
|
+
"name": "☁ " + variable,
|
|
441
|
+
"value": value,
|
|
442
|
+
"user": self.username,
|
|
443
|
+
"project_id": self.project_id,
|
|
444
|
+
}
|
|
445
|
+
packet_list.append(packet)
|
|
446
|
+
self._send_packet_list(packet_list, max_retries=max_retries)
|
|
447
|
+
self.last_var_set = time.time()
|
|
448
|
+
|
|
449
|
+
def _assert_recorder_running(self, *, recorder_initial_values: dict[str, Any]) -> cloud_recorder.CloudRecorder:
|
|
450
|
+
if self.recorder is None:
|
|
451
|
+
self.recorder = cloud_recorder.CloudRecorder(self, initial_values=recorder_initial_values)
|
|
452
|
+
self.recorder.start()
|
|
453
|
+
start_time = time.time()
|
|
454
|
+
while not (self.recorder.cloud_values != {} or start_time < time.time() - 5):
|
|
455
|
+
time.sleep(0.01)
|
|
456
|
+
return self.recorder
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None):
|
|
460
|
+
var = "☁ "+var.removeprefix("☁ ")
|
|
461
|
+
recorder = self._assert_recorder_running(recorder_initial_values=recorder_initial_values or {})
|
|
462
|
+
return recorder.get_var(var)
|
|
463
|
+
|
|
464
|
+
def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None):
|
|
465
|
+
recorder = self._assert_recorder_running(recorder_initial_values=recorder_initial_values or {})
|
|
466
|
+
return recorder.get_all_vars()
|
|
467
|
+
|
|
468
|
+
def create_event_stream(self):
|
|
469
|
+
if self.event_stream:
|
|
470
|
+
raise ValueError("Cloud already has an event stream.")
|
|
471
|
+
self.event_stream = WebSocketEventStream(self)
|
|
472
|
+
return self.event_stream
|
|
473
|
+
|
|
474
|
+
class LogCloudMeta(ABCMeta):
|
|
475
|
+
def __instancecheck__(cls, instance) -> bool:
|
|
476
|
+
if hasattr(instance, "logs"):
|
|
477
|
+
return isinstance(instance, BaseCloud)
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
class LogCloud(BaseCloud, metaclass=LogCloudMeta):
|
|
481
|
+
@abstractmethod
|
|
482
|
+
def logs(self, *, filter_by_var_named: Optional[str] = None, limit: int = 100, offset: int = 0) -> list[cloud_activity.CloudActivity]:
|
|
483
|
+
pass
|
cloud/cloud.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""v2 ready: ScratchCloud, TwCloud and CustomCloud classes"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Optional, Any
|
|
7
|
+
|
|
8
|
+
from websocket import WebSocketBadStatusException
|
|
9
|
+
|
|
10
|
+
from ._base import BaseCloud
|
|
11
|
+
from scratchattach.utils.requests import requests
|
|
12
|
+
from scratchattach.utils import exceptions, commons
|
|
13
|
+
from scratchattach.site import cloud_activity
|
|
14
|
+
|
|
15
|
+
class ScratchCloud(BaseCloud):
|
|
16
|
+
def __init__(self, *, project_id, _session=None):
|
|
17
|
+
super().__init__()
|
|
18
|
+
|
|
19
|
+
self.project_id = project_id
|
|
20
|
+
|
|
21
|
+
# Configure this object's attributes specifically for being used with Scratch's cloud:
|
|
22
|
+
self.cloud_host = "wss://clouddata.scratch.mit.edu"
|
|
23
|
+
self.length_limit = 256
|
|
24
|
+
self._session = _session
|
|
25
|
+
if self._session is not None:
|
|
26
|
+
self.username = self._session.username
|
|
27
|
+
self.cookie = "scratchsessionsid=" + self._session.id + ";"
|
|
28
|
+
self.origin = "https://scratch.mit.edu"
|
|
29
|
+
|
|
30
|
+
def connect(self):
|
|
31
|
+
self._assert_auth() # Connecting to Scratch's cloud websocket requires a login to the Scratch website
|
|
32
|
+
try:
|
|
33
|
+
super().connect()
|
|
34
|
+
except WebSocketBadStatusException as e:
|
|
35
|
+
raise exceptions.CloudConnectionError("Error: Scratch's Cloud system may be down. Please try again later.") from e
|
|
36
|
+
|
|
37
|
+
def set_var(self, variable, value, *, max_retries : int = 2):
|
|
38
|
+
self._assert_auth() # Setting a cloud var requires a login to the Scratch website
|
|
39
|
+
super().set_var(variable, value, max_retries=max_retries)
|
|
40
|
+
|
|
41
|
+
def set_vars(self, var_value_dict, *, intelligent_waits=True, max_retries : int = 2):
|
|
42
|
+
self._assert_auth()
|
|
43
|
+
super().set_vars(var_value_dict, intelligent_waits=intelligent_waits, max_retries=max_retries)
|
|
44
|
+
|
|
45
|
+
def logs(self, *, filter_by_var_named=None, limit=100, offset=0) -> list[cloud_activity.CloudActivity]:
|
|
46
|
+
"""
|
|
47
|
+
Gets the data from Scratch's clouddata logs.
|
|
48
|
+
|
|
49
|
+
Keyword Arguments:
|
|
50
|
+
filter_by_var_named (str or None): If you only want to get data for one cloud variable, set this argument to its name.
|
|
51
|
+
limit (int): Max. amount of returned activity.
|
|
52
|
+
offset (int): Offset of the first activity in the returned list.
|
|
53
|
+
log_url (str): If you want to get the clouddata from a cloud log API different to Scratch's normal cloud log API, set this argument to the URL of the API. Only set this argument if you know what you are doing. If you want to get the clouddata from the normal API, don't put this argument.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
data = requests.get(f"https://clouddata.scratch.mit.edu/logs?projectid={self.project_id}&limit={limit}&offset={offset}", timeout=10).json()
|
|
57
|
+
if filter_by_var_named is not None:
|
|
58
|
+
filter_by_var_named = filter_by_var_named.removeprefix("☁ ")
|
|
59
|
+
data = list(filter(lambda k: k["name"] == "☁ "+filter_by_var_named, data))
|
|
60
|
+
for x in data:
|
|
61
|
+
x["cloud"] = self
|
|
62
|
+
return commons.parse_object_list(data, cloud_activity.CloudActivity, self._session, "name")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise exceptions.FetchError(str(e))
|
|
65
|
+
|
|
66
|
+
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None, use_logs=False):
|
|
67
|
+
var = var.removeprefix("☁ ")
|
|
68
|
+
if self._session is None or use_logs:
|
|
69
|
+
filtered = self.logs(limit=100, filter_by_var_named="☁ "+var)
|
|
70
|
+
if len(filtered) == 0:
|
|
71
|
+
return None
|
|
72
|
+
return filtered[0].value
|
|
73
|
+
else:
|
|
74
|
+
if self.recorder is None:
|
|
75
|
+
initial_values = self.get_all_vars(use_logs=True)
|
|
76
|
+
return super().get_var("☁ "+var, recorder_initial_values=initial_values)
|
|
77
|
+
else:
|
|
78
|
+
return super().get_var("☁ "+var, recorder_initial_values=recorder_initial_values)
|
|
79
|
+
|
|
80
|
+
def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None, use_logs=False):
|
|
81
|
+
if self._session is None or use_logs:
|
|
82
|
+
logs = self.logs(limit=100)
|
|
83
|
+
logs.reverse()
|
|
84
|
+
clouddata = {}
|
|
85
|
+
for activity in logs:
|
|
86
|
+
clouddata[activity.name] = activity.value
|
|
87
|
+
return clouddata
|
|
88
|
+
else:
|
|
89
|
+
if self.recorder is None:
|
|
90
|
+
initial_values = self.get_all_vars(use_logs=True)
|
|
91
|
+
return super().get_all_vars(recorder_initial_values=initial_values)
|
|
92
|
+
else:
|
|
93
|
+
return super().get_all_vars(recorder_initial_values=recorder_initial_values)
|
|
94
|
+
|
|
95
|
+
def events(self, *, use_logs=False):
|
|
96
|
+
if self._session is None or use_logs:
|
|
97
|
+
from scratchattach.eventhandlers.cloud_events import CloudLogEvents
|
|
98
|
+
return CloudLogEvents(self)
|
|
99
|
+
else:
|
|
100
|
+
return super().events()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TwCloud(BaseCloud):
|
|
104
|
+
def __init__(self, *, project_id, cloud_host="wss://clouddata.turbowarp.org", purpose="", contact="",
|
|
105
|
+
_session=None):
|
|
106
|
+
super().__init__()
|
|
107
|
+
|
|
108
|
+
self.project_id = project_id
|
|
109
|
+
|
|
110
|
+
# Configure this object's attributes specifically for being used with TurboWarp's cloud:
|
|
111
|
+
self.cloud_host = cloud_host
|
|
112
|
+
self.ws_shortterm_ratelimit = 0 # TurboWarp doesn't enforce a wait time between cloud variable sets
|
|
113
|
+
self.ws_longterm_ratelimit = 0
|
|
114
|
+
self.length_limit = 100000 # TurboWarp doesn't enforce a cloud variable length
|
|
115
|
+
purpose_string = ""
|
|
116
|
+
if purpose != "" or contact != "":
|
|
117
|
+
purpose_string = f" (Purpose:{purpose}; Contact:{contact})"
|
|
118
|
+
self.header = {"User-Agent":f"scratchattach/2.0.0{purpose_string}"}
|
|
119
|
+
|
|
120
|
+
class CustomCloud(BaseCloud):
|
|
121
|
+
|
|
122
|
+
def __init__(self, *, project_id, cloud_host, **kwargs):
|
|
123
|
+
super().__init__()
|
|
124
|
+
|
|
125
|
+
self.project_id = project_id
|
|
126
|
+
self.cloud_host = cloud_host
|
|
127
|
+
|
|
128
|
+
# Configure this object's attributes specifically for the cloud that the developer wants to connect to:
|
|
129
|
+
# -> For this purpose, all additional keyword arguments (kwargs) will be set as attributes of the CustomCloud object
|
|
130
|
+
# This allows the maximum amount of attribute customization
|
|
131
|
+
# See the docstring for the cloud._base.BaseCloud class to find out what attributes can be set / specified as keyword args
|
|
132
|
+
self.__dict__.update(kwargs)
|
|
133
|
+
|
|
134
|
+
# If even more customization is needed, the developer can create a class inheriting from cloud._base.BaseCloud to override functions like .set_var etc.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_cloud(project_id, *, CloudClass: type[BaseCloud] = ScratchCloud) -> BaseCloud:
|
|
138
|
+
"""
|
|
139
|
+
Connects to a cloud (by default Scratch's cloud) as logged out user.
|
|
140
|
+
|
|
141
|
+
Warning:
|
|
142
|
+
Since this method doesn't connect a login / session to the returned object, setting Scratch cloud variables won't be possible with it.
|
|
143
|
+
|
|
144
|
+
To set Scratch cloud variables, use `scratchattach.site.session.Session.connect_scratch_cloud` instead.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
project_id:
|
|
148
|
+
|
|
149
|
+
Keyword arguments:
|
|
150
|
+
CloudClass: The class that the returned object should be of. By default this class is scratchattach.cloud.ScratchCloud.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Type[scratchattach.cloud._base.BaseCloud]: An object representing the cloud of a project. Can be of any class inheriting from BaseCloud.
|
|
154
|
+
"""
|
|
155
|
+
warnings.warn(
|
|
156
|
+
"To set Scratch cloud variables, use session.connect_cloud instead of get_cloud",
|
|
157
|
+
exceptions.CloudAuthenticationWarning
|
|
158
|
+
)
|
|
159
|
+
return CloudClass(project_id=project_id)
|
|
160
|
+
|
|
161
|
+
def get_scratch_cloud(project_id):
|
|
162
|
+
"""
|
|
163
|
+
Warning:
|
|
164
|
+
Since this method doesn't connect a login / session to the returned object, setting Scratch cloud variables won't be possible with it.
|
|
165
|
+
|
|
166
|
+
To set Scratch cloud variables, use `scratchattach.Session.connect_scratch_cloud` instead.
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
scratchattach.cloud.ScratchCloud: An object representing the Scratch cloud of a project.
|
|
171
|
+
"""
|
|
172
|
+
warnings.warn(
|
|
173
|
+
"To set Scratch cloud variables, use session.connect_scratch_cloud instead of get_scratch_cloud",
|
|
174
|
+
exceptions.CloudAuthenticationWarning
|
|
175
|
+
)
|
|
176
|
+
return ScratchCloud(project_id=project_id)
|
|
177
|
+
|
|
178
|
+
def get_tw_cloud(project_id, *, purpose="", contact="", cloud_host="wss://clouddata.turbowarp.org"):
|
|
179
|
+
"""
|
|
180
|
+
Returns:
|
|
181
|
+
scratchattach.cloud.TwCloud: An object representing the TurboWarp cloud of a project.
|
|
182
|
+
"""
|
|
183
|
+
return TwCloud(project_id=project_id, purpose=purpose, contact=contact, cloud_host=cloud_host)
|
editor/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scratchattach.editor (sbeditor v2) - a library for all things sb3
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .asset import Asset, Costume, Sound
|
|
6
|
+
from .blockshape import BlockShapes
|
|
7
|
+
from .project import Project
|
|
8
|
+
from .extension import Extensions, Extension
|
|
9
|
+
from .mutation import Mutation, Argument, ArgumentType, parse_proc_code, construct_proccode, ArgTypes
|
|
10
|
+
from .meta import Meta, set_meta_platform
|
|
11
|
+
from .sprite import Sprite
|
|
12
|
+
from .block import Block
|
|
13
|
+
from .prim import Prim, PrimTypes
|
|
14
|
+
from .backpack_json import load_script as load_script_from_backpack
|
|
15
|
+
from .twconfig import TWConfig, is_valid_twconfig
|
|
16
|
+
from .inputs import Input, ShadowStatuses
|
|
17
|
+
from .field import Field
|
|
18
|
+
from .vlb import Variable, List, Broadcast
|
|
19
|
+
from .comment import Comment
|
|
20
|
+
from .monitor import Monitor
|
|
21
|
+
|
|
22
|
+
from .build_defaulting import add_chain, add_comment, add_block
|