From 157b78a0520319538557db24291bf44bec6c9501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 3 Jun 2015 16:42:57 +0200 Subject: [PATCH] WIP: Use Flask-Assets to merge js, css and less files Should reduce number of requests and hence load times needed for loading web interface. TODO: Handling of empty bundles needs to be fixed --- setup.py | 1 + src/octoprint/plugin/types.py | 18 +++- src/octoprint/server/__init__.py | 116 ++++++++++++++++++++- src/octoprint/server/util/__init__.py | 3 + src/octoprint/server/util/flask.py | 92 +++++++++++++++- src/octoprint/server/views.py | 17 +-- src/octoprint/settings.py | 3 +- src/octoprint/templates/javascripts.jinja2 | 49 ++------- src/octoprint/templates/stylesheets.jinja2 | 31 +++--- 9 files changed, 246 insertions(+), 84 deletions(-) diff --git a/setup.py b/setup.py index 1c11afc3..e0ebf435 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ INSTALL_REQUIRES = [ "Flask-Login==0.2.2", "Flask-Principal==0.3.5", "Flask-Babel==0.9", + "Flask-Assets", "pyserial", "netaddr", "watchdog", diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index aa4b8fe2..56a1bedb 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -648,12 +648,24 @@ class BlueprintPlugin(OctoPrintPlugin, RestartNeedingPlugin): ``template_folder``, etc. Defaults to the blueprint's ``static_folder`` and ``template_folder`` to be set to the plugin's basefolder - plus ``/static`` or respectively ``/templates``. + plus ``/static`` or respectively ``/templates``, or -- if the plugin also implements :class:`AssetPlugin` and/or + :class:`TemplatePlugin` -- the paths provided by ``get_asset_folder`` and ``get_template_folder`` respectively. """ import os + + if isinstance(self, AssetPlugin): + static_folder = self.get_asset_folder() + else: + static_folder = os.path.join(self._basefolder, "static") + + if isinstance(self, TemplatePlugin): + template_folder = self.get_template_folder() + else: + template_folder = os.path.join(self._basefolder, "templates") + return dict( - static_folder=os.path.join(self._basefolder, "static"), - template_folder=os.path.join(self._basefolder, "templates") + static_folder=static_folder, + template_folder=template_folder ) def is_blueprint_protected(self): diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index b217540a..5701f6b4 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -7,10 +7,11 @@ __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms import uuid from sockjs.tornado import SockJSRouter -from flask import Flask, g, request, session +from flask import Flask, g, request, session, Blueprint from flask.ext.login import LoginManager, current_user from flask.ext.principal import Principal, Permission, RoleNeed, identity_loaded, UserNeed from flask.ext.babel import Babel, gettext, ngettext +from flask.ext.assets import Environment, Bundle from babel import Locale from watchdog.observers import Observer from collections import defaultdict @@ -24,6 +25,7 @@ SUCCESS = {} NO_CONTENT = ("", 204) app = Flask("octoprint") +assets = None babel = None debug = False @@ -232,6 +234,9 @@ class Server(): pluginLifecycleManager.add_callback("enabled", template_enabled) pluginLifecycleManager.add_callback("disabled", template_disabled) + # setup assets + self._setup_assets() + # configure timelapse octoprint.timelapse.configureTimelapse() @@ -240,6 +245,7 @@ class Server(): if self._debug: events.DebugEventListener() + # setup access control if s.getBoolean(["accessControl", "enabled"]): userManagerName = s.get(["accessControl", "userManager"]) try: @@ -300,10 +306,14 @@ class Server(): upload_suffixes = dict(name=s.get(["server", "uploads", "nameSuffix"]), path=s.get(["server", "uploads", "pathSuffix"])) server_routes = self._router.urls + [ + # various downloads (r"/downloads/timelapse/([^/]*\.mpg)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("timelapse"), as_attachment=True)), (r"/downloads/files/local/(.*)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("uploads"), as_attachment=True, path_validation=util.tornado.path_validation_factory(lambda path: not os.path.basename(path).startswith("."), status_code=404))), (r"/downloads/logs/([^/]*)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("logs"), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.admin_validator))), + # camera snapshot (r"/downloads/camera/current", util.tornado.UrlForwardHandler, dict(url=s.get(["webcam", "snapshot"]), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.user_validator))), + # generated webassets + (r"/static/webassets/(.*)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("webassets"))) ] for name, hook in pluginManager.get_hooks("octoprint.server.http.routes").items(): try: @@ -610,11 +620,21 @@ class Server(): # also register any blueprints defined in BlueprintPlugins self._register_blueprint_plugins() + # and register a blueprint for serving the static files of asset plugins which are not blueprint plugins themselves + self._register_asset_plugins() + def _register_blueprint_plugins(self): blueprint_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.BlueprintPlugin) for plugin in blueprint_plugins: self._register_blueprint_plugin(plugin) + def _register_asset_plugins(self): + asset_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.AssetPlugin) + for plugin in asset_plugins: + if isinstance(plugin, octoprint.plugin.BlueprintPlugin): + continue + self._register_asset_plugin(plugin) + def _register_blueprint_plugin(self, plugin): name = plugin._identifier blueprint = plugin.get_blueprint() @@ -632,6 +652,100 @@ class Server(): if self._logger: self._logger.debug("Registered API of plugin {name} under URL prefix {url_prefix}".format(name=name, url_prefix=url_prefix)) + def _register_asset_plugin(self, plugin): + name = plugin._identifier + + url_prefix = "/plugin/{name}".format(name=name) + blueprint = Blueprint("plugin." + name, name, static_folder=plugin.get_asset_folder()) + app.register_blueprint(blueprint, url_prefix=url_prefix) + + if self._logger: + self._logger.debug("Registered assets of plugin {name} under URL prefix {url_prefix}".format(name=name, url_prefix=url_prefix)) + + def _setup_assets(self): + global app + global assets + global pluginManager + + AdjustedEnvironment = type(Environment)(Environment.__name__, (Environment,), dict( + resolver_class=util.flask.PluginAssetResolver + )) + assets = AdjustedEnvironment(app) + + dynamic_assets = util.flask.collect_plugin_assets() + + js_libs = [ + "js/lib/jquery/jquery.min.js", + "js/lib/modernizr.custom.js", + "js/lib/lodash.min.js", + "js/lib/sprintf.min.js", + "js/lib/knockout.js", + "js/lib/knockout.mapping-latest.js", + "js/lib/babel.js", + "js/lib/avltree.js", + "js/lib/bootstrap/bootstrap.js", + "js/lib/bootstrap/bootstrap-modalmanager.js", + "js/lib/bootstrap/bootstrap-modal.js", + "js/lib/bootstrap/bootstrap-slider.js", + "js/lib/bootstrap/bootstrap-tabdrop.js", + "js/lib/jquery/jquery.ui.core.js", + "js/lib/jquery/jquery.ui.widget.js", + "js/lib/jquery/jquery.ui.mouse.js", + "js/lib/jquery/jquery.flot.js", + "js/lib/jquery/jquery.iframe-transport.js", + "js/lib/jquery/jquery.fileupload.js", + "js/lib/jquery/jquery.slimscroll.min.js", + "js/lib/jquery/jquery.qrcode.min.js", + "js/lib/sockjs-0.3.4.min.js", + "js/lib/moment-with-locales.min.js", + "js/lib/pusher.color.min.js", + "js/lib/detectmobilebrowser.js", + "js/lib/md5.min.js", + "js/lib/pnotify.min.js", + "js/lib/bootstrap-slider-knockout-binding.js", + "js/lib/loglevel.min.js" + ] + + js_app = dynamic_assets["js"] + [ + "js/app/dataupdater.js", + "js/app/helpers.js", + "js/app/main.js", + ] + + css_libs = [ + "css/bootstrap.min.css", + "css/bootstrap-modal.css", + "css/bootstrap-slider.css", + "css/bootstrap-tabdrop.css", + "css/font-awesome.min.css", + "css/jquery.fileupload-ui.css", + "css/pnotify.min.css" + ] + + css_app = [] + less_app = [] + for sheet, path in dynamic_assets["stylesheets"]: + if sheet == "css": + css_app.append(path) + elif sheet == "less": + less_app.append(path) + + js_libs_bundle = Bundle(*js_libs, output="webassets/packed_libs.js") + js_app_bundle = Bundle(*js_app, output="webassets/package_app.js") + css_libs_bundle = Bundle(*css_libs, output="webassets/packed_libs.css") + + assets.register("js_libs", js_libs_bundle) + assets.register("js_app", js_app_bundle) + assets.register("css_libs", css_libs_bundle) + + if len(css_app): + css_app_bundle = Bundle(*css_app, output="webassets/packed_app.css") + assets.register("css_app", css_app_bundle) + if len(less_app): + less_app_bundle = Bundle(*less_app, output="webassets/packed_app.less") + assets.register("less_app", less_app_bundle) + + class LifecycleManager(object): def __init__(self, plugin_manager): self._plugin_manager = plugin_manager diff --git a/src/octoprint/server/util/__init__.py b/src/octoprint/server/util/__init__.py index 2c59233c..426acc7a 100644 --- a/src/octoprint/server/util/__init__.py +++ b/src/octoprint/server/util/__init__.py @@ -32,6 +32,9 @@ def apiKeyRequestHandler(): if _flask.request.method == 'OPTIONS' and settings().getBoolean(["api", "allowCrossOrigin"]): return optionsAllowOrigin(_flask.request) + if _flask.request.endpoint == "static" or _flask.request.endpoint.endswith(".static"): + return + apikey = get_api_key(_flask.request) if apikey is None: # no api key => 401 diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py index 8881f6e9..6ff85474 100644 --- a/src/octoprint/server/util/flask.py +++ b/src/octoprint/server/util/flask.py @@ -10,6 +10,7 @@ import tornado.web import flask import flask.ext.login import flask.ext.principal +import flask.ext.assets import functools import time import uuid @@ -20,6 +21,7 @@ import netaddr from octoprint.settings import settings import octoprint.server import octoprint.users +import octoprint.plugin from werkzeug.contrib.cache import SimpleCache @@ -32,8 +34,6 @@ def enable_additional_translations(default_locale="en", additional_folders=None) from babel import support, Locale import flask.ext.babel - import octoprint.plugin - if additional_folders is None: additional_folders = [] @@ -411,3 +411,91 @@ def get_json_command_from_request(request, valid_commands): return None, None, make_response("Mandatory parameter %s missing for command %s" % (parameter, command), 400) return command, data, None + +##~~ Flask-Assets resolver with plugin asset support + +class PluginAssetResolver(flask.ext.assets.FlaskResolver): + + def split_prefix(self, item): + if item.startswith("plugin/"): + try: + prefix, plugin, name = item.split("/", 2) + blueprint = prefix + "." + plugin + + directory = flask.ext.assets.get_static_folder(self.env._app.blueprints[blueprint]) + item = name + return directory, item + except (ValueError, KeyError): + pass + + return flask.ext.assets.FlaskResolver.split_prefix(self, item) + + def resolve_output_to_path(self, target, bundle): + if target.startswith("webassets/"): + import os + return os.path.normpath(os.path.join(settings().getBaseFolder("webassets"), target[len("webassets/"):])) + return flask.ext.assets.FlaskResolver.resolve_output_to_path(self, target, bundle) + +##~~ plugin assets collector + +def collect_plugin_assets(enable_gcodeviewer=True, enable_timelapse=True, preferred_stylesheet="css"): + supported_stylesheets = ("css", "less") + assets = dict( + js=[], + stylesheets=[] + ) + assets["js"] = [ + 'js/app/viewmodels/appearance.js', + 'js/app/viewmodels/connection.js', + 'js/app/viewmodels/control.js', + 'js/app/viewmodels/firstrun.js', + 'js/app/viewmodels/files.js', + 'js/app/viewmodels/loginstate.js', + 'js/app/viewmodels/navigation.js', + 'js/app/viewmodels/printerstate.js', + 'js/app/viewmodels/printerprofiles.js', + 'js/app/viewmodels/settings.js', + 'js/app/viewmodels/slicing.js', + 'js/app/viewmodels/temperature.js', + 'js/app/viewmodels/terminal.js', + 'js/app/viewmodels/users.js', + 'js/app/viewmodels/log.js', + 'js/app/viewmodels/usersettings.js' + ] + if enable_gcodeviewer: + assets["js"] += [ + 'js/app/viewmodels/gcode.js', + 'gcodeviewer/js/ui.js', + 'gcodeviewer/js/gCodeReader.js', + 'gcodeviewer/js/renderer.js' + ] + if enable_timelapse: + assets["js"].append('js/app/viewmodels/timelapse.js') + + if preferred_stylesheet == "less": + assets["stylesheets"].append(("less", 'less/octoprint.less')) + elif preferred_stylesheet == "css": + assets["stylesheets"].append(("css", 'css/octoprint.css')) + + asset_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.AssetPlugin) + for implementation in asset_plugins: + name = implementation._identifier + all_assets = implementation.get_assets() + + if "js" in all_assets: + for asset in all_assets["js"]: + assets["js"].append('plugin/{name}/{asset}'.format(**locals())) + + if preferred_stylesheet in all_assets: + for asset in all_assets[preferred_stylesheet]: + assets["stylesheets"].append((preferred_stylesheet, 'plugin/{name}/{asset}'.format(**locals()))) + else: + for stylesheet in supported_stylesheets: + if not stylesheet in all_assets: + continue + + for asset in all_assets[stylesheet]: + assets["stylesheets"].append((stylesheet, 'plugin/{name}/{asset}'.format(**locals()))) + break + + return assets diff --git a/src/octoprint/server/views.py b/src/octoprint/server/views.py index 131e70b2..ae30e26f 100644 --- a/src/octoprint/server/views.py +++ b/src/octoprint/server/views.py @@ -8,7 +8,7 @@ __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms import os from collections import defaultdict -from flask import request, g, url_for, make_response, render_template, send_from_directory +from flask import request, g, url_for, make_response, render_template, send_from_directory, redirect import octoprint.plugin @@ -491,19 +491,6 @@ def localeJs(locale, domain): @app.route("/plugin_assets//") def plugin_assets(name, filename): - asset_plugins = pluginManager.get_filtered_implementations(lambda p: p._identifier == name, octoprint.plugin.AssetPlugin) - - if not asset_plugins: - return make_response("Asset not found", 404) - - if len(asset_plugins) > 1: - return make_response("More than one asset provider for {name}, can't proceed".format(name=name), 500) - - asset_plugin = asset_plugins[0] - asset_folder = asset_plugin.get_asset_folder() - if asset_folder is None: - return make_response("Asset not found", 404) - - return send_from_directory(asset_folder, filename) + return redirect(url_for("plugin." + name + ".static", filename=filename)) diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 6d977027..e65e0037 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -155,7 +155,8 @@ default_settings = { "slicingProfiles": None, "printerProfiles": None, "scripts": None, - "translations": None + "translations": None, + "webassets": None }, "temperature": { "profiles": [ diff --git a/src/octoprint/templates/javascripts.jinja2 b/src/octoprint/templates/javascripts.jinja2 index e7ac54a6..ccfa4ae7 100644 --- a/src/octoprint/templates/javascripts.jinja2 +++ b/src/octoprint/templates/javascripts.jinja2 @@ -1,48 +1,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - +{% assets "js_libs" %} + +{% endassets %} - - -{% for url in assets["js"] %} - -{% endfor %} - - - - - - - - - - +{% assets "js_app" %} + +{% endassets %} {% if g.locale %} diff --git a/src/octoprint/templates/stylesheets.jinja2 b/src/octoprint/templates/stylesheets.jinja2 index 72aaf447..8516f823 100644 --- a/src/octoprint/templates/stylesheets.jinja2 +++ b/src/octoprint/templates/stylesheets.jinja2 @@ -1,20 +1,13 @@ - - - - - - - +{% assets "css_libs" %} + +{% endassets %} -{% set lessneeded=[] %} -{% for type, url in assets["stylesheets"] %} - {% if type == "css" %} - - {% elif type == "less" %} - {% do lessneeded.append(1) %} - - {% endif %} -{% endfor %} -{% if lessneeded %} - -{% endif %} +{% assets "css_app" %} + +{% endassets %} + +{% assets "less_app" %} + +{% endassets %} + +