summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--blockext/__init__.py435
-rw-r--r--blockext/blocks.py254
-rw-r--r--blockext/generate.py235
-rw-r--r--blockext/helper.py217
-rw-r--r--blockext/languages.py118
-rw-r--r--blockext/server.py102
-rw-r--r--blockext/snap.py156
-rw-r--r--example.py131
8 files changed, 1030 insertions, 618 deletions
diff --git a/blockext/__init__.py b/blockext/__init__.py
index f1be90f..5552bc2 100644
--- a/blockext/__init__.py
+++ b/blockext/__init__.py
@@ -1,414 +1,65 @@
-from __future__ import unicode_literals
+from __future__ import (absolute_import, division,
+ print_function, unicode_literals)
+from future.builtins import *
-from cgi import escape
-import inspect
-import itertools
-import re
-import threading
-import time
-try:
- from urllib import quote
- from urllib import unquote as _unquote_str
- def unquote(part):
- part = str(part)
- return _unquote_str(part).decode("utf-8")
-except ImportError:
- from urllib.parse import unquote, quote
-try:
- from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
- from SocketServer import ThreadingMixIn
-except ImportError:
- from http.server import HTTPServer, BaseHTTPRequestHandler
- from socketserver import ThreadingMixIn
-
-__version__ = '0.1.0'
-
-
-
-# TODO I'm sure this isn't right
-try:
- unicode
- unichr
-except NameError:
- unicode = str
- unichr = chr
-
-def make_unicode(text):
- """Fix strings passed in from extension code."""
- if isinstance(text, bytes):
- text = text.decode("utf-8")
- return unicode(text)
-
-def unquote_unicode(text):
- def unicode_unquoter(match):
- c = unichr(int(match.group(1), 16))
- # urlib.unquote doesn't like Unicode.
- # So, we simply URL-encode the character again -- but this time, the
- # "correct" way!
- return quote(c.encode("utf-8"))
- return re.sub(r'%u([0-9a-fA-F]{4})', unicode_unquoter, text)
-
-
-class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
- """Handle requests in a separate thread."""
-
-
-class Blockext(BaseHTTPRequestHandler):
- # HTTPServer makes one Blockext instance for each request.
-
- blocks = {}
- _handlers = {}
- menus = {}
- name = "A blockext extension"
- filename = "extension"
- port = 8080
-
- requests = {}
-
- def log_message(self, format, *args):
- if isinstance(args[0], str) and args[0].startswith("GET /poll"):
- return
- return BaseHTTPRequestHandler.log_message(self, format, *args)
-
- @classmethod
- def handlers(cls):
- handlers = {}
- for name, func in cls._handlers.items():
- name = name.format(**vars(cls))
- handlers[name] = func
- return handlers
-
- def do_OPTIONS(self):
- self.send_response(200)
- self.send_header("Access-Control-Allow-Origin", "*")
- self.send_header("Access-Control-Allow-Headers",
- "X-Requested-With, X-Application")
- self.end_headers()
-
- def do_GET(self):
- is_browser = ("text/html" in self.headers.get("Accept", "") and
- "Snap!" not in self.headers.get("X-Application", "") and
- "Origin" not in self.headers)
- mime_type = "text/plain"
-
- path = self.path.split("/")[1:]
- path = [unquote(unquote_unicode(p)) for p in path]
- name = path[0]
- args = path[1:]
-
- func = self.handlers().get(name, self.blocks.get(name, None))
- if func:
- if isinstance(func, Block):
- response = func(*args)
- mime_type = "text/plain"
- else:
- (mime_type, response) = func(is_browser=is_browser, *args)
- status = 200
- else:
- response = "ERROR: Not found"
- status = 404
-
- if is_browser and mime_type == "text/plain":
- # Some browsers seem to hate plain text.
- response = u"""<!DOCTYPE html>
- <meta charset="utf8">
- <title>{title}</title>
- <pre>{response}</pre>
- """.format(title=name, response=escape(response))
- mime_type = "text/html"
-
- if not isinstance(response, bytes):
- response = response.encode("utf-8")
- self.send_response(status)
- self.send_header("Content-Length", str(len(response)))
- self.send_header("Content-Type", mime_type)
- if mime_type == "application/octet-stream":
- self.send_header("Content-Disposition", "attachment")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
-
- self.wfile.write(response)
-
- @classmethod
- def register(cls, name, block):
- cls.blocks[name] = block
-
- @classmethod
- def register_handler(cls, name, func, hidden=False, display=None):
- cls._handlers[name] = func
- func.is_hidden = hidden
- func.display = display
-
-
-def handler(name, **kwargs):
- def wrapper(func):
- Blockext.register_handler(name, func, **kwargs)
- return func
- return wrapper
-
-
-def menu(name, values):
- Blockext.menus[name] = list(map(make_unicode, values))
-
-
-@handler("", hidden=True)
-def index(is_browser=False):
- if is_browser:
- html = """<!DOCTYPE html>
- <meta charset="utf8">
- <title>blockext</title>
-
- <style>
- body {
- font-family: sans-serif;
- }
- a {
- color: #10a;
- text-decoration: none;
- }
- a:hover {
- text-decoration: underline;
- }
- ul {
- padding-left: 1.5em;
- }
- li {
- margin: 1em 0;
- }
- li p {
- margin: 0.5em 0;
- margin-left: 1em;
- }
- </style>
- """
-
- html += "<h1>{name}</h1>".format(name=escape(Blockext.name))
-
- html += "<ul>"
- handlers = Blockext.handlers()
- for name in sorted(handlers):
- func = handlers[name]
- if func.is_hidden: continue
- html += '<li><a href="/{path}">{display}</a>'.format(
- path=escape(name),
- display=escape(func.display or name),
- )
- html += "</ul>"
+"""Library for writing Scratch 2.0 and Snap! extensions.
- html += "<h2>Blocks</h2>"
- html += "<ul>"
- for name in sorted(Blockext.blocks):
- block = Blockext.blocks[name]
- if block.is_hidden: continue
- parts = [name] + block.defaults
- if block.is_blocking: parts.insert(1, "-")
- html += '<li><a href="/{path}">{text}</a>'.format(
- path=escape("/".join(map(unicode, parts))),
- text=escape(unicode(block) +
- (" *" if block.is_blocking else "")),
- )
- if block.help_text:
- html += "<p>{text}".format(
- text=escape(block.help_text).replace("\n\n", "<p>"),
- )
- html += "</ul>"
- html += "<small>* = blocking</small>"
+Blockext provides two things:
- return ("text/html", html)
- else:
- blocks = [n for (n, b) in Blockext.blocks.items() if not b.is_hidden]
- return ("text/plain", "\n".join(blocks))
-
-
-class Block(object):
- INPUT_RE = re.compile(r'(%[^ ](?:\.[A-z]+)?)')
-
- SHAPE_FMTS = {
- "predicate": "<%s>",
- "reporter": "(%s)",
- }
-
- INPUT_DEFAULTS = {
- "n": 0,
- "d": 0,
- "b": False,
- }
-
- INPUT_FMTS = {
- "n": "(%s)",
- "s": "[%s]",
- "m": "[%s \u25be]",
- "d": "(%s )",
- "b": "<%s>",
- }
-
- def __init__(self, text, shape, func, blocking=False, hidden=False,
- help_text=None):
- if blocking and shape != "command":
- raise ValueError("only commands can be blocking")
-
- self._text = make_unicode(text)
- self.shape = shape
- self.func = func
- self.is_blocking = blocking
- self.is_hidden = hidden
-
- self._help_text = help_text or func.__doc__ or ""
- self._help_text = re.sub(r'[ \t]*\n[ \t]*', "\n", self._help_text)
- self._help_text = self._help_text.strip()
+- automatic generation of extension files for both Scratch and Snap! from
+ blocks defined in Python code.
+- an method for extensions to communicate with Scratch and Snap!.
- self.arg_shapes = []
- for part in Block.INPUT_RE.split(self.text):
- match = Block.INPUT_RE.match(part)
- if match and match.group() == part:
- assert part[1] in "nbsmd"
- if part[1] in "md":
- assert "." in part
- self.arg_shapes.append(part[1:])
+"""
- defaults = list(inspect.getargspec(func).defaults or [])
- padding = len(self.arg_shapes) - len(defaults)
- defaults = [None] * padding + defaults
- shape_defaults = map(Block.INPUT_DEFAULTS.get, self.arg_shapes)
- self.defaults = [a or b for (a, b) in zip(defaults, shape_defaults)]
+__version__ = '0.2.0a'
- @property
- def text(self):
- return self._text.format(**vars(Blockext))
-
- @property
- def help_text(self):
- return self._help_text.format(**vars(Blockext))
-
- def __repr__(self):
- return "<Block(%r)>" % self.text
-
- def __str__(self):
- r = ""
- defaults = list(self.defaults)
- for part in Block.INPUT_RE.split(self.text):
- match = Block.INPUT_RE.match(part)
- if match and match.group() == part:
- shape = part[1]
- fmt = Block.INPUT_FMTS.get(shape, "%s")
- value = defaults.pop(0)
- part = fmt % unicode(value or " ")
- r += part
- fmt = Block.SHAPE_FMTS.get(self.shape, "%s")
- return fmt % r
-
- @staticmethod
- def convert_arg(arg, type_):
- shape = type_[0]
- if shape == "n":
- try:
- arg = int(arg)
- except ValueError:
- try:
- arg = float(arg)
- except ValueError:
- arg = 0
- elif shape == "b":
- arg = True if arg == "true" else False if arg == "false" else None
- elif shape == "m":
- menu_name = arg[2:]
- # TODO check in menu options?
- # shape = "d" ?
- return arg
+from collections import OrderedDict
+from functools import wraps
+import re
- def __call__(self, *args):
- request_id = None
- if self.is_blocking:
- request_id = args[0]
- args = args[1:]
- Blockext.requests[request_id] = True
- args = [Block.convert_arg(a, t) for (a, t)
- in zip(args, self.arg_shapes)]
- result = self.func(*args)
- if self.shape == "command": result = None
- result = ("true" if result is True else
- "false" if result is False else
- "" if result == None else make_unicode(result))
- if request_id and request_id in Blockext.requests:
- del Blockext.requests[request_id]
- return result
+from .blocks import Block, Input, Descriptor, load_po_files
+from .generate import generate_file
+from .helper import Extension
+_doc_pat = re.compile(r'[ \t]*\n[ \t]*')
def _shape(shape):
- def decorator(text, **kwargs):
- def wrapper(func):
+ def make_block(spec, defaults=[], help_text="", **kwargs):
+ def wrapper(func, help_text=help_text):
+ # Magic: name -> selector
selector = func.__name__
- block = Block(text, shape, func, **kwargs)
- Blockext.register(selector, block)
- return block
- return wrapper
- return decorator
+ # Magic: docstring -> help text
+ help_text = help_text or func.__doc__ or ""
+ help_text = _doc_pat.sub("\n", help_text)
+
+ block = Block(selector, shape, spec, defaults=defaults,
+ help_text=help_text, **kwargs)
+ block(func) # attaches itself to func._block
+ return func
+ return wrapper
+ return make_block
command = _shape("command")
reporter = _shape("reporter")
predicate = _shape("predicate")
+del _shape
-# Decorators (for special-in-Scratch features)
-def problem(func):
- """Decorator for the problem report tooltip in Scratch 2.0.
-
- The function should return a string describing the problem with the
- extension, if any. The problem report should help users troubleshoot.
-
- Returns None if everything is working correctly.
-
- @problem
- def spaceship_problem():
- if not spaceship_is_connected():
- return "The spaceship is not connected."
-
- """
-
- @reporter("problem with {name}", hidden=True)
- def _problem():
- """Reports a short description of the problem to help troubleshooting.
-
- If the extension is working fine, it reports an empty string.
-
- """
- return func()
-
- return _problem
-
-def reset(func):
- @command("reset {name}", hidden=True)
- def reset_all():
- """Resets the extension to its initial state.
-
- Triggered by clicking the red stop button in Scratch 2.0.
-
- """
- func()
-
- return reset_all
-
-
-def run(name="", filename="extension", port=8080):
- blocking_reporters = [b.text for b in Blockext.blocks.values()
- if b.shape != "command" and
- not all(shape[0] in "md" for shape in b.arg_shapes)]
- if blocking_reporters:
- print("WARNING: Scratch 2.0 doesn't support blocking reporters yet.")
- print("Affects: " + "\n ".join(blocking_reporters))
- print("")
-
- Blockext.name = name
- Blockext.filename = filename
- Blockext.port = port
- server = ThreadedHTTPServer(('localhost', port), Blockext)
- print('Listening on {}'.format(port))
- server.serve_forever()
+def get_decorated_blocks_from_class(cls, selectors=None):
+ if selectors:
+ cls_vars = vars(cls)
+ values = map(cls_vars.get, selectors)
+ else:
+ values = vars(cls).values()
+ functions = []
+ for value in values:
+ if callable(value) and hasattr(value, '_block'):
+ functions.append(value)
+ functions.sort(key=lambda func: func._block_id)
+ return [f._block for f in functions]
-import blockext.scratch
-from blockext.scratch import problem, reset
-import blockext.snap
diff --git a/blockext/blocks.py b/blockext/blocks.py
new file mode 100644
index 0000000..148866b
--- /dev/null
+++ b/blockext/blocks.py
@@ -0,0 +1,254 @@
+from __future__ import (absolute_import, division,
+ print_function, unicode_literals)
+from future.builtins import *
+
+import os
+import re
+
+
+
+class Block(object):
+ _highest_id = 0
+
+ def __init__(self, selector, shape, parts_or_spec, is_blocking=False,
+ help_text="", defaults=[]):
+ self.shape = str(shape)
+ """A string determining the kind of values the block reports.
+
+ * ``"command"`` -- Doesn't report a value. (puzzle-piece)
+ * ``"reporter"`` -- Reports a number. (round ends)
+ * ``"predicate"`` -- Reports a boolean. (pointy ends)
+
+ """
+
+ if selector.startswith("_"):
+ raise ValueError("names starting with an underscore are reserved")
+ self.selector = str(selector)
+ """Used by the block language to identify the block."""
+
+ if isinstance(parts_or_spec, list):
+ self.parts = [p if isinstance(p, Input) else str(p) for p in parts]
+ else:
+ self.parts = parse_spec(parts_or_spec)
+
+ for input_, value in zip(self.inputs, defaults):
+ input_.default = value
+
+ self.is_blocking = bool(is_blocking)
+ """True if the block language should wait for the block to return."""
+
+ self.help_text = str(help_text)
+ """Text explaining the block to a Scratch user."""
+
+ self.translations = {}
+
+ @property
+ def inputs(self):
+ return [p for p in self.parts if isinstance(p, Input)]
+
+ @property
+ def defaults(self):
+ return [x.default for x in self.inputs]
+
+ @property
+ def spec(self):
+ return generate_spec(self.parts)
+
+ def __repr__(self):
+ return "<Block({spec})>".format(spec=repr(generate_spec(self.parts)))
+
+ def __call__(self, func):
+ func._block = self
+ Block._highest_id += 1
+ func._block_id = Block._highest_id
+ return func
+
+
+class Input(object):
+ """The specification for an argument to a :class:`Block`."""
+
+ DEFAULTS = {
+ "number": 0,
+ "number-menu": 0,
+ "readonly-menu": None, # Set in _set_menu_defaults()
+ "string": "",
+ "boolean": False,
+ }
+
+ def __init__(self, shape, menu=None):
+ self.shape = str(shape)
+ """A string identifying the kind of values the input accepts.
+
+ * ``'number'`` -- number input (round ends)
+ * ``'string'`` -- string input (square ends)
+ * ``'boolean'`` -- boolean input (pointy ends)
+ * ``'readonly-menu'`` -- menu input
+ * ``'number-menu'`` -- editable number input with menu
+
+ """
+
+ if 'menu' in shape:
+ assert menu, "Menu is required"
+ else:
+ assert not menu, "Menu not allowed"
+ self.menu = str(menu) if menu else None
+ """For menu inputs: the options the drop-down menu contains.
+
+ The options come from an earlier :attr:`Extension.menu` call::
+
+ ext.add_menu("menuName", ["one", "two", "three", ...])
+
+ """
+
+ self.default = Input.DEFAULTS.get(self.shape)
+
+ def __repr__(self):
+ r = "Input({}".format(repr(self.menu))
+ if self.menu:
+ r += ", menu={}".format(repr(self.menu))
+ return r + ")"
+
+ def __eq__(self, other):
+ return (isinstance(other, Input) and self.shape == other.shape
+ and self.menu == other.menu)
+
+ def _set_menu_defaults(self, menus):
+ if self.default is None:
+ self.default = ""
+ if self.shape == "readonly-menu":
+ options = menus[self.menu]
+ if options:
+ self.default = options[0]
+
+
+INPUT_SPECS = {
+ "n": "number",
+ "s": "string",
+ "b": "boolean",
+ "m": "readonly-menu",
+ "d": "number-menu",
+}
+
+
+def parse_spec(spec):
+ def generate_parts(spec):
+ for part in re.split(r"(%[^ ](?:\.[A-z]+)?)", spec):
+ match = re.match(r"^%([^ ])(?:\.([A-z]+))?$", part)
+ if match:
+ shape = INPUT_SPECS.get(match.group(1))
+ if not shape:
+ raise ValueError("Unknown input shape %s" % part)
+ part = Input(shape, match.group(2))
+ else:
+ part = str(part)
+ yield part
+
+ spec = str(spec)
+ parts = list(generate_parts(spec))
+ inputs = [p for p in parts if isinstance(p, Input)]
+
+ return parts
+
+
+def generate_spec(block_parts):
+ """A string identifying the labels and inputs to the block.
+
+ Words starting with "%" produce input slots. Supported input types are:
+
+ * ``%n`` -- number input (round ends)
+ * ``%s`` -- string input (square ends)
+ * ``%b`` -- boolean input (pointy ends)
+ * ``%m.menuName`` -- menu input
+ * ``%d.menuName`` -- editable number input with menu
+
+ The last two input slots produce a drop-down menu. The options come
+ from an earlier :attr:`Extension.menu` call::
+
+ ext.add_menu("menuName", ["one", "two", "three", ...])
+
+ """
+
+ def stringify_part(part):
+ if isinstance(part, Input):
+ for s, shape in INPUT_SPECS.items():
+ if shape == part.shape:
+ break
+ else:
+ assert False
+ r = "%" + s
+ if part.menu:
+ r += "." + part.menu
+ return r
+ return part
+
+ spec = "".join(map(stringify_part, block_parts))
+ return spec
+
+
+def load_po_files(this_file, relative_folder=None, **language_file_paths):
+ translations = {}
+ base = ""
+ if this_file is not None:
+ base = os.path.abspath(os.path.dirname(this_file))
+ if relative_folder:
+ base = os.path.join(base, relative_folder)
+ for lang, path in language_file_paths.items():
+ path = os.path.join(base, path)
+ with open(path) as f:
+ translations[lang] = Language.from_po_file(f)
+ return translations
+
+
+class Language(object):
+ def __init__(self, strings):
+ self._strings = strings
+
+ def __getitem__(self, key):
+ """Return translation if possible, else untranslated string."""
+ return self._strings.get(key, key)
+
+ get = __getitem__
+
+ @classmethod
+ def from_po_file(cls, path):
+ return
+ raise NotImplementedError()
+
+ def get_menus(self, menus):
+ translated_menus = {}
+ for key, options in menus.items():
+ translated_menus[key] = list(map(self.get, options))
+ return translated_menus
+
+
+class Descriptor(object):
+ def __init__(self, name, port, blocks, menus=None, translations=None):
+ self.name = str(name)
+ """Human-readable name of the hardware."""
+
+ self.port = int(port)
+ """Port the extension runs on."""
+
+ self.blocks = list(blocks)
+ """The list of blocks displayed in the interface."""
+
+ menus = menus or {}
+ menus = dict((str(k), list(map(str, v))) for k, v in menus.items())
+ self.menus = menus
+ """Options for custom drop-down menus."""
+
+ translations = translations or {}
+ if "en" in translations:
+ raise ValueError("english must be default")
+ translations["en"] = Language({})
+ self.translations = translations
+ """Translations for block specs and menu options."""
+
+ # Set default menu options
+ for block in self.blocks:
+ for input_ in block.inputs:
+ input_._set_menu_defaults(self.menus)
+
+ def __repr__(self):
+ return "<Descriptor(%r, %i)>" % (self.name, self.port)
+
diff --git a/blockext/generate.py b/blockext/generate.py
new file mode 100644
index 0000000..5e7f296
--- /dev/null
+++ b/blockext/generate.py
@@ -0,0 +1,235 @@
+from __future__ import (absolute_import, division,
+ print_function, unicode_literals)
+from future.builtins import *
+
+from .blocks import Block, Input
+from .languages import language_codes
+
+
+class Program(object):
+ """For exporting blocks to a specfic block language."""
+
+ name = "Program" # "Scratch 2", "Snap"
+ """Name of the hardware or extension. Must be filename-friendly."""
+
+ by_short_name = {} # "scratch2": ScratchProgram, "snap": SnapProgram
+
+ file_extension = "xml"
+
+ content_type = "application/octet-stream"
+
+ @classmethod
+ def get_filename(cls, descriptor, lang):
+ language = language_codes.get(lang) or lang
+ fmt = "{cls.name} {descriptor.name} {language}.{cls.file_extension}"
+ return fmt.format(**locals())
+
+ @classmethod
+ def generate_file(cls, descriptor, language):
+ raise NotImplementedError(self)
+
+
+
+#-- Scratch 2.0 --#
+
+import json
+
+BLOCK_SHAPES = {
+ "command": " ",
+ "reporter": "r",
+ "predicate": "b",
+}
+
+class ScratchProgram(Program):
+ name = "Scratch"
+ file_extension = "s2e"
+
+ @classmethod
+ def generate_file(cls, descriptor, language):
+ s2e = {
+ "extensionName": descriptor.name,
+ "extensionPort": descriptor.port,
+ "blockSpecs": [],
+ "menus": language.get_menus(descriptor.menus),
+ }
+ for block in descriptor.blocks:
+ shape = BLOCK_SHAPES[block.shape]
+ if block.shape == "command" and block.is_blocking:
+ shape = "w"
+ spec = language.get(block.spec)
+ blockspec = [shape, spec, block.selector] + block.defaults
+ s2e["blockSpecs"].append(blockspec)
+ return json.dumps(s2e, ensure_ascii=False).encode("utf-8")
+ # TODO check Scratch will accept utf-8 json
+
+Program.by_short_name["scratch"] = ScratchProgram
+
+
+
+#-- Snap! --#
+
+import re
+from xml.etree import ElementTree
+from xml.etree.ElementTree import Element, SubElement
+
+INPUT_SELECTORS = {
+ "number": "n",
+ "string": "s",
+ "boolean": "b",
+ "readonly-menu": "txt",
+ "number-menu": "n",
+}
+
+class SnapProgram(Program):
+ name = "Snap"
+ file_extension = "xml"
+ content_type = "application/xml"
+
+ @classmethod
+ def generate_file(cls, descriptor, language):
+ return generate_snap(descriptor, language)
+
+def generate_snap(descriptor, language):
+ root = Element("blocks", {
+ "app": "Snap! 4.0, http://snap.berkeley.edu",
+ "version": "1",
+ })
+
+ menus = language.get_menus(descriptor.menus)
+
+ for block in descriptor.blocks:
+ defn = SubElement(root, "block-definition", {
+ "type": "%s" % block.shape, # Can't use a future.builtins.str
+ "category": "other",
+ })
+
+ if block.help_text:
+ comment = SubElement(defn, "comment", w="360", collapsed="false")
+ comment.text = block.help_text
+
+ SubElement(defn, "header")
+ SubElement(defn, "code")
+ inputs = SubElement(defn, "inputs")
+
+ snap_spec = ""
+ for part in block.parts:
+ if isinstance(part, Input):
+ input_el = SubElement(inputs, "input", {
+ "type": "%{shape}".format(shape=INPUT_SELECTORS[part.shape]),
+ "readonly": "true" if part.shape == "m" else "",
+ })
+ input_el.text = str(part.default)
+ if "menu" in part.shape:
+ options = SubElement(input_el, "options")
+ options.text = "\n".join(menus[part.menu])
+ # TODO menus
+ # XXX ^ why is there a todo comment here?
+
+ index = block.inputs.index(part)
+ part = "%'arg-{}'".format(index)
+ else:
+ assert isinstance(part, str)
+ # Snap! doesn't allow %-signs in block text yet.
+ part = re.compile(r" *% *").sub(" ", part)
+ snap_spec += part
+
+ defn.attrib["s"] = snap_spec
+
+ http_block = Element("block", s="reportURL")
+ join_block = SubElement(http_block, "block", s="reportJoinWords")
+ list_ = SubElement(join_block, "list")
+ url = "localhost:{descriptor.port}/{block.selector}".format(**vars())
+ if block.is_blocking:
+ url += "/-" # Blank request id
+ SubElement(list_, "l").text = url
+
+ for index, input_ in enumerate(block.inputs):
+ SubElement(list_, "l").text = "/"
+ encode = SubElement(list_, "block", s="reportTextFunction")
+ l = SubElement(encode, "l")
+ SubElement(l, "option").text = "encode URI component"
+ join = SubElement(encode, "block", s="reportJoinWords")
+ SubElement(join, "block", var="arg-{}".format(index))
+
+ if block.shape == "command":
+ script_xml = """
+ <script>
+ <block s="{cmd}">
+ <block s="reifyReporter">
+ <autolambda>
+ {http_block_xml}
+ </autolambda>
+ </block>
+ </block>
+ </script>
+ """.format(
+ cmd="doRun" if block.is_blocking else "fork",
+ http_block_xml="{http_block_xml}",
+ )
+ elif block.shape == "predicate":
+ script_xml = """
+ <script>
+ <block s="doDeclareVariables">
+ <list>
+ <l>result</l>
+ </list>
+ </block>
+ <block s="doSetVar">
+ <l>result</l>
+ {http_block_xml}
+ </block>
+ <block s="doIf">
+ <block s="reportEquals">
+ <block var="result"/>
+ <l>true</l>
+ </block>
+ <script>
+ <block s="doSetVar">
+ <l>result</l>
+ <block s="reportTrue"/>
+ </block>
+ </script>
+ </block>
+ <block s="doIf">
+ <block s="reportEquals">
+ <block var="result"/>
+ <l>false</l>
+ </block>
+ <script>
+ <block s="doSetVar">
+ <l>result</l>
+ <block s="reportFalse"/>
+ </block>
+ </script>
+ </block>
+ <block s="doReport">
+ <block var="result"/>
+ </block>
+ </script>
+ """
+ elif block.shape == "reporter":
+ script_xml = """
+ <script>
+ <block s="doReport">
+ {http_block_xml}
+ </block>
+ </script>
+ """
+
+ script = ElementTree.fromstring(script_xml.format(
+ http_block_xml=str(ElementTree.tostring(http_block).decode("utf-8"))
+ ))
+ defn.append(script)
+
+ return ElementTree.tostring(root)
+
+Program.by_short_name["snap"] = SnapProgram
+
+
+def generate_file(descriptor, program_short_name, language_code="en"):
+ program = Program.by_short_name[program_short_name]
+ filename = Program.get_filename(descriptor, language_code)
+ language = descriptor.translations[language_code]
+ contents = program.generate_file(descriptor, language)
+ return filename, contents
+
diff --git a/blockext/helper.py b/blockext/helper.py
new file mode 100644
index 0000000..4d866a6
--- /dev/null
+++ b/blockext/helper.py
@@ -0,0 +1,217 @@
+"""Code to interface with Scratch/Snap! as a "helper app".
+
+Uses blockext.server as an HTTP nanoframework.
+"""
+
+from __future__ import (absolute_import, division,
+ print_function, unicode_literals)
+from future.builtins import *
+
+import itertools
+
+from .blocks import Block
+from .server import Server, Response, NotFound, Download, Redirect, quote
+from .generate import Program
+
+
+
+class HelperApp(object):
+ def __init__(self, helper_cls, blocks_by_selector, descriptor, debug=False):
+ self.blocks_by_selector = blocks_by_selector
+ self.helper_cls = helper_cls
+
+ self.debug = debug
+ self.descriptor = descriptor
+
+ self.requests = set()
+ self.helper_cls = helper_cls
+ self.helper = helper_cls()
+
+ def get_response(self, selector=None, *args):
+ if not self.ensure_connected():
+ return Response("")
+
+ if not selector:
+ return self.index()
+
+ func_name = "handle_" + selector
+ func = getattr(self, func_name, None)
+ if func:
+ return func(*args)
+ else:
+ return self.handle(selector, *args)
+
+ def ensure_connected(self):
+ if hasattr(self.helper, "_is_connected"):
+ return self.helper._is_connected()
+ else:
+ return True
+
+ def handle(self, selector, *args):
+ try:
+ block = self.blocks_by_selector[selector]
+ except KeyError:
+ return NotFound()
+
+ content = self.run_block(block, args)
+ return Response(content.encode("utf-8"))
+
+ def run_block(self, block, args):
+ args = list(args)
+ if block.is_blocking:
+ request_id = args.pop(0)
+ # Snap! doesn't use _busy, so it sends "-" as the request_id
+ if request_id != "-":
+ self.requests.add(request_id)
+
+ assert len(args) == len(block.inputs)
+
+ args = [decode_arg(a, t) for (a, t)
+ in zip(args, block.inputs)]
+
+ func = getattr(self.helper, block.selector)
+ result = func(*args)
+ content = encode_result(result, block.shape)
+
+ if block.is_blocking:
+ if request_id in self.requests:
+ self.requests.remove(request_id)
+
+ return content
+
+ def handle_poll(self):
+ reporter_values = {}
+ for selector, block in self.blocks_by_selector.items():
+ if block.is_blocking: continue
+ if block.shape not in ("reporter", "predicate"):
+ continue
+ func = getattr(self.helper, selector)
+ menu_names = [input_.menu for input_ in block.inputs]
+ if all(menu_names): # they're all menu inputs
+ insert_options = map(self.descriptor.menus.get, menu_names)
+ for args in itertools.product(*insert_options):
+ path = "/".join([selector] + list(args))
+ reporter_values[path] = self.run_block(block, args)
+
+ if self.requests:
+ reporter_values["_busy"] = " ".join(map(str, self.requests))
+
+ if hasattr(self.helper, "_problem"):
+ problem = self.helper._problem()
+ if problem:
+ reporter_values["_problem"] = str(problem)
+
+ lines = []
+ for name, value in reporter_values.items():
+ lines.append(name + " " + value)
+
+ content = "\n".join(lines).encode("utf-8")
+ return Response(content)
+
+ def handle_reset_all(self):
+ # TODO what if the helper isn't connected?
+ if hasattr(self.helper, "_on_reset"):
+ self.helper._on_reset()
+ self.requests = set()
+ # TODO clear self.requests, kill threads (?)
+ return Response("")
+
+
+ # For debugging extensions #
+
+ def index(self):
+ if not self.debug:
+ return NotFound()
+
+ return Response("""\
+ <!doctype html>
+ <p><a href="/_generate_blocks/scratch">Download Scratch 2 extension file</a>
+ <p><a href="/_generate_blocks/snap">Download Snap! blocks</a>
+ """, content_type="text/html")
+
+ def handle__generate_blocks(self, program_name, language_code="en", filename=None):
+ if not self.debug:
+ return NotFound()
+
+ program = Program.by_short_name[program_name]
+ language = self.descriptor.translations[language_code]
+
+ if not filename:
+ filename = program.get_filename(self.descriptor, language_code)
+ new_path = "/".join((
+ "/_generate_blocks",
+ quote(program_name),
+ quote(language_code),
+ quote(filename))
+ )
+ return Redirect(new_path)
+
+ return Download(program.generate_file(self.descriptor, language),
+ program.content_type)
+
+
+
+#- Translate between block language strings and Pythonic values. -#
+
+def decode_arg(arg, input_):
+ if input_.shape == "number":
+ try:
+ arg = int(arg)
+ except ValueError:
+ try:
+ arg = float(arg)
+ except ValueError:
+ arg = 0
+ elif input_.shape == "boolean":
+ arg = True if arg == "true" else False if arg == "false" else None
+ return arg
+
+
+def encode_result(result, shape="reporter"):
+ if shape == "command":
+ result = None
+ elif shape == "predicate":
+ result = bool(result)
+ elif shape == "reporter":
+ pass
+
+ result = ("true" if result is True else
+ "false" if result is False else
+ "" if result == None else str(result))
+ return result
+
+
+
+class Extension(object):
+ def __init__(self, helper_cls, descriptor, deprecated_blocks=None):
+ self.helper_cls = helper_cls
+ """The class implementing the block methods.
+
+ Block selectors must correspond to method names on this class.
+
+ """
+
+ self.descriptor = descriptor
+ """Information about the extension."""
+
+ deprecated_blocks = deprecated_blocks or []
+ self._blocks_by_selector = {}
+ for block in descriptor.blocks + deprecated_blocks:
+ if not isinstance(block, Block):
+ raise ValueError("not a block: " + repr(block))
+ if block.selector in self._blocks_by_selector:
+ raise ValueError("block selectors must be unique")
+ if not hasattr(helper_cls, block.selector):
+ raise ValueError(
+ "helper class needs method for block: " + repr(block.selector)
+ )
+ self._blocks_by_selector[block.selector] = block
+
+ def run_forever(self, debug=False):
+ host = "localhost"
+ app = HelperApp(self.helper_cls, self._blocks_by_selector,
+ self.descriptor, debug)
+ server = Server(app, host, self.descriptor.port)
+ print("Listening on {host}:{self.descriptor.port}".format(**vars()))
+ server.serve_forever()
+
diff --git a/blockext/languages.py b/blockext/languages.py
new file mode 100644
index 0000000..b60f193
--- /dev/null
+++ b/blockext/languages.py
@@ -0,0 +1,118 @@
+#coding=utf8
+"""ISO code to language name mappings.
+
+Taken from Scratch 2.0 and Snap!.
+
+"""
+
+"""
+Updating this file
+==================
+
+Get strings from Scratch 2 using:
+
+ >>> import requests
+ >>> url = "http://scratch.mit.edu/scratchr2/static/locale/lang_list.txt"
+ >>> r = requests.get(url)
+ >>> content = r.content.decode("utf-8")
+ >>> lines = content.strip().split("\n")
+ >>> pairs = [l.split(",") for l in lines]
+ >>> scratch_codes = dict(pairs)
+
+Get strings from Snap! using:
+
+ var languages = {}; for (key in SnapTranslator.dict) {
+ languages[key] = SnapTranslator.dict[key].language_name;
+ }; JSON.stringify(languages);
+
+ >>> snap_codes = json.loads('...');
+
+Combine:
+
+ >>> language_codes = snap_codes.copy()
+ >>> language_codes.update(scratch_codes)
+
+Print out using:
+
+ >>> for k, v in sorted(language_codes.items()):
+ ... print(" %r: %r," % (k, v))
+
+"""
+
+language_codes = {
+ 'an': 'Aragonés',
+ 'ar': 'العربية',
+ 'ast': 'Asturianu',
+ 'bg': 'Български',
+ 'ca': 'Català',
+ 'cat': 'Meow',
+ 'cs': 'Česky',
+ 'cy': 'Cymraeg',
+ 'da': 'Dansk ',
+ 'de': 'Deutsch',
+ 'dk': 'Dansk',
+ 'el': 'Ελληνικά',
+ 'en': 'English',
+ 'eo': 'Esperanto',
+ 'es': 'Español',
+ 'et': 'Eesti',
+ 'eu': 'Euskara',
+ 'fa': 'فارسی',
+ 'fa-af': 'Dari',
+ 'fi': 'Suomi',
+ 'fr': 'Français',
+ 'fr-ca': 'Français (Canada)',
+ 'ga': 'Gaeilge',
+ 'gl': 'Galego',
+ 'he': 'עִבְרִית',
+ 'hi': 'हिन्दी',
+ 'hr': 'Hrvatski',
+ 'hu': 'Magyar',
+ 'hy': 'Հայերեն',
+ 'id': 'Bahasa Indonesia',
+ 'is': 'Íslenska',
+ 'it': 'Italiano',
+ 'ja': '日本語',
+ 'ja-hr': 'にほんご',
+ 'ja_HIRA': 'にほんご',
+ 'km': 'សំលៀកបំពាក',
+ 'kn': 'ಭಾಷೆ-ಹೆಸರು',
+ 'ko': '한국어',
+ 'ku': 'Kurdî',
+ 'la': 'Latina',
+ 'lt': 'Lietuvių',
+ 'lv': 'Latviešu',
+ 'mk': 'Македонски',
+ 'ml': 'മലയാളം',
+ 'mn': 'Монгол хэл',
+ 'mr': 'मराठी',
+ 'ms': 'Bahasa Melayu',
+ 'mt': 'Malti',
+ 'my': 'မြန်မာဘာသာ',
+ 'nai': 'Tepehuan',
+ 'nb': 'Norsk Bokmål',
+ 'nl': 'Nederlands',
+ 'no': 'Norsk',
+ 'pl': 'Polski',
+ 'pt': 'Português',
+ 'pt-br': 'Português (Brasil)',
+ 'pt_BR': 'Português do Brasil',
+ 'ro': 'Română',
+ 'ru': 'Русский',
+ 'rw': 'Kinyarwanda',
+ 'sc': 'Sardu',
+ 'si': 'Slovenščina',
+ 'sk': 'Slovenčina',
+ 'sl': 'Slovenščina',
+ 'sr': 'Српски',
+ 'sv': 'Svenska',
+ 'th': 'ไทย',
+ 'tr': 'Türkçe',
+ 'tw': '繁體中文',
+ 'uk': 'Українська',
+ 'vi': 'Tiếng Việt',
+ 'zh': '简体中文',
+ 'zh-cn': '简体中文',
+ 'zh-tw': '正體中文',
+}
+
diff --git a/blockext/server.py b/blockext/server.py
new file mode 100644
index 0000000..6380a82
--- /dev/null
+++ b/blockext/server.py
@@ -0,0 +1,102 @@
+"""A nano HTTP server."""
+
+from __future__ import (absolute_import, division,
+ print_function, unicode_literals)
+from future.builtins import *
+from future.utils import native
+
+from future import standard_library
+standard_library.install_hooks()
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from socketserver import ThreadingMixIn
+
+
+try:
+ from urllib import quote as _quote_str
+ from urllib import unquote as _unquote_str
+ def quote(part):
+ return _quote_str(part.encode("utf-8"))
+ def unquote(part):
+ if not isinstance(part, bytes): part = part.decode("utf-8")
+ return _unquote_str(part).decode("utf-8")
+except ImportError:
+ from urllib.parse import quote, unquote
+
+
+
+#-- Response --#
+
+class Response(object):
+ """HTTP response headers, status, and content."""
+ def __init__(self, content, status=200, **headers):
+ if not isinstance(content, bytes): content = content.encode("utf-8")
+ self.content = bytes(content)
+ self.status = status
+ defaults = dict(
+ content_type="text/plain",
+ content_length=str(len(self.content)),
+ access_control_allow_origin="*",
+ )
+ defaults.update(headers)
+ if "content_type" not in headers:
+ headers["content_type"] = "text/plain"
+ self.headers = dict((k.title().replace("_", "-"), v)
+ for (k, v) in defaults.items())
+
+def Download(content, content_type="application/octet-stream"):
+ """Response that downloads the file."""
+ return Response(content, content_type=content_type,
+ content_disposition="attachment")
+
+def Redirect(location):
+ """Response that temporary-redirects to the new location."""
+ return Response(location.encode("utf-8"), 302, location=location)
+
+def NotFound():
+ return Response("ERROR: Not found", 404)
+
+
+#-- Server --#
+
+def GetRequestHandlerFactory(app):
+ app_ = app
+ class DerivedGetRequestHandler(GetRequestHandler):
+ app = app_
+ return DerivedGetRequestHandler
+
+
+class GetRequestHandler(BaseHTTPRequestHandler):
+ def log_message(self, format_, *args):
+ if isinstance(args[0], str) and args[0].startswith("GET /poll"):
+ return
+ return BaseHTTPRequestHandler.log_message(self, format_, *args)
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Headers",
+ "X-Requested-With, X-Application")
+ self.end_headers()
+
+ def do_GET(self):
+ args = self.path.split("/")
+ args = list(map(unquote, args))
+ assert args.pop(0) == "" # since path starts with a slash
+ response = self.app.get_response(*args)
+
+ self.send_response(response.status)
+ for k, v in response.headers.items():
+ self.send_header(k, str(v))
+ self.end_headers()
+ self.wfile.write(native(response.content))
+
+
+class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
+ """Handle requests in a separate thread."""
+
+
+def Server(app, host, port):
+ handler = GetRequestHandlerFactory(app)
+ server = ThreadedHTTPServer((host, port), handler)
+ return server
+
diff --git a/blockext/snap.py b/blockext/snap.py
deleted file mode 100644
index 4a67a67..0000000
--- a/blockext/snap.py
+++ /dev/null
@@ -1,156 +0,0 @@
-from __future__ import unicode_literals
-
-from xml.etree import ElementTree
-from xml.etree.ElementTree import Element, SubElement
-
-from blockext import *
-
-
-
-INPUT_SELECTORS = {
- "n": "n",
- "s": "s",
- "b": "b",
- "m": "txt",
- "d": "n",
-}
-
-@handler("snap_{filename}.xml", display="Download Snap! blocks")
-def generate_xml(is_browser=False):
- root = Element("blocks", {
- "app": "Snap! 4.0, http://snap.berkeley.edu",
- "version": "1",
- })
-
- for name, block in Blockext.blocks.items():
- if block.is_hidden: continue
-
- defn = SubElement(root, "block-definition", {
- "type": block.shape,
- "category": "other",
- })
-
- if block.help_text:
- comment = SubElement(defn, "comment", w="360", collapsed="false")
- comment.text = block.help_text
-
- SubElement(defn, "header")
- SubElement(defn, "code")
- inputs = SubElement(defn, "inputs")
-
- input_names = inspect.getargspec(block.func).args
- defaults = list(block.defaults)
- selector = ""
- for part in Block.INPUT_RE.split(block.text):
- match = Block.INPUT_RE.match(part)
- if match and match.group() == part:
- shape = part[1]
- input_ = SubElement(inputs, "input", {
- "type": "%{shape}".format(shape=INPUT_SELECTORS[shape]),
- "readonly": "true" if shape == "m" else "",
- })
- default = (defaults.pop(0) if defaults else "") or ""
- input_.text = unicode(default)
- if shape in "md":
- options = SubElement(input_, "options")
- menu_name = part[3:]
- options.text = "\n".join(Blockext.menus[menu_name])
- # TODO menus
-
- input_name = input_names.pop(0) if input_names else ""
- part = "%'{name}'".format(name=input_name)
- else:
- # Snap! doesn't allow %-signs in block text yet.
- part = part.replace("%", " ").replace(" ", " ")
- selector += part
- defn.attrib["s"] = selector
-
- http_block = Element("block", s="reportURL")
- join_block = SubElement(http_block, "block", s="reportJoinWords")
- list_ = SubElement(join_block, "list")
- url = "localhost:{port}/{name}".format(port=Blockext.port, name=name)
- if block.is_blocking:
- url += "/-" # Blank request id
- SubElement(list_, "l").text = url
- input_names = inspect.getargspec(block.func).args
- for name in input_names:
- SubElement(list_, "l").text = "/"
- encode = SubElement(list_, "block", s="reportTextFunction")
- l = SubElement(encode, "l")
- SubElement(l, "option").text = "encode URI component"
- join = SubElement(encode, "block", s="reportJoinWords")
- SubElement(join, "block", var=name)
-
- if block.shape == "command":
- script_xml = """
- <script>
- <block s="{selector}">
- <block s="reifyReporter">
- <autolambda>
- {http_block_xml}
- </autolambda>
- </block>
- </block>
- </script>
- """.format(
- selector="doRun" if block.is_blocking else "fork",
- http_block_xml="{http_block_xml}",
- )
- elif block.shape == "predicate":
- script_xml = """
- <script>
- <block s="doDeclareVariables">
- <list>
- <l>result</l>
- </list>
- </block>
- <block s="doSetVar">
- <l>result</l>
- {http_block_xml}
- </block>
- <block s="doIf">
- <block s="reportEquals">
- <block var="result"/>
- <l>true</l>
- </block>
- <script>
- <block s="doSetVar">
- <l>result</l>
- <block s="reportTrue"/>
- </block>
- </script>
- </block>
- <block s="doIf">
- <block s="reportEquals">
- <block var="result"/>
- <l>false</l>
- </block>
- <script>
- <block s="doSetVar">
- <l>result</l>
- <block s="reportFalse"/>
- </block>
- </script>
- </block>
- <block s="doReport">
- <block var="result"/>
- </block>
- </script>
- """
- elif block.shape == "reporter":
- script_xml = """
- <script>
- <block s="doReport">
- {http_block_xml}
- </block>
- </script>
- """
-
- script = ElementTree.fromstring(script_xml.format(
- http_block_xml=ElementTree.tostring(http_block),
- ))
- defn.append(script)
-
- # It's useful to change this to "application/xml" while debugging.
- return ("application/octet-stream", ElementTree.tostring(root))
-
diff --git a/example.py b/example.py
index 02c3896..0eec225 100644
--- a/example.py
+++ b/example.py
@@ -1,92 +1,83 @@
# coding=utf-8
+from __future__ import unicode_literals
-import blockext
-from blockext import *
-
+import time
+from blockext import *
-@predicate("not %b")
-def not_(value):
- return not value
-
-@command("say %s for %n secs", blocking=True)
-def say_for_secs(text="Hello", duration=5):
- import time
- print(text)
- time.sleep(duration)
-@command("play note %n")
-def play_note(note):
- print("DING {note}".format(note=note))
- time.sleep(2)
+class Example:
+ def __init__(self):
+ self.foo = 0
-menu("pizza", ["tomato", "cheese", "hawaii", "nothing",
- "spinach and cauliflower", "empty return value",
- "cheese and tomato", "fancy",
- "ü",
- "/",
- ])
+ def _problem(self):
+ if time.time() % 8 > 4:
+ return "The Scratch Sensor board is not connected. Foo."
-@reporter("colour of %m.pizza flavour pizza")
-def pizza_colour(pizza="tomato"):
- return {
- "tomato": "red",
- "cheese": "yellow",
- "cheese and tomato": "YELLOW",
- "hawaii": "orange AND BLUE",
- "spinach and cauliflower": "GREEN ü",
- "empty return value": "",
- "nothing": "",
- "/": "SLASH",
- u"ü": "unicode",
- u"fancy": "❤☀☆☂",
- }[pizza]
+ def _on_reset(self):
+ print("""
+ Reset! The red stop button has been clicked,
+ And now everything is how it was.
+ ...
+ (Poetry's not my strong point, you understand.)
+ """)
-@reporter("spaaace")
-def getSpaaace():
- return "space space space space space!"
+ @predicate("not %b")
+ def not_(self, value):
+ return not value
-@reporter("id %s")
-def id(text):
- """Tests strings can get passed from Snap! to Python and back."""
- print(text)
- return text
+ @command("say %s for %n secs", is_blocking=True)
+ def say_for_secs(self, text="Hello", duration=5):
+ print(text)
+ time.sleep(duration)
-@command("set number to %n% units")
-def percent(number=42):
- print(number)
+ @command("play note %n")
+ def play_note(self, note):
+ print("DING {note}".format(note=note))
+ time.sleep(2)
-foo = None
+ @reporter("colour of %m.pizza flavour pizza", defaults=["tomato"])
+ def pizza_colour(self, pizza):
+ return {
+ "tomato": "red",
+ "cheese": "yellow",
+ "hawaii": "orange and blue",
+ }[pizza]
-@command("set foo to %s")
-def set_foo(value=''):
- global foo
- foo = value
+ @reporter("id %s")
+ def id(self, text):
+ """Tests strings can get passed from Snap! to Python and back."""
+ print(text)
+ return text
-@reporter("foo")
-def get_foo():
- return foo
+ @command("set number to %n% units")
+ def percent(self, number=42):
+ print(number)
-@command("ü")
-def x(): pass
+ @command("set foo to %s")
+ def set_foo(self, value=''):
+ self.foo = value
-@problem
-def my_problem():
- if time.time() % 8 > 4:
- return "The Scratch Sensor board is not connected. Foo."
+ @reporter("foo")
+ def get_foo(self):
+ return self.foo
-@reset
-def my_reset():
- print("""
- Reset! The red stop button has been clicked,
- And now everything is how it was.
- ...
- (Poetry's not my strong point, you understand.)
- """)
+ @command("ü")
+ def x(self):
+ pass
+descriptor = Descriptor(
+ name = "Fancy Spaceship",
+ port = 1234,
+ blocks = get_decorated_blocks_from_class(Example),
+ menus = dict(
+ pizza = ["tomato", "cheese", "hawaii"],
+ ),
+)
+extension = Extension(Example, descriptor)
if __name__ == "__main__":
- blockext.run("Fancy Spaceship", "spaceship", 1234)
+ extension.run_forever(debug=True)