Source code for kurt.plugin

# Copyright (C) 2012 Tim Radvan
#
# This file is part of Kurt.
#
# Kurt is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Kurt is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with Kurt. If not, see <http://www.gnu.org/licenses/>.

"""
To add support for a new file format, write a new :class:`KurtPlugin` subclass::

    import kurt
    from kurt.plugin import Kurt, KurtPlugin

    class MyScratchModPlugin(KurtPlugin):
        def load(self, fp):
            kurt_project = kurt.Project()
            # ... set kurt_project attributes ... #
            return kurt_project

        def save(self, fp, kurt_project):
            # ... save kurt_project attributes to file ...

    Kurt.register(MyScratchModPlugin())

Take a look at :mod:`kurt.scratch20` for a more detailed example.


List available plugins
~~~~~~~~~~~~~~~~~~~~~~

To get a list of the plugins registered with :class:`Kurt`:

    >>> kurt.plugin.Kurt.plugins.keys()
    ['scratch20', 'scratch14']

You should see your plugin in the output, unless you forgot to :attr:`register
<Kurt.register>` it.


Notes
~~~~~

Some things to keep in mind:

* Most Scratch file formats have the *stage* as the base object -- so project
  attributes, such as the notes and the list of sprites, are stored on the
  stage object.
* For Scratch, which doesn't support stage-specific variables, global variables
  and lists are stored on the Project, not the Stage.
* If your plugin contains obsolete blocks, they should be at the *end* of the
  :attr:`blocks` list. Otherwise things might not work properly.

"""

from collections import OrderedDict

import kurt



[docs]class KurtPlugin(object): """Handles a specific file format. Loading and saving converts between a :class:`Project`, kurt's internal representation, and a file of this format. """ name = "scratch14" """Short name of this file format, Python identifier style. Used internally by kurt. Examples: ``"scratch14"``, ``"scratch20.sprite"``, ``"byob3"``, ``"snap"`` """ display_name = "Scratch 2.0 Sprite" """Human-readable name of this file format. May be displayed to the user. Should not contain "Project" or "File". Examples: ``"Scratch 1.4"``, ``"Scratch 2.0 Sprite"``, ``"BYOB 3.1"`` """ extension = ".sb" """The extension used by this format, with leading dot. Used by :attr:`Project.load` to recognise its files. """ features = [] """A list of the :class:`Features <Feature>` that the plugin supports.""" blocks = [] """The list of :class:`PluginBlockType` objects supported by this plugin, in the order they appear in the program's interface. """ def __repr__(self): return self.__module__ + "." + self.__class__.__name__ + "()" # Override the following methods in subclass:
[docs] def load(self, fp): """Load a project from a file with this format. :attr:`Project.path` will be set later. :attr:`Project.name` will be set to the filename of the path to the file if unset. :param fp: A file pointer to the file, opened in binary mode. :returns: :class:`Project` """ raise NotImplementedError
[docs] def save(self, fp, project): """Save a project to a file with this format. :param path: A file pointer to the file, opened in binary mode. :param project: a :class:`Project` """ raise NotImplementedError
[docs]class Kurt(object): """The Kurt file format loader. This class manages the registering and selection of file formats. Used by :class:`Project`. """ plugins = OrderedDict() blocks = [] @classmethod
[docs] def register(cls, plugin): """Register a new :class:`KurtPlugin`. Once registered, the plugin can be used by :class:`Project`, when: * :attr:`Project.load` sees a file with the right extension * :attr:`Project.convert` is called with the format as a parameter """ cls.plugins[plugin.name] = plugin # make features plugin.features = map(Feature.get, plugin.features) # fix blocks blocks = [] for pbt in plugin.blocks: if pbt: pbt = pbt.copy() pbt.format = plugin.name blocks.append(pbt) plugin.blocks = blocks # add blocks new_blocks = filter(None, plugin.blocks) for pbt in new_blocks: for bt in cls.blocks: if (bt.has_command(pbt.command) or bt.has_command(pbt._match)): bt._add_conversion(plugin.name, pbt) break else: if pbt._match: raise ValueError, "Couldn't match %r" % pbt._match cls.blocks.append(kurt.BlockType(pbt))
@classmethod
[docs] def get_plugin(cls, name=None, **kwargs): """Returns the first format plugin whose attributes match kwargs. For example:: get_plugin(extension="scratch14") Will return the :class:`KurtPlugin` whose :attr:`extension <KurtPlugin.extension>` attribute is ``"scratch14"``. The :attr:`name <KurtPlugin.name>` is used as the ``format`` parameter to :attr:`Project.load` and :attr:`Project.save`. :raises: :class:`ValueError` if the format doesn't exist. :returns: :class:`KurtPlugin` """ if isinstance(name, KurtPlugin): return name if 'extension' in kwargs: kwargs['extension'] = kwargs['extension'].lower() if name: kwargs["name"] = name if not kwargs: raise ValueError, "No arguments" for plugin in cls.plugins.values(): for name in kwargs: if getattr(plugin, name) != kwargs[name]: break else: return plugin raise ValueError, "Unknown format %r" % kwargs
@classmethod
[docs] def block_by_command(cls, command): """Return the block with the given :attr:`command`. Returns None if the block is not found. """ for block in cls.blocks: if block.has_command(command): return block
@classmethod
[docs] def blocks_by_text(cls, text): """Return a list of blocks matching the given :attr:`text`. Capitalisation and spaces are ignored. """ text = kurt.BlockType._strip_text(text) matches = [] for block in cls.blocks: for pbt in block.conversions: if pbt.stripped_text == text: matches.append(block) break return matches
#-- Features --# def empty_generator(): if False: yield class Feature(object): """A format feature that a plugin supports.""" FEATURES = {} @classmethod def get(cls, name): if isinstance(name, Feature): return name return cls.FEATURES[name] def __init__(self, name, description): self.name = name self.description = description Feature.FEATURES[name] = self def __repr__(self): return "<Feature(%s)>" % self.name def __eq__(self, other): if isinstance(other, basestring): return self.name == other return self is other def normalize(self, project): """Convert project to a plugin that SUPPORTS this feature.""" return empty_generator() def workaround(self, project): """Convert project to a plugin that does NOT support this feature.""" return empty_generator() def workaround(feature): feature = Feature.get(feature) def _wrapper(f): assert callable(f) feature.workaround = f return _wrapper def normalize(feature): feature = Feature.get(feature) def _wrapper(f): assert callable(f) feature.normalize = f return _wrapper Feature("Vector Images", """Allow vector format (SVG) image files for costumes.""") @workaround("Vector Images") def _workaround_no_vector_images(project): """Replace vector images with fake ones.""" RED = (255, 0, 0) PLACEHOLDER = kurt.Image.new((32, 32), RED) for scriptable in [project.stage] + project.sprites: for costume in scriptable.costumes: if costume.image.format == "SVG": yield "%s - %s" % (scriptable.name, costume.name) costume.image = PLACEHOLDER Feature("Stage-specific Variables", """Can have stage-specific variables and lists, in addition to global variables and lists (which are stored on the :class:`Project`).""") @workaround("Stage-specific Variables") def _workaround_no_stage_specific_variables(project): """Make Stage-specific variables global (move them to Project).""" for (name, var) in project.stage.variables.items(): yield "variable %s" % name for (name, _list) in project.stage.lists.items(): yield "list %s" % name project.variables.update(project.stage.variables) project.lists.update(project.stage.lists) project.stage.variables = {} project.stage.lists = {} Feature("Custom Blocks", """Blocks accept :class:`CustomBlockType` objects for their :attr:`type`.""") Feature("First-class Lists", """Variables can take list values. Nested lists are supported. `Scriptable.lists` and `Project.lists` are unused.""") @workaround("First-class Lists") def _workaround_no_first_class_lists(project): """Replace list-containing variables with lists of the same name.""" return empty_generator() # TODO @normalize("First-class Lists") def _normalize_first_class_lists(project): """Replace `Scriptable.lists` with variables containing lists.""" return empty_generator() # TODO #-- Convert Blocks --# def block_workaround(bt, workaround): if isinstance(workaround, kurt.Block): w = workaround workaround = lambda block: w.copy() else: assert callable(workaround) bt = kurt.BlockType.get(bt) bt._add_workaround(workaround)