diff options
author | Ben Sturmfels <ben@sturm.com.au> | 2021-03-05 23:12:19 +1100 |
---|---|---|
committer | Ben Sturmfels <ben@sturm.com.au> | 2021-03-05 23:12:19 +1100 |
commit | dec47c7102cf0aa3a4debf002928db8e460c0d71 (patch) | |
tree | 47631fc15c7af172aa699506adf3d76d3a71976c /mediagoblin/tools | |
parent | 5f3a782fef4855e10b7259624a14d8afb0f7be93 (diff) | |
download | mediagoblin-dec47c7102cf0aa3a4debf002928db8e460c0d71.tar.lz mediagoblin-dec47c7102cf0aa3a4debf002928db8e460c0d71.tar.xz mediagoblin-dec47c7102cf0aa3a4debf002928db8e460c0d71.zip |
Apply `pyupgrade --py3-plus` to remove Python 2 compatibility code.
Diffstat (limited to 'mediagoblin/tools')
-rw-r--r-- | mediagoblin/tools/common.py | 4 | ||||
-rw-r--r-- | mediagoblin/tools/crypto.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/exif.py | 14 | ||||
-rw-r--r-- | mediagoblin/tools/files.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/licenses.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/mail.py | 9 | ||||
-rw-r--r-- | mediagoblin/tools/metadata.py | 7 | ||||
-rw-r--r-- | mediagoblin/tools/pagination.py | 6 | ||||
-rw-r--r-- | mediagoblin/tools/pluginapi.py | 4 | ||||
-rw-r--r-- | mediagoblin/tools/processing.py | 8 | ||||
-rw-r--r-- | mediagoblin/tools/request.py | 6 | ||||
-rw-r--r-- | mediagoblin/tools/response.py | 6 | ||||
-rw-r--r-- | mediagoblin/tools/routing.py | 8 | ||||
-rw-r--r-- | mediagoblin/tools/session.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/staticdirect.py | 12 | ||||
-rw-r--r-- | mediagoblin/tools/subtitles.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/template.py | 10 | ||||
-rw-r--r-- | mediagoblin/tools/text.py | 12 | ||||
-rw-r--r-- | mediagoblin/tools/timesince.py | 1 | ||||
-rw-r--r-- | mediagoblin/tools/translate.py | 12 | ||||
-rw-r--r-- | mediagoblin/tools/url.py | 4 | ||||
-rw-r--r-- | mediagoblin/tools/workbench.py | 6 |
22 files changed, 66 insertions, 73 deletions
diff --git a/mediagoblin/tools/common.py b/mediagoblin/tools/common.py index 34586611..f34149b7 100644 --- a/mediagoblin/tools/common.py +++ b/mediagoblin/tools/common.py @@ -47,7 +47,7 @@ def simple_printer(string): sys.stdout.flush() -class CollectingPrinter(object): +class CollectingPrinter: """ Another printer object, this one useful for capturing output for examination during testing or otherwise. @@ -68,6 +68,6 @@ class CollectingPrinter(object): @property def combined_string(self): - return u''.join(self.collection) + return ''.join(self.collection) diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 1107e200..4bc541f8 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -79,7 +79,7 @@ def setup_crypto(app_config): key_filepath = os.path.join(key_dir, 'itsdangeroussecret.bin') try: load_key(key_filepath) - except IOError as error: + except OSError as error: if error.errno != errno.ENOENT: raise create_key(key_dir, key_filepath) diff --git a/mediagoblin/tools/exif.py b/mediagoblin/tools/exif.py index 2215fb0c..cf739b07 100644 --- a/mediagoblin/tools/exif.py +++ b/mediagoblin/tools/exif.py @@ -84,7 +84,7 @@ def extract_exif(filename): try: with open(filename, 'rb') as image: return process_file(image, details=False) - except IOError: + except OSError: raise BadMediaFail(_('Could not read the image file.')) @@ -100,8 +100,8 @@ def clean_exif(exif): 'JPEGThumbnail', 'Thumbnail JPEGInterchangeFormat'] - return dict((key, _ifd_tag_to_dict(value)) for (key, value) - in six.iteritems(exif) if key not in disabled_tags) + return {key: _ifd_tag_to_dict(value) for (key, value) + in exif.items() if key not in disabled_tags} def _ifd_tag_to_dict(tag): @@ -117,7 +117,7 @@ def _ifd_tag_to_dict(tag): 'field_length': tag.field_length, 'values': None} - if isinstance(tag.printable, six.binary_type): + if isinstance(tag.printable, bytes): # Force it to be decoded as UTF-8 so that it'll fit into the DB data['printable'] = tag.printable.decode('utf8', 'replace') @@ -125,7 +125,7 @@ def _ifd_tag_to_dict(tag): data['values'] = [_ratio_to_list(val) if isinstance(val, Ratio) else val for val in tag.values] else: - if isinstance(tag.values, six.binary_type): + if isinstance(tag.values, bytes): # Force UTF-8, so that it fits into the DB data['values'] = tag.values.decode('utf8', 'replace') else: @@ -140,7 +140,7 @@ def _ratio_to_list(ratio): def get_useful(tags): from collections import OrderedDict - return OrderedDict((key, tag) for (key, tag) in six.iteritems(tags)) + return OrderedDict((key, tag) for (key, tag) in tags.items()) def get_gps_data(tags): @@ -162,7 +162,7 @@ def get_gps_data(tags): 'latitude': tags['GPS GPSLatitude'], 'longitude': tags['GPS GPSLongitude']} - for key, dat in six.iteritems(dms_data): + for key, dat in dms_data.items(): gps_data[key] = ( lambda v: safe_gps_ratio_divide(v[0]) \ diff --git a/mediagoblin/tools/files.py b/mediagoblin/tools/files.py index 0509a387..e2e07733 100644 --- a/mediagoblin/tools/files.py +++ b/mediagoblin/tools/files.py @@ -27,7 +27,7 @@ def delete_media_files(media): - media: A MediaEntry document """ no_such_files = [] - for listpath in six.itervalues(media.media_files): + for listpath in media.media_files.values(): try: mg_globals.public_store.delete_file( listpath) diff --git a/mediagoblin/tools/licenses.py b/mediagoblin/tools/licenses.py index 9c13c683..0940ac56 100644 --- a/mediagoblin/tools/licenses.py +++ b/mediagoblin/tools/licenses.py @@ -64,7 +64,7 @@ SORTED_LICENSES = [ # dict {uri: License,...} to enable fast license lookup by uri. Ideally, # we'd want to use an OrderedDict (python 2.7+) here to avoid having the # same data in two structures -SUPPORTED_LICENSES = dict(((l.uri, l) for l in SORTED_LICENSES)) +SUPPORTED_LICENSES = {l.uri: l for l in SORTED_LICENSES} def get_license_by_url(url): diff --git a/mediagoblin/tools/mail.py b/mediagoblin/tools/mail.py index 3dc180d8..3e46fb36 100644 --- a/mediagoblin/tools/mail.py +++ b/mediagoblin/tools/mail.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from __future__ import print_function, unicode_literals import socket import logging @@ -64,7 +63,7 @@ class NoSMTPServerError(MailError): pass -class FakeMhost(object): +class FakeMhost: """ Just a fake mail host so we can capture and test messages from send_email @@ -115,7 +114,7 @@ def send_email(from_addr, to_addrs, subject, message_body): mhost = smtp_init( mg_globals.app_config['email_smtp_host'], mg_globals.app_config['email_smtp_port']) - except socket.error as original_error: + except OSError as original_error: error_message = "Couldn't contact mail server on <{}>:<{}>".format( mg_globals.app_config['email_smtp_host'], mg_globals.app_config['email_smtp_port']) @@ -126,7 +125,7 @@ def send_email(from_addr, to_addrs, subject, message_body): if not mg_globals.app_config['email_smtp_host']: # e.g. host = '' try: mhost.connect() # We SMTP.connect explicitly - except socket.error as original_error: + except OSError as original_error: error_message = "Couldn't contact mail server on <{}>:<{}>".format( mg_globals.app_config['email_smtp_host'], mg_globals.app_config['email_smtp_port']) @@ -138,7 +137,7 @@ def send_email(from_addr, to_addrs, subject, message_body): except smtplib.SMTPException: # Only raise an exception if we're forced to if mg_globals.app_config['email_smtp_force_starttls']: - six.reraise(*sys.exc_info()) + raise if ((not common.TESTS_ENABLED) and (mg_globals.app_config['email_smtp_user'] diff --git a/mediagoblin/tools/metadata.py b/mediagoblin/tools/metadata.py index aeb4f829..5e5ad9fd 100644 --- a/mediagoblin/tools/metadata.py +++ b/mediagoblin/tools/metadata.py @@ -15,7 +15,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -from io import open import os import copy import json @@ -65,8 +64,8 @@ class DefaultChecker(FormatChecker): checkers = copy.deepcopy(draft4_format_checker.checkers) -DefaultChecker.checkers[u"uri"] = (is_uri, ()) -DefaultChecker.checkers[u"date-time"] = (is_datetime, (ValueError, TypeError)) +DefaultChecker.checkers["uri"] = (is_uri, ()) +DefaultChecker.checkers["date-time"] = (is_datetime, (ValueError, TypeError)) DEFAULT_CHECKER = DefaultChecker() # Crappy default schema, checks for things we deem important @@ -219,5 +218,5 @@ def expand_json(metadata, context=DEFAULT_CONTEXT): def rdfa_to_readable(rdfa_predicate): - readable = rdfa_predicate.split(u":")[1].capitalize() + readable = rdfa_predicate.split(":")[1].capitalize() return readable diff --git a/mediagoblin/tools/pagination.py b/mediagoblin/tools/pagination.py index db5f69fb..5e859a5f 100644 --- a/mediagoblin/tools/pagination.py +++ b/mediagoblin/tools/pagination.py @@ -19,12 +19,12 @@ from math import ceil, floor from itertools import count from werkzeug.datastructures import MultiDict -from six.moves import range, urllib, zip +from six.moves import urllib PAGINATION_DEFAULT_PER_PAGE = 30 -class Pagination(object): +class Pagination: """ Pagination class for database queries. @@ -105,7 +105,7 @@ class Pagination(object): new_get_params = dict(get_params) or {} new_get_params['page'] = page_no - return "%s?%s" % ( + return "{}?{}".format( base_url, urllib.parse.urlencode(new_get_params)) def get_page_url(self, request, page_no): diff --git a/mediagoblin/tools/pluginapi.py b/mediagoblin/tools/pluginapi.py index 1eabe9f1..be125a0e 100644 --- a/mediagoblin/tools/pluginapi.py +++ b/mediagoblin/tools/pluginapi.py @@ -66,7 +66,7 @@ from mediagoblin import mg_globals _log = logging.getLogger(__name__) -class PluginManager(object): +class PluginManager: """Manager for plugin things .. Note:: @@ -128,7 +128,7 @@ class PluginManager(object): def register_route(self, route): """Registers a single route""" - _log.debug('registering route: {0}'.format(route)) + _log.debug('registering route: {}'.format(route)) self.routes.append(route) def get_routes(self): diff --git a/mediagoblin/tools/processing.py b/mediagoblin/tools/processing.py index 26d7bb9b..5af3b5ad 100644 --- a/mediagoblin/tools/processing.py +++ b/mediagoblin/tools/processing.py @@ -47,12 +47,12 @@ def json_processing_callback(entry): Send an HTTP post to the registered callback url, if any. ''' if not entry.processing_metadata: - _log.debug('No processing callback URL for {0}'.format(entry)) + _log.debug('No processing callback URL for {}'.format(entry)) return url = entry.processing_metadata[0].callback_url - _log.debug('Sending processing callback for {0} to {1}'.format( + _log.debug('Sending processing callback for {} to {}'.format( entry, url)) @@ -76,11 +76,11 @@ def json_processing_callback(entry): try: request.urlopen(request) - _log.debug('Processing callback for {0} sent'.format(entry)) + _log.debug('Processing callback for {} sent'.format(entry)) return True except request.HTTPError: - _log.error('Failed to send callback: {0}'.format( + _log.error('Failed to send callback: {}'.format( traceback.format_exc())) return False diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index 7e1973d3..51403f05 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -39,9 +39,9 @@ def setup_user_in_request(request): # If API request the user will be associated with the access token authorization = decode_authorization_header(request.headers) - if authorization.get(u"access_token"): + if authorization.get("access_token"): # Check authorization header. - token = authorization[u"oauth_token"] + token = authorization["oauth_token"] token = AccessToken.query.filter_by(token=token).first() if token is not None: request.user = token.user @@ -66,7 +66,7 @@ def decode_request(request): content_type, _ = parse_options_header(request.content_type) if content_type == json_encoded: - data = json.loads(six.text_type(data, "utf-8")) + data = json.loads(str(data, "utf-8")) elif content_type == form_encoded or content_type == "": data = request.form else: diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 889938a8..93b9c6e7 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -27,7 +27,7 @@ from datetime import date class Response(wz_Response): """Set default response mimetype to HTML, otherwise we get text/plain""" - default_mimetype = u'text/html' + default_mimetype = 'text/html' def render_to_response(request, template, context, status=200, mimetype=None): @@ -106,7 +106,7 @@ def render_http_exception(request, exc, description): elif stock_desc and exc.code == 404: return render_404(request) - return render_error(request, title='{0} {1}'.format(exc.code, exc.name), + return render_error(request, title='{} {}'.format(exc.code, exc.name), err_msg=description, status=exc.code) @@ -154,7 +154,7 @@ def json_response(serializable, _disable_cors=False, *args, **kw): 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} - for key, value in six.iteritems(cors_headers): + for key, value in cors_headers.items(): response.headers.set(key, value) return response diff --git a/mediagoblin/tools/routing.py b/mediagoblin/tools/routing.py index a2c89f2a..1c360bff 100644 --- a/mediagoblin/tools/routing.py +++ b/mediagoblin/tools/routing.py @@ -44,17 +44,17 @@ class MGRoute(Rule): if not (self.match_slash or path.endswith("/")): path = path + "/" - return super(MGRoute, self).match(path, *args, **kwargs) + return super().match(path, *args, **kwargs) def endpoint_to_controller(rule): endpoint = rule.endpoint view_func = rule.gmg_controller - _log.debug('endpoint: {0} view_func: {1}'.format(endpoint, view_func)) + _log.debug('endpoint: {} view_func: {}'.format(endpoint, view_func)) # import the endpoint, or if it's already a callable, call that - if isinstance(view_func, six.string_types): + if isinstance(view_func, str): view_func = import_component(view_func) rule.gmg_controller = view_func @@ -73,7 +73,7 @@ def mount(mountpoint, routes): Mount a bunch of routes to this mountpoint """ for endpoint, url, controller in routes: - url = "%s/%s" % (mountpoint.rstrip('/'), url.lstrip('/')) + url = "{}/{}".format(mountpoint.rstrip('/'), url.lstrip('/')) add_route(endpoint, url, controller) def extract_url_arguments(url, urlmap): diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py index a57f69cc..64d88fdc 100644 --- a/mediagoblin/tools/session.py +++ b/mediagoblin/tools/session.py @@ -39,7 +39,7 @@ class Session(dict): self.save() -class SessionManager(object): +class SessionManager: def __init__(self, cookie_name='MGSession', namespace=None): if namespace is None: namespace = cookie_name diff --git a/mediagoblin/tools/staticdirect.py b/mediagoblin/tools/staticdirect.py index 881dd20e..545500bc 100644 --- a/mediagoblin/tools/staticdirect.py +++ b/mediagoblin/tools/staticdirect.py @@ -29,7 +29,7 @@ import six _log = logging.getLogger(__name__) -class StaticDirect(object): +class StaticDirect: """ Direct to a static resource. @@ -48,9 +48,9 @@ class StaticDirect(object): "http://example.org/themestatic/images/lollerskate.png" """ def __init__(self, domains): - self.domains = dict( - [(key, value.rstrip('/')) - for key, value in six.iteritems(domains)]) + self.domains = { + key: value.rstrip('/') + for key, value in domains.items()} self.cache = {} def __call__(self, filepath, domain=None): @@ -62,11 +62,11 @@ class StaticDirect(object): return static_direction def get(self, filepath, domain=None): - return '%s/%s' % ( + return '{}/{}'.format( self.domains[domain], filepath.lstrip('/')) -class PluginStatic(object): +class PluginStatic: """Pass this into the ``'static_setup'`` hook to register your plugin's static directory. diff --git a/mediagoblin/tools/subtitles.py b/mediagoblin/tools/subtitles.py index efafbeec..c6e420a5 100644 --- a/mediagoblin/tools/subtitles.py +++ b/mediagoblin/tools/subtitles.py @@ -10,7 +10,7 @@ def get_path(path): def open_subtitle(path): subtitle_path = get_path(path) - subtitle = open(subtitle_path,"r") # Opening the file using the absolute path + subtitle = open(subtitle_path) # Opening the file using the absolute path text = subtitle.read() return text diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py index f2619808..1a335fe4 100644 --- a/mediagoblin/tools/template.py +++ b/mediagoblin/tools/template.py @@ -65,12 +65,8 @@ def get_jinja_env(app, template_loader, locale): 'jinja2.ext.i18n', 'jinja2.ext.autoescape', TemplateHookExtension] + local_exts) - if six.PY2: - template_env.install_gettext_callables(mg_globals.thread_scope.translations.ugettext, - mg_globals.thread_scope.translations.ungettext) - else: - template_env.install_gettext_callables(mg_globals.thread_scope.translations.gettext, - mg_globals.thread_scope.translations.ngettext) + template_env.install_gettext_callables(mg_globals.thread_scope.translations.gettext, + mg_globals.thread_scope.translations.ngettext) # All templates will know how to ... # ... fetch all waiting messages and remove them from the queue @@ -164,7 +160,7 @@ class TemplateHookExtension(Extension): ... will include all templates hooked into the comment_extras section. """ - tags = set(["template_hook"]) + tags = {"template_hook"} def parse(self, parser): includes = [] diff --git a/mediagoblin/tools/text.py b/mediagoblin/tools/text.py index 48a53d23..2ef14eb9 100644 --- a/mediagoblin/tools/text.py +++ b/mediagoblin/tools/text.py @@ -43,13 +43,13 @@ HTML_CLEANER = Cleaner( safe_attrs_only=True, add_nofollow=True, # for now host_whitelist=(), - whitelist_tags=set([])) + whitelist_tags=set()) def clean_html(html): # clean_html barfs on an empty string if not html: - return u'' + return '' return HTML_CLEANER.clean_html(html) @@ -65,7 +65,7 @@ def convert_to_tag_list_of_dicts(tag_string): if tag_string: # Strip out internal, trailing, and leading whitespace - stripped_tag_string = u' '.join(tag_string.strip().split()) + stripped_tag_string = ' '.join(tag_string.strip().split()) # Split the tag string into a list of tags for tag in stripped_tag_string.split(','): @@ -84,12 +84,12 @@ def media_tags_as_string(media_entry_tags): """ tags_string = '' if media_entry_tags: - tags_string = u', '.join([tag['name'] for tag in media_entry_tags]) + tags_string = ', '.join([tag['name'] for tag in media_entry_tags]) return tags_string TOO_LONG_TAG_WARNING = \ - u'Tags must be shorter than %s characters. Tags that are too long: %s' + 'Tags must be shorter than %s characters. Tags that are too long: %s' def tag_length_validator(form, field): @@ -119,6 +119,6 @@ def cleaned_markdown_conversion(text): # Markdown will do nothing with and clean_html can do nothing with # an empty string :) if not text: - return u'' + return '' return clean_html(UNSAFE_MARKDOWN_INSTANCE.convert(text)) diff --git a/mediagoblin/tools/timesince.py b/mediagoblin/tools/timesince.py index 7a8b3ff0..d020a334 100644 --- a/mediagoblin/tools/timesince.py +++ b/mediagoblin/tools/timesince.py @@ -26,7 +26,6 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from __future__ import unicode_literals import datetime import pytz diff --git a/mediagoblin/tools/translate.py b/mediagoblin/tools/translate.py index a5e56cfe..c383fce0 100644 --- a/mediagoblin/tools/translate.py +++ b/mediagoblin/tools/translate.py @@ -33,7 +33,7 @@ TRANSLATIONS_PATH = pkg_resources.resource_filename( 'mediagoblin', 'i18n') # Known RTL languages -KNOWN_RTL = set(["ar", "fa", "he", "iw", "ur", "yi", "ji"]) +KNOWN_RTL = {"ar", "fa", "he", "iw", "ur", "yi", "ji"} def is_rtl(lang): """Returns true when the local language is right to left""" @@ -54,11 +54,11 @@ class ReallyLazyProxy(LazyProxy): Like LazyProxy, except that it doesn't cache the value ;) """ def __init__(self, func, *args, **kwargs): - super(ReallyLazyProxy, self).__init__(func, *args, **kwargs) + super().__init__(func, *args, **kwargs) object.__setattr__(self, '_is_cache_enabled', False) def __repr__(self): - return "<%s for %s(%r, %r)>" % ( + return "<{} for {}({!r}, {!r})>".format( self.__class__.__name__, self._func, self._args, @@ -71,10 +71,10 @@ def locale_to_lower_upper(locale): """ if '-' in locale: lang, country = locale.split('-', 1) - return '%s_%s' % (lang.lower(), country.upper()) + return '{}_{}'.format(lang.lower(), country.upper()) elif '_' in locale: lang, country = locale.split('_', 1) - return '%s_%s' % (lang.lower(), country.upper()) + return '{}_{}'.format(lang.lower(), country.upper()) else: return locale.lower() @@ -85,7 +85,7 @@ def locale_to_lower_lower(locale): """ if '_' in locale: lang, country = locale.split('_', 1) - return '%s-%s' % (lang.lower(), country.lower()) + return '{}-{}'.format(lang.lower(), country.lower()) else: return locale.lower() diff --git a/mediagoblin/tools/url.py b/mediagoblin/tools/url.py index 4d97247a..fbfeefae 100644 --- a/mediagoblin/tools/url.py +++ b/mediagoblin/tools/url.py @@ -22,11 +22,11 @@ import six _punct_re = re.compile(r'[\t !"#:$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+') -def slugify(text, delim=u'-'): +def slugify(text, delim='-'): """ Generates an ASCII-only slug. Taken from http://flask.pocoo.org/snippets/5/ """ result = [] for word in _punct_re.split(text.lower()): result.extend(unidecode(word).split()) - return six.text_type(delim.join(result)) + return str(delim.join(result)) diff --git a/mediagoblin/tools/workbench.py b/mediagoblin/tools/workbench.py index f1ad6414..bcc48640 100644 --- a/mediagoblin/tools/workbench.py +++ b/mediagoblin/tools/workbench.py @@ -27,7 +27,7 @@ from mediagoblin._compat import py2_unicode @py2_unicode -class Workbench(object): +class Workbench: """ Represent the directory for the workbench @@ -42,7 +42,7 @@ class Workbench(object): self.dir = dir def __str__(self): - return six.text_type(self.dir) + return str(self.dir) def __repr__(self): try: @@ -137,7 +137,7 @@ class Workbench(object): self.destroy() -class WorkbenchManager(object): +class WorkbenchManager: """ A system for generating and destroying workbenches. |