Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9863d50
feat(cloud_server): implement SSL secure websocket from semver2 into …
Boss-1s Aug 26, 2026
7e8fddd
fix(cloud_server): expose `init_ssl_cloud_sever` to top-level
Boss-1s Aug 26, 2026
bab2905
chore(eventhandlers._base): move mixin class to _base
Boss-1s Aug 26, 2026
5cd379d
fatal(eventhandlers._base): missing imports
Boss-1s Aug 26, 2026
2950297
export cloud server types
Boss-1s Sep 2, 2026
a1423ce
fix(sa.eventhandlers): clean up
Boss-1s Sep 2, 2026
8d5aa20
fix(sa.eventhandlers._base): ensure blocked_ips is always some list
Boss-1s Sep 2, 2026
70ffb36
feat: expose BaseCloudServer
Boss-1s Sep 2, 2026
92cc748
fatal: BaseCloudServer should not have ssl-related stuff
Boss-1s Sep 2, 2026
1924636
docstrings
Boss-1s Sep 2, 2026
2a5dfc9
resolve https://github.com/TimMcCool/scratchattach/pull/723#discussio…
Boss-1s Sep 2, 2026
5b0ff22
resolve
Boss-1s Sep 2, 2026
b80c260
revert
Boss-1s Sep 2, 2026
f81bce7
apply changes
TheCommCraft Sep 3, 2026
1d0bf6e
Merge branch 'TimMcCool:main' into semver3-secure-ws
Boss-1s Sep 3, 2026
e905eb8
.
Boss-1s Sep 3, 2026
16b58b7
replace all references to scratchattach/2.0.0
Boss-1s Sep 3, 2026
6243190
fatal: stale assignment to nonexistent arg websocketclass
Boss-1s Sep 3, 2026
c476784
Merge branch 'main' into semver3-secure-ws
Boss-1s Sep 3, 2026
65a4ddd
rename `skip_forward` and annotate type
TheCommCraft Sep 3, 2026
b77df00
use `serveonce` instead of `serveforever`
TheCommCraft Sep 3, 2026
3b96888
prevent 100% CPU usage
Boss-1s Sep 4, 2026
1b34b7c
revert 3b96888
Boss-1s Sep 4, 2026
b4047e5
Merge branch 'TimMcCool:main' into semver3-secure-ws
Boss-1s Sep 5, 2026
14d1074
server can be TwCloudServer or TwSSLCloudServer
Boss-1s Sep 5, 2026
0f921da
remove invalid cloudactivitydict values + temp fill-in for cloud key
Boss-1s Sep 5, 2026
c8a76c9
feat: link_cloud
Boss-1s Sep 7, 2026
cb00837
add cloud server adapter
TheCommCraft Sep 7, 2026
615cb99
fix circular import
TheCommCraft Sep 7, 2026
cc66264
fix second circular import
TheCommCraft Sep 7, 2026
aa697f9
Merge branch 'main' into semver3-secure-ws
TheCommCraft Nov 6, 2026
9964178
fix errors originating in merge commit
TheCommCraft Nov 6, 2026
c4779a2
fix KeyError and infinite loop
TheCommCraft Nov 6, 2026
d78f40d
remove resolved todo
TheCommCraft Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,13 @@ identifier.sqlite
obfuscated.sb3
.pytest_cache
.ruff_cache

.coverage
certfile.pem
coverage.xml
keyfile.pem
.test/test.py
.test/teststststs.py
.test/ws_client_test.py
tests/test_tw_cloud_debug_compliance.py
.vscode
50 changes: 43 additions & 7 deletions scratchattach/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
from .cloud.cloud import CustomCloud, ScratchCloud, TwCloud, get_cloud, get_scratch_cloud, get_tw_cloud
from .cloud.cloud import (
CustomCloud,
ScratchCloud,
TwCloud,
get_cloud,
get_scratch_cloud,
get_tw_cloud,
)
from .cloud._base import BaseCloud, AnyCloud

from .eventhandlers.cloud_server import init_cloud_server
from .eventhandlers._base import BaseEventHandler
from .eventhandlers.cloud_server import (
init_cloud_server,
init_ssl_cloud_server,
TwCloudSocket,
TwCloudServer,
TwSSLCloudServer,
)
from .eventhandlers._base import BaseEventHandler, BaseCloudServer
from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter
from .eventhandlers.cloud_storage import Database
from .eventhandlers.combine import MultiEventHandler

from .other.other_apis import *

# from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset
# from .other.project_json_capabilities import (
# ProjectBody,
# get_empty_project_pb,
# get_pb_from_dict,
# read_sb3_file,
# download_asset,
# )
from .utils.encoder import Encoding
from .utils.enums import Languages, TTSVoices
from .utils.exceptions import (
Expand All @@ -27,11 +45,29 @@
from .site.cloud_activity import CloudActivity
from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list, youtube_link_to_scratch
from .site.project import Project, get_project, search_projects, explore_projects
from .site.session import Session, login, login_by_id, login_by_session_string, login_by_io, login_by_file, login_from_browser
from .site.session import (
Session,
login,
login_by_id,
login_by_session_string,
login_by_io,
login_by_file,
login_from_browser,
)
from .site.studio import Studio, get_studio, search_studios, explore_studios
from .site.classroom import Classroom, get_classroom
from .site.user import User, get_user, Rank
from .site._base import BaseSiteComponent
from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI
from .site.browser_cookies import (
Browser,
ANY,
FIREFOX,
CHROME,
CHROMIUM,
VIVALDI,
EDGE,
EDGE_DEV,
SAFARI,
)

from . import editor
149 changes: 140 additions & 9 deletions scratchattach/cloud/_base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from __future__ import annotations
import queue
import threading
import traceback

import json
Expand All @@ -13,6 +15,7 @@
from scratchattach.cloud import cloud as cloud_module

if TYPE_CHECKING:
from scratchattach.eventhandlers import cloud_server
from _typeshed import SupportsRead
else:
T = TypeVar("T")
Expand Down Expand Up @@ -106,7 +109,7 @@ def set_vars(
"""

@abstractmethod
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T:
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T | None:
pass

@abstractmethod
Expand Down Expand Up @@ -146,7 +149,7 @@ def create_event_stream(self) -> EventStream:
pass


class DummyCloud(AnyCloud[Any]):
class DummyCloud(AnyCloud[T]):
class DummyEventStream(EventStream):
def read(self, length=...):
return iter(())
Expand Down Expand Up @@ -174,12 +177,12 @@ def set_vars(
):
pass

def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> Any:
pass
def get_var(self, var, *, recorder_initial_values: Optional[dict[str, T]] = None) -> T | None:
return None

def get_all_vars(
self, *, recorder_initial_values: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
self, *, recorder_initial_values: Optional[dict[str, T]] = None
) -> dict[str, T]:
return {}


Expand Down Expand Up @@ -301,7 +304,7 @@ def close(self) -> None:
self.source_cloud.disconnect()


class BaseCloud(AnyCloud[Union[str, int]]):
class BaseCloud(AnyCloud[Union[str, int, float]]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small thing, but str | int | float is more concise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would apply in a lot of cases in the codebase, e.g. every use of Optional, so this is more of a separate style issue

"""
Base class for a project's cloud variables. Represents a cloud.

Expand Down Expand Up @@ -557,12 +560,16 @@ def _ensure_recorder_running(
time.sleep(0.01)
return recorder

def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None):
def get_var(
self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None
) -> str | int | float | None:
var = "☁ " + var.removeprefix("☁ ")
recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values)
return recorder.get_var(var)

def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None):
def get_all_vars(
self, *, recorder_initial_values: Optional[dict[str, Any]] = None
) -> dict[str, str | int | float]:
recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values)
return recorder.get_all_vars()

Expand Down Expand Up @@ -623,3 +630,127 @@ def _get_cloud_var_initial_data_or_none(project_id: Union[str, int]) -> Optional
return _get_cloud_var_initial_data(project_id)
except Exception:
return None


class CloudServerAdapter(AnyCloud[str | int | float]):
server: "cloud_server.BaseCloudServer"
disconnected: threading.Event
project_id: str | int
connected_event_stream_queues: dict[int, queue.Queue[dict[str, Any]]]

def __init__(self, server: "cloud_server.BaseCloudServer", project_id: str | int):
self.server = server
self.disconnected = threading.Event()
self.project_id = project_id
self.connected_event_stream_queues = {}

@self.server.event
def on_outgoing_packet(event):
for listener in self.connected_event_stream_queues.values():
listener.put(event)

def connect(self):
self.active_connection = True
self.disconnected.clear()

def disconnect(self):
self.active_connection = False
self.disconnected.set()

def reconnect(self):
self.disconnect()
time.sleep(0.1)
self.connect()

def _enforce_ratelimit(self, *, n: int) -> None:
pass

def set_var(self, variable: str, value: str | int | float, *, max_retries: int = 2) -> None:
"""
Sets a cloud variable.

Args:
variable (str): The name of the cloud variable that should be set (provided without the cloud emoji)
value (Any): The value the cloud variable should be set to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any is the wrong type here. should be str | int | float


Kwargs:
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
"""
if self.disconnected.is_set():
return
self.server.set_var(
self.project_id,
variable,
value,
)

def set_vars(
self,
var_value_dict: dict[str, str | int | float],
*,
intelligent_waits: bool = True,
max_retries: int = 2,
Comment on lines +691 to +692

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these arguments are unused. if this is intentional, they should also be removed from the docstring

):
"""
Sets multiple cloud variables at once (works for an unlimited amount of variables).

Args:
var_value_dict (dict): variable:value dictionary with the variables / values to set. The dict should like this: {"var1":"value1", "var2":"value2", ...}

Kwargs:
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
max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception
"""
if self.disconnected.is_set():
return
Comment on lines +704 to +705

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be more appropriate to raise an error if you are trying to set variables if you are disconnected?

Also, it is noteworthy that self.disconnected.is_set() is being used for the set_var methods but not for the get_var methods. Is this intentional? Perhaps it is useful to still be able to see the variables after you are disconnected?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that was the thought behind it

self.server.set_project_vars(self.project_id, var_value_dict)

def get_var(
self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None
) -> str | int | float | None:
return self.server.get_var(self.project_id, var)

def get_all_vars(
self, *, recorder_initial_values: Optional[dict[str, Any]] = None
) -> dict[str, str | int | float]:
return self.server.get_project_vars(self.project_id).copy()

def create_event_stream(self) -> CloudServerAdapterEventStream:
return CloudServerAdapterEventStream(self)


class CloudServerAdapterEventStream(EventStream):
adapter: CloudServerAdapter
disconnected: threading.Event
_queue: queue.Queue[dict[str, Any]]

def __init__(self, adapter: CloudServerAdapter):
self.adapter = adapter
self.disconnected = threading.Event()
self._queue = queue.Queue()
self.adapter.connected_event_stream_queues[id(self)] = self._queue

def read(self, amount: int = -1) -> Iterator[dict[str, Any]]:
if self.disconnected.is_set() or self.adapter.disconnected.is_set():
return
end_time = time.time() + self.timeout if self.timeout is not None else None
progress = 0
while (progress < amount if amount >= 0 else progress == 0) and (
time.time() < end_time if end_time is not None else True
):
Comment on lines +738 to +740

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a pretty confusing condition

try:
yield self._queue.get(
self.timeout is None or self.timeout > 0.0,
max(end_time - time.time(), 0) if end_time is not None else None,
)
progress += 1
except queue.Empty:
pass

def __del__(self):
self.close()

def close(self) -> None:
if not self.disconnected.is_set():
del self.adapter.connected_event_stream_queues[id(self)]
self.disconnected.set()
Loading
Loading