# 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)