scratchattach 2.1.8__py3-none-any.whl → 2.1.10a0__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 (59) hide show
  1. scratchattach/__init__.py +28 -25
  2. scratchattach/cloud/__init__.py +2 -0
  3. scratchattach/cloud/_base.py +454 -282
  4. scratchattach/cloud/cloud.py +171 -168
  5. scratchattach/editor/__init__.py +21 -0
  6. scratchattach/editor/asset.py +199 -0
  7. scratchattach/editor/backpack_json.py +117 -0
  8. scratchattach/editor/base.py +142 -0
  9. scratchattach/editor/block.py +507 -0
  10. scratchattach/editor/blockshape.py +353 -0
  11. scratchattach/editor/build_defaulting.py +47 -0
  12. scratchattach/editor/comment.py +74 -0
  13. scratchattach/editor/commons.py +243 -0
  14. scratchattach/editor/extension.py +43 -0
  15. scratchattach/editor/field.py +90 -0
  16. scratchattach/editor/inputs.py +132 -0
  17. scratchattach/editor/meta.py +106 -0
  18. scratchattach/editor/monitor.py +175 -0
  19. scratchattach/editor/mutation.py +317 -0
  20. scratchattach/editor/pallete.py +91 -0
  21. scratchattach/editor/prim.py +170 -0
  22. scratchattach/editor/project.py +273 -0
  23. scratchattach/editor/sbuild.py +2837 -0
  24. scratchattach/editor/sprite.py +586 -0
  25. scratchattach/editor/twconfig.py +113 -0
  26. scratchattach/editor/vlb.py +134 -0
  27. scratchattach/eventhandlers/_base.py +99 -92
  28. scratchattach/eventhandlers/cloud_events.py +110 -103
  29. scratchattach/eventhandlers/cloud_recorder.py +26 -21
  30. scratchattach/eventhandlers/cloud_requests.py +460 -452
  31. scratchattach/eventhandlers/cloud_server.py +246 -244
  32. scratchattach/eventhandlers/cloud_storage.py +135 -134
  33. scratchattach/eventhandlers/combine.py +29 -27
  34. scratchattach/eventhandlers/filterbot.py +160 -159
  35. scratchattach/eventhandlers/message_events.py +41 -40
  36. scratchattach/other/other_apis.py +284 -212
  37. scratchattach/other/project_json_capabilities.py +475 -546
  38. scratchattach/site/_base.py +64 -46
  39. scratchattach/site/activity.py +414 -122
  40. scratchattach/site/backpack_asset.py +118 -84
  41. scratchattach/site/classroom.py +430 -142
  42. scratchattach/site/cloud_activity.py +107 -103
  43. scratchattach/site/comment.py +220 -190
  44. scratchattach/site/forum.py +400 -399
  45. scratchattach/site/project.py +806 -787
  46. scratchattach/site/session.py +1134 -867
  47. scratchattach/site/studio.py +611 -609
  48. scratchattach/site/user.py +835 -837
  49. scratchattach/utils/commons.py +243 -148
  50. scratchattach/utils/encoder.py +157 -156
  51. scratchattach/utils/enums.py +197 -190
  52. scratchattach/utils/exceptions.py +233 -206
  53. scratchattach/utils/requests.py +67 -59
  54. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/LICENSE +21 -21
  55. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/METADATA +154 -146
  56. scratchattach-2.1.10a0.dist-info/RECORD +62 -0
  57. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/WHEEL +1 -1
  58. scratchattach-2.1.8.dist-info/RECORD +0 -40
  59. {scratchattach-2.1.8.dist-info → scratchattach-2.1.10a0.dist-info}/top_level.txt +0 -0
@@ -1,452 +1,460 @@
1
- """CloudRequests class (threading.Event version)"""
2
-
3
- from .cloud_events import CloudEvents
4
- from ..site import project
5
- from threading import Thread, Event, current_thread
6
- import time
7
- import random
8
- import traceback
9
- from ..utils.encoder import Encoding
10
- from ..utils import exceptions
11
-
12
- class Request:
13
-
14
- """
15
- Saves a request added to the request handler
16
- """
17
-
18
- def __init__(self, request_name, *, on_call, cloud_requests, thread=True, enabled=True, response_priority=0):
19
- self.name = request_name
20
- self.on_call = on_call
21
- self.thread = thread
22
- self.enabled = enabled
23
- self.response_priority = response_priority
24
- self.cloud_requests = cloud_requests # the corresponding CloudRequests object
25
-
26
- def __call__(self, received_request):
27
- if not self.enabled:
28
- self.cloud_requests.call_event("on_disabled_request", [received_request])
29
- try:
30
- current_thread().setName(received_request.request_id)
31
- output = self.on_call(*received_request.arguments)
32
- self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":output, "priority":self.response_priority})
33
- except Exception as e:
34
- self.cloud_requests.call_event("on_error", [received_request, e])
35
- if self.cloud_requests.ignore_exceptions:
36
- print(
37
- f"Warning: Caught error in request '{self.name}' - Full error below"
38
- )
39
- try:
40
- traceback.print_exc()
41
- except Exception:
42
- print(e)
43
- else:
44
- print(f"Exception in request '{self.name}':")
45
- raise(e)
46
- self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":[f"Error in request {self.name}","Check the Python console"], "priority":self.response_priority})
47
- self.cloud_requests.responder_event.set() # Activate the .cloud_requests._responder process so it sends back the data to Scratch
48
-
49
- class ReceivedRequest:
50
-
51
- def __init__(self, **entries):
52
- self.__dict__.update(entries)
53
-
54
- class CloudRequests(CloudEvents):
55
-
56
- # The CloudRequests class is built upon CloudEvents, similar to how Filterbot is built upon MessageEvents
57
-
58
- def __init__(self, cloud, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], no_packet_loss=False, respond_order="receive"):
59
- super().__init__(cloud)
60
- # Setup
61
- self._requests = {}
62
- self.event(self.on_set, thread=False)
63
- self.event(self.on_reconnect, thread=True)
64
- self.respond_in_thread = False
65
- self.no_packet_loss = no_packet_loss # When enabled, query the clouddata log regularly for missed requests and reconnect after every single request (reduces packet loss a lot, but is spammy and can make response duration longer)
66
- self.used_cloud_vars = used_cloud_vars
67
- self.respond_order = respond_order
68
-
69
- # Lists and dicts for saving request-related stuff
70
- self.request_parts = {} # Dict (key: request_id) for saving the parts of the requests not fully received yet
71
- self.received_requests = [] # List saving the requests that have been fully received, but not executed yet (as ReceivedRequest objects). Requests that run in threads will never be put into this list, but are executed directly.
72
- self.executed_requests = {} # Dict (key: request_id) saving the request that are currently being executed and have not been responded yet (as ReceivedRequest objects)
73
- self.request_outputs = [] # List for the output data returned by the requests (so the thread sending it back to Scratch can access it)
74
- self.responded_request_ids = [] # Saves the last 15 request ids that have been responded to. This prevents double responses then using the clouddata logs as 2nd source for preventing packet loss
75
- self.packet_memory = [] # Saves the last 15 responses so the Scratch project can re-request packets that weren't received
76
- self._packets_to_resend = []
77
-
78
- # threading Event objects used to block threads until they are needed (lower CPU usage compared to a busy-sleep event queue)#
79
- self.executer_event = Event()
80
- self.responder_event = Event()
81
-
82
- # Start ._executer and ._responder threads (these threads are remain blocked until cloud activity is received and don't consume any CPU)
83
- self.executer_thread = Thread(target=self._executer)
84
- self.responder_thread = Thread(target=self._responder)
85
- self.executer_thread.start()
86
- self.responder_thread.start()
87
-
88
- self.current_var = 0 # ID of the last set FROM_HOST_ variable (when a response is sent back to Scratch, these are set cyclically)
89
- self.credit_check()
90
- self.hard_stop = False # When set to True, all processes will halt immediately without finishing safely (can result in not fully received / responded requests etc.)
91
-
92
- # -- Adding and removing requests --
93
-
94
- def request(self, function=None, *, enabled=True, name=None, thread=True, response_priority=0):
95
- """
96
- Decorator function. Adds a request to the request handler.
97
- """
98
- def inner(function):
99
- # called if the decorator provides arguments
100
- if thread:
101
- self.respond_in_thread = True
102
- self._requests[function.__name__ if name is None else name] = Request(
103
- function.__name__ if name is None else name,
104
- enabled = enabled,
105
- thread=thread,
106
- response_priority=response_priority,
107
- on_call=function,
108
- cloud_requests=self
109
- )
110
-
111
- if function is None:
112
- # => the decorator provides arguments
113
- return inner
114
- else:
115
- # => the decorator doesn't provide arguments
116
- inner(function)
117
-
118
- def add_request(self, function, *, enabled=True, name=None):
119
- self.request(enabled=enabled, name=name)(function)
120
-
121
- def remove_request(self, name):
122
- try:
123
- self._requests.pop(name)
124
- except Exception:
125
- raise ValueError(
126
- f"No request with name {name} found to remove"
127
- )
128
-
129
- # -- Parse and send back the request output --
130
-
131
- def _parse_output(self, request_name, output, request_id):
132
- """
133
- Prepares the transmission of the request output to the Scratch project
134
- """
135
- if len(str(output)) > 3000:
136
- print(
137
- f"Warning: Output of request '{request_name}' is longer than 3000 characters (length: {len(str(output))} characters). Responding the request will take >4 seconds."
138
- )
139
-
140
- if str(request_id).endswith("0"):
141
- try:
142
- int(output) == output
143
- except Exception:
144
- send_as_integer = False
145
- else:
146
- send_as_integer = not ("-" in str(output)) and not isinstance(output, bool)
147
- else:
148
- send_as_integer = False
149
-
150
- if output is None:
151
- print(f"Warning: Request '{request_name}' didn't return anything.")
152
- return
153
- elif send_as_integer:
154
- output = str(output)
155
- elif not isinstance(output, list):
156
- if output == "":
157
- output = "-"
158
- output = Encoding.encode(output)
159
- else:
160
- input = output
161
- output = ""
162
- for i in input:
163
- output += Encoding.encode(i)
164
- output += "89"
165
- self._respond(request_id, output, validation=3222 if send_as_integer else 2222)
166
-
167
- def _set_FROM_HOST_var(self, value):
168
- try:
169
- self.cloud.set_var(f"FROM_HOST_{self.used_cloud_vars[self.current_var]}", value)
170
- except exceptions.ConnectionError:
171
- self.call_even("on_disconnect")
172
- except Exception as e:
173
- print("scratchattach: internal error while responding (please submit a bug report on GitHub):", e)
174
- self.current_var += 1
175
- if self.current_var == len(self.used_cloud_vars):
176
- self.current_var = 0
177
- time.sleep(self.cloud.ws_shortterm_ratelimit)
178
-
179
- def _respond(self, request_id, response, *, validation=2222):
180
- """
181
- Sends back the request response to the Scratch project
182
- """
183
- if (self.cloud.last_var_set + 8 < time.time() # if the cloud connection has been idle for too long, a reconnect is necessary to make sure the first package will not be lost
184
- ) or self.no_packet_loss:
185
- self.cloud.reconnect()
186
-
187
- memory = {"rid":request_id}
188
- remaining_response = str(response)
189
- length_limit = self.cloud.length_limit - (len(str(request_id))+6) # the subtrahend is the worst-case length of the "."+numbers after the "."
190
-
191
- i = 0
192
- while not remaining_response == "":
193
- if len(remaining_response) > length_limit:
194
- response_part = remaining_response[:length_limit]
195
- remaining_response = remaining_response[length_limit:]
196
-
197
- i += 1
198
- if i > 99:
199
- iteration_string = str(i)
200
- elif i > 9:
201
- iteration_string = "0" + str(i)
202
- else:
203
- iteration_string = "00" + str(i)
204
-
205
- value_to_send = f"{response_part}.{request_id}{iteration_string}1"
206
- memory[i] = value_to_send
207
-
208
- self._set_FROM_HOST_var(value_to_send)
209
-
210
- else:
211
- self._set_FROM_HOST_var(f"{remaining_response}.{request_id}{validation}")
212
- self.packet_memory.append(memory)
213
- if len(self.packet_memory) > 15:
214
- self.packet_memory.pop(0)
215
- remaining_response = ""
216
-
217
- if self.hard_stop: # stop immediately without exiting safely
218
- break
219
-
220
- def _request_packet_from_memory(self, request_id, packet_id):
221
- memory = list(filter(lambda x : x["rid"] == request_id, self.packet_memory))
222
- if len(memory) > 0:
223
- self._packets_to_resend.append(memory[0][int(packet_id)])
224
- self.responder_event.set() # activate _responder process
225
-
226
- # -- Register and handle incoming requests --
227
-
228
- def on_set(self, activity):
229
- """
230
- This function is automatically called on cloud activites by the underlying cloud events that this CloudRequests class inherits from
231
- It registers incoming cloud activity and (if request.thread is True) runs them directly or (else) adds detected request to the .received_requests list
232
- """
233
- # Note for contributors: All functions called in this on_set function MUST be executed in threads because this function blocks the cloud events receiver, which is usually not a problem (because of the websocket buffer) but can cause problems in rare cases
234
- if activity.var == "TO_HOST" and "." in activity.value:
235
- # Parsing the received request
236
- raw_request, request_id = activity.value.split(".")
237
-
238
- if len(request_id) == 8 and request_id[-1] == "9":
239
- # A lost packet was re-requested
240
- self._request_packet_from_memory(request_id[1:], int(raw_request))
241
- return
242
-
243
- if request_id in self.responded_request_ids:
244
- # => The received request has already been answered, meaning this activity has already been received
245
- return
246
-
247
- if activity.value[0] == "-":
248
- # => The received request is actually part of a bigger request
249
- if not request_id in self.request_parts:
250
- self.request_parts[request_id] = []
251
- self.request_parts[request_id].append(raw_request[1:])
252
- return
253
-
254
- self.responded_request_ids.insert(0, request_id)
255
- self.responded_request_ids = self.responded_request_ids[:35]
256
-
257
- # If the request consists of multiple parts: Put together the parts to get the whole raw request string
258
- _raw_request = ""
259
- if request_id in self.request_parts:
260
- data = self.request_parts[request_id]
261
- for i in data:
262
- _raw_request += i
263
- self.request_parts.pop(request_id)
264
- raw_request = _raw_request + raw_request
265
-
266
- # Decode request and parse arguemtns:
267
- request = Encoding.decode(raw_request)
268
- arguments = request.split("&")
269
- request_name = arguments.pop(0)#
270
-
271
- # Check if the request is unknown:
272
- if request_name not in self._requests:
273
- print(
274
- f"Warning: Client received an unknown request called '{request_name}'"
275
- )
276
- self.call_event("on_unknown_request", [
277
- ReceivedRequest(request_name=request,
278
- requester=activity.user,
279
- timestamp=activity.timestamp,
280
- arguments=arguments,
281
- request_id=request_id,
282
- activity=activity
283
- )
284
- ])
285
- return
286
-
287
- received_request = ReceivedRequest(
288
- request = self._requests[request_name],
289
- requester=activity.user,
290
- timestamp=activity.timestamp,
291
- arguments=arguments,
292
- request_id=request_id,
293
- activity=activity
294
- )
295
- self.call_event("on_request", received_request)
296
- if received_request.request.thread:
297
- self.executed_requests[request_id] = received_request
298
- Thread(target=received_request.request, args=[received_request]).start() # Execute the request function directly in a thread
299
- else:
300
- self.received_requests.append(received_request)
301
- self.executer_event.set() # Activate the ._executer process so that it handles the received request
302
-
303
- def _executer(self):
304
- """
305
- A process that detects new requests in .received_requests, moves them to .executed_requests and executes them. Only requests not running in threads are handled in this process.
306
- """
307
- # If .no_packet_loss is enabled and the cloud provides logs, the logs are used to check whether there are cloud activities that were not received over the cloud connection used by the underlying cloud events
308
- use_extra_data = (self.no_packet_loss and hasattr(self.cloud, "logs"))
309
-
310
- self.executer_event.wait() # Wait for requests to be received
311
- while self.executer_thread is not None: # If self.executer_thread is None, it means cloud requests were stopped using .stop()
312
- self.executer_event.clear()
313
-
314
- if self.received_requests == [] and use_extra_data:
315
- Thread(target=self.on_reconnect).start()
316
-
317
- while self.received_requests != []:
318
- received_request = self.received_requests.pop(0)
319
- self.executed_requests[received_request.request_id] = received_request
320
- received_request.request(received_request) # Execute the request function
321
-
322
- if use_extra_data:
323
- Thread(target=self.on_reconnect).start()
324
- if self.hard_stop: # stop immediately without exiting safely
325
- break
326
-
327
- self.executer_event.wait(timeout = 2.5 if use_extra_data else None) # Wait for requests to be received
328
-
329
- def _responder(self):
330
- """
331
- A process that detects incoming request outputs in .request_outputs and handles them by sending them back to the Scratch project, also removes the corresponding ReceivedRequest object from .executed_requests
332
- """
333
- while self.responder_thread is not None: # If self.responder_thread is None, it means cloud requests were stopped using .stop()
334
- self.responder_event.wait() # Wait for executed requests to respond
335
- self.responder_event.clear()
336
-
337
- while self._packets_to_resend != []:
338
- self._set_FROM_HOST_var(self._packets_to_resend.pop(0))
339
- if self.hard_stop: # stop immediately without exiting safely
340
- break
341
-
342
- while self.request_outputs != []:
343
- if self.respond_order == "finish":
344
- output_obj = self.request_outputs.pop(0)
345
- else:
346
- output_obj = min(self.request_outputs, key=lambda x : x[self.respond_order])
347
- self.request_outputs.remove(output_obj)
348
- if output_obj["request_id"] in self.executed_requests:
349
- received_request = self.executed_requests.pop(output_obj["request_id"])
350
- self._parse_output(received_request, output_obj["output"], output_obj["request_id"])
351
- else:
352
- self._parse_output("[sent from backend]", output_obj["output"], output_obj["request_id"])
353
- if self.hard_stop: # stop immediately without exiting safely
354
- break
355
-
356
- def on_reconnect(self):
357
- """
358
- Called when the underlying cloud events reconnect. Makes sure that no requests are missed in this case.
359
- """
360
- try:
361
- extradata = self.cloud.logs(limit=35)[::-1] # Reverse result so oldest activity is first
362
- for activity in extradata:
363
- if activity.timestamp < self.startup_time:
364
- continue
365
- self.on_set(activity) # Read in the fetched activity
366
- except Exception:
367
- pass
368
-
369
- # -- Functions to be used in requests to get info about the request --
370
-
371
- def get_requester(self):
372
- """
373
- Can be used inside a request to get the username that performed the request.
374
- """
375
- activity = self.executed_requests[current_thread().name].activity
376
- if activity.user is None:
377
- activity.load_log_data()
378
- return activity.user
379
-
380
- def get_timestamp(self):
381
- """
382
- Can be used inside a request to get the timestamp of when the request was received.
383
- """
384
- activity = self.executed_requests[current_thread().name].activity
385
- return activity.timestamp
386
-
387
- def get_exact_timestamp(self):
388
- """
389
- Can be used inside a request to get the exact timestamp of when the request was performed.
390
- """
391
- activity = self.executed_requests[current_thread().name].activity
392
- activity.load_log_data()
393
- return activity.timestamp
394
-
395
- # -- Other stuff --
396
-
397
- def send(self, data, *, priority=0):
398
- """
399
- Send data to the Scratch project without a priorly received request. The Scratch project will only receive the data if it's running.
400
- """
401
- self.request_outputs.append({"receive":time.time()*1000, "request_id":"100000000"+str(random.randint(1000, 9999)), "output":data, "priority":priority})
402
- self.responder_event.set() # activate _responder process
403
- # Prevent user from breaking cloud requests by sending too fast (automatically increase wait time if the server can't keep up):
404
- if len(self.request_outputs) > 20:
405
- time.sleep(0.5)
406
- if len(self.request_outputs) > 15:
407
- time.sleep(0.2)
408
- if len(self.request_outputs) > 10:
409
- time.sleep(0.13)
410
- elif len(self.request_outputs) > 3:
411
- time.sleep(0.1)
412
- else:
413
- time.sleep(0.07)
414
-
415
- def stop(self):
416
- """
417
- Stops the request handler and all associated threads forever. Lets running response sending processes finish.
418
- """
419
- # Override the .stop function from BaseEventHandler to make sure the ._executer and ._responder threads are also terminated
420
- super().stop()
421
- self.executer_thread = None
422
- self.responder_thread = None
423
- self.executer_event.set()
424
- self.responder_event.set()
425
-
426
- def hard_stop(self):
427
- """
428
- Stops the request handler and all associated threads forever. Stops running response sending processes immediately.
429
- """
430
- self.hard_stop = True
431
- self.stop()
432
- time.sleep(0.5)
433
- self.hard_stop = False
434
-
435
- def credit_check(self):
436
- try:
437
- p = project.Project(id=self.cloud.project_id)
438
- if not p.update(): # can't get project, probably because it's unshared (no authentication is used for getting it)
439
- print("If you use cloud requests or cloud storages, please credit TimMcCool!")
440
- return
441
- description = (str(p.instructions) + str(p.notes)).lower()
442
- if not ("timmccool" in description or "timmcool" in description or "timccool" in description or "timcool" in description):
443
- print("It was detected that no credit was given in the project description! Please credit TimMcCool when using CloudRequests.")
444
- else:
445
- print("Thanks for giving credit for CloudRequests!")
446
- except Exception:
447
- print("If you use CloudRequests, please credit TimMcCool!")
448
-
449
- def run(self):
450
- # Was changed to .start(), but .run() is kept for backwards compatibility
451
- print("Warning: requests.run() was changed to requests.start() in v2.0. .run() will be removed in a future version")
452
- self.start()
1
+ """CloudRequests class (threading.Event version)"""
2
+ from __future__ import annotations
3
+
4
+ from .cloud_events import CloudEvents
5
+ from ..site import project
6
+ from threading import Thread, Event, current_thread
7
+ import time
8
+ import random
9
+ import traceback
10
+ from ..utils.encoder import Encoding
11
+ from ..utils import exceptions
12
+
13
+ class Request:
14
+
15
+ """
16
+ Saves a request added to the request handler
17
+ """
18
+
19
+ def __init__(self, request_name, *, on_call, cloud_requests, thread=True, enabled=True, response_priority=0, debug=False):
20
+ self.name = request_name
21
+ self.on_call = on_call
22
+ self.thread = thread
23
+ self.enabled = enabled
24
+ self.response_priority = response_priority
25
+ self.cloud_requests = cloud_requests # the corresponding CloudRequests object
26
+ self.debug = debug
27
+
28
+ def __call__(self, received_request):
29
+ if not self.enabled:
30
+ self.cloud_requests.call_event("on_disabled_request", [received_request])
31
+ try:
32
+ current_thread().setName(received_request.request_id)
33
+ output = self.on_call(*received_request.arguments)
34
+ self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":output, "priority":self.response_priority})
35
+ except Exception as e:
36
+ self.cloud_requests.call_event("on_error", [received_request, e])
37
+ if self.cloud_requests.ignore_exceptions:
38
+ print(
39
+ f"Warning: Caught error in request '{self.name}' - Full error below"
40
+ )
41
+ try:
42
+ traceback.print_exc()
43
+ except Exception:
44
+ print(e)
45
+ else:
46
+ print(f"Exception in request '{self.name}':")
47
+ raise(e)
48
+ if self.debug:
49
+ traceback_full = traceback.format_exc().splitlines()
50
+ output = [f"Error in request {self.name}", "Traceback: "]
51
+ output.extend(traceback_full)
52
+ self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":output, "priority":self.response_priority})
53
+ else:
54
+ self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":[f"Error in request {self.name}","Check the Python console"], "priority":self.response_priority})
55
+ self.cloud_requests.responder_event.set() # Activate the .cloud_requests._responder process so it sends back the data to Scratch
56
+
57
+ class ReceivedRequest:
58
+
59
+ def __init__(self, **entries):
60
+ self.__dict__.update(entries)
61
+
62
+ class CloudRequests(CloudEvents):
63
+
64
+ # The CloudRequests class is built upon CloudEvents, similar to how Filterbot is built upon MessageEvents
65
+
66
+ def __init__(self, cloud, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], no_packet_loss=False, respond_order="receive"):
67
+ super().__init__(cloud)
68
+ # Setup
69
+ self._requests = {}
70
+ self.event(self.on_set, thread=False)
71
+ self.event(self.on_reconnect, thread=True)
72
+ self.respond_in_thread = False
73
+ self.no_packet_loss = no_packet_loss # When enabled, query the clouddata log regularly for missed requests and reconnect after every single request (reduces packet loss a lot, but is spammy and can make response duration longer)
74
+ self.used_cloud_vars = used_cloud_vars
75
+ self.respond_order = respond_order
76
+
77
+ # Lists and dicts for saving request-related stuff
78
+ self.request_parts = {} # Dict (key: request_id) for saving the parts of the requests not fully received yet
79
+ self.received_requests = [] # List saving the requests that have been fully received, but not executed yet (as ReceivedRequest objects). Requests that run in threads will never be put into this list, but are executed directly.
80
+ self.executed_requests = {} # Dict (key: request_id) saving the request that are currently being executed and have not been responded yet (as ReceivedRequest objects)
81
+ self.request_outputs = [] # List for the output data returned by the requests (so the thread sending it back to Scratch can access it)
82
+ self.responded_request_ids = [] # Saves the last 15 request ids that have been responded to. This prevents double responses then using the clouddata logs as 2nd source for preventing packet loss
83
+ self.packet_memory = [] # Saves the last 15 responses so the Scratch project can re-request packets that weren't received
84
+ self._packets_to_resend = []
85
+
86
+ # threading Event objects used to block threads until they are needed (lower CPU usage compared to a busy-sleep event queue)#
87
+ self.executer_event = Event()
88
+ self.responder_event = Event()
89
+
90
+ # Start ._executer and ._responder threads (these threads are remain blocked until cloud activity is received and don't consume any CPU)
91
+ self.executer_thread = Thread(target=self._executer)
92
+ self.responder_thread = Thread(target=self._responder)
93
+ self.executer_thread.start()
94
+ self.responder_thread.start()
95
+
96
+ self.current_var = 0 # ID of the last set FROM_HOST_ variable (when a response is sent back to Scratch, these are set cyclically)
97
+ self.credit_check()
98
+ self.hard_stop = False # When set to True, all processes will halt immediately without finishing safely (can result in not fully received / responded requests etc.)
99
+
100
+ # -- Adding and removing requests --
101
+
102
+ def request(self, function=None, *, enabled=True, name=None, thread=True, response_priority=0):
103
+ """
104
+ Decorator function. Adds a request to the request handler.
105
+ """
106
+ def inner(function):
107
+ # called if the decorator provides arguments
108
+ if thread:
109
+ self.respond_in_thread = True
110
+ self._requests[function.__name__ if name is None else name] = Request(
111
+ function.__name__ if name is None else name,
112
+ enabled = enabled,
113
+ thread=thread,
114
+ response_priority=response_priority,
115
+ on_call=function,
116
+ cloud_requests=self
117
+ )
118
+
119
+ if function is None:
120
+ # => the decorator provides arguments
121
+ return inner
122
+ else:
123
+ # => the decorator doesn't provide arguments
124
+ inner(function)
125
+
126
+ def add_request(self, function, *, enabled=True, name=None):
127
+ self.request(enabled=enabled, name=name)(function)
128
+
129
+ def remove_request(self, name):
130
+ try:
131
+ self._requests.pop(name)
132
+ except Exception:
133
+ raise ValueError(
134
+ f"No request with name {name} found to remove"
135
+ )
136
+
137
+ # -- Parse and send back the request output --
138
+
139
+ def _parse_output(self, request_name, output, request_id):
140
+ """
141
+ Prepares the transmission of the request output to the Scratch project
142
+ """
143
+ if len(str(output)) > 3000:
144
+ print(
145
+ f"Warning: Output of request '{request_name}' is longer than 3000 characters (length: {len(str(output))} characters). Responding the request will take >4 seconds."
146
+ )
147
+
148
+ if str(request_id).endswith("0"):
149
+ try:
150
+ int(output) == output
151
+ except Exception:
152
+ send_as_integer = False
153
+ else:
154
+ send_as_integer = not ("-" in str(output)) and not isinstance(output, bool)
155
+ else:
156
+ send_as_integer = False
157
+
158
+ if output is None:
159
+ print(f"Warning: Request '{request_name}' didn't return anything.")
160
+ return
161
+ elif send_as_integer:
162
+ output = str(output)
163
+ elif not isinstance(output, list):
164
+ if output == "":
165
+ output = "-"
166
+ output = Encoding.encode(output)
167
+ else:
168
+ input = output
169
+ output = ""
170
+ for i in input:
171
+ output += Encoding.encode(i)
172
+ output += "89"
173
+ self._respond(request_id, output, validation=3222 if send_as_integer else 2222)
174
+
175
+ def _set_FROM_HOST_var(self, value):
176
+ try:
177
+ self.cloud.set_var(f"FROM_HOST_{self.used_cloud_vars[self.current_var]}", value)
178
+ except exceptions.CloudConnectionError:
179
+ self.call_event("on_disconnect")
180
+ except Exception as e:
181
+ print("scratchattach: internal error while responding (please submit a bug report on GitHub):", e)
182
+ self.current_var += 1
183
+ if self.current_var == len(self.used_cloud_vars):
184
+ self.current_var = 0
185
+ time.sleep(self.cloud.ws_shortterm_ratelimit)
186
+
187
+ def _respond(self, request_id, response, *, validation=2222):
188
+ """
189
+ Sends back the request response to the Scratch project
190
+ """
191
+ if (self.cloud.last_var_set + 8 < time.time() # if the cloud connection has been idle for too long, a reconnect is necessary to make sure the first package will not be lost
192
+ ) or self.no_packet_loss:
193
+ self.cloud.reconnect()
194
+
195
+ memory = {"rid":request_id}
196
+ remaining_response = str(response)
197
+ length_limit = self.cloud.length_limit - (len(str(request_id))+6) # the subtrahend is the worst-case length of the "."+numbers after the "."
198
+
199
+ i = 0
200
+ while not remaining_response == "":
201
+ if len(remaining_response) > length_limit:
202
+ response_part = remaining_response[:length_limit]
203
+ remaining_response = remaining_response[length_limit:]
204
+
205
+ i += 1
206
+ if i > 99:
207
+ iteration_string = str(i)
208
+ elif i > 9:
209
+ iteration_string = "0" + str(i)
210
+ else:
211
+ iteration_string = "00" + str(i)
212
+
213
+ value_to_send = f"{response_part}.{request_id}{iteration_string}1"
214
+ memory[i] = value_to_send
215
+
216
+ self._set_FROM_HOST_var(value_to_send)
217
+
218
+ else:
219
+ self._set_FROM_HOST_var(f"{remaining_response}.{request_id}{validation}")
220
+ self.packet_memory.append(memory)
221
+ if len(self.packet_memory) > 15:
222
+ self.packet_memory.pop(0)
223
+ remaining_response = ""
224
+
225
+ if self.hard_stop: # stop immediately without exiting safely
226
+ break
227
+
228
+ def _request_packet_from_memory(self, request_id, packet_id):
229
+ memory = list(filter(lambda x : x["rid"] == request_id, self.packet_memory))
230
+ if len(memory) > 0:
231
+ self._packets_to_resend.append(memory[0][int(packet_id)])
232
+ self.responder_event.set() # activate _responder process
233
+
234
+ # -- Register and handle incoming requests --
235
+
236
+ def on_set(self, activity):
237
+ """
238
+ This function is automatically called on cloud activites by the underlying cloud events that this CloudRequests class inherits from
239
+ It registers incoming cloud activity and (if request.thread is True) runs them directly or (else) adds detected request to the .received_requests list
240
+ """
241
+ # Note for contributors: All functions called in this on_set function MUST be executed in threads because this function blocks the cloud events receiver, which is usually not a problem (because of the websocket buffer) but can cause problems in rare cases
242
+ if activity.var == "TO_HOST" and "." in activity.value:
243
+ # Parsing the received request
244
+ raw_request, request_id = activity.value.split(".")
245
+
246
+ if len(request_id) == 8 and request_id[-1] == "9":
247
+ # A lost packet was re-requested
248
+ self._request_packet_from_memory(request_id[1:], int(raw_request))
249
+ return
250
+
251
+ if request_id in self.responded_request_ids:
252
+ # => The received request has already been answered, meaning this activity has already been received
253
+ return
254
+
255
+ if activity.value[0] == "-":
256
+ # => The received request is actually part of a bigger request
257
+ if not request_id in self.request_parts:
258
+ self.request_parts[request_id] = []
259
+ self.request_parts[request_id].append(raw_request[1:])
260
+ return
261
+
262
+ self.responded_request_ids.insert(0, request_id)
263
+ self.responded_request_ids = self.responded_request_ids[:35]
264
+
265
+ # If the request consists of multiple parts: Put together the parts to get the whole raw request string
266
+ _raw_request = ""
267
+ if request_id in self.request_parts:
268
+ data = self.request_parts[request_id]
269
+ for i in data:
270
+ _raw_request += i
271
+ self.request_parts.pop(request_id)
272
+ raw_request = _raw_request + raw_request
273
+
274
+ # Decode request and parse arguemtns:
275
+ request = Encoding.decode(raw_request)
276
+ arguments = request.split("&")
277
+ request_name = arguments.pop(0)#
278
+
279
+ # Check if the request is unknown:
280
+ if request_name not in self._requests:
281
+ print(
282
+ f"Warning: Client received an unknown request called '{request_name}'"
283
+ )
284
+ self.call_event("on_unknown_request", [
285
+ ReceivedRequest(request_name=request,
286
+ requester=activity.user,
287
+ timestamp=activity.timestamp,
288
+ arguments=arguments,
289
+ request_id=request_id,
290
+ activity=activity
291
+ )
292
+ ])
293
+ return
294
+
295
+ received_request = ReceivedRequest(
296
+ request = self._requests[request_name],
297
+ requester=activity.user,
298
+ timestamp=activity.timestamp,
299
+ arguments=arguments,
300
+ request_id=request_id,
301
+ activity=activity
302
+ )
303
+ self.call_event("on_request", received_request)
304
+ if received_request.request.thread:
305
+ self.executed_requests[request_id] = received_request
306
+ Thread(target=received_request.request, args=[received_request]).start() # Execute the request function directly in a thread
307
+ else:
308
+ self.received_requests.append(received_request)
309
+ self.executer_event.set() # Activate the ._executer process so that it handles the received request
310
+
311
+ def _executer(self):
312
+ """
313
+ A process that detects new requests in .received_requests, moves them to .executed_requests and executes them. Only requests not running in threads are handled in this process.
314
+ """
315
+ # If .no_packet_loss is enabled and the cloud provides logs, the logs are used to check whether there are cloud activities that were not received over the cloud connection used by the underlying cloud events
316
+ use_extra_data = (self.no_packet_loss and hasattr(self.cloud, "logs"))
317
+
318
+ self.executer_event.wait() # Wait for requests to be received
319
+ while self.executer_thread is not None: # If self.executer_thread is None, it means cloud requests were stopped using .stop()
320
+ self.executer_event.clear()
321
+
322
+ if self.received_requests == [] and use_extra_data:
323
+ Thread(target=self.on_reconnect).start()
324
+
325
+ while self.received_requests != []:
326
+ received_request = self.received_requests.pop(0)
327
+ self.executed_requests[received_request.request_id] = received_request
328
+ received_request.request(received_request) # Execute the request function
329
+
330
+ if use_extra_data:
331
+ Thread(target=self.on_reconnect).start()
332
+ if self.hard_stop: # stop immediately without exiting safely
333
+ break
334
+
335
+ self.executer_event.wait(timeout = 2.5 if use_extra_data else None) # Wait for requests to be received
336
+
337
+ def _responder(self):
338
+ """
339
+ A process that detects incoming request outputs in .request_outputs and handles them by sending them back to the Scratch project, also removes the corresponding ReceivedRequest object from .executed_requests
340
+ """
341
+ while self.responder_thread is not None: # If self.responder_thread is None, it means cloud requests were stopped using .stop()
342
+ self.responder_event.wait() # Wait for executed requests to respond
343
+ self.responder_event.clear()
344
+
345
+ while self._packets_to_resend != []:
346
+ self._set_FROM_HOST_var(self._packets_to_resend.pop(0))
347
+ if self.hard_stop: # stop immediately without exiting safely
348
+ break
349
+
350
+ while self.request_outputs != []:
351
+ if self.respond_order == "finish":
352
+ output_obj = self.request_outputs.pop(0)
353
+ else:
354
+ output_obj = min(self.request_outputs, key=lambda x : x[self.respond_order])
355
+ self.request_outputs.remove(output_obj)
356
+ if output_obj["request_id"] in self.executed_requests:
357
+ received_request = self.executed_requests.pop(output_obj["request_id"])
358
+ self._parse_output(received_request, output_obj["output"], output_obj["request_id"])
359
+ else:
360
+ self._parse_output("[sent from backend]", output_obj["output"], output_obj["request_id"])
361
+ if self.hard_stop: # stop immediately without exiting safely
362
+ break
363
+
364
+ def on_reconnect(self):
365
+ """
366
+ Called when the underlying cloud events reconnect. Makes sure that no requests are missed in this case.
367
+ """
368
+ try:
369
+ extradata = self.cloud.logs(limit=35)[::-1] # Reverse result so oldest activity is first
370
+ for activity in extradata:
371
+ if activity.timestamp < self.startup_time:
372
+ continue
373
+ self.on_set(activity) # Read in the fetched activity
374
+ except Exception:
375
+ pass
376
+
377
+ # -- Functions to be used in requests to get info about the request --
378
+
379
+ def get_requester(self):
380
+ """
381
+ Can be used inside a request to get the username that performed the request.
382
+ """
383
+ activity = self.executed_requests[current_thread().name].activity
384
+ if activity.user is None:
385
+ activity.load_log_data()
386
+ return activity.user
387
+
388
+ def get_timestamp(self):
389
+ """
390
+ Can be used inside a request to get the timestamp of when the request was received.
391
+ """
392
+ activity = self.executed_requests[current_thread().name].activity
393
+ return activity.timestamp
394
+
395
+ def get_exact_timestamp(self):
396
+ """
397
+ Can be used inside a request to get the exact timestamp of when the request was performed.
398
+ """
399
+ activity = self.executed_requests[current_thread().name].activity
400
+ activity.load_log_data()
401
+ return activity.timestamp
402
+
403
+ # -- Other stuff --
404
+
405
+ def send(self, data, *, priority=0):
406
+ """
407
+ Send data to the Scratch project without a priorly received request. The Scratch project will only receive the data if it's running.
408
+ """
409
+ self.request_outputs.append({"receive":time.time()*1000, "request_id":"100000000"+str(random.randint(1000, 9999)), "output":data, "priority":priority})
410
+ self.responder_event.set() # activate _responder process
411
+ # Prevent user from breaking cloud requests by sending too fast (automatically increase wait time if the server can't keep up):
412
+ if len(self.request_outputs) > 20:
413
+ time.sleep(0.5)
414
+ if len(self.request_outputs) > 15:
415
+ time.sleep(0.2)
416
+ if len(self.request_outputs) > 10:
417
+ time.sleep(0.13)
418
+ elif len(self.request_outputs) > 3:
419
+ time.sleep(0.1)
420
+ else:
421
+ time.sleep(0.07)
422
+
423
+ def stop(self):
424
+ """
425
+ Stops the request handler and all associated threads forever. Lets running response sending processes finish.
426
+ """
427
+ # Override the .stop function from BaseEventHandler to make sure the ._executer and ._responder threads are also terminated
428
+ super().stop()
429
+ self.executer_thread = None
430
+ self.responder_thread = None
431
+ self.executer_event.set()
432
+ self.responder_event.set()
433
+
434
+ def hard_stop(self):
435
+ """
436
+ Stops the request handler and all associated threads forever. Stops running response sending processes immediately.
437
+ """
438
+ self.hard_stop = True
439
+ self.stop()
440
+ time.sleep(0.5)
441
+ self.hard_stop = False
442
+
443
+ def credit_check(self):
444
+ try:
445
+ p = project.Project(id=self.cloud.project_id)
446
+ if not p.update(): # can't get project, probably because it's unshared (no authentication is used for getting it)
447
+ print("If you use cloud requests or cloud storages, please credit TimMcCool!")
448
+ return
449
+ description = (str(p.instructions) + str(p.notes)).lower()
450
+ if not ("timmccool" in description or "timmcool" in description or "timccool" in description or "timcool" in description):
451
+ print("It was detected that no credit was given in the project description! Please credit TimMcCool when using CloudRequests.")
452
+ else:
453
+ print("Thanks for giving credit for CloudRequests!")
454
+ except Exception:
455
+ print("If you use CloudRequests, please credit TimMcCool!")
456
+
457
+ def run(self):
458
+ # Was changed to .start(), but .run() is kept for backwards compatibility
459
+ print("Warning: requests.run() was changed to requests.start() in v2.0. .run() will be removed in a future version")
460
+ self.start()