scratchstore 1.0.0__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.
@@ -0,0 +1,1100 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import html
5
+ import json
6
+ import logging
7
+ import re
8
+ import random
9
+ import threading
10
+ import time
11
+ import urllib.parse
12
+ import zlib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from queue import Empty, Queue
16
+ from typing import Any, Callable, Iterable
17
+
18
+ __version__ = "0.3.0"
19
+
20
+ LOG = logging.getLogger("scratchstore")
21
+
22
+ # Two-digit, one-based line numbers from list.txt. The mapping is shared with
23
+ # the Scratch client; every supported character has exactly one code.
24
+ _SOURCE_LINES = list("abcdefghijklmnopqrstuvwxyz1234567890!@#$%^&*()_+{}|:\"<>?-=[]\\;',./`~ ")
25
+ CHAR_TO_CODE: dict[str, str] = {
26
+ char: f"{line_number:02d}"
27
+ for line_number, char in enumerate(_SOURCE_LINES, start=1)
28
+ }
29
+ CODE_TO_CHAR: dict[str, str] = {
30
+ code: char for char, code in CHAR_TO_CODE.items()
31
+ }
32
+
33
+ CLOUD_VALUE_LIMIT = 256
34
+ SEED_LENGTH = 5
35
+ SEED_MIN = 10_000
36
+ SEED_MAX = 99_999
37
+ FRAME_DATA_LIMIT = 250 # full 250 encoded digits; cloud storage preserves leading zeroes
38
+ RESPONSE_PAGE_LANES = 9
39
+ RESPONSE_LANE_NAMES = tuple(f"FH_{i}" for i in range(1, 10))
40
+ REQUEST_VARIABLE = "TH"
41
+ RESERVED = set(":|;\\")
42
+
43
+
44
+ class ScratchStoreError(Exception):
45
+ """Base exception for ScratchStore."""
46
+
47
+
48
+ class NewScratcherError(ScratchStoreError):
49
+ """Raised when the configured Scratch account is still a New Scratcher."""
50
+
51
+
52
+ class UnsupportedCharacterError(ScratchStoreError):
53
+ """Raised when a string contains a character absent from the supplied map."""
54
+
55
+
56
+ class ProtocolError(ScratchStoreError):
57
+ """Raised for malformed protocol packets or requests."""
58
+
59
+
60
+ class ScratchAuthenticationError(ScratchStoreError):
61
+ """Raised when Scratch rejects a login or session validation request."""
62
+
63
+
64
+ def encode_text(text: str) -> str:
65
+ """Encode text as two decimal digits per character using list.txt's map."""
66
+ out: list[str] = []
67
+ # The wire alphabet intentionally contains lowercase letters only. Normalize
68
+ # text here as a final guard for usernames and any other mixed-case metadata.
69
+ for char in str(text).lower():
70
+ code = CHAR_TO_CODE.get(char)
71
+ if code is None:
72
+ raise UnsupportedCharacterError(
73
+ f"Character {char!r} is not in list.txt's encoding map."
74
+ )
75
+ out.append(code)
76
+ encoded = "".join(out)
77
+ if len(encoded) % 2:
78
+ raise AssertionError("The encoder produced an odd-length stream.")
79
+ return encoded
80
+
81
+
82
+ def decode_text(encoded: str) -> str:
83
+ """Decode an even-length digit stream made by encode_text()."""
84
+ if len(encoded) % 2:
85
+ raise ProtocolError("Encoded streams must contain an even number of digits.")
86
+ if encoded and not encoded.isdigit():
87
+ raise ProtocolError("Encoded streams may contain decimal digits only.")
88
+ out: list[str] = []
89
+ for pos in range(0, len(encoded), 2):
90
+ code = encoded[pos:pos + 2]
91
+ char = CODE_TO_CHAR.get(code)
92
+ if char is None:
93
+ raise ProtocolError(f"Unknown character code {code!r}.")
94
+ out.append(char)
95
+ return "".join(out)
96
+
97
+
98
+ def escape_field(value: Any) -> str:
99
+ """Escape protocol delimiters in a database/key/value field."""
100
+ text = str(value)
101
+ return "".join(("\\" + ch) if ch in RESERVED else ch for ch in text)
102
+
103
+
104
+ def split_escaped(text: str, delimiter: str, *, maxsplit: int = -1) -> list[str]:
105
+ """Split on unescaped delimiters while keeping escape pairs intact."""
106
+ if len(delimiter) != 1:
107
+ raise ValueError("delimiter must be one character")
108
+ parts: list[str] = []
109
+ current: list[str] = []
110
+ escaped = False
111
+ splits = 0
112
+ for char in text:
113
+ if escaped:
114
+ current.extend(("\\", char))
115
+ escaped = False
116
+ elif char == "\\":
117
+ escaped = True
118
+ elif char == delimiter and (maxsplit < 0 or splits < maxsplit):
119
+ parts.append("".join(current))
120
+ current.clear()
121
+ splits += 1
122
+ else:
123
+ current.append(char)
124
+ if escaped:
125
+ current.append("\\")
126
+ parts.append("".join(current))
127
+ return parts
128
+
129
+
130
+ def unescape_field(text: str) -> str:
131
+ """Remove backslash escaping from one protocol field."""
132
+ out: list[str] = []
133
+ i = 0
134
+ while i < len(text):
135
+ if text[i] == "\\" and i + 1 < len(text):
136
+ i += 1
137
+ out.append(text[i])
138
+ i += 1
139
+ return "".join(out)
140
+
141
+
142
+ def _decode_scratch_session_id(session_id: str) -> dict[str, Any]:
143
+ """Decode the username/token payload embedded in Scratch's session cookie.
144
+
145
+ This follows scratchattach's ``decode_session_id`` implementation:
146
+ ``<base64-json>:<base62-timestamp>:<signature>``. Some session IDs use a
147
+ zlib-compressed first component, indicated by the ``".`` prefix.
148
+ """
149
+ parts = str(session_id).split(":")
150
+ if len(parts) != 3:
151
+ raise ScratchAuthenticationError(
152
+ "Scratch returned a session cookie with an unexpected format."
153
+ )
154
+
155
+ first = parts[0]
156
+ try:
157
+ # Match scratchattach's exact padding convention. The cookie can
158
+ # retain surrounding quote characters, which the base64 decoder skips.
159
+ decoded = base64.urlsafe_b64decode(first + "==")
160
+ if first.startswith('".'):
161
+ decoded = zlib.decompress(decoded)
162
+ payload = json.loads(decoded.decode("utf-8"))
163
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError, zlib.error) as exc:
164
+ raise ScratchAuthenticationError(
165
+ "Scratch returned a session cookie whose embedded data could not be decoded."
166
+ ) from exc
167
+
168
+ if not isinstance(payload, dict):
169
+ raise ScratchAuthenticationError("Scratch's session cookie did not contain a JSON object.")
170
+ if not isinstance(payload.get("username"), str) or not payload["username"]:
171
+ raise ScratchAuthenticationError("Scratch's session cookie did not contain a username.")
172
+ if not isinstance(payload.get("token"), str) or not payload["token"]:
173
+ raise ScratchAuthenticationError("Scratch's session cookie did not contain an X-Token.")
174
+ return payload
175
+
176
+
177
+ def _extract_session_cookie(response: Any, http_session: Any) -> str | None:
178
+ """Extract scratchsessionsid while preserving quotes used by Scratch."""
179
+ set_cookie = getattr(response, "headers", {}).get("Set-Cookie", "") or ""
180
+ match = re.search(
181
+ r'(?:^|[,;]\s*)scratchsessionsid=("[^"]+"|[^;,\s]+)',
182
+ set_cookie,
183
+ flags=re.IGNORECASE,
184
+ )
185
+ if match:
186
+ return match.group(1).strip()
187
+
188
+ # Requests' cookie jar is a useful fallback when Set-Cookie was normalized.
189
+ try:
190
+ value = http_session.cookies.get("scratchsessionsid")
191
+ if value:
192
+ return str(value)
193
+ except Exception:
194
+ pass
195
+ try:
196
+ value = response.cookies.get("scratchsessionsid")
197
+ if value:
198
+ return str(value)
199
+ except Exception:
200
+ pass
201
+ return None
202
+
203
+
204
+ def _detect_new_scratcher_from_profile(http_session: Any, username: str, headers: dict[str, str], cookies: dict[str, str]) -> bool | None:
205
+ """Fallback badge check using the public profile's group label.
206
+
207
+ Scratchattach tracks this as a fallback because the public user API does
208
+ not expose the ``permissions.new_scratcher`` field. The profile HTML
209
+ structure is unofficial and may change, so ``None`` means inconclusive.
210
+ """
211
+ url = f"https://scratch.mit.edu/users/{urllib.parse.quote(username, safe='')}/"
212
+ profile_headers = dict(headers)
213
+ profile_headers.pop("X-Token", None)
214
+ profile_headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
215
+ try:
216
+ response = http_session.get(url, headers=profile_headers, cookies=cookies, timeout=15)
217
+ if response.status_code != 200:
218
+ return None
219
+ # Match the same visible group badge used by ScratchAttach's fallback
220
+ # check. Strip nested HTML and decode entities before comparing.
221
+ match = re.search(
222
+ r'<span\b[^>]*class=["\'][^"\']*\bgroup\b[^"\']*["\'][^>]*>(.*?)</span>',
223
+ response.text,
224
+ flags=re.IGNORECASE | re.DOTALL,
225
+ )
226
+ if not match:
227
+ return None
228
+ label = re.sub(r"<[^>]+>", " ", match.group(1))
229
+ label = html.unescape(label).strip().casefold()
230
+ if "new scratcher" in label:
231
+ return True
232
+ if "scratcher" in label:
233
+ return False
234
+ except Exception as exc:
235
+ LOG.debug("Could not check the public Scratch profile badge: %s", exc)
236
+ return None
237
+
238
+
239
+ def _split_cloud_value(value: Any) -> tuple[str, str] | None:
240
+ """Return (payload, seed) from a cloud value ending in .12345."""
241
+ raw = str(value).strip()
242
+ payload, dot, seed = raw.rpartition(".")
243
+ if not dot or len(seed) != SEED_LENGTH or not seed.isdigit():
244
+ return None
245
+ if len(payload) > 250 or len(payload) % 2 or not payload.isdigit():
246
+ return None
247
+ if not SEED_MIN <= int(seed) <= SEED_MAX:
248
+ return None
249
+ return payload, seed
250
+
251
+
252
+ @dataclass
253
+ class Database:
254
+ """A JSON-backed database registered with ScratchStore."""
255
+ name: str
256
+ file: Path
257
+ data: dict[str, Any] = field(default_factory=dict)
258
+ _lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
259
+
260
+ def __post_init__(self) -> None:
261
+ self.name = str(self.name).lower()
262
+ self.file = Path(self.file).expanduser()
263
+ self._load_or_create()
264
+
265
+ def _load_or_create(self) -> None:
266
+ self.file.parent.mkdir(parents=True, exist_ok=True)
267
+ if not self.file.exists():
268
+ self.data = {}
269
+ self.save()
270
+ return
271
+ try:
272
+ raw = json.loads(self.file.read_text(encoding="utf-8"))
273
+ except json.JSONDecodeError as exc:
274
+ raise ScratchStoreError(f"Database file {self.file} is not valid JSON: {exc}") from exc
275
+ if not isinstance(raw, dict):
276
+ raise ScratchStoreError(f"Database file {self.file} must contain a JSON object.")
277
+ if isinstance(raw.get("data"), dict):
278
+ source = raw["data"]
279
+ else:
280
+ # Accept a simple flat JSON object as an existing database too.
281
+ source = raw
282
+
283
+ normalized: dict[str, Any] = {}
284
+ for key, value in source.items():
285
+ lowered_key = str(key).lower()
286
+ if lowered_key in normalized:
287
+ raise ScratchStoreError(
288
+ f"Database file {self.file} contains keys that collide after lowercasing: {lowered_key!r}."
289
+ )
290
+ normalized_value = value.lower() if isinstance(value, str) else value
291
+ normalized[lowered_key] = normalized_value
292
+ self.data = normalized
293
+ if normalized != source:
294
+ # Persist the migration so older database files stay normalized
295
+ # even before the next Scratch write occurs.
296
+ self.save()
297
+
298
+ def save(self) -> None:
299
+ with self._lock:
300
+ document = {"format": "scratchstore", "version": 1, "name": self.name, "data": self.data}
301
+ temporary = self.file.with_name(self.file.name + ".tmp")
302
+ temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
303
+ temporary.replace(self.file)
304
+
305
+ def keys(self) -> list[str]:
306
+ with self._lock:
307
+ return list(self.data.keys())
308
+
309
+ def get(self, key: str) -> Any:
310
+ with self._lock:
311
+ return self.data.get(str(key).lower(), None)
312
+
313
+ def set(self, key: str, value: str) -> None:
314
+ with self._lock:
315
+ self.data[str(key).lower()] = str(value).lower()
316
+ self.save()
317
+
318
+ def delete(self, key: str) -> bool:
319
+ normalized_key = str(key).lower()
320
+ with self._lock:
321
+ if normalized_key not in self.data:
322
+ return False
323
+ del self.data[normalized_key]
324
+ self.save()
325
+ return True
326
+
327
+
328
+ class _EventManager:
329
+ """Thread-safe registry for the public ``@events.event`` decorator.
330
+
331
+ Handler names identify their event. Currently supported handlers are
332
+ ``on_set`` and ``on_get``. Decorating a function does not wrap or replace it.
333
+ """
334
+
335
+ _SUPPORTED = {"on_set", "on_get"}
336
+
337
+ def __init__(self) -> None:
338
+ self._handlers: dict[str, list[Callable[..., Any]]] = {
339
+ name: [] for name in self._SUPPORTED
340
+ }
341
+ self._lock = threading.RLock()
342
+
343
+ def event(self, function: Callable[..., Any]) -> Callable[..., Any]:
344
+ if not callable(function):
345
+ raise TypeError("@events.event can only decorate a callable")
346
+ event_name = getattr(function, "__name__", "")
347
+ if event_name not in self._SUPPORTED:
348
+ supported = ", ".join(sorted(self._SUPPORTED))
349
+ raise ValueError(
350
+ f"Event handler must be named one of: {supported} (received {event_name!r})"
351
+ )
352
+ with self._lock:
353
+ self._handlers[event_name].append(function)
354
+ return function
355
+
356
+ def _dispatch(self, event_name: str, *args: Any) -> None:
357
+ with self._lock:
358
+ handlers = tuple(self._handlers.get(event_name, ()))
359
+ for handler in handlers:
360
+ try:
361
+ handler(*args)
362
+ except Exception:
363
+ LOG.exception("An %s event handler failed", event_name)
364
+
365
+
366
+ # Exposed as ``from scratchstore import events``.
367
+ events = _EventManager()
368
+
369
+
370
+ @dataclass
371
+ class _ProjectConfig:
372
+ project_id: str
373
+ username: str
374
+ password: str
375
+
376
+
377
+ class _Connection:
378
+ def __init__(self) -> None:
379
+ self._project: _ProjectConfig | None = None
380
+ self._databases: list[Database] = []
381
+ self._lock = threading.RLock()
382
+
383
+ def setup(self, _id: int | str, username: str, password: str) -> None:
384
+ if isinstance(_id, bool) or not isinstance(_id, (int, str)):
385
+ raise TypeError("_id must be a string or integer")
386
+ if not str(_id).strip():
387
+ raise ValueError("_id cannot be empty")
388
+ if not username or not isinstance(username, str):
389
+ raise ValueError("username must be a non-empty string")
390
+ if not password or not isinstance(password, str):
391
+ raise ValueError("password must be a non-empty string")
392
+ with self._lock:
393
+ self._project = _ProjectConfig(str(_id), username, password)
394
+
395
+ def use(self, databases: Iterable[Database] | Database) -> None:
396
+ if isinstance(databases, Database):
397
+ databases = [databases]
398
+ with self._lock:
399
+ for database in databases:
400
+ if not isinstance(database, Database):
401
+ raise TypeError("connection.use() expects Database objects returned by scratchstore.add()")
402
+ database.name = str(database.name).lower()
403
+ if any(existing.name.lower() == database.name for existing in self._databases):
404
+ raise ValueError(f"A database named {database.name!r} is already registered")
405
+ self._databases.append(database)
406
+
407
+ def snapshot(self) -> tuple[_ProjectConfig | None, list[Database]]:
408
+ with self._lock:
409
+ return self._project, list(self._databases)
410
+
411
+
412
+ class _Server:
413
+ def __init__(
414
+ self,
415
+ project: _ProjectConfig,
416
+ databases: list[Database],
417
+ *,
418
+ automatic: bool = True,
419
+ ) -> None:
420
+ self.project = project
421
+ self.automatic = bool(automatic)
422
+ self.databases = {database.name: database for database in databases}
423
+ self._database_order = [database.name for database in databases]
424
+ self._buffers: dict[str, dict[str, Any]] = {}
425
+ self._buffer_lock = threading.RLock()
426
+ self._response_lock = threading.Lock()
427
+ self._send_lock = threading.Lock()
428
+ self._queue: Queue[tuple[str, str] | None] = Queue()
429
+ self._stop_event = threading.Event()
430
+ self._worker: threading.Thread | None = None
431
+ self._receiver: threading.Thread | None = None
432
+ self._http: Any = None
433
+ self._session_id: str | None = None
434
+ self._session_token: str | None = None
435
+ self._authenticated_username: str | None = None
436
+ self._new_scratcher: bool | None = None
437
+ self._cookie_header: str | None = None
438
+ self._ws: Any = None
439
+ self._connection_ready = threading.Event()
440
+ self._ignore_initial_th_snapshot = True
441
+ self._last_response_seed: str | None = None
442
+
443
+ def _authenticate(self) -> None:
444
+ """Authenticate using Scratch's native login/session-cookie flow.
445
+
446
+ This mirrors scratchattach's current implementation without importing
447
+ scratchattach: POST /login/, extract scratchsessionsid, decode the
448
+ username and X-Token from that cookie, then *optionally* refresh account
449
+ permissions through POST /session (no trailing slash).
450
+ """
451
+ try:
452
+ import requests
453
+ except ImportError as exc:
454
+ raise ScratchStoreError(
455
+ "The requests package is missing. Run: python -m pip install -r requirements.txt"
456
+ ) from exc
457
+
458
+ http = requests.Session()
459
+ csrf_token = "a"
460
+ # These headers are based on scratchattach.utils.commons.headers and
461
+ # scratchattach.site.session.login(). The Cookie header is important:
462
+ # Scratch's login endpoint expects the CSRF cookie to match X-CSRFToken.
463
+ login_headers = {
464
+ "User-Agent": (
465
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
466
+ "(KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36"
467
+ ),
468
+ "X-CSRFToken": csrf_token,
469
+ "X-Requested-With": "XMLHttpRequest",
470
+ "Referer": "https://scratch.mit.edu",
471
+ "Origin": "https://scratch.mit.edu",
472
+ "Cookie": "scratchcsrftoken=a;scratchlanguage=en;",
473
+ }
474
+ LOG.info("Authenticating directly with Scratch as %s", self.project.username)
475
+
476
+ try:
477
+ # scratchattach's current login() uses /login/, not /accounts/login/.
478
+ # It relies on Set-Cookie rather than treating the response body as
479
+ # the authoritative source of the session.
480
+ login_response = http.post(
481
+ "https://scratch.mit.edu/login/",
482
+ json={
483
+ "username": self.project.username,
484
+ "password": self.project.password,
485
+ },
486
+ headers=login_headers,
487
+ timeout=20,
488
+ )
489
+ except requests.RequestException as exc:
490
+ http.close()
491
+ raise ScratchAuthenticationError(
492
+ f"Could not contact Scratch's login endpoint: {exc}"
493
+ ) from exc
494
+
495
+ session_id = _extract_session_cookie(login_response, http)
496
+ if not session_id:
497
+ status = getattr(login_response, "status_code", "unknown")
498
+ # Do not print response bodies here; login responses can contain
499
+ # account/security details and should not be copied into logs.
500
+ http.close()
501
+ raise ScratchAuthenticationError(
502
+ f"Scratch login did not provide a scratchsessionsid cookie (HTTP {status}). "
503
+ "The credentials may be invalid, or Scratch may have challenged/blocked this request."
504
+ )
505
+
506
+ # scratchattach decodes this cookie immediately. In particular, the
507
+ # X-Token is embedded in the cookie; it is not necessary to obtain it
508
+ # from /session before opening the cloud WebSocket.
509
+ try:
510
+ session_payload = _decode_scratch_session_id(session_id)
511
+ except ScratchAuthenticationError:
512
+ http.close()
513
+ raise
514
+ actual_username = str(session_payload["username"])
515
+ session_token = str(session_payload["token"])
516
+ if actual_username.casefold() != self.project.username.casefold():
517
+ LOG.warning(
518
+ "Scratch authenticated as %s rather than the configured username %s",
519
+ actual_username,
520
+ self.project.username,
521
+ )
522
+
523
+ session_cookies = {
524
+ "scratchsessionsid": session_id,
525
+ "scratchcsrftoken": csrf_token,
526
+ "scratchlanguage": "en",
527
+ }
528
+ session_headers = dict(login_headers)
529
+ session_headers.pop("Cookie", None)
530
+ session_headers["X-Token"] = session_token
531
+ session_headers["Accept"] = "application/json"
532
+ session_headers["Content-Type"] = "application/json"
533
+
534
+ # This refresh is used only to obtain account permission metadata.
535
+ # scratchattach's Session.update() posts to /session (no trailing
536
+ # slash), with the decoded X-Token and session cookies. A failure here
537
+ # must not discard an otherwise valid session cookie.
538
+ new_scratcher: bool | None = None
539
+ try:
540
+ session_response = http.post(
541
+ "https://scratch.mit.edu/session",
542
+ headers=session_headers,
543
+ cookies=session_cookies,
544
+ timeout=20,
545
+ )
546
+ if session_response.status_code == 200:
547
+ session_data = session_response.json()
548
+ permissions = session_data.get("permissions", {}) if isinstance(session_data, dict) else {}
549
+ user_data = session_data.get("user", {}) if isinstance(session_data, dict) else {}
550
+ flag = permissions.get("new_scratcher") if isinstance(permissions, dict) else None
551
+ if isinstance(flag, bool):
552
+ new_scratcher = flag
553
+ refreshed_username = user_data.get("username") if isinstance(user_data, dict) else None
554
+ if isinstance(refreshed_username, str) and refreshed_username:
555
+ actual_username = refreshed_username
556
+ refreshed_token = user_data.get("token") if isinstance(user_data, dict) else None
557
+ if isinstance(refreshed_token, str) and refreshed_token:
558
+ session_token = refreshed_token
559
+ session_headers["X-Token"] = refreshed_token
560
+ else:
561
+ LOG.warning(
562
+ "Scratch authenticated the session cookie, but POST /session returned HTTP %s; "
563
+ "permission metadata will be checked separately.",
564
+ session_response.status_code,
565
+ )
566
+ except (requests.RequestException, ValueError, TypeError) as exc:
567
+ LOG.warning(
568
+ "Scratch authenticated the session cookie, but account permissions could not be refreshed: %s",
569
+ exc,
570
+ )
571
+
572
+ # /session is the authoritative source for permissions when available.
573
+ # If it is unavailable (e.g. HTTP 403), use the public profile badge as
574
+ # a best-effort fallback. An unknown result is not treated as proof that
575
+ # the account is a New Scratcher or proof that it is a Scratcher.
576
+ if new_scratcher is None:
577
+ new_scratcher = _detect_new_scratcher_from_profile(
578
+ http, actual_username, session_headers, session_cookies
579
+ )
580
+
581
+ if new_scratcher is True:
582
+ http.close()
583
+ self._new_scratcher = True
584
+ raise NewScratcherError(
585
+ "This account is a New Scratcher. Scratch cloud variables require Scratcher status."
586
+ )
587
+ if new_scratcher is None:
588
+ LOG.warning(
589
+ "Scratch login succeeded, but Scratch did not disclose the account's Scratcher badge status. "
590
+ "Continuing with the authenticated session; the cloud server will determine whether cloud access is allowed."
591
+ )
592
+
593
+ self._http = http
594
+ self._session_id = session_id
595
+ self._session_token = session_token
596
+ self._authenticated_username = actual_username
597
+ self._new_scratcher = new_scratcher
598
+ # scratchattach's ScratchCloud sends just the session cookie to the
599
+ # cloud WebSocket, plus the Scratch origin. The X-Token is not a WS cookie.
600
+ self._cookie_header = f"scratchsessionsid={session_id};"
601
+ LOG.info("Logged into Scratch as %s", self._authenticated_username)
602
+ LOG.info(
603
+ "Scratch authentication succeeded",
604
+ "unknown" if self._new_scratcher is None else self._new_scratcher,
605
+ )
606
+
607
+ def _open_cloud_socket(self) -> Any:
608
+ """Open Scratch's real cloud WebSocket and send its project handshake."""
609
+ try:
610
+ import websocket
611
+ except ImportError as exc:
612
+ raise ScratchStoreError(
613
+ "The websocket-client package is missing. Run: python -m pip install -r requirements.txt"
614
+ ) from exc
615
+
616
+ ws = None
617
+ try:
618
+ ws = websocket.create_connection(
619
+ "wss://clouddata.scratch.mit.edu/",
620
+ cookie=self._cookie_header,
621
+ origin="https://scratch.mit.edu",
622
+ timeout=5,
623
+ enable_multithread=True,
624
+ )
625
+ handshake = {
626
+ "method": "handshake",
627
+ "user": self._authenticated_username,
628
+ "project_id": self.project.project_id,
629
+ }
630
+ ws.send(json.dumps(handshake, separators=(",", ":")) + "\n")
631
+ ws.settimeout(1.0)
632
+ except Exception as exc:
633
+ if ws is not None:
634
+ try:
635
+ ws.close()
636
+ except Exception:
637
+ pass
638
+ raise ScratchStoreError(f"Could not connect to Scratch's cloud WebSocket: {exc}") from exc
639
+
640
+ self._ws = ws
641
+ self._connection_ready.set()
642
+ self._ignore_initial_th_snapshot = True
643
+ LOG.info("Connected to Scratch clouddata for project %s", self.project.project_id)
644
+ return ws
645
+
646
+ def connect(self) -> None:
647
+ self._authenticate()
648
+ self._open_cloud_socket()
649
+ self._worker = threading.Thread(
650
+ target=self._worker_loop, name="ScratchStore-worker", daemon=True
651
+ )
652
+ self._receiver = threading.Thread(
653
+ target=self._receiver_loop, name="ScratchStore-cloud-receiver", daemon=True
654
+ )
655
+ self._worker.start()
656
+ self._receiver.start()
657
+
658
+ def _receiver_loop(self) -> None:
659
+ """Read newline-delimited cloud events and reconnect if the socket drops."""
660
+ try:
661
+ import websocket
662
+ except ImportError:
663
+ return
664
+
665
+ while not self._stop_event.is_set():
666
+ ws = self._ws
667
+ if ws is None:
668
+ if not self.automatic:
669
+ self._stop_event.set()
670
+ break
671
+ if self._stop_event.wait(0.5):
672
+ break
673
+ try:
674
+ self._open_cloud_socket()
675
+ except Exception as reconnect_error:
676
+ LOG.error("Scratch cloud reconnect failed: %s", reconnect_error)
677
+ self._stop_event.wait(3.0)
678
+ continue
679
+ try:
680
+ message = ws.recv()
681
+ if message is None or message == "":
682
+ raise ConnectionError("Scratch cloud WebSocket closed")
683
+ for raw_line in str(message).splitlines():
684
+ if not raw_line.strip():
685
+ continue
686
+ try:
687
+ packet = json.loads(raw_line)
688
+ except json.JSONDecodeError:
689
+ LOG.debug("Ignoring non-JSON cloud frame: %r", raw_line[:160])
690
+ continue
691
+ if isinstance(packet, dict):
692
+ self._handle_cloud_packet(packet)
693
+ except websocket.WebSocketTimeoutException:
694
+ continue
695
+ except Exception as exc:
696
+ if self._stop_event.is_set():
697
+ break
698
+ self._connection_ready.clear()
699
+ try:
700
+ ws.close()
701
+ except Exception:
702
+ pass
703
+ if self._ws is ws:
704
+ self._ws = None
705
+
706
+ if not self.automatic:
707
+ LOG.error("Scratch cloud socket disconnected; automatic reconnect is disabled")
708
+ self._stop_event.set()
709
+ break
710
+
711
+ LOG.warning("Scratch cloud socket disconnected (%s); reconnecting", exc)
712
+ self._stop_event.wait(1.0)
713
+ if self._stop_event.is_set():
714
+ break
715
+ try:
716
+ self._open_cloud_socket()
717
+ except Exception as reconnect_error:
718
+ LOG.error("Scratch cloud reconnect failed: %s", reconnect_error)
719
+ self._stop_event.wait(3.0)
720
+
721
+ def _lookup_log_username(self, variable_name: str, value: str) -> str | None:
722
+ """Resolve the writer from Scratch's cloud activity log.
723
+
724
+ Scratch's live WebSocket broadcasts variable updates without a writer
725
+ field. The cloud log endpoint includes the actor, so match its newest
726
+ exact name/value record instead of guessing the writer.
727
+ """
728
+ if self._http is None:
729
+ return None
730
+ normalized_name = variable_name if variable_name.startswith("☁ ") else f"☁ {variable_name}"
731
+ for attempt in range(4):
732
+ try:
733
+ response = self._http.get(
734
+ "https://clouddata.scratch.mit.edu/logs",
735
+ params={"projectid": self.project.project_id, "limit": 50, "offset": 0},
736
+ timeout=10,
737
+ )
738
+ response.raise_for_status()
739
+ records = response.json()
740
+ if isinstance(records, list):
741
+ for record in records:
742
+ if not isinstance(record, dict):
743
+ continue
744
+ record_name = str(record.get("name", ""))
745
+ record_value = str(record.get("value", ""))
746
+ if record_name == normalized_name and record_value == value:
747
+ actor = record.get("user") or record.get("username")
748
+ if isinstance(actor, dict):
749
+ actor = actor.get("username")
750
+ if actor:
751
+ return str(actor)
752
+ # The cloud log can briefly lag the live WebSocket event.
753
+ except Exception as exc:
754
+ LOG.debug("Cloud log actor lookup attempt %d failed: %s", attempt + 1, exc)
755
+ if attempt < 3:
756
+ time.sleep(0.12)
757
+ return None
758
+
759
+ def _handle_cloud_packet(self, packet: dict[str, Any]) -> None:
760
+ if packet.get("method") != "set":
761
+ return
762
+ variable = str(packet.get("name", "")).removeprefix("☁ ")
763
+ if variable != REQUEST_VARIABLE or "value" not in packet:
764
+ return
765
+ value = str(packet.get("value", ""))
766
+
767
+ # The handshake sends the project's current variable values as an
768
+ # initial snapshot. Do not mistake the old TH value for a new request.
769
+ if self._ignore_initial_th_snapshot:
770
+ self._ignore_initial_th_snapshot = False
771
+ LOG.debug("Ignoring the initial TH cloud-variable snapshot")
772
+ return
773
+
774
+ username = packet.get("user") or packet.get("username")
775
+ if not username:
776
+ username = self._lookup_log_username(variable, value)
777
+ if not username:
778
+ LOG.warning("Could not resolve the Scratch username for a TH update; ignoring it")
779
+ return
780
+
781
+ parsed = _split_cloud_value(value)
782
+ if parsed is None:
783
+ LOG.debug("Ignoring malformed TH packet from %s", username)
784
+ return
785
+ payload, seed = parsed
786
+ now = time.monotonic()
787
+ completed: str | None = None
788
+ with self._buffer_lock:
789
+ username_key = str(username).lower()
790
+ buffer = self._buffers.get(username_key)
791
+ if buffer is None or now - buffer["updated"] > 8.0:
792
+ buffer = {"parts": [], "seeds": set(), "updated": now}
793
+ self._buffers[username_key] = buffer
794
+ if seed in buffer["seeds"]:
795
+ return
796
+ buffer["seeds"].add(seed)
797
+ buffer["parts"].append(payload)
798
+ buffer["updated"] = now
799
+ encoded = "".join(buffer["parts"])
800
+ try:
801
+ decoded = decode_text(encoded)
802
+ except ProtocolError:
803
+ LOG.warning("Discarding malformed request stream from %s", username)
804
+ self._buffers.pop(username_key, None)
805
+ return
806
+ if decoded.endswith("stop:"):
807
+ completed = decoded[:-5]
808
+ self._buffers.pop(username_key, None)
809
+ if completed is not None:
810
+ self._queue.put((str(username), completed))
811
+
812
+ def _worker_loop(self) -> None:
813
+ """Process completed client requests and send their responses.
814
+
815
+ The cloud receiver only assembles TH messages and places completed
816
+ requests on ``self._queue``. This worker performs database operations
817
+ serially so two requests cannot interleave their shared FH responses.
818
+ """
819
+ while not self._stop_event.is_set():
820
+ try:
821
+ item = self._queue.get(timeout=0.5)
822
+ except Empty:
823
+ continue
824
+
825
+ try:
826
+ if item is None:
827
+ return
828
+
829
+ username, command = item
830
+ try:
831
+ response = self._execute(command, username=username)
832
+ except ProtocolError as exc:
833
+ LOG.warning("Invalid request from %s: %s", username, exc)
834
+ response = "error:bad_request"
835
+ except UnsupportedCharacterError as exc:
836
+ LOG.warning("Unsupported data in request from %s: %s", username, exc)
837
+ response = "error:unsupported_character"
838
+ except Exception:
839
+ # Keep detailed diagnostics on the host, but do not expose
840
+ # exception text or filesystem paths to Scratch clients.
841
+ LOG.exception("Request from %s failed", username)
842
+ response = "error:internal"
843
+
844
+ try:
845
+ self._send_response(response, username)
846
+ except Exception:
847
+ LOG.exception("Could not send the response to %s", username)
848
+ finally:
849
+ self._queue.task_done()
850
+
851
+ def _send_cloud_values(self, values: dict[str, str]) -> None:
852
+ """Send multiple cloud-variable set packets over our own WebSocket."""
853
+ packets = []
854
+ for variable, value in values.items():
855
+ value = str(value)
856
+ if len(value) > CLOUD_VALUE_LIMIT or not value.replace(".", "").replace("-", "").isnumeric():
857
+ raise ProtocolError(f"Invalid Scratch cloud value for {variable!r}")
858
+ packets.append({
859
+ "method": "set",
860
+ "user": self._authenticated_username,
861
+ "project_id": self.project.project_id,
862
+ "name": variable if variable.startswith("☁ ") else f"☁ {variable}",
863
+ "value": value,
864
+ })
865
+ payload = "".join(json.dumps(packet, separators=(",", ":"), ensure_ascii=False) + "\n" for packet in packets)
866
+ if not self._connection_ready.wait(timeout=10):
867
+ raise ScratchStoreError("Scratch cloud WebSocket is not connected")
868
+ with self._send_lock:
869
+ ws = self._ws
870
+ if ws is None:
871
+ raise ScratchStoreError("Scratch cloud WebSocket disconnected before send")
872
+ try:
873
+ # One WebSocket frame can carry several newline-delimited JSON
874
+ # cloud packets; this keeps an FH page together.
875
+ ws.send(payload)
876
+ except Exception as exc:
877
+ self._connection_ready.clear()
878
+ raise ScratchStoreError(f"Could not send cloud-variable updates: {exc}") from exc
879
+
880
+ def _execute(self, command: str, username: str = "unknown") -> str:
881
+ head = split_escaped(command, ":", maxsplit=1)
882
+ if len(head) != 2:
883
+ raise ProtocolError("Request is missing its command separator.")
884
+ operation, raw_args = head
885
+ actor = str(username).lower()
886
+
887
+ if operation == "ping":
888
+ LOG.info("Ping operation from %s", actor)
889
+ return "pong"
890
+ if operation == "getalldb":
891
+ LOG.info("Get-all-databases operation from %s", actor)
892
+ events._dispatch("on_get", 3, None, None, actor)
893
+ return "|".join(escape_field(name) for name in self._database_order)
894
+
895
+ raw_fields = split_escaped(raw_args, "|")
896
+ fields = [unescape_field(field) for field in raw_fields]
897
+
898
+ if operation == "set":
899
+ if len(fields) != 3:
900
+ raise ProtocolError("set requires database, key, and value")
901
+ database = self._get_database(fields[0])
902
+ LOG.info(
903
+ "Set operation from %s: database=%s key=%s",
904
+ actor, database.name, fields[1].lower(),
905
+ )
906
+ database.set(fields[1], fields[2])
907
+ events._dispatch(
908
+ "on_set", 1, database.name, fields[1].lower(), fields[2].lower(), actor
909
+ )
910
+ return "saved"
911
+
912
+ if operation == "get":
913
+ if len(fields) == 1:
914
+ database = self._get_database(fields[0])
915
+ LOG.info(
916
+ "Get-keys operation from %s: database=%s",
917
+ actor, database.name,
918
+ )
919
+ events._dispatch("on_get", 2, database.name, None, actor)
920
+ return "|".join(escape_field(key) for key in database.keys())
921
+ if len(fields) == 2:
922
+ database = self._get_database(fields[0])
923
+ LOG.info(
924
+ "Get-value operation from %s: database=%s key=%s",
925
+ actor, database.name, fields[1].lower(),
926
+ )
927
+ value = database.get(fields[1])
928
+ if value is None:
929
+ return "not_found"
930
+ events._dispatch("on_get", 1, database.name, fields[1].lower(), actor)
931
+ return escape_field(value)
932
+ raise ProtocolError("get requires a database, optionally followed by a key")
933
+
934
+ if operation in {"del", "delete"}:
935
+ if len(fields) != 2:
936
+ raise ProtocolError("del requires database and key")
937
+ database = self._get_database(fields[0])
938
+ LOG.info(
939
+ "Delete operation from %s: database=%s key=%s",
940
+ actor, database.name, fields[1].lower(),
941
+ )
942
+ deleted_value = database.get(fields[1])
943
+ deleted = database.delete(fields[1])
944
+ if deleted:
945
+ events._dispatch(
946
+ "on_set", 2, database.name, fields[1].lower(), deleted_value, actor
947
+ )
948
+ return "deleted"
949
+ return "not_found"
950
+
951
+ LOG.warning("Unknown operation from %s: %s", actor, operation)
952
+ raise ProtocolError(f"Unknown operation {operation!r}")
953
+
954
+ def _get_database(self, name: str) -> Database:
955
+ database = self.databases.get(str(name).lower())
956
+ if database is None:
957
+ raise ProtocolError(f"Unknown database {name!r}")
958
+ return database
959
+
960
+ def _send_response(self, body: str, username: str) -> None:
961
+ # Keep the two username fields separate: the first belongs to the data
962
+ # response, while stop;username is the end-of-stream marker.
963
+ # Scratch usernames can contain uppercase letters, but the wire alphabet
964
+ # is deliberately lowercase. The client lowercases USERNAME() as well.
965
+ username = str(username).lower()
966
+ response_text = f"{str(body).lower()};username{username}stop;{username}"
967
+ encoded = encode_text(response_text)
968
+ chunks = [encoded[i:i + FRAME_DATA_LIMIT] for i in range(0, len(encoded), FRAME_DATA_LIMIT)]
969
+ if not chunks:
970
+ chunks = [""]
971
+
972
+ with self._response_lock:
973
+ for page_start in range(0, len(chunks), RESPONSE_PAGE_LANES):
974
+ page_chunks = chunks[page_start:page_start + RESPONSE_PAGE_LANES]
975
+ lane_count = len(page_chunks)
976
+ # The first seed digit tells the Scratch client how many FH
977
+ # lanes belong to this page. The remaining four digits keep
978
+ # each page uniquely identifiable without touching unused lanes.
979
+ seed = f"{lane_count}{random.randint(0, 9999):04d}"
980
+ while seed == self._last_response_seed:
981
+ seed = f"{lane_count}{random.randint(0, 9999):04d}"
982
+ self._last_response_seed = seed
983
+ values: dict[str, str] = {}
984
+ for lane_name, payload in zip(RESPONSE_LANE_NAMES, page_chunks):
985
+ # Only write populated lanes; later FH variables retain
986
+ # their previous values and are ignored by the client.
987
+ value = f"{payload}.{seed}"
988
+ if len(value) > CLOUD_VALUE_LIMIT:
989
+ raise AssertionError("A cloud value exceeded Scratch's 256-character limit")
990
+ values[lane_name] = value
991
+ self._send_cloud_values(values)
992
+ # Long responses are streamed page-by-page, with 0.5-second
993
+ # inter-page spacing to reduce bursts of cloud variable updates.
994
+ if page_start + RESPONSE_PAGE_LANES < len(chunks):
995
+ time.sleep(1)
996
+ # Leave the final response visible briefly before the next queued
997
+ # request overwrites the shared FH variables.
998
+ time.sleep(1)
999
+
1000
+ def run_forever(self) -> None:
1001
+ self.connect()
1002
+ LOG.info("ScratchStore is running. Press Ctrl+C to stop.")
1003
+ try:
1004
+ while not self._stop_event.wait(0.5):
1005
+ pass
1006
+ except KeyboardInterrupt:
1007
+ LOG.info("Stopping ScratchStore...")
1008
+ finally:
1009
+ self.stop()
1010
+
1011
+ def stop(self) -> None:
1012
+ self._stop_event.set()
1013
+ try:
1014
+ self._queue.put_nowait(None)
1015
+ except Exception:
1016
+ pass
1017
+ self._connection_ready.clear()
1018
+ ws = self._ws
1019
+ self._ws = None
1020
+ if ws is not None:
1021
+ try:
1022
+ ws.close()
1023
+ except Exception:
1024
+ pass
1025
+ for thread in (self._receiver, self._worker):
1026
+ if thread is not None and thread.is_alive() and thread is not threading.current_thread():
1027
+ thread.join(timeout=2.0)
1028
+ if self._http is not None:
1029
+ try:
1030
+ self._http.close()
1031
+ except Exception:
1032
+ pass
1033
+
1034
+
1035
+ def add(name: str, file: str | Path) -> Database:
1036
+ """Open an existing JSON database or create an empty one."""
1037
+ if not isinstance(name, str) or not name:
1038
+ raise ValueError("name must be a non-empty string")
1039
+ return Database(name=name, file=Path(file))
1040
+
1041
+
1042
+ connection = _Connection()
1043
+ _active_server: _Server | None = None
1044
+
1045
+
1046
+ def start(verbose: bool = False, automatic: bool = True) -> None:
1047
+ """Start the Scratch server.
1048
+
1049
+ Args:
1050
+ verbose: Enable informational server logs when true.
1051
+ automatic: Reconnect to the cloud WebSocket after disconnects when true.
1052
+ """
1053
+ global _active_server
1054
+ if not isinstance(verbose, bool):
1055
+ raise TypeError("verbose must be a bool")
1056
+ if not isinstance(automatic, bool):
1057
+ raise TypeError("automatic must be a bool")
1058
+
1059
+ project, databases = connection.snapshot()
1060
+ if project is None:
1061
+ raise ScratchStoreError(
1062
+ "Call scratchstore.connection.setup(...) before scratchstore.start()."
1063
+ )
1064
+ if not databases:
1065
+ raise ScratchStoreError(
1066
+ "Register at least one database with scratchstore.connection.use(databases=[...])."
1067
+ )
1068
+ if len(databases) > 100:
1069
+ raise ScratchStoreError("This version supports up to 100 databases.")
1070
+
1071
+ # Keep application-provided logging configuration intact.
1072
+ if not logging.getLogger().handlers:
1073
+ logging.basicConfig(
1074
+ level=logging.INFO if verbose else logging.WARNING,
1075
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
1076
+ )
1077
+ LOG.setLevel(logging.INFO if verbose else logging.WARNING)
1078
+
1079
+ _active_server = _Server(project, databases, automatic=automatic)
1080
+ _active_server.run_forever()
1081
+
1082
+
1083
+ def stop() -> None:
1084
+ """Stop the active server, if one is running."""
1085
+ if _active_server is not None:
1086
+ _active_server.stop()
1087
+
1088
+
1089
+ __all__ = [
1090
+ "Database", "ScratchStoreError", "NewScratcherError",
1091
+ "UnsupportedCharacterError", "ProtocolError", "ScratchAuthenticationError",
1092
+ "connection", "events", "add", "start", "stop",
1093
+ "encode_text", "decode_text",
1094
+ ]
1095
+
1096
+
1097
+ if __name__ == "__main__":
1098
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
1099
+ print("ScratchStore server module imported successfully.")
1100
+ print("Configure scratchstore.connection, register databases, then call scratchstore.start().")
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: scratchstore
3
+ Version: 1.0.0
4
+ Summary: A short summary of what your module does
5
+ Project-URL: Homepage, https://github.com/gixtuh/scratchstore
6
+ Author: gixtuh
7
+ License-File: LICENSE
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: requests>=2.31.0
13
+ Requires-Dist: websocket-client>=1.8.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # scratchstore
17
+ ScratchStore (inspired by the word "data store") is a cloud data store/database engine which lets you write anything into a data store file using Python and Scratch with the power of cloud variables.
18
+
19
+ ## Installation
20
+ ```bash
21
+ pip install scratchstore
22
+ ```
23
+ ... is all that's required to install ScratchStore
24
+
25
+ **ScratchStore is still in active development, expect bugs!**
@@ -0,0 +1,5 @@
1
+ scratchstore/__init__.py,sha256=3yWUlauWHU8WnjOg5CnmzcUss5y0KOxVjy76YyJ_Tsc,45305
2
+ scratchstore-1.0.0.dist-info/METADATA,sha256=N-d3uPbGXNYLIZ3CmnTiN5YcTzJb42Tmwkh17AjxPz0,868
3
+ scratchstore-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ scratchstore-1.0.0.dist-info/licenses/LICENSE,sha256=gFaoemo3q8MXu-GvXvXonMPTw8f9_Cs-XfT48epyaPs,1048
5
+ scratchstore-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 gixtuh
2
+ Permission is hereby granted, free of charge, to any person obtaining a copy
3
+ of this software and associated documentation files (the "Software"), to deal
4
+ in the Software without restriction, including without limitation the rights
5
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
6
+ copies of the Software, and to permit persons to whom the Software is
7
+ furnished to do so, subject to the following conditions:
8
+
9
+ The above copyright notice and this permission notice shall be included in all
10
+ copies or substantial portions of the Software.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
14
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
18
+ SOFTWARE.