diff --git a/Makefile b/Makefile index d203291a..6771022c 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ build2/libsass/cpp/%.o: libsass/src/%.cpp build2/pysass.o: pysass.cpp @mkdir -p build2 - gcc -pthread -fno-strict-aliasing -DNDEBUG -g -fwrapv -O2 -Wall -Wstrict-prototypes -fPIC -I./libsass/include $(PY_HEADERS) -c $^ -o $@ -c -O2 -fPIC -std=c++0x -Wall -Wno-parentheses + gcc -pthread -fno-strict-aliasing -Wno-write-strings -DNDEBUG -g -fwrapv -O2 -Wall -fPIC -I./libsass/include $(PY_HEADERS) -c $^ -o $@ -c -O2 -fPIC -std=c++0x -Wall -Wno-parentheses _sass.so: $(C_OBJECTS) $(CPP_OBJECTS) build2/pysass.o g++ -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions -Wl,-Bsymbolic-functions -Wl,-z,relro $^ -L./libsass -o $@ -fPIC -lstdc++ diff --git a/pysass.cpp b/pysass.cpp index 89484b75..7ecdde3a 100644 --- a/pysass.cpp +++ b/pysass.cpp @@ -272,8 +272,8 @@ static union Sass_Value* _unknown_type_to_sass_error(PyObject* value) { return retv; } -static union Sass_Value* _exception_to_sass_error() { - union Sass_Value* retv = NULL; +static PyObject* _exception_to_bytes() { + PyObject* retv = NULL; PyObject* etype = NULL; PyObject* evalue = NULL; PyObject* etb = NULL; @@ -287,15 +287,11 @@ static union Sass_Value* _exception_to_sass_error() { PyList_Insert(traceback_parts, 0, PyUnicode_FromString("\n")); PyObject* joinstr = PyUnicode_FromString(""); PyObject* result = PyUnicode_Join(joinstr, traceback_parts); - PyObject* bytes = PyUnicode_AsEncodedString( - result, "UTF-8", "strict" - ); - retv = sass_make_error(PySass_Bytes_AS_STRING(bytes)); + retv = PyUnicode_AsEncodedString(result, "UTF-8", "strict"); Py_DECREF(traceback_mod); Py_DECREF(traceback_parts); Py_DECREF(joinstr); Py_DECREF(result); - Py_DECREF(bytes); } Py_DECREF(etype); Py_DECREF(evalue); @@ -303,6 +299,22 @@ static union Sass_Value* _exception_to_sass_error() { return retv; } +static union Sass_Value* _exception_to_sass_error() { + PyObject* bytes = _exception_to_bytes(); + union Sass_Value* retv = sass_make_error(PySass_Bytes_AS_STRING(bytes)); + Py_DECREF(bytes); + return retv; +} + +static Sass_Import_List _exception_to_sass_import_error(const char* path) { + PyObject* bytes = _exception_to_bytes(); + Sass_Import_List import_list = sass_make_import_list(1); + import_list[0] = sass_make_import_entry(path, 0, 0); + sass_import_set_error(import_list[0], PySass_Bytes_AS_STRING(bytes), 0, 0); + Py_DECREF(bytes); + return import_list; +} + static union Sass_Value* _to_sass_value(PyObject* value) { union Sass_Value* retv = NULL; PyObject* types_mod = PyImport_ImportModule("sass"); @@ -404,6 +416,96 @@ static void _add_custom_functions( sass_option_set_c_functions(options, fn_list); } +static Sass_Import_List _call_py_importer_f( + const char* path, Sass_Importer_Entry cb, struct Sass_Compiler* comp +) { + PyObject* pyfunc = (PyObject*)sass_importer_get_cookie(cb); + PyObject* py_result = NULL; + Sass_Import_List sass_imports = NULL; + Py_ssize_t i; + + py_result = PyObject_CallFunction(pyfunc, PySass_IF_PY3("y", "s"), path); + + /* Handle importer throwing an exception */ + if (!py_result) goto done; + + /* Could return None indicating it could not handle the import */ + if (py_result == Py_None) { + Py_XDECREF(py_result); + return NULL; + } + + /* Otherwise, we know our importer is well formed (because we wrap it) + * The return value will be a tuple of 1, 2, or 3 tuples */ + sass_imports = sass_make_import_list(PyTuple_GET_SIZE(py_result)); + for (i = 0; i < PyTuple_GET_SIZE(py_result); i += 1) { + char* path_str = NULL; /* XXX: Memory leak? */ + char* source_str = NULL; + char* sourcemap_str = NULL; + PyObject* tup = PyTuple_GET_ITEM(py_result, i); + Py_ssize_t size = PyTuple_GET_SIZE(tup); + + if (size == 1) { + PyArg_ParseTuple(tup, PySass_IF_PY3("y", "s"), &path_str); + } else if (size == 2) { + PyArg_ParseTuple( + tup, PySass_IF_PY3("yy", "ss"), &path_str, &source_str + ); + } else if (size == 3) { + PyArg_ParseTuple( + tup, PySass_IF_PY3("yyy", "sss"), + &path_str, &source_str, &sourcemap_str + ); + } + + /* We need to give copies of these arguments; libsass handles + * deallocation of them later, whereas path_str is left flapping + * in the breeze -- it's treated const, so that's okay. */ + if (source_str) source_str = strdup(source_str); + if (sourcemap_str) sourcemap_str = strdup(sourcemap_str); + + sass_imports[i] = sass_make_import_entry( + path_str, source_str, sourcemap_str + ); + } + +done: + if (sass_imports == NULL) { + sass_imports = _exception_to_sass_import_error(path); + } + + Py_XDECREF(py_result); + + return sass_imports; +} + +static void _add_custom_importers( + struct Sass_Options* options, PyObject* custom_importers +) { + Py_ssize_t i; + Sass_Importer_List importer_list; + + if (custom_importers == Py_None) { + return; + } + + importer_list = sass_make_importer_list(PyTuple_GET_SIZE(custom_importers)); + + for (i = 0; i < PyTuple_GET_SIZE(custom_importers); i += 1) { + PyObject* item = PyTuple_GET_ITEM(custom_importers, i); + int priority = 0; + PyObject* import_function = NULL; + + PyArg_ParseTuple(item, "iO", &priority, &import_function); + + importer_list[i] = sass_make_importer( + _call_py_importer_f, priority, import_function + ); + } + + sass_option_set_c_importers(options, importer_list); +} + static PyObject * PySass_compile_string(PyObject *self, PyObject *args) { struct Sass_Context *ctx; @@ -414,13 +516,14 @@ PySass_compile_string(PyObject *self, PyObject *args) { Sass_Output_Style output_style; int source_comments, error_status, precision, indented; PyObject *custom_functions; + PyObject *custom_importers; PyObject *result; if (!PyArg_ParseTuple(args, - PySass_IF_PY3("yiiyiOi", "siisiOi"), + PySass_IF_PY3("yiiyiOiO", "siisiOiO"), &string, &output_style, &source_comments, &include_paths, &precision, - &custom_functions, &indented)) { + &custom_functions, &indented, &custom_importers)) { return NULL; } @@ -432,7 +535,7 @@ PySass_compile_string(PyObject *self, PyObject *args) { sass_option_set_precision(options, precision); sass_option_set_is_indented_syntax_src(options, indented); _add_custom_functions(options, custom_functions); - + _add_custom_importers(options, custom_importers); sass_compile_data_context(context); ctx = sass_data_context_get_context(context); @@ -457,13 +560,15 @@ PySass_compile_filename(PyObject *self, PyObject *args) { const char *error_message, *output_string, *source_map_string; Sass_Output_Style output_style; int source_comments, error_status, precision; - PyObject *source_map_filename, *custom_functions, *result; + PyObject *source_map_filename, *custom_functions, *custom_importers, + *result; if (!PyArg_ParseTuple(args, - PySass_IF_PY3("yiiyiOO", "siisiOO"), + PySass_IF_PY3("yiiyiOOO", "siisiOOO"), &filename, &output_style, &source_comments, &include_paths, &precision, - &source_map_filename, &custom_functions)) { + &source_map_filename, &custom_functions, + &custom_importers)) { return NULL; } @@ -487,7 +592,7 @@ PySass_compile_filename(PyObject *self, PyObject *args) { sass_option_set_include_path(options, include_paths); sass_option_set_precision(options, precision); _add_custom_functions(options, custom_functions); - + _add_custom_importers(options, custom_importers); sass_compile_file_context(context); ctx = sass_file_context_get_context(context); diff --git a/sass.py b/sass.py index b827e5b7..b60e15dc 100644 --- a/sass.py +++ b/sass.py @@ -13,6 +13,7 @@ from __future__ import absolute_import import collections +import functools import inspect from io import open import os @@ -144,9 +145,59 @@ def __str__(self): return self.signature +def _normalize_importer_return_value(result): + # An importer must return an iterable of iterables of 1-3 stringlike + # objects + if result is None: + return result + + def _to_importer_result(single_result): + single_result = tuple(single_result) + if len(single_result) not in (1, 2, 3): + raise ValueError( + 'Expected importer result to be a tuple of length (1, 2, 3) ' + 'but got {0}: {1!r}'.format(len(single_result), single_result) + ) + + def _to_bytes(obj): + if not isinstance(obj, bytes): + return obj.encode('UTF-8') + else: + return obj + + return tuple(_to_bytes(s) for s in single_result) + + return tuple(_to_importer_result(x) for x in result) + + +def _importer_callback_wrapper(func): + @functools.wraps(func) + def inner(path): + ret = func(path.decode('UTF-8')) + return _normalize_importer_return_value(ret) + return inner + + +def _validate_importers(importers): + """Validates the importers and decorates the callables with our output + formatter. + """ + # They could have no importers, that's chill + if importers is None: + return None + + def _to_importer(priority, func): + assert isinstance(priority, int), priority + assert callable(func), func + return (priority, _importer_callback_wrapper(func)) + + # Our code assumes tuple of tuples + return tuple(_to_importer(priority, func) for priority, func in importers) + + def compile_dirname( search_path, output_path, output_style, source_comments, include_paths, - precision, custom_functions, + precision, custom_functions, importers ): fs_encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() for dirpath, _, filenames in os.walk(search_path): @@ -163,7 +214,7 @@ def compile_dirname( input_filename = input_filename.encode(fs_encoding) s, v, _ = compile_filename( input_filename, output_style, source_comments, include_paths, - precision, None, custom_functions, + precision, None, custom_functions, importers ) if s: v = v.decode('UTF-8') @@ -219,6 +270,10 @@ def compile(**kwargs): formatted. :const:`False` by default :type indented: :class:`bool` :returns: the compiled CSS string + :param importers: optional callback functions. + see also below `importer callbacks + `_ description + :type importers: :class:`collections.Callable` :rtype: :class:`str` :raises sass.CompileError: when it fails for any reason (for example the given SASS has broken syntax) @@ -253,6 +308,10 @@ def compile(**kwargs): :type custom_functions: :class:`collections.Set`, :class:`collections.Sequence`, :class:`collections.Mapping` + :param importers: optional callback functions. + see also below `importer callbacks + `_ description + :type importers: :class:`collections.Callable` :returns: the compiled CSS string, or a pair of the compiled CSS string and the source map string if ``source_comments='map'`` :rtype: :class:`str`, :class:`tuple` @@ -347,6 +406,49 @@ def func_name(a, b): custom_functions={func_name} ) + .. _importer-callbacks: + + Newer versions of ``libsass`` allow developers to define callbacks to be + called and given a chance to process ``@import`` directives. You can + define yours by passing in a list of callables via the ``importers`` + parameter. The callables must be passed as 2-tuples in the form: + + .. code-block:: python + + (priority_int, callback_fn) + + A priority of zero is acceptable; priority determines the order callbacks + are attempted. + + These callbacks must accept a single string argument representing the path + passed to the ``@import`` directive, and either return ``None`` to + indicate the path wasn't handled by that callback (to continue with others + or fall back on internal ``libsass`` filesystem behaviour) or a list of + one or more tuples, each in one of three forms: + + * A 1-tuple representing an alternate path to handle internally; or, + * A 2-tuple representing an alternate path and the content that path + represents; or, + * A 3-tuple representing the same as the 2-tuple with the addition of a + "sourcemap". + + All tuple return values must be strings. As a not overly realistic + example: + + .. code-block:: python + + def my_importer(path): + return [(path, '#' + path + ' { color: red; }')] + + sass.compile( + ..., + importers=[(0, my_importer)] + ) + + Now, within the style source, attempting to ``@import 'button';`` will + instead attach ``color: red`` as a property of an element with the + imported name. + .. versionadded:: 0.4.0 Added ``source_comments`` and ``source_map_filename`` parameters. @@ -458,6 +560,8 @@ def func_name(a, b): 'not {1!r}'.format(SassFunction, custom_functions) ) + importers = _validate_importers(kwargs.pop('importers', None)) + if 'string' in modes: string = kwargs.pop('string') if isinstance(string, text_type): @@ -469,7 +573,7 @@ def func_name(a, b): _check_no_remaining_kwargs(compile, kwargs) s, v = compile_string( string, output_style, source_comments, include_paths, precision, - custom_functions, indented, + custom_functions, indented, importers, ) if s: return v.decode('utf-8') @@ -484,7 +588,7 @@ def func_name(a, b): _check_no_remaining_kwargs(compile, kwargs) s, v, source_map = compile_filename( filename, output_style, source_comments, include_paths, precision, - source_map_filename, custom_functions, + source_map_filename, custom_functions, importers, ) if s: v = v.decode('utf-8') @@ -530,7 +634,7 @@ def func_name(a, b): _check_no_remaining_kwargs(compile, kwargs) s, v = compile_dirname( search_path, output_path, output_style, source_comments, - include_paths, precision, custom_functions, + include_paths, precision, custom_functions, importers, ) if s: return diff --git a/sasstests.py b/sasstests.py index c16402e0..7b1ae5fd 100644 --- a/sasstests.py +++ b/sasstests.py @@ -274,6 +274,138 @@ def test_compile_string_sass_style(self): indented=True) assert actual == 'a b {\n color: blue; }\n' + def test_importer_one_arg(self): + """Demonstrates one-arg importers + chaining.""" + def importer_returning_one_argument(path): + assert type(path) is text_type + return ( + # Trigger the import of an actual file + ('test/b.scss',), + (path, '.{0}-one-arg {{ color: blue; }}'.format(path)), + ) + + ret = sass.compile( + string="@import 'foo';", + importers=((0, importer_returning_one_argument),), + output_style='compressed', + ) + assert ret == 'b i{font-size:20px}.foo-one-arg{color:blue}\n' + + def test_importer_does_not_handle_returns_None(self): + def importer_one(path): + if path == 'one': + return ((path, 'a { color: red; }'),) + + def importer_two(path): + if path == 'two': + return ((path, 'b { color: blue; }'),) + + ret = sass.compile( + string='@import "one"; @import "two";', + importers=((0, importer_one), (0, importer_two)), + output_style='compressed', + ) + assert ret == 'a{color:red}b{color:blue}\n' + + def test_importers_other_iterables(self): + def importer_one(path): + if path == 'one': + # Need to do this to avoid returning empty generator + def gen(): + yield (path, 'a { color: red; }') + yield (path + 'other', 'b { color: orange; }') + return gen() + + def importer_two(path): + if path == 'two': + # List of lists + return [ + [path, 'c { color: yellow; }'], + [path + 'other', 'd { color: green; }'], + ] + + ret = sass.compile( + string='@import "one"; @import "two";', + # Importers can also be lists + importers=[[0, importer_one], [0, importer_two]], + output_style='compressed', + ) + assert ret == ( + 'a{color:red}b{color:orange}c{color:yellow}d{color:green}\n' + ) + + def test_importers_srcmap(self): + def importer_with_srcmap(path): + return ( + ( + path, + 'a { color: red; }', + json.dumps({ + "version": 3, + "sources": [ + path + ".db" + ], + "mappings": ";AAAA,CAAC,CAAC;EAAE,KAAK,EAAE,GAAI,GAAI", + }), + ), + ) + + # This exercises the code, but I don't know what the outcome is + # supposed to be. + ret = sass.compile( + string='@import "test";', + importers=((0, importer_with_srcmap),), + output_style='compressed', + ) + assert ret == 'a{color:red}\n' + + def test_importers_raises_exception(self): + def importer(path): + raise ValueError('Bad path: {0}'.format(path)) + + with assert_raises_compile_error(RegexMatcher( + r'^Error: \n' + r' Traceback \(most recent call last\):\n' + r'.+' + r'ValueError: Bad path: hi\n' + r' on line 1 of stdin\n' + r'>> @import "hi";\n' + r' --------\^\n' + )): + sass.compile(string='@import "hi";', importers=((0, importer),)) + + def test_importer_returns_wrong_tuple_size_zero(self): + def importer(path): + return ((),) + + with assert_raises_compile_error(RegexMatcher( + r'^Error: \n' + r' Traceback \(most recent call last\):\n' + r'.+' + r'ValueError: Expected importer result to be a tuple of ' + r'length \(1, 2, 3\) but got 0: \(\)\n' + r' on line 1 of stdin\n' + r'>> @import "hi";\n' + r' --------\^\n' + )): + sass.compile(string='@import "hi";', importers=((0, importer),)) + + def test_importer_returns_wrong_tuple_size_too_big(self): + def importer(path): + return (('a', 'b', 'c', 'd'),) + + with assert_raises_compile_error(RegexMatcher( + r'^Error: \n' + r' Traceback \(most recent call last\):\n' + r'.+' + r'ValueError: Expected importer result to be a tuple of ' + r"length \(1, 2, 3\) but got 4: \('a', 'b', 'c', 'd'\)\n" + r' on line 1 of stdin\n' + r'>> @import "hi";\n' + r' --------\^\n' + )): + sass.compile(string='@import "hi";', importers=((0, importer),)) + def test_compile_string_deprecated_source_comments_line_numbers(self): source = '''a { b { color: blue; }