From e8fe9da6026fb818982311ab87ab5abfc0f013d2 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 9 Apr 2014 16:07:59 +0200 Subject: [PATCH] Livereload css --- couchpotato/core/_base/clientscript.py | 129 +- libs/livereload/__init__.py | 16 + libs/livereload/_compat.py | 43 + libs/livereload/handlers.py | 209 +++ libs/livereload/livereload.js | 1055 ++++++++++++++ libs/livereload/server.py | 224 +++ libs/livereload/watcher.py | 132 ++ libs/scss/__init__.py | 1617 ++++++++++++++++++++++ libs/scss/__main__.py | 3 + libs/scss/_native.py | 262 ++++ libs/scss/config.py | 36 + libs/scss/cssdefs.py | 384 +++++ libs/scss/errors.py | 169 +++ libs/scss/expression.py | 905 ++++++++++++ libs/scss/functions/__init__.py | 25 + libs/scss/functions/compass/__init__.py | 2 + libs/scss/functions/compass/gradients.py | 400 ++++++ libs/scss/functions/compass/helpers.py | 654 +++++++++ libs/scss/functions/compass/images.py | 285 ++++ libs/scss/functions/compass/layouts.py | 346 +++++ libs/scss/functions/compass/sprites.py | 524 +++++++ libs/scss/functions/core.py | 784 +++++++++++ libs/scss/functions/extra.py | 471 +++++++ libs/scss/functions/library.py | 81 ++ libs/scss/rule.py | 524 +++++++ libs/scss/scss_meta.py | 67 + libs/scss/selector.py | 607 ++++++++ libs/scss/setup.py | 14 + libs/scss/src/_speedups.c | 554 ++++++++ libs/scss/src/block_locator.c | 547 ++++++++ libs/scss/src/block_locator.h | 52 + libs/scss/src/scanner.c | 463 +++++++ libs/scss/src/scanner.h | 68 + libs/scss/src/utils.h | 98 ++ libs/scss/tool.py | 403 ++++++ libs/scss/types.py | 1128 +++++++++++++++ libs/scss/util.py | 146 ++ 37 files changed, 13370 insertions(+), 57 deletions(-) create mode 100644 libs/livereload/__init__.py create mode 100644 libs/livereload/_compat.py create mode 100644 libs/livereload/handlers.py create mode 100644 libs/livereload/livereload.js create mode 100644 libs/livereload/server.py create mode 100644 libs/livereload/watcher.py create mode 100644 libs/scss/__init__.py create mode 100644 libs/scss/__main__.py create mode 100644 libs/scss/_native.py create mode 100644 libs/scss/config.py create mode 100644 libs/scss/cssdefs.py create mode 100644 libs/scss/errors.py create mode 100644 libs/scss/expression.py create mode 100644 libs/scss/functions/__init__.py create mode 100644 libs/scss/functions/compass/__init__.py create mode 100644 libs/scss/functions/compass/gradients.py create mode 100644 libs/scss/functions/compass/helpers.py create mode 100644 libs/scss/functions/compass/images.py create mode 100644 libs/scss/functions/compass/layouts.py create mode 100644 libs/scss/functions/compass/sprites.py create mode 100644 libs/scss/functions/core.py create mode 100644 libs/scss/functions/extra.py create mode 100644 libs/scss/functions/library.py create mode 100644 libs/scss/rule.py create mode 100644 libs/scss/scss_meta.py create mode 100644 libs/scss/selector.py create mode 100644 libs/scss/setup.py create mode 100644 libs/scss/src/_speedups.c create mode 100644 libs/scss/src/block_locator.c create mode 100644 libs/scss/src/block_locator.h create mode 100644 libs/scss/src/scanner.c create mode 100644 libs/scss/src/scanner.h create mode 100644 libs/scss/src/utils.h create mode 100644 libs/scss/tool.py create mode 100644 libs/scss/types.py create mode 100644 libs/scss/util.py diff --git a/couchpotato/core/_base/clientscript.py b/couchpotato/core/_base/clientscript.py index e5dbd8f7..58d1ffdd 100644 --- a/couchpotato/core/_base/clientscript.py +++ b/couchpotato/core/_base/clientscript.py @@ -1,15 +1,14 @@ import os import re -import traceback +import time -from couchpotato.core.event import addEvent +from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import ss from couchpotato.core.helpers.variable import tryInt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env -from minify.cssmin import cssmin -from minify.jsmin import jsmin +from scss import Scss from tornado.web import StaticFileHandler @@ -22,7 +21,7 @@ class ClientScript(Plugin): core_static = { 'style': [ - 'style/main.css', + 'style/main.scss', 'style/uniform.generic.css', 'style/uniform.css', 'style/settings.css', @@ -54,8 +53,8 @@ class ClientScript(Plugin): ], } - urls = {'style': {}, 'script': {}} - minified = {'style': {}, 'script': {}} + watcher = None + paths = {'style': {}, 'script': {}} comment = { 'style': '/*** %s:%d ***/\n', @@ -74,11 +73,26 @@ class ClientScript(Plugin): addEvent('clientscript.get_styles', self.getStyles) addEvent('clientscript.get_scripts', self.getScripts) - if not Env.get('dev'): - addEvent('app.load', self.minify) + addEvent('app.load', self.livereload, priority = 1) + addEvent('app.load', self.compile) self.addCore() + def livereload(self): + + if Env.get('dev'): + from livereload import Server + from livereload.watcher import Watcher + + self.livereload_server = Server() + self.livereload_server.watch('%s/minified/*.css' % Env.get('cache_dir')) + self.livereload_server.watch('%s/*.css' % os.path.join(Env.get('app_dir'), 'couchpotato', 'static', 'style')) + + self.watcher = Watcher() + fireEvent('schedule.interval', 'livereload.watcher', self.watcher.examine, seconds = .5) + + self.livereload_server.serve(port = 35729) + def addCore(self): for static_type in self.core_static: @@ -91,7 +105,7 @@ class ClientScript(Plugin): else: self.registerStyle(core_url, file_path, position = 'front') - def minify(self): + def compile(self): # Create cache dir cache = Env.get('cache_dir') @@ -105,44 +119,62 @@ class ClientScript(Plugin): positions = self.paths.get(file_type, {}) for position in positions: files = positions.get(position) - self._minify(file_type, files, position, position + '.' + ext) + self._compile(file_type, files, position, position + '.' + ext) - def _minify(self, file_type, files, position, out): + def _compile(self, file_type, paths, position, out): cache = Env.get('cache_dir') out_name = out - out = os.path.join(cache, 'minified', out_name) + minified_dir = os.path.join(cache, 'minified') + + data_combined = '' raw = [] - for file_path in files: + for file_path in paths: + f = open(file_path, 'r').read() - if file_type == 'script': - data = jsmin(f) - else: - data = self.prefix(f) - data = cssmin(data) - data = data.replace('../images/', '../static/images/') - data = data.replace('../fonts/', '../static/fonts/') - data = data.replace('../../static/', '../static/') # Replace inside plugins + # Compile scss + if file_path[-5:] == '.scss': - raw.append({'file': file_path, 'date': int(os.path.getmtime(file_path)), 'data': data}) + # Compile to css + compiler = Scss(live_errors = True, search_paths = [os.path.dirname(file_path)]) + f = compiler.compile(f) + + # Reload watcher + if Env.get('dev'): + self.watcher.watch(file_path, self.compile) + + url_path = paths[file_path].get('original_url') + compiled_file_name = position + '_%s.css' % url_path.replace('/', '_').split('.scss')[0] + compiled_file_path = os.path.join(minified_dir, compiled_file_name) + self.createFile(compiled_file_path, f.strip()) + + # Remove scss path + paths[file_path]['url'] = 'minified/%s?%s' % (compiled_file_name, tryInt(time.time())) + + if not Env.get('dev'): + + if file_type == 'script': + data = f + else: + data = self.prefix(f) + data = data.replace('../images/', '../static/images/') + data = data.replace('../fonts/', '../static/fonts/') + data = data.replace('../../static/', '../static/') # Replace inside plugins + + data_combined += self.comment.get(file_type) % (ss(file_path), int(os.path.getmtime(file_path))) + data_combined += data + '\n\n' + + del paths[file_path] # Combine all files together with some comments - data = '' - for r in raw: - data += self.comment.get(file_type) % (ss(r.get('file')), r.get('date')) - data += r.get('data') + '\n\n' + if not Env.get('dev'): - self.createFile(out, data.strip()) + self.createFile(os.path.join(minified_dir, out_name), data_combined.strip()) - if not self.minified.get(file_type): - self.minified[file_type] = {} - if not self.minified[file_type].get(position): - self.minified[file_type][position] = [] - - minified_url = 'minified/%s?%s' % (out_name, tryInt(os.path.getmtime(out))) - self.minified[file_type][position].append(minified_url) + minified_url = 'minified/%s?%s' % (out_name, tryInt(os.path.getmtime(out))) + self.minified[file_type][position].append(minified_url) def getStyles(self, *args, **kwargs): return self.get('style', *args, **kwargs) @@ -150,22 +182,9 @@ class ClientScript(Plugin): def getScripts(self, *args, **kwargs): return self.get('script', *args, **kwargs) - def get(self, type, as_html = False, location = 'head'): - - data = '' if as_html else [] - - try: - try: - if not Env.get('dev'): - return self.minified[type][location] - except: - pass - - return self.urls[type][location] - except: - log.error('Error getting minified %s, %s: %s', (type, location, traceback.format_exc())) - - return data + def get(self, type, location = 'head'): + paths = self.paths[type][location] + return [paths[x].get('url', paths[x].get('original_url')) for x in paths] def registerStyle(self, api_path, file_path, position = 'head'): self.register(api_path, file_path, 'style', position) @@ -177,13 +196,9 @@ class ClientScript(Plugin): api_path = '%s?%s' % (api_path, tryInt(os.path.getmtime(file_path))) - if not self.urls[type].get(location): - self.urls[type][location] = [] - self.urls[type][location].append(api_path) - if not self.paths[type].get(location): - self.paths[type][location] = [] - self.paths[type][location].append(file_path) + self.paths[type][location] = {} + self.paths[type][location][file_path] = {'original_url': api_path} prefix_properties = ['border-radius', 'transform', 'transition', 'box-shadow'] prefix_tags = ['ms', 'moz', 'webkit'] diff --git a/libs/livereload/__init__.py b/libs/livereload/__init__.py new file mode 100644 index 00000000..f40c4271 --- /dev/null +++ b/libs/livereload/__init__.py @@ -0,0 +1,16 @@ +""" + livereload + ~~~~~~~~~~ + + A python version of livereload. + + :copyright: (c) 2013 by Hsiaoming Yang +""" + +__version__ = '2.2.0' +__author__ = 'Hsiaoming Yang ' +__homepage__ = 'https://github.com/lepture/python-livereload' + +from .server import Server, shell + +__all__ = ('Server', 'shell') diff --git a/libs/livereload/_compat.py b/libs/livereload/_compat.py new file mode 100644 index 00000000..284a569a --- /dev/null +++ b/libs/livereload/_compat.py @@ -0,0 +1,43 @@ +# coding: utf-8 +""" + livereload._compat + ~~~~~~~~~~~~~~~~~~ + + Compatible module for python2 and python3. + + :copyright: (c) 2013 by Hsiaoming Yang +""" + + +import sys +PY3 = sys.version_info[0] == 3 + +if PY3: + unicode_type = str + bytes_type = bytes + text_types = (str,) +else: + unicode_type = unicode + bytes_type = str + text_types = (str, unicode) + + +def to_unicode(value, encoding='utf-8'): + """Convert different types of objects to unicode.""" + if isinstance(value, unicode_type): + return value + + if isinstance(value, bytes_type): + return unicode_type(value, encoding=encoding) + + if isinstance(value, int): + return unicode_type(str(value)) + + return value + + +def to_bytes(value, encoding='utf-8'): + """Convert different types of objects to bytes.""" + if isinstance(value, bytes_type): + return value + return value.encode(encoding) diff --git a/libs/livereload/handlers.py b/libs/livereload/handlers.py new file mode 100644 index 00000000..bb86fa84 --- /dev/null +++ b/libs/livereload/handlers.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" + livereload.handlers + ~~~~~~~~~~~~~~~~~~~ + + HTTP and WebSocket handlers for livereload. + + :copyright: (c) 2013 by Hsiaoming Yang +""" + +import os +import time +import hashlib +import logging +import mimetypes +from tornado import ioloop +from tornado import escape +from tornado.websocket import WebSocketHandler +from tornado.web import RequestHandler +from tornado.util import ObjectDict +from ._compat import to_bytes + + +class LiveReloadHandler(WebSocketHandler): + waiters = set() + watcher = None + _last_reload_time = None + + def allow_draft76(self): + return True + + def on_close(self): + if self in LiveReloadHandler.waiters: + LiveReloadHandler.waiters.remove(self) + + def send_message(self, message): + if isinstance(message, dict): + message = escape.json_encode(message) + + try: + self.write_message(message) + except: + logging.error('Error sending message', exc_info=True) + + def poll_tasks(self): + filepath = self.watcher.examine() + if not filepath: + return + logging.info('File %s changed', filepath) + self.watch_tasks() + + def watch_tasks(self): + if time.time() - self._last_reload_time < 3: + # if you changed lot of files in one time + # it will refresh too many times + logging.info('ignore this reload action') + return + + logging.info('Reload %s waiters', len(self.waiters)) + + msg = { + 'command': 'reload', + 'path': self.watcher.filepath or '*', + 'liveCSS': True + } + + self._last_reload_time = time.time() + for waiter in LiveReloadHandler.waiters: + try: + waiter.write_message(msg) + except: + logging.error('Error sending message', exc_info=True) + LiveReloadHandler.waiters.remove(waiter) + + def on_message(self, message): + """Handshake with livereload.js + + 1. client send 'hello' + 2. server reply 'hello' + 3. client send 'info' + + http://feedback.livereload.com/knowledgebase/articles/86174-livereload-protocol + """ + message = ObjectDict(escape.json_decode(message)) + if message.command == 'hello': + handshake = {} + handshake['command'] = 'hello' + handshake['protocols'] = [ + 'http://livereload.com/protocols/official-7', + 'http://livereload.com/protocols/official-8', + 'http://livereload.com/protocols/official-9', + 'http://livereload.com/protocols/2.x-origin-version-negotiation', + 'http://livereload.com/protocols/2.x-remote-control' + ] + handshake['serverName'] = 'livereload-tornado' + self.send_message(handshake) + + if message.command == 'info' and 'url' in message: + logging.info('Browser Connected: %s' % message.url) + LiveReloadHandler.waiters.add(self) + + if not LiveReloadHandler._last_reload_time: + if not self.watcher._tasks: + logging.info('Watch current working directory') + self.watcher.watch(os.getcwd()) + + LiveReloadHandler._last_reload_time = time.time() + logging.info('Start watching changes') + if not self.watcher.start(self.poll_tasks): + ioloop.PeriodicCallback(self.poll_tasks, 800).start() + + +class LiveReloadJSHandler(RequestHandler): + def initialize(self, port): + self._port = port + + def get(self): + js = os.path.join( + os.path.abspath(os.path.dirname(__file__)), 'livereload.js', + ) + self.set_header('Content-Type', 'application/javascript') + with open(js, 'r') as f: + content = f.read() + content = content.replace('{{port}}', str(self._port)) + self.write(content) + + +class ForceReloadHandler(RequestHandler): + def get(self): + msg = { + 'command': 'reload', + 'path': self.get_argument('path', default=None) or '*', + 'liveCSS': True, + 'liveImg': True + } + for waiter in LiveReloadHandler.waiters: + try: + waiter.write_message(msg) + except: + logging.error('Error sending message', exc_info=True) + LiveReloadHandler.waiters.remove(waiter) + self.write('ok') + + +class StaticHandler(RequestHandler): + def initialize(self, root, fallback=None): + self._root = os.path.abspath(root) + self._fallback = fallback + + def filepath(self, url): + url = url.lstrip('/') + url = os.path.join(self._root, url) + + if url.endswith('/'): + url += 'index.html' + elif not os.path.exists(url) and not url.endswith('.html'): + url += '.html' + + if not os.path.isfile(url): + return None + return url + + def get(self, path='/'): + filepath = self.filepath(path) + if not filepath and path.endswith('/'): + rootdir = os.path.join(self._root, path.lstrip('/')) + return self.create_index(rootdir) + + if not filepath: + if self._fallback: + self._fallback(self.request) + self._finished = True + return + return self.send_error(404) + + mime_type, encoding = mimetypes.guess_type(filepath) + if not mime_type: + mime_type = 'text/html' + + self.mime_type = mime_type + self.set_header('Content-Type', mime_type) + + with open(filepath, 'r') as f: + data = f.read() + + hasher = hashlib.sha1() + hasher.update(to_bytes(data)) + self.set_header('Etag', '"%s"' % hasher.hexdigest()) + + ua = self.request.headers.get('User-Agent', 'bot').lower() + if mime_type == 'text/html' and 'msie' not in ua: + data = data.replace( + '', + '' + ) + self.write(data) + + def create_index(self, root): + files = os.listdir(root) + self.write('') diff --git a/libs/livereload/livereload.js b/libs/livereload/livereload.js new file mode 100644 index 00000000..491ddcc1 --- /dev/null +++ b/libs/livereload/livereload.js @@ -0,0 +1,1055 @@ +(function() { +var __customevents = {}, __protocol = {}, __connector = {}, __timer = {}, __options = {}, __reloader = {}, __livereload = {}, __less = {}, __startup = {}; + +// customevents +var CustomEvents; +CustomEvents = { + bind: function(element, eventName, handler) { + if (element.addEventListener) { + return element.addEventListener(eventName, handler, false); + } else if (element.attachEvent) { + element[eventName] = 1; + return element.attachEvent('onpropertychange', function(event) { + if (event.propertyName === eventName) { + return handler(); + } + }); + } else { + throw new Error("Attempt to attach custom event " + eventName + " to something which isn't a DOMElement"); + } + }, + fire: function(element, eventName) { + var event; + if (element.addEventListener) { + event = document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + return document.dispatchEvent(event); + } else if (element.attachEvent) { + if (element[eventName]) { + return element[eventName]++; + } + } else { + throw new Error("Attempt to fire custom event " + eventName + " on something which isn't a DOMElement"); + } + } +}; +__customevents.bind = CustomEvents.bind; +__customevents.fire = CustomEvents.fire; + +// protocol +var PROTOCOL_6, PROTOCOL_7, Parser, ProtocolError; +var __indexOf = Array.prototype.indexOf || function(item) { + for (var i = 0, l = this.length; i < l; i++) { + if (this[i] === item) return i; + } + return -1; +}; +__protocol.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official-6'; +__protocol.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official-7'; +__protocol.ProtocolError = ProtocolError = (function() { + function ProtocolError(reason, data) { + this.message = "LiveReload protocol error (" + reason + ") after receiving data: \"" + data + "\"."; + } + return ProtocolError; +})(); +__protocol.Parser = Parser = (function() { + function Parser(handlers) { + this.handlers = handlers; + this.reset(); + } + Parser.prototype.reset = function() { + return this.protocol = null; + }; + Parser.prototype.process = function(data) { + var command, message, options, _ref; + try { + if (!(this.protocol != null)) { + if (data.match(/^!!ver:([\d.]+)$/)) { + this.protocol = 6; + } else if (message = this._parseMessage(data, ['hello'])) { + if (!message.protocols.length) { + throw new ProtocolError("no protocols specified in handshake message"); + } else if (__indexOf.call(message.protocols, PROTOCOL_7) >= 0) { + this.protocol = 7; + } else if (__indexOf.call(message.protocols, PROTOCOL_6) >= 0) { + this.protocol = 6; + } else { + throw new ProtocolError("no supported protocols found"); + } + } + return this.handlers.connected(this.protocol); + } else if (this.protocol === 6) { + message = JSON.parse(data); + if (!message.length) { + throw new ProtocolError("protocol 6 messages must be arrays"); + } + command = message[0], options = message[1]; + if (command !== 'refresh') { + throw new ProtocolError("unknown protocol 6 command"); + } + return this.handlers.message({ + command: 'reload', + path: options.path, + liveCSS: (_ref = options.apply_css_live) != null ? _ref : true + }); + } else { + message = this._parseMessage(data, ['reload', 'alert']); + return this.handlers.message(message); + } + } catch (e) { + if (e instanceof ProtocolError) { + return this.handlers.error(e); + } else { + throw e; + } + } + }; + Parser.prototype._parseMessage = function(data, validCommands) { + var message, _ref; + try { + message = JSON.parse(data); + } catch (e) { + throw new ProtocolError('unparsable JSON', data); + } + if (!message.command) { + throw new ProtocolError('missing "command" key', data); + } + if (_ref = message.command, __indexOf.call(validCommands, _ref) < 0) { + throw new ProtocolError("invalid command '" + message.command + "', only valid commands are: " + (validCommands.join(', ')) + ")", data); + } + return message; + }; + return Parser; +})(); + +// connector +// Generated by CoffeeScript 1.3.3 +var Connector, PROTOCOL_6, PROTOCOL_7, Parser, Version, _ref; + +_ref = __protocol, Parser = _ref.Parser, PROTOCOL_6 = _ref.PROTOCOL_6, PROTOCOL_7 = _ref.PROTOCOL_7; + +Version = '2.0.8'; + +__connector.Connector = Connector = (function() { + + function Connector(options, WebSocket, Timer, handlers) { + var _this = this; + this.options = options; + this.WebSocket = WebSocket; + this.Timer = Timer; + this.handlers = handlers; + this._uri = "ws://" + this.options.host + ":" + this.options.port + "/livereload"; + this._nextDelay = this.options.mindelay; + this._connectionDesired = false; + this.protocol = 0; + this.protocolParser = new Parser({ + connected: function(protocol) { + _this.protocol = protocol; + _this._handshakeTimeout.stop(); + _this._nextDelay = _this.options.mindelay; + _this._disconnectionReason = 'broken'; + return _this.handlers.connected(protocol); + }, + error: function(e) { + _this.handlers.error(e); + return _this._closeOnError(); + }, + message: function(message) { + return _this.handlers.message(message); + } + }); + this._handshakeTimeout = new Timer(function() { + if (!_this._isSocketConnected()) { + return; + } + _this._disconnectionReason = 'handshake-timeout'; + return _this.socket.close(); + }); + this._reconnectTimer = new Timer(function() { + if (!_this._connectionDesired) { + return; + } + return _this.connect(); + }); + this.connect(); + } + + Connector.prototype._isSocketConnected = function() { + return this.socket && this.socket.readyState === this.WebSocket.OPEN; + }; + + Connector.prototype.connect = function() { + var _this = this; + this._connectionDesired = true; + if (this._isSocketConnected()) { + return; + } + this._reconnectTimer.stop(); + this._disconnectionReason = 'cannot-connect'; + this.protocolParser.reset(); + this.handlers.connecting(); + this.socket = new this.WebSocket(this._uri); + this.socket.onopen = function(e) { + return _this._onopen(e); + }; + this.socket.onclose = function(e) { + return _this._onclose(e); + }; + this.socket.onmessage = function(e) { + return _this._onmessage(e); + }; + return this.socket.onerror = function(e) { + return _this._onerror(e); + }; + }; + + Connector.prototype.disconnect = function() { + this._connectionDesired = false; + this._reconnectTimer.stop(); + if (!this._isSocketConnected()) { + return; + } + this._disconnectionReason = 'manual'; + return this.socket.close(); + }; + + Connector.prototype._scheduleReconnection = function() { + if (!this._connectionDesired) { + return; + } + if (!this._reconnectTimer.running) { + this._reconnectTimer.start(this._nextDelay); + return this._nextDelay = Math.min(this.options.maxdelay, this._nextDelay * 2); + } + }; + + Connector.prototype.sendCommand = function(command) { + if (this.protocol == null) { + return; + } + return this._sendCommand(command); + }; + + Connector.prototype._sendCommand = function(command) { + return this.socket.send(JSON.stringify(command)); + }; + + Connector.prototype._closeOnError = function() { + this._handshakeTimeout.stop(); + this._disconnectionReason = 'error'; + return this.socket.close(); + }; + + Connector.prototype._onopen = function(e) { + var hello; + this.handlers.socketConnected(); + this._disconnectionReason = 'handshake-failed'; + hello = { + command: 'hello', + protocols: [PROTOCOL_6, PROTOCOL_7] + }; + hello.ver = Version; + if (this.options.ext) { + hello.ext = this.options.ext; + } + if (this.options.extver) { + hello.extver = this.options.extver; + } + if (this.options.snipver) { + hello.snipver = this.options.snipver; + } + this._sendCommand(hello); + return this._handshakeTimeout.start(this.options.handshake_timeout); + }; + + Connector.prototype._onclose = function(e) { + this.protocol = 0; + this.handlers.disconnected(this._disconnectionReason, this._nextDelay); + return this._scheduleReconnection(); + }; + + Connector.prototype._onerror = function(e) {}; + + Connector.prototype._onmessage = function(e) { + return this.protocolParser.process(e.data); + }; + + return Connector; + +})(); + +// timer +var Timer; +var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; +__timer.Timer = Timer = (function() { + function Timer(func) { + this.func = func; + this.running = false; + this.id = null; + this._handler = __bind(function() { + this.running = false; + this.id = null; + return this.func(); + }, this); + } + Timer.prototype.start = function(timeout) { + if (this.running) { + clearTimeout(this.id); + } + this.id = setTimeout(this._handler, timeout); + return this.running = true; + }; + Timer.prototype.stop = function() { + if (this.running) { + clearTimeout(this.id); + this.running = false; + return this.id = null; + } + }; + return Timer; +})(); +Timer.start = function(timeout, func) { + return setTimeout(func, timeout); +}; + +// options +var Options; +__options.Options = Options = (function() { + function Options() { + this.host = null; + this.port = {{port}}; + this.snipver = null; + this.ext = null; + this.extver = null; + this.mindelay = 1000; + this.maxdelay = 60000; + this.handshake_timeout = 5000; + } + Options.prototype.set = function(name, value) { + switch (typeof this[name]) { + case 'undefined': + break; + case 'number': + return this[name] = +value; + default: + return this[name] = value; + } + }; + return Options; +})(); +Options.extract = function(document) { + var element, keyAndValue, m, mm, options, pair, src, _i, _j, _len, _len2, _ref, _ref2; + _ref = document.getElementsByTagName('script'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + element = _ref[_i]; + if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) { + options = new Options(); + if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) { + options.host = mm[1]; + if (mm[2]) { + options.port = parseInt(mm[2], 10); + } + } + if (m[2]) { + _ref2 = m[2].split('&'); + for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) { + pair = _ref2[_j]; + if ((keyAndValue = pair.split('=')).length > 1) { + options.set(keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')); + } + } + } + return options; + } + } + return null; +}; + +// reloader +// Generated by CoffeeScript 1.3.1 +(function() { + var IMAGE_STYLES, Reloader, numberOfMatchingSegments, pathFromUrl, pathsMatch, pickBestMatch, splitUrl; + + splitUrl = function(url) { + var hash, index, params; + if ((index = url.indexOf('#')) >= 0) { + hash = url.slice(index); + url = url.slice(0, index); + } else { + hash = ''; + } + if ((index = url.indexOf('?')) >= 0) { + params = url.slice(index); + url = url.slice(0, index); + } else { + params = ''; + } + return { + url: url, + params: params, + hash: hash + }; + }; + + pathFromUrl = function(url) { + var path; + url = splitUrl(url).url; + if (url.indexOf('file://') === 0) { + path = url.replace(/^file:\/\/(localhost)?/, ''); + } else { + path = url.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//, '/'); + } + return decodeURIComponent(path); + }; + + pickBestMatch = function(path, objects, pathFunc) { + var bestMatch, object, score, _i, _len; + bestMatch = { + score: 0 + }; + for (_i = 0, _len = objects.length; _i < _len; _i++) { + object = objects[_i]; + score = numberOfMatchingSegments(path, pathFunc(object)); + if (score > bestMatch.score) { + bestMatch = { + object: object, + score: score + }; + } + } + if (bestMatch.score > 0) { + return bestMatch; + } else { + return null; + } + }; + + numberOfMatchingSegments = function(path1, path2) { + var comps1, comps2, eqCount, len; + path1 = path1.replace(/^\/+/, '').toLowerCase(); + path2 = path2.replace(/^\/+/, '').toLowerCase(); + if (path1 === path2) { + return 10000; + } + comps1 = path1.split('/').reverse(); + comps2 = path2.split('/').reverse(); + len = Math.min(comps1.length, comps2.length); + eqCount = 0; + while (eqCount < len && comps1[eqCount] === comps2[eqCount]) { + ++eqCount; + } + return eqCount; + }; + + pathsMatch = function(path1, path2) { + return numberOfMatchingSegments(path1, path2) > 0; + }; + + IMAGE_STYLES = [ + { + selector: 'background', + styleNames: ['backgroundImage'] + }, { + selector: 'border', + styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] + } + ]; + + __reloader.Reloader = Reloader = (function() { + + Reloader.name = 'Reloader'; + + function Reloader(window, console, Timer) { + this.window = window; + this.console = console; + this.Timer = Timer; + this.document = this.window.document; + this.importCacheWaitPeriod = 200; + this.plugins = []; + } + + Reloader.prototype.addPlugin = function(plugin) { + return this.plugins.push(plugin); + }; + + Reloader.prototype.analyze = function(callback) { + return results; + }; + + Reloader.prototype.reload = function(path, options) { + var plugin, _base, _i, _len, _ref; + this.options = options; + if ((_base = this.options).stylesheetReloadTimeout == null) { + _base.stylesheetReloadTimeout = 15000; + } + _ref = this.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plugin = _ref[_i]; + if (plugin.reload && plugin.reload(path, options)) { + return; + } + } + if (options.liveCSS) { + if (path.match(/\.css$/i)) { + if (this.reloadStylesheet(path)) { + return; + } + } + } + if (options.liveImg) { + if (path.match(/\.(jpe?g|png|gif)$/i)) { + this.reloadImages(path); + return; + } + } + return this.reloadPage(); + }; + + Reloader.prototype.reloadPage = function() { + return this.window.document.location.reload(); + }; + + Reloader.prototype.reloadImages = function(path) { + var expando, img, selector, styleNames, styleSheet, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3, _results; + expando = this.generateUniqueString(); + _ref = this.document.images; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + img = _ref[_i]; + if (pathsMatch(path, pathFromUrl(img.src))) { + img.src = this.generateCacheBustUrl(img.src, expando); + } + } + if (this.document.querySelectorAll) { + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + _ref1 = IMAGE_STYLES[_j], selector = _ref1.selector, styleNames = _ref1.styleNames; + _ref2 = this.document.querySelectorAll("[style*=" + selector + "]"); + for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { + img = _ref2[_k]; + this.reloadStyleImages(img.style, styleNames, path, expando); + } + } + } + if (this.document.styleSheets) { + _ref3 = this.document.styleSheets; + _results = []; + for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) { + styleSheet = _ref3[_l]; + _results.push(this.reloadStylesheetImages(styleSheet, path, expando)); + } + return _results; + } + }; + + Reloader.prototype.reloadStylesheetImages = function(styleSheet, path, expando) { + var rule, rules, styleNames, _i, _j, _len, _len1; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (!rules) { + return; + } + for (_i = 0, _len = rules.length; _i < _len; _i++) { + rule = rules[_i]; + switch (rule.type) { + case CSSRule.IMPORT_RULE: + this.reloadStylesheetImages(rule.styleSheet, path, expando); + break; + case CSSRule.STYLE_RULE: + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + styleNames = IMAGE_STYLES[_j].styleNames; + this.reloadStyleImages(rule.style, styleNames, path, expando); + } + break; + case CSSRule.MEDIA_RULE: + this.reloadStylesheetImages(rule, path, expando); + } + } + }; + + Reloader.prototype.reloadStyleImages = function(style, styleNames, path, expando) { + var newValue, styleName, value, _i, _len, + _this = this; + for (_i = 0, _len = styleNames.length; _i < _len; _i++) { + styleName = styleNames[_i]; + value = style[styleName]; + if (typeof value === 'string') { + newValue = value.replace(/\burl\s*\(([^)]*)\)/, function(match, src) { + if (pathsMatch(path, pathFromUrl(src))) { + return "url(" + (_this.generateCacheBustUrl(src, expando)) + ")"; + } else { + return match; + } + }); + if (newValue !== value) { + style[styleName] = newValue; + } + } + } + }; + + Reloader.prototype.reloadStylesheet = function(path) { + var imported, link, links, match, style, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, + _this = this; + links = (function() { + var _i, _len, _ref, _results; + _ref = this.document.getElementsByTagName('link'); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + link = _ref[_i]; + if (link.rel === 'stylesheet' && !link.__LiveReload_pendingRemoval) { + _results.push(link); + } + } + return _results; + }).call(this); + imported = []; + _ref = this.document.getElementsByTagName('style'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + style = _ref[_i]; + if (style.sheet) { + this.collectImportedStylesheets(style, style.sheet, imported); + } + } + for (_j = 0, _len1 = links.length; _j < _len1; _j++) { + link = links[_j]; + this.collectImportedStylesheets(link, link.sheet, imported); + } + if (this.window.StyleFix && this.document.querySelectorAll) { + _ref1 = this.document.querySelectorAll('style[data-href]'); + for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { + style = _ref1[_k]; + links.push(style); + } + } + this.console.log("LiveReload found " + links.length + " LINKed stylesheets, " + imported.length + " @imported stylesheets"); + match = pickBestMatch(path, links.concat(imported), function(l) { + return pathFromUrl(_this.linkHref(l)); + }); + if (match) { + if (match.object.rule) { + this.console.log("LiveReload is reloading imported stylesheet: " + match.object.href); + this.reattachImportedRule(match.object); + } else { + this.console.log("LiveReload is reloading stylesheet: " + (this.linkHref(match.object))); + this.reattachStylesheetLink(match.object); + } + } else { + this.console.log("LiveReload will reload all stylesheets because path '" + path + "' did not match any specific one"); + for (_l = 0, _len3 = links.length; _l < _len3; _l++) { + link = links[_l]; + this.reattachStylesheetLink(link); + } + } + return true; + }; + + Reloader.prototype.collectImportedStylesheets = function(link, styleSheet, result) { + var index, rule, rules, _i, _len; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (rules && rules.length) { + for (index = _i = 0, _len = rules.length; _i < _len; index = ++_i) { + rule = rules[index]; + switch (rule.type) { + case CSSRule.CHARSET_RULE: + continue; + case CSSRule.IMPORT_RULE: + result.push({ + link: link, + rule: rule, + index: index, + href: rule.href + }); + this.collectImportedStylesheets(link, rule.styleSheet, result); + break; + default: + break; + } + } + } + }; + + Reloader.prototype.waitUntilCssLoads = function(clone, func) { + var callbackExecuted, executeCallback, poll, + _this = this; + callbackExecuted = false; + executeCallback = function() { + if (callbackExecuted) { + return; + } + callbackExecuted = true; + return func(); + }; + clone.onload = function() { + console.log("onload!"); + _this.knownToSupportCssOnLoad = true; + return executeCallback(); + }; + if (!this.knownToSupportCssOnLoad) { + (poll = function() { + if (clone.sheet) { + console.log("polling!"); + return executeCallback(); + } else { + return _this.Timer.start(50, poll); + } + })(); + } + return this.Timer.start(this.options.stylesheetReloadTimeout, executeCallback); + }; + + Reloader.prototype.linkHref = function(link) { + return link.href || link.getAttribute('data-href'); + }; + + Reloader.prototype.reattachStylesheetLink = function(link) { + var clone, parent, + _this = this; + if (link.__LiveReload_pendingRemoval) { + return; + } + link.__LiveReload_pendingRemoval = true; + if (link.tagName === 'STYLE') { + clone = this.document.createElement('link'); + clone.rel = 'stylesheet'; + clone.media = link.media; + clone.disabled = link.disabled; + } else { + clone = link.cloneNode(false); + } + clone.href = this.generateCacheBustUrl(this.linkHref(link)); + parent = link.parentNode; + if (parent.lastChild === link) { + parent.appendChild(clone); + } else { + parent.insertBefore(clone, link.nextSibling); + } + return this.waitUntilCssLoads(clone, function() { + var additionalWaitingTime; + if (/AppleWebKit/.test(navigator.userAgent)) { + additionalWaitingTime = 5; + } else { + additionalWaitingTime = 200; + } + return _this.Timer.start(additionalWaitingTime, function() { + var _ref; + if (!link.parentNode) { + return; + } + link.parentNode.removeChild(link); + clone.onreadystatechange = null; + return (_ref = _this.window.StyleFix) != null ? _ref.link(clone) : void 0; + }); + }); + }; + + Reloader.prototype.reattachImportedRule = function(_arg) { + var href, index, link, media, newRule, parent, rule, tempLink, + _this = this; + rule = _arg.rule, index = _arg.index, link = _arg.link; + parent = rule.parentStyleSheet; + href = this.generateCacheBustUrl(rule.href); + media = rule.media.length ? [].join.call(rule.media, ', ') : ''; + newRule = "@import url(\"" + href + "\") " + media + ";"; + rule.__LiveReload_newHref = href; + tempLink = this.document.createElement("link"); + tempLink.rel = 'stylesheet'; + tempLink.href = href; + tempLink.__LiveReload_pendingRemoval = true; + if (link.parentNode) { + link.parentNode.insertBefore(tempLink, link); + } + return this.Timer.start(this.importCacheWaitPeriod, function() { + if (tempLink.parentNode) { + tempLink.parentNode.removeChild(tempLink); + } + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + parent.deleteRule(index + 1); + rule = parent.cssRules[index]; + rule.__LiveReload_newHref = href; + return _this.Timer.start(_this.importCacheWaitPeriod, function() { + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + return parent.deleteRule(index + 1); + }); + }); + }; + + Reloader.prototype.generateUniqueString = function() { + return 'livereload=' + Date.now(); + }; + + Reloader.prototype.generateCacheBustUrl = function(url, expando) { + var hash, oldParams, params, _ref; + if (expando == null) { + expando = this.generateUniqueString(); + } + _ref = splitUrl(url), url = _ref.url, hash = _ref.hash, oldParams = _ref.params; + if (this.options.overrideURL) { + if (url.indexOf(this.options.serverURL) < 0) { + url = this.options.serverURL + this.options.overrideURL + "?url=" + encodeURIComponent(url); + } + } + params = oldParams.replace(/(\?|&)livereload=(\d+)/, function(match, sep) { + return "" + sep + expando; + }); + if (params === oldParams) { + if (oldParams.length === 0) { + params = "?" + expando; + } else { + params = "" + oldParams + "&" + expando; + } + } + return url + params + hash; + }; + + return Reloader; + + })(); + +}).call(this); + +// livereload +var Connector, LiveReload, Options, Reloader, Timer; + +Connector = __connector.Connector; + +Timer = __timer.Timer; + +Options = __options.Options; + +Reloader = __reloader.Reloader; + +__livereload.LiveReload = LiveReload = (function() { + + function LiveReload(window) { + var _this = this; + this.window = window; + this.listeners = {}; + this.plugins = []; + this.pluginIdentifiers = {}; + this.console = this.window.location.href.match(/LR-verbose/) && this.window.console && this.window.console.log && this.window.console.error ? this.window.console : { + log: function() {}, + error: function() {} + }; + if (!(this.WebSocket = this.window.WebSocket || this.window.MozWebSocket)) { + console.error("LiveReload disabled because the browser does not seem to support web sockets"); + return; + } + if (!(this.options = Options.extract(this.window.document))) { + console.error("LiveReload disabled because it could not find its own ' + ) + + if status_code != 304: + if "content-length" not in header_set: + headers.append(("Content-Length", str(len(body)))) + if "content-type" not in header_set: + headers.append(("Content-Type", "text/html; charset=UTF-8")) + if "server" not in header_set: + headers.append(("Server", "livereload-tornado")) + + parts = [escape.utf8("HTTP/1.1 " + data["status"] + "\r\n")] + for key, value in headers: + if key.lower() == 'content-length': + value = str(len(body)) + parts.append( + escape.utf8(key) + b": " + escape.utf8(value) + b"\r\n" + ) + parts.append(b"\r\n") + parts.append(body) + request.write(b"".join(parts)) + request.finish() + self._log(status_code, request) + + +class Server(object): + """Livereload server interface. + + Initialize a server and watch file changes:: + + server = Server(wsgi_app) + server.serve() + + :param app: a wsgi application instance + :param watcher: A Watcher instance, you don't have to initialize + it by yourself. Under Linux, you will want to install + pyinotify and use INotifyWatcher() to avoid wasted + CPU usage. + """ + def __init__(self, app=None, watcher=None): + self.app = app + self.port = 5500 + self.root = None + if not watcher: + watcher = Watcher() + self.watcher = watcher + + def watch(self, filepath, func=None): + """Add the given filepath for watcher list. + + Once you have intialized a server, watch file changes before + serve the server:: + + server.watch('static/*.stylus', 'make static') + def alert(): + print('foo') + server.watch('foo.txt', alert) + server.serve() + + :param filepath: files to be watched, it can be a filepath, + a directory, or a glob pattern + :param func: the function to be called, it can be a string of + shell command, or any callable object without + parameters + """ + if isinstance(func, text_types): + func = shell(func) + + self.watcher.watch(filepath, func) + + def application(self, debug=True): + LiveReloadHandler.watcher = self.watcher + handlers = [ + (r'/livereload', LiveReloadHandler), + (r'/forcereload', ForceReloadHandler), + (r'/livereload.js', LiveReloadJSHandler, dict(port=self.port)), + ] + + if self.app: + self.app = WSGIWrapper(self.app) + handlers.append( + (r'.*', FallbackHandler, dict(fallback=self.app)) + ) + else: + handlers.append( + (r'(.*)', StaticHandler, dict(root=self.root or '.')), + ) + return Application(handlers=handlers, debug=debug) + + def serve(self, port=None, host=None, root=None, debug=True, open_url=False): + """Start serve the server with the given port. + + :param port: serve on this port, default is 5500 + :param host: serve on this hostname, default is 0.0.0.0 + :param root: serve static on this root directory + :param open_url: open system browser + """ + if root: + self.root = root + if port: + self.port = port + if host is None: + host = '' + + self.application(debug=debug).listen(self.port, address=host) + logging.getLogger().setLevel(logging.INFO) + + host = host or '127.0.0.1' + print('Serving on %s:%s' % (host, self.port)) + + # Async open web browser after 5 sec timeout + if open_url: + def opener(): + time.sleep(5) + webbrowser.open('http://%s:%s' % (host, self.port)) + threading.Thread(target=opener).start() + + try: + IOLoop.instance().start() + except KeyboardInterrupt: + print('Shutting down...') diff --git a/libs/livereload/watcher.py b/libs/livereload/watcher.py new file mode 100644 index 00000000..2f15cb17 --- /dev/null +++ b/libs/livereload/watcher.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +""" + livereload.watcher + ~~~~~~~~~~~~~~~~~~ + + A file watch management for LiveReload Server. + + :copyright: (c) 2013 by Hsiaoming Yang +""" + +import os +import glob +import time + + +class Watcher(object): + """A file watcher registery.""" + def __init__(self): + self._tasks = {} + self._mtimes = {} + + # filepath that is changed + self.filepath = None + self._start = time.time() + + def ignore(self, filename): + """Ignore a given filename or not.""" + _, ext = os.path.splitext(filename) + return ext in ['.pyc', '.pyo', '.o', '.swp'] + + def watch(self, path, func=None): + """Add a task to watcher.""" + self._tasks[path] = func + + def start(self, callback): + """Start the watcher running, calling callback when changes are observed. If this returns False, + regular polling will be used.""" + return False + + def examine(self): + """Check if there are changes, if true, run the given task.""" + # clean filepath + self.filepath = None + for path in self._tasks: + if self.is_changed(path): + func = self._tasks[path] + # run function + func and func() + return self.filepath + + def is_changed(self, path): + if os.path.isfile(path): + return self.is_file_changed(path) + elif os.path.isdir(path): + return self.is_folder_changed(path) + return self.is_glob_changed(path) + + def is_file_changed(self, path): + if not os.path.isfile(path): + return False + + if self.ignore(path): + return False + + mtime = os.path.getmtime(path) + + if path not in self._mtimes: + self._mtimes[path] = mtime + self.filepath = path + return mtime > self._start + + if self._mtimes[path] != mtime: + self._mtimes[path] = mtime + self.filepath = path + return True + + self._mtimes[path] = mtime + return False + + def is_folder_changed(self, path): + for root, dirs, files in os.walk(path, followlinks=True): + if '.git' in dirs: + dirs.remove('.git') + if '.hg' in dirs: + dirs.remove('.hg') + if '.svn' in dirs: + dirs.remove('.svn') + if '.cvs' in dirs: + dirs.remove('.cvs') + + for f in files: + if self.is_file_changed(os.path.join(root, f)): + return True + return False + + def is_glob_changed(self, path): + for f in glob.glob(path): + if self.is_file_changed(f): + return True + return False + + +class INotifyWatcher(Watcher): + def __init__(self): + Watcher.__init__(self) + + import pyinotify + self.wm = pyinotify.WatchManager() + self.notifier = None + self.callback = None + + def watch(self, path, func=None): + import pyinotify + flag = pyinotify.IN_CREATE | pyinotify.IN_DELETE | pyinotify.IN_MODIFY + self.wm.add_watch(path, flag, rec=True, do_glob=True, auto_add=True) + Watcher.watch(self, path, func) + + def inotify_event(self, event): + self.callback() + + def start(self, callback): + if not self.notifier: + self.callback = callback + + import pyinotify + from tornado import ioloop + self.notifier = pyinotify.TornadoAsyncNotifier( + self.wm, ioloop.IOLoop.instance(), + default_proc_fun=self.inotify_event + ) + callback() + return True diff --git a/libs/scss/__init__.py b/libs/scss/__init__.py new file mode 100644 index 00000000..00a366fa --- /dev/null +++ b/libs/scss/__init__.py @@ -0,0 +1,1617 @@ +#!/usr/bin/env python +#-*- coding: utf-8 -*- +""" +pyScss, a Scss compiler for Python + +@author German M. Bravo (Kronuz) +@version 1.2.0 alpha +@see https://github.com/Kronuz/pyScss +@copyright (c) 2012-2013 German M. Bravo (Kronuz) +@license MIT License + http://www.opensource.org/licenses/mit-license.php + +pyScss compiles Scss, a superset of CSS that is more powerful, elegant and +easier to maintain than plain-vanilla CSS. The library acts as a CSS source code +preprocesor which allows you to use variables, nested rules, mixins, andhave +inheritance of rules, all with a CSS-compatible syntax which the preprocessor +then compiles to standard CSS. + +Scss, as an extension of CSS, helps keep large stylesheets well-organized. It +borrows concepts and functionality from projects such as OOCSS and other similar +frameworks like as Sass. It's build on top of the original PHP xCSS codebase +structure but it's been completely rewritten, many bugs have been fixed and it +has been extensively extended to support almost the full range of Sass' Scss +syntax and functionality. + +Bits of code in pyScss come from various projects: +Compass: + (c) 2009 Christopher M. Eppstein + http://compass-style.org/ +Sass: + (c) 2006-2009 Hampton Catlin and Nathan Weizenbaum + http://sass-lang.com/ +xCSS: + (c) 2010 Anton Pawlik + http://xcss.antpaw.org/docs/ + +""" +from __future__ import absolute_import +from __future__ import print_function + +from scss.scss_meta import BUILD_INFO, PROJECT, VERSION, REVISION, URL, AUTHOR, AUTHOR_EMAIL, LICENSE + +__project__ = PROJECT +__version__ = VERSION +__author__ = AUTHOR + ' <' + AUTHOR_EMAIL + '>' +__license__ = LICENSE + + +from collections import defaultdict +import glob +from itertools import product +import logging +import os.path +import re +import sys + +import six + +from scss import config +from scss.cssdefs import ( + SEPARATOR, + _ml_comment_re, _sl_comment_re, + _escape_chars_re, + _spaces_re, _expand_rules_space_re, _collapse_properties_space_re, + _strings_re, _prop_split_re, +) +from scss.errors import SassError +from scss.expression import Calculator +from scss.functions import ALL_BUILTINS_LIBRARY +from scss.functions.compass.sprites import sprite_map +from scss.rule import Namespace, SassRule, UnparsedBlock +from scss.types import Boolean, List, Null, Number, String, Undefined +from scss.util import dequote, normalize_var, print_timing # profile + +log = logging.getLogger(__name__) + +################################################################################ +# Load C acceleration modules +locate_blocks = None +try: + from scss._speedups import locate_blocks +except ImportError: + import warnings + warnings.warn( + "Scanning acceleration disabled (_speedups not found)!", + RuntimeWarning + ) + from scss._native import locate_blocks + +################################################################################ + + +_safe_strings = { + '^doubleslash^': '//', + '^bigcopen^': '/*', + '^bigcclose^': '*/', + '^doubledot^': ':', + '^semicolon^': ';', + '^curlybracketopen^': '{', + '^curlybracketclosed^': '}', +} +_reverse_safe_strings = dict((v, k) for k, v in _safe_strings.items()) +_safe_strings_re = re.compile('|'.join(map(re.escape, _safe_strings))) +_reverse_safe_strings_re = re.compile('|'.join(map(re.escape, _reverse_safe_strings))) + +_default_scss_vars = { + '$BUILD-INFO': String.unquoted(BUILD_INFO), + '$PROJECT': String.unquoted(PROJECT), + '$VERSION': String.unquoted(VERSION), + '$REVISION': String.unquoted(REVISION), + '$URL': String.unquoted(URL), + '$AUTHOR': String.unquoted(AUTHOR), + '$AUTHOR-EMAIL': String.unquoted(AUTHOR_EMAIL), + '$LICENSE': String.unquoted(LICENSE), + + # unsafe chars will be hidden as vars + '$--doubleslash': String.unquoted('//'), + '$--bigcopen': String.unquoted('/*'), + '$--bigcclose': String.unquoted('*/'), + '$--doubledot': String.unquoted(':'), + '$--semicolon': String.unquoted(';'), + '$--curlybracketopen': String.unquoted('{'), + '$--curlybracketclosed': String.unquoted('}'), + + # shortcuts (it's "a hidden feature" for now) + 'bg:': String.unquoted('background:'), + 'bgc:': String.unquoted('background-color:'), +} + + +################################################################################ + + +class SourceFile(object): + def __init__(self, filename, contents, parent_dir='.', is_string=False, is_sass=None, line_numbers=True, line_strip=True): + self.filename = filename + self.sass = filename.endswith('.sass') if is_sass is None else is_sass + self.line_numbers = line_numbers + self.line_strip = line_strip + self.contents = self.prepare_source(contents) + self.parent_dir = os.path.realpath(parent_dir) + self.is_string = is_string + + def __repr__(self): + return "" % ( + self.filename, + id(self), + ) + + @classmethod + def from_filename(cls, fn, filename=None, is_sass=None, line_numbers=True): + if filename is None: + _, filename = os.path.split(fn) + + with open(fn) as f: + contents = f.read() + + return cls(filename, contents, is_sass=is_sass, line_numbers=line_numbers) + + @classmethod + def from_string(cls, string, filename=None, is_sass=None, line_numbers=True): + if filename is None: + filename = "" % string[:50] + + return cls(filename, string, is_string=True, is_sass=is_sass, line_numbers=line_numbers) + + def parse_scss_line(self, line_no, line, state): + ret = '' + + if line is None: + line = '' + + line = state['line_buffer'] + line.rstrip() # remove EOL character + + if line and line[-1] == '\\': + state['line_buffer'] = line[:-1] + return '' + else: + state['line_buffer'] = '' + + output = state['prev_line'] + if self.line_strip: + output = output.strip() + output_line_no = state['prev_line_no'] + + state['prev_line'] = line + state['prev_line_no'] = line_no + + if output: + if self.line_numbers: + output = str(output_line_no + 1) + SEPARATOR + output + output += '\n' + ret += output + + return ret + + def parse_sass_line(self, line_no, line, state): + ret = '' + + if line is None: + line = '' + + line = state['line_buffer'] + line.rstrip() # remove EOL character + + if line and line[-1] == '\\': + state['line_buffer'] = line[:-1] + return ret + else: + state['line_buffer'] = '' + + indent = len(line) - len(line.lstrip()) + + # make sure we support multi-space indent as long as indent is consistent + if indent and not state['indent_marker']: + state['indent_marker'] = indent + + if state['indent_marker']: + indent /= state['indent_marker'] + + if indent == state['prev_indent']: + # same indentation as previous line + if state['prev_line']: + state['prev_line'] += ';' + elif indent > state['prev_indent']: + # new indentation is greater than previous, we just entered a new block + state['prev_line'] += ' {' + state['nested_blocks'] += 1 + else: + # indentation is reset, we exited a block + block_diff = state['prev_indent'] - indent + if state['prev_line']: + state['prev_line'] += ';' + state['prev_line'] += ' }' * block_diff + state['nested_blocks'] -= block_diff + + output = state['prev_line'] + if self.line_strip: + output = output.strip() + output_line_no = state['prev_line_no'] + + state['prev_indent'] = indent + state['prev_line'] = line + state['prev_line_no'] = line_no + + if output: + if self.line_numbers: + output = str(output_line_no + 1) + SEPARATOR + output + output += '\n' + ret += output + return ret + + def prepare_source(self, codestr, sass=False): + # Decorate lines with their line numbers and a delimiting NUL and remove empty lines + state = { + 'line_buffer': '', + 'prev_line': '', + 'prev_line_no': 0, + 'prev_indent': 0, + 'nested_blocks': 0, + 'indent_marker': 0, + } + if self.sass: + parse_line = self.parse_sass_line + else: + parse_line = self.parse_scss_line + _codestr = codestr + codestr = '' + for line_no, line in enumerate(_codestr.splitlines()): + codestr += parse_line(line_no, line, state) + codestr += parse_line(None, None, state) # parse the last line stored in prev_line buffer + + # protects codestr: "..." strings + codestr = _strings_re.sub(lambda m: _reverse_safe_strings_re.sub(lambda n: _reverse_safe_strings[n.group(0)], m.group(0)), codestr) + + # removes multiple line comments + codestr = _ml_comment_re.sub('', codestr) + + # removes inline comments, but not :// (protocol) + codestr = _sl_comment_re.sub('', codestr) + + codestr = _safe_strings_re.sub(lambda m: _safe_strings[m.group(0)], codestr) + + # expand the space in rules + codestr = _expand_rules_space_re.sub(' {', codestr) + + # collapse the space in properties blocks + codestr = _collapse_properties_space_re.sub(r'\1{', codestr) + + return codestr + + +class Scss(object): + def __init__(self, + scss_vars=None, scss_opts=None, scss_files=None, super_selector=None, + live_errors=False, library=ALL_BUILTINS_LIBRARY, func_registry=None, search_paths=None): + + if super_selector: + self.super_selector = super_selector + ' ' + else: + self.super_selector = '' + + self._scss_vars = {} + if scss_vars: + calculator = Calculator() + for var_name, value in scss_vars.items(): + if isinstance(value, six.string_types): + scss_value = calculator.evaluate_expression(value) + if scss_value is None: + # TODO warning? + scss_value = String.unquoted(value) + else: + scss_value = value + self._scss_vars[var_name] = scss_value + + self._scss_opts = scss_opts + self._scss_files = scss_files + # NOTE: func_registry is backwards-compatibility for only one user and + # has never existed in a real release + self._library = func_registry or library + self._search_paths = search_paths + + # If true, swallow compile errors and embed them in the output instead + self.live_errors = live_errors + + self.reset() + + def get_scss_constants(self): + scss_vars = self.root_namespace.variables + return dict((k, v) for k, v in scss_vars.items() if k and (not k.startswith('$') or k.startswith('$') and k[1].isupper())) + + def get_scss_vars(self): + scss_vars = self.root_namespace.variables + return dict((k, v) for k, v in scss_vars.items() if k and not (not k.startswith('$') or k.startswith('$') and k[1].isupper())) + + def reset(self, input_scss=None): + # Initialize + self.scss_vars = _default_scss_vars.copy() + if self._scss_vars is not None: + self.scss_vars.update(self._scss_vars) + + self.scss_opts = self._scss_opts.copy() if self._scss_opts else {} + + self.root_namespace = Namespace(variables=self.scss_vars, functions=self._library) + + # Figure out search paths. Fall back from provided explicitly to + # defined globally to just searching the current directory + self.search_paths = ['.'] + if self._search_paths is not None: + assert not isinstance(self._search_paths, six.string_types), \ + "`search_paths` should be an iterable, not a string" + self.search_paths.extend(self._search_paths) + else: + if config.LOAD_PATHS: + if isinstance(config.LOAD_PATHS, six.string_types): + # Back-compat: allow comma-delimited + self.search_paths.extend(config.LOAD_PATHS.split(',')) + else: + self.search_paths.extend(config.LOAD_PATHS) + + self.search_paths.extend(self.scss_opts.get('load_paths', [])) + + self.source_files = [] + self.source_file_index = {} + if self._scss_files is not None: + for name, contents in list(self._scss_files.items()): + if name in self.source_file_index: + raise KeyError("Duplicate filename %r" % name) + source_file = SourceFile(name, contents) + self.source_files.append(source_file) + self.source_file_index[name] = source_file + + self.rules = [] + + #@profile + #@print_timing(2) + def Compilation(self, scss_string=None, scss_file=None, super_selector=None, filename=None, is_sass=None, line_numbers=True): + if super_selector: + self.super_selector = super_selector + ' ' + self.reset() + + source_file = None + if scss_string is not None: + source_file = SourceFile.from_string(scss_string, filename, is_sass, line_numbers) + elif scss_file is not None: + source_file = SourceFile.from_filename(scss_file, filename, is_sass, line_numbers) + + if source_file is not None: + # Clear the existing list of files + self.source_files = [] + self.source_file_index = dict() + + self.source_files.append(source_file) + self.source_file_index[source_file.filename] = source_file + + # this will compile and manage rule: child objects inside of a node + self.parse_children() + + # this will manage @extends + self.apply_extends() + + rules_by_file, css_files = self.parse_properties() + + all_rules = 0 + all_selectors = 0 + exceeded = '' + final_cont = '' + files = len(css_files) + for source_file in css_files: + rules = rules_by_file[source_file] + fcont, total_rules, total_selectors = self.create_css(rules) + all_rules += total_rules + all_selectors += total_selectors + if not exceeded and all_selectors > 4095: + exceeded = " (IE exceeded!)" + log.error("Maximum number of supported selectors in Internet Explorer (4095) exceeded!") + if files > 1 and self.scss_opts.get('debug_info', False): + if source_file.is_string: + final_cont += "/* %s %s generated add up to a total of %s %s accumulated%s */\n" % ( + total_selectors, + 'selector' if total_selectors == 1 else 'selectors', + all_selectors, + 'selector' if all_selectors == 1 else 'selectors', + exceeded) + else: + final_cont += "/* %s %s generated from '%s' add up to a total of %s %s accumulated%s */\n" % ( + total_selectors, + 'selector' if total_selectors == 1 else 'selectors', + source_file.filename, + all_selectors, + 'selector' if all_selectors == 1 else 'selectors', + exceeded) + final_cont += fcont + + return final_cont + + def compile(self, *args, **kwargs): + try: + return self.Compilation(*args, **kwargs) + except SassError as e: + if self.live_errors: + # TODO should this setting also capture and display warnings? + return e.to_css() + else: + raise + + def parse_selectors(self, raw_selectors): + """ + Parses out the old xCSS "foo extends bar" syntax. + + Returns a 2-tuple: a set of selectors, and a set of extended selectors. + """ + # Fix tabs and spaces in selectors + raw_selectors = _spaces_re.sub(' ', raw_selectors) + + import re + + from scss.selector import Selector + + parts = re.split(r'\s+extends\s+', raw_selectors, 1) + if len(parts) > 1: + unparsed_selectors, unsplit_parents = parts + # Multiple `extends` are delimited by `&` + unparsed_parents = unsplit_parents.split('&') + else: + unparsed_selectors, = parts + unparsed_parents = () + + selectors = Selector.parse_many(unparsed_selectors) + parents = [Selector.parse_one(parent) for parent in unparsed_parents] + + return selectors, parents + + @print_timing(3) + def parse_children(self, scope=None): + children = [] + root_namespace = self.root_namespace + for source_file in self.source_files: + rule = SassRule( + source_file=source_file, + + unparsed_contents=source_file.contents, + namespace=root_namespace, + options=self.scss_opts, + ) + self.rules.append(rule) + children.append(rule) + + for rule in children: + self.manage_children(rule, scope) + + if self.scss_opts.get('warn_unused'): + for name, file_and_line in root_namespace.unused_imports(): + log.warn("Unused @import: '%s' (%s)", name, file_and_line) + + @print_timing(4) + def manage_children(self, rule, scope): + try: + self._manage_children_impl(rule, scope) + except SassReturn: + raise + except SassError as e: + e.add_rule(rule) + raise + except Exception as e: + raise SassError(e, rule=rule) + + def _manage_children_impl(self, rule, scope): + calculator = Calculator(rule.namespace) + + for c_lineno, c_property, c_codestr in locate_blocks(rule.unparsed_contents): + block = UnparsedBlock(rule, c_lineno, c_property, c_codestr) + + if block.is_atrule: + code = block.directive + code = code.lower() + if code == '@warn': + value = calculator.calculate(block.argument) + log.warn(repr(value)) + elif code == '@print': + value = calculator.calculate(block.argument) + sys.stderr.write("%s\n" % value) + elif code == '@raw': + value = calculator.calculate(block.argument) + sys.stderr.write("%s\n" % repr(value)) + elif code == '@dump_context': + sys.stderr.write("%s\n" % repr(rule.namespace._variables)) + elif code == '@dump_functions': + sys.stderr.write("%s\n" % repr(rule.namespace._functions)) + elif code == '@dump_mixins': + sys.stderr.write("%s\n" % repr(rule.namespace._mixins)) + elif code == '@dump_imports': + sys.stderr.write("%s\n" % repr(rule.namespace._imports)) + elif code == '@dump_options': + sys.stderr.write("%s\n" % repr(rule.options)) + elif code == '@debug': + setting = block.argument.strip() + if setting.lower() in ('1', 'true', 't', 'yes', 'y', 'on'): + setting = True + elif setting.lower() in ('0', 'false', 'f', 'no', 'n', 'off', 'undefined'): + setting = False + config.DEBUG = setting + log.info("Debug mode is %s", 'On' if config.DEBUG else 'Off') + elif code == '@option': + self._settle_options(rule, scope, block) + elif code == '@content': + self._do_content(rule, scope, block) + elif code == '@import': + self._do_import(rule, scope, block) + elif code == '@extend': + from scss.selector import Selector + selectors = calculator.apply_vars(block.argument) + # XXX this no longer handles `&`, which is from xcss + rule.extends_selectors.extend(Selector.parse_many(selectors)) + #rule.extends_selectors.update(p.strip() for p in selectors.replace(',', '&').split('&')) + #rule.extends_selectors.discard('') + elif code == '@return': + # TODO should assert this only happens within a @function + ret = calculator.calculate(block.argument) + raise SassReturn(ret) + elif code == '@include': + self._do_include(rule, scope, block) + elif code in ('@mixin', '@function'): + self._do_functions(rule, scope, block) + elif code in ('@if', '@else if'): + self._do_if(rule, scope, block) + elif code == '@else': + self._do_else(rule, scope, block) + elif code == '@for': + self._do_for(rule, scope, block) + elif code == '@each': + self._do_each(rule, scope, block) + elif code == '@while': + self._do_while(rule, scope, block) + elif code in ('@variables', '@vars'): + self._get_variables(rule, scope, block) + elif block.unparsed_contents is None: + rule.properties.append((block.prop, None)) + elif scope is None: # needs to have no scope to crawl down the nested rules + self._nest_at_rules(rule, scope, block) + #################################################################### + # Properties + elif block.unparsed_contents is None: + self._get_properties(rule, scope, block) + # Nested properties + elif block.is_scope: + if block.header.unscoped_value: + # Possibly deal with default unscoped value + self._get_properties(rule, scope, block) + + rule.unparsed_contents = block.unparsed_contents + subscope = (scope or '') + block.header.scope + '-' + self.manage_children(rule, subscope) + #################################################################### + # Nested rules + elif scope is None: # needs to have no scope to crawl down the nested rules + self._nest_rules(rule, scope, block) + + @print_timing(10) + def _settle_options(self, rule, scope, block): + for option in block.argument.split(','): + option, value = (option.split(':', 1) + [''])[:2] + option = option.strip().lower() + value = value.strip() + if option: + if value.lower() in ('1', 'true', 't', 'yes', 'y', 'on'): + value = True + elif value.lower() in ('0', 'false', 'f', 'no', 'n', 'off', 'undefined'): + value = False + option = option.replace('-', '_') + if option == 'compress': + option = 'style' + log.warn("The option 'compress' is deprecated. Please use 'style' instead.") + rule.options[option] = value + + def _get_funct_def(self, rule, calculator, argument): + funct, lpar, argstr = argument.partition('(') + funct = calculator.do_glob_math(funct) + funct = normalize_var(funct.strip()) + argstr = argstr.strip() + + # Parse arguments with the argspec rule + if lpar: + if not argstr.endswith(')'): + raise SyntaxError("Expected ')', found end of line for %s (%s)" % (funct, rule.file_and_line)) + argstr = argstr[:-1].strip() + else: + # Whoops, no parens at all. That's like calling with no arguments. + argstr = '' + + argstr = calculator.do_glob_math(argstr) + argspec_node = calculator.parse_expression(argstr, target='goal_argspec') + return funct, argspec_node + + def _populate_namespace_from_call(self, name, callee_namespace, mixin, args, kwargs): + # Mutation protection + args = list(args) + kwargs = dict(kwargs) + + #m_params = mixin[0] + #m_defaults = mixin[1] + #m_codestr = mixin[2] + pristine_callee_namespace = mixin[3] + callee_argspec = mixin[4] + import_key = mixin[5] + + callee_calculator = Calculator(callee_namespace) + + # Populate the mixin/function's namespace with its arguments + for var_name, node in callee_argspec.iter_def_argspec(): + if args: + # If there are positional arguments left, use the first + value = args.pop(0) + elif var_name in kwargs: + # Try keyword arguments + value = kwargs.pop(var_name) + elif node is not None: + # OK, there's a default argument; try that + # DEVIATION: this allows argument defaults to refer to earlier + # argument values + value = node.evaluate(callee_calculator, divide=True) + else: + # TODO this should raise + value = Undefined() + + callee_namespace.set_variable(var_name, value, local_only=True) + + if callee_argspec.slurp: + # Slurpy var gets whatever is left + callee_namespace.set_variable( + callee_argspec.slurp.name, + List(args, use_comma=True)) + args = [] + elif callee_argspec.inject: + # Callee namespace gets all the extra kwargs whether declared or + # not + for var_name, value in kwargs.items(): + callee_namespace.set_variable(var_name, value, local_only=True) + kwargs = {} + + # TODO would be nice to say where the mixin/function came from + if kwargs: + raise NameError("%s has no such argument %s" % (name, kwargs.keys()[0])) + + if args: + raise NameError("%s received extra arguments: %r" % (name, args)) + + pristine_callee_namespace.use_import(import_key) + return callee_namespace + + @print_timing(10) + def _do_functions(self, rule, scope, block): + """ + Implements @mixin and @function + """ + if not block.argument: + raise SyntaxError("%s requires a function name (%s)" % (block.directive, rule.file_and_line)) + + calculator = Calculator(rule.namespace) + funct, argspec_node = self._get_funct_def(rule, calculator, block.argument) + + defaults = {} + new_params = [] + + for var_name, default in argspec_node.iter_def_argspec(): + new_params.append(var_name) + if default is not None: + defaults[var_name] = default + + mixin = [rule.source_file, block.lineno, block.unparsed_contents, rule.namespace, argspec_node, rule.import_key] + if block.directive == '@function': + def _call(mixin): + def __call(namespace, *args, **kwargs): + source_file = mixin[0] + lineno = mixin[1] + m_codestr = mixin[2] + pristine_callee_namespace = mixin[3] + callee_namespace = pristine_callee_namespace.derive() + + # TODO CallOp converts Sass names to Python names, so we + # have to convert them back to Sass names. would be nice + # to avoid this back-and-forth somehow + kwargs = dict( + (normalize_var('$' + key), value) + for (key, value) in kwargs.items()) + + self._populate_namespace_from_call( + "Function {0}".format(funct), + callee_namespace, mixin, args, kwargs) + + _rule = SassRule( + source_file=source_file, + lineno=lineno, + unparsed_contents=m_codestr, + namespace=callee_namespace, + + # rule + import_key=rule.import_key, + options=rule.options, + properties=rule.properties, + extends_selectors=rule.extends_selectors, + ancestry=rule.ancestry, + nested=rule.nested, + ) + try: + self.manage_children(_rule, scope) + except SassReturn as e: + return e.retval + else: + return Null() + return __call + _mixin = _call(mixin) + _mixin.mixin = mixin + mixin = _mixin + + if block.directive == '@mixin': + add = rule.namespace.set_mixin + elif block.directive == '@function': + add = rule.namespace.set_function + + # Register the mixin for every possible arity it takes + if argspec_node.slurp or argspec_node.inject: + add(funct, None, mixin) + else: + while len(new_params): + add(funct, len(new_params), mixin) + param = new_params.pop() + if param not in defaults: + break + if not new_params: + add(funct, 0, mixin) + + @print_timing(10) + def _do_include(self, rule, scope, block): + """ + Implements @include, for @mixins + """ + caller_namespace = rule.namespace + caller_calculator = Calculator(caller_namespace) + funct, caller_argspec = self._get_funct_def(rule, caller_calculator, block.argument) + + # Render the passed arguments, using the caller's namespace + args, kwargs = caller_argspec.evaluate_call_args(caller_calculator) + + argc = len(args) + len(kwargs) + try: + mixin = caller_namespace.mixin(funct, argc) + except KeyError: + try: + # TODO maybe? don't do this, once '...' works + # Fallback to single parameter: + mixin = caller_namespace.mixin(funct, 1) + except KeyError: + log.error("Mixin not found: %s:%d (%s)", funct, argc, rule.file_and_line, extra={'stack': True}) + return + else: + args = [List(args, use_comma=True)] + # TODO what happens to kwargs? + + source_file = mixin[0] + lineno = mixin[1] + m_codestr = mixin[2] + pristine_callee_namespace = mixin[3] + callee_argspec = mixin[4] + if caller_argspec.inject and callee_argspec.inject: + # DEVIATION: Pass the ENTIRE local namespace to the mixin (yikes) + callee_namespace = Namespace.derive_from( + caller_namespace, + pristine_callee_namespace) + else: + callee_namespace = pristine_callee_namespace.derive() + + self._populate_namespace_from_call( + "Mixin {0}".format(funct), + callee_namespace, mixin, args, kwargs) + + _rule = SassRule( + source_file=source_file, + lineno=lineno, + unparsed_contents=m_codestr, + namespace=callee_namespace, + + # rule + import_key=rule.import_key, + options=rule.options, + properties=rule.properties, + extends_selectors=rule.extends_selectors, + ancestry=rule.ancestry, + nested=rule.nested, + ) + + _rule.options['@content'] = block.unparsed_contents + self.manage_children(_rule, scope) + + @print_timing(10) + def _do_content(self, rule, scope, block): + """ + Implements @content + """ + if '@content' not in rule.options: + log.error("Content string not found for @content (%s)", rule.file_and_line) + rule.unparsed_contents = rule.options.pop('@content', '') + self.manage_children(rule, scope) + + @print_timing(10) + def _do_import(self, rule, scope, block): + """ + Implements @import + Load and import mixins and functions and rules + """ + # Protect against going to prohibited places... + if any(scary_token in block.argument for scary_token in ('..', '://', 'url(')): + rule.properties.append((block.prop, None)) + return + + full_filename = None + names = block.argument.split(',') + for name in names: + name = dequote(name.strip()) + + source_file = None + full_filename, seen_paths = self._find_import(rule, name) + + if full_filename is None: + i_codestr = self._do_magic_import(rule, scope, block) + + if i_codestr is not None: + source_file = SourceFile.from_string(i_codestr) + + elif full_filename in self.source_file_index: + source_file = self.source_file_index[full_filename] + + else: + with open(full_filename) as f: + source = f.read() + source_file = SourceFile( + full_filename, + source, + parent_dir=os.path.realpath(os.path.dirname(full_filename)), + ) + + self.source_files.append(source_file) + self.source_file_index[full_filename] = source_file + + if source_file is None: + load_paths_msg = "\nLoad paths:\n\t%s" % "\n\t".join(seen_paths) + log.warn("File to import not found or unreadable: '%s' (%s)%s", name, rule.file_and_line, load_paths_msg) + continue + + import_key = (name, source_file.parent_dir) + if rule.namespace.has_import(import_key): + # If already imported in this scope, skip + continue + + _rule = SassRule( + source_file=source_file, + lineno=block.lineno, + import_key=import_key, + unparsed_contents=source_file.contents, + + # rule + options=rule.options, + properties=rule.properties, + extends_selectors=rule.extends_selectors, + ancestry=rule.ancestry, + namespace=rule.namespace, + ) + rule.namespace.add_import(import_key, rule.import_key, rule.file_and_line) + self.manage_children(_rule, scope) + + def _find_import(self, rule, name): + """Find the file referred to by an @import. + + Takes a name from an @import and returns an absolute path, or None. + """ + name, ext = os.path.splitext(name) + if ext: + search_exts = [ext] + else: + search_exts = ['.scss', '.sass'] + + dirname, name = os.path.split(name) + + seen_paths = [] + + for path in self.search_paths: + for basepath in [rule.source_file.parent_dir, '.']: + full_path = os.path.realpath(os.path.join(basepath, path, dirname)) + + if full_path in seen_paths: + continue + seen_paths.append(full_path) + + for prefix, suffix in product(('_', ''), search_exts): + full_filename = os.path.join(full_path, prefix + name + suffix) + if os.path.exists(full_filename): + return full_filename, seen_paths + + return None, seen_paths + + @print_timing(10) + def _do_magic_import(self, rule, scope, block): + """ + Implements @import for sprite-maps + Imports magic sprite map directories + """ + if callable(config.STATIC_ROOT): + files = sorted(config.STATIC_ROOT(block.argument)) + else: + glob_path = os.path.join(config.STATIC_ROOT, block.argument) + files = glob.glob(glob_path) + files = sorted((file[len(config.STATIC_ROOT):], None) for file in files) + + if not files: + return + + # Build magic context + map_name = os.path.normpath(os.path.dirname(block.argument)).replace('\\', '_').replace('/', '_') + kwargs = {} + + calculator = Calculator(rule.namespace) + + def setdefault(var, val): + _var = '$' + map_name + '-' + var + if _var in rule.context: + kwargs[var] = calculator.interpolate(rule.context[_var], rule, self._library) + else: + rule.context[_var] = val + kwargs[var] = calculator.interpolate(val, rule, self._library) + return rule.context[_var] + + setdefault('sprite-base-class', String('.' + map_name + '-sprite', quotes=None)) + setdefault('sprite-dimensions', Boolean(False)) + position = setdefault('position', Number(0, '%')) + spacing = setdefault('spacing', Number(0)) + repeat = setdefault('repeat', String('no-repeat', quotes=None)) + names = tuple(os.path.splitext(os.path.basename(file))[0] for file, storage in files) + for n in names: + setdefault(n + '-position', position) + setdefault(n + '-spacing', spacing) + setdefault(n + '-repeat', repeat) + rule.context['$' + map_name + '-' + 'sprites'] = sprite_map(block.argument, **kwargs) + ret = ''' + @import "compass/utilities/sprites/base"; + + // All sprites should extend this class + // The %(map_name)s-sprite mixin will do so for you. + #{$%(map_name)s-sprite-base-class} { + background: $%(map_name)s-sprites; + } + + // Use this to set the dimensions of an element + // based on the size of the original image. + @mixin %(map_name)s-sprite-dimensions($name) { + @include sprite-dimensions($%(map_name)s-sprites, $name); + } + + // Move the background position to display the sprite. + @mixin %(map_name)s-sprite-position($name, $offset-x: 0, $offset-y: 0) { + @include sprite-position($%(map_name)s-sprites, $name, $offset-x, $offset-y); + } + + // Extends the sprite base class and set the background position for the desired sprite. + // It will also apply the image dimensions if $dimensions is true. + @mixin %(map_name)s-sprite($name, $dimensions: $%(map_name)s-sprite-dimensions, $offset-x: 0, $offset-y: 0) { + @extend #{$%(map_name)s-sprite-base-class}; + @include sprite($%(map_name)s-sprites, $name, $dimensions, $offset-x, $offset-y); + } + + @mixin %(map_name)s-sprites($sprite-names, $dimensions: $%(map_name)s-sprite-dimensions) { + @include sprites($%(map_name)s-sprites, $sprite-names, $%(map_name)s-sprite-base-class, $dimensions); + } + + // Generates a class for each sprited image. + @mixin all-%(map_name)s-sprites($dimensions: $%(map_name)s-sprite-dimensions) { + @include %(map_name)s-sprites(%(sprites)s, $dimensions); + } + ''' % {'map_name': map_name, 'sprites': ' '.join(names)} + return ret + + @print_timing(10) + def _do_if(self, rule, scope, block): + """ + Implements @if and @else if + """ + # "@if" indicates whether any kind of `if` since the last `@else` has + # succeeded, in which case `@else if` should be skipped + if block.directive != '@if': + if '@if' not in rule.options: + raise SyntaxError("@else with no @if (%s)" % (rule.file_and_line,)) + if rule.options['@if']: + # Last @if succeeded; stop here + return + + calculator = Calculator(rule.namespace) + condition = calculator.calculate(block.argument) + if condition: + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation + # DEVIATION: Allow not creating a new namespace + inner_rule.namespace = rule.namespace + self.manage_children(inner_rule, scope) + rule.options['@if'] = condition + + @print_timing(10) + def _do_else(self, rule, scope, block): + """ + Implements @else + """ + if '@if' not in rule.options: + log.error("@else with no @if (%s)", rule.file_and_line) + val = rule.options.pop('@if', True) + if not val: + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + inner_rule.namespace = rule.namespace # DEVIATION: Commenting this line gives the Sass bahavior + inner_rule.unparsed_contents = block.unparsed_contents + self.manage_children(inner_rule, scope) + + @print_timing(10) + def _do_for(self, rule, scope, block): + """ + Implements @for + """ + var, _, name = block.argument.partition(' from ') + frm, _, through = name.partition(' through ') + if not through: + frm, _, through = frm.partition(' to ') + calculator = Calculator(rule.namespace) + frm = calculator.calculate(frm) + through = calculator.calculate(through) + try: + frm = int(float(frm)) + through = int(float(through)) + except ValueError: + return + + if frm > through: + # DEVIATION: allow reversed '@for .. from .. through' (same as enumerate() and range()) + frm, through = through, frm + rev = reversed + else: + rev = lambda x: x + var = var.strip() + var = calculator.do_glob_math(var) + var = normalize_var(var) + + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation + # DEVIATION: Allow not creating a new namespace + inner_rule.namespace = rule.namespace + + for i in rev(range(frm, through + 1)): + inner_rule.namespace.set_variable(var, Number(i)) + self.manage_children(inner_rule, scope) + + @print_timing(10) + def _do_each(self, rule, scope, block): + """ + Implements @each + """ + varstring, _, valuestring = block.argument.partition(' in ') + calculator = Calculator(rule.namespace) + values = calculator.calculate(valuestring) + if not values: + return + + varlist = varstring.split(",") + varlist = [ + normalize_var(calculator.do_glob_math(var.strip())) + for var in varlist + ] + + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation + # DEVIATION: Allow not creating a new namespace + inner_rule.namespace = rule.namespace + + for v in List.from_maybe(values): + v = List.from_maybe(v) + for i, var in enumerate(varlist): + if i >= len(v): + value = Null() + else: + value = v[i] + inner_rule.namespace.set_variable(var, value) + self.manage_children(inner_rule, scope) + + @print_timing(10) + def _do_while(self, rule, scope, block): + """ + Implements @while + """ + calculator = Calculator(rule.namespace) + first_condition = condition = calculator.calculate(block.argument) + while condition: + inner_rule = rule.copy() + inner_rule.unparsed_contents = block.unparsed_contents + if not rule.options.get('control_scoping', config.CONTROL_SCOPING): # TODO: maybe make this scoping mode for contol structures as the default as a default deviation + # DEVIATION: Allow not creating a new namespace + inner_rule.namespace = rule.namespace + self.manage_children(inner_rule, scope) + condition = calculator.calculate(block.argument) + rule.options['@if'] = first_condition + + @print_timing(10) + def _get_variables(self, rule, scope, block): + """ + Implements @variables and @vars + """ + _rule = rule.copy() + _rule.unparsed_contents = block.unparsed_contents + _rule.namespace = rule.namespace + _rule.properties = {} + self.manage_children(_rule, scope) + for name, value in _rule.properties.items(): + rule.namespace.set_variable(name, value) + + @print_timing(10) + def _get_properties(self, rule, scope, block): + """ + Implements properties and variables extraction and assignment + """ + prop, raw_value = (_prop_split_re.split(block.prop, 1) + [None])[:2] + try: + is_var = (block.prop[len(prop)] == '=') + except IndexError: + is_var = False + calculator = Calculator(rule.namespace) + prop = prop.strip() + prop = calculator.do_glob_math(prop) + if not prop: + return + + # Parse the value and determine whether it's a default assignment + is_default = False + if raw_value is not None: + raw_value = raw_value.strip() + if prop.startswith('$'): + raw_value, subs = re.subn(r'(?i)\s+!default\Z', '', raw_value) + if subs: + is_default = True + + _prop = (scope or '') + prop + if is_var or prop.startswith('$') and raw_value is not None: + # Variable assignment + _prop = normalize_var(_prop) + try: + existing_value = rule.namespace.variable(_prop) + except KeyError: + existing_value = None + + is_defined = existing_value is not None and not existing_value.is_null + if is_default and is_defined: + pass + else: + if is_defined and prop.startswith('$') and prop[1].isupper(): + log.warn("Constant %r redefined", prop) + + # Variable assignment is an expression, so it always performs + # real division + value = calculator.calculate(raw_value, divide=True) + rule.namespace.set_variable(_prop, value) + else: + # Regular property destined for output + _prop = calculator.apply_vars(_prop) + if raw_value is None: + value = None + else: + value = calculator.calculate(raw_value) + + if value is None: + pass + elif isinstance(value, six.string_types): + # TODO kill this branch + pass + else: + style = self.scss_opts.get('style', config.STYLE) + compress = style in (True, 'compressed') + value = value.render(compress=compress) + + rule.properties.append((_prop, value)) + + @print_timing(10) + def _nest_at_rules(self, rule, scope, block): + """ + Implements @-blocks + """ + # Interpolate the current block + # TODO this seems like it should be done in the block header. and more + # generally? + calculator = Calculator(rule.namespace) + block.header.argument = calculator.apply_vars(block.header.argument) + + # TODO merge into RuleAncestry + new_ancestry = list(rule.ancestry.headers) + if block.directive == '@media' and new_ancestry: + for i, header in reversed(list(enumerate(new_ancestry))): + if header.is_selector: + continue + elif header.directive == '@media': + from scss.rule import BlockAtRuleHeader + new_ancestry[i] = BlockAtRuleHeader( + '@media', + "%s and %s" % (header.argument, block.argument)) + break + else: + new_ancestry.insert(i, block.header) + else: + new_ancestry.insert(0, block.header) + else: + new_ancestry.append(block.header) + + from scss.rule import RuleAncestry + rule.descendants += 1 + new_rule = SassRule( + source_file=rule.source_file, + import_key=rule.import_key, + lineno=block.lineno, + unparsed_contents=block.unparsed_contents, + + options=rule.options.copy(), + #properties + #extends_selectors + ancestry=RuleAncestry(new_ancestry), + + namespace=rule.namespace.derive(), + nested=rule.nested + 1, + ) + self.rules.append(new_rule) + rule.namespace.use_import(rule.import_key) + self.manage_children(new_rule, scope) + + if new_rule.options.get('warn_unused'): + for name, file_and_line in new_rule.namespace.unused_imports(): + log.warn("Unused @import: '%s' (%s)", name, file_and_line) + + @print_timing(10) + def _nest_rules(self, rule, scope, block): + """ + Implements Nested CSS rules + """ + calculator = Calculator(rule.namespace) + raw_selectors = calculator.do_glob_math(block.prop) + # DEVIATION: ruby sass doesn't support bare variables in selectors + raw_selectors = calculator.apply_vars(raw_selectors) + c_selectors, c_parents = self.parse_selectors(raw_selectors) + + new_ancestry = rule.ancestry.with_nested_selectors(c_selectors) + + rule.descendants += 1 + new_rule = SassRule( + source_file=rule.source_file, + import_key=rule.import_key, + lineno=block.lineno, + unparsed_contents=block.unparsed_contents, + + options=rule.options.copy(), + #properties + extends_selectors=c_parents, + ancestry=new_ancestry, + + namespace=rule.namespace.derive(), + nested=rule.nested + 1, + ) + self.rules.append(new_rule) + rule.namespace.use_import(rule.import_key) + self.manage_children(new_rule, scope) + + if new_rule.options.get('warn_unused'): + for name, file_and_line in new_rule.namespace.unused_imports(): + log.warn("Unused @import: '%s' (%s)", name, file_and_line) + + @print_timing(3) + def apply_extends(self): + """Run through the given rules and translate all the pending @extends + declarations into real selectors on parent rules. + + The list is modified in-place and also sorted in dependency order. + """ + # Game plan: for each rule that has an @extend, add its selectors to + # every rule that matches that @extend. + # First, rig a way to find arbitrary selectors quickly. Most selectors + # revolve around elements, classes, and IDs, so parse those out and use + # them as a rough key. Ignore order and duplication for now. + key_to_selectors = defaultdict(set) + selector_to_rules = defaultdict(list) + # DEVIATION: These are used to rearrange rules in dependency order, so + # an @extended parent appears in the output before a child. Sass does + # not do this, and the results may be unexpected. Pending removal. + rule_order = dict() + rule_dependencies = dict() + order = 0 + for rule in self.rules: + rule_order[rule] = order + # Rules are ultimately sorted by the earliest rule they must + # *precede*, so every rule should "depend" on the next one + rule_dependencies[rule] = [order + 1] + order += 1 + + for selector in rule.selectors: + for key in selector.lookup_key(): + key_to_selectors[key].add(selector) + selector_to_rules[selector].append(rule) + + # Now go through all the rules with an @extends and find their parent + # rules. + for rule in self.rules: + for selector in rule.extends_selectors: + # This is a little dirty. intersection isn't a class method. + # Don't think about it too much. + candidates = set.intersection(*( + key_to_selectors[key] for key in selector.lookup_key())) + extendable_selectors = [ + candidate for candidate in candidates + if candidate.is_superset_of(selector)] + + if not extendable_selectors: + log.warn( + "Can't find any matching rules to extend: %s" + % selector.render()) + continue + + # Armed with a set of selectors that this rule can extend, do + # some substitution and modify the appropriate parent rules + for extendable_selector in extendable_selectors: + # list() shields us from problems mutating the list within + # this loop, which can happen in the case of @extend loops + parent_rules = list(selector_to_rules[extendable_selector]) + for parent_rule in parent_rules: + if parent_rule is rule: + # Don't extend oneself + continue + + more_parent_selectors = [] + + for rule_selector in rule.selectors: + more_parent_selectors.extend( + extendable_selector.substitute( + selector, rule_selector)) + + for parent in more_parent_selectors: + # Update indices, in case any later rules try to + # extend this one + for key in parent.lookup_key(): + key_to_selectors[key].add(parent) + # TODO this could lead to duplicates? maybe should + # be a set too + selector_to_rules[parent].append(parent_rule) + + parent_rule.ancestry = ( + parent_rule.ancestry.with_more_selectors( + more_parent_selectors)) + rule_dependencies[parent_rule].append(rule_order[rule]) + + self.rules.sort(key=lambda rule: min(rule_dependencies[rule])) + + @print_timing(3) + def parse_properties(self): + css_files = [] + seen_files = set() + rules_by_file = {} + + for rule in self.rules: + source_file = rule.source_file + rules_by_file.setdefault(source_file, []).append(rule) + + if rule.is_empty: + continue + + if source_file not in seen_files: + seen_files.add(source_file) + css_files.append(source_file) + + return rules_by_file, css_files + + @print_timing(3) + def create_css(self, rules): + """ + Generate the final CSS string + """ + style = self.scss_opts.get('style', config.STYLE) + debug_info = self.scss_opts.get('debug_info', False) + + if style == 'legacy' or style is False: + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', False, '', '\n', '\n', '\n', debug_info + elif style == 'compressed' or style is True: + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = False, '', '', False, '', '', '', '', False + elif style == 'compact': + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', '', False, '\n', ' ', '\n', ' ', debug_info + elif style == 'expanded': + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', False, '\n', '\n', '\n', '\n', debug_info + else: # if style == 'nested': + sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg = True, ' ', ' ', True, '\n', '\n', '\n', ' ', debug_info + + return self._create_css(rules, sc, sp, tb, nst, srnl, nl, rnl, lnl, dbg) + + def _textwrap(self, txt, width=70): + if not hasattr(self, '_textwrap_wordsep_re'): + self._textwrap_wordsep_re = re.compile(r'(?<=,)\s+') + self._textwrap_strings_re = re.compile(r'''(["'])(?:(?!\1)[^\\]|\\.)*\1''') + + # First, remove commas from anything within strings (marking commas as \0): + def _repl(m): + ori = m.group(0) + fin = ori.replace(',', '\0') + if ori != fin: + subs[fin] = ori + return fin + subs = {} + txt = self._textwrap_strings_re.sub(_repl, txt) + + # Mark split points for word separators using (marking spaces with \1): + txt = self._textwrap_wordsep_re.sub('\1', txt) + + # Replace all the strings back: + for fin, ori in subs.items(): + txt = txt.replace(fin, ori) + + # Split in chunks: + chunks = txt.split('\1') + + # Break in lines of at most long_width width appending chunks: + ln = '' + lines = [] + long_width = int(width * 1.2) + for chunk in chunks: + _ln = ln + ' ' if ln else '' + _ln += chunk + if len(ln) >= width or len(_ln) >= long_width: + if ln: + lines.append(ln) + _ln = chunk + ln = _ln + if ln: + lines.append(ln) + + return lines + + def _create_css(self, rules, sc=True, sp=' ', tb=' ', nst=True, srnl='\n', nl='\n', rnl='\n', lnl='', debug_info=False): + skip_selectors = False + + prev_ancestry_headers = [] + + total_rules = 0 + total_selectors = 0 + + result = '' + dangling_property = False + separate = False + nesting = current_nesting = last_nesting = -1 if nst else 0 + nesting_stack = [] + for rule in rules: + nested = rule.nested + if nested <= 1: + separate = True + + if nst: + last_nesting = current_nesting + current_nesting = nested + + delta_nesting = current_nesting - last_nesting + if delta_nesting > 0: + nesting_stack += [nesting] * delta_nesting + elif delta_nesting < 0: + nesting_stack = nesting_stack[:delta_nesting] + nesting = nesting_stack[-1] + + if rule.is_empty: + continue + + if nst: + nesting += 1 + + ancestry = rule.ancestry + ancestry_len = len(ancestry) + + first_mismatch = 0 + for i, (old_header, new_header) in enumerate(zip(prev_ancestry_headers, ancestry.headers)): + if old_header != new_header: + first_mismatch = i + break + + # When sc is False, sets of properties are printed without a + # trailing semicolon. If the previous block isn't being closed, + # that trailing semicolon needs adding in to separate the last + # property from the next rule. + if not sc and dangling_property and first_mismatch >= len(prev_ancestry_headers): + result += ';' + + # Close blocks and outdent as necessary + for i in range(len(prev_ancestry_headers), first_mismatch, -1): + result += tb * (i - 1) + '}' + rnl + + # Open new blocks as necessary + for i in range(first_mismatch, ancestry_len): + header = ancestry.headers[i] + + if separate: + if result: + result += srnl + separate = False + if debug_info: + if not rule.source_file.is_string: + filename = rule.source_file.filename + lineno = str(rule.lineno) + if debug_info == 'comments': + result += tb * (i + nesting) + "/* file: %s, line: %s */" % (filename, lineno) + nl + else: + filename = _escape_chars_re.sub(r'\\\1', filename) + result += tb * (i + nesting) + "@media -sass-debug-info{filename{font-family:file\:\/\/%s}line{font-family:\\00003%s}}" % (filename, lineno) + nl + + if header.is_selector: + header_string = header.render(sep=',' + sp, super_selector=self.super_selector) + if nl: + header_string = (nl + tb * (i + nesting)).join(self._textwrap(header_string)) + else: + header_string = header.render() + result += tb * (i + nesting) + header_string + sp + '{' + nl + + total_rules += 1 + if header.is_selector: + total_selectors += 1 + + prev_ancestry_headers = ancestry.headers + dangling_property = False + + if not skip_selectors: + result += self._print_properties(rule.properties, sc, sp, tb * (ancestry_len + nesting), nl, lnl) + dangling_property = True + + # Close all remaining blocks + for i in reversed(range(len(prev_ancestry_headers))): + result += tb * i + '}' + rnl + + return (result, total_rules, total_selectors) + + def _print_properties(self, properties, sc=True, sp=' ', tb='', nl='\n', lnl=' '): + result = '' + last_prop_index = len(properties) - 1 + for i, (name, value) in enumerate(properties): + if value is None: + prop = name + elif value: + if nl: + value = (nl + tb + tb).join(self._textwrap(value)) + prop = name + ':' + sp + value + else: + # Empty string means there's supposed to be a value but it + # evaluated to nothing; skip this + # TODO interacts poorly with last_prop_index + continue + + if i == last_prop_index: + if sc: + result += tb + prop + ';' + lnl + else: + result += tb + prop + lnl + else: + result += tb + prop + ';' + nl + return result + + +# TODO: this should inherit from SassError, but can't, because that assumes +# it's wrapping another error. fix this with the exception hierarchy +class SassReturn(Exception): + """Special control-flow exception used to hop up the stack from a Sass + function's ``@return``. + """ + def __init__(self, retval): + self.retval = retval + Exception.__init__(self) + + def __str__(self): + return "Returning {0!r}".format(self.retval) diff --git a/libs/scss/__main__.py b/libs/scss/__main__.py new file mode 100644 index 00000000..792ead73 --- /dev/null +++ b/libs/scss/__main__.py @@ -0,0 +1,3 @@ +import scss.tool + +scss.tool.main() diff --git a/libs/scss/_native.py b/libs/scss/_native.py new file mode 100644 index 00000000..7dafe548 --- /dev/null +++ b/libs/scss/_native.py @@ -0,0 +1,262 @@ +"""Pure-Python scanner and parser, used if _speedups is not available.""" +from __future__ import print_function + +from scss.cssdefs import SEPARATOR +import re + +DEBUG = False + +# TODO copied from __init__ +_nl_num_re = re.compile(r'\n.+' + SEPARATOR, re.MULTILINE) +_blocks_re = re.compile(r'[{},;()\'"\n]') + + +def _strip_selprop(selprop, lineno): + # Get the line number of the selector or property and strip all other + # line numbers that might still be there (from multiline selectors) + _lineno, _sep, selprop = selprop.partition(SEPARATOR) + if _sep == SEPARATOR: + _lineno = _lineno.strip(' \t\n;') + try: + lineno = int(_lineno) + except ValueError: + pass + else: + selprop = _lineno + selprop = _nl_num_re.sub('\n', selprop) + selprop = selprop.strip() + return selprop, lineno + + +def _strip(selprop): + # Strip all line numbers, ignoring them in the way + selprop, _ = _strip_selprop(selprop, None) + return selprop + + +def locate_blocks(codestr): + """ + For processing CSS like strings. + + Either returns all selectors (that can be "smart" multi-lined, as + long as it's joined by `,`, or enclosed in `(` and `)`) with its code block + (the one between `{` and `}`, which can be nested), or the "lose" code + (properties) that doesn't have any blocks. + """ + lineno = 0 + + par = 0 + instr = None + depth = 0 + skip = False + thin = None + i = init = safe = lose = 0 + start = end = None + + for m in _blocks_re.finditer(codestr): + i = m.start(0) + c = codestr[i] + if instr is not None: + if c == instr: + instr = None # A string ends (FIXME: needs to accept escaped characters) + elif c in ('"', "'"): + instr = c # A string starts + elif c == '(': # parenthesis begins: + par += 1 + thin = None + safe = i + 1 + elif c == ')': # parenthesis ends: + par -= 1 + elif not par and not instr: + if c == '{': # block begins: + if depth == 0: + if i > 0 and codestr[i - 1] == '#': # Do not process #{...} as blocks! + skip = True + else: + start = i + if thin is not None and _strip(codestr[thin:i]): + init = thin + if lose < init: + _property, lineno = _strip_selprop(codestr[lose:init], lineno) + if _property: + yield lineno, _property, None + lose = init + thin = None + depth += 1 + elif c == '}': # block ends: + if depth > 0: + depth -= 1 + if depth == 0: + if not skip: + end = i + _selectors, lineno = _strip_selprop(codestr[init:start], lineno) + _codestr = codestr[start + 1:end].strip() + if _selectors: + yield lineno, _selectors, _codestr + init = safe = lose = end + 1 + thin = None + skip = False + elif depth == 0: + if c == ';': # End of property (or block): + init = i + if lose < init: + _property, lineno = _strip_selprop(codestr[lose:init], lineno) + if _property: + yield lineno, _property, None + init = safe = lose = i + 1 + thin = None + elif c == ',': + if thin is not None and _strip(codestr[thin:i]): + init = thin + thin = None + safe = i + 1 + elif c == '\n': + if thin is not None and _strip(codestr[thin:i]): + init = thin + thin = i + 1 + elif thin is None and _strip(codestr[safe:i]): + thin = i + 1 # Step on thin ice, if it breaks, it breaks here + if depth > 0: + if not skip: + _selectors, lineno = _strip_selprop(codestr[init:start], lineno) + _codestr = codestr[start + 1:].strip() + if _selectors: + yield lineno, _selectors, _codestr + if par: + raise Exception("Missing closing parenthesis somewhere in block: '%s'" % _selectors) + elif instr: + raise Exception("Missing closing string somewhere in block: '%s'" % _selectors) + else: + raise Exception("Block never closed: '%s'" % _selectors) + losestr = codestr[lose:] + for _property in losestr.split(';'): + _property, lineno = _strip_selprop(_property, lineno) + if _property: + yield lineno, _property, None + + +################################################################################ +# Parser + +class NoMoreTokens(Exception): + """ + Another exception object, for when we run out of tokens + """ + pass + + +class Scanner(object): + def __init__(self, patterns, ignore, input=None): + """ + Patterns is [(terminal,regex)...] + Ignore is [terminal,...]; + Input is a string + """ + self.reset(input) + self.ignore = ignore + # The stored patterns are a pair (compiled regex,source + # regex). If the patterns variable passed in to the + # constructor is None, we assume that the class already has a + # proper .patterns list constructed + if patterns is not None: + self.patterns = [] + for k, r in patterns: + self.patterns.append((k, re.compile(r))) + + def reset(self, input): + self.tokens = [] + self.restrictions = [] + self.input = input + self.pos = 0 + + def __repr__(self): + """ + Print the last 10 tokens that have been scanned in + """ + output = '' + for t in self.tokens[-10:]: + output = "%s\n (@%s) %s = %s" % (output, t[0], t[2], repr(t[3])) + return output + + def _scan(self, restrict): + """ + Should scan another token and add it to the list, self.tokens, + and add the restriction to self.restrictions + """ + # Keep looking for a token, ignoring any in self.ignore + token = None + while True: + best_pat = None + # Search the patterns for a match, with earlier + # tokens in the list having preference + best_pat_len = 0 + for tok, regex in self.patterns: + if DEBUG: + print("\tTrying %s: %s at pos %d -> %s" % (repr(tok), repr(regex.pattern), self.pos, repr(self.input))) + # First check to see if we're restricting to this token + if restrict and tok not in restrict and tok not in self.ignore: + if DEBUG: + print("\tSkipping %r!" % (tok,)) + continue + m = regex.match(self.input, self.pos) + if m: + # We got a match + best_pat = tok + best_pat_len = len(m.group(0)) + if DEBUG: + print("Match OK! %s: %s at pos %d" % (repr(tok), repr(regex.pattern), self.pos)) + break + + # If we didn't find anything, raise an error + if best_pat is None: + msg = "Bad token found" + if restrict: + msg = "Bad token found while trying to find one of the restricted tokens: %s" % (", ".join(repr(r) for r in restrict)) + raise SyntaxError("SyntaxError[@ char %s: %s]" % (repr(self.pos), msg)) + + # If we found something that isn't to be ignored, return it + if best_pat in self.ignore: + # This token should be ignored... + self.pos += best_pat_len + else: + end_pos = self.pos + best_pat_len + # Create a token with this data + token = ( + self.pos, + end_pos, + best_pat, + self.input[self.pos:end_pos] + ) + break + if token is not None: + self.pos = token[1] + # Only add this token if it's not in the list + # (to prevent looping) + if not self.tokens or token != self.tokens[-1]: + self.tokens.append(token) + self.restrictions.append(restrict) + return 1 + return 0 + + def token(self, i, restrict=None): + """ + Get the i'th token, and if i is one past the end, then scan + for another token; restrict is a list of tokens that + are allowed, or 0 for any token. + """ + tokens_len = len(self.tokens) + if i == tokens_len: # We are at the end, get the next... + tokens_len += self._scan(restrict) + if i < tokens_len: + if restrict and self.restrictions[i] and restrict > self.restrictions[i]: + raise NotImplementedError("Unimplemented: restriction set changed") + return self.tokens[i] + raise NoMoreTokens + + def rewind(self, i): + tokens_len = len(self.tokens) + if i <= tokens_len: + token = self.tokens[i] + self.tokens = self.tokens[:i] + self.restrictions = self.restrictions[:i] + self.pos = token[0] diff --git a/libs/scss/config.py b/libs/scss/config.py new file mode 100644 index 00000000..7836b654 --- /dev/null +++ b/libs/scss/config.py @@ -0,0 +1,36 @@ +################################################################################ +# Configuration: +DEBUG = False +VERBOSITY = 1 + +import os +PROJECT_ROOT = os.path.normpath(os.path.dirname(os.path.abspath(__file__))) + +# Sass @import load_paths: +LOAD_PATHS = os.path.join(PROJECT_ROOT, 'sass/frameworks') + +# Assets path, where new sprite files are created (defaults to STATIC_ROOT + '/assets'): +ASSETS_ROOT = None +# Cache files path, where cache files are saved (defaults to ASSETS_ROOT): +CACHE_ROOT = None +# Assets path, where new sprite files are created: +STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') +FONTS_ROOT = None # default: STATIC_ROOT +IMAGES_ROOT = None # default: STATIC_ROOT + +# Urls for the static and assets: +ASSETS_URL = 'static/assets/' +STATIC_URL = 'static/' +FONTS_URL = None # default: STATIC_URL +IMAGES_URL = None # default: STATIC_URL + +# Rendering style. Available values are 'nested', 'expanded', 'compact', 'compressed' and 'legacy' (defaults to 'nested'): +STYLE = 'nested' + +# Use a different scope inside control structures create a scope (defaults to create new scopes for control structures, same as Sass): +CONTROL_SCOPING = True + +# Throw fatal errors when finding undefined variables: +FATAL_UNDEFINED = True + +SPRTE_MAP_DIRECTION = 'vertical' diff --git a/libs/scss/cssdefs.py b/libs/scss/cssdefs.py new file mode 100644 index 00000000..7b738056 --- /dev/null +++ b/libs/scss/cssdefs.py @@ -0,0 +1,384 @@ +from math import pi +import re + +# ------------------------------------------------------------------------------ +# Built-in CSS color names +# See: http://www.w3.org/TR/css3-color/#svg-color + +COLOR_NAMES = { + 'aliceblue': (240, 248, 255, 1), + 'antiquewhite': (250, 235, 215, 1), + 'aqua': (0, 255, 255, 1), + 'aquamarine': (127, 255, 212, 1), + 'azure': (240, 255, 255, 1), + 'beige': (245, 245, 220, 1), + 'bisque': (255, 228, 196, 1), + 'black': (0, 0, 0, 1), + 'blanchedalmond': (255, 235, 205, 1), + 'blue': (0, 0, 255, 1), + 'blueviolet': (138, 43, 226, 1), + 'brown': (165, 42, 42, 1), + 'burlywood': (222, 184, 135, 1), + 'cadetblue': (95, 158, 160, 1), + 'chartreuse': (127, 255, 0, 1), + 'chocolate': (210, 105, 30, 1), + 'coral': (255, 127, 80, 1), + 'cornflowerblue': (100, 149, 237, 1), + 'cornsilk': (255, 248, 220, 1), + 'crimson': (220, 20, 60, 1), + 'cyan': (0, 255, 255, 1), + 'darkblue': (0, 0, 139, 1), + 'darkcyan': (0, 139, 139, 1), + 'darkgoldenrod': (184, 134, 11, 1), + 'darkgray': (169, 169, 169, 1), + 'darkgreen': (0, 100, 0, 1), + 'darkkhaki': (189, 183, 107, 1), + 'darkmagenta': (139, 0, 139, 1), + 'darkolivegreen': (85, 107, 47, 1), + 'darkorange': (255, 140, 0, 1), + 'darkorchid': (153, 50, 204, 1), + 'darkred': (139, 0, 0, 1), + 'darksalmon': (233, 150, 122, 1), + 'darkseagreen': (143, 188, 143, 1), + 'darkslateblue': (72, 61, 139, 1), + 'darkslategray': (47, 79, 79, 1), + 'darkturquoise': (0, 206, 209, 1), + 'darkviolet': (148, 0, 211, 1), + 'deeppink': (255, 20, 147, 1), + 'deepskyblue': (0, 191, 255, 1), + 'dimgray': (105, 105, 105, 1), + 'dodgerblue': (30, 144, 255, 1), + 'firebrick': (178, 34, 34, 1), + 'floralwhite': (255, 250, 240, 1), + 'forestgreen': (34, 139, 34, 1), + 'fuchsia': (255, 0, 255, 1), + 'gainsboro': (220, 220, 220, 1), + 'ghostwhite': (248, 248, 255, 1), + 'gold': (255, 215, 0, 1), + 'goldenrod': (218, 165, 32, 1), + 'gray': (128, 128, 128, 1), + 'green': (0, 128, 0, 1), + 'greenyellow': (173, 255, 47, 1), + 'honeydew': (240, 255, 240, 1), + 'hotpink': (255, 105, 180, 1), + 'indianred': (205, 92, 92, 1), + 'indigo': (75, 0, 130, 1), + 'ivory': (255, 255, 240, 1), + 'khaki': (240, 230, 140, 1), + 'lavender': (230, 230, 250, 1), + 'lavenderblush': (255, 240, 245, 1), + 'lawngreen': (124, 252, 0, 1), + 'lemonchiffon': (255, 250, 205, 1), + 'lightblue': (173, 216, 230, 1), + 'lightcoral': (240, 128, 128, 1), + 'lightcyan': (224, 255, 255, 1), + 'lightgoldenrodyellow': (250, 250, 210, 1), + 'lightgreen': (144, 238, 144, 1), + 'lightgrey': (211, 211, 211, 1), + 'lightpink': (255, 182, 193, 1), + 'lightsalmon': (255, 160, 122, 1), + 'lightseagreen': (32, 178, 170, 1), + 'lightskyblue': (135, 206, 250, 1), + 'lightslategray': (119, 136, 153, 1), + 'lightsteelblue': (176, 196, 222, 1), + 'lightyellow': (255, 255, 224, 1), + 'lime': (0, 255, 0, 1), + 'limegreen': (50, 205, 50, 1), + 'linen': (250, 240, 230, 1), + 'magenta': (255, 0, 255, 1), + 'maroon': (128, 0, 0, 1), + 'mediumaquamarine': (102, 205, 170, 1), + 'mediumblue': (0, 0, 205, 1), + 'mediumorchid': (186, 85, 211, 1), + 'mediumpurple': (147, 112, 219, 1), + 'mediumseagreen': (60, 179, 113, 1), + 'mediumslateblue': (123, 104, 238, 1), + 'mediumspringgreen': (0, 250, 154, 1), + 'mediumturquoise': (72, 209, 204, 1), + 'mediumvioletred': (199, 21, 133, 1), + 'midnightblue': (25, 25, 112, 1), + 'mintcream': (245, 255, 250, 1), + 'mistyrose': (255, 228, 225, 1), + 'moccasin': (255, 228, 181, 1), + 'navajowhite': (255, 222, 173, 1), + 'navy': (0, 0, 128, 1), + 'oldlace': (253, 245, 230, 1), + 'olive': (128, 128, 0, 1), + 'olivedrab': (107, 142, 35, 1), + 'orange': (255, 165, 0, 1), + 'orangered': (255, 69, 0, 1), + 'orchid': (218, 112, 214, 1), + 'palegoldenrod': (238, 232, 170, 1), + 'palegreen': (152, 251, 152, 1), + 'paleturquoise': (175, 238, 238, 1), + 'palevioletred': (219, 112, 147, 1), + 'papayawhip': (255, 239, 213, 1), + 'peachpuff': (255, 218, 185, 1), + 'peru': (205, 133, 63, 1), + 'pink': (255, 192, 203, 1), + 'plum': (221, 160, 221, 1), + 'powderblue': (176, 224, 230, 1), + 'purple': (128, 0, 128, 1), + 'red': (255, 0, 0, 1), + 'rosybrown': (188, 143, 143, 1), + 'royalblue': (65, 105, 225, 1), + 'saddlebrown': (139, 69, 19, 1), + 'salmon': (250, 128, 114, 1), + 'sandybrown': (244, 164, 96, 1), + 'seagreen': (46, 139, 87, 1), + 'seashell': (255, 245, 238, 1), + 'sienna': (160, 82, 45, 1), + 'silver': (192, 192, 192, 1), + 'skyblue': (135, 206, 235, 1), + 'slateblue': (106, 90, 205, 1), + 'slategray': (112, 128, 144, 1), + 'snow': (255, 250, 250, 1), + 'springgreen': (0, 255, 127, 1), + 'steelblue': (70, 130, 180, 1), + 'tan': (210, 180, 140, 1), + 'teal': (0, 128, 128, 1), + 'thistle': (216, 191, 216, 1), + 'tomato': (255, 99, 71, 1), + 'transparent': (0, 0, 0, 0), + 'turquoise': (64, 224, 208, 1), + 'violet': (238, 130, 238, 1), + 'wheat': (245, 222, 179, 1), + 'white': (255, 255, 255, 1), + 'whitesmoke': (245, 245, 245, 1), + 'yellow': (255, 255, 0, 1), + 'yellowgreen': (154, 205, 50, 1), +} +COLOR_LOOKUP = dict((v, k) for (k, v) in COLOR_NAMES.items()) + +# ------------------------------------------------------------------------------ +# Built-in CSS units +# See: http://www.w3.org/TR/2013/CR-css3-values-20130730/#numeric-types + +# Maps units to a set of common units per type, with conversion factors +BASE_UNIT_CONVERSIONS = { + # Lengths + 'mm': (1, 'mm'), + 'cm': (10, 'mm'), + 'in': (25.4, 'mm'), + 'px': (25.4 / 96, 'mm'), + 'pt': (25.4 / 72, 'mm'), + 'pc': (25.4 / 6, 'mm'), + + # Angles + 'deg': (1 / 360, 'turn'), + 'grad': (1 / 400, 'turn'), + 'rad': (pi / 2, 'turn'), + 'turn': (1, 'turn'), + + # Times + 'ms': (1, 'ms'), + 's': (1000, 'ms'), + + # Frequencies + 'hz': (1, 'hz'), + 'khz': (1000, 'hz'), + + # Resolutions + 'dpi': (1, 'dpi'), + 'dpcm': (2.54, 'dpi'), + 'dppx': (96, 'dpi'), +} + + +def get_conversion_factor(unit): + """Look up the "base" unit for this unit and the factor for converting to + it. + + Returns a 2-tuple of `factor, base_unit`. + """ + if unit in BASE_UNIT_CONVERSIONS: + return BASE_UNIT_CONVERSIONS[unit] + else: + return 1, unit + + +def convert_units_to_base_units(units): + """Convert a set of units into a set of "base" units. + + Returns a 2-tuple of `factor, new_units`. + """ + total_factor = 1 + new_units = [] + for unit in units: + if unit not in BASE_UNIT_CONVERSIONS: + continue + + factor, new_unit = BASE_UNIT_CONVERSIONS[unit] + total_factor *= factor + new_units.append(new_unit) + + new_units.sort() + return total_factor, tuple(new_units) + + +def count_base_units(units): + """Returns a dict mapping names of base units to how many times they + appear in the given iterable of units. Effectively this counts how + many length units you have, how many time units, and so forth. + """ + ret = {} + for unit in units: + factor, base_unit = get_conversion_factor(unit) + + ret.setdefault(base_unit, 0) + ret[base_unit] += 1 + + return ret + + +def cancel_base_units(units, to_remove): + """Given a list of units, remove a specified number of each base unit. + + Arguments: + units: an iterable of units + to_remove: a mapping of base_unit => count, such as that returned from + count_base_units + + Returns a 2-tuple of (factor, remaining_units). + """ + + # Copy the dict since we're about to mutate it + to_remove = to_remove.copy() + remaining_units = [] + total_factor = 1 + + for unit in units: + factor, base_unit = get_conversion_factor(unit) + if not to_remove.get(base_unit, 0): + remaining_units.append(unit) + continue + + total_factor *= factor + to_remove[base_unit] -= 1 + + return total_factor, remaining_units + + +# A fixed set of units can be omitted when the value is 0 +# See: http://www.w3.org/TR/2013/CR-css3-values-20130730/#lengths +ZEROABLE_UNITS = frozenset(( + # Relative lengths + 'em', 'ex', 'ch', 'rem', + # Viewport + 'vw', 'vh', 'vmin', 'vmax', + # Absolute lengths + 'cm', 'mm', 'in', 'px', 'pt', 'pc', +)) + + +# ------------------------------------------------------------------------------ +# Built-in CSS function reference + +# Known function names +BUILTIN_FUNCTIONS = frozenset([ + # CSS2 + 'attr', 'counter', 'counters', 'url', 'rgb', 'rect', + + # CSS3 values: http://www.w3.org/TR/css3-values/ + 'calc', 'min', 'max', 'cycle', + + # CSS3 colors: http://www.w3.org/TR/css3-color/ + 'rgba', 'hsl', 'hsla', + + # CSS3 fonts: http://www.w3.org/TR/css3-fonts/ + 'local', 'format', + + # CSS3 images: http://www.w3.org/TR/css3-images/ + 'image', 'element', + 'linear-gradient', 'radial-gradient', + 'repeating-linear-gradient', 'repeating-radial-gradient', + + # CSS3 transforms: http://www.w3.org/TR/css3-transforms/ + 'perspective', + 'matrix', 'matrix3d', + 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'rotate3d', + 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d', + 'scale', 'scaleX', 'scaleY', 'scaleZ', 'scale3d', + 'skew', 'skewX', 'skewY', + + # CSS3 transitions: http://www.w3.org/TR/css3-transitions/ + 'cubic-bezier', 'steps', + + # CSS filter effects: + # https://dvcs.w3.org/hg/FXTF/raw-file/tip/filters/index.html + 'grayscale', 'sepia', 'saturate', 'hue-rotate', 'invert', 'opacity', + 'brightness', 'contrast', 'blur', 'drop-shadow', 'custom', + + # CSS4 image module: + # http://dev.w3.org/csswg/css-images/ + 'image-set', 'cross-fade', + 'conic-gradient', 'repeating-conic-gradient', + + # Others + 'color-stop', # Older version of CSS3 gradients + 'mask', # ??? + 'from', 'to', # Very old WebKit gradient syntax +]) + + +def is_builtin_css_function(name): + """Returns whether the given `name` looks like the name of a builtin CSS + function. + + Unrecognized functions not in this list produce warnings. + """ + name = name.replace('_', '-') + + if name in BUILTIN_FUNCTIONS: + return True + + # Vendor-specific functions (-foo-bar) are always okay + if name[0] == '-' and '-' in name[1:]: + return True + + return False + +# ------------------------------------------------------------------------------ +# Bits and pieces of grammar, as regexen + +SEPARATOR = '\x00' + +_expr_glob_re = re.compile(r''' + \#\{(.*?)\} # Global Interpolation only +''', re.VERBOSE) + +# XXX these still need to be fixed; the //-in-functions thing is a chumpy hack +_ml_comment_re = re.compile(r'\/\*(.*?)\*\/', re.DOTALL) +_sl_comment_re = re.compile(r'(? 1: + log.error("Undefined variable '%s'", n, extra={'stack': True}) + return n + else: + if v: + if not isinstance(v, six.string_types): + v = v.render() + # TODO this used to test for _dequote + if m.group(1): + v = dequote(v) + else: + v = m.group(0) + return v + + cont = _interpolate_re.sub(_av, cont) + # TODO this is surprising and shouldn't be here + cont = self.do_glob_math(cont) + return cont + + def calculate(self, _base_str, divide=False): + better_expr_str = _base_str + + better_expr_str = self.do_glob_math(better_expr_str) + + better_expr_str = self.evaluate_expression(better_expr_str, divide=divide) + + if better_expr_str is None: + better_expr_str = String.unquoted(self.apply_vars(_base_str)) + + return better_expr_str + + # TODO only used by magic-import...? + def interpolate(self, var): + value = self.namespace.variable(var) + if var != value and isinstance(value, six.string_types): + _vi = self.evaluate_expression(value) + if _vi is not None: + value = _vi + return value + + def evaluate_expression(self, expr, divide=False): + try: + ast = self.parse_expression(expr) + except SassError: + if config.DEBUG: + raise + else: + return None + + try: + return ast.evaluate(self, divide=divide) + except Exception as e: + raise SassEvaluationError(e, expression=expr) + + def parse_expression(self, expr, target='goal'): + if not isinstance(expr, six.string_types): + raise TypeError("Expected string, got %r" % (expr,)) + + key = (target, expr) + if key in self.ast_cache: + return self.ast_cache[key] + + try: + parser = SassExpression(SassExpressionScanner(expr)) + ast = getattr(parser, target)() + except SyntaxError as e: + raise SassParseError(e, expression=expr, expression_pos=parser._char_pos) + + self.ast_cache[key] = ast + return ast + + +# ------------------------------------------------------------------------------ +# Expression classes -- the AST resulting from a parse + +class Expression(object): + def __repr__(self): + return '<%s()>' % (self.__class__.__name__) + + def evaluate(self, calculator, divide=False): + """Evaluate this AST node, and return a Sass value. + + `divide` indicates whether a descendant node representing a division + should be forcibly treated as a division. See the commentary in + `BinaryOp`. + """ + raise NotImplementedError + + +class Parentheses(Expression): + """An expression of the form `(foo)`. + + Only exists to force a slash to be interpreted as division when contained + within parentheses. + """ + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.contents)) + + def __init__(self, contents): + self.contents = contents + + def evaluate(self, calculator, divide=False): + return self.contents.evaluate(calculator, divide=True) + + +class UnaryOp(Expression): + def __repr__(self): + return '<%s(%s, %s)>' % (self.__class__.__name__, repr(self.op), repr(self.operand)) + + def __init__(self, op, operand): + self.op = op + self.operand = operand + + def evaluate(self, calculator, divide=False): + return self.op(self.operand.evaluate(calculator, divide=True)) + + +class BinaryOp(Expression): + def __repr__(self): + return '<%s(%s, %s, %s)>' % (self.__class__.__name__, repr(self.op), repr(self.left), repr(self.right)) + + def __init__(self, op, left, right): + self.op = op + self.left = left + self.right = right + + def evaluate(self, calculator, divide=False): + left = self.left.evaluate(calculator, divide=True) + right = self.right.evaluate(calculator, divide=True) + + # Special handling of division: treat it as a literal slash if both + # operands are literals, there are parentheses, or this is part of a + # bigger expression. + # The first condition is covered by the type check. The other two are + # covered by the `divide` argument: other nodes that perform arithmetic + # will pass in True, indicating that this should always be a division. + if ( + self.op is operator.truediv + and not divide + and isinstance(self.left, Literal) + and isinstance(self.right, Literal) + ): + return String(left.render() + ' / ' + right.render(), quotes=None) + + return self.op(left, right) + + +class AnyOp(Expression): + def __repr__(self): + return '<%s(*%s)>' % (self.__class__.__name__, repr(self.op), repr(self.operands)) + + def __init__(self, *operands): + self.operands = operands + + def evaluate(self, calculator, divide=False): + for operand in self.operands: + value = operand.evaluate(calculator, divide=True) + if value: + return value + return value + + +class AllOp(Expression): + def __repr__(self): + return '<%s(*%s)>' % (self.__class__.__name__, repr(self.operands)) + + def __init__(self, *operands): + self.operands = operands + + def evaluate(self, calculator, divide=False): + for operand in self.operands: + value = operand.evaluate(calculator, divide=True) + if not value: + return value + return value + + +class NotOp(Expression): + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.operand)) + + def __init__(self, operand): + self.operand = operand + + def evaluate(self, calculator, divide=False): + operand = self.operand.evaluate(calculator, divide=True) + return Boolean(not(operand)) + + +class CallOp(Expression): + def __repr__(self): + return '<%s(%s, %s)>' % (self.__class__.__name__, repr(self.func_name), repr(self.argspec)) + + def __init__(self, func_name, argspec): + self.func_name = func_name + self.argspec = argspec + + def evaluate(self, calculator, divide=False): + # TODO bake this into the context and options "dicts", plus library + func_name = normalize_var(self.func_name) + + argspec_node = self.argspec + + # Turn the pairs of arg tuples into *args and **kwargs + # TODO unclear whether this is correct -- how does arg, kwarg, arg + # work? + args, kwargs = argspec_node.evaluate_call_args(calculator) + argspec_len = len(args) + len(kwargs) + + # Translate variable names to Python identifiers + # TODO what about duplicate kw names? should this happen in argspec? + # how does that affect mixins? + kwargs = dict( + (key.lstrip('$').replace('-', '_'), value) + for key, value in kwargs.items()) + + # TODO merge this with the library + funct = None + try: + funct = calculator.namespace.function(func_name, argspec_len) + # @functions take a ns as first arg. TODO: Python functions possibly + # should too + if getattr(funct, '__name__', None) == '__call': + funct = partial(funct, calculator.namespace) + except KeyError: + try: + # DEVIATION: Fall back to single parameter + funct = calculator.namespace.function(func_name, 1) + args = [List(args, use_comma=True)] + except KeyError: + if not is_builtin_css_function(func_name): + log.error("Function not found: %s:%s", func_name, argspec_len, extra={'stack': True}) + + if funct: + ret = funct(*args, **kwargs) + if not isinstance(ret, Value): + raise TypeError("Expected Sass type as return value, got %r" % (ret,)) + return ret + + # No matching function found, so render the computed values as a CSS + # function call. Slurpy arguments are expanded and named arguments are + # unsupported. + if kwargs: + raise TypeError("The CSS function %s doesn't support keyword arguments." % (func_name,)) + + # TODO another candidate for a "function call" sass type + rendered_args = [arg.render() for arg in args] + + return String( + u"%s(%s)" % (func_name, u", ".join(rendered_args)), + quotes=None) + + +class Literal(Expression): + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.value)) + + def __init__(self, value): + if isinstance(value, Undefined) and config.FATAL_UNDEFINED: + raise SyntaxError("Undefined literal.") + else: + self.value = value + + def evaluate(self, calculator, divide=False): + return self.value + + +class Variable(Expression): + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.name)) + + def __init__(self, name): + self.name = name + + def evaluate(self, calculator, divide=False): + try: + value = calculator.namespace.variable(self.name) + except KeyError: + if config.FATAL_UNDEFINED: + raise SyntaxError("Undefined variable: '%s'." % self.name) + else: + if config.VERBOSITY > 1: + log.error("Undefined variable '%s'", self.name, extra={'stack': True}) + return Undefined() + else: + if isinstance(value, six.string_types): + evald = calculator.evaluate_expression(value) + if evald is not None: + return evald + return value + + +class ListLiteral(Expression): + def __repr__(self): + return '<%s(%s, comma=%s)>' % (self.__class__.__name__, repr(self.items), repr(self.comma)) + + def __init__(self, items, comma=True): + self.items = items + self.comma = comma + + def evaluate(self, calculator, divide=False): + items = [item.evaluate(calculator, divide=divide) for item in self.items] + + # Whether this is a "plain" literal matters for null removal: nulls are + # left alone if this is a completely vanilla CSS property + is_literal = True + if divide: + # TODO sort of overloading "divide" here... rename i think + is_literal = False + elif not all(isinstance(item, Literal) for item in self.items): + is_literal = False + + return List(items, use_comma=self.comma, is_literal=is_literal) + + +class MapLiteral(Expression): + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.pairs)) + + def __init__(self, pairs): + self.pairs = tuple((var, value) for var, value in pairs if value is not None) + + def evaluate(self, calculator, divide=False): + scss_pairs = [] + for key, value in self.pairs: + scss_pairs.append(( + key.evaluate(calculator), + value.evaluate(calculator), + )) + + return Map(scss_pairs) + + +class ArgspecLiteral(Expression): + """Contains pairs of argument names and values, as parsed from a function + definition or function call. + + Note that the semantics are somewhat ambiguous. Consider parsing: + + $foo, $bar: 3 + + If this appeared in a function call, $foo would refer to a value; if it + appeared in a function definition, $foo would refer to an existing + variable. This it's up to the caller to use the right iteration function. + """ + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.argpairs)) + + def __init__(self, argpairs, slurp=None): + # argpairs is a list of 2-tuples, parsed as though this were a function + # call, so (variable name as string or None, default value as AST + # node). + # slurp is the name of a variable to receive slurpy arguments. + self.argpairs = tuple(argpairs) + if slurp is all: + # DEVIATION: special syntax to allow injecting arbitrary arguments + # from the caller to the callee + self.inject = True + self.slurp = None + elif slurp: + self.inject = False + self.slurp = Variable(slurp) + else: + self.inject = False + self.slurp = None + + def iter_list_argspec(self): + yield None, ListLiteral(zip(*self.argpairs)[1]) + + def iter_def_argspec(self): + """Interpreting this literal as a function definition, yields pairs of + (variable name as a string, default value as an AST node or None). + """ + started_kwargs = False + seen_vars = set() + + for var, value in self.argpairs: + if var is None: + # value is actually the name + var = value + value = None + + if started_kwargs: + raise SyntaxError( + "Required argument %r must precede optional arguments" + % (var.name,)) + + else: + started_kwargs = True + + if not isinstance(var, Variable): + raise SyntaxError("Expected variable name, got %r" % (var,)) + + if var.name in seen_vars: + raise SyntaxError("Duplicate argument %r" % (var.name,)) + seen_vars.add(var.name) + + yield var.name, value + + def evaluate_call_args(self, calculator): + """Interpreting this literal as a function call, return a 2-tuple of + ``(args, kwargs)``. + """ + args = [] + kwargs = {} + for var_node, value_node in self.argpairs: + value = value_node.evaluate(calculator, divide=True) + if var_node is None: + # Positional + args.append(value) + else: + # Named + if not isinstance(var_node, Variable): + raise SyntaxError("Expected variable name, got %r" % (var_node,)) + kwargs[var_node.name] = value + + # Slurpy arguments go on the end of the args + if self.slurp: + args.extend(self.slurp.evaluate(calculator, divide=True)) + + return args, kwargs + + +def parse_bareword(word): + if word in COLOR_NAMES: + return Color.from_name(word) + elif word == 'null': + return Null() + elif word == 'undefined': + return Undefined() + elif word == 'true': + return Boolean(True) + elif word == 'false': + return Boolean(False) + else: + return String(word, quotes=None) + + +class Parser(object): + def __init__(self, scanner): + self._scanner = scanner + self._pos = 0 + self._char_pos = 0 + + def reset(self, input): + self._scanner.reset(input) + self._pos = 0 + self._char_pos = 0 + + def _peek(self, types): + """ + Returns the token type for lookahead; if there are any args + then the list of args is the set of token types to allow + """ + try: + tok = self._scanner.token(self._pos, types) + return tok[2] + except SyntaxError: + return None + + def _scan(self, type): + """ + Returns the matched text, and moves to the next token + """ + tok = self._scanner.token(self._pos, set([type])) + self._char_pos = tok[0] + if tok[2] != type: + raise SyntaxError("SyntaxError[@ char %s: %s]" % (repr(tok[0]), "Trying to find " + type)) + self._pos += 1 + return tok[3] + + +################################################################################ +## Grammar compiled using Yapps: + +class SassExpressionScanner(Scanner): + patterns = None + _patterns = [ + ('":"', ':'), + ('","', ','), + ('[ \r\t\n]+', '[ \r\t\n]+'), + ('LPAR', '\\(|\\['), + ('RPAR', '\\)|\\]'), + ('END', '$'), + ('MUL', '[*]'), + ('DIV', '/'), + ('ADD', '[+]'), + ('SUB', '-\\s'), + ('SIGN', '-(?![a-zA-Z_])'), + ('AND', '(?='), + ('LT', '<'), + ('GT', '>'), + ('DOTDOTDOT', '[.]{3}'), + ('KWSTR', "'[^']*'(?=\\s*:)"), + ('STR', "'[^']*'"), + ('KWQSTR', '"[^"]*"(?=\\s*:)'), + ('QSTR', '"[^"]*"'), + ('UNITS', '(? 1 else v[0] + + def expr_slst(self): + or_expr = self.or_expr() + v = [or_expr] + while self._peek(self.expr_slst_rsts) not in self.argspec_items_rsts: + or_expr = self.or_expr() + v.append(or_expr) + return ListLiteral(v, comma=False) if len(v) > 1 else v[0] + + def or_expr(self): + and_expr = self.and_expr() + v = and_expr + while self._peek(self.or_expr_rsts) == 'OR': + OR = self._scan('OR') + and_expr = self.and_expr() + v = AnyOp(v, and_expr) + return v + + def and_expr(self): + not_expr = self.not_expr() + v = not_expr + while self._peek(self.and_expr_rsts) == 'AND': + AND = self._scan('AND') + not_expr = self.not_expr() + v = AllOp(v, not_expr) + return v + + def not_expr(self): + _token_ = self._peek(self.argspec_item_chks) + if _token_ != 'NOT': + comparison = self.comparison() + return comparison + else: # == 'NOT' + NOT = self._scan('NOT') + not_expr = self.not_expr() + return NotOp(not_expr) + + def comparison(self): + a_expr = self.a_expr() + v = a_expr + while self._peek(self.comparison_rsts) in self.comparison_chks: + _token_ = self._peek(self.comparison_chks) + if _token_ == 'LT': + LT = self._scan('LT') + a_expr = self.a_expr() + v = BinaryOp(operator.lt, v, a_expr) + elif _token_ == 'GT': + GT = self._scan('GT') + a_expr = self.a_expr() + v = BinaryOp(operator.gt, v, a_expr) + elif _token_ == 'LE': + LE = self._scan('LE') + a_expr = self.a_expr() + v = BinaryOp(operator.le, v, a_expr) + elif _token_ == 'GE': + GE = self._scan('GE') + a_expr = self.a_expr() + v = BinaryOp(operator.ge, v, a_expr) + elif _token_ == 'EQ': + EQ = self._scan('EQ') + a_expr = self.a_expr() + v = BinaryOp(operator.eq, v, a_expr) + else: # == 'NE' + NE = self._scan('NE') + a_expr = self.a_expr() + v = BinaryOp(operator.ne, v, a_expr) + return v + + def a_expr(self): + m_expr = self.m_expr() + v = m_expr + while self._peek(self.a_expr_rsts) in self.a_expr_chks: + _token_ = self._peek(self.a_expr_chks) + if _token_ == 'ADD': + ADD = self._scan('ADD') + m_expr = self.m_expr() + v = BinaryOp(operator.add, v, m_expr) + else: # == 'SUB' + SUB = self._scan('SUB') + m_expr = self.m_expr() + v = BinaryOp(operator.sub, v, m_expr) + return v + + def m_expr(self): + u_expr = self.u_expr() + v = u_expr + while self._peek(self.m_expr_rsts) in self.m_expr_chks: + _token_ = self._peek(self.m_expr_chks) + if _token_ == 'MUL': + MUL = self._scan('MUL') + u_expr = self.u_expr() + v = BinaryOp(operator.mul, v, u_expr) + else: # == 'DIV' + DIV = self._scan('DIV') + u_expr = self.u_expr() + v = BinaryOp(operator.truediv, v, u_expr) + return v + + def u_expr(self): + _token_ = self._peek(self.u_expr_rsts) + if _token_ == 'SIGN': + SIGN = self._scan('SIGN') + u_expr = self.u_expr() + return UnaryOp(operator.neg, u_expr) + elif _token_ == 'ADD': + ADD = self._scan('ADD') + u_expr = self.u_expr() + return UnaryOp(operator.pos, u_expr) + else: # in self.u_expr_chks + atom = self.atom() + return atom + + def atom(self): + _token_ = self._peek(self.u_expr_chks) + if _token_ == 'LPAR': + LPAR = self._scan('LPAR') + _token_ = self._peek(self.atom_rsts) + if _token_ not in self.argspec_item_chks: + expr_map = self.expr_map() + v = expr_map + else: # in self.argspec_item_chks + expr_lst = self.expr_lst() + v = expr_lst + RPAR = self._scan('RPAR') + return Parentheses(v) + elif _token_ == 'FNCT': + FNCT = self._scan('FNCT') + LPAR = self._scan('LPAR') + argspec = self.argspec() + RPAR = self._scan('RPAR') + return CallOp(FNCT, argspec) + elif _token_ == 'BANG_IMPORTANT': + BANG_IMPORTANT = self._scan('BANG_IMPORTANT') + return Literal(String(BANG_IMPORTANT, quotes=None)) + elif _token_ == 'ID': + ID = self._scan('ID') + return Literal(parse_bareword(ID)) + elif _token_ == 'NUM': + NUM = self._scan('NUM') + UNITS = None + if self._peek(self.atom_rsts_) == 'UNITS': + UNITS = self._scan('UNITS') + return Literal(Number(float(NUM), unit=UNITS)) + elif _token_ == 'STR': + STR = self._scan('STR') + return Literal(String(STR[1:-1], quotes="'")) + elif _token_ == 'QSTR': + QSTR = self._scan('QSTR') + return Literal(String(QSTR[1:-1], quotes='"')) + elif _token_ == 'COLOR': + COLOR = self._scan('COLOR') + return Literal(Color.from_hex(COLOR, literal=True)) + else: # == 'VAR' + VAR = self._scan('VAR') + return Variable(VAR) + + def kwatom(self): + _token_ = self._peek(self.kwatom_rsts) + if _token_ == '":"': + pass + elif _token_ == 'KWID': + KWID = self._scan('KWID') + return Literal(parse_bareword(KWID)) + elif _token_ == 'KWNUM': + KWNUM = self._scan('KWNUM') + UNITS = None + if self._peek(self.kwatom_rsts_) == 'UNITS': + UNITS = self._scan('UNITS') + return Literal(Number(float(KWNUM), unit=UNITS)) + elif _token_ == 'KWSTR': + KWSTR = self._scan('KWSTR') + return Literal(String(KWSTR[1:-1], quotes="'")) + elif _token_ == 'KWQSTR': + KWQSTR = self._scan('KWQSTR') + return Literal(String(KWQSTR[1:-1], quotes='"')) + elif _token_ == 'KWCOLOR': + KWCOLOR = self._scan('KWCOLOR') + return Literal(Color.from_hex(COLOR, literal=True)) + else: # == 'KWVAR' + KWVAR = self._scan('KWVAR') + return Variable(KWVAR) + + u_expr_chks = set(['LPAR', 'COLOR', 'QSTR', 'NUM', 'FNCT', 'STR', 'VAR', 'BANG_IMPORTANT', 'ID']) + m_expr_rsts = set(['LPAR', 'SUB', 'QSTR', 'RPAR', 'MUL', 'DIV', 'BANG_IMPORTANT', 'LE', 'COLOR', 'NE', 'LT', 'NUM', 'GT', 'END', 'SIGN', 'GE', 'FNCT', 'STR', 'VAR', 'EQ', 'ID', 'AND', 'ADD', 'NOT', 'OR', '","']) + argspec_items_rsts = set(['RPAR', 'END', '","']) + expr_map_rsts = set(['RPAR', '","']) + argspec_items_rsts__ = set(['KWVAR', 'LPAR', 'QSTR', 'SLURPYVAR', 'COLOR', 'DOTDOTDOT', 'SIGN', 'VAR', 'ADD', 'NUM', 'FNCT', 'STR', 'NOT', 'BANG_IMPORTANT', 'ID']) + kwatom_rsts = set(['KWVAR', 'KWID', 'KWSTR', 'KWQSTR', 'KWCOLOR', '":"', 'KWNUM']) + argspec_item_chks = set(['LPAR', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'FNCT', 'STR', 'NOT', 'BANG_IMPORTANT', 'ID']) + a_expr_chks = set(['ADD', 'SUB']) + expr_slst_rsts = set(['LPAR', 'END', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'RPAR', 'FNCT', 'STR', 'NOT', 'BANG_IMPORTANT', 'ID', '","']) + or_expr_rsts = set(['LPAR', 'END', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'RPAR', 'FNCT', 'STR', 'NOT', 'ID', 'BANG_IMPORTANT', 'OR', '","']) + and_expr_rsts = set(['AND', 'LPAR', 'END', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'RPAR', 'FNCT', 'STR', 'NOT', 'ID', 'BANG_IMPORTANT', 'OR', '","']) + comparison_rsts = set(['LPAR', 'QSTR', 'RPAR', 'BANG_IMPORTANT', 'LE', 'COLOR', 'NE', 'LT', 'NUM', 'GT', 'END', 'SIGN', 'ADD', 'FNCT', 'STR', 'VAR', 'EQ', 'ID', 'AND', 'GE', 'NOT', 'OR', '","']) + argspec_chks = set(['DOTDOTDOT', 'SLURPYVAR']) + atom_rsts_ = set(['LPAR', 'SUB', 'QSTR', 'RPAR', 'VAR', 'MUL', 'DIV', 'BANG_IMPORTANT', 'LE', 'COLOR', 'NE', 'LT', 'NUM', 'GT', 'END', 'SIGN', 'GE', 'FNCT', 'STR', 'UNITS', 'EQ', 'ID', 'AND', 'ADD', 'NOT', 'OR', '","']) + expr_map_rsts_ = set(['KWVAR', 'KWID', 'KWSTR', 'KWQSTR', 'RPAR', 'KWCOLOR', '":"', 'KWNUM', '","']) + u_expr_rsts = set(['LPAR', 'COLOR', 'QSTR', 'SIGN', 'ADD', 'NUM', 'FNCT', 'STR', 'VAR', 'BANG_IMPORTANT', 'ID']) + comparison_chks = set(['GT', 'GE', 'NE', 'LT', 'LE', 'EQ']) + argspec_items_rsts_ = set(['KWVAR', 'LPAR', 'QSTR', 'END', 'SLURPYVAR', 'COLOR', 'DOTDOTDOT', 'SIGN', 'VAR', 'ADD', 'NUM', 'RPAR', 'FNCT', 'STR', 'NOT', 'BANG_IMPORTANT', 'ID']) + a_expr_rsts = set(['LPAR', 'SUB', 'QSTR', 'RPAR', 'BANG_IMPORTANT', 'LE', 'COLOR', 'NE', 'LT', 'NUM', 'GT', 'END', 'SIGN', 'GE', 'FNCT', 'STR', 'VAR', 'EQ', 'ID', 'AND', 'ADD', 'NOT', 'OR', '","']) + m_expr_chks = set(['MUL', 'DIV']) + kwatom_rsts_ = set(['UNITS', '":"']) + argspec_items_chks = set(['KWVAR', 'LPAR', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'FNCT', 'STR', 'NOT', 'BANG_IMPORTANT', 'ID']) + argspec_rsts = set(['KWVAR', 'LPAR', 'BANG_IMPORTANT', 'END', 'SLURPYVAR', 'COLOR', 'DOTDOTDOT', 'RPAR', 'VAR', 'ADD', 'NUM', 'FNCT', 'STR', 'NOT', 'QSTR', 'SIGN', 'ID']) + atom_rsts = set(['KWVAR', 'KWID', 'KWSTR', 'BANG_IMPORTANT', 'LPAR', 'COLOR', 'KWQSTR', 'SIGN', 'KWCOLOR', 'VAR', 'ADD', 'NUM', '":"', 'STR', 'NOT', 'QSTR', 'KWNUM', 'ID', 'FNCT']) + argspec_chks_ = set(['END', 'RPAR']) + argspec_rsts_ = set(['KWVAR', 'LPAR', 'BANG_IMPORTANT', 'END', 'COLOR', 'QSTR', 'SIGN', 'VAR', 'ADD', 'NUM', 'FNCT', 'STR', 'NOT', 'RPAR', 'ID']) + + +### Grammar ends. +################################################################################ + +__all__ = ('Calculator',) diff --git a/libs/scss/functions/__init__.py b/libs/scss/functions/__init__.py new file mode 100644 index 00000000..0d76eaf0 --- /dev/null +++ b/libs/scss/functions/__init__.py @@ -0,0 +1,25 @@ +from __future__ import absolute_import + +from scss.functions.library import FunctionLibrary + +from scss.functions.core import CORE_LIBRARY +from scss.functions.extra import EXTRA_LIBRARY +from scss.functions.compass.sprites import COMPASS_SPRITES_LIBRARY +from scss.functions.compass.gradients import COMPASS_GRADIENTS_LIBRARY +from scss.functions.compass.helpers import COMPASS_HELPERS_LIBRARY +from scss.functions.compass.images import COMPASS_IMAGES_LIBRARY + + +ALL_BUILTINS_LIBRARY = FunctionLibrary() +ALL_BUILTINS_LIBRARY.inherit( + CORE_LIBRARY, + EXTRA_LIBRARY, + COMPASS_GRADIENTS_LIBRARY, + COMPASS_HELPERS_LIBRARY, + COMPASS_IMAGES_LIBRARY, + COMPASS_SPRITES_LIBRARY, +) + +# TODO back-compat for the only codebase still using the old name :) +FunctionRegistry = FunctionLibrary +scss_builtins = ALL_BUILTINS_LIBRARY diff --git a/libs/scss/functions/compass/__init__.py b/libs/scss/functions/compass/__init__.py new file mode 100644 index 00000000..73cb9df9 --- /dev/null +++ b/libs/scss/functions/compass/__init__.py @@ -0,0 +1,2 @@ +# Global cache of image sizes, shared between sprites and images libraries. +_image_size_cache = {} diff --git a/libs/scss/functions/compass/gradients.py b/libs/scss/functions/compass/gradients.py new file mode 100644 index 00000000..6f2b5b83 --- /dev/null +++ b/libs/scss/functions/compass/gradients.py @@ -0,0 +1,400 @@ +"""Utilities for working with gradients. Inspired by Compass, but not quite +the same. +""" + +from __future__ import absolute_import + +import base64 +import logging + +import six + +from scss.functions.library import FunctionLibrary +from scss.functions.compass.helpers import opposite_position, position +from scss.types import Color, List, Number, String +from scss.util import escape, split_params, to_float, to_str + +log = logging.getLogger(__name__) + +COMPASS_GRADIENTS_LIBRARY = FunctionLibrary() +register = COMPASS_GRADIENTS_LIBRARY.register + + +# ------------------------------------------------------------------------------ + +def __color_stops(percentages, *args): + if len(args) == 1: + if isinstance(args[0], (list, tuple, List)): + list(args[0]) + elif isinstance(args[0], (String, six.string_types)): + color_stops = [] + colors = split_params(getattr(args[0], 'value', args[0])) + for color in colors: + color = color.strip() + if color.startswith('color-stop('): + s, c = split_params(color[11:].rstrip(')')) + s = s.strip() + c = c.strip() + else: + c, s = color.split() + color_stops.append((to_float(s), c)) + return color_stops + + colors = [] + stops = [] + prev_color = False + for c in args: + for c in List.from_maybe(c): + if isinstance(c, Color): + if prev_color: + stops.append(None) + colors.append(c) + prev_color = True + elif isinstance(c, Number): + stops.append(c) + prev_color = False + + if prev_color: + stops.append(None) + stops = stops[:len(colors)] + if stops[0] is None: + stops[0] = Number(0, '%') + if stops[-1] is None: + stops[-1] = Number(100, '%') + + maxable_stops = [s for s in stops if s and not s.is_simple_unit('%')] + if maxable_stops: + max_stops = max(maxable_stops) + else: + max_stops = None + + stops = [_s / max_stops if _s and not _s.is_simple_unit('%') else _s for _s in stops] + + init = 0 + start = None + for i, s in enumerate(stops + [1.0]): + if s is None: + if start is None: + start = i + end = i + else: + final = s + if start is not None: + stride = (final - init) / Number(end - start + 1 + (1 if i < len(stops) else 0)) + for j in range(start, end + 1): + stops[j] = init + stride * Number(j - start + 1) + init = final + start = None + + if not max_stops or percentages: + pass + else: + stops = [s if s.is_simple_unit('%') else s * max_stops for s in stops] + + return list(zip(stops, colors)) + + +def _render_standard_color_stops(color_stops): + pairs = [] + for i, (stop, color) in enumerate(color_stops): + if ((i == 0 and stop == Number(0, '%')) or + (i == len(color_stops) - 1 and stop == Number(100, '%'))): + pairs.append(color) + else: + pairs.append(List([color, stop], use_comma=False)) + + return List(pairs, use_comma=True) + + +@register('grad-color-stops') +def grad_color_stops(*args): + args = List.from_maybe_starargs(args) + color_stops = __color_stops(True, *args) + ret = ', '.join(['color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops]) + return String.unquoted(ret) + + +def __grad_end_position(radial, color_stops): + return __grad_position(-1, 100, radial, color_stops) + + +@register('grad-point') +def grad_point(*p): + pos = set() + hrz = vrt = Number(0.5, '%') + for _p in p: + pos.update(String.unquoted(_p).value.split()) + if 'left' in pos: + hrz = Number(0, '%') + elif 'right' in pos: + hrz = Number(1, '%') + if 'top' in pos: + vrt = Number(0, '%') + elif 'bottom' in pos: + vrt = Number(1, '%') + return List([v for v in (hrz, vrt) if v is not None]) + + +def __grad_position(index, default, radial, color_stops): + try: + stops = Number(color_stops[index][0]) + if radial and not stops.is_simple_unit('px') and (index == 0 or index == -1 or index == len(color_stops) - 1): + log.warn("Webkit only supports pixels for the start and end stops for radial gradients. Got %s", stops) + except IndexError: + stops = Number(default) + return stops + + +@register('grad-end-position') +def grad_end_position(*color_stops): + color_stops = __color_stops(False, *color_stops) + return Number(__grad_end_position(False, color_stops)) + + +@register('color-stops') +def color_stops(*args): + args = List.from_maybe_starargs(args) + color_stops = __color_stops(False, *args) + ret = ', '.join(['%s %s' % (c.render(), s.render()) for s, c in color_stops]) + return String.unquoted(ret) + + +@register('color-stops-in-percentages') +def color_stops_in_percentages(*args): + args = List.from_maybe_starargs(args) + color_stops = __color_stops(True, *args) + ret = ', '.join(['%s %s' % (c.render(), s.render()) for s, c in color_stops]) + return String.unquoted(ret) + + +def _get_gradient_position_and_angle(args): + for arg in args: + ret = None + skip = False + for a in arg: + if isinstance(a, Color): + skip = True + break + elif isinstance(a, Number): + ret = arg + if skip: + continue + if ret is not None: + return ret + for seek in ( + 'center', + 'top', 'bottom', + 'left', 'right', + ): + if String(seek) in arg: + return arg + return None + + +def _get_gradient_shape_and_size(args): + for arg in args: + for seek in ( + 'circle', 'ellipse', + 'closest-side', 'closest-corner', + 'farthest-side', 'farthest-corner', + 'contain', 'cover', + ): + if String(seek) in arg: + return arg + return None + + +def _get_gradient_color_stops(args): + color_stops = [] + for arg in args: + for a in List.from_maybe(arg): + if isinstance(a, Color): + color_stops.append(arg) + break + return color_stops or None + + +@register('radial-gradient') +def radial_gradient(*args): + args = List.from_maybe_starargs(args) + + position_and_angle = _get_gradient_position_and_angle(args) + shape_and_size = _get_gradient_shape_and_size(args) + color_stops = _get_gradient_color_stops(args) + if color_stops is None: + raise Exception('No color stops provided to radial-gradient function') + color_stops = __color_stops(False, *color_stops) + + args = [ + position(position_and_angle) if position_and_angle is not None else None, + shape_and_size if shape_and_size is not None else None, + ] + args.extend(_render_standard_color_stops(color_stops)) + + to__s = 'radial-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')' + ret = String.unquoted(to__s) + + def to__css2(): + return String.unquoted('') + ret.to__css2 = to__css2 + + def to__moz(): + return String.unquoted('-moz-' + to__s) + ret.to__moz = to__moz + + def to__pie(): + log.warn("PIE does not support radial-gradient.") + return String.unquoted('-pie-radial-gradient(unsupported)') + ret.to__pie = to__pie + + def to__webkit(): + return String.unquoted('-webkit-' + to__s) + ret.to__webkit = to__webkit + + def to__owg(): + args = [ + 'radial', + grad_point(position_and_angle) if position_and_angle is not None else 'center', + '0', + grad_point(position_and_angle) if position_and_angle is not None else 'center', + __grad_end_position(True, color_stops), + ] + args.extend('color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops) + ret = '-webkit-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')' + return String.unquoted(ret) + ret.to__owg = to__owg + + def to__svg(): + return radial_svg_gradient(color_stops, position_and_angle or 'center') + ret.to__svg = to__svg + + return ret + + +@register('linear-gradient') +def linear_gradient(*args): + args = List.from_maybe_starargs(args) + + position_and_angle = _get_gradient_position_and_angle(args) + color_stops = _get_gradient_color_stops(args) + if color_stops is None: + raise Exception('No color stops provided to linear-gradient function') + color_stops = __color_stops(False, *color_stops) + + args = [ + position(position_and_angle) if position_and_angle is not None else None, + ] + args.extend(_render_standard_color_stops(color_stops)) + + to__s = 'linear-gradient(' + ', '.join(to_str(a) for a in args or [] if a is not None) + ')' + ret = String.unquoted(to__s) + + def to__css2(): + return String.unquoted('') + ret.to__css2 = to__css2 + + def to__moz(): + return String.unquoted('-moz-' + to__s) + ret.to__moz = to__moz + + def to__pie(): + return String.unquoted('-pie-' + to__s) + ret.to__pie = to__pie + + def to__ms(): + return String.unquoted('-ms-' + to__s) + ret.to__ms = to__ms + + def to__o(): + return String.unquoted('-o-' + to__s) + ret.to__o = to__o + + def to__webkit(): + return String.unquoted('-webkit-' + to__s) + ret.to__webkit = to__webkit + + def to__owg(): + args = [ + 'linear', + position(position_and_angle or None), + opposite_position(position_and_angle or None), + ] + args.extend('color-stop(%s, %s)' % (s.render(), c.render()) for s, c in color_stops) + ret = '-webkit-gradient(' + ', '.join(to_str(a) for a in args if a is not None) + ')' + return String.unquoted(ret) + ret.to__owg = to__owg + + def to__svg(): + return linear_svg_gradient(color_stops, position_and_angle or 'top') + ret.to__svg = to__svg + + return ret + + +@register('radial-svg-gradient') +def radial_svg_gradient(*args): + args = List.from_maybe_starargs(args) + color_stops = args + center = None + if isinstance(args[-1], (String, Number, six.string_types)): + center = args[-1] + color_stops = args[:-1] + color_stops = __color_stops(False, *color_stops) + cx, cy = zip(*grad_point(center).items())[1] + r = __grad_end_position(True, color_stops) + svg = __radial_svg(color_stops, cx, cy, r) + url = 'data:' + 'image/svg+xml' + ';base64,' + base64.b64encode(svg) + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) + + +@register('linear-svg-gradient') +def linear_svg_gradient(*args): + args = List.from_maybe_starargs(args) + color_stops = args + start = None + if isinstance(args[-1], (String, Number, six.string_types)): + start = args[-1] + color_stops = args[:-1] + color_stops = __color_stops(False, *color_stops) + x1, y1 = zip(*grad_point(start).items())[1] + x2, y2 = zip(*grad_point(opposite_position(start)).items())[1] + svg = _linear_svg(color_stops, x1, y1, x2, y2) + url = 'data:' + 'image/svg+xml' + ';base64,' + base64.b64encode(svg) + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) + + +def __color_stops_svg(color_stops): + ret = ''.join('' % (to_str(s), c) for s, c in color_stops) + return ret + + +def __svg_template(gradient): + ret = '\ +\ +%s\ +\ +' % gradient + return ret + + +def _linear_svg(color_stops, x1, y1, x2, y2): + gradient = '%s' % ( + to_str(Number(x1)), + to_str(Number(y1)), + to_str(Number(x2)), + to_str(Number(y2)), + __color_stops_svg(color_stops) + ) + return __svg_template(gradient) + + +def __radial_svg(color_stops, cx, cy, r): + gradient = '%s' % ( + to_str(Number(cx)), + to_str(Number(cy)), + to_str(Number(r)), + __color_stops_svg(color_stops) + ) + return __svg_template(gradient) diff --git a/libs/scss/functions/compass/helpers.py b/libs/scss/functions/compass/helpers.py new file mode 100644 index 00000000..fe1ef6c9 --- /dev/null +++ b/libs/scss/functions/compass/helpers.py @@ -0,0 +1,654 @@ +"""Miscellaneous helper functions ported from Compass. + +See: http://compass-style.org/reference/compass/helpers/ + +This collection is not necessarily complete or up-to-date. +""" + +from __future__ import absolute_import + +import base64 +import logging +import math +import os.path +import time + +import six + +from scss import config +from scss.functions.library import FunctionLibrary +from scss.types import Boolean, List, Null, Number, String +from scss.util import escape, to_str +import re + +log = logging.getLogger(__name__) + + +COMPASS_HELPERS_LIBRARY = FunctionLibrary() +register = COMPASS_HELPERS_LIBRARY.register + +FONT_TYPES = { + 'woff': 'woff', + 'otf': 'opentype', + 'opentype': 'opentype', + 'ttf': 'truetype', + 'truetype': 'truetype', + 'svg': 'svg', + 'eot': 'embedded-opentype' +} + + +def add_cache_buster(url, mtime): + fragment = url.split('#') + query = fragment[0].split('?') + if len(query) > 1 and query[1] != '': + cb = '&_=%s' % (mtime) + url = '?'.join(query) + cb + else: + cb = '?_=%s' % (mtime) + url = query[0] + cb + if len(fragment) > 1: + url += '#' + fragment[1] + return url + + +# ------------------------------------------------------------------------------ +# Data manipulation + +@register('blank') +def blank(*objs): + """Returns true when the object is false, an empty string, or an empty list""" + for o in objs: + if isinstance(o, Boolean): + is_blank = not o + elif isinstance(o, String): + is_blank = not len(o.value.strip()) + elif isinstance(o, List): + is_blank = all(blank(el) for el in o) + else: + is_blank = False + + if not is_blank: + return Boolean(False) + + return Boolean(True) + + +@register('compact') +def compact(*args): + """Returns a new list after removing any non-true values""" + use_comma = True + if len(args) == 1 and isinstance(args[0], List): + use_comma = args[0].use_comma + args = args[0] + + return List( + [arg for arg in args if arg], + use_comma=use_comma, + ) + + +@register('reject') +def reject(lst, *values): + """Removes the given values from the list""" + lst = List.from_maybe(lst) + values = frozenset(List.from_maybe_starargs(values)) + + ret = [] + for item in lst: + if item not in values: + ret.append(item) + return List(ret, use_comma=lst.use_comma) + + +@register('first-value-of') +def first_value_of(*args): + if len(args) == 1 and isinstance(args[0], String): + first = args[0].value.split()[0] + return type(args[0])(first) + + args = List.from_maybe_starargs(args) + if len(args): + return args[0] + else: + return Null() + + +@register('-compass-list') +def dash_compass_list(*args): + return List.from_maybe_starargs(args) + + +@register('-compass-space-list') +def dash_compass_space_list(*lst): + """ + If the argument is a list, it will return a new list that is space delimited + Otherwise it returns a new, single element, space-delimited list. + """ + ret = dash_compass_list(*lst) + ret.value.pop('_', None) + return ret + + +@register('-compass-slice', 3) +def dash_compass_slice(lst, start_index, end_index=None): + start_index = Number(start_index).value + end_index = Number(end_index).value if end_index is not None else None + ret = {} + lst = List(lst) + if end_index: + # This function has an inclusive end, but Python slicing is exclusive + end_index += 1 + ret = lst.value[start_index:end_index] + return List(ret, use_comma=lst.use_comma) + + +# ------------------------------------------------------------------------------ +# Property prefixing + +@register('prefixed') +def prefixed(prefix, *args): + to_fnct_str = 'to_' + to_str(prefix).replace('-', '_') + for arg in List.from_maybe_starargs(args): + if hasattr(arg, to_fnct_str): + return Boolean(True) + return Boolean(False) + + +@register('prefix') +def prefix(prefix, *args): + to_fnct_str = 'to_' + to_str(prefix).replace('-', '_') + args = list(args) + for i, arg in enumerate(args): + if isinstance(arg, List): + _value = [] + for iarg in arg: + to_fnct = getattr(iarg, to_fnct_str, None) + if to_fnct: + _value.append(to_fnct()) + else: + _value.append(iarg) + args[i] = List(_value) + else: + to_fnct = getattr(arg, to_fnct_str, None) + if to_fnct: + args[i] = to_fnct() + + return List.maybe_new(args, use_comma=True) + + +@register('-moz') +def dash_moz(*args): + return prefix('_moz', *args) + + +@register('-svg') +def dash_svg(*args): + return prefix('_svg', *args) + + +@register('-css2') +def dash_css2(*args): + return prefix('_css2', *args) + + +@register('-pie') +def dash_pie(*args): + return prefix('_pie', *args) + + +@register('-webkit') +def dash_webkit(*args): + return prefix('_webkit', *args) + + +@register('-owg') +def dash_owg(*args): + return prefix('_owg', *args) + + +@register('-khtml') +def dash_khtml(*args): + return prefix('_khtml', *args) + + +@register('-ms') +def dash_ms(*args): + return prefix('_ms', *args) + + +@register('-o') +def dash_o(*args): + return prefix('_o', *args) + + +# ------------------------------------------------------------------------------ +# Selector generation + +@register('append-selector', 2) +def append_selector(selector, to_append): + if isinstance(selector, List): + lst = selector.value + else: + lst = String.unquoted(selector).value.split(',') + to_append = String.unquoted(to_append).value.strip() + ret = sorted(set(s.strip() + to_append for s in lst if s.strip())) + ret = dict(enumerate(ret)) + ret['_'] = ',' + return ret + + +_elements_of_type_block = 'address, article, aside, blockquote, center, dd, details, dir, div, dl, dt, fieldset, figcaption, figure, footer, form, frameset, h1, h2, h3, h4, h5, h6, header, hgroup, hr, isindex, menu, nav, noframes, noscript, ol, p, pre, section, summary, ul' +_elements_of_type_inline = 'a, abbr, acronym, audio, b, basefont, bdo, big, br, canvas, cite, code, command, datalist, dfn, em, embed, font, i, img, input, kbd, keygen, label, mark, meter, output, progress, q, rp, rt, ruby, s, samp, select, small, span, strike, strong, sub, sup, textarea, time, tt, u, var, video, wbr' +_elements_of_type_table = 'table' +_elements_of_type_list_item = 'li' +_elements_of_type_table_row_group = 'tbody' +_elements_of_type_table_header_group = 'thead' +_elements_of_type_table_footer_group = 'tfoot' +_elements_of_type_table_row = 'tr' +_elements_of_type_table_cel = 'td, th' +_elements_of_type_html5_block = 'article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary' +_elements_of_type_html5_inline = 'audio, canvas, command, datalist, embed, keygen, mark, meter, output, progress, rp, rt, ruby, time, video, wbr' +_elements_of_type_html5 = 'article, aside, audio, canvas, command, datalist, details, embed, figcaption, figure, footer, header, hgroup, keygen, mark, menu, meter, nav, output, progress, rp, rt, ruby, section, summary, time, video, wbr' +_elements_of_type = { + 'block': sorted(_elements_of_type_block.replace(' ', '').split(',')), + 'inline': sorted(_elements_of_type_inline.replace(' ', '').split(',')), + 'table': sorted(_elements_of_type_table.replace(' ', '').split(',')), + 'list-item': sorted(_elements_of_type_list_item.replace(' ', '').split(',')), + 'table-row-group': sorted(_elements_of_type_table_row_group.replace(' ', '').split(',')), + 'table-header-group': sorted(_elements_of_type_table_header_group.replace(' ', '').split(',')), + 'table-footer-group': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')), + 'table-row': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')), + 'table-cell': sorted(_elements_of_type_table_footer_group.replace(' ', '').split(',')), + 'html5-block': sorted(_elements_of_type_html5_block.replace(' ', '').split(',')), + 'html5-inline': sorted(_elements_of_type_html5_inline.replace(' ', '').split(',')), + 'html5': sorted(_elements_of_type_html5.replace(' ', '').split(',')), +} + + +@register('elements-of-type', 1) +def elements_of_type(display): + d = String.unquoted(display) + ret = _elements_of_type.get(d.value, None) + if ret is None: + raise Exception("Elements of type '%s' not found!" % d.value) + return List(ret, use_comma=True) + + +@register('enumerate', 3) +@register('enumerate', 4) +def enumerate_(prefix, frm, through, separator='-'): + separator = String.unquoted(separator).value + try: + frm = int(getattr(frm, 'value', frm)) + except ValueError: + frm = 1 + try: + through = int(getattr(through, 'value', through)) + except ValueError: + through = frm + if frm > through: + # DEVIATION: allow reversed enumerations (and ranges as range() uses enumerate, like '@for .. from .. through') + frm, through = through, frm + rev = reversed + else: + rev = lambda x: x + + ret = [] + for i in rev(range(frm, through + 1)): + if prefix and prefix.value: + ret.append(String.unquoted(prefix.value + separator + str(i))) + else: + ret.append(Number(i)) + + return List(ret, use_comma=True) + + +@register('headers', 0) +@register('headers', 1) +@register('headers', 2) +@register('headings', 0) +@register('headings', 1) +@register('headings', 2) +def headers(frm=None, to=None): + if frm and to is None: + if isinstance(frm, String) and frm.value.lower() == 'all': + frm = 1 + to = 6 + else: + try: + to = int(getattr(frm, 'value', frm)) + except ValueError: + to = 6 + frm = 1 + else: + try: + frm = 1 if frm is None else int(getattr(frm, 'value', frm)) + except ValueError: + frm = 1 + try: + to = 6 if to is None else int(getattr(to, 'value', to)) + except ValueError: + to = 6 + ret = [String.unquoted('h' + str(i)) for i in range(frm, to + 1)] + return List(ret, use_comma=True) + + +@register('nest') +def nest(*arguments): + if isinstance(arguments[0], List): + lst = arguments[0] + elif isinstance(arguments[0], String): + lst = arguments[0].value.split(',') + else: + raise TypeError("Expected list or string, got %r" % (arguments[0],)) + + ret = [] + for s in lst: + if isinstance(s, String): + s = s.value + elif isinstance(s, six.string_types): + s = s + else: + raise TypeError("Expected string, got %r" % (s,)) + + s = s.strip() + if not s: + continue + + ret.append(s) + + for arg in arguments[1:]: + if isinstance(arg, List): + lst = arg + elif isinstance(arg, String): + lst = arg.value.split(',') + else: + raise TypeError("Expected list or string, got %r" % (arg,)) + + new_ret = [] + for s in lst: + if isinstance(s, String): + s = s.value + elif isinstance(s, six.string_types): + s = s + else: + raise TypeError("Expected string, got %r" % (s,)) + + s = s.strip() + if not s: + continue + + for r in ret: + if '&' in s: + new_ret.append(s.replace('&', r)) + else: + if not r or r[-1] in ('.', ':', '#'): + new_ret.append(r + s) + else: + new_ret.append(r + ' ' + s) + ret = new_ret + + ret = [String.unquoted(s) for s in sorted(set(ret))] + return List(ret, use_comma=True) + + +# This isn't actually from Compass, but it's just a shortcut for enumerate(). +# DEVIATION: allow reversed ranges (range() uses enumerate() which allows reversed values, like '@for .. from .. through') +@register('range', 1) +@register('range', 2) +def range_(frm, through=None): + if through is None: + through = frm + frm = 1 + return enumerate_(None, frm, through) + +# ------------------------------------------------------------------------------ +# Working with CSS constants + +OPPOSITE_POSITIONS = { + 'top': String.unquoted('bottom'), + 'bottom': String.unquoted('top'), + 'left': String.unquoted('right'), + 'right': String.unquoted('left'), + 'center': String.unquoted('center'), +} +DEFAULT_POSITION = [String.unquoted('center'), String.unquoted('top')] + + +def _position(opposite, positions): + if positions is None: + positions = DEFAULT_POSITION + else: + positions = List.from_maybe(positions) + + ret = [] + for pos in positions: + if isinstance(pos, (String, six.string_types)): + pos_value = getattr(pos, 'value', pos) + if pos_value in OPPOSITE_POSITIONS: + if opposite: + ret.append(OPPOSITE_POSITIONS[pos_value]) + else: + ret.append(pos) + continue + elif pos_value == 'to': + # Gradient syntax keyword; leave alone + ret.append(pos) + continue + + elif isinstance(pos, Number): + if pos.is_simple_unit('%'): + if opposite: + ret.append(Number(100 - pos.value, '%')) + else: + ret.append(pos) + continue + elif pos.is_simple_unit('deg'): + # TODO support other angle types? + if opposite: + ret.append(Number((pos.value + 180) % 360, 'deg')) + else: + ret.append(pos) + continue + + if opposite: + log.warn("Can't find opposite for position %r" % (pos,)) + ret.append(pos) + + return List(ret, use_comma=False).maybe() + + +@register('position') +def position(p): + return _position(False, p) + + +@register('opposite-position') +def opposite_position(p): + return _position(True, p) + + +# ------------------------------------------------------------------------------ +# Math + +@register('pi', 0) +def pi(): + return Number(math.pi) + + +@register('e', 0) +def e(): + return Number(math.e) + + +@register('log', 1) +@register('log', 2) +def log_(number, base=None): + if not isinstance(number, Number): + raise TypeError("Expected number, got %r" % (number,)) + elif not number.is_unitless: + raise ValueError("Expected unitless number, got %r" % (number,)) + + if base is None: + pass + elif not isinstance(base, Number): + raise TypeError("Expected number, got %r" % (base,)) + elif not base.is_unitless: + raise ValueError("Expected unitless number, got %r" % (base,)) + + if base is None: + ret = math.log(number.value) + else: + ret = math.log(number.value, base.value) + + return Number(ret) + + +@register('pow', 2) +def pow(number, exponent): + return number ** exponent + + +COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.sqrt), 'sqrt', 1) +COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.sin), 'sin', 1) +COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.cos), 'cos', 1) +COMPASS_HELPERS_LIBRARY.add(Number.wrap_python_function(math.tan), 'tan', 1) + + +# ------------------------------------------------------------------------------ +# Fonts + +def _fonts_root(): + return config.STATIC_ROOT if config.FONTS_ROOT is None else config.FONTS_ROOT + + +def _font_url(path, only_path=False, cache_buster=True, inline=False): + filepath = String.unquoted(path).value + file = None + FONTS_ROOT = _fonts_root() + if callable(FONTS_ROOT): + try: + _file, _storage = list(FONTS_ROOT(filepath))[0] + d_obj = _storage.modified_time(_file) + filetime = int(time.mktime(d_obj.timetuple())) + if inline: + file = _storage.open(_file) + except: + filetime = 'NA' + else: + _path = os.path.join(FONTS_ROOT, filepath.strip('/')) + if os.path.exists(_path): + filetime = int(os.path.getmtime(_path)) + if inline: + file = open(_path, 'rb') + else: + filetime = 'NA' + + BASE_URL = config.FONTS_URL or config.STATIC_URL + if file and inline: + font_type = None + if re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value): + font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', path.value).groups()[1]).value + + if not FONT_TYPES.get(font_type): + raise Exception('Could not determine font type for "%s"' % path.value) + + mime = FONT_TYPES.get(font_type) + if font_type == 'woff': + mime = 'application/font-woff' + elif font_type == 'eot': + mime = 'application/vnd.ms-fontobject' + url = 'data:' + (mime if '/' in mime else 'font/%s' % mime) + ';base64,' + base64.b64encode(file.read()) + file.close() + else: + url = '%s/%s' % (BASE_URL.rstrip('/'), filepath.lstrip('/')) + if cache_buster and filetime != 'NA': + url = add_cache_buster(url, filetime) + + if not only_path: + url = 'url(%s)' % escape(url) + return String.unquoted(url) + + +def _font_files(args, inline): + if args == (): + return String.unquoted("") + + fonts = [] + args_len = len(args) + skip_next = False + for index in range(len(args)): + arg = args[index] + if not skip_next: + font_type = args[index + 1] if args_len > (index + 1) else None + if font_type and font_type.value in FONT_TYPES: + skip_next = True + else: + if re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value): + font_type = String.unquoted(re.match(r'^([^?]+)[.](.*)([?].*)?$', arg.value).groups()[1]) + + if font_type.value in FONT_TYPES: + fonts.append(String.unquoted('%s format("%s")' % (_font_url(arg, inline=inline), String.unquoted(FONT_TYPES[font_type.value]).value))) + else: + raise Exception('Could not determine font type for "%s"' % arg.value) + else: + skip_next = False + + return List(fonts, separator=',') + + +@register('font-url', 1) +@register('font-url', 2) +def font_url(path, only_path=False, cache_buster=True): + """ + Generates a path to an asset found relative to the project's font directory. + Passing a true value as the second argument will cause the only the path to + be returned instead of a `url()` function + """ + return _font_url(path, only_path, cache_buster, False) + + +@register('font-files') +def font_files(*args): + return _font_files(args, inline=False) + + +@register('inline-font-files') +def inline_font_files(*args): + return _font_files(args, inline=True) + + +# ------------------------------------------------------------------------------ +# External stylesheets + +@register('stylesheet-url', 1) +@register('stylesheet-url', 2) +def stylesheet_url(path, only_path=False, cache_buster=True): + """ + Generates a path to an asset found relative to the project's css directory. + Passing a true value as the second argument will cause the only the path to + be returned instead of a `url()` function + """ + filepath = String.unquoted(path).value + if callable(config.STATIC_ROOT): + try: + _file, _storage = list(config.STATIC_ROOT(filepath))[0] + d_obj = _storage.modified_time(_file) + filetime = int(time.mktime(d_obj.timetuple())) + except: + filetime = 'NA' + else: + _path = os.path.join(config.STATIC_ROOT, filepath.strip('/')) + if os.path.exists(_path): + filetime = int(os.path.getmtime(_path)) + else: + filetime = 'NA' + BASE_URL = config.STATIC_URL + + url = '%s%s' % (BASE_URL, filepath) + if cache_buster: + url = add_cache_buster(url, filetime) + if not only_path: + url = 'url("%s")' % (url) + return String.unquoted(url) diff --git a/libs/scss/functions/compass/images.py b/libs/scss/functions/compass/images.py new file mode 100644 index 00000000..3eab26ce --- /dev/null +++ b/libs/scss/functions/compass/images.py @@ -0,0 +1,285 @@ +"""Image utilities ported from Compass.""" + +from __future__ import absolute_import +from __future__ import print_function + +import base64 +import hashlib +import logging +import mimetypes +import os.path +import time + +import six +from six.moves import xrange + +from scss import config +from scss.functions.compass import _image_size_cache +from scss.functions.compass.helpers import add_cache_buster +from scss.functions.library import FunctionLibrary +from scss.types import Color, List, Number, String +from scss.util import escape + +try: + from PIL import Image +except ImportError: + try: + import Image + except: + Image = None + +log = logging.getLogger(__name__) + +COMPASS_IMAGES_LIBRARY = FunctionLibrary() +register = COMPASS_IMAGES_LIBRARY.register + + +# ------------------------------------------------------------------------------ + +def _images_root(): + return config.STATIC_ROOT if config.IMAGES_ROOT is None else config.IMAGES_ROOT + + +def _image_url(path, only_path=False, cache_buster=True, dst_color=None, src_color=None, inline=False, mime_type=None, spacing=None, collapse_x=None, collapse_y=None): + """ + src_color - a list of or a single color to be replaced by each corresponding dst_color colors + spacing - spaces to be added to the image + collapse_x, collapse_y - collapsable (layered) image of the given size (x, y) + """ + if inline or dst_color or spacing: + if not Image: + raise Exception("Images manipulation require PIL") + filepath = String.unquoted(path).value + fileext = os.path.splitext(filepath)[1].lstrip('.').lower() + if mime_type: + mime_type = String.unquoted(mime_type).value + if not mime_type: + mime_type = mimetypes.guess_type(filepath)[0] + if not mime_type: + mime_type = 'image/%s' % fileext + path = None + IMAGES_ROOT = _images_root() + if callable(IMAGES_ROOT): + try: + _file, _storage = list(IMAGES_ROOT(filepath))[0] + d_obj = _storage.modified_time(_file) + filetime = int(time.mktime(d_obj.timetuple())) + if inline or dst_color or spacing: + path = _storage.open(_file) + except: + filetime = 'NA' + else: + _path = os.path.join(IMAGES_ROOT.rstrip('/'), filepath.strip('/')) + if os.path.exists(_path): + filetime = int(os.path.getmtime(_path)) + if inline or dst_color or spacing: + path = open(_path, 'rb') + else: + filetime = 'NA' + + BASE_URL = config.IMAGES_URL or config.STATIC_URL + if path: + dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(dst_color) if v] + + src_color = Color.from_name('black') if src_color is None else src_color + src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(src_color)] + + len_colors = max(len(dst_colors), len(src_colors)) + dst_colors = (dst_colors * len_colors)[:len_colors] + src_colors = (src_colors * len_colors)[:len_colors] + + spacing = Number(0) if spacing is None else spacing + spacing = [int(Number(v).value) for v in List.from_maybe(spacing)] + spacing = (spacing * 4)[:4] + + file_name, file_ext = os.path.splitext(os.path.normpath(filepath).replace('\\', '_').replace('/', '_')) + key = (filetime, src_color, dst_color, spacing) + key = file_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_') + asset_file = key + file_ext + ASSETS_ROOT = config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets') + asset_path = os.path.join(ASSETS_ROOT, asset_file) + + if os.path.exists(asset_path): + filepath = asset_file + BASE_URL = config.ASSETS_URL + if inline: + path = open(asset_path, 'rb') + url = 'data:' + mime_type + ';base64,' + base64.b64encode(path.read()) + else: + url = '%s%s' % (BASE_URL, filepath) + if cache_buster: + filetime = int(os.path.getmtime(asset_path)) + url = add_cache_buster(url, filetime) + else: + simply_process = False + image = None + + if fileext in ('cur',): + simply_process = True + else: + try: + image = Image.open(path) + except IOError: + if not collapse_x and not collapse_y and not dst_colors: + simply_process = True + + if simply_process: + if inline: + url = 'data:' + mime_type + ';base64,' + base64.b64encode(path.read()) + else: + url = '%s%s' % (BASE_URL, filepath) + if cache_buster: + filetime = int(os.path.getmtime(asset_path)) + url = add_cache_buster(url, filetime) + else: + width, height = collapse_x or image.size[0], collapse_y or image.size[1] + new_image = Image.new( + mode='RGBA', + size=(width + spacing[1] + spacing[3], height + spacing[0] + spacing[2]), + color=(0, 0, 0, 0) + ) + for i, dst_color in enumerate(dst_colors): + src_color = src_colors[i] + pixdata = image.load() + for _y in xrange(image.size[1]): + for _x in xrange(image.size[0]): + pixel = pixdata[_x, _y] + if pixel[:3] == src_color: + pixdata[_x, _y] = tuple([int(c) for c in dst_color] + [pixel[3] if len(pixel) == 4 else 255]) + iwidth, iheight = image.size + if iwidth != width or iheight != height: + cy = 0 + while cy < iheight: + cx = 0 + while cx < iwidth: + cropped_image = image.crop((cx, cy, cx + width, cy + height)) + new_image.paste(cropped_image, (int(spacing[3]), int(spacing[0])), cropped_image) + cx += width + cy += height + else: + new_image.paste(image, (int(spacing[3]), int(spacing[0]))) + + if not inline: + try: + new_image.save(asset_path) + filepath = asset_file + BASE_URL = config.ASSETS_URL + if cache_buster: + filetime = int(os.path.getmtime(asset_path)) + except IOError: + log.exception("Error while saving image") + inline = True # Retry inline version + url = os.path.join(config.ASSETS_URL.rstrip('/'), asset_file.lstrip('/')) + if cache_buster: + url = add_cache_buster(url, filetime) + if inline: + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents) + else: + url = os.path.join(BASE_URL.rstrip('/'), filepath.lstrip('/')) + if cache_buster and filetime != 'NA': + url = add_cache_buster(url, filetime) + + if not only_path: + url = 'url(%s)' % escape(url) + return String.unquoted(url) + + +@register('inline-image', 1) +@register('inline-image', 2) +@register('inline-image', 3) +@register('inline-image', 4) +@register('inline-image', 5) +def inline_image(image, mime_type=None, dst_color=None, src_color=None, spacing=None, collapse_x=None, collapse_y=None): + """ + Embeds the contents of a file directly inside your stylesheet, eliminating + the need for another HTTP request. For small files such images or fonts, + this can be a performance benefit at the cost of a larger generated CSS + file. + """ + return _image_url(image, False, False, dst_color, src_color, True, mime_type, spacing, collapse_x, collapse_y) + + +@register('image-url', 1) +@register('image-url', 2) +@register('image-url', 3) +@register('image-url', 4) +@register('image-url', 5) +@register('image-url', 6) +def image_url(path, only_path=False, cache_buster=True, dst_color=None, src_color=None, spacing=None, collapse_x=None, collapse_y=None): + """ + Generates a path to an asset found relative to the project's images + directory. + Passing a true value as the second argument will cause the only the path to + be returned instead of a `url()` function + """ + return _image_url(path, only_path, cache_buster, dst_color, src_color, False, None, spacing, collapse_x, collapse_y) + + +@register('image-width', 1) +def image_width(image): + """ + Returns the width of the image found at the path supplied by `image` + relative to your project's images directory. + """ + if not Image: + raise Exception("Images manipulation require PIL") + filepath = String.unquoted(image).value + path = None + try: + width = _image_size_cache[filepath][0] + except KeyError: + width = 0 + IMAGES_ROOT = _images_root() + if callable(IMAGES_ROOT): + try: + _file, _storage = list(IMAGES_ROOT(filepath))[0] + path = _storage.open(_file) + except: + pass + else: + _path = os.path.join(IMAGES_ROOT, filepath.strip('/')) + if os.path.exists(_path): + path = open(_path, 'rb') + if path: + image = Image.open(path) + size = image.size + width = size[0] + _image_size_cache[filepath] = size + return Number(width, 'px') + + +@register('image-height', 1) +def image_height(image): + """ + Returns the height of the image found at the path supplied by `image` + relative to your project's images directory. + """ + if not Image: + raise Exception("Images manipulation require PIL") + filepath = String.unquoted(image).value + path = None + try: + height = _image_size_cache[filepath][1] + except KeyError: + height = 0 + IMAGES_ROOT = _images_root() + if callable(IMAGES_ROOT): + try: + _file, _storage = list(IMAGES_ROOT(filepath))[0] + path = _storage.open(_file) + except: + pass + else: + _path = os.path.join(IMAGES_ROOT, filepath.strip('/')) + if os.path.exists(_path): + path = open(_path, 'rb') + if path: + image = Image.open(path) + size = image.size + height = size[1] + _image_size_cache[filepath] = size + return Number(height, 'px') diff --git a/libs/scss/functions/compass/layouts.py b/libs/scss/functions/compass/layouts.py new file mode 100644 index 00000000..06e67c8d --- /dev/null +++ b/libs/scss/functions/compass/layouts.py @@ -0,0 +1,346 @@ +"""Functions used for generating packed CSS sprite maps. + + +These are ported from the Binary Tree Bin Packing Algorithm: +http://codeincomplete.com/posts/2011/5/7/bin_packing/ +""" + +# Copyright (c) 2011, 2012, 2013 Jake Gordon and contributors +# Copyright (c) 2013 German M. Bravo + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +class LayoutNode(object): + def __init__(self, x, y, w, h, down=None, right=None, used=False): + self.x = x + self.y = y + self.w = w + self.h = h + self.down = down + self.right = right + self.used = used + self.width = 0 + self.height = 0 + + @property + def area(self): + return self.width * self.height + + def __repr__(self): + return '<%s (%s, %s) [%sx%s]>' % (self.__class__.__name__, self.x, self.y, self.w, self.h) + + +class SpritesLayout(object): + def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None): + self.num_blocks = len(blocks) + + if margin is None: + margin = [[0] * 4] * self.num_blocks + elif not isinstance(margin, (tuple, list)): + margin = [[margin] * 4] * self.num_blocks + elif not isinstance(margin[0], (tuple, list)): + margin = [margin] * self.num_blocks + + if padding is None: + padding = [[0] * 4] * self.num_blocks + elif not isinstance(padding, (tuple, list)): + padding = [[padding] * 4] * self.num_blocks + elif not isinstance(padding[0], (tuple, list)): + padding = [padding] * self.num_blocks + + if pmargin is None: + pmargin = [[0.0] * 4] * self.num_blocks + elif not isinstance(pmargin, (tuple, list)): + pmargin = [[pmargin] * 4] * self.num_blocks + elif not isinstance(pmargin[0], (tuple, list)): + pmargin = [pmargin] * self.num_blocks + + if ppadding is None: + ppadding = [[0.0] * 4] * self.num_blocks + elif not isinstance(ppadding, (tuple, list)): + ppadding = [[ppadding] * 4] * self.num_blocks + elif not isinstance(ppadding[0], (tuple, list)): + ppadding = [ppadding] * self.num_blocks + + self.blocks = tuple(( + b[0] + padding[i][3] + padding[i][1] + margin[i][3] + margin[i][1] + int(round(b[0] * (ppadding[i][3] + ppadding[i][1] + pmargin[i][3] + pmargin[i][1]))), + b[1] + padding[i][0] + padding[i][2] + margin[i][0] + margin[i][2] + int(round(b[1] * (ppadding[i][0] + ppadding[i][2] + pmargin[i][0] + pmargin[i][2]))), + b[0], + b[1], + i + ) for i, b in enumerate(blocks)) + + self.margin = margin + self.padding = padding + self.pmargin = pmargin + self.ppadding = ppadding + + +class PackedSpritesLayout(SpritesLayout): + @staticmethod + def MAXSIDE(a, b): + """maxside: Sort pack by maximum sides""" + return cmp(max(b[0], b[1]), max(a[0], a[1])) or cmp(min(b[0], b[1]), min(a[0], a[1])) or cmp(b[1], a[1]) or cmp(b[0], a[0]) + + @staticmethod + def WIDTH(a, b): + """width: Sort pack by width""" + return cmp(b[0], a[0]) or cmp(b[1], a[1]) + + @staticmethod + def HEIGHT(a, b): + """height: Sort pack by height""" + return cmp(b[1], a[1]) or cmp(b[0], a[0]) + + @staticmethod + def AREA(a, b): + """area: Sort pack by area""" + return cmp(b[0] * b[1], a[0] * a[1]) or cmp(b[1], a[1]) or cmp(b[0], a[0]) + + def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, methods=None): + super(PackedSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin) + + ratio = 0 + + if methods is None: + methods = (self.MAXSIDE, self.WIDTH, self.HEIGHT, self.AREA) + + for method in methods: + sorted_blocks = sorted( + self.blocks, + cmp=method, + ) + root = LayoutNode( + x=0, + y=0, + w=sorted_blocks[0][0] if sorted_blocks else 0, + h=sorted_blocks[0][1] if sorted_blocks else 0 + ) + + area = 0 + nodes = [None] * self.num_blocks + + for block in sorted_blocks: + w, h, width, height, i = block + node = self._findNode(root, w, h) + if node: + node = self._splitNode(node, w, h) + else: + root = self._growNode(root, w, h) + node = self._findNode(root, w, h) + if node: + node = self._splitNode(node, w, h) + else: + node = None + nodes[i] = node + node.width = width + node.height = height + area += node.area + + this_ratio = area / float(root.w * root.h) + # print method.__doc__, "%g%%" % (this_ratio * 100) + if ratio < this_ratio: + self.root = root + self.nodes = nodes + self.method = method + ratio = this_ratio + if ratio > 0.96: + break + # print self.method.__doc__, "%g%%" % (ratio * 100) + + def __iter__(self): + for i, node in enumerate(self.nodes): + margin, padding = self.margin[i], self.padding[i] + pmargin, ppadding = self.pmargin[i], self.ppadding[i] + cssw = node.width + padding[3] + padding[1] + int(round(node.width * (ppadding[3] + ppadding[1]))) # image width plus padding + cssh = node.height + padding[0] + padding[2] + int(round(node.height * (ppadding[0] + ppadding[2]))) # image height plus padding + cssx = node.x + margin[3] + int(round(node.width * pmargin[3])) + cssy = node.y + margin[0] + int(round(node.height * pmargin[0])) + x = cssx + padding[3] + int(round(node.width * ppadding[3])) + y = cssy + padding[0] + int(round(node.height * ppadding[0])) + yield x, y, node.width, node.height, cssx, cssy, cssw, cssh + + @property + def width(self): + return self.root.w + + @property + def height(self): + return self.root.h + + def _findNode(self, root, w, h): + if root.used: + return self._findNode(root.right, w, h) or self._findNode(root.down, w, h) + elif w <= root.w and h <= root.h: + return root + else: + return None + + def _splitNode(self, node, w, h): + node.used = True + node.down = LayoutNode( + x=node.x, + y=node.y + h, + w=node.w, + h=node.h - h + ) + node.right = LayoutNode( + x=node.x + w, + y=node.y, + w=node.w - w, + h=h + ) + return node + + def _growNode(self, root, w, h): + canGrowDown = w <= root.w + canGrowRight = h <= root.h + + shouldGrowRight = canGrowRight and (root.h >= root.w + w) # attempt to keep square-ish by growing right when height is much greater than width + shouldGrowDown = canGrowDown and (root.w >= root.h + h) # attempt to keep square-ish by growing down when width is much greater than height + + if shouldGrowRight: + return self._growRight(root, w, h) + elif shouldGrowDown: + return self._growDown(root, w, h) + elif canGrowRight: + return self._growRight(root, w, h) + elif canGrowDown: + return self._growDown(root, w, h) + else: + # need to ensure sensible root starting size to avoid this happening + assert False, "Blocks must be properly sorted!" + + def _growRight(self, root, w, h): + root = LayoutNode( + used=True, + x=0, + y=0, + w=root.w + w, + h=root.h, + down=root, + right=LayoutNode( + x=root.w, + y=0, + w=w, + h=root.h + ) + ) + return root + + def _growDown(self, root, w, h): + root = LayoutNode( + used=True, + x=0, + y=0, + w=root.w, + h=root.h + h, + down=LayoutNode( + x=0, + y=root.h, + w=root.w, + h=h + ), + right=root + ) + return root + + +class HorizontalSpritesLayout(SpritesLayout): + def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, position=None): + super(HorizontalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin) + + self.width = sum(zip(*self.blocks)[0]) + self.height = max(zip(*self.blocks)[1]) + + if position is None: + position = [0.0] * self.num_blocks + elif not isinstance(position, (tuple, list)): + position = [position] * self.num_blocks + self.position = position + + def __iter__(self): + cx = 0 + for i, block in enumerate(self.blocks): + w, h, width, height, i = block + margin, padding = self.margin[i], self.padding[i] + pmargin, ppadding = self.pmargin[i], self.ppadding[i] + position = self.position[i] + cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding + cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding + cssx = cx + margin[3] + int(round(width * pmargin[3])) # anchored at x + cssy = int(round((self.height - cssh) * position)) # centered vertically + x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding + y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding + yield x, y, width, height, cssx, cssy, cssw, cssh + cx += cssw + margin[3] + margin[1] + int(round(width * (pmargin[3] + pmargin[1]))) + + +class VerticalSpritesLayout(SpritesLayout): + def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None, position=None): + super(VerticalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin) + + self.width = max(zip(*self.blocks)[0]) + self.height = sum(zip(*self.blocks)[1]) + + if position is None: + position = [0.0] * self.num_blocks + elif not isinstance(position, (tuple, list)): + position = [position] * self.num_blocks + self.position = position + + def __iter__(self): + cy = 0 + for i, block in enumerate(self.blocks): + w, h, width, height, i = block + margin, padding = self.margin[i], self.padding[i] + pmargin, ppadding = self.pmargin[i], self.ppadding[i] + position = self.position[i] + cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding + cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding + cssx = int(round((self.width - cssw) * position)) # centered horizontally + cssy = cy + margin[0] + int(round(height * pmargin[0])) # anchored at y + x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding + y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding + yield x, y, width, height, cssx, cssy, cssw, cssh + cy += cssh + margin[0] + margin[2] + int(round(height * (pmargin[0] + pmargin[2]))) + + +class DiagonalSpritesLayout(SpritesLayout): + def __init__(self, blocks, padding=None, margin=None, ppadding=None, pmargin=None): + super(DiagonalSpritesLayout, self).__init__(blocks, padding, margin, ppadding, pmargin) + self.width = sum(zip(*self.blocks)[0]) + self.height = sum(zip(*self.blocks)[1]) + + def __iter__(self): + cx, cy = 0, 0 + for i, block in enumerate(self.blocks): + w, h, width, height, i = block + margin, padding = self.margin[i], self.padding[i] + pmargin, ppadding = self.pmargin[i], self.ppadding[i] + cssw = width + padding[3] + padding[1] + int(round(width * (ppadding[3] + ppadding[1]))) # image width plus padding + cssh = height + padding[0] + padding[2] + int(round(height * (ppadding[0] + ppadding[2]))) # image height plus padding + cssx = cx + margin[3] + int(round(width * pmargin[3])) # anchored at x + cssy = cy + margin[0] + int(round(height * pmargin[0])) # anchored at y + x = cssx + padding[3] + int(round(width * ppadding[3])) # image drawn offset to account for padding + y = cssy + padding[0] + int(round(height * ppadding[0])) # image drawn offset to account for padding + yield x, y, width, height, cssx, cssy, cssw, cssh + cx += cssw + margin[3] + margin[1] + int(round(width * (pmargin[3] + pmargin[1]))) + cy += cssh + margin[0] + margin[2] + int(round(height * (pmargin[0] + pmargin[2]))) diff --git a/libs/scss/functions/compass/sprites.py b/libs/scss/functions/compass/sprites.py new file mode 100644 index 00000000..2549cb36 --- /dev/null +++ b/libs/scss/functions/compass/sprites.py @@ -0,0 +1,524 @@ +"""Functions used for generating CSS sprites. + +These are ported from the Compass sprite library: +http://compass-style.org/reference/compass/utilities/sprites/ +""" + +from __future__ import absolute_import + +import six + +import base64 +import glob +import hashlib +import logging +import os.path +import tempfile +import time + +try: + import cPickle as pickle +except ImportError: + import pickle + +try: + from PIL import Image +except ImportError: + try: + import Image + except: + Image = None + +from six.moves import xrange + +from scss import config +from scss.functions.compass import _image_size_cache +from scss.functions.compass.layouts import PackedSpritesLayout, HorizontalSpritesLayout, VerticalSpritesLayout, DiagonalSpritesLayout +from scss.functions.library import FunctionLibrary +from scss.types import Color, List, Number, String, Boolean +from scss.util import escape + +log = logging.getLogger(__name__) + +MAX_SPRITE_MAPS = 4096 +KEEP_SPRITE_MAPS = int(MAX_SPRITE_MAPS * 0.8) + +COMPASS_SPRITES_LIBRARY = FunctionLibrary() +register = COMPASS_SPRITES_LIBRARY.register + + +# ------------------------------------------------------------------------------ +# Compass-like functionality for sprites and images + +sprite_maps = {} + + +def alpha_composite(im1, im2, offset=None, box=None, opacity=1): + im1size = im1.size + im2size = im2.size + if offset is None: + offset = (0, 0) + if box is None: + box = (0, 0) + im2size + o1x, o1y = offset + o2x, o2y, o2w, o2h = box + width = o2w - o2x + height = o2h - o2y + im1_data = im1.load() + im2_data = im2.load() + for y in xrange(height): + for x in xrange(width): + pos1 = o1x + x, o1y + y + if pos1[0] >= im1size[0] or pos1[1] >= im1size[1]: + continue + pos2 = o2x + x, o2y + y + if pos2[0] >= im2size[0] or pos2[1] >= im2size[1]: + continue + dr, dg, db, da = im1_data[pos1] + sr, sg, sb, sa = im2_data[pos2] + da /= 255.0 + sa /= 255.0 + sa *= opacity + ida = da * (1 - sa) + oa = (sa + ida) + if oa: + pixel = ( + int(round((sr * sa + dr * ida)) / oa), + int(round((sg * sa + dg * ida)) / oa), + int(round((sb * sa + db * ida)) / oa), + int(round(255 * oa)) + ) + else: + pixel = (0, 0, 0, 0) + im1_data[pos1] = pixel + return im1 + + +@register('sprite-map') +def sprite_map(g, **kwargs): + """ + Generates a sprite map from the files matching the glob pattern. + Uses the keyword-style arguments passed in to control the placement. + + $direction - Sprite map layout. Can be `vertical` (default), `horizontal`, `diagonal` or `smart`. + + $position - For `horizontal` and `vertical` directions, the position of the sprite. (defaults to `0`) + $-position - Position of a given sprite. + + $padding, $spacing - Adds paddings to sprites (top, right, bottom, left). (defaults to `0, 0, 0, 0`) + $-padding, $-spacing - Padding for a given sprite. + + $dst-color - Together with `$src-color`, forms a map of source colors to be converted to destiny colors (same index of `$src-color` changed to `$dst-color`). + $-dst-color - Destiny colors for a given sprite. (defaults to `$dst-color`) + + $src-color - Selects source colors to be converted to the corresponding destiny colors. (defaults to `black`) + $-dst-color - Source colors for a given sprite. (defaults to `$src-color`) + + $collapse - Collapses every image in the sprite map to a fixed size (`x` and `y`). + $collapse-x - Collapses a size for `x`. + $collapse-y - Collapses a size for `y`. + """ + if not Image: + raise Exception("Images manipulation require PIL") + + now_time = time.time() + + g = String(g, quotes=None).value + + if g in sprite_maps: + sprite_maps[glob]['*'] = now_time + elif '..' not in g: # Protect against going to prohibited places... + if callable(config.STATIC_ROOT): + glob_path = g + rfiles = files = sorted(config.STATIC_ROOT(g)) + else: + glob_path = os.path.join(config.STATIC_ROOT, g) + files = glob.glob(glob_path) + files = sorted((f, None) for f in files) + rfiles = [(rf[len(config.STATIC_ROOT):], s) for rf, s in files] + + if not files: + log.error("Nothing found at '%s'", glob_path) + return String.unquoted('') + + map_name = os.path.normpath(os.path.dirname(g)).replace('\\', '_').replace('/', '_') + key = list(zip(*files)[0]) + [repr(kwargs), config.ASSETS_URL] + key = map_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_') + asset_file = key + '.png' + ASSETS_ROOT = config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets') + asset_path = os.path.join(ASSETS_ROOT, asset_file) + cache_path = os.path.join(config.CACHE_ROOT or ASSETS_ROOT, asset_file + '.cache') + + inline = Boolean(kwargs.get('inline', False)) + + sprite_map = None + asset = None + file_asset = None + inline_asset = None + if os.path.exists(asset_path) or inline: + try: + save_time, file_asset, inline_asset, sprite_map, sizes = pickle.load(open(cache_path)) + if file_asset: + sprite_maps[file_asset.render()] = sprite_map + if inline_asset: + sprite_maps[inline_asset.render()] = sprite_map + if inline: + asset = inline_asset + else: + asset = file_asset + except: + pass + + if sprite_map: + for file_, storage in files: + if storage is not None: + d_obj = storage.modified_time(file_) + _time = time.mktime(d_obj.timetuple()) + else: + _time = os.path.getmtime(file_) + if save_time < _time: + if _time > now_time: + log.warning("File '%s' has a date in the future (cache ignored)" % file_) + sprite_map = None # Invalidate cached sprite map + break + + if sprite_map is None or asset is None: + cache_buster = Boolean(kwargs.get('cache_buster', True)) + direction = String.unquoted(kwargs.get('direction', config.SPRTE_MAP_DIRECTION)).value + repeat = String.unquoted(kwargs.get('repeat', 'no-repeat')).value + collapse = kwargs.get('collapse', Number(0)) + if isinstance(collapse, List): + collapse_x = int(Number(collapse[0]).value) + collapse_y = int(Number(collapse[-1]).value) + else: + collapse_x = collapse_y = int(Number(collapse).value) + if 'collapse_x' in kwargs: + collapse_x = int(Number(kwargs['collapse_x']).value) + if 'collapse_y' in kwargs: + collapse_y = int(Number(kwargs['collapse_y']).value) + + position = Number(kwargs.get('position', 0)) + if not position.is_simple_unit('%') and position.value > 1: + position = position.value / 100.0 + else: + position = position.value + if position < 0: + position = 0.0 + elif position > 1: + position = 1.0 + + padding = kwargs.get('padding', kwargs.get('spacing', Number(0))) + padding = [int(Number(v).value) for v in List.from_maybe(padding)] + padding = (padding * 4)[:4] + + dst_colors = kwargs.get('dst_color') + dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(dst_colors) if v] + src_colors = kwargs.get('src_color', Color.from_name('black')) + src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(src_colors)] + len_colors = max(len(dst_colors), len(src_colors)) + dst_colors = (dst_colors * len_colors)[:len_colors] + src_colors = (src_colors * len_colors)[:len_colors] + + def images(f=lambda x: x): + for file_, storage in f(files): + if storage is not None: + _file = storage.open(file_) + else: + _file = file_ + _image = Image.open(_file) + yield _image + + names = tuple(os.path.splitext(os.path.basename(file_))[0] for file_, storage in files) + + has_dst_colors = False + all_dst_colors = [] + all_src_colors = [] + all_positions = [] + all_paddings = [] + + for name in names: + name = name.replace('-', '_') + + _position = kwargs.get(name + '_position') + if _position is None: + _position = position + else: + _position = Number(_position) + if not _position.is_simple_unit('%') and _position.value > 1: + _position = _position.value / 100.0 + else: + _position = _position.value + if _position < 0: + _position = 0.0 + elif _position > 1: + _position = 1.0 + all_positions.append(_position) + + _padding = kwargs.get(name + '_padding', kwargs.get(name + '_spacing')) + if _padding is None: + _padding = padding + else: + _padding = [int(Number(v).value) for v in List.from_maybe(_padding)] + _padding = (_padding * 4)[:4] + all_paddings.append(_padding) + + _dst_colors = kwargs.get(name + '_dst_color') + if _dst_colors is None: + _dst_colors = dst_colors + if dst_colors: + has_dst_colors = True + else: + has_dst_colors = True + _dst_colors = [list(Color(v).value[:3]) for v in List.from_maybe(_dst_colors) if v] + _src_colors = kwargs.get(name + '_src_color', Color.from_name('black')) + if _src_colors is None: + _src_colors = src_colors + else: + _src_colors = [tuple(Color(v).value[:3]) for v in List.from_maybe(_src_colors)] + _len_colors = max(len(_dst_colors), len(_src_colors)) + _dst_colors = (_dst_colors * _len_colors)[:_len_colors] + _src_colors = (_src_colors * _len_colors)[:_len_colors] + all_dst_colors.append(_dst_colors) + all_src_colors.append(_src_colors) + + sizes = tuple((collapse_x or i.size[0], collapse_y or i.size[1]) for i in images()) + + if direction == 'horizontal': + layout = HorizontalSpritesLayout(sizes, all_paddings, position=all_positions) + elif direction == 'vertical': + layout = VerticalSpritesLayout(sizes, all_paddings, position=all_positions) + elif direction == 'diagonal': + layout = DiagonalSpritesLayout(sizes, all_paddings) + elif direction == 'smart': + layout = PackedSpritesLayout(sizes, all_paddings) + else: + raise Exception("Invalid direction %r" % (direction,)) + layout_positions = list(layout) + + new_image = Image.new( + mode='RGBA', + size=(layout.width, layout.height), + color=(0, 0, 0, 0) + ) + + useless_dst_color = has_dst_colors + + offsets_x = [] + offsets_y = [] + for i, image in enumerate(images()): + x, y, width, height, cssx, cssy, cssw, cssh = layout_positions[i] + iwidth, iheight = image.size + + if has_dst_colors: + pixdata = image.load() + for _y in xrange(iheight): + for _x in xrange(iwidth): + pixel = pixdata[_x, _y] + a = pixel[3] if len(pixel) == 4 else 255 + if a: + rgb = pixel[:3] + for j, dst_color in enumerate(all_dst_colors[i]): + if rgb == all_src_colors[i][j]: + new_color = tuple([int(c) for c in dst_color] + [a]) + if pixel != new_color: + pixdata[_x, _y] = new_color + useless_dst_color = False + break + + if iwidth != width or iheight != height: + cy = 0 + while cy < iheight: + cx = 0 + while cx < iwidth: + new_image = alpha_composite(new_image, image, (x, y), (cx, cy, cx + width, cy + height)) + cx += width + cy += height + else: + new_image.paste(image, (x, y)) + offsets_x.append(cssx) + offsets_y.append(cssy) + + if useless_dst_color: + log.warning("Useless use of $dst-color in sprite map for files at '%s' (never used for)" % glob_path) + + filetime = int(now_time) + + if not inline: + try: + new_image.save(asset_path) + url = '%s%s' % (config.ASSETS_URL, asset_file) + if cache_buster: + url += '?_=%s' % filetime + except IOError: + log.exception("Error while saving image") + inline = True + if inline: + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + mime_type = 'image/png' + url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents) + + url = 'url(%s)' % escape(url) + if inline: + asset = inline_asset = List([String.unquoted(url), String.unquoted(repeat)]) + else: + asset = file_asset = List([String.unquoted(url), String.unquoted(repeat)]) + + # Add the new object: + sprite_map = dict(zip(names, zip(sizes, rfiles, offsets_x, offsets_y))) + sprite_map['*'] = now_time + sprite_map['*f*'] = asset_file + sprite_map['*k*'] = key + sprite_map['*n*'] = map_name + sprite_map['*t*'] = filetime + + cache_tmp = tempfile.NamedTemporaryFile(delete=False, dir=ASSETS_ROOT) + pickle.dump((now_time, file_asset, inline_asset, sprite_map, zip(files, sizes)), cache_tmp) + cache_tmp.close() + os.rename(cache_tmp.name, cache_path) + + # Use the sorted list to remove older elements (keep only 500 objects): + if len(sprite_maps) > MAX_SPRITE_MAPS: + for a in sorted(sprite_maps, key=lambda a: sprite_maps[a]['*'], reverse=True)[KEEP_SPRITE_MAPS:]: + del sprite_maps[a] + log.warning("Exceeded maximum number of sprite maps (%s)" % MAX_SPRITE_MAPS) + sprite_maps[asset.render()] = sprite_map + for file_, size in sizes: + _image_size_cache[file_] = size + # TODO this sometimes returns an empty list, or is never assigned to + return asset + + +@register('sprite-map-name', 1) +def sprite_map_name(map): + """ + Returns the name of a sprite map The name is derived from the folder than + contains the sprites. + """ + map = map.render() + sprite_map = sprite_maps.get(map) + if not sprite_map: + log.error("No sprite map found: %s", map, extra={'stack': True}) + if sprite_map: + return String.unquoted(sprite_map['*n*']) + return String.unquoted('') + + +@register('sprite-file', 2) +def sprite_file(map, sprite): + """ + Returns the relative path (from the images directory) to the original file + used when construction the sprite. This is suitable for passing to the + image_width and image_height helpers. + """ + map = map.render() + sprite_map = sprite_maps.get(map) + sprite_name = String.unquoted(sprite).value + sprite = sprite_map and sprite_map.get(sprite_name) + if not sprite_map: + log.error("No sprite map found: %s", map, extra={'stack': True}) + elif not sprite: + log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True}) + if sprite: + return String(sprite[1][0]) + return String.unquoted('') + + +@register('sprites', 1) +@register('sprite-names', 1) +def sprites(map): + map = map.render() + sprite_map = sprite_maps.get(map, {}) + return List(list(String.unquoted(s) for s in sorted(s for s in sprite_map if not s.startswith('*')))) + + +@register('sprite', 2) +@register('sprite', 3) +@register('sprite', 4) +@register('sprite', 5) +def sprite(map, sprite, offset_x=None, offset_y=None, cache_buster=True): + """ + Returns the image and background position for use in a single shorthand + property + """ + map = map.render() + sprite_map = sprite_maps.get(map) + sprite_name = String.unquoted(sprite).value + sprite = sprite_map and sprite_map.get(sprite_name) + if not sprite_map: + log.error("No sprite map found: %s", map, extra={'stack': True}) + elif not sprite: + log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True}) + if sprite: + url = '%s%s' % (config.ASSETS_URL, sprite_map['*f*']) + if cache_buster: + url += '?_=%s' % sprite_map['*t*'] + x = Number(offset_x or 0, 'px') + y = Number(offset_y or 0, 'px') + if not x.value or (x.value <= -1 or x.value >= 1) and not x.is_simple_unit('%'): + x -= Number(sprite[2], 'px') + if not y.value or (y.value <= -1 or y.value >= 1) and not y.is_simple_unit('%'): + y -= Number(sprite[3], 'px') + url = "url(%s)" % escape(url) + return List([String.unquoted(url), x, y]) + return List([Number(0), Number(0)]) + + +@register('sprite-url', 1) +@register('sprite-url', 2) +def sprite_url(map, cache_buster=True): + """ + Returns a url to the sprite image. + """ + map = map.render() + sprite_map = sprite_maps.get(map) + if not sprite_map: + log.error("No sprite map found: %s", map, extra={'stack': True}) + if sprite_map: + url = '%s%s' % (config.ASSETS_URL, sprite_map['*f*']) + if cache_buster: + url += '?_=%s' % sprite_map['*t*'] + url = "url(%s)" % escape(url) + return String.unquoted(url) + return String.unquoted('') + + +@register('sprite-position', 2) +@register('sprite-position', 3) +@register('sprite-position', 4) +def sprite_position(map, sprite, offset_x=None, offset_y=None): + """ + Returns the position for the original image in the sprite. + This is suitable for use as a value to background-position. + """ + map = map.render() + sprite_map = sprite_maps.get(map) + sprite_name = String.unquoted(sprite).value + sprite = sprite_map and sprite_map.get(sprite_name) + if not sprite_map: + log.error("No sprite map found: %s", map, extra={'stack': True}) + elif not sprite: + log.error("No sprite found: %s in %s", sprite_name, sprite_map['*n*'], extra={'stack': True}) + if sprite: + x = None + if offset_x is not None and not isinstance(offset_x, Number): + x = offset_x + if not x or x.value not in ('left', 'right', 'center'): + if x: + offset_x = None + x = Number(offset_x or 0, 'px') + if not x.value or (x.value <= -1 or x.value >= 1) and not x.is_simple_unit('%'): + x -= Number(sprite[2], 'px') + y = None + if offset_y is not None and not isinstance(offset_y, Number): + y = offset_y + if not y or y.value not in ('top', 'bottom', 'center'): + if y: + offset_y = None + y = Number(offset_y or 0, 'px') + if not y.value or (y.value <= -1 or y.value >= 1) and not y.is_simple_unit('%'): + y -= Number(sprite[3], 'px') + return List([x, y]) + return List([Number(0), Number(0)]) diff --git a/libs/scss/functions/core.py b/libs/scss/functions/core.py new file mode 100644 index 00000000..e916acae --- /dev/null +++ b/libs/scss/functions/core.py @@ -0,0 +1,784 @@ +"""Functions from the Sass "standard library", i.e., built into the original +Ruby implementation. +""" + +from __future__ import absolute_import +from __future__ import division + +import logging +import math + +from six.moves import xrange + +from scss.functions.library import FunctionLibrary +from scss.types import Boolean, Color, List, Null, Number, String, Map, expect_type + +log = logging.getLogger(__name__) + +CORE_LIBRARY = FunctionLibrary() +register = CORE_LIBRARY.register + + +# ------------------------------------------------------------------------------ +# Color creation + +def _interpret_percentage(n, relto=1., clamp=True): + expect_type(n, Number, unit='%') + + if n.is_unitless: + ret = n.value / relto + else: + ret = n.value / 100 + + if clamp: + if ret < 0: + return 0 + elif ret > 1: + return 1 + + return ret + + +@register('rgba', 4) +def rgba(r, g, b, a): + r = _interpret_percentage(r, relto=255) + g = _interpret_percentage(g, relto=255) + b = _interpret_percentage(b, relto=255) + a = _interpret_percentage(a, relto=1) + + return Color.from_rgb(r, g, b, a) + + +@register('rgb', 3) +def rgb(r, g, b, type='rgb'): + return rgba(r, g, b, Number(1.0)) + + +@register('rgba', 1) +@register('rgba', 2) +def rgba2(color, a=None): + if a is None: + alpha = 1 + else: + alpha = _interpret_percentage(a) + + return Color.from_rgb(*color.rgba[:3], alpha=alpha) + + +@register('rgb', 1) +def rgb1(color): + return rgba2(color, a=Number(1)) + + +@register('hsla', 4) +def hsla(h, s, l, a): + return Color.from_hsl( + h.value / 360 % 1, + # Ruby sass treats plain numbers for saturation and lightness as though + # they were percentages, just without the % + _interpret_percentage(s, relto=100), + _interpret_percentage(l, relto=100), + alpha=a.value, + ) + + +@register('hsl', 3) +def hsl(h, s, l): + return hsla(h, s, l, Number(1)) + + +@register('hsla', 1) +@register('hsla', 2) +def hsla2(color, a=None): + return rgba2(color, a) + + +@register('hsl', 1) +def hsl1(color): + return rgba2(color, a=Number(1)) + + +@register('mix', 2) +@register('mix', 3) +def mix(color1, color2, weight=Number(50, "%")): + """ + Mixes together two colors. Specifically, takes the average of each of the + RGB components, optionally weighted by the given percentage. + The opacity of the colors is also considered when weighting the components. + + Specifically, takes the average of each of the RGB components, + optionally weighted by the given percentage. + The opacity of the colors is also considered when weighting the components. + + The weight specifies the amount of the first color that should be included + in the returned color. + 50%, means that half the first color + and half the second color should be used. + 25% means that a quarter of the first color + and three quarters of the second color should be used. + + For example: + + mix(#f00, #00f) => #7f007f + mix(#f00, #00f, 25%) => #3f00bf + mix(rgba(255, 0, 0, 0.5), #00f) => rgba(63, 0, 191, 0.75) + """ + # This algorithm factors in both the user-provided weight + # and the difference between the alpha values of the two colors + # to decide how to perform the weighted average of the two RGB values. + # + # It works by first normalizing both parameters to be within [-1, 1], + # where 1 indicates "only use color1", -1 indicates "only use color 0", + # and all values in between indicated a proportionately weighted average. + # + # Once we have the normalized variables w and a, + # we apply the formula (w + a)/(1 + w*a) + # to get the combined weight (in [-1, 1]) of color1. + # This formula has two especially nice properties: + # + # * When either w or a are -1 or 1, the combined weight is also that number + # (cases where w * a == -1 are undefined, and handled as a special case). + # + # * When a is 0, the combined weight is w, and vice versa + # + # Finally, the weight of color1 is renormalized to be within [0, 1] + # and the weight of color2 is given by 1 minus the weight of color1. + # + # Algorithm from the Sass project: http://sass-lang.com/ + + p = _interpret_percentage(weight) + + # Scale weight to [-1, 1] + w = p * 2 - 1 + # Compute difference in alpha channels + a = color1.alpha - color2.alpha + + # Weight of first color + if w * a == -1: + # Avoid zero-div case + scaled_weight1 = w + else: + scaled_weight1 = (w + a) / (1 + w * a) + + # Unscale back to [0, 1] and get the weight of the other color + w1 = (scaled_weight1 + 1) / 2 + w2 = 1 - w1 + + # Do the scaling. Note that alpha isn't scaled by alpha, as that wouldn't + # make much sense; it uses the original untwiddled weight, p. + channels = [ + ch1 * w1 + ch2 * w2 + for (ch1, ch2) in zip(color1.rgba[:3], color2.rgba[:3])] + alpha = color1.alpha * p + color2.alpha * (1 - p) + return Color.from_rgb(*channels, alpha=alpha) + + +# ------------------------------------------------------------------------------ +# Color inspection + +@register('red', 1) +def red(color): + r, g, b, a = color.rgba + return Number(r * 255) + + +@register('green', 1) +def green(color): + r, g, b, a = color.rgba + return Number(g * 255) + + +@register('blue', 1) +def blue(color): + r, g, b, a = color.rgba + return Number(b * 255) + + +@register('opacity', 1) +@register('alpha', 1) +def alpha(color): + return Number(color.alpha) + + +@register('hue', 1) +def hue(color): + h, s, l = color.hsl + return Number(h * 360, "deg") + + +@register('saturation', 1) +def saturation(color): + h, s, l = color.hsl + return Number(s * 100, "%") + + +@register('lightness', 1) +def lightness(color): + h, s, l = color.hsl + return Number(l * 100, "%") + + +@register('ie-hex-str', 1) +def ie_hex_str(color): + c = Color(color).value + return String(u'#%02X%02X%02X%02X' % (round(c[3] * 255), round(c[0]), round(c[1]), round(c[2]))) + + +# ------------------------------------------------------------------------------ +# Color modification + +@register('fade-in', 2) +@register('fadein', 2) +@register('opacify', 2) +def opacify(color, amount): + r, g, b, a = color.rgba + return Color.from_rgb( + r, g, b, + alpha=color.alpha + amount.value) + + +@register('fade-out', 2) +@register('fadeout', 2) +@register('transparentize', 2) +def transparentize(color, amount): + r, g, b, a = color.rgba + return Color.from_rgb( + r, g, b, + alpha=color.alpha - amount.value) + + +@register('lighten', 2) +def lighten(color, amount): + return adjust_color(color, lightness=amount) + + +@register('darken', 2) +def darken(color, amount): + return adjust_color(color, lightness=-amount) + + +@register('saturate', 2) +def saturate(color, amount): + return adjust_color(color, saturation=amount) + + +@register('desaturate', 2) +def desaturate(color, amount): + return adjust_color(color, saturation=-amount) + + +@register('greyscale', 1) +def greyscale(color): + h, s, l = color.hsl + return Color.from_hsl(h, 0, l, alpha=color.alpha) + + +@register('grayscale', 1) +def grayscale(color): + if isinstance(color, Number) and color.is_unitless: + # grayscale(n) is a CSS3 filter and should be left intact, but only + # when using the "a" spelling + return String.unquoted("grayscale(%d)" % (color.value,)) + else: + return greyscale(color) + + +@register('spin', 2) +@register('adjust-hue', 2) +def adjust_hue(color, degrees): + h, s, l = color.hsl + delta = degrees.value / 360 + return Color.from_hsl((h + delta) % 1, s, l, alpha=color.alpha) + + +@register('complement', 1) +def complement(color): + h, s, l = color.hsl + return Color.from_hsl((h + 0.5) % 1, s, l, alpha=color.alpha) + + +@register('invert', 1) +def invert(color): + """ + Returns the inverse (negative) of a color. + The red, green, and blue values are inverted, while the opacity is left alone. + """ + r, g, b, a = color.rgba + return Color.from_rgb(1 - r, 1 - g, 1 - b, alpha=a) + + +@register('adjust-lightness', 2) +def adjust_lightness(color, amount): + return adjust_color(color, lightness=amount) + + +@register('adjust-saturation', 2) +def adjust_saturation(color, amount): + return adjust_color(color, saturation=amount) + + +@register('scale-lightness', 2) +def scale_lightness(color, amount): + return scale_color(color, lightness=amount) + + +@register('scale-saturation', 2) +def scale_saturation(color, amount): + return scale_color(color, saturation=amount) + + +@register('adjust-color') +def adjust_color(color, red=None, green=None, blue=None, hue=None, saturation=None, lightness=None, alpha=None): + do_rgb = red or green or blue + do_hsl = hue or saturation or lightness + if do_rgb and do_hsl: + raise ValueError("Can't adjust both RGB and HSL channels at the same time") + + zero = Number(0) + a = color.alpha + (alpha or zero).value + + if do_rgb: + r, g, b = color.rgba[:3] + channels = [ + current + (adjustment or zero).value / 255 + for (current, adjustment) in zip(color.rgba, (red, green, blue))] + return Color.from_rgb(*channels, alpha=a) + + else: + h, s, l = color.hsl + h = (h + (hue or zero).value / 360) % 1 + s += _interpret_percentage(saturation or zero, relto=100, clamp=False) + l += _interpret_percentage(lightness or zero, relto=100, clamp=False) + return Color.from_hsl(h, s, l, a) + + +def _scale_channel(channel, scaleby): + if scaleby is None: + return channel + + expect_type(scaleby, Number) + if not scaleby.is_simple_unit('%'): + raise ValueError("Expected percentage, got %r" % (scaleby,)) + + factor = scaleby.value / 100 + if factor > 0: + # Add x% of the remaining range, up to 1 + return channel + (1 - channel) * factor + else: + # Subtract x% of the existing channel. We add here because the factor + # is already negative + return channel * (1 + factor) + + +@register('scale-color') +def scale_color(color, red=None, green=None, blue=None, saturation=None, lightness=None, alpha=None): + do_rgb = red or green or blue + do_hsl = saturation or lightness + if do_rgb and do_hsl: + raise ValueError("Can't scale both RGB and HSL channels at the same time") + + scaled_alpha = _scale_channel(color.alpha, alpha) + + if do_rgb: + channels = [ + _scale_channel(channel, scaleby) + for channel, scaleby in zip(color.rgba, (red, green, blue))] + return Color.from_rgb(*channels, alpha=scaled_alpha) + + else: + channels = [ + _scale_channel(channel, scaleby) + for channel, scaleby in zip(color.hsl, (None, saturation, lightness))] + return Color.from_hsl(*channels, alpha=scaled_alpha) + + +@register('change-color') +def change_color(color, red=None, green=None, blue=None, hue=None, saturation=None, lightness=None, alpha=None): + do_rgb = red or green or blue + do_hsl = hue or saturation or lightness + if do_rgb and do_hsl: + raise ValueError("Can't change both RGB and HSL channels at the same time") + + if alpha is None: + alpha = color.alpha + else: + alpha = alpha.value + + if do_rgb: + channels = list(color.rgba[:3]) + if red: + channels[0] = _interpret_percentage(red, relto=255) + if green: + channels[1] = _interpret_percentage(green, relto=255) + if blue: + channels[2] = _interpret_percentage(blue, relto=255) + + return Color.from_rgb(*channels, alpha=alpha) + + else: + channels = list(color.hsl) + if hue: + expect_type(hue, Number, unit=None) + channels[0] = (hue.value / 360) % 1 + # Ruby sass treats plain numbers for saturation and lightness as though + # they were percentages, just without the % + if saturation: + channels[1] = _interpret_percentage(saturation, relto=100) + if lightness: + channels[2] = _interpret_percentage(lightness, relto=100) + + return Color.from_hsl(*channels, alpha=alpha) + + +# ------------------------------------------------------------------------------ +# String functions + +@register('e', 1) +@register('escape', 1) +@register('unquote') +def unquote(*args): + arg = List.from_maybe_starargs(args).maybe() + + if isinstance(arg, String): + return String(arg.value, quotes=None) + else: + return String(arg.render(), quotes=None) + + +@register('quote') +def quote(*args): + arg = List.from_maybe_starargs(args).maybe() + + if isinstance(arg, String): + return String(arg.value, quotes='"') + else: + return String(arg.render(), quotes='"') + + +@register('str-length', 1) +def str_length(string): + expect_type(string, String) + + # nb: can't use `len(string)`, because that gives the Sass list length, + # which is 1 + return Number(len(string.value)) + + +# TODO this and several others should probably also require integers +# TODO and assert that the indexes are valid +@register('str-insert', 3) +def str_insert(string, insert, index): + expect_type(string, String) + expect_type(insert, String) + expect_type(index, Number, unit=None) + + py_index = index.to_python_index(len(string.value), check_bounds=False) + return String( + string.value[:py_index] + + insert.value + + string.value[py_index:], + quotes=string.quotes) + + +@register('str-index', 2) +def str_index(string, substring): + expect_type(string, String) + expect_type(substring, String) + + # 1-based indexing, with 0 for failure + return Number(string.value.find(substring.value) + 1) + + +@register('str-slice', 2) +@register('str-slice', 3) +def str_slice(string, start_at, end_at=None): + expect_type(string, String) + expect_type(start_at, Number, unit=None) + py_start_at = start_at.to_python_index(len(string.value)) + + if end_at is None: + py_end_at = None + else: + expect_type(end_at, Number, unit=None) + # Endpoint is inclusive, unlike Python + py_end_at = end_at.to_python_index(len(string.value)) + 1 + + return String( + string.value[py_start_at:py_end_at], + quotes=string.quotes) + + +@register('to-upper-case', 1) +def to_upper_case(string): + expect_type(string, String) + + return String(string.value.upper(), quotes=string.quotes) + + +@register('to-lower-case', 1) +def to_lower_case(string): + expect_type(string, String) + + return String(string.value.lower(), quotes=string.quotes) + + +# ------------------------------------------------------------------------------ +# Number functions + +@register('percentage', 1) +def percentage(value): + expect_type(value, Number, unit=None) + return value * Number(100, unit='%') + +CORE_LIBRARY.add(Number.wrap_python_function(abs), 'abs', 1) +CORE_LIBRARY.add(Number.wrap_python_function(round), 'round', 1) +CORE_LIBRARY.add(Number.wrap_python_function(math.ceil), 'ceil', 1) +CORE_LIBRARY.add(Number.wrap_python_function(math.floor), 'floor', 1) + + +# ------------------------------------------------------------------------------ +# List functions + +def __parse_separator(separator, default_from=None): + if separator is None: + separator = 'auto' + separator = String.unquoted(separator).value + + if separator == 'comma': + return True + elif separator == 'space': + return False + elif separator == 'auto': + if not default_from: + return True + elif len(default_from) < 2: + return True + else: + return default_from.use_comma + else: + raise ValueError('Separator must be auto, comma, or space') + + +# TODO get the compass bit outta here +@register('-compass-list-size') +@register('length') +def _length(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return Number(len(lst)) + + +@register('set-nth', 3) +def set_nth(list, n, value): + expect_type(n, Number, unit=None) + + py_n = n.to_python_index(len(list)) + return List( + tuple(list[:py_n]) + (value,) + tuple(list[py_n + 1:]), + use_comma=list.use_comma) + + +# TODO get the compass bit outta here +@register('-compass-nth', 2) +@register('nth', 2) +def nth(lst, n): + """Return the nth item in the list.""" + expect_type(n, (String, Number), unit=None) + + if isinstance(n, String): + if n.value.lower() == 'first': + i = 0 + elif n.value.lower() == 'last': + i = -1 + else: + raise ValueError("Invalid index %r" % (n,)) + else: + # DEVIATION: nth treats lists as circular lists + i = n.to_python_index(len(lst), circular=True) + + return lst[i] + + +@register('join', 2) +@register('join', 3) +def join(lst1, lst2, separator=None): + ret = [] + ret.extend(List.from_maybe(lst1)) + ret.extend(List.from_maybe(lst2)) + + use_comma = __parse_separator(separator, default_from=lst1) + return List(ret, use_comma=use_comma) + + +@register('min') +def min_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return min(lst) + + +@register('max') +def max_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return max(lst) + + +@register('append', 2) +@register('append', 3) +def append(lst, val, separator=None): + ret = [] + ret.extend(List.from_maybe(lst)) + ret.append(val) + + use_comma = __parse_separator(separator, default_from=lst) + return List(ret, use_comma=use_comma) + + +@register('index', 2) +def index(lst, val): + for i in xrange(len(lst)): + if lst.value[i] == val: + return Number(i + 1) + return Boolean(False) + + +@register('zip') +def zip_(*lists): + return List( + [List(zipped) for zipped in zip(*lists)], + use_comma=True) + + +# TODO need a way to use "list" as the arg name without shadowing the builtin +@register('list-separator', 1) +def list_separator(list): + if list.use_comma: + return String.unquoted('comma') + else: + return String.unquoted('space') + + +# ------------------------------------------------------------------------------ +# Map functions + +@register('map-get', 2) +def map_get(map, key): + return map.to_dict().get(key, Null()) + + +@register('map-merge', 2) +def map_merge(*maps): + key_order = [] + index = {} + for map in maps: + for key, value in map.to_pairs(): + if key not in index: + key_order.append(key) + + index[key] = value + + pairs = [(key, index[key]) for key in key_order] + return Map(pairs, index=index) + + +@register('map-keys', 1) +def map_keys(map): + return List( + [k for (k, v) in map.to_pairs()], + use_comma=True) + + +@register('map-values', 1) +def map_values(map): + return List( + [v for (k, v) in map.to_pairs()], + use_comma=True) + + +@register('map-has-key', 2) +def map_has_key(map, key): + return Boolean(key in map.to_dict()) + + +# DEVIATIONS: these do not exist in ruby sass + +@register('map-get', 3) +def map_get3(map, key, default): + return map.to_dict().get(key, default) + + +@register('map-get-nested', 2) +@register('map-get-nested', 3) +def map_get_nested3(map, keys, default=Null()): + for key in keys: + map = map.to_dict().get(key, None) + if map is None: + return default + + return map + + +@register('map-merge-deep', 2) +def map_merge_deep(*maps): + pairs = [] + keys = set() + for map in maps: + for key, value in map.to_pairs(): + keys.add(key) + + for key in keys: + values = [map.to_dict().get(key, None) for map in maps] + values = [v for v in values if v is not None] + if all(isinstance(v, Map) for v in values): + pairs.append((key, map_merge_deep(*values))) + else: + pairs.append((key, values[-1])) + + return Map(pairs) + + +# ------------------------------------------------------------------------------ +# Meta functions + +@register('type-of', 1) +def _type_of(obj): # -> bool, number, string, color, list + return String(obj.sass_type_name) + + +@register('unit', 1) +def unit(number): # -> px, em, cm, etc. + numer = '*'.join(sorted(number.unit_numer)) + denom = '*'.join(sorted(number.unit_denom)) + + if denom: + ret = numer + '/' + denom + else: + ret = numer + return String.unquoted(ret) + + +@register('unitless', 1) +def unitless(value): + if not isinstance(value, Number): + raise TypeError("Expected number, got %r" % (value,)) + + return Boolean(value.is_unitless) + + +@register('comparable', 2) +def comparable(number1, number2): + left = number1.to_base_units() + right = number2.to_base_units() + return Boolean( + left.unit_numer == right.unit_numer + and left.unit_denom == right.unit_denom) + + +# ------------------------------------------------------------------------------ +# Miscellaneous + +@register('if', 2) +@register('if', 3) +def if_(condition, if_true, if_false=Null()): + return if_true if condition else if_false diff --git a/libs/scss/functions/extra.py b/libs/scss/functions/extra.py new file mode 100644 index 00000000..da317a64 --- /dev/null +++ b/libs/scss/functions/extra.py @@ -0,0 +1,471 @@ +"""Functions new to the pyScss library.""" + +from __future__ import absolute_import + +import base64 +import hashlib +import logging +import os.path +import random + +import six +from six.moves import xrange + +from scss import config +from scss.functions.library import FunctionLibrary +from scss.types import Color, Number, String, List +from scss.util import escape + +try: + from PIL import Image, ImageDraw +except ImportError: + try: + import Image + import ImageDraw + except: + Image = None + +log = logging.getLogger(__name__) + +EXTRA_LIBRARY = FunctionLibrary() +register = EXTRA_LIBRARY.register + + +# ------------------------------------------------------------------------------ +# Image stuff +def _image_noise(pixdata, size, density=None, intensity=None, color=None, opacity=None, monochrome=None, background=None): + if not density: + density = [0.8] + elif not isinstance(density, (tuple, list)): + density = [density] + + if not intensity: + intensity = [0.5] + elif not isinstance(intensity, (tuple, list)): + intensity = [intensity] + + if not color: + color = [(0, 0, 0, 0)] + elif not isinstance(color, (tuple, list)) or not isinstance(color[0], (tuple, list)): + color = [color] + + if not opacity: + opacity = [0.2] + elif not isinstance(opacity, (tuple, list)): + opacity = [opacity] + + if not monochrome: + monochrome = [False] + elif not isinstance(monochrome, (tuple, list)): + monochrome = [monochrome] + + pixels = {} + + if background: + for y in xrange(size): + for x in xrange(size): + ca = float(background[3]) + pixels[(x, y)] = (background[0] * ca, background[1] * ca, background[2] * ca, ca) + + loops = max(map(len, (density, intensity, color, opacity, monochrome))) + for l in range(loops): + _density = density[l % len(density)] + _intensity = intensity[l % len(intensity)] + _color = color[l % len(color)] + _opacity = opacity[l % len(opacity)] + _monochrome = monochrome[l % len(monochrome)] + _intensity = 1 - _intensity + if _intensity < 0.5: + cx = 255 * _intensity + cm = cx + else: + cx = 255 * (1 - _intensity) + cm = 255 * _intensity + xa = int(cm - cx) + xb = int(cm + cx) + if xa > 0: + xa &= 255 + else: + xa = 0 + if xb > 0: + xb &= 255 + else: + xb = 0 + r, g, b, a = _color + for i in xrange(int(round(_density * size ** 2))): + x = random.randint(1, size) + y = random.randint(1, size) + cc = random.randint(xa, xb) + cr = (cc) * (1 - a) + a * r + cg = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * g + cb = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * b + ca = random.random() * _opacity + ica = 1 - ca + pos = (x - 1, y - 1) + dst = pixels.get(pos, (0, 0, 0, 0)) + src = (cr * ca, cg * ca, cb * ca, ca) + pixels[pos] = (src[0] + dst[0] * ica, src[1] + dst[1] * ica, src[2] + dst[2] * ica, src[3] + dst[3] * ica) + + for pos, col in pixels.items(): + ca = col[3] + if ca: + pixdata[pos] = tuple(int(round(c)) for c in (col[0] / ca, col[1] / ca, col[2] / ca, ca * 255)) + + +def _image_brushed(pixdata, size, density=None, intensity=None, color=None, opacity=None, monochrome=None, direction=None, spread=None, background=None): + if not density: + density = [0.8] + elif not isinstance(density, (tuple, list)): + density = [density] + + if not intensity: + intensity = [0.5] + elif not isinstance(intensity, (tuple, list)): + intensity = [intensity] + + if not color: + color = [(0, 0, 0, 0)] + elif not isinstance(color, (tuple, list)) or not isinstance(color[0], (tuple, list)): + color = [color] + + if not opacity: + opacity = [0.2] + elif not isinstance(opacity, (tuple, list)): + opacity = [opacity] + + if not monochrome: + monochrome = [False] + elif not isinstance(monochrome, (tuple, list)): + monochrome = [monochrome] + + if not direction: + direction = [0] + elif not isinstance(direction, (tuple, list)): + direction = [direction] + + if not spread: + spread = [0] + elif not isinstance(spread, (tuple, list)): + spread = [spread] + + def ppgen(d): + if d is None: + return + d = d % 4 + if d == 0: + pp = lambda x, y, o: ((x - o) % size, y) + elif d == 1: + pp = lambda x, y, o: ((x - o) % size, (y + x - o) % size) + elif d == 2: + pp = lambda x, y, o: (y, (x - o) % size) + else: + pp = lambda x, y, o: ((x - o) % size, (y - x - o) % size) + return pp + + pixels = {} + + if background: + for y in xrange(size): + for x in xrange(size): + ca = float(background[3]) + pixels[(x, y)] = (background[0] * ca, background[1] * ca, background[2] * ca, ca) + + loops = max(map(len, (density, intensity, color, opacity, monochrome, direction, spread))) + for l in range(loops): + _density = density[l % len(density)] + _intensity = intensity[l % len(intensity)] + _color = color[l % len(color)] + _opacity = opacity[l % len(opacity)] + _monochrome = monochrome[l % len(monochrome)] + _direction = direction[l % len(direction)] + _spread = spread[l % len(spread)] + _intensity = 1 - _intensity + if _intensity < 0.5: + cx = 255 * _intensity + cm = cx + else: + cx = 255 * (1 - _intensity) + cm = 255 * _intensity + xa = int(cm - cx) + xb = int(cm + cx) + if xa > 0: + xa &= 255 + else: + xa = 0 + if xb > 0: + xb &= 255 + else: + xb = 0 + r, g, b, a = _color + pp = ppgen(_direction) + if pp: + for y in xrange(size): + if _spread and (y + (l % 2)) % _spread: + continue + o = random.randint(1, size) + cc = random.randint(xa, xb) + cr = (cc) * (1 - a) + a * r + cg = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * g + cb = (cc if _monochrome else random.randint(xa, xb)) * (1 - a) + a * b + da = random.randint(0, 255) * _opacity + ip = round((size / 2.0 * _density) / int(1 / _density)) + iq = round((size / 2.0 * (1 - _density)) / int(1 / _density)) + if ip: + i = da / ip + aa = 0 + else: + i = 0 + aa = da + d = 0 + p = ip + for x in xrange(size): + if d == 0: + if p > 0: + p -= 1 + aa += i + else: + d = 1 + q = iq + elif d == 1: + if q > 0: + q -= 1 + else: + d = 2 + p = ip + elif d == 2: + if p > 0: + p -= 1 + aa -= i + else: + d = 3 + q = iq + elif d == 3: + if q > 0: + q -= 1 + else: + d = 0 + p = ip + if aa > 0: + ca = aa / 255.0 + else: + ca = 0.0 + ica = 1 - ca + pos = pp(x, y, o) + dst = pixels.get(pos, (0, 0, 0, 0)) + src = (cr * ca, cg * ca, cb * ca, ca) + pixels[pos] = (src[0] + dst[0] * ica, src[1] + dst[1] * ica, src[2] + dst[2] * ica, src[3] + dst[3] * ica) + + for pos, col in pixels.items(): + ca = col[3] + if ca: + pixdata[pos] = tuple(int(round(c)) for c in (col[0] / ca, col[1] / ca, col[2] / ca, ca * 255)) + + +@register('background-noise', 0) +@register('background-noise', 1) +@register('background-noise', 2) +@register('background-noise', 3) +@register('background-noise', 4) +@register('background-noise', 5) +@register('background-noise', 6) +@register('background-noise', 7) +def background_noise(density=None, opacity=None, size=None, monochrome=False, intensity=(), color=None, background=None, inline=False): + if not Image: + raise Exception("Images manipulation require PIL") + + density = [Number(v).value for v in List.from_maybe(density)] + intensity = [Number(v).value for v in List.from_maybe(intensity)] + color = [Color(v).value for v in List.from_maybe(color) if v] + opacity = [Number(v).value for v in List.from_maybe(opacity)] + + size = int(Number(size).value) if size else 0 + if size < 1 or size > 512: + size = 200 + + monochrome = bool(monochrome) + + background = Color(background).value if background else None + + new_image = Image.new( + mode='RGBA', + size=(size, size) + ) + + pixdata = new_image.load() + _image_noise(pixdata, size, density, intensity, color, opacity, monochrome) + + if not inline: + key = (size, density, intensity, color, opacity, monochrome) + asset_file = 'noise-%s%sx%s' % ('mono-' if monochrome else '', size, size) + # asset_file += '-[%s][%s]' % ('-'.join(to_str(s).replace('.', '_') for s in density or []), '-'.join(to_str(s).replace('.', '_') for s in opacity or [])) + asset_file += '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_') + asset_file += '.png' + asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file) + try: + new_image.save(asset_path) + except IOError: + log.exception("Error while saving image") + inline = True # Retry inline version + url = '%s%s' % (config.ASSETS_URL, asset_file) + if inline: + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + url = 'data:image/png;base64,' + base64.b64encode(contents) + + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) + + +@register('background-brushed', 0) +@register('background-brushed', 1) +@register('background-brushed', 2) +@register('background-brushed', 3) +@register('background-brushed', 4) +@register('background-brushed', 5) +@register('background-brushed', 6) +@register('background-brushed', 7) +@register('background-brushed', 8) +@register('background-brushed', 9) +def background_brushed(density=None, intensity=None, color=None, opacity=None, size=None, monochrome=False, direction=(), spread=(), background=None, inline=False): + if not Image: + raise Exception("Images manipulation require PIL") + + density = [Number(v).value for v in List.from_maybe(density)] + intensity = [Number(v).value for v in List.from_maybe(intensity)] + color = [Color(v).value for v in List.from_maybe(color) if v] + opacity = [Number(v).value for v in List.from_maybe(opacity)] + + size = int(Number(size).value) if size else -1 + if size < 0 or size > 512: + size = 200 + + monochrome = bool(monochrome) + + direction = [Number(v).value for v in List.from_maybe(direction)] + spread = [Number(v).value for v in List.from_maybe(spread)] + + background = Color(background).value if background else None + + new_image = Image.new( + mode='RGBA', + size=(size, size) + ) + + pixdata = new_image.load() + _image_brushed(pixdata, size, density, intensity, color, opacity, monochrome, direction, spread, background) + + if not inline: + key = (size, density, intensity, color, opacity, monochrome, direction, spread, background) + asset_file = 'brushed-%s%sx%s' % ('mono-' if monochrome else '', size, size) + # asset_file += '-[%s][%s][%s]' % ('-'.join(to_str(s).replace('.', '_') for s in density or []), '-'.join(to_str(s).replace('.', '_') for s in opacity or []), '-'.join(to_str(s).replace('.', '_') for s in direction or [])) + asset_file += '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_') + asset_file += '.png' + asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file) + try: + new_image.save(asset_path) + except IOError: + log.exception("Error while saving image") + inline = True # Retry inline version + url = '%s%s' % (config.ASSETS_URL, asset_file) + if inline: + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + url = 'data:image/png;base64,' + base64.b64encode(contents) + + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) + + +@register('grid-image', 4) +@register('grid-image', 5) +def _grid_image(left_gutter, width, right_gutter, height, columns=1, grid_color=None, baseline_color=None, background_color=None, inline=False): + if not Image: + raise Exception("Images manipulation require PIL") + if grid_color is None: + grid_color = (120, 170, 250, 15) + else: + c = Color(grid_color).value + grid_color = (c[0], c[1], c[2], int(c[3] * 255.0)) + if baseline_color is None: + baseline_color = (120, 170, 250, 30) + else: + c = Color(baseline_color).value + baseline_color = (c[0], c[1], c[2], int(c[3] * 255.0)) + if background_color is None: + background_color = (0, 0, 0, 0) + else: + c = Color(background_color).value + background_color = (c[0], c[1], c[2], int(c[3] * 255.0)) + _height = int(height) if height >= 1 else int(height * 1000.0) + _width = int(width) if width >= 1 else int(width * 1000.0) + _left_gutter = int(left_gutter) if left_gutter >= 1 else int(left_gutter * 1000.0) + _right_gutter = int(right_gutter) if right_gutter >= 1 else int(right_gutter * 1000.0) + if _height <= 0 or _width <= 0 or _left_gutter <= 0 or _right_gutter <= 0: + raise ValueError + _full_width = (_left_gutter + _width + _right_gutter) + new_image = Image.new( + mode='RGBA', + size=(_full_width * int(columns), _height), + color=background_color + ) + draw = ImageDraw.Draw(new_image) + for i in range(int(columns)): + draw.rectangle((i * _full_width + _left_gutter, 0, i * _full_width + _left_gutter + _width - 1, _height - 1), fill=grid_color) + if _height > 1: + draw.rectangle((0, _height - 1, _full_width * int(columns) - 1, _height - 1), fill=baseline_color) + if not inline: + grid_name = 'grid_' + if left_gutter: + grid_name += str(int(left_gutter)) + '+' + grid_name += str(int(width)) + if right_gutter: + grid_name += '+' + str(int(right_gutter)) + if height and height > 1: + grid_name += 'x' + str(int(height)) + key = (columns, grid_color, baseline_color, background_color) + key = grid_name + '-' + base64.urlsafe_b64encode(hashlib.md5(repr(key)).digest()).rstrip('=').replace('-', '_') + asset_file = key + '.png' + asset_path = os.path.join(config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets'), asset_file) + try: + new_image.save(asset_path) + except IOError: + log.exception("Error while saving image") + inline = True # Retry inline version + url = '%s%s' % (config.ASSETS_URL, asset_file) + if inline: + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + url = 'data:image/png;base64,' + base64.b64encode(contents) + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) + + +@register('image-color', 1) +@register('image-color', 2) +@register('image-color', 3) +def image_color(color, width=1, height=1): + if not Image: + raise Exception("Images manipulation require PIL") + w = int(Number(width).value) + h = int(Number(height).value) + if w <= 0 or h <= 0: + raise ValueError + new_image = Image.new( + mode='RGB' if color.alpha == 1 else 'RGBA', + size=(w, h), + color=color.rgba255, + ) + output = six.BytesIO() + new_image.save(output, format='PNG') + contents = output.getvalue() + output.close() + mime_type = 'image/png' + url = 'data:' + mime_type + ';base64,' + base64.b64encode(contents) + inline = 'url("%s")' % escape(url) + return String.unquoted(inline) diff --git a/libs/scss/functions/library.py b/libs/scss/functions/library.py new file mode 100644 index 00000000..ce4a45c4 --- /dev/null +++ b/libs/scss/functions/library.py @@ -0,0 +1,81 @@ +"""Defines `FunctionLibrary`, a class that collects functions to be exposed to +SCSS during compilation. +""" + +# TODO the constant versions of this should be frozen +class FunctionLibrary(object): + """Contains a set of functions to be exposed to SCSS. + + Functions are keyed by both their name _and_ arity; this allows for + function overloading somewhat beyond what Python easily allows, and is + required for several functions in the standard Sass library. + + Functions may also have arbitrary arity, in which case they accept any + number of arguments, though a function of the same name with a specific + arity takes precedence. + """ + + def __init__(self): + self._functions = {} + + def derive(self): + """Returns a new registry object, pre-populated with all the functions + in this registry. + """ + new = type(self)() + new.inherit(self) + return new + + def inherit(self, *other_libraries): + """Import all the functions from the given other libraries into this one. + + Note that existing functions ARE NOT replaced -- which also means that + functions from the first library take precedence over functions from + the second library, etc. + """ + new_functions = dict() + + # dict.update replaces keys; go through the other libraries in reverse, + # so the first one wins + for library in reversed(other_libraries): + new_functions.update(library._functions) + + new_functions.update(self._functions) + self._functions = new_functions + + def register(self, name, argc=None): + """Decorator for adding a function to this library.""" + # XXX: this should allow specifying names of keyword arguments, as + # well. currently none of these functions support kwargs, i think. + # XXX automatically inferring the name and args would be... + # interesting + # XXX also it would be nice if a function which EXISTS but has the + # wrong number of args threw a useful error; atm i think it'll become + # literal (yikes!) + + key = (name, argc) + + def decorator(f): + self._functions[key] = f + return f + + return decorator + + def add(self, function, name, argc=None): + """Add a function to this library imperatively.""" + + key = (name, argc) + self._functions[key] = function + + def lookup(self, name, argc=None): + """Find a function given its name and the number of arguments it takes. + Falls back to a function with the same name that takes any number of + arguments. + """ + # Try the given arity first + key = name, argc + if key in self._functions: + return self._functions[key] + + # Fall back to arbitrary-arity (or KeyError if neither exists) + return self._functions[name, None] diff --git a/libs/scss/rule.py b/libs/scss/rule.py new file mode 100644 index 00000000..60fec68d --- /dev/null +++ b/libs/scss/rule.py @@ -0,0 +1,524 @@ +from __future__ import absolute_import +from __future__ import print_function + +import logging +import six + +from scss.types import Value, Undefined + +log = logging.getLogger(__name__) + +SORTED_SELECTORS = False + +sort = sorted if SORTED_SELECTORS else lambda it: it + + +def normalize_var(name): + if isinstance(name, six.string_types): + return name.replace('_', '-') + else: + log.warn("Variable name doesn't look like a string: %r", name) + return name + + +def extend_unique(seq, more): + """Return a new sequence containing the items in `seq` plus any items in + `more` that aren't already in `seq`, preserving the order of both. + """ + seen = set(seq) + new = [] + for item in more: + if item not in seen: + seen.add(item) + new.append(item) + + return seq + type(seq)(new) + + +class Scope(object): + """Implements Sass variable scoping. + + Similar to `ChainMap`, except that assigning a new value will replace an + existing value, not mask it. + """ + def __init__(self, maps=()): + maps = list(maps) + assert all(isinstance(m, dict) or isinstance(m, type(self)) for m in maps) # Check all passed maps are compatible with the current Scope + self.maps = [dict()] + maps + + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join(repr(map) for map in self.maps), id(self)) + + def __getitem__(self, key): + for map in self.maps: + if key in map: + return map[key] + + raise KeyError(key) + + def __setitem__(self, key, value): + self.set(key, value) + + def __contains__(self, key): + try: + self[key] + except KeyError: + return False + else: + return True + + def keys(self): + # For mapping interface + keys = set() + for map in self.maps: + keys.update(map.keys()) + return list(keys) + + def set(self, key, value, force_local=False): + if not force_local: + for map in self.maps: + if key in map: + if isinstance(map[key], Undefined): + break + map[key] = value + return + + self.maps[0][key] = value + + def new_child(self): + return type(self)(self.maps) + + +class VariableScope(Scope): + pass + + +class FunctionScope(Scope): + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) + + +class MixinScope(Scope): + def __repr__(self): + return "<%s(%s) at 0x%x>" % (type(self).__name__, ', '.join('[%s]' % ', '.join('%s:%s' % (f, n) for f, n in sorted(map.keys())) for map in self.maps), id(self)) + + +class ImportScope(Scope): + pass + + +class Namespace(object): + """...""" + _mutable = True + + def __init__(self, variables=None, functions=None, mixins=None, mutable=True): + self._mutable = mutable + + if variables is None: + self._variables = VariableScope() + else: + # TODO parse into sass values once that's a thing, or require them + # all to be + self._variables = VariableScope([variables]) + + if functions is None: + self._functions = FunctionScope() + else: + self._functions = FunctionScope([functions._functions]) + + self._mixins = MixinScope() + + self._imports = ImportScope() + + def _assert_mutable(self): + if not self._mutable: + raise AttributeError("This Namespace instance is immutable") + + @classmethod + def derive_from(cls, *others): + self = cls() + if len(others) == 1: + self._variables = others[0]._variables.new_child() + self._functions = others[0]._functions.new_child() + self._mixins = others[0]._mixins.new_child() + self._imports = others[0]._imports.new_child() + else: + # Note that this will create a 2-dimensional scope where each of + # these scopes is checked first in order. TODO is this right? + self._variables = VariableScope(other._variables for other in others) + self._functions = FunctionScope(other._functions for other in others) + self._mixins = MixinScope(other._mixins for other in others) + self._imports = ImportScope(other._imports for other in others) + return self + + def derive(self): + """Return a new child namespace. All existing variables are still + readable and writeable, but any new variables will only exist within a + new scope. + """ + return type(self).derive_from(self) + + @property + def variables(self): + return dict((k, self._variables[k]) for k in self._variables.keys()) + + def variable(self, name, throw=False): + name = normalize_var(name) + return self._variables[name] + + def set_variable(self, name, value, local_only=False): + self._assert_mutable() + name = normalize_var(name) + if not isinstance(value, Value): + raise TypeError("Expected a Sass type, while setting %s got %r" % (name, value,)) + self._variables.set(name, value, force_local=local_only) + + def has_import(self, import_key): + return import_key in self._imports + + def add_import(self, import_key, parent_import_key, file_and_line): + self._assert_mutable() + if import_key: + imports = [0, parent_import_key, file_and_line] + self._imports[import_key] = imports + + def use_import(self, import_key): + self._assert_mutable() + if import_key and import_key in self._imports: + imports = self._imports[import_key] + imports[0] += 1 + self.use_import(imports[1]) + + def unused_imports(self): + unused = [] + for import_key in self._imports.keys(): + imports = self._imports[import_key] + if not imports[0]: + unused.append((import_key[0], imports[2])) + return unused + + def _get_callable(self, chainmap, name, arity): + name = normalize_var(name) + if arity is not None: + # With explicit arity, try the particular arity before falling back + # to the general case (None) + try: + return chainmap[name, arity] + except KeyError: + pass + + return chainmap[name, None] + + def _set_callable(self, chainmap, name, arity, cb): + name = normalize_var(name) + chainmap[name, arity] = cb + + def mixin(self, name, arity): + return self._get_callable(self._mixins, name, arity) + + def set_mixin(self, name, arity, cb): + self._assert_mutable() + self._set_callable(self._mixins, name, arity, cb) + + def function(self, name, arity): + return self._get_callable(self._functions, name, arity) + + def set_function(self, name, arity, cb): + self._assert_mutable() + self._set_callable(self._functions, name, arity, cb) + + +class SassRule(object): + """At its heart, a CSS rule: combination of a selector and zero or more + properties. But this is Sass, so it also tracks some Sass-flavored + metadata, like `@extend` rules and `@media` nesting. + """ + + def __init__(self, source_file, import_key=None, unparsed_contents=None, + options=None, properties=None, + namespace=None, + lineno=0, extends_selectors=frozenset(), + ancestry=None, + nested=0): + + self.source_file = source_file + self.import_key = import_key + self.lineno = lineno + + self.unparsed_contents = unparsed_contents + self.options = options + self.extends_selectors = extends_selectors + + if namespace is None: + assert False + self.namespace = Namespace() + else: + self.namespace = namespace + + if properties is None: + self.properties = [] + else: + self.properties = properties + + if ancestry is None: + self.ancestry = RuleAncestry() + else: + self.ancestry = ancestry + + self.nested = nested + + self.descendants = 0 + + def __repr__(self): + return "" % ( + self.ancestry, + len(self.properties), + ) + + @property + def selectors(self): + # TEMPORARY + if self.ancestry.headers and self.ancestry.headers[-1].is_selector: + return self.ancestry.headers[-1].selectors + else: + return () + + @property + def file_and_line(self): + """Return the filename and line number where this rule originally + appears, in the form "foo.scss:3". Used for error messages. + """ + return "%s:%d" % (self.source_file.filename, self.lineno) + + @property + def is_empty(self): + """Return whether this rule is considered "empty" -- i.e., has no + contents that should end up in the final CSS. + """ + if self.properties: + # Rules containing CSS properties are never empty + return False + + if not self.descendants: + for header in self.ancestry.headers: + if header.is_atrule and header.directive != '@media': + # At-rules should always be preserved, UNLESS they are @media + # blocks, which are known to be noise if they don't have any + # contents of their own + return False + + return True + + def copy(self): + return type(self)( + source_file=self.source_file, + lineno=self.lineno, + unparsed_contents=self.unparsed_contents, + + options=self.options, + #properties=list(self.properties), + properties=self.properties, + extends_selectors=self.extends_selectors, + #ancestry=list(self.ancestry), + ancestry=self.ancestry, + + namespace=self.namespace.derive(), + nested=self.nested, + ) + + +class RuleAncestry(object): + def __init__(self, headers=()): + self.headers = tuple(headers) + + def __repr__(self): + return "<%s %r>" % (type(self).__name__, self.headers) + + def __len__(self): + return len(self.headers) + + def with_nested_selectors(self, c_selectors): + if self.headers and self.headers[-1].is_selector: + # Need to merge with parent selectors + p_selectors = self.headers[-1].selectors + + new_selectors = [] + for p_selector in p_selectors: + for c_selector in c_selectors: + new_selectors.append(c_selector.with_parent(p_selector)) + + # Replace the last header with the new merged selectors + new_headers = self.headers[:-1] + (BlockSelectorHeader(new_selectors),) + return RuleAncestry(new_headers) + + else: + # Whoops, no parent selectors. Just need to double-check that + # there are no uses of `&`. + for c_selector in c_selectors: + if c_selector.has_parent_reference: + raise ValueError("Can't use parent selector '&' in top-level rules") + + # Add the children as a new header + new_headers = self.headers + (BlockSelectorHeader(c_selectors),) + return RuleAncestry(new_headers) + + def with_more_selectors(self, selectors): + """Return a new ancestry that also matches the given selectors. No + nesting is done. + """ + if self.headers and self.headers[-1].is_selector: + new_selectors = extend_unique( + self.headers[-1].selectors, + selectors) + new_headers = self.headers[:-1] + ( + BlockSelectorHeader(new_selectors),) + return RuleAncestry(new_headers) + else: + new_headers = self.headers + (BlockSelectorHeader(selectors),) + return RuleAncestry(new_headers) + + +class BlockHeader(object): + """...""" + # TODO doc me depending on how UnparsedBlock is handled... + + is_atrule = False + is_scope = False + is_selector = False + + @classmethod + def parse(cls, prop, has_contents=False): + # Simple pre-processing + if prop.startswith('+') and not has_contents: + # Expand '+' at the beginning of a rule as @include. But not if + # there's a block, because that's probably a CSS selector. + # DEVIATION: this is some semi hybrid of Sass and xCSS syntax + prop = '@include ' + prop[1:] + try: + if '(' not in prop or prop.index(':') < prop.index('('): + prop = prop.replace(':', '(', 1) + if '(' in prop: + prop += ')' + except ValueError: + pass + elif prop.startswith('='): + # Expand '=' at the beginning of a rule as @mixin + prop = '@mixin ' + prop[1:] + elif prop.startswith('@prototype '): + # Remove '@prototype ' + # TODO what is @prototype?? + prop = prop[11:] + + # Minor parsing + if prop.startswith('@'): + if prop.lower().startswith('@else if '): + directive = '@else if' + argument = prop[9:] + else: + directive, _, argument = prop.partition(' ') + directive = directive.lower() + + return BlockAtRuleHeader(directive, argument) + else: + if prop.endswith(':') or ': ' in prop: + # Syntax is ": [prop]" -- if the optional prop exists, + # it becomes the first rule with no suffix + scope, unscoped_value = prop.split(':', 1) + scope = scope.rstrip() + unscoped_value = unscoped_value.lstrip() + return BlockScopeHeader(scope, unscoped_value) + else: + return BlockSelectorHeader(prop) + + +class BlockAtRuleHeader(BlockHeader): + is_atrule = True + + def __init__(self, directive, argument): + self.directive = directive + self.argument = argument + + def __repr__(self): + return "<%s %r %r>" % (type(self).__name__, self.directive, self.argument) + + def render(self): + if self.argument: + return "%s %s" % (self.directive, self.argument) + else: + return self.directive + + +class BlockSelectorHeader(BlockHeader): + is_selector = True + + def __init__(self, selectors): + self.selectors = tuple(selectors) + + def __repr__(self): + return "<%s %r>" % (type(self).__name__, self.selectors) + + def render(self, sep=', ', super_selector=''): + return sep.join(sort( + super_selector + s.render() + for s in self.selectors + if not s.has_placeholder)) + + +class BlockScopeHeader(BlockHeader): + is_scope = True + + def __init__(self, scope, unscoped_value): + self.scope = scope + + if unscoped_value: + self.unscoped_value = unscoped_value + else: + self.unscoped_value = None + + +class UnparsedBlock(object): + """A Sass block whose contents have not yet been parsed. + + At the top level, CSS (and Sass) documents consist of a sequence of blocks. + A block may be a ruleset: + + selector { block; block; block... } + + Or it may be an @-rule: + + @rule arguments { block; block; block... } + + Or it may be only a single property declaration: + + property: value + + pyScss's first parsing pass breaks the document into these blocks, and each + block becomes an instance of this class. + """ + + def __init__(self, parent_rule, lineno, prop, unparsed_contents): + self.parent_rule = parent_rule + self.header = BlockHeader.parse(prop, has_contents=bool(unparsed_contents)) + + # Basic properties + self.lineno = lineno + self.prop = prop + self.unparsed_contents = unparsed_contents + + @property + def directive(self): + return self.header.directive + + @property + def argument(self): + return self.header.argument + + ### What kind of thing is this? + + @property + def is_atrule(self): + return self.header.is_atrule + + @property + def is_scope(self): + return self.header.is_scope diff --git a/libs/scss/scss_meta.py b/libs/scss/scss_meta.py new file mode 100644 index 00000000..481eba4a --- /dev/null +++ b/libs/scss/scss_meta.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +#-*- coding: utf-8 -*- +""" +pyScss, a Scss compiler for Python + +@author German M. Bravo (Kronuz) +@version 1.2.0 alpha +@see https://github.com/Kronuz/pyScss +@copyright (c) 2012-2013 German M. Bravo (Kronuz) +@license MIT License + http://www.opensource.org/licenses/mit-license.php + +pyScss compiles Scss, a superset of CSS that is more powerful, elegant and +easier to maintain than plain-vanilla CSS. The library acts as a CSS source code +preprocesor which allows you to use variables, nested rules, mixins, andhave +inheritance of rules, all with a CSS-compatible syntax which the preprocessor +then compiles to standard CSS. + +Scss, as an extension of CSS, helps keep large stylesheets well-organized. It +borrows concepts and functionality from projects such as OOCSS and other similar +frameworks like as Sass. It's build on top of the original PHP xCSS codebase +structure but it's been completely rewritten, many bugs have been fixed and it +has been extensively extended to support almost the full range of Sass' Scss +syntax and functionality. + +Bits of code in pyScss come from various projects: +Compass: + (c) 2009 Christopher M. Eppstein + http://compass-style.org/ +Sass: + (c) 2006-2009 Hampton Catlin and Nathan Weizenbaum + http://sass-lang.com/ +xCSS: + (c) 2010 Anton Pawlik + http://xcss.antpaw.org/docs/ + + This file defines Meta data, according to PEP314 + (http://www.python.org/dev/peps/pep-0314/) which is common to both pyScss + and setup.py distutils. + + We create this here so this information can be compatible with BOTH + Python 2.x and Python 3.x so setup.py can use it when building pyScss + for both Py3.x and Py2.x + +""" + +VERSION_INFO = (1, 2, 0, 'post3') +DATE_INFO = (2013, 10, 8) # YEAR, MONTH, DAY +VERSION = '.'.join(str(i) for i in VERSION_INFO) +REVISION = '%04d%02d%02d' % DATE_INFO +BUILD_INFO = "pyScss v" + VERSION + " (" + REVISION + ")" +AUTHOR = "German M. Bravo (Kronuz)" +AUTHOR_EMAIL = 'german.mb@gmail.com' +URL = 'http://github.com/Kronuz/pyScss' +DOWNLOAD_URL = 'http://github.com/Kronuz/pyScss/tarball/v' + VERSION +LICENSE = "MIT" +PROJECT = "pyScss" + +if __name__ == "__main__": + print('VERSION = ' + VERSION) + print('REVISION = ' + REVISION) + print('BUILD_INFO = ' + BUILD_INFO) + print('AUTHOR = ' + AUTHOR) + print('AUTHOR_EMAIL = ' + AUTHOR_EMAIL) + print('URL = ' + URL) + print('LICENSE = ' + LICENSE) + print('PROJECT = ' + PROJECT) diff --git a/libs/scss/selector.py b/libs/scss/selector.py new file mode 100644 index 00000000..ebaed3c0 --- /dev/null +++ b/libs/scss/selector.py @@ -0,0 +1,607 @@ +from __future__ import print_function + +import re + +# Super dumb little selector parser. + +# Yes, yes, this is a regex tokenizer. The actual meaning of the +# selector doesn't matter; the parts are just important for matching up +# during @extend. + +# Selectors have three levels: simple, combinator, comma-delimited. +# Each combinator can only appear once as a delimiter between simple +# selectors, so it can be thought of as a prefix. +# So this: +# a.b + c, d#e +# parses into two Selectors with these structures: +# [[' ', 'a', '.b'], ['+', 'c']] +# [[' ', 'd', '#e']] +# Note that the first simple selector has an implied descendant +# combinator -- i.e., it is a descendant of the root element. +# TODO `*html` is incorrectly parsed as a single selector +# TODO this oughta be touched up for css4 selectors +SELECTOR_TOKENIZER = re.compile(r''' + # Colons introduce pseudo-selectors, sometimes with parens + # TODO doesn't handle quoted ) + [:]+ [-\w]+ (?: [(] .+? [)] )? + + # These guys are combinators -- note that a single space counts too + | \s* [ +>~,] \s* + + # Square brackets are attribute tests + # TODO: this doesn't handle ] within a string + | [[] .+? []] + + # Dot and pound start class/id selectors. Percent starts a Sass + # extend-target faux selector. + | [.#%] [-\w]+ + + # Percentages are used for @keyframes + | [-.\d]+ [%] + + # Plain identifiers, or single asterisks, are element names + | [-\w]+ + | [*] + + # & is the sass replacement token + | [&] + + # And as a last-ditch effort, just eat up to whitespace + | (\S+) +''', re.VERBOSE | re.MULTILINE) + + +# Maps the first character of a token to a rough ordering. The default +# (element names) is zero. +TOKEN_TYPE_ORDER = { + '#': 2, + '.': 3, + '[': 3, + ':': 3, + '%': 4, +} +TOKEN_SORT_KEY = lambda token: TOKEN_TYPE_ORDER.get(token[0], 0) + + +def _is_combinator_subset_of(specific, general, is_first=True): + """Return whether `specific` matches a non-strict subset of what `general` + matches. + """ + if is_first and general == ' ': + # First selector always has a space to mean "descendent of root", which + # still holds if any other selector appears above it + return True + + if specific == general: + return True + + if specific == '>' and general == ' ': + return True + + if specific == '+' and general == '~': + return True + + return False + + +class SimpleSelector(object): + """A simple selector, by CSS 2.1 terminology: a combination of element + name, class selectors, id selectors, and other criteria that all apply to a + single element. + + Note that CSS 3 considers EACH of those parts to be a "simple selector", + and calls a group of them a "sequence of simple selectors". That's a + terrible class name, so we're going with 2.1 here. + + For lack of a better name, each of the individual parts is merely called a + "token". + """ + def __init__(self, combinator, tokens): + self.combinator = combinator + # TODO enforce that only one element name (including *) appears in a + # selector + # TODO remove duplicates + self.tokens = tuple(sorted(tokens, key=TOKEN_SORT_KEY)) + + def __repr__(self): + return "<%s: %r>" % (type(self).__name__, self.render()) + + def __hash__(self): + return hash((self.combinator, self.tokens)) + + def __eq__(self, other): + if not isinstance(other, SimpleSelector): + return NotImplemented + + return ( + self.combinator == other.combinator and + self.tokens == other.tokens) + + @property + def has_parent_reference(self): + return '&' in self.tokens or 'self' in self.tokens + + @property + def has_placeholder(self): + return any( + token[0] == '%' + for token in self.tokens) + + def is_superset_of(self, other, soft_combinator=False): + """Return True iff this selector matches the same elements as `other`, + and perhaps others. + + That is, ``.foo`` is a superset of ``.foo.bar``, because the latter is + more specific. + + Set `soft_combinator` true to ignore the specific case of this selector + having a descendent combinator and `other` having anything else. This + is for superset checking for ``@extend``, where a space combinator + really means "none". + """ + # Combinators must match, OR be compatible -- space is a superset of >, + # ~ is a superset of + + if soft_combinator and self.combinator == ' ': + combinator_superset = True + else: + combinator_superset = ( + self.combinator == other.combinator or + (self.combinator == ' ' and other.combinator == '>') or + (self.combinator == '~' and other.combinator == '+')) + + return ( + combinator_superset and + set(self.tokens) <= set(other.tokens)) + + def replace_parent(self, parent_simples): + """If ``&`` (or the legacy xCSS equivalent ``self``) appears in this + selector, replace it with the given iterable of parent selectors. + + Returns a tuple of simple selectors. + """ + assert parent_simples + + ancestors = parent_simples[:-1] + parent = parent_simples[-1] + + did_replace = False + new_tokens = [] + for token in self.tokens: + if not did_replace and token in ('&', 'self'): + did_replace = True + new_tokens.extend(parent.tokens) + else: + new_tokens.append(token) + + if not did_replace: + # This simple selector doesn't contain a parent reference so just + # stick it on the end + return parent_simples + (self,) + + # This simple selector was merged into the direct parent. + merged_self = type(self)(parent.combinator, new_tokens) + selector = ancestors + (merged_self,) + # Our combinator goes on the first ancestor, i.e., substituting "foo + # bar baz" into "+ &.quux" produces "+ foo bar baz.quux". This means a + # potential conflict with the first ancestor's combinator! + root = selector[0] + if not _is_combinator_subset_of(self.combinator, root.combinator): + raise ValueError( + "Can't sub parent {0!r} into {1!r}: " + "combinators {2!r} and {3!r} conflict!" + .format( + parent_simples, self, self.combinator, root.combinator)) + + root = type(self)(self.combinator, root.tokens) + selector = (root,) + selector[1:] + return tuple(selector) + + # TODO just use set ops for these, once the constructor removes dupes + def merge_with(self, other): + new_tokens = self.tokens + tuple(token for token in other.tokens if token not in set(self.tokens)) + return type(self)(self.combinator, new_tokens) + + def difference(self, other): + new_tokens = tuple(token for token in self.tokens if token not in set(other.tokens)) + return type(self)(self.combinator, new_tokens) + + def render(self): + # TODO fail if there are no tokens, or if one is a placeholder? + rendered = ''.join(self.tokens) + if self.combinator != ' ': + rendered = ' '.join((self.combinator, rendered)) + + return rendered + + +class Selector(object): + """A single CSS selector.""" + + def __init__(self, simples): + """Return a selector containing a sequence of `SimpleSelector`s. + + You probably want to use `parse_many` or `parse_one` instead. + """ + # TODO enforce uniqueness + self.simple_selectors = tuple(simples) + + @classmethod + def parse_many(cls, selector): + selector = selector.strip() + ret = [] + + pending = dict( + simples=[], + combinator=' ', + tokens=[], + ) + + def promote_simple(): + if pending['tokens']: + pending['simples'].append( + SimpleSelector(pending['combinator'], pending['tokens'])) + pending['combinator'] = ' ' + pending['tokens'] = [] + + def promote_selector(): + promote_simple() + if pending['simples']: + ret.append(cls(pending['simples'])) + pending['simples'] = [] + + pos = 0 + while pos < len(selector): + # TODO i don't think this deals with " + " correctly. anywhere. + # TODO this used to turn "1.5%" into empty string; why does error + # not work? + m = SELECTOR_TOKENIZER.match(selector, pos) + if not m: + # TODO prettify me + raise SyntaxError("Couldn't parse selector: %r" % (selector,)) + + token = m.group(0) + pos += len(token) + + # Kill any extraneous space, BUT make sure not to turn a lone space + # into an empty string + token = token.strip() or ' ' + + if token == ',': + # End current selector + # TODO what about "+ ,"? what do i even do with that + promote_selector() + elif token in ' +>~': + # End current simple selector + promote_simple() + pending['combinator'] = token + else: + # Add to pending simple selector + pending['tokens'].append(token) + + # Deal with any remaining pending bits + promote_selector() + + return ret + + @classmethod + def parse_one(cls, selector_string): + selectors = cls.parse_many(selector_string) + if len(selectors) != 1: + # TODO better error + raise ValueError + + return selectors[0] + + def __repr__(self): + return "<%s: %r>" % (type(self).__name__, self.render()) + + def __hash__(self): + return hash(self.simple_selectors) + + def __eq__(self, other): + if not isinstance(other, Selector): + return NotImplemented + + return self.simple_selectors == other.simple_selectors + + @property + def has_parent_reference(self): + return any( + simple.has_parent_reference + for simple in self.simple_selectors) + + @property + def has_placeholder(self): + return any( + simple.has_placeholder + for simple in self.simple_selectors) + + def with_parent(self, parent): + saw_parent_ref = False + + new_simples = [] + for simple in self.simple_selectors: + if simple.has_parent_reference: + new_simples.extend(simple.replace_parent(parent.simple_selectors)) + saw_parent_ref = True + else: + new_simples.append(simple) + + if not saw_parent_ref: + new_simples = parent.simple_selectors + tuple(new_simples) + + return type(self)(new_simples) + + def lookup_key(self): + """Build a key from the "important" parts of a selector: elements, + classes, ids. + """ + parts = set() + for node in self.simple_selectors: + for token in node.tokens: + if token[0] not in ':[': + parts.add(token) + + if not parts: + # Should always have at least ONE key; selectors with no elements, + # no classes, and no ids can be indexed as None to avoid a scan of + # every selector in the entire document + parts.add(None) + + return frozenset(parts) + + def is_superset_of(self, other): + assert isinstance(other, Selector) + + idx = 0 + for other_node in other.simple_selectors: + if idx >= len(self.simple_selectors): + return False + + while idx < len(self.simple_selectors): + node = self.simple_selectors[idx] + idx += 1 + + if node.is_superset_of(other_node): + break + + return True + + def substitute(self, target, replacement): + """Return a list of selectors obtained by replacing the `target` + selector with `replacement`. + + Herein lie the guts of the Sass @extend directive. + + In general, for a selector ``a X b Y c``, a target ``X Y``, and a + replacement ``q Z``, return the selectors ``a q X b Z c`` and ``q a X b + Z c``. Note in particular that no more than two selectors will be + returned, and the permutation of ancestors will never insert new simple + selectors "inside" the target selector. + """ + + # Find the target in the parent selector, and split it into + # before/after + p_before, p_extras, p_after = self.break_around(target.simple_selectors) + + # The replacement has no hinge; it only has the most specific simple + # selector (which is the part that replaces "self" in the parent) and + # whatever preceding simple selectors there may be + r_trail = replacement.simple_selectors[:-1] + r_extras = replacement.simple_selectors[-1] + + # TODO what if the prefix doesn't match? who wins? should we even get + # this far? + focal_nodes = (p_extras.merge_with(r_extras),) + + befores = _merge_selectors(p_before, r_trail) + + cls = type(self) + return [ + cls(before + focal_nodes + p_after) + for before in befores] + + def break_around(self, hinge): + """Given a simple selector node contained within this one (a "hinge"), + break it in half and return a parent selector, extra specifiers for the + hinge, and a child selector. + + That is, given a hinge X, break the selector A + X.y B into A, + .y, + and B. + """ + hinge_start = hinge[0] + for i, node in enumerate(self.simple_selectors): + # In this particular case, a ' ' combinator actually means "no" (or + # any) combinator, so it should be ignored + if hinge_start.is_superset_of(node, soft_combinator=True): + start_idx = i + break + else: + raise ValueError( + "Couldn't find hinge %r in compound selector %r" % + (hinge_start, self.simple_selectors)) + + for i, hinge_node in enumerate(hinge): + if i == 0: + # We just did this + continue + + self_node = self.simple_selectors[start_idx + i] + if hinge_node.is_superset_of(self_node): + continue + + # TODO this isn't true; consider finding `a b` in `a c a b` + raise ValueError( + "Couldn't find hinge %r in compound selector %r" % + (hinge_node, self.simple_selectors)) + + end_idx = start_idx + len(hinge) - 1 + + focal_node = self.simple_selectors[end_idx] + extras = focal_node.difference(hinge[-1]) + + return ( + self.simple_selectors[:start_idx], + extras, + self.simple_selectors[end_idx + 1:]) + + def render(self): + return ' '.join(simple.render() for simple in self.simple_selectors) + + +def _merge_selectors(left, right): + """Given two selector chains (lists of simple selectors), return a list of + selector chains representing elements matched by both of them. + + This operation is not exact, and involves some degree of fudging -- the + wackier and more divergent the input, the more fudging. It's meant to be + what a human might expect rather than a precise covering of all possible + cases. Most notably, when the two input chains have absolutely nothing in + common, the output is merely ``left + right`` and ``right + left`` rather + than all possible interleavings. + """ + + if not left or not right: + # At least one is empty, so there are no conflicts; just return + # whichever isn't empty. Remember to return a LIST, though + return [left or right] + + lcs = longest_common_subsequence(left, right, _merge_simple_selectors) + + ret = [()] # start with a dummy empty chain or weaving won't work + + left_last = 0 + right_last = 0 + for left_next, right_next, merged in lcs: + ret = _weave_conflicting_selectors( + ret, + left[left_last:left_next], + right[right_last:right_next], + (merged,)) + + left_last = left_next + 1 + right_last = right_next + 1 + + ret = _weave_conflicting_selectors( + ret, + left[left_last:], + right[right_last:]) + + return ret + + +def _weave_conflicting_selectors(prefixes, a, b, suffix=()): + """Part of the selector merge algorithm above. Not useful on its own. Pay + no attention to the man behind the curtain. + """ + # OK, what this actually does: given a list of selector chains, two + # "conflicting" selector chains, and an optional suffix, return a new list + # of chains like this: + # prefix[0] + a + b + suffix, + # prefix[0] + b + a + suffix, + # prefix[1] + a + b + suffix, + # ... + # In other words, this just appends a new chain to each of a list of given + # chains, except that the new chain might be the superposition of two + # other incompatible chains. + both = a and b + for prefix in prefixes: + yield prefix + a + b + suffix + if both: + # Only use both orderings if there's an actual conflict! + yield prefix + b + a + suffix + + +def _merge_simple_selectors(a, b): + """Merge two simple selectors, for the purposes of the LCS algorithm below. + + In practice this returns the more specific selector if one is a subset of + the other, else it returns None. + """ + # TODO what about combinators + if a.is_superset_of(b): + return b + elif b.is_superset_of(a): + return a + else: + return None + + +def longest_common_subsequence(a, b, mergefunc=None): + """Find the longest common subsequence between two iterables. + + The longest common subsequence is the core of any diff algorithm: it's the + longest sequence of elements that appears in both parent sequences in the + same order, but NOT necessarily consecutively. + + Original algorithm borrowed from Wikipedia: + http://en.wikipedia.org/wiki/Longest_common_subsequence_problem#Code_for_the_dynamic_programming_solution + + This function is used only to implement @extend, largely because that's + what the Ruby implementation does. Thus it's been extended slightly from + the simple diff-friendly algorithm given above. + + What @extend wants to know is whether two simple selectors are compatible, + not just equal. To that end, you must pass in a "merge" function to + compare a pair of elements manually. It should return `None` if they are + incompatible, and a MERGED element if they are compatible -- in the case of + selectors, this is whichever one is more specific. + + Because of this fuzzier notion of equality, the return value is a list of + ``(a_index, b_index, value)`` tuples rather than items alone. + """ + if mergefunc is None: + # Stupid default, just in case + def mergefunc(a, b): + if a == b: + return a + return None + + # Precalculate equality, since it can be a tad expensive and every pair is + # compared at least once + eq = {} + for ai, aval in enumerate(a): + for bi, bval in enumerate(b): + eq[ai, bi] = mergefunc(aval, bval) + + # Build the "length" matrix, which provides the length of the LCS for + # arbitrary-length prefixes. -1 exists only to support the base case + prefix_lcs_length = {} + for ai in range(-1, len(a)): + for bi in range(-1, len(b)): + if ai == -1 or bi == -1: + l = 0 + elif eq[ai, bi]: + l = prefix_lcs_length[ai - 1, bi - 1] + 1 + else: + l = max( + prefix_lcs_length[ai, bi - 1], + prefix_lcs_length[ai - 1, bi]) + + prefix_lcs_length[ai, bi] = l + + # The interesting part. The key insight is that the bottom-right value in + # the length matrix must be the length of the LCS because of how the matrix + # is defined, so all that's left to do is backtrack from the ends of both + # sequences in whatever way keeps the LCS as long as possible, and keep + # track of the equal pairs of elements we see along the way. + # Wikipedia does this with recursion, but the algorithm is trivial to + # rewrite as a loop, as below. + ai = len(a) - 1 + bi = len(b) - 1 + + ret = [] + while ai >= 0 and bi >= 0: + merged = eq[ai, bi] + if merged is not None: + ret.append((ai, bi, merged)) + ai -= 1 + bi -= 1 + elif prefix_lcs_length[ai, bi - 1] > prefix_lcs_length[ai - 1, bi]: + bi -= 1 + else: + ai -= 1 + + # ret has the latest items first, which is backwards + ret.reverse() + return ret diff --git a/libs/scss/setup.py b/libs/scss/setup.py new file mode 100644 index 00000000..7112a187 --- /dev/null +++ b/libs/scss/setup.py @@ -0,0 +1,14 @@ +from distutils.core import setup, Extension + +setup(name='pyScss', + version='1.1.1', + description='pyScss', + ext_modules=[ + Extension( + '_scss', + sources=['src/_scss.c', 'src/block_locator.c', 'src/scanner.c'], + libraries=['pcre'], + optional=True + ) + ] +) diff --git a/libs/scss/src/_speedups.c b/libs/scss/src/_speedups.c new file mode 100644 index 00000000..b4b990cf --- /dev/null +++ b/libs/scss/src/_speedups.c @@ -0,0 +1,554 @@ +/* +* pyScss, a Scss compiler for Python +* SCSS blocks scanner. +* +* German M. Bravo (Kronuz) +* https://github.com/Kronuz/pyScss +* +* MIT license (http://www.opensource.org/licenses/mit-license.php) +* Copyright (c) 2011 German M. Bravo (Kronuz), All rights reserved. +*/ +#include +#include "block_locator.h" +#include "scanner.h" + +/* BlockLocator */ +staticforward PyTypeObject scss_BlockLocatorType; + +typedef struct { + PyObject_HEAD + BlockLocator *locator; +} scss_BlockLocator; + +static int +scss_BlockLocator_init(scss_BlockLocator *self, PyObject *args, PyObject *kwds) +{ + char *codestr; + int codestr_sz; + + self->locator = NULL; + + if (!PyArg_ParseTuple(args, "s#", &codestr, &codestr_sz)) { + return -1; + } + + self->locator = BlockLocator_new(codestr, codestr_sz); + + #ifdef DEBUG + PySys_WriteStderr("Scss BlockLocator object initialized! (%lu bytes)\n", sizeof(scss_BlockLocator)); + #endif + + return 0; +} + +static void +scss_BlockLocator_dealloc(scss_BlockLocator *self) +{ + if (self->locator != NULL) BlockLocator_del(self->locator); + + self->ob_type->tp_free((PyObject*)self); + + #ifdef DEBUG + PySys_WriteStderr("Scss BlockLocator object destroyed!\n"); + #endif +} + +scss_BlockLocator* +scss_BlockLocator_iter(scss_BlockLocator *self) +{ + Py_INCREF(self); + return self; +} + +PyObject* +scss_BlockLocator_iternext(scss_BlockLocator *self) +{ + Block *block; + + if (self->locator != NULL) { + block = BlockLocator_iternext(self->locator); + + if (block->error > 0) { + return Py_BuildValue( + "is#s#", + block->lineno, + block->selprop, + block->selprop_sz, + block->codestr, + block->codestr_sz + ); + } + + if (block->error > 0) { + PyErr_SetString(PyExc_Exception, self->locator->exc); + return NULL; + } + } + + /* Raising of standard StopIteration exception with empty value. */ + PyErr_SetNone(PyExc_StopIteration); + return NULL; +} + +/* Type definition */ + +static PyTypeObject scss_BlockLocatorType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "scss._BlockLocator", /* tp_name */ + sizeof(scss_BlockLocator), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)scss_BlockLocator_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_ITER, /* tp_flags */ + "Internal BlockLocator iterator object.", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + (getiterfunc)scss_BlockLocator_iter, /* tp_iter: __iter__() method */ + (iternextfunc)scss_BlockLocator_iternext, /* tp_iternext: next() method */ + 0, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)scss_BlockLocator_init, /* tp_init */ +}; + + +/* Scanner */ +static PyObject *PyExc_scss_NoMoreTokens; + +staticforward PyTypeObject scss_ScannerType; + +typedef struct { + PyObject_HEAD + Scanner *scanner; +} scss_Scanner; + + +static PyObject * +scss_Scanner_rewind(scss_Scanner *self, PyObject *args) +{ + int token_num; + if (self->scanner != NULL) { + if (PyArg_ParseTuple(args, "i", &token_num)) { + Scanner_rewind(self->scanner, token_num); + } + } + Py_INCREF(Py_None); + return (PyObject *)Py_None; +} + + +static PyObject * +scss_Scanner_token(scss_Scanner *self, PyObject *args) +{ + PyObject *iter; + PyObject *item; + long size; + + Token *p_token; + + int token_num; + PyObject *restrictions = NULL; + Pattern *_restrictions = NULL; + int restrictions_sz = 0; + if (self->scanner != NULL) { + if (PyArg_ParseTuple(args, "i|O", &token_num, &restrictions)) { + if (restrictions != NULL) { + size = PySequence_Size(restrictions); + if (size != -1) { + _restrictions = PyMem_New(Pattern, size); + iter = PyObject_GetIter(restrictions); + while ((item = PyIter_Next(iter))) { + if (PyString_Check(item)) { + _restrictions[restrictions_sz].tok = PyString_AsString(item); + _restrictions[restrictions_sz].expr = NULL; + restrictions_sz++; + } + Py_DECREF(item); + } + Py_DECREF(iter); + } + } + p_token = Scanner_token(self->scanner, token_num, _restrictions, restrictions_sz); + + if (_restrictions != NULL) PyMem_Del(_restrictions); + + if (p_token == (Token *)SCANNER_EXC_BAD_TOKEN) { + PyErr_SetString(PyExc_SyntaxError, self->scanner->exc); + return NULL; + } + if (p_token == (Token *)SCANNER_EXC_RESTRICTED) { + PyErr_SetString(PyExc_SyntaxError, self->scanner->exc); + return NULL; + } + if (p_token == (Token *)SCANNER_EXC_UNIMPLEMENTED) { + PyErr_SetString(PyExc_NotImplementedError, self->scanner->exc); + return NULL; + } + if (p_token == (Token *)SCANNER_EXC_NO_MORE_TOKENS) { + PyErr_SetNone(PyExc_scss_NoMoreTokens); + return NULL; + } + if (p_token < 0) { + PyErr_SetNone(PyExc_Exception); + return NULL; + } + return Py_BuildValue( + "iiss#", + p_token->string - self->scanner->input, + p_token->string - self->scanner->input + p_token->string_sz, + p_token->regex->tok, + p_token->string, + p_token->string_sz + ); + } + } + Py_INCREF(Py_None); + return (PyObject *)Py_None; +} + +static PyObject * +scss_Scanner_reset(scss_Scanner *self, PyObject *args, PyObject *kwds) +{ + char *input = NULL; + int input_sz = 0; + + if (self->scanner != NULL) { + if (PyArg_ParseTuple(args, "|z#", &input, &input_sz)) { + Scanner_reset(self->scanner, input, input_sz); + } + } + + Py_INCREF(Py_None); + return (PyObject *)Py_None; +} + +static PyObject * +scss_Scanner_setup_patterns(PyObject *self, PyObject *patterns) +{ + PyObject *item, *item0, *item1; + int i, is_tuple, _is_tuple; + long size; + + Pattern *_patterns = NULL; + int patterns_sz = 0; + if (!Scanner_initialized()) { + is_tuple = PyTuple_Check(patterns); + if (is_tuple || PyList_Check(patterns)) { + size = is_tuple ? PyTuple_Size(patterns) : PyList_Size(patterns); + _patterns = PyMem_New(Pattern, size); + for (i = 0; i < size; ++i) { + item = is_tuple ? PyTuple_GetItem(patterns, i) : PyList_GetItem(patterns, i); + _is_tuple = PyTuple_Check(item); + if (_is_tuple || PyList_Check(item)) { + item0 = _is_tuple ? PyTuple_GetItem(item, 0) : PyList_GetItem(item, 0); + item1 = _is_tuple ? PyTuple_GetItem(item, 1) : PyList_GetItem(item, 1); + if (PyString_Check(item0) && PyString_Check(item1)) { + _patterns[patterns_sz].tok = PyString_AsString(item0); + _patterns[patterns_sz].expr = PyString_AsString(item1); + patterns_sz++; + } + } + } + } + Scanner_initialize(_patterns, patterns_sz); + if (_patterns != NULL) PyMem_Del(_patterns); + } + Py_INCREF(Py_None); + return (PyObject *)Py_None; +} + +static int +scss_Scanner_init(scss_Scanner *self, PyObject *args, PyObject *kwds) +{ + PyObject *item, *item0, *item1; + int i, is_tuple, _is_tuple; + long size; + + PyObject *patterns, *ignore; + Pattern *_patterns = NULL; + int patterns_sz = 0; + Pattern *_ignore = NULL; + int ignore_sz = 0; + char *input = NULL; + int input_sz = 0; + + self->scanner = NULL; + + if (!PyArg_ParseTuple(args, "OO|z#", &patterns, &ignore, &input, &input_sz)) { + return -1; + } + + if (!Scanner_initialized()) { + is_tuple = PyTuple_Check(patterns); + if (is_tuple || PyList_Check(patterns)) { + size = is_tuple ? PyTuple_Size(patterns) : PyList_Size(patterns); + _patterns = PyMem_New(Pattern, size); + for (i = 0; i < size; ++i) { + item = is_tuple ? PyTuple_GetItem(patterns, i) : PyList_GetItem(patterns, i); + _is_tuple = PyTuple_Check(item); + if (_is_tuple || PyList_Check(item)) { + item0 = _is_tuple ? PyTuple_GetItem(item, 0) : PyList_GetItem(item, 0); + item1 = _is_tuple ? PyTuple_GetItem(item, 1) : PyList_GetItem(item, 1); + if (PyString_Check(item0) && PyString_Check(item1)) { + _patterns[patterns_sz].tok = PyString_AsString(item0); + _patterns[patterns_sz].expr = PyString_AsString(item1); + patterns_sz++; + } + } + } + } + Scanner_initialize(_patterns, patterns_sz); + } + + is_tuple = PyTuple_Check(ignore); + if (is_tuple || PyList_Check(ignore)) { + size = is_tuple ? PyTuple_Size(ignore) : PyList_Size(ignore); + _ignore = PyMem_New(Pattern, size); + for (i = 0; i < size; ++i) { + item = is_tuple ? PyTuple_GetItem(ignore, i) : PyList_GetItem(ignore, i); + if (PyString_Check(item)) { + _ignore[ignore_sz].tok = PyString_AsString(item); + _ignore[ignore_sz].expr = NULL; + ignore_sz++; + } + } + } + + self->scanner = Scanner_new(_patterns, patterns_sz, _ignore, ignore_sz, input, input_sz); + + if (_patterns != NULL) PyMem_Del(_patterns); + if (_ignore != NULL) PyMem_Del(_ignore); + + #ifdef DEBUG + PySys_WriteStderr("Scss Scanner object initialized! (%lu bytes)\n", sizeof(scss_Scanner)); + #endif + + return 0; +} + +static PyObject * +scss_Scanner_repr(scss_Scanner *self) +{ + /* Print the last 10 tokens that have been scanned in */ + PyObject *repr, *tmp; + Token *p_token; + int i, start, pos; + + if (self->scanner != NULL && self->scanner->tokens_sz) { + start = self->scanner->tokens_sz - 10; + repr = PyString_FromString(""); + for (i = (start < 0) ? 0 : start; i < self->scanner->tokens_sz; i++) { + p_token = &self->scanner->tokens[i]; + PyString_ConcatAndDel(&repr, PyString_FromString("\n")); + pos = (int)(p_token->string - self->scanner->input); + PyString_ConcatAndDel(&repr, PyString_FromFormat(" (@%d) %s = ", + pos, p_token->regex->tok)); + tmp = PyString_FromStringAndSize(p_token->string, p_token->string_sz); + PyString_ConcatAndDel(&repr, PyObject_Repr(tmp)); + Py_XDECREF(tmp); + } + } else { + repr = PyString_FromString("None"); + } + + return (PyObject *)repr; + +/* + PyObject *repr, *tmp, *tmp2; + Token *p_token; + char *tok; + int i, start, first = 1, cur, max=0, pos; + + if (self->scanner != NULL && self->scanner->tokens_sz) { + start = self->scanner->tokens_sz - 10; + repr = PyString_FromString(""); + for (i = (start < 0) ? 0 : start; i < self->scanner->tokens_sz; i++) { + p_token = self->scanner->tokens[i]; + PyString_ConcatAndDel(&repr, PyString_FromString("\n")); + pos = (int)(p_token->string - self->scanner->input); + PyString_ConcatAndDel(&repr, PyString_FromFormat(" (@%d) %s = ", + pos, p_token->regex->tok)); + tmp = PyString_FromString(p_token->string); + PyString_ConcatAndDel(&repr, PyObject_Repr(tmp)); + Py_XDECREF(tmp); + } + + start = self->scanner->tokens_sz - 10; + for (i = (start < 0) ? 0 : start; i < self->scanner->tokens_sz; i++) { + p_token = self->scanner->tokens[i]; + cur = strlen(p_token->regex->tok) * 2; + if (cur > max) max = cur; + } + tok = PyMem_New(char, max + 4); + repr = PyString_FromString(""); + for (i = (start < 0) ? 0 : start; i < self->scanner->tokens_sz; i++) { + p_token = self->scanner->tokens[i]; + if (!first) PyString_ConcatAndDel(&repr, PyString_FromString("\n")); + + pos = (int)(p_token->string - self->scanner->input); + if (pos < 10) PyString_ConcatAndDel(&repr, PyString_FromString(" ")); + if (pos < 100) PyString_ConcatAndDel(&repr, PyString_FromString(" ")); + if (pos < 1000) PyString_ConcatAndDel(&repr, PyString_FromString(" ")); + PyString_ConcatAndDel(&repr, PyString_FromFormat("(@%d) ", + pos)); + + tmp = PyString_FromString(p_token->regex->tok); + tmp2 = PyObject_Repr(tmp); + memset(tok, ' ', max + 4); + tok[max + 3 - PyString_Size(tmp2)] = '\0'; + PyString_ConcatAndDel(&repr, PyString_FromString(tok)); + PyString_ConcatAndDel(&repr, tmp2); + Py_XDECREF(tmp); + + PyString_ConcatAndDel(&repr, PyString_FromString(" = ")); + tmp = PyString_FromString(p_token->string); + PyString_ConcatAndDel(&repr, PyObject_Repr(tmp)); + Py_XDECREF(tmp); + + first = 0; + } + PyMem_Del(tok); + } else { + repr = PyString_FromString("None"); + } + + return (PyObject *)repr; +*/ +} + +static void +scss_Scanner_dealloc(scss_Scanner *self) +{ + if (self->scanner != NULL) Scanner_del(self->scanner); + + self->ob_type->tp_free((PyObject*)self); + + #ifdef DEBUG + PySys_WriteStderr("Scss Scanner object destroyed!\n"); + #endif +} + +static PyMethodDef scss_Scanner_methods[] = { + {"reset", (PyCFunction)scss_Scanner_reset, METH_VARARGS, "Scan the next token"}, + {"token", (PyCFunction)scss_Scanner_token, METH_VARARGS, "Get the nth token"}, + {"rewind", (PyCFunction)scss_Scanner_rewind, METH_VARARGS, "Rewind scanner"}, + {"setup_patterns", (PyCFunction)scss_Scanner_setup_patterns, METH_O | METH_STATIC, "Initialize patterns."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +static PyTypeObject scss_ScannerType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "scss.Scanner", /* tp_name */ + sizeof(scss_Scanner), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)scss_Scanner_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + (reprfunc)scss_Scanner_repr, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + "Scanner object.", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter: __iter__() method */ + 0, /* tp_iternext: next() method */ + scss_Scanner_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)scss_Scanner_init, /* tp_init */ +}; + + +/* Python constructor */ + +static PyObject * +scss_locate_blocks(PyObject *self, PyObject *args) +{ + scss_BlockLocator *result = NULL; + + result = PyObject_New(scss_BlockLocator, &scss_BlockLocatorType); + if (result) { + scss_BlockLocator_init(result, args, NULL); + } + + return (PyObject *)result; +} + + +/* Module functions */ + +static PyMethodDef scss_methods[] = { + {"locate_blocks", (PyCFunction)scss_locate_blocks, METH_VARARGS, "Locate Scss blocks."}, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + + +/* Module init function */ + +PyMODINIT_FUNC +init_speedups(void) +{ + PyObject* m; + + scss_BlockLocatorType.tp_new = PyType_GenericNew; + if (PyType_Ready(&scss_BlockLocatorType) < 0) + return; + + scss_ScannerType.tp_new = PyType_GenericNew; + if (PyType_Ready(&scss_ScannerType) < 0) + return; + + BlockLocator_initialize(); + Scanner_initialize(NULL, 0); + + m = Py_InitModule("_speedups", scss_methods); + + Py_INCREF(&scss_BlockLocatorType); + PyModule_AddObject(m, "_BlockLocator", (PyObject *)&scss_BlockLocatorType); + + Py_INCREF(&scss_ScannerType); + PyModule_AddObject(m, "Scanner", (PyObject *)&scss_ScannerType); + + PyExc_scss_NoMoreTokens = PyErr_NewException("_speedups.NoMoreTokens", NULL, NULL); + Py_INCREF(PyExc_scss_NoMoreTokens); + PyModule_AddObject(m, "NoMoreTokens", (PyObject *)PyExc_scss_NoMoreTokens); +} diff --git a/libs/scss/src/block_locator.c b/libs/scss/src/block_locator.c new file mode 100644 index 00000000..f35d6d33 --- /dev/null +++ b/libs/scss/src/block_locator.c @@ -0,0 +1,547 @@ +/* +* pyScss, a Scss compiler for Python +* SCSS blocks scanner. +* +* German M. Bravo (Kronuz) +* https://github.com/Kronuz/pyScss +* +* MIT license (http://www.opensource.org/licenses/mit-license.php) +* Copyright (c) 2011 German M. Bravo (Kronuz), All rights reserved. +*/ +#include + +#include +#include +#include +#include "block_locator.h" + +int _strip(char *begin, char *end, int *lineno) { + // " 1\0 some, \n 2\0 aca " + int _cnt, + cnt = 0, + pass = 1, + addnl = 0; + char c, + *line = NULL, + *first = begin, + *last = begin, + *write = lineno ? begin : NULL; + while (begin < end) { + c = *begin; + if (c == '\0') { + if (lineno && line == NULL) { + line = first - 1; + do { + c = *++line; + } while (c == ' ' || c == '\t' || c == '\n' || c == ';'); + if (c != '\0') { + sscanf(line, "%d", lineno); + } + } + first = last = begin + 1; + pass = 1; + } else if (c == '\n') { + _cnt = (int)(last - first); + if (_cnt > 0) { + cnt += _cnt + addnl; + if (write != NULL) { + if (addnl) { + *write++ = '\n'; + } + while (first < last) { + *write++ = *first++; + } + addnl = 1; + } + } + first = last = begin + 1; + pass = 1; + } else if (c == ' ' || c == '\t') { + if (pass) { + first = last = begin + 1; + } + } else { + last = begin + 1; + pass = 0; + } + begin++; + } + _cnt = (int)(last - first); + if (_cnt > 0) { + cnt += _cnt + addnl; + if (write != NULL) { + if (addnl) { + *write++ = '\n'; + } + while (first < last) { + *write++ = *first++; + } + } + } + return cnt; +} + + +/* BlockLocator */ + +typedef void _BlockLocator_Callback(BlockLocator*); + +static void +_BlockLocator_start_string(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // A string starts + self->instr = *(self->codestr_ptr); +} + +static void +_BlockLocator_end_string(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // A string ends (FIXME: needs to accept escaped characters) + self->instr = 0; +} + +static void +_BlockLocator_start_parenthesis(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // parenthesis begins: + self->par++; + self->thin = NULL; + self->safe = self->codestr_ptr + 1; +} + +static void +_BlockLocator_end_parenthesis(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + self->par--; +} + +static void +_BlockLocator_flush_properties(BlockLocator *self) { + int len, lineno = -1; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Flush properties + if (self->lose <= self->init) { + len = _strip(self->lose, self->init, &lineno); + if (len) { + if (lineno != -1) { + self->lineno = lineno; + } + + self->block.selprop = self->lose; + self->block.selprop_sz = len; + self->block.codestr = NULL; + self->block.codestr_sz = 0; + self->block.lineno = self->lineno; + self->block.error = 1; + } + self->lose = self->init; + } +} + +static void +_BlockLocator_start_block1(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Start block: + if (self->codestr_ptr > self->codestr && *(self->codestr_ptr - 1) == '#') { + self->skip = 1; + } else { + self->start = self->codestr_ptr; + if (self->thin != NULL && _strip(self->thin, self->codestr_ptr, NULL)) { + self->init = self->thin; + } + _BlockLocator_flush_properties(self); + self->thin = NULL; + } + self->depth++; +} + +static void +_BlockLocator_start_block(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Start block: + self->depth++; +} + +static void +_BlockLocator_end_block1(BlockLocator *self) { + int len, lineno = -1; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Block ends: + self->depth--; + if (!self->skip) { + self->end = self->codestr_ptr; + len = _strip(self->init, self->start, &lineno); + if (lineno != -1) { + self->lineno = lineno; + } + + self->block.selprop = self->init; + self->block.selprop_sz = len; + self->block.codestr = (self->start + 1); + self->block.codestr_sz = (int)(self->end - (self->start + 1)); + self->block.lineno = self->lineno; + self->block.error = 1; + + self->init = self->safe = self->lose = self->end + 1; + self->thin = NULL; + } + self->skip = 0; +} + +static void +_BlockLocator_end_block(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Block ends: + self->depth--; +} + +static void +_BlockLocator_end_property(BlockLocator *self) { + int len, lineno = -1; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // End of property (or block): + self->init = self->codestr_ptr; + if (self->lose <= self->init) { + len = _strip(self->lose, self->init, &lineno); + if (len) { + if (lineno != -1) { + self->lineno = lineno; + } + + self->block.selprop = self->lose; + self->block.selprop_sz = len; + self->block.codestr = NULL; + self->block.codestr_sz = 0; + self->block.lineno = self->lineno; + self->block.error = 1; + } + self->init = self->safe = self->lose = self->codestr_ptr + 1; + } + self->thin = NULL; +} + +static void +_BlockLocator_mark_safe(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // We are on a safe zone + if (self->thin != NULL && _strip(self->thin, self->codestr_ptr, NULL)) { + self->init = self->thin; + } + self->thin = NULL; + self->safe = self->codestr_ptr + 1; +} + +static void +_BlockLocator_mark_thin(BlockLocator *self) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + // Step on thin ice, if it breaks, it breaks here + if (self->thin != NULL && _strip(self->thin, self->codestr_ptr, NULL)) { + self->init = self->thin; + self->thin = self->codestr_ptr + 1; + } else if (self->thin == NULL && _strip(self->safe, self->codestr_ptr, NULL)) { + self->thin = self->codestr_ptr + 1; + } +} + +int function_map_initialized = 0; +_BlockLocator_Callback* scss_function_map[256 * 256 * 2 * 3]; // (c, instr, par, depth) + +static void +init_function_map(void) { + int i; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (function_map_initialized) { + return; + } + function_map_initialized = 1; + + for (i = 0; i < 256 * 256 * 2 * 3; i++) { + scss_function_map[i] = NULL; + } + scss_function_map[(int)'\"' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_start_string; + scss_function_map[(int)'\"' + 256*0 + 256*256*1 + 256*256*2*0] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*1 + 256*256*2*0] = _BlockLocator_start_string; + scss_function_map[(int)'\"' + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_start_string; + scss_function_map[(int)'\"' + 256*0 + 256*256*1 + 256*256*2*1] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*1 + 256*256*2*1] = _BlockLocator_start_string; + scss_function_map[(int)'\"' + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_start_string; + scss_function_map[(int)'\"' + 256*0 + 256*256*1 + 256*256*2*2] = _BlockLocator_start_string; + scss_function_map[(int)'\'' + 256*0 + 256*256*1 + 256*256*2*2] = _BlockLocator_start_string; + + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*0 + 256*256*2*0] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*0 + 256*256*2*0] = _BlockLocator_end_string; + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*1 + 256*256*2*0] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*1 + 256*256*2*0] = _BlockLocator_end_string; + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*0 + 256*256*2*1] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*0 + 256*256*2*1] = _BlockLocator_end_string; + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*1 + 256*256*2*1] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*1 + 256*256*2*1] = _BlockLocator_end_string; + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*0 + 256*256*2*2] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*0 + 256*256*2*2] = _BlockLocator_end_string; + scss_function_map[(int)'\"' + 256*(int)'\"' + 256*256*1 + 256*256*2*2] = _BlockLocator_end_string; + scss_function_map[(int)'\'' + 256*(int)'\'' + 256*256*1 + 256*256*2*2] = _BlockLocator_end_string; + + scss_function_map[(int)'(' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_start_parenthesis; + scss_function_map[(int)'(' + 256*0 + 256*256*1 + 256*256*2*0] = _BlockLocator_start_parenthesis; + scss_function_map[(int)'(' + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_start_parenthesis; + scss_function_map[(int)'(' + 256*0 + 256*256*1 + 256*256*2*1] = _BlockLocator_start_parenthesis; + scss_function_map[(int)'(' + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_start_parenthesis; + scss_function_map[(int)'(' + 256*0 + 256*256*1 + 256*256*2*2] = _BlockLocator_start_parenthesis; + + scss_function_map[(int)')' + 256*0 + 256*256*1 + 256*256*2*0] = _BlockLocator_end_parenthesis; + scss_function_map[(int)')' + 256*0 + 256*256*1 + 256*256*2*1] = _BlockLocator_end_parenthesis; + scss_function_map[(int)')' + 256*0 + 256*256*1 + 256*256*2*2] = _BlockLocator_end_parenthesis; + + scss_function_map[(int)'{' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_start_block1; + scss_function_map[(int)'{' + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_start_block; + scss_function_map[(int)'{' + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_start_block; + + scss_function_map[(int)'}' + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_end_block1; + scss_function_map[(int)'}' + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_end_block; + + scss_function_map[(int)';' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_end_property; + + scss_function_map[(int)',' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_mark_safe; + + scss_function_map[(int)'\n' + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_mark_thin; + + scss_function_map[0 + 256*0 + 256*256*0 + 256*256*2*0] = _BlockLocator_flush_properties; + scss_function_map[0 + 256*0 + 256*256*0 + 256*256*2*1] = _BlockLocator_flush_properties; + scss_function_map[0 + 256*0 + 256*256*0 + 256*256*2*2] = _BlockLocator_flush_properties; + + #ifdef DEBUG + fprintf(stderr, "\tScss function maps initialized!\n"); + #endif +} + + +/* BlockLocator public interface */ + +void +BlockLocator_initialize(void) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + init_function_map(); +} + +void +BlockLocator_finalize(void) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif +} + +BlockLocator * +BlockLocator_new(char *codestr, int codestr_sz) +{ + BlockLocator *self; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + self = PyMem_New(BlockLocator, 1); + if (self) { + memset(self, 0, sizeof(BlockLocator)); + self->_codestr = PyMem_New(char, codestr_sz); + memcpy(self->_codestr, codestr, codestr_sz); + self->codestr_sz = codestr_sz; + self->codestr = PyMem_New(char, self->codestr_sz); + memcpy(self->codestr, self->_codestr, self->codestr_sz); + self->codestr_ptr = self->codestr; + self->lineno = 0; + self->par = 0; + self->instr = 0; + self->depth = 0; + self->skip = 0; + self->thin = self->codestr; + self->init = self->codestr; + self->safe = self->codestr; + self->lose = self->codestr; + self->start = NULL; + self->end = NULL; + #ifdef DEBUG + fprintf(stderr, "\tScss BlockLocator object created (%d bytes)!\n", codestr_sz); + #endif + } + return self; +} + +void +BlockLocator_del(BlockLocator *self) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + free(self->codestr); + free(self->_codestr); + free(self); +} + +void +BlockLocator_rewind(BlockLocator *self) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + free(self->codestr); + self->codestr = PyMem_New(char, self->codestr_sz); + memcpy(self->codestr, self->_codestr, self->codestr_sz); + self->codestr_ptr = self->codestr; + self->lineno = 0; + self->par = 0; + self->instr = 0; + self->depth = 0; + self->skip = 0; + self->thin = self->codestr; + self->init = self->codestr; + self->safe = self->codestr; + self->lose = self->codestr; + self->start = NULL; + self->end = NULL; + + #ifdef DEBUG + fprintf(stderr, "\tScss BlockLocator object rewound!\n"); + #endif +} + +Block* +BlockLocator_iternext(BlockLocator *self) +{ + _BlockLocator_Callback *fn; + unsigned char c = 0; + char *codestr_end = self->codestr + self->codestr_sz; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + memset(&self->block, 0, sizeof(Block)); + + while (self->codestr_ptr < codestr_end) { + c = *(self->codestr_ptr); + if (!c) { + self->codestr_ptr++; + continue; + } + + repeat: + + fn = scss_function_map[ + (int)c + + 256 * self->instr + + 256 * 256 * (int)(self->par != 0) + + 256 * 256 * 2 * (int)(self->depth > 1 ? 2 : self->depth) + ]; + + if (fn != NULL) { + fn(self); + } + + self->codestr_ptr++; + if (self->codestr_ptr > codestr_end) { + self->codestr_ptr = codestr_end; + } + + if (self->block.error) { + #ifdef DEBUG + if (self->block.error > 0) { + fprintf(stderr, "\tBlock found!\n"); + } else { + fprintf(stderr, "\tException!\n"); + } + #endif + return &self->block; + } + } + if (self->par > 0) { + if (self->block.error >= 0) { + self->block.error = -1; + sprintf(self->exc, "Missing closing parenthesis somewhere in block"); + #ifdef DEBUG + fprintf(stderr, "\t%s\n", self->exc); + #endif + } + } else if (self->instr != 0) { + if (self->block.error >= 0) { + self->block.error = -2; + sprintf(self->exc, "Missing closing string somewhere in block"); + #ifdef DEBUG + fprintf(stderr, "\t%s\n", self->exc); + #endif + } + } else if (self->depth > 0) { + if (self->block.error >= 0) { + self->block.error = -3; + sprintf(self->exc, "Missing closing string somewhere in block"); + #ifdef DEBUG + fprintf(stderr, "\t%s\n", self->exc); + #endif + } + if (self->init < codestr_end) { + c = '}'; + goto repeat; + } + } + if (self->init < codestr_end) { + self->init = codestr_end; + c = 0; + goto repeat; + } + + BlockLocator_rewind(self); + + return &self->block; +} diff --git a/libs/scss/src/block_locator.h b/libs/scss/src/block_locator.h new file mode 100644 index 00000000..c59afbc6 --- /dev/null +++ b/libs/scss/src/block_locator.h @@ -0,0 +1,52 @@ +/* +* pyScss, a Scss compiler for Python +* SCSS blocks scanner. +* +* German M. Bravo (Kronuz) +* https://github.com/Kronuz/pyScss +* +* MIT license (http://www.opensource.org/licenses/mit-license.php) +* Copyright (c) 2011 German M. Bravo (Kronuz), All rights reserved. +*/ +#ifndef BLOCK_LOCATOR_H +#define BLOCK_LOCATOR_H + +#define MAX_EXC_STRING 4096 + +typedef struct { + int error; + int lineno; + char *selprop; + int selprop_sz; + char *codestr; + int codestr_sz; +} Block; + +typedef struct { + char exc[MAX_EXC_STRING]; + char *_codestr; + char *codestr; + char *codestr_ptr; + int codestr_sz; + int lineno; + int par; + char instr; + int depth; + int skip; + char *thin; + char *init; + char *safe; + char *lose; + char *start; + char *end; + Block block; +} BlockLocator; + +void BlockLocator_initialize(void); +void BlockLocator_finalize(void); + +Block* BlockLocator_iternext(BlockLocator *self); +BlockLocator *BlockLocator_new(char *codestr, int codestr_sz); +void BlockLocator_del(BlockLocator *self); + +#endif diff --git a/libs/scss/src/scanner.c b/libs/scss/src/scanner.c new file mode 100644 index 00000000..b8fedeff --- /dev/null +++ b/libs/scss/src/scanner.c @@ -0,0 +1,463 @@ +/* +* pyScss, a Scss compiler for Python +* SCSS blocks scanner. +* +* German M. Bravo (Kronuz) +* https://github.com/Kronuz/pyScss +* +* MIT license (http://www.opensource.org/licenses/mit-license.php) +* Copyright (c) 2011 German M. Bravo (Kronuz), All rights reserved. +*/ +#include + +#include +#include +#include "scanner.h" + +#include "utils.h" + +int Pattern_patterns_sz = 0; +int Pattern_patterns_bsz = 0; +Pattern *Pattern_patterns = NULL; +int Pattern_patterns_initialized = 0; + +Pattern* +Pattern_regex(char *tok, char *expr) { + int j; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + for (j = 0; j < Pattern_patterns_sz; j++) { + if (strcmp(Pattern_patterns[j].tok, tok) == 0) { + return &Pattern_patterns[j]; + } + } + if (expr) { + if (j >= Pattern_patterns_bsz) { + /* Needs to expand block */ + Pattern_patterns_bsz = Pattern_patterns_bsz + BLOCK_SIZE_PATTERNS; + PyMem_Resize(Pattern_patterns, Pattern, Pattern_patterns_bsz); + } + Pattern_patterns[j].tok = PyMem_Strdup(tok); + Pattern_patterns[j].expr = PyMem_Strdup(expr); + Pattern_patterns[j].pattern = NULL; + Pattern_patterns_sz = j + 1; + return &Pattern_patterns[j]; + } + return NULL; +} + +static int +Pattern_match(Pattern *regex, char *string, int string_sz, int start_at, Token *p_token) { + int options = PCRE_ANCHORED; + const char *errptr; + int ret, erroffset, ovector[3]; + pcre *p_pattern = regex->pattern; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (p_pattern == NULL) { + #ifdef DEBUG + fprintf(stderr, "\tpcre_compile %s\n", repr(regex->expr)); + #endif + p_pattern = regex->pattern = pcre_compile(regex->expr, options, &errptr, &erroffset, NULL); + } + ret = pcre_exec( + p_pattern, + NULL, /* no extra data */ + string, + string_sz, + start_at, + PCRE_ANCHORED, /* default options */ + ovector, /* output vector for substring information */ + 3 /* number of elements in the output vector */ + ); + if (ret >= 0) { + if (p_token) { + p_token->regex = regex; + p_token->string = string + ovector[0]; + p_token->string_sz = ovector[1] - ovector[0]; + } + return 1; + } + return 0; +} + +static void Pattern_initialize(Pattern *, int); +static void Pattern_setup(Pattern *, int); +static void Pattern_finalize(void); + + +static void +Pattern_initialize(Pattern *patterns, int patterns_sz) { + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (!Pattern_patterns_initialized) { + if (patterns_sz) { + Pattern_patterns_initialized = 1; + Pattern_setup(patterns, patterns_sz); + } + } +} + +static void +Pattern_setup(Pattern *patterns, int patterns_sz) { + int i; + Pattern *regex; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (!Pattern_patterns_initialized) { + Pattern_initialize(patterns, patterns_sz); + } else { + for (i = 0; i < patterns_sz; i++) { + regex = Pattern_regex(patterns[i].tok, patterns[i].expr); + #ifdef DEBUG + if (regex) { + fprintf(stderr, "\tAdded regex pattern %s: %s\n", repr(regex->tok), repr(regex->expr)); + } + #endif + } + } +} + +static void +Pattern_finalize(void) { + int j; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (Pattern_patterns_initialized) { + for (j = 0; j < Pattern_patterns_sz; j++) { + PyMem_Del(Pattern_patterns[j].tok); + PyMem_Del(Pattern_patterns[j].expr); + if (Pattern_patterns[j].pattern != NULL) { + pcre_free(Pattern_patterns[j].pattern); + } + } + PyMem_Del(Pattern_patterns); + Pattern_patterns = NULL; + Pattern_patterns_sz = 0; + Pattern_patterns_bsz = 0; + Pattern_patterns_initialized = 0; + } +} + +/* Scanner */ + + +static long +_Scanner_scan(Scanner *self, Pattern *restrictions, int restrictions_sz) +{ + Token best_token, *p_token; + Restriction *p_restriction; + Pattern *regex; + int j, k, max, skip; + size_t len; + char *aux; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + while (1) { + regex = NULL; + best_token.regex = NULL; + /* Search the patterns for a match, with earlier + tokens in the list having preference */ + for (j = 0; j < Pattern_patterns_sz; j++) { + regex = &Pattern_patterns[j]; + #ifdef DEBUG + fprintf(stderr, "\tTrying %s: %s at pos %d -> %s\n", repr(regex->tok), repr(regex->expr), self->pos, repr(self->input)); + #endif + /* First check to see if we're restricting to this token */ + skip = restrictions_sz; + if (skip) { + max = (restrictions_sz > self->ignore_sz) ? restrictions_sz : self->ignore_sz; + for (k = 0; k < max; k++) { + if (k < restrictions_sz && strcmp(regex->tok, restrictions[k].tok) == 0) { + skip = 0; + break; + } + if (k < self->ignore_sz && regex == self->ignore[k]) { + skip = 0; + break; + } + } + if (skip) { + continue; + #ifdef DEBUG + fprintf(stderr, "\tSkipping %s!\n", repr(regex->tok)); + #endif + } + } + if (Pattern_match( + regex, + self->input, + self->input_sz, + self->pos, + &best_token + )) { + #ifdef DEBUG + fprintf(stderr, "Match OK! %s: %s at pos %d\n", repr(regex->tok), repr(regex->expr), self->pos); + #endif + break; + } + } + /* If we didn't find anything, raise an error */ + if (best_token.regex == NULL) { + if (restrictions_sz) { + sprintf(self->exc, "SyntaxError[@ char %d: Bad token found while trying to find one of the restricted tokens: ", self->pos); + aux = self->exc + strlen(self->exc); + for (k=0; k self->exc + sizeof(self->exc) - 10) { + sprintf(aux, (k > 0) ? ", ..." : "..."); + break; + } + sprintf(aux, (k > 0) ? ", %s" : "%s", repr(restrictions[k].tok)); + aux += len + 2; + } + sprintf(aux, "]"); + return SCANNER_EXC_RESTRICTED; + } + sprintf(self->exc, "SyntaxError[@ char %d: Bad token found]", self->pos); + return SCANNER_EXC_BAD_TOKEN; + } + /* If we found something that isn't to be ignored, return it */ + skip = 0; + for (k = 0; k < self->ignore_sz; k++) { + if (best_token.regex == self->ignore[k]) { + /* This token should be ignored... */ + self->pos += best_token.string_sz; + skip = 1; + break; + } + } + if (!skip) { + break; + } + } + if (best_token.regex) { + self->pos = (int)(best_token.string - self->input + best_token.string_sz); + /* Only add this token if it's not in the list (to prevent looping) */ + p_token = &self->tokens[self->tokens_sz - 1]; + if (self->tokens_sz == 0 || + p_token->regex != best_token.regex || + p_token->string != best_token.string || + p_token->string_sz != best_token.string_sz + ) { + if (self->tokens_sz >= self->tokens_bsz) { + /* Needs to expand block */ + self->tokens_bsz = self->tokens_bsz + BLOCK_SIZE_PATTERNS; + PyMem_Resize(self->tokens, Token, self->tokens_bsz); + PyMem_Resize(self->restrictions, Restriction, self->tokens_bsz); + } + memcpy(&self->tokens[self->tokens_sz], &best_token, sizeof(Token)); + p_restriction = &self->restrictions[self->tokens_sz]; + if (restrictions_sz) { + p_restriction->patterns = PyMem_New(Pattern *, restrictions_sz); + p_restriction->patterns_sz = 0; + for (j = 0; j < restrictions_sz; j++) { + regex = Pattern_regex(restrictions[j].tok, restrictions[j].expr); + if (regex) { + p_restriction->patterns[p_restriction->patterns_sz++] = regex; + } + } + } else { + p_restriction->patterns = NULL; + p_restriction->patterns_sz = 0; + } + self->tokens_sz++; + return 1; + } + } + return 0; +} + + +/* Scanner public interface */ + +void +Scanner_reset(Scanner *self, char *input, int input_sz) { + int i; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + for (i = 0; i < self->tokens_sz; i++) { + PyMem_Del(self->restrictions[i].patterns); + self->restrictions[i].patterns = NULL; + self->restrictions[i].patterns_sz = 0; + } + self->tokens_sz = 0; + + if (self->input != NULL) { + PyMem_Del(self->input); + } + self->input = PyMem_Strndup(input, input_sz); + self->input_sz = input_sz; + #ifdef DEBUG + fprintf(stderr, "Scanning in %s\n", repr(self->input)); + #endif + + self->pos = 0; +} + +void +Scanner_del(Scanner *self) { + int i; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (self->ignore != NULL) { + PyMem_Del(self->ignore); + } + + if (self->tokens != NULL) { + for (i = 0; i < self->tokens_sz; i++) { + PyMem_Del(self->restrictions[i].patterns); + } + PyMem_Del(self->tokens); + PyMem_Del(self->restrictions); + } + + if (self->input != NULL) { + PyMem_Del(self->input); + } + + PyMem_Del(self); +} + +Scanner* +Scanner_new(Pattern patterns[], int patterns_sz, Pattern ignore[], int ignore_sz, char *input, int input_sz) +{ + int i; + Scanner *self; + Pattern *regex; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + self = PyMem_New(Scanner, 1); + memset(self, 0, sizeof(Scanner)); + if (self) { + for (i = 0; i < patterns_sz; i++) { + regex = Pattern_regex(patterns[i].tok, patterns[i].expr); + #ifdef DEBUG + if (regex) { + fprintf(stderr, "\tAdded regex pattern %s: %s\n", repr(regex->tok), repr(regex->expr)); + } + #endif + } + if (ignore_sz) { + self->ignore = PyMem_New(Pattern *, ignore_sz); + for (i = 0; i < ignore_sz; i++) { + regex = Pattern_regex(ignore[i].tok, ignore[i].expr); + if (regex) { + self->ignore[self->ignore_sz++] = regex; + #ifdef DEBUG + fprintf(stderr, "\tIgnoring token %s\n", repr(regex->tok)); + #endif + } + } + } else { + self->ignore = NULL; + } + Scanner_reset(self, input, input_sz); + } + return self; +} + +int +Scanner_initialized(void) +{ + return Pattern_patterns_initialized; +} + +void +Scanner_initialize(Pattern patterns[], int patterns_sz) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + Pattern_initialize(patterns, patterns_sz); +} + +void +Scanner_finalize(void) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + Pattern_finalize(); +} + +Token* +Scanner_token(Scanner *self, int i, Pattern restrictions[], int restrictions_sz) +{ + int j, k, found; + Pattern *regex; + long result; + + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (i == self->tokens_sz) { + result = _Scanner_scan(self, restrictions, restrictions_sz); + if (result < 0) { + return (Token *)result; + } + } else if (i >= 0 && i < self->tokens_sz) { + if (self->restrictions[i].patterns_sz) { + for (j = 0; j < restrictions_sz; j++) { + found = 0; + for (k = 0; k < self->restrictions[i].patterns_sz; k++) { + regex = Pattern_regex(restrictions[j].tok, restrictions[j].expr); + if (strcmp(restrictions[j].tok, self->restrictions[i].patterns[k]->tok) == 0) { + found = 1; + break; + } + } + if (!found) { + sprintf(self->exc, "Unimplemented: restriction set changed"); + return (Token *)SCANNER_EXC_UNIMPLEMENTED; + } + } + } + } + if (i >= 0 && i < self->tokens_sz) { + return &self->tokens[i]; + } + return (Token *)SCANNER_EXC_NO_MORE_TOKENS; +} + +void +Scanner_rewind(Scanner *self, int i) +{ + #ifdef DEBUG + fprintf(stderr, "%s\n", __PRETTY_FUNCTION__); + #endif + + if (i >= 0 && i < self->tokens_sz) { + self->tokens_sz = i; + self->pos = (int)(self->tokens[i].string - self->input); + } +} diff --git a/libs/scss/src/scanner.h b/libs/scss/src/scanner.h new file mode 100644 index 00000000..df9fb629 --- /dev/null +++ b/libs/scss/src/scanner.h @@ -0,0 +1,68 @@ +/* +* pyScss, a Scss compiler for Python +* SCSS blocks scanner. +* +* German M. Bravo (Kronuz) +* https://github.com/Kronuz/pyScss +* +* MIT license (http://www.opensource.org/licenses/mit-license.php) +* Copyright (c) 2011 German M. Bravo (Kronuz), All rights reserved. +*/ +#ifndef SCANNER_H +#define SCANNER_H + +#define PCRE_STATIC +#include + +#define BLOCK_SIZE_PATTERNS 50 +#define BLOCK_SIZE_TOKENS 50 + +#define MAX_EXC_STRING 4096 + +#define SCANNER_EXC_BAD_TOKEN (long)-1 +#define SCANNER_EXC_RESTRICTED (long)-2 +#define SCANNER_EXC_UNIMPLEMENTED (long)-3 +#define SCANNER_EXC_NO_MORE_TOKENS (long)-4 + +typedef struct { + char *tok; + char *expr; + pcre *pattern; +} Pattern; + +typedef struct { + Pattern *regex; + char *string; + int string_sz; +} Token; + +typedef struct { + int patterns_sz; + Pattern **patterns; +} Restriction; + +typedef struct { + char exc[MAX_EXC_STRING]; + int ignore_sz; + Pattern **ignore; + int tokens_sz; + int tokens_bsz; + Token *tokens; + Restriction *restrictions; + int input_sz; + char *input; + int pos; +} Scanner; + +int Scanner_initialized(void); +void Scanner_initialize(Pattern *, int); +void Scanner_finalize(void); + +void Scanner_reset(Scanner *self, char *input, int input_sz); +Scanner *Scanner_new(Pattern *, int, Pattern *, int, char *, int); +void Scanner_del(Scanner *); + +Token* Scanner_token(Scanner *, int, Pattern *, int); +void Scanner_rewind(Scanner *, int); + +#endif diff --git a/libs/scss/src/utils.h b/libs/scss/src/utils.h new file mode 100644 index 00000000..ee5401e1 --- /dev/null +++ b/libs/scss/src/utils.h @@ -0,0 +1,98 @@ +#include + +#include + +char * +PyMem_Strndup(const char *str, size_t len) +{ + if (str != NULL) { + char *copy = PyMem_New(char, len + 1); + if (copy != NULL) + memcpy(copy, str, len); + copy[len] = '\0'; + return copy; + } + return NULL; +} + +char * +PyMem_Strdup(const char *str) +{ + return PyMem_Strndup(str, strlen(str)); +} + +char * +reprn(char *str, size_t len) { + static char strings[10240]; + static size_t current = 0; + size_t reqlen = 2; + char c, + *out, + *write, + *begin = str, + *end = str + len; + while (begin < end) { + c = *begin; + if (c == '\'') { + reqlen += 2; + } else if (c == '\r') { + reqlen += 2; + } else if (c == '\n') { + reqlen += 2; + } else if (c == '\t') { + reqlen += 2; + } else if (c < ' ') { + reqlen += 3; + } else { + reqlen++; + } + begin++; + } + if (reqlen > 10240) { + reqlen = 10240; + } + if (current + reqlen > 10240) { + current = 0; + } + begin = str; + end = str + len; + out = write = strings + current; + *write++ = '\''; + while (begin < end) { + c = *begin; + if (c == '\'') { + if (write + 5 >= strings + 10240) break; + sprintf(write, "\\'"); + write += 2; + } else if (c == '\r') { + if (write + 5 >= strings + 10240) break; + sprintf(write, "\\r"); + write += 2; + } else if (c == '\n') { + if (write + 5 >= strings + 10240) break; + sprintf(write, "\\n"); + write += 2; + } else if (c == '\t') { + if (write + 5 >= strings + 10240) break; + sprintf(write, "\\t"); + write += 2; + } else if (c < ' ') { + if (write + 6 >= strings + 10240) break; + sprintf(write, "\\x%02x", c); + write += 3; + } else { + if (write + 4 >= strings + 10240) break; + *write++ = c; + } + begin++; + } + *write++ = '\''; + *write++ = '\0'; + current += (size_t)(write - out); + return out; +} + +char * +repr(char *str) { + return reprn(str, strlen(str)); +} diff --git a/libs/scss/tool.py b/libs/scss/tool.py new file mode 100644 index 00000000..205bb817 --- /dev/null +++ b/libs/scss/tool.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python +from __future__ import absolute_import +from __future__ import print_function + +from contextlib import contextmanager +import logging +import os +import re +import sys +from collections import deque + +from scss import config +from scss.util import profiling +from scss import Scss, SourceFile, log +from scss import _prop_split_re +from scss.rule import SassRule +from scss.rule import UnparsedBlock +from scss.expression import Calculator +from scss.scss_meta import BUILD_INFO +from scss.errors import SassEvaluationError + +try: + raw_input +except NameError: + raw_input = input + +log.setLevel(logging.INFO) + + +def main(): + logging.basicConfig(format="%(levelname)s: %(message)s") + + from optparse import OptionGroup, OptionParser, SUPPRESS_HELP + + parser = OptionParser(usage="Usage: %prog [options] [file]", + description="Converts Scss files to CSS.", + add_help_option=False) + parser.add_option("-i", "--interactive", action="store_true", + help="Run an interactive Scss shell") + parser.add_option("-w", "--watch", metavar="DIR", + help="Watch the files in DIR, and recompile when they change") + parser.add_option("-r", "--recursive", action="store_true", default=False, + help="Also watch directories inside of the watch directory") + parser.add_option("-o", "--output", metavar="PATH", + help="Write output to PATH (a directory if using watch, a file otherwise)") + parser.add_option("-s", "--suffix", metavar="STRING", + help="If using watch, a suffix added to the output filename (i.e. filename.STRING.css)") + parser.add_option("--time", action="store_true", + help="Display compliation times") + parser.add_option("--debug-info", action="store_true", + help="Turns on scss's debugging information") + parser.add_option("--no-debug-info", action="store_false", + dest="debug_info", default=False, + help="Turns off scss's debugging information") + parser.add_option("-T", "--test", action="store_true", help=SUPPRESS_HELP) + parser.add_option("-t", "--style", metavar="NAME", + dest="style", default='nested', + help="Output style. Can be nested (default), compact, compressed, or expanded.") + parser.add_option("-C", "--no-compress", action="store_false", dest="style", default=True, + help="Don't minify outputted CSS") + parser.add_option("-?", action="help", help=SUPPRESS_HELP) + parser.add_option("-h", "--help", action="help", + help="Show this message and exit") + parser.add_option("-v", "--version", action="store_true", + help="Print version and exit") + + paths_group = OptionGroup(parser, "Resource Paths") + paths_group.add_option("-I", "--load-path", metavar="PATH", + action="append", dest="load_paths", + help="Add a scss import path, may be given multiple times") + paths_group.add_option("-S", "--static-root", metavar="PATH", dest="static_root", + help="Static root path (Where images and static resources are located)") + paths_group.add_option("-A", "--assets-root", metavar="PATH", dest="assets_root", + help="Assets root path (Sprite images will be created here)") + paths_group.add_option("-a", "--assets-url", metavar="URL", dest="assets_url", + help="URL to reach the files in your assets_root") + paths_group.add_option("-F", "--fonts-root", metavar="PATH", dest="fonts_root", + help="Fonts root path (Where fonts are located)") + paths_group.add_option("-f", "--fonts-url", metavar="PATH", dest="fonts_url", + help="URL to reach the fonts in your fonts_root") + paths_group.add_option("--images-root", metavar="PATH", dest="images_root", + help="Images root path (Where images are located)") + paths_group.add_option("--images-url", metavar="PATH", dest="images_url", + help="URL to reach the images in your images_root") + paths_group.add_option("--cache-root", metavar="PATH", dest="cache_root", + help="Cache root path (Cache files will be created here)") + parser.add_option_group(paths_group) + + parser.add_option("--sass", action="store_true", + dest="is_sass", default=None, + help="Sass mode") + + (options, args) = parser.parse_args() + + # General runtime configuration + config.VERBOSITY = 0 + if options.time: + config.VERBOSITY = 2 + + if options.static_root is not None: + config.STATIC_ROOT = options.static_root + if options.assets_root is not None: + config.ASSETS_ROOT = options.assets_root + + if options.fonts_root is not None: + config.FONTS_ROOT = options.fonts_root + if options.fonts_url is not None: + config.FONTS_URL = options.fonts_url + + if options.images_root is not None: + config.IMAGES_ROOT = options.images_root + if options.images_url is not None: + config.IMAGES_URL = options.images_url + + if options.cache_root is not None: + config.CACHE_ROOT = options.cache_root + if options.load_paths is not None: + # TODO: Convert global LOAD_PATHS to a list. Use it directly. + # Doing the above will break backwards compatibility! + if hasattr(config.LOAD_PATHS, 'split'): + load_path_list = [p.strip() for p in config.LOAD_PATHS.split(',')] + else: + load_path_list = list(config.LOAD_PATHS) + + for path_param in options.load_paths: + for p in path_param.replace(os.pathsep, ',').replace(';', ',').split(','): + p = p.strip() + if p and p not in load_path_list: + load_path_list.append(p) + + # TODO: Remove this once global LOAD_PATHS is a list. + if hasattr(config.LOAD_PATHS, 'split'): + config.LOAD_PATHS = ','.join(load_path_list) + else: + config.LOAD_PATHS = load_path_list + if options.assets_url is not None: + config.ASSETS_URL = options.assets_url + + # Execution modes + if options.test: + run_tests() + elif options.version: + print_version() + elif options.interactive: + run_repl(options) + elif options.watch: + watch_sources(options) + else: + do_build(options, args) + + +def print_version(): + print(BUILD_INFO) + + +def run_tests(): + try: + import pytest + except ImportError: + raise ImportError("You need py.test installed to run the test suite.") + pytest.main("") # don't let py.test re-consume our arguments + + +def do_build(options, args): + if options.output is not None: + output = open(options.output, 'wt') + else: + output = sys.stdout + + css = Scss(scss_opts={ + 'style': options.style, + 'debug_info': options.debug_info, + }) + if args: + for path in args: + output.write(css.compile(scss_file=path, is_sass=options.is_sass)) + else: + output.write(css.compile(sys.stdin.read(), is_sass=options.is_sass)) + + for f, t in profiling.items(): + sys.stderr.write("%s took %03fs" % (f, t)) + + +def watch_sources(options): + import time + try: + from watchdog.observers import Observer + from watchdog.events import PatternMatchingEventHandler + except ImportError: + sys.stderr.write("Using watch functionality requires the `watchdog` library: http://pypi.python.org/pypi/watchdog/") + sys.exit(1) + if options.output and not os.path.isdir(options.output): + sys.stderr.write("watch file output directory is invalid: '%s'" % (options.output)) + sys.exit(2) + + class ScssEventHandler(PatternMatchingEventHandler): + def __init__(self, *args, **kwargs): + super(ScssEventHandler, self).__init__(*args, **kwargs) + self.css = Scss(scss_opts={ + 'style': options.style, + 'debug_info': options.debug_info, + }) + self.output = options.output + self.suffix = options.suffix + + def is_valid(self, path): + return os.path.isfile(path) and (path.endswith('.scss') or path.endswith('.sass')) and not os.path.basename(path).startswith('_') + + def process(self, path): + if os.path.isdir(path): + for f in os.listdir(path): + full = os.path.join(path, f) + if self.is_valid(full): + self.compile(full) + elif self.is_valid(path): + self.compile(path) + + def compile(self, src_path): + fname = os.path.basename(src_path) + if fname.endswith('.scss') or fname.endswith('.sass'): + fname = fname[:-5] + if self.suffix: + fname += '.' + self.suffix + fname += '.css' + else: + # you didn't give me a file of the correct type! + return False + + if self.output: + dest_path = os.path.join(self.output, fname) + else: + dest_path = os.path.join(os.path.dirname(src_path), fname) + + print("Compiling %s => %s" % (src_path, dest_path)) + dest_file = open(dest_path, 'w') + dest_file.write(self.css.compile(scss_file=src_path)) + + def on_moved(self, event): + super(ScssEventHandler, self).on_moved(event) + self.process(event.dest_path) + + def on_created(self, event): + super(ScssEventHandler, self).on_created(event) + self.process(event.src_path) + + def on_modified(self, event): + super(ScssEventHandler, self).on_modified(event) + self.process(event.src_path) + + event_handler = ScssEventHandler(patterns=['*.scss', '*.sass']) + observer = Observer() + observer.schedule(event_handler, path=options.watch, recursive=options.recursive) + observer.start() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() + + +@contextmanager +def readline_history(fn): + try: + import readline + except ImportError: + yield + return + + try: + readline.read_history_file(fn) + except IOError: + pass + + try: + yield + finally: + try: + readline.write_history_file(fn) + except IOError: + pass + + +def run_repl(is_sass=False): + repl = SassRepl() + + with readline_history(os.path.expanduser('~/.scss-history')): + print("Welcome to %s interactive shell" % (BUILD_INFO,)) + while True: + try: + in_ = raw_input('>>> ').strip() + for output in repl(in_): + print(output) + except (EOFError, KeyboardInterrupt): + print("Bye!") + return + + +class SassRepl(object): + def __init__(self, is_sass=False): + self.css = Scss() + self.namespace = self.css.root_namespace + self.options = self.css.scss_opts + self.source_file = SourceFile.from_string('', '', line_numbers=False, is_sass=is_sass) + self.calculator = Calculator(self.namespace) + + def __call__(self, s): + from pprint import pformat + + if s in ('exit', 'quit'): + raise KeyboardInterrupt + + for s in s.split(';'): + s = self.source_file.prepare_source(s.strip()) + if not s: + continue + elif s.startswith('@'): + scope = None + properties = [] + children = deque() + rule = SassRule(self.source_file, namespace=self.namespace, options=self.options, properties=properties) + block = UnparsedBlock(rule, 1, s, None) + code, name = (s.split(None, 1) + [''])[:2] + if code == '@option': + self.css._settle_options(rule, children, scope, block) + continue + elif code == '@import': + self.css._do_import(rule, children, scope, block) + continue + elif code == '@include': + final_cont = '' + self.css._do_include(rule, children, scope, block) + code = self.css._print_properties(properties).rstrip('\n') + if code: + final_cont += code + if children: + self.css.children.extendleft(children) + self.css.parse_children() + code = self.css._create_css(self.css.rules).rstrip('\n') + if code: + final_cont += code + yield final_cont + continue + elif s == 'ls' or s.startswith('show(') or s.startswith('show ') or s.startswith('ls(') or s.startswith('ls '): + m = re.match(r'(?:show|ls)(\()?\s*([^,/\\) ]*)(?:[,/\\ ]([^,/\\ )]+))*(?(1)\))', s, re.IGNORECASE) + if m: + name = m.group(2) + code = m.group(3) + name = name and name.strip().rstrip('s') # remove last 's' as in functions + code = code and code.strip() + ns = self.namespace + if not name: + yield pformat(sorted(['vars', 'options', 'mixins', 'functions'])) + elif name in ('v', 'var', 'variable'): + variables = dict(ns._variables) + if code == '*': + pass + elif code: + variables = dict((k, v) for k, v in variables.items() if code in k) + else: + variables = dict((k, v) for k, v in variables.items() if not k.startswith('$--')) + yield pformat(variables) + + elif name in ('o', 'opt', 'option'): + opts = self.options + if code == '*': + pass + elif code: + opts = dict((k, v) for k, v in opts.items() if code in k) + else: + opts = dict((k, v) for k, v in opts.items() if not k.startswith('@')) + yield pformat(opts) + + elif name in ('m', 'mix', 'mixin', 'f', 'func', 'funct', 'function'): + if name.startswith('m'): + funcs = dict(ns._mixins) + elif name.startswith('f'): + funcs = dict(ns._functions) + if code == '*': + pass + elif code: + funcs = dict((k, v) for k, v in funcs.items() if code in k[0]) + else: + pass + # TODO print source when possible + yield pformat(funcs) + continue + elif s.startswith('$') and (':' in s or '=' in s): + prop, value = [a.strip() for a in _prop_split_re.split(s, 1)] + prop = self.calculator.do_glob_math(prop) + value = self.calculator.calculate(value) + self.namespace.set_variable(prop, value) + continue + + # TODO respect compress? + try: + yield(self.calculator.calculate(s).render()) + except (SyntaxError, SassEvaluationError) as e: + print("%s" % e, file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/libs/scss/types.py b/libs/scss/types.py new file mode 100644 index 00000000..aafbab4e --- /dev/null +++ b/libs/scss/types.py @@ -0,0 +1,1128 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import colorsys +import operator + +import six + +from scss.cssdefs import COLOR_LOOKUP, COLOR_NAMES, ZEROABLE_UNITS, convert_units_to_base_units, cancel_base_units, count_base_units +from scss.util import escape + + +################################################################################ +# pyScss data types: + +class Value(object): + is_null = False + sass_type_name = u'unknown' + + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.value)) + + # Sass values are all true, except for booleans and nulls + def __bool__(self): + return True + + def __nonzero__(self): + # Py 2's name for __bool__ + return self.__bool__() + + # All Sass scalars also act like one-element spaced lists + use_comma = False + + def __iter__(self): + return iter((self,)) + + def __len__(self): + return 1 + + def __getitem__(self, key): + if key not in (-1, 0): + raise IndexError(key) + + return self + + def __contains__(self, item): + return self == item + + ### NOTE: From here on down, the operators are exposed to Sass code and + ### thus should ONLY return Sass types + + # Reasonable default for equality + def __eq__(self, other): + return Boolean( + type(self) == type(other) and self.value == other.value) + + def __ne__(self, other): + return Boolean(not self.__eq__(other)) + + # Only numbers support ordering + def __lt__(self, other): + raise TypeError("Can't compare %r with %r" % (self, other)) + + def __le__(self, other): + raise TypeError("Can't compare %r with %r" % (self, other)) + + def __gt__(self, other): + raise TypeError("Can't compare %r with %r" % (self, other)) + + def __ge__(self, other): + raise TypeError("Can't compare %r with %r" % (self, other)) + + # Math ops + def __add__(self, other): + # Default behavior is to treat both sides like strings + if isinstance(other, String): + return String(self.render() + other.value, quotes=other.quotes) + return String(self.render() + other.render()) + + def __sub__(self, other): + # Default behavior is to treat the whole expression like one string + return String(self.render() + "-" + other.render()) + + def __div__(self, other): + return String(self.render() + "/" + other.render()) + + # Sass types have no notion of floor vs true division + def __truediv__(self, other): + return self.__div__(other) + + def __floordiv__(self, other): + return self.__div__(other) + + def __mul__(self, other): + return NotImplemented + + def __pos__(self): + return String("+" + self.render()) + + def __neg__(self): + return String("-" + self.render()) + + def to_dict(self): + """Return the Python dict equivalent of this map. + + If this type can't be expressed as a map, raise. + """ + return dict(self.to_pairs()) + + def to_pairs(self): + """Return the Python list-of-tuples equivalent of this map. Note that + this is different from ``self.to_dict().items()``, because Sass maps + preserve order. + + If this type can't be expressed as a map, raise. + """ + raise ValueError("Not a map: {0!r}".format(self)) + + def render(self, compress=False): + return self.__str__() + + +class Null(Value): + is_null = True + sass_type_name = u'null' + + def __init__(self, value=None): + pass + + def __str__(self): + return self.sass_type_name + + def __repr__(self): + return "<%s()>" % (self.__class__.__name__,) + + def __hash__(self): + return hash(None) + + def __bool__(self): + return False + + def __eq__(self, other): + return Boolean(isinstance(other, Null)) + + def __ne__(self, other): + return Boolean(not self.__eq__(other)) + + def render(self, compress=False): + return self.sass_type_name + + +class Undefined(Null): + sass_type_name = u'undefined' + + def __init__(self, value=None): + pass + + def __add__(self, other): + return self + + def __radd__(self, other): + return self + + def __sub__(self, other): + return self + + def __rsub__(self, other): + return self + + def __div__(self, other): + return self + + def __rdiv__(self, other): + return self + + def __truediv__(self, other): + return self + + def __rtruediv__(self, other): + return self + + def __floordiv__(self, other): + return self + + def __rfloordiv__(self, other): + return self + + def __mul__(self, other): + return self + + def __rmul__(self, other): + return self + + def __pos__(self): + return self + + def __neg__(self): + return self + + +class Boolean(Value): + sass_type_name = u'bool' + + def __init__(self, value): + self.value = bool(value) + + def __str__(self): + return 'true' if self.value else 'false' + + def __hash__(self): + return hash(self.value) + + def __bool__(self): + return self.value + + def render(self, compress=False): + if self.value: + return 'true' + else: + return 'false' + + +class Number(Value): + sass_type_name = u'number' + + def __init__(self, amount, unit=None, unit_numer=(), unit_denom=()): + if isinstance(amount, Number): + assert not unit and not unit_numer and not unit_denom + self.value = amount.value + self.unit_numer = amount.unit_numer + self.unit_denom = amount.unit_denom + return + + if not isinstance(amount, (int, float)): + raise TypeError("Expected number, got %r" % (amount,)) + + if unit is not None: + unit_numer = unit_numer + (unit.lower(),) + + # Cancel out any convertable units on the top and bottom + numerator_base_units = count_base_units(unit_numer) + denominator_base_units = count_base_units(unit_denom) + + # Count which base units appear both on top and bottom + cancelable_base_units = {} + for unit, count in numerator_base_units.items(): + cancelable_base_units[unit] = min( + count, denominator_base_units.get(unit, 0)) + + # Actually remove the units + numer_factor, unit_numer = cancel_base_units(unit_numer, cancelable_base_units) + denom_factor, unit_denom = cancel_base_units(unit_denom, cancelable_base_units) + + # And we're done + self.unit_numer = tuple(unit_numer) + self.unit_denom = tuple(unit_denom) + self.value = amount * (numer_factor / denom_factor) + + def __repr__(self): + full_unit = ' * '.join(self.unit_numer) + if self.unit_denom: + full_unit += ' / ' + full_unit += ' * '.join(self.unit_denom) + + if full_unit: + full_unit = ' ' + full_unit + + return '<%s(%r%s)>' % (self.__class__.__name__, self.value, full_unit) + + def __hash__(self): + return hash((self.value, self.unit_numer, self.unit_denom)) + + def __int__(self): + return int(self.value) + + def __float__(self): + return float(self.value) + + def __pos__(self): + return self + + def __neg__(self): + return self * Number(-1) + + def __str__(self): + return self.render() + + def __eq__(self, other): + if not isinstance(other, Number): + return Boolean(False) + return self._compare(other, operator.__eq__, soft_fail=True) + + def __lt__(self, other): + return self._compare(other, operator.__lt__) + + def __le__(self, other): + return self._compare(other, operator.__le__) + + def __gt__(self, other): + return self._compare(other, operator.__gt__) + + def __ge__(self, other): + return self._compare(other, operator.__ge__) + + def _compare(self, other, op, soft_fail=False): + if not isinstance(other, Number): + raise TypeError("Can't compare %r and %r" % (self, other)) + + # A unitless operand is treated as though it had the other operand's + # units, and zero values can cast to anything, so in both cases the + # units can be ignored + if (self.is_unitless or other.is_unitless or + self.value == 0 or other.value == 0): + left = self + right = other + else: + left = self.to_base_units() + right = other.to_base_units() + + if left.unit_numer != right.unit_numer or left.unit_denom != right.unit_denom: + if soft_fail: + # Used for equality only, where == should never fail + return Boolean(False) + else: + raise ValueError("Can't reconcile units: %r and %r" % (self, other)) + + return Boolean(op(round(left.value, 5), round(right.value, 5))) + + def __pow__(self, exp): + if not isinstance(exp, Number): + raise TypeError("Can't raise %r to power %r" % (self, exp)) + if not exp.is_unitless: + raise TypeError("Exponent %r cannot have units" % (exp,)) + + if self.is_unitless: + return Number(self.value ** exp.value) + + # Units can only be exponentiated to integral powers -- what's the + # square root of 'px'? (Well, it's sqrt(px), but supporting that is + # a bit out of scope.) + if exp.value != int(exp.value): + raise ValueError("Can't raise units of %r to non-integral power %r" % (self, exp)) + + return Number( + self.value ** int(exp.value), + unit_numer=self.unit_numer * int(exp.value), + unit_denom=self.unit_denom * int(exp.value), + ) + + def __mul__(self, other): + if not isinstance(other, Number): + return NotImplemented + + amount = self.value * other.value + numer = self.unit_numer + other.unit_numer + denom = self.unit_denom + other.unit_denom + + return Number(amount, unit_numer=numer, unit_denom=denom) + + def __div__(self, other): + if not isinstance(other, Number): + return NotImplemented + + amount = self.value / other.value + numer = self.unit_numer + other.unit_denom + denom = self.unit_denom + other.unit_numer + + return Number(amount, unit_numer=numer, unit_denom=denom) + + def __add__(self, other): + # Numbers auto-cast to strings when added to other strings + if isinstance(other, String): + return String(self.render(), quotes=None) + other + + return self._add_sub(other, operator.add) + + def __sub__(self, other): + return self._add_sub(other, operator.sub) + + def _add_sub(self, other, op): + """Implements both addition and subtraction.""" + if not isinstance(other, Number): + return NotImplemented + + # If either side is unitless, inherit the other side's units. Skip all + # the rest of the conversion math, too. + if self.is_unitless or other.is_unitless: + return Number( + op(self.value, other.value), + unit_numer=self.unit_numer or other.unit_numer, + unit_denom=self.unit_denom or other.unit_denom, + ) + + # Likewise, if either side is zero, it can auto-cast to any units + if self.value == 0: + return Number( + op(self.value, other.value), + unit_numer=other.unit_numer, + unit_denom=other.unit_denom, + ) + elif other.value == 0: + return Number( + op(self.value, other.value), + unit_numer=self.unit_numer, + unit_denom=self.unit_denom, + ) + + # Reduce both operands to the same units + left = self.to_base_units() + right = other.to_base_units() + + if left.unit_numer != right.unit_numer or left.unit_denom != right.unit_denom: + raise ValueError("Can't reconcile units: %r and %r" % (self, other)) + + new_amount = op(left.value, right.value) + + # Convert back to the left side's units + if left.value != 0: + new_amount = new_amount * self.value / left.value + + return Number(new_amount, unit_numer=self.unit_numer, unit_denom=self.unit_denom) + + ### Helper methods, mostly used internally + + def to_base_units(self): + """Convert to a fixed set of "base" units. The particular units are + arbitrary; what's important is that they're consistent. + + Used for addition and comparisons. + """ + # Convert to "standard" units, as defined by the conversions dict above + amount = self.value + + numer_factor, numer_units = convert_units_to_base_units(self.unit_numer) + denom_factor, denom_units = convert_units_to_base_units(self.unit_denom) + + return Number( + amount * numer_factor / denom_factor, + unit_numer=numer_units, + unit_denom=denom_units, + ) + + ### Utilities for public consumption + + @classmethod + def wrap_python_function(cls, fn): + """Wraps an unary Python math function, translating the argument from + Sass to Python on the way in, and vice versa for the return value. + + Used to wrap simple Python functions like `ceil`, `floor`, etc. + """ + def wrapped(sass_arg): + # TODO enforce no units for trig? + python_arg = sass_arg.value + python_ret = fn(python_arg) + sass_ret = cls( + python_ret, + unit_numer=sass_arg.unit_numer, + unit_denom=sass_arg.unit_denom) + return sass_ret + + return wrapped + + def to_python_index(self, length, check_bounds=True, circular=False): + """Return a plain Python integer appropriate for indexing a sequence of + the given length. Raise if this is impossible for any reason + whatsoever. + """ + if not self.is_unitless: + raise ValueError("Index cannot have units: {0!r}".format(self)) + + ret = int(self.value) + if ret != self.value: + raise ValueError("Index must be an integer: {0!r}".format(ret)) + + if ret == 0: + raise ValueError("Index cannot be zero") + + if check_bounds and not circular and abs(ret) > length: + raise ValueError("Index {0!r} out of bounds for length {1}".format(ret, length)) + + if ret > 0: + ret -= 1 + + if circular: + ret = ret % length + + return ret + + @property + def has_simple_unit(self): + """Returns True iff the unit is expressible in CSS, i.e., has no + denominator and at most one unit in the numerator. + """ + return len(self.unit_numer) <= 1 and not self.unit_denom + + def is_simple_unit(self, unit): + """Return True iff the unit is simple (as above) and matches the given + unit. + """ + if self.unit_denom or len(self.unit_numer) > 1: + return False + + if not self.unit_numer: + # Empty string historically means no unit + return unit == '' + + return self.unit_numer[0] == unit + + @property + def is_unitless(self): + return not self.unit_numer and not self.unit_denom + + def render(self, compress=False): + if not self.has_simple_unit: + raise ValueError("Can't express compound units in CSS: %r" % (self,)) + + if self.unit_numer: + unit = self.unit_numer[0] + else: + unit = '' + + value = self.value + if compress and unit in ZEROABLE_UNITS and value == 0: + return '0' + + if value == 0: # -0.0 is plain 0 + value = 0 + + val = "%0.05f" % round(value, 5) + val = val.rstrip('0').rstrip('.') + + if compress and val.startswith('0.'): + # Strip off leading zero when compressing + val = val[1:] + + return val + unit + + +class List(Value): + """A list of other values. May be delimited by commas or spaces. + + Lists of one item don't make much sense in CSS, but can exist in Sass. Use ...... + + Lists may also contain zero items, but these are forbidden from appearing + in CSS output. + """ + + sass_type_name = u'list' + + def __init__(self, iterable, separator=None, use_comma=None, is_literal=False): + if isinstance(iterable, List): + iterable = iterable.value + + if not isinstance(iterable, (list, tuple)): + raise TypeError("Expected list, got %r" % (iterable,)) + + self.value = list(iterable) + + for item in self.value: + if not isinstance(item, Value): + raise TypeError("Expected a Sass type, got %r" % (item,)) + + # TODO remove separator argument entirely + if use_comma is None: + self.use_comma = separator == "," + else: + self.use_comma = use_comma + + self.is_literal = is_literal + + @classmethod + def maybe_new(cls, values, use_comma=True): + """If `values` contains only one item, return that item. Otherwise, + return a List as normal. + """ + if len(values) == 1: + return values[0] + else: + return cls(values, use_comma=use_comma) + + def maybe(self): + """If this List contains only one item, return it. Otherwise, return + the List. + """ + if len(self.value) == 1: + return self.value[0] + else: + return self + + @classmethod + def from_maybe(cls, values, use_comma=True): + """If `values` appears to not be a list, return a list containing it. + Otherwise, return a List as normal. + """ + if values is None: + values = [] + return values + + @classmethod + def from_maybe_starargs(cls, args, use_comma=True): + """If `args` has one element which appears to be a list, return it. + Otherwise, return a list as normal. + + Mainly used by Sass function implementations that predate `...` + support, so they can accept both a list of arguments and a single list + stored in a variable. + """ + if len(args) == 1: + if isinstance(args[0], cls): + return args[0] + elif isinstance(args[0], (list, tuple)): + return cls(args[0], use_comma=use_comma) + + return cls(args, use_comma=use_comma) + + def __repr__(self): + return "" % ( + self.value, + self.delimiter(compress=True), + ) + + def __hash__(self): + return hash((tuple(self.value), self.use_comma)) + + def delimiter(self, compress=False): + if self.use_comma: + if compress: + return ',' + else: + return ', ' + else: + return ' ' + + def __len__(self): + return len(self.value) + + def __str__(self): + return self.render() + + def __iter__(self): + return iter(self.value) + + def __contains__(self, item): + return item in self.value + + def __getitem__(self, key): + return self.value[key] + + def to_pairs(self): + pairs = [] + for item in self: + if len(item) != 2: + return super(List, self).to_pairs() + + pairs.append(tuple(item)) + + return pairs + + def render(self, compress=False): + if not self.value: + raise ValueError("Can't render empty list as CSS") + + delim = self.delimiter(compress) + + if self.is_literal: + value = self.value + else: + # Non-literal lists have nulls stripped + value = [item for item in self.value if not item.is_null] + # Non-empty lists containing only nulls become nothing, just like + # single nulls + if not value: + return '' + + return delim.join( + item.render(compress=compress) + for item in value + ) + + # DEVIATION: binary ops on lists and scalars act element-wise + def __add__(self, other): + if isinstance(other, List): + max_list, min_list = (self, other) if len(self) > len(other) else (other, self) + return List([item + max_list[i] for i, item in enumerate(min_list)], use_comma=self.use_comma) + + elif isinstance(other, String): + # UN-DEVIATION: adding a string should fall back to canonical + # behavior of string addition + return super(List, self).__add__(other) + + else: + return List([item + other for item in self], use_comma=self.use_comma) + + def __sub__(self, other): + if isinstance(other, List): + max_list, min_list = (self, other) if len(self) > len(other) else (other, self) + return List([item - max_list[i] for i, item in enumerate(min_list)], use_comma=self.use_comma) + + return List([item - other for item in self], use_comma=self.use_comma) + + def __mul__(self, other): + if isinstance(other, List): + max_list, min_list = (self, other) if len(self) > len(other) else (other, self) + max_list, min_list = (self, other) if len(self) > len(other) else (other, self) + return List([item * max_list[i] for i, item in enumerate(min_list)], use_comma=self.use_comma) + + return List([item * other for item in self], use_comma=self.use_comma) + + def __div__(self, other): + if isinstance(other, List): + max_list, min_list = (self, other) if len(self) > len(other) else (other, self) + return List([item / max_list[i] for i, item in enumerate(min_list)], use_comma=self.use_comma) + + return List([item / other for item in self], use_comma=self.use_comma) + + def __pos__(self): + return self + + def __neg__(self): + return List([-item for item in self], use_comma=self.use_comma) + + +def _constrain(value, lb=0, ub=1): + """Helper for Color constructors. Constrains a value to a range.""" + if value < lb: + return lb + elif value > ub: + return ub + else: + return value + + +class Color(Value): + sass_type_name = u'color' + original_literal = None + + def __init__(self, tokens): + self.tokens = tokens + self.value = (0, 0, 0, 1) + if tokens is None: + self.value = (0, 0, 0, 1) + elif isinstance(tokens, Color): + self.value = tokens.value + else: + raise TypeError("Can't make Color from %r" % (tokens,)) + + ### Alternate constructors + + @classmethod + def from_rgb(cls, red, green, blue, alpha=1.0, original_literal=None): + red = _constrain(red) + green = _constrain(green) + blue = _constrain(blue) + alpha = _constrain(alpha) + + self = cls.__new__(cls) # TODO + self.tokens = None + # TODO really should store these things internally as 0-1, but can't + # until stuff stops examining .value directly + self.value = (red * 255.0, green * 255.0, blue * 255.0, alpha) + + if original_literal is not None: + self.original_literal = original_literal + + return self + + @classmethod + def from_hsl(cls, hue, saturation, lightness, alpha=1.0): + hue = _constrain(hue) + saturation = _constrain(saturation) + lightness = _constrain(lightness) + alpha = _constrain(alpha) + + r, g, b = colorsys.hls_to_rgb(hue, lightness, saturation) + return cls.from_rgb(r, g, b, alpha) + + @classmethod + def from_hex(cls, hex_string, literal=False): + if not hex_string.startswith('#'): + raise ValueError("Expected #abcdef, got %r" % (hex_string,)) + + if literal: + original_literal = hex_string + else: + original_literal = None + + hex_string = hex_string[1:] + + # Always include the alpha channel + if len(hex_string) == 3: + hex_string += 'f' + elif len(hex_string) == 6: + hex_string += 'ff' + + # Now there should be only two possibilities. Normalize to a list of + # two hex digits + if len(hex_string) == 4: + chunks = [ch * 2 for ch in hex_string] + elif len(hex_string) == 8: + chunks = [ + hex_string[0:2], hex_string[2:4], hex_string[4:6], hex_string[6:8] + ] + + rgba = [int(ch, 16) / 255 for ch in chunks] + return cls.from_rgb(*rgba, original_literal=original_literal) + + @classmethod + def from_name(cls, name): + """Build a Color from a CSS color name.""" + self = cls.__new__(cls) # TODO + self.original_literal = name + + r, g, b, a = COLOR_NAMES[name] + + self.value = r, g, b, a + return self + + ### Accessors + + @property + def rgb(self): + # TODO: deprecate, relies on internals + return tuple(self.value[:3]) + + @property + def rgba(self): + return ( + self.value[0] / 255, + self.value[1] / 255, + self.value[2] / 255, + self.value[3], + ) + + @property + def hsl(self): + rgba = self.rgba + h, l, s = colorsys.rgb_to_hls(*rgba[:3]) + return h, s, l + + @property + def alpha(self): + return self.value[3] + + @property + def rgba255(self): + return ( + int(self.value[0] * 1 + 0.5), + int(self.value[1] * 1 + 0.5), + int(self.value[2] * 1 + 0.5), + int(self.value[3] * 255 + 0.5), + ) + + def __repr__(self): + return '<%s(%s)>' % (self.__class__.__name__, repr(self.value)) + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other): + if not isinstance(other, Color): + return Boolean(False) + + # Scale channels to 255 and round to integers; this allows only 8-bit + # color, but Ruby sass makes the same assumption, and otherwise it's + # easy to get lots of float errors for HSL colors. + left = tuple(round(n) for n in self.rgba255) + right = tuple(round(n) for n in other.rgba255) + return Boolean(left == right) + + def __add__(self, other): + if isinstance(other, (Color, Number)): + return self._operate(other, operator.add) + else: + return super(Color, self).__add__(other) + + def __sub__(self, other): + if isinstance(other, (Color, Number)): + return self._operate(other, operator.sub) + else: + return super(Color, self).__sub__(other) + + def __mul__(self, other): + if isinstance(other, (Color, Number)): + return self._operate(other, operator.mul) + else: + return super(Color, self).__mul__(other) + + def __div__(self, other): + if isinstance(other, (Color, Number)): + return self._operate(other, operator.div) + else: + return super(Color, self).__div__(other) + + def _operate(self, other, op): + if isinstance(other, Number): + if not other.is_unitless: + raise ValueError("Expected unitless Number, got %r" % (other,)) + + other_rgb = (other.value,) * 3 + elif isinstance(other, Color): + if self.alpha != other.alpha: + raise ValueError("Alpha channels must match between %r and %r" + % (self, other)) + + other_rgb = other.rgb + else: + raise TypeError("Expected Color or Number, got %r" % (other,)) + + new_rgb = [ + min(255., max(0., op(left, right))) + # for from_rgb + / 255. + for (left, right) in zip(self.rgb, other_rgb) + ] + + return Color.from_rgb(*new_rgb, alpha=self.alpha) + + def render(self, compress=False): + """Return a rendered representation of the color. If `compress` is + true, the shortest possible representation is used; otherwise, named + colors are rendered as names and all others are rendered as hex (or + with the rgba function). + """ + + if not compress and self.original_literal: + return self.original_literal + + candidates = [] + + # TODO this assumes CSS resolution is 8-bit per channel, but so does + # Ruby. + r, g, b, a = self.value + r, g, b = int(round(r)), int(round(g)), int(round(b)) + + # Build a candidate list in order of preference. If `compress` is + # True, the shortest candidate is used; otherwise, the first candidate + # is used. + + # Try color name + key = r, g, b, a + if key in COLOR_LOOKUP: + candidates.append(COLOR_LOOKUP[key]) + + if a == 1: + # Hex is always shorter than function notation + if all(ch % 17 == 0 for ch in (r, g, b)): + candidates.append("#%1x%1x%1x" % (r // 17, g // 17, b // 17)) + else: + candidates.append("#%02x%02x%02x" % (r, g, b)) + else: + # Can't use hex notation for RGBA + if compress: + sp = '' + else: + sp = ' ' + candidates.append("rgba(%d,%s%d,%s%d,%s%.2g)" % (r, sp, g, sp, b, sp, a)) + + if compress: + return min(candidates, key=len) + else: + return candidates[0] + + +# TODO be unicode-clean and delete this nonsense +DEFAULT_STRING_ENCODING = "utf8" + + +class String(Value): + """Represents both CSS quoted string values and CSS identifiers (such as + `left`). + + Makes no distinction between single and double quotes, except that the same + quotes are preserved on string literals that pass through unmodified. + Otherwise, double quotes are used. + """ + + sass_type_name = u'string' + + def __init__(self, value, quotes='"'): + if isinstance(value, String): + # TODO unclear if this should be here, but many functions rely on + # it + value = value.value + elif isinstance(value, Number): + # TODO this may only be necessary in the case of __radd__ and + # number values + value = str(value) + + if isinstance(value, six.binary_type): + value = value.decode(DEFAULT_STRING_ENCODING) + + if not isinstance(value, six.text_type): + raise TypeError("Expected string, got {0!r}".format(value)) + + # TODO probably disallow creating an unquoted string outside a + # set of chars like [-a-zA-Z0-9]+ + + if six.PY3: + self.value = value + else: + # TODO well, at least 3 uses unicode everywhere + self.value = value.encode(DEFAULT_STRING_ENCODING) + self.quotes = quotes + + @classmethod + def unquoted(cls, value): + """Helper to create a string with no quotes.""" + return cls(value, quotes=None) + + def __hash__(self): + return hash(self.value) + + def __str__(self): + if self.quotes: + return self.quotes + escape(self.value) + self.quotes + else: + return self.value + + def __repr__(self): + if self.quotes != '"': + quotes = ', quotes=%r' % self.quotes + else: + quotes = '' + return '<%s(%s%s)>' % (self.__class__.__name__, repr(self.value), quotes) + + def __eq__(self, other): + return Boolean(isinstance(other, String) and self.value == other.value) + + def __add__(self, other): + if isinstance(other, String): + other_value = other.value + else: + other_value = other.render() + + return String( + self.value + other_value, + quotes='"' if self.quotes else None) + + def __mul__(self, other): + # DEVIATION: Ruby Sass doesn't do this, because Ruby doesn't. But + # Python does, and in Ruby Sass it's just fatal anyway. + if not isinstance(other, Number): + return super(String, self).__mul__(other) + + if not other.is_unitless: + raise TypeError("Can only multiply strings by unitless numbers") + + n = other.value + if n != int(n): + raise ValueError("Can only multiply strings by integers") + + return String(self.value * int(other.value), quotes=self.quotes) + + def render(self, compress=False): + return self.__str__() + + +class Map(Value): + sass_type_name = u'map' + + def __init__(self, pairs, index=None): + self.pairs = tuple(pairs) + + if index is None: + self.index = {} + for key, value in pairs: + self.index[key] = value + else: + self.index = index + + def __repr__(self): + return "" % (", ".join("%s: %s" % pair for pair in self.pairs),) + + def __hash__(self): + return hash(self.pairs) + + def __len__(self): + return len(self.pairs) + + def __iter__(self): + return iter(self.pairs) + + def __getitem__(self, index): + return List(self.pairs[index], use_comma=True) + + def __eq__(self, other): + try: + return self.pairs == other.to_pairs() + except ValueError: + return NotImplemented + + def to_dict(self): + return self.index + + def to_pairs(self): + return self.pairs + + def render(self, compress=False): + raise TypeError("Cannot render map %r as CSS" % (self,)) + + +def expect_type(value, types, unit=any): + if not isinstance(value, types): + if isinstance(types, type): + types = (type,) + sass_type_names = list(set(t.sass_type_name for t in types)) + sass_type_names.sort() + + # Join with commas in English fashion + if len(sass_type_names) == 1: + sass_type = sass_type_names[0] + elif len(sass_type_names) == 2: + sass_type = u' or '.join(sass_type_names) + else: + sass_type = u', '.join(sass_type_names[:-1]) + sass_type += u', or ' + sass_type_names[-1] + + raise TypeError("Expected %s, got %r" % (sass_type, value)) + + if unit is not any and isinstance(value, Number): + if unit is None and not value.is_unitless: + raise ValueError("Expected unitless number, got %r" % (value,)) + + elif unit == '%' and not ( + value.is_unitless or value.is_simple_unit('%')): + raise ValueError("Expected unitless number or percentage, got %r" % (value,)) diff --git a/libs/scss/util.py b/libs/scss/util.py new file mode 100644 index 00000000..0bcb13eb --- /dev/null +++ b/libs/scss/util.py @@ -0,0 +1,146 @@ +from __future__ import absolute_import +from __future__ import print_function + +import re +import sys +import time + +import six + +from scss import config + + +def split_params(params): + params = params.split(',') or [] + if params: + final_params = [] + param = params.pop(0) + try: + while True: + while param.count('(') != param.count(')'): + try: + param = param + ',' + params.pop(0) + except IndexError: + break + final_params.append(param) + param = params.pop(0) + except IndexError: + pass + params = final_params + return params + + +def dequote(s): + if s and s[0] in ('"', "'") and s[-1] == s[0]: + s = s[1:-1] + s = unescape(s) + return s + + +def depar(s): + while s and s[0] == '(' and s[-1] == ')': + s = s[1:-1] + return s + + +def to_str(num): + try: + render = num.render + except AttributeError: + pass + else: + return render() + + if isinstance(num, dict): + s = sorted(num.items()) + sp = num.get('_', '') + return (sp + ' ').join(to_str(v) for n, v in s if n != '_') + elif isinstance(num, float): + num = ('%0.05f' % round(num, 5)).rstrip('0').rstrip('.') + return num + elif isinstance(num, bool): + return 'true' if num else 'false' + elif num is None: + return '' + return str(num) + + +def to_float(num): + if isinstance(num, (float, int)): + return float(num) + num = to_str(num) + if num and num[-1] == '%': + return float(num[:-1]) / 100.0 + else: + return float(num) + + +def escape(s): + return re.sub(r'''(["'])''', r'\\\1', s) + + +def unescape(s): + return re.sub(r'''\\(['"])''', r'\1', s) + + +def normalize_var(var): + """Sass defines `foo_bar` and `foo-bar` as being identical, both in + variable names and functions/mixins. This normalizes everything to use + dashes. + """ + return var.replace('_', '-') + + +################################################################################ +# Function timing decorator +profiling = {} + + +def print_timing(level=0): + def _print_timing(func): + if config.VERBOSITY: + def wrapper(*args, **kwargs): + if config.VERBOSITY >= level: + t1 = time.time() + res = func(*args, **kwargs) + t2 = time.time() + profiling.setdefault(func.func_name, 0) + profiling[func.func_name] += (t2 - t1) + return res + else: + return func(*args, **kwargs) + return wrapper + else: + return func + return _print_timing + + +################################################################################ +# Profiler decorator +def profile(fn): + import cProfile + import pstats + def wrapper(*args, **kwargs): + profiler = cProfile.Profile() + stream = six.StringIO() + profiler.enable() + try: + res = fn(*args, **kwargs) + finally: + profiler.disable() + stats = pstats.Stats(profiler, stream=stream) + stats.sort_stats('time') + print >>stream, "" + print >>stream, "=" * 100 + print >>stream, "Stats:" + stats.print_stats() + print >>stream, "=" * 100 + print >>stream, "Callers:" + stats.print_callers() + print >>stream, "=" * 100 + print >>stream, "Callees:" + stats.print_callees() + print >>sys.stderr, stream.getvalue() + stream.close() + return res + return wrapper