diff options
| author | blob8108 <blob8108@gmail.com> | 2014-05-28 20:50:08 +0100 |
|---|---|---|
| committer | blob8108 <blob8108@gmail.com> | 2014-05-28 20:50:08 +0100 |
| commit | ee0474c2690ef221ddadbb8b556511f92b81bd27 (patch) | |
| tree | a02d64689cb284e4998a46f68f6fcada5c5b92dd | |
| parent | 14df0ecb5a4e0fb67ea0ebaae52ec45a9f7ba9a0 (diff) | |
| download | blockext-ee0474c2690ef221ddadbb8b556511f92b81bd27.tar.gz blockext-ee0474c2690ef221ddadbb8b556511f92b81bd27.zip | |
GIGA-COMMIT: Blockext 0.2 draft version
| -rw-r--r-- | blockext/__init__.py | 435 | ||||
| -rw-r--r-- | blockext/blocks.py | 254 | ||||
| -rw-r--r-- | blockext/generate.py | 235 | ||||
| -rw-r--r-- | blockext/helper.py | 217 | ||||
| -rw-r--r-- | blockext/languages.py | 118 | ||||
| -rw-r--r-- | blockext/server.py | 102 | ||||
| -rw-r--r-- | blockext/snap.py | 156 | ||||
| -rw-r--r-- | example.py | 131 |
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)) - @@ -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) |
