From 11e2b44c0e7992db0e339f4dd53fe93a16710802 Mon Sep 17 00:00:00 2001 From: "Eevee (Alex Munroe)" Date: Wed, 27 Aug 2014 14:56:41 -0700 Subject: Introduce an Extension class, finally. Making a stab at #130. All the core functions have been moved into a core Extension, and Namespace has been beefed up a little bit so declaring functions is easy. --- scss/compiler.py | 75 +++- scss/core.py | 808 ++++++++++++++++++++++++++++++++++++++ scss/extension.py | 40 ++ scss/functions/__init__.py | 14 +- scss/functions/core.py | 799 ------------------------------------- scss/legacy.py | 8 +- scss/namespace.py | 37 ++ scss/tests/functions/test_core.py | 6 +- scss/tests/test_expression.py | 7 +- 9 files changed, 951 insertions(+), 843 deletions(-) create mode 100644 scss/core.py create mode 100644 scss/extension.py delete mode 100644 scss/functions/core.py diff --git a/scss/compiler.py b/scss/compiler.py index fa25e7e..d7f43c7 100644 --- a/scss/compiler.py +++ b/scss/compiler.py @@ -16,12 +16,15 @@ import warnings import six import scss.config as config +from scss.core import CoreExtension from scss.cssdefs import _spaces_re from scss.cssdefs import _escape_chars_re from scss.cssdefs import _prop_split_re from scss.errors import SassError from scss.expression import Calculator -from scss.functions import ALL_BUILTINS_LIBRARY +from scss.extension import Extension +from scss.extension import NamespaceAdapterExtension +from scss.functions import COMPASS_LIBRARY from scss.functions.compass.sprites import sprite_map from scss.rule import BlockAtRuleHeader from scss.rule import Namespace @@ -50,16 +53,10 @@ except ImportError: # TODO should mention logging for the programmatic interface in the # documentation +# TODO or have a little helper (or compiler setting) to turn it on log = logging.getLogger(__name__) -# TODO produce this -# TODO is this a Library (can't define values), a Namespace (lot of -# semi-private functionality, no support for magic imports), or an Extension -# (doesn't exist yet)? -DEFAULT_NAMESPACE = Namespace(functions=ALL_BUILTINS_LIBRARY) - - _default_rule_re = re.compile(r'(?i)\s+!default\Z') _xcss_extends_re = re.compile(r'\s+extends\s+') @@ -87,14 +84,17 @@ def warn_deprecated(rule, message): ) +# TODO it's probably still kind of weird to go Compiler().compile('a/b/c') and +# not have stuff in a/b/ importable. maybe need a top-level compile_sass() +# function that takes a single file? (compile_file? compile_string?) class Compiler(object): """A Sass compiler. Stores settings and knows how to fire off a compilation. Main entry point into compiling Sass. """ def __init__( self, root='', search_path=('',), - namespace=DEFAULT_NAMESPACE, output_style='nested', - generate_source_map=False, + namespace=None, extensions=(CoreExtension, Namespace(functions=COMPASS_LIBRARY)), + output_style='nested', generate_source_map=False, live_errors=False, warn_unused_imports=False, super_selector='', ): @@ -107,18 +107,36 @@ class Compiler(object): to ``root``. Absolute and parent paths are allowed here, but ``@import`` will refuse to load files that aren't in one of the directories here. Defaults to only the root. - :param namespace: Global namespace to inject into compiled Sass. See - the Namespace documentation for details. - :type namespace: :class:`Namespace` """ - # normpath() will (textually) eliminate any use of .. - self.root = os.path.normpath(os.path.abspath(root)) + if root is None: + self.root = None + else: + # normpath() will (textually) eliminate any use of .. + self.root = os.path.normpath(os.path.abspath(root)) + self.search_path = tuple( - os.path.normpath(os.path.join(self.root, path)) + self.normalize_path(path) for path in search_path ) - self.namespace = namespace + self.extensions = [] + if namespace is not None: + self.extensions.append(NamespaceAdapterExtension(namespace)) + for extension in extensions: + if isinstance(extension, Extension): + self.extensions.append(extension) + elif (isinstance(extension, type) and + issubclass(extension, Extension)): + self.extensions.append(extension()) + elif isinstance(extension, Namespace): + self.extensions.append( + NamespaceAdapterExtension(extension)) + else: + raise TypeError( + "Expected an Extension or Namespace, got: {0!r}" + .format(extension) + ) + self.output_style = output_style self.generate_source_map = generate_source_map self.live_errors = live_errors @@ -127,6 +145,15 @@ class Compiler(object): self.calculator = Calculator() + def normalize_path(self, path): + if self.root is None: + if not os.path.isabs(path): + raise IOError("Can't make absolute path when root is None") + else: + path = os.path.join(self.root, path) + + return os.path.normpath(path) + def make_compilation(self): return Compilation(self) @@ -145,8 +172,11 @@ class Compiler(object): else: raise - def compile(self): + def compile(self, *filenames): compilation = self.make_compilation() + for filename in filenames: + source = SourceFile.from_filename(self.normalize_path(filename)) + compilation.add_source(source) return self.call_and_catch_errors(compilation.run) @@ -156,8 +186,13 @@ class Compilation(object): self.compiler = compiler # TODO this needs a write barrier, so assignment can't overwrite what's - # in the original namespace - self.root_namespace = compiler.namespace + # in the original namespaces + # TODO or maybe the extensions themselves should take care of that, so + # it IS possible to overwrite from within sass, but only per-instance? + self.root_namespace = Namespace.derive_from(*( + ext.namespace for ext in compiler.extensions + if ext.namespace + )) self.sources = [] self.source_index = {} diff --git a/scss/core.py b/scss/core.py new file mode 100644 index 0000000..1f33514 --- /dev/null +++ b/scss/core.py @@ -0,0 +1,808 @@ +"""Extension for built-in Sass functionality.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import math + +from six.moves import xrange + +from scss.extension import Extension +from scss.namespace import Namespace +from scss.types import ( + Arglist, Boolean, Color, List, Null, Number, String, Map, expect_type) + + +class CoreExtension(Extension): + name = 'core' + namespace = Namespace() + + +# Alias to make the below declarations less noisy +ns = CoreExtension.namespace + + +# ------------------------------------------------------------------------------ +# 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 + + +@ns.declare +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) + + +@ns.declare +def rgb(r, g, b, type='rgb'): + return rgba(r, g, b, Number(1.0)) + + +@ns.declare +def rgba_(color, a=None): + if a is None: + alpha = 1 + else: + alpha = _interpret_percentage(a) + + return Color.from_rgb(*color.rgba[:3], alpha=alpha) + + +@ns.declare +def rgb_(color): + return rgba2(color, a=Number(1)) + + +@ns.declare +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, + ) + + +@ns.declare +def hsl(h, s, l): + return hsla(h, s, l, Number(1)) + + +@ns.declare +def hsla_(color, a=None): + return rgba2(color, a) + + +@ns.declare +def hsl_(color): + return rgba2(color, a=Number(1)) + + +@ns.declare +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 + +@ns.declare +def red(color): + r, g, b, a = color.rgba + return Number(r * 255) + + +@ns.declare +def green(color): + r, g, b, a = color.rgba + return Number(g * 255) + + +@ns.declare +def blue(color): + r, g, b, a = color.rgba + return Number(b * 255) + + +@ns.declare_alias('opacity') +@ns.declare +def alpha(color): + return Number(color.alpha) + + +@ns.declare +def hue(color): + h, s, l = color.hsl + return Number(h * 360, "deg") + + +@ns.declare +def saturation(color): + h, s, l = color.hsl + return Number(s * 100, "%") + + +@ns.declare +def lightness(color): + h, s, l = color.hsl + return Number(l * 100, "%") + + +@ns.declare +def ie_hex_str(color): + c = Color(color).value + return String("#{3:02X}{0:02X}{1:02X}{2:02X}".format( + int(round(c[0])), + int(round(c[1])), + int(round(c[2])), + int(round(c[3] * 255)), + )) + + +# ------------------------------------------------------------------------------ +# Color modification + +@ns.declare_alias('fade-in') +@ns.declare_alias('fadein') +@ns.declare +def opacify(color, amount): + r, g, b, a = color.rgba + if amount.is_simple_unit('%'): + amt = amount.value / 100 + else: + amt = amount.value + return Color.from_rgb( + r, g, b, + alpha=a + amt) + + +@ns.declare_alias('fade-out') +@ns.declare_alias('fadeout') +@ns.declare +def transparentize(color, amount): + r, g, b, a = color.rgba + if amount.is_simple_unit('%'): + amt = amount.value / 100 + else: + amt = amount.value + return Color.from_rgb( + r, g, b, + alpha=a - amt) + + +@ns.declare +def lighten(color, amount): + return adjust_color(color, lightness=amount) + + +@ns.declare +def darken(color, amount): + return adjust_color(color, lightness=-amount) + + +@ns.declare +def saturate(color, amount): + return adjust_color(color, saturation=amount) + + +@ns.declare +def desaturate(color, amount): + return adjust_color(color, saturation=-amount) + + +@ns.declare +def greyscale(color): + h, s, l = color.hsl + return Color.from_hsl(h, 0, l, alpha=color.alpha) + + +@ns.declare +def grayscale(color): + if isinstance(color, Number): + # grayscale(n) and grayscale(n%) are CSS3 filters and should be left + # intact, but only when using the "a" spelling + return String.unquoted("grayscale(%s)" % (color.render(),)) + else: + return greyscale(color) + + +@ns.declare_alias('spin') +@ns.declare +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) + + +@ns.declare +def complement(color): + h, s, l = color.hsl + return Color.from_hsl((h + 0.5) % 1, s, l, alpha=color.alpha) + + +@ns.declare +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) + + +@ns.declare +def adjust_lightness(color, amount): + return adjust_color(color, lightness=amount) + + +@ns.declare +def adjust_saturation(color, amount): + return adjust_color(color, saturation=amount) + + +@ns.declare +def scale_lightness(color, amount): + return scale_color(color, lightness=amount) + + +@ns.declare +def scale_saturation(color, amount): + return scale_color(color, saturation=amount) + + +@ns.declare +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) + + +@ns.declare +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) + + +@ns.declare +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 + +@ns.declare_alias('e') +@ns.declare_alias('escape') +@ns.declare +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) + + +@ns.declare +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='"') + + +@ns.declare +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 +@ns.declare +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) + + +@ns.declare +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) + + +@ns.declare +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) + + +@ns.declare +def to_upper_case(string): + expect_type(string, String) + + return String(string.value.upper(), quotes=string.quotes) + + +@ns.declare +def to_lower_case(string): + expect_type(string, String) + + return String(string.value.lower(), quotes=string.quotes) + + +# ------------------------------------------------------------------------------ +# Number functions + +@ns.declare +def percentage(value): + expect_type(value, Number, unit=None) + return value * Number(100, unit='%') + + +ns.set_function('abs', 1, Number.wrap_python_function(abs)) +ns.set_function('round', 1, Number.wrap_python_function(round)) +ns.set_function('ceil', 1, Number.wrap_python_function(math.ceil)) +ns.set_function('floor', 1, Number.wrap_python_function(math.floor)) + + +# ------------------------------------------------------------------------------ +# 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 +@ns.declare_alias('-compass-list-size') +@ns.declare +def length(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return Number(len(lst)) + + +@ns.declare +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 +@ns.declare_alias('-compass-nth') +@ns.declare +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] + + +@ns.declare +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) + + +@ns.declare +def min_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return min(lst) + + +@ns.declare +def max_(*lst): + if len(lst) == 1 and isinstance(lst[0], (list, tuple, List)): + lst = lst[0] + return max(lst) + + +@ns.declare +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) + + +@ns.declare +def index(lst, val): + for i in xrange(len(lst)): + if lst.value[i] == val: + return Number(i + 1) + return Boolean(False) + + +@ns.declare +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 +@ns.declare +def list_separator(list): + if list.use_comma: + return String.unquoted('comma') + else: + return String.unquoted('space') + + +# ------------------------------------------------------------------------------ +# Map functions + +@ns.declare +def map_get(map, key): + return map.to_dict().get(key, Null()) + + +@ns.declare +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) + + +@ns.declare +def map_keys(map): + return List( + [k for (k, v) in map.to_pairs()], + use_comma=True) + + +@ns.declare +def map_values(map): + return List( + [v for (k, v) in map.to_pairs()], + use_comma=True) + + +@ns.declare +def map_has_key(map, key): + return Boolean(key in map.to_dict()) + + +# DEVIATIONS: these do not exist in ruby sass + +@ns.declare +def map_get3(map, key, default): + return map.to_dict().get(key, default) + + +@ns.declare +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 + + +@ns.declare +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 + +@ns.declare +def type_of(obj): # -> bool, number, string, color, list + return String(obj.sass_type_name) + + +@ns.declare +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) + + +@ns.declare +def unitless(value): + if not isinstance(value, Number): + raise TypeError("Expected number, got %r" % (value,)) + + return Boolean(value.is_unitless) + + +@ns.declare +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) + + +@ns.declare +def keywords(value): + """Extract named arguments, as a map, from an argument list.""" + expect_type(value, Arglist) + return value.extract_keywords() + + +# ------------------------------------------------------------------------------ +# Miscellaneous + +@ns.declare +def if_(condition, if_true, if_false=Null()): + return if_true if condition else if_false diff --git a/scss/extension.py b/scss/extension.py new file mode 100644 index 0000000..396b296 --- /dev/null +++ b/scss/extension.py @@ -0,0 +1,40 @@ +"""Support for extending the Sass compiler.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + + +class Extension(object): + """An extension to the Sass compile process. Subclass to add your own + behavior. + + Methods are hooks, called by the compiler at certain points. Each + extension is considered in the order it's provided. + """ + + # TODO unsure how this could work given that we'd have to load modules for + # it to be available + name = None + """A unique name for this extension, which will allow it to be referenced + from the command line. + """ + + namespace = None + """An optional :class:`scss.namespace.Namespace` that will be injected into + the compiler. + """ + + def __init__(self): + pass + + def before_import(self): + pass + + +class NamespaceAdapterExtension(Extension): + """Trivial wrapper that adapts a bare :class:`scss.namespace.Namespace` + into a full extension. + """ + + def __init__(self, namespace): + self.namespace = namespace diff --git a/scss/functions/__init__.py b/scss/functions/__init__.py index 484b8bf..ab6fdd2 100644 --- a/scss/functions/__init__.py +++ b/scss/functions/__init__.py @@ -5,6 +5,7 @@ from scss.functions.library import FunctionLibrary from scss.functions.core import CORE_LIBRARY from scss.functions.extra import EXTRA_LIBRARY from scss.functions.fonts import FONTS_LIBRARY +from scss.functions.compass.configuration import ns 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 @@ -12,18 +13,11 @@ from scss.functions.compass.images import COMPASS_IMAGES_LIBRARY from scss.functions.bootstrap import BOOTSTRAP_LIBRARY -ALL_BUILTINS_LIBRARY = FunctionLibrary() -ALL_BUILTINS_LIBRARY.inherit( - CORE_LIBRARY, - EXTRA_LIBRARY, - FONTS_LIBRARY, +COMPASS_LIBRARY = FunctionLibrary() +COMPASS_LIBRARY.inherit( COMPASS_GRADIENTS_LIBRARY, COMPASS_HELPERS_LIBRARY, COMPASS_IMAGES_LIBRARY, COMPASS_SPRITES_LIBRARY, - BOOTSTRAP_LIBRARY, + ns, ) - -# TODO back-compat for the only codebase still using the old name :) -FunctionRegistry = FunctionLibrary -scss_builtins = ALL_BUILTINS_LIBRARY diff --git a/scss/functions/core.py b/scss/functions/core.py deleted file mode 100644 index 1fc830b..0000000 --- a/scss/functions/core.py +++ /dev/null @@ -1,799 +0,0 @@ -"""Functions from the Sass "standard library", i.e., built into the original -Ruby implementation. -""" -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - -import logging -import math - -from six.moves import xrange - -from scss.functions.library import FunctionLibrary -from scss.types import Arglist, 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('#%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 - if amount.is_simple_unit('%'): - amt = amount.value / 100 - else: - amt = amount.value - return Color.from_rgb( - r, g, b, - alpha=a + amt) - - -@register('fade-out', 2) -@register('fadeout', 2) -@register('transparentize', 2) -def transparentize(color, amount): - r, g, b, a = color.rgba - if amount.is_simple_unit('%'): - amt = amount.value / 100 - else: - amt = amount.value - return Color.from_rgb( - r, g, b, - alpha=a - amt) - - -@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): - # grayscale(n) and grayscale(n%) are CSS3 filters and should be left intact, but only - # when using the "a" spelling - return String.unquoted("grayscale(%s)" % (color.render(),)) - 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) - - -@register('keywords', 1) -def keywords(value): - """Extract named arguments, as a map, from an argument list.""" - expect_type(value, Arglist) - return value.extract_keywords() - - -# ------------------------------------------------------------------------------ -# 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/scss/legacy.py b/scss/legacy.py index 095d2ce..12e225d 100644 --- a/scss/legacy.py +++ b/scss/legacy.py @@ -7,7 +7,6 @@ import six from scss.compiler import Compiler import scss.config as config -from scss.functions import ALL_BUILTINS_LIBRARY from scss.expression import Calculator from scss.namespace import Namespace from scss.scss_meta import ( @@ -49,7 +48,7 @@ class Scss(object): def __init__( self, scss_vars=None, scss_opts=None, scss_files=None, super_selector='', live_errors=False, - library=ALL_BUILTINS_LIBRARY, func_registry=None, + library=None, search_paths=None): self.super_selector = super_selector @@ -69,9 +68,7 @@ class Scss(object): self._scss_opts = scss_opts or {} 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._library = library self._search_paths = search_paths # If true, swallow compile errors and embed them in the output instead @@ -92,7 +89,6 @@ class Scss(object): root_namespace = Namespace( variables=self.scss_vars, - functions=self._library, ) # Figure out search paths. Fall back from provided explicitly to diff --git a/scss/namespace.py b/scss/namespace.py index f469c46..6c0ae14 100644 --- a/scss/namespace.py +++ b/scss/namespace.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from __future__ import division from __future__ import print_function +import inspect import logging import six @@ -145,6 +146,42 @@ class Namespace(object): """ return type(self).derive_from(self) + def declare(self, function): + """Insert a Python function into this Namespace, detecting its name and + argument count automatically. + """ + self._auto_register_function(function, function.__name__) + return function + + def declare_alias(self, name): + """Insert a Python function into this Namespace with an + explicitly-given name, but detect its argument count automatically. + """ + def decorator(f): + self._auto_register_function(f, name) + return f + + return decorator + + def _auto_register_function(self, function, name): + name = name.replace('_', '-').rstrip('-') + argspec = inspect.getargspec(function) + + if argspec.varargs or argspec.keywords: + # Accepts some arbitrary number of arguments + arities = [None] + else: + # Accepts a fixed range of arguments + if argspec.defaults: + num_optional = len(argspec.defaults) + else: + num_optional = 0 + num_args = len(argspec.args) + arities = range(num_args - num_optional, num_args + 1) + + for arity in arities: + self.set_function(name, arity, function) + @property def variables(self): return dict((k, self._variables[k]) for k in self._variables.keys()) diff --git a/scss/tests/functions/test_core.py b/scss/tests/functions/test_core.py index a16b447..1d7c64d 100644 --- a/scss/tests/functions/test_core.py +++ b/scss/tests/functions/test_core.py @@ -7,9 +7,8 @@ from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals +from scss.core import CoreExtension from scss.expression import Calculator -from scss.functions.core import CORE_LIBRARY -from scss.rule import Namespace from scss.types import Color, Number, String import pytest @@ -20,8 +19,7 @@ xfail = pytest.mark.xfail @pytest.fixture def calc(): - ns = Namespace(functions=CORE_LIBRARY) - return Calculator(ns).evaluate_expression + return Calculator(CoreExtension.namespace).evaluate_expression # ------------------------------------------------------------------------------ diff --git a/scss/tests/test_expression.py b/scss/tests/test_expression.py index 8c7a82a..0c607da 100644 --- a/scss/tests/test_expression.py +++ b/scss/tests/test_expression.py @@ -4,9 +4,9 @@ contains a variety of expression-related tests. from __future__ import absolute_import from __future__ import unicode_literals +from scss.core import CoreExtension from scss.errors import SassEvaluationError from scss.expression import Calculator -from scss.functions.core import CORE_LIBRARY from scss.rule import Namespace from scss.types import Color, List, Null, Number, String @@ -33,7 +33,7 @@ def test_reference_operations(): # Need to build the calculator manually to get at its namespace, and need # to use calculate() instead of evaluate_expression() so interpolation # works - ns = Namespace(functions=CORE_LIBRARY) + ns = CoreExtension.namespace.derive() calc = Calculator(ns).calculate # Simple example @@ -81,8 +81,7 @@ def test_parse(calc): def test_functions(calc): - ns = Namespace(functions=CORE_LIBRARY) - calc = Calculator(ns).calculate + calc = Calculator(CoreExtension.namespace).calculate assert calc('grayscale(red)') == Color.from_rgb(0.5, 0.5, 0.5) assert calc('grayscale(1)') == String('grayscale(1)', quotes=None) # Misusing css built-in functions (with scss counterpart) -- cgit v1.2.1