summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEevee (Alex Munroe) <eevee.git@veekun.com>2014-10-06 15:03:11 -0700
committerEevee (Alex Munroe) <eevee.git@veekun.com>2014-10-06 15:03:11 -0700
commitd3366e69c90701d6bf1f53497a2fb8cec8d40c4e (patch)
tree29514a85ee8371777ecb2f537d74c4172d9d0003
parent6d9c167b53066f3dbfaa179775f2af675fff65f4 (diff)
downloadpyscss-d3366e69c90701d6bf1f53497a2fb8cec8d40c4e.tar.gz
Experiment with porting path handling to use pathlib.
The biggest impetus here is to allow Django integration without having to copy and paste massive piles of code.
-rw-r--r--conftest.py20
-rw-r--r--scss/compiler.py47
-rw-r--r--scss/errors.py2
-rw-r--r--scss/extension/api.py3
-rw-r--r--scss/extension/core.py65
-rw-r--r--scss/legacy.py29
-rw-r--r--scss/source.py179
-rw-r--r--scss/tool.py2
-rw-r--r--setup.py1
9 files changed, 240 insertions, 108 deletions
diff --git a/conftest.py b/conftest.py
index 0a81986..e4371a3 100644
--- a/conftest.py
+++ b/conftest.py
@@ -4,6 +4,7 @@ from __future__ import division
from __future__ import print_function
import logging
+from pathlib import Path
import pytest
@@ -99,22 +100,25 @@ class SassItem(pytest.Item):
excinfo.traceback = excinfo.traceback.cut(__file__)
def runtest(self):
- scss_file = self.fspath
- css_file = scss_file.new(ext='css')
+ scss_file = Path(str(self.fspath))
+ css_file = scss_file.with_suffix('.css')
with css_file.open('rb') as fh:
# Output is Unicode, so decode this here
expected = fh.read().decode('utf8')
- scss.config.STATIC_ROOT = str(scss_file.dirpath('static'))
+ scss.config.STATIC_ROOT = str(scss_file.parent / 'static')
+
+ search_path = []
+ include = scss_file.parent / 'include'
+ if include.exists():
+ search_path.append(include)
+ search_path.append(scss_file.parent)
actual = compile_file(
- str(scss_file),
+ scss_file,
output_style='expanded',
- search_path=[
- str(scss_file.dirpath('include')),
- str(scss_file.dirname),
- ],
+ search_path=search_path,
extensions=[
CoreExtension,
ExtraExtension,
diff --git a/scss/compiler.py b/scss/compiler.py
index f13c86a..50602d2 100644
--- a/scss/compiler.py
+++ b/scss/compiler.py
@@ -5,10 +5,8 @@ from __future__ import division
from collections import defaultdict
from enum import Enum
-import glob
-from itertools import product
import logging
-import os.path
+from pathlib import Path
import re
import sys
import warnings
@@ -86,7 +84,7 @@ class Compiler(object):
compilation. Main entry point into compiling Sass.
"""
def __init__(
- self, root='', search_path=(),
+ self, root=Path(), search_path=(),
namespace=None, extensions=(CoreExtension,),
output_style='nested', generate_source_map=False,
live_errors=False, warn_unused_imports=False,
@@ -100,16 +98,21 @@ class Compiler(object):
:param root: Directory to treat as the "project root". Search paths
and some custom extensions (e.g. Compass) are relative to this
directory. Defaults to the current directory.
+ :type root: :class:`pathlib.Path`
:param search_path: List of paths to search for ``@import``s, relative
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.
+ :type search_path: list of :class:`pathlib.Path` objects, or something
+ that implements a similar interface (useful for custom pseudo
+ filesystems)
"""
+ # TODO perhaps polite to automatically cast any string paths to Path?
+ # but have to be careful since the api explicitly allows dummy objects.
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.root = root.resolve()
self.search_path = tuple(
self.normalize_path(path)
@@ -144,13 +147,11 @@ class Compiler(object):
self.super_selector = super_selector
def normalize_path(self, path):
+ if path.is_absolute():
+ return 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)
+ raise IOError("Can't make absolute path when root is None")
+ return self.root / path
def make_compilation(self):
return Compilation(self)
@@ -192,17 +193,20 @@ class Compiler(object):
def compile_file(filename, compiler_class=Compiler, **kwargs):
- """Compile a single file, and return a string of CSS.
+ """Compile a single file (provided as a :class:`pathlib.Path`), and return
+ a string of CSS.
Keyword arguments are passed along to the underlying `Compiler`.
Note that the search path is set to the file's containing directory by
default, unless you explicitly pass a ``search_path`` kwarg.
+
+ :param filename: Path to the file to compile.
+ :type filename: str, bytes, or :class:`pathlib.Path`
"""
+ filename = Path(filename)
if 'search_path' not in kwargs:
- kwargs['search_path'] = [
- os.path.abspath(os.path.dirname(filename)),
- ]
+ kwargs['search_path'] = [filename.parent.resolve()]
compiler = compiler_class(**kwargs)
return compiler.compile(filename)
@@ -245,10 +249,11 @@ class Compilation(object):
'control_scoping', self.compiler.loops_have_own_scopes)
def add_source(self, source):
- if source.path in self.source_index:
- raise KeyError("Duplicate source %r" % source.path)
+ if source.key in self.source_index:
+ return self.source_index[source.key]
self.sources.append(source)
- self.source_index[source.path] = source
+ self.source_index[source.key] = source
+ return source
def run(self):
# this will compile and manage rule: child objects inside of a node
@@ -844,9 +849,7 @@ class Compilation(object):
# Didn't find anything!
raise SassImportError(name, self.compiler, rule=rule)
- if source.path not in self.source_index:
- self.add_source(source)
- source = self.source_index[source.path]
+ source = self.add_source(source)
if rule.namespace.has_import(source):
# If already imported in this scope, skip
diff --git a/scss/errors.py b/scss/errors.py
index 971ee76..75d98b6 100644
--- a/scss/errors.py
+++ b/scss/errors.py
@@ -163,7 +163,7 @@ class SassImportError(SassBaseError):
.format(
self.bad_name,
", ".join(repr(ext) for ext in self.compiler.extensions),
- "\n ".join(self.compiler.search_path),
+ "\n ".join(str(path) for path in self.compiler.search_path),
)
)
diff --git a/scss/extension/api.py b/scss/extension/api.py
index a6334e1..b3ccd1f 100644
--- a/scss/extension/api.py
+++ b/scss/extension/api.py
@@ -27,6 +27,9 @@ class Extension(object):
def __init__(self):
pass
+ def __repr__(self):
+ return "<{0}>".format(type(self).__name__)
+
def handle_import(self, name, compilation, rule):
"""Attempt to resolve an import. Called once for every Sass string
listed in an ``@import`` statement. Imports that Sass dictates should
diff --git a/scss/extension/core.py b/scss/extension/core.py
index 317fb15..6e6b8c6 100644
--- a/scss/extension/core.py
+++ b/scss/extension/core.py
@@ -7,6 +7,7 @@ from __future__ import unicode_literals
from itertools import product
import math
import os.path
+from pathlib import PurePosixPath
from six.moves import xrange
@@ -25,44 +26,60 @@ class CoreExtension(Extension):
"""Implementation of the core Sass import mechanism, which just looks
for files on disk.
"""
- name, ext = os.path.splitext(name)
- if ext:
- search_exts = [ext]
+ # TODO virtually all of this is the same as the django stuff, except
+ # for the bit that actually looks for and tries to open the file.
+ # would be much easier if you could just stick an object in the search
+ # path that implements the pathlib API. the only problem is what to do
+ # when one path is a child of another, so the same file has two names,
+ # but tbh i'm not actually sure that's something worth protecting
+ # against...? like, the only cost is that we'll parse twice (or, later
+ # on, not respect single-import), and the fix is to just Not Do That
+ # TODO i think with the new origin semantics, i've made it possible to
+ # import relative to the current file even if the current file isn't
+ # anywhere in the search path. is that right?
+ path = PurePosixPath(name)
+ if path.suffix:
+ search_exts = [path.suffix]
else:
search_exts = ['.scss', '.sass']
- dirname, basename = os.path.split(name)
-
- # Search relative to the importing file first
- search_path = [
- os.path.normpath(os.path.abspath(
- os.path.dirname(rule.source_file.path)))]
+ dirname = path.parent
+ basename = path.stem
+
+ search_path = [] # tuple of (origin, start_from)
+ if dirname.is_absolute():
+ relative_to = PurePosixPath(*dirname.parts[1:])
+ elif rule.source_file.origin:
+ # Search relative to the current file first, only if not doing an
+ # absolute import
+ relative_to = rule.source_file.relpath.parent / dirname
+ search_path.append(rule.source_file.origin)
+ else:
+ relative_to = dirname
search_path.extend(compilation.compiler.search_path)
for prefix, suffix in product(('_', ''), search_exts):
filename = prefix + basename + suffix
- for directory in search_path:
- path = os.path.normpath(
- os.path.join(directory, dirname, filename))
-
- if path == rule.source_file.path:
+ for origin in search_path:
+ relpath = relative_to / filename
+ # Lexically (ignoring symlinks!) eliminate .. from the part
+ # of the path that exists within Sass-space. pathlib
+ # deliberately doesn't do this, but os.path does.
+ relpath = PurePosixPath(os.path.normpath(str(relpath)))
+
+ if rule.source_file.key == (origin, relpath):
# Avoid self-import
# TODO is this what ruby does?
continue
- if not os.path.exists(path):
- continue
-
- # Ensure that no one used .. to escape the search path
- for valid_path in compilation.compiler.search_path:
- rel = os.path.relpath(path, start=valid_path)
- if not rel.startswith('../'):
- break
- else:
+ path = origin / relpath
+ if not path.exists():
continue
# All good!
- return SourceFile.from_filename(path)
+ # TODO if this file has already been imported, we'll do the
+ # source preparation twice. make it lazy.
+ return SourceFile.read(origin, relpath)
# Alias to make the below declarations less noisy
diff --git a/scss/legacy.py b/scss/legacy.py
index 367c700..fef131f 100644
--- a/scss/legacy.py
+++ b/scss/legacy.py
@@ -3,6 +3,8 @@ from __future__ import print_function
from __future__ import unicode_literals
from __future__ import division
+from pathlib import Path
+
import six
from scss.calculator import Calculator
@@ -42,7 +44,6 @@ _default_scss_vars = {
}
-# TODO move this to a back-compat module so init is finally empty
# TODO using this should spew an actual deprecation warning
class Scss(object):
"""Original programmatic interface to the compiler.
@@ -121,6 +122,13 @@ class Scss(object):
elif output_style is False:
output_style = 'legacy'
+ fixed_search_path = []
+ for path in search_paths:
+ if isinstance(path, six.string_types):
+ fixed_search_path.append(Path(path))
+ else:
+ fixed_search_path.append(path)
+
# Build the compiler
compiler = Compiler(
namespace=root_namespace,
@@ -131,7 +139,7 @@ class Scss(object):
CompassExtension,
BootstrapExtension,
],
- search_path=search_paths,
+ search_path=fixed_search_path,
live_errors=self.live_errors,
generate_source_map=self._scss_opts.get('debug_info', False),
output_style=output_style,
@@ -152,22 +160,25 @@ class Scss(object):
elif scss_string is not None:
source = SourceFile.from_string(
scss_string,
- path=filename,
+ relpath=filename,
is_sass=is_sass,
)
compilation.add_source(source)
elif scss_file is not None:
- source = SourceFile.from_filename(
- scss_file,
- path=filename,
- is_sass=is_sass,
- )
+ # This is now the only way to allow forcibly overriding the
+ # filename a source "thinks" it is
+ with open(scss_file, 'rb') as f:
+ source = SourceFile.from_file(
+ scss_file,
+ relpath=filename or scss_file,
+ is_sass=is_sass,
+ )
compilation.add_source(source)
# Plus the ones from the constructor
if self._scss_files:
for name, contents in list(self._scss_files.items()):
- source = SourceFile.from_string(contents, path=name)
+ source = SourceFile.from_string(contents, relpath=name)
compilation.add_source(source)
return compiler.call_and_catch_errors(compilation.run)
diff --git a/scss/source.py b/scss/source.py
index 7887134..960fd89 100644
--- a/scss/source.py
+++ b/scss/source.py
@@ -6,6 +6,7 @@ from __future__ import division
import hashlib
import logging
import os
+from pathlib import Path
import re
import six
@@ -36,50 +37,102 @@ _reverse_safe_strings_re = re.compile('|'.join(
map(re.escape, _reverse_safe_strings)))
+class MISSING(object):
+ def __repr__(self):
+ return "<MISSING>"
+MISSING = MISSING()
+
+
class SourceFile(object):
"""A single input file to be fed to the compiler. Detects the encoding
(according to CSS spec rules) and performs some light pre-processing.
+
+ This class is mostly internal and you shouldn't have to worry about it.
+
+ Source files are uniquely identified by their ``.key``, a 2-tuple of
+ ``(origin, relpath)``.
+
+ ``origin`` is an object from the compiler's search
+ path, most often a directory represented by a :class:`pathlib.Path`.
+ ``relpath`` is a relative path from there to the actual file, again usually
+ a ``Path``.
+
+ The idea here is that source files don't always actually come from the
+ filesystem, yet import semantics are expressed in terms of paths. By
+ keeping the origin and relative path separate, it's possible for e.g.
+ Django to swap in an object that has the ``Path`` interface, but actually
+ looks for files in an arbitrary storage backend. In that case it would
+ make no sense to key files by their absolute path, as they may not exist on
+ disk or even on the same machine. Also, relative imports can then continue
+ to work, because they're guaranteed to only try the same origin.
+
+ The ``origin`` may thus be anything that implements a minimal ``Path``ish
+ interface (division operator, ``.parent``, ``.resolve()``). It may also be
+ ``None``, indicating that the file came from a string or some other origin
+ that can't usefully produce other files.
+
+ ``relpath``, however, should always be a ``Path``. or string. XXX only when origin (There's little
+ advantage to making it anything else.) A ``relpath`` may **never** contain
+ ".."; there is nothing above the origin.
+
+ Note that one minor caveat of this setup is that it's possible for the same
+ file on disk to be imported under two different names (even though symlinks
+ are always resolved), if directories in the search path happen to overlap.
"""
- path = None
- """For "real" files, an absolute path to the original source file. For ad
- hoc strings, some other kind of identifier. This is used as a hash key and
- a test of equality, so it MUST be unique!
+ key = None
+ """A 2-tuple of ``(origin, relpath)`` that uniquely identifies where the
+ file came from and how to find its siblings.
"""
def __init__(
- self, path, contents, encoding=None,
- is_real_file=True, is_sass=None):
+ self, origin, relpath, contents, encoding=None,
+ is_sass=None):
"""Not normally used. See the three alternative constructors:
- :func:`SourceFile.from_file`, :func:`SourceFile.from_filename`, and
+ :func:`SourceFile.from_file`, :func:`SourceFile.from_path`, and
:func:`SourceFile.from_string`.
"""
if not isinstance(contents, six.text_type):
raise TypeError(
- "Expected bytes for 'contents', got {0}"
+ "Expected text for 'contents', got {0}"
.format(type(contents)))
- if is_real_file and not os.path.isabs(path):
+ if origin and '..' in relpath.parts:
raise ValueError(
- "Expected an absolute path for 'path', got {0!r}"
- .format(path))
+ "relpath cannot contain ..: {0!r}".format(relpath))
+
+ self.origin = origin
+ self.relpath = relpath
+ self.key = origin, relpath
- self.path = path
self.encoding = encoding
if is_sass is None:
- # TODO autodetect from the contents if the extension is bogus or
- # missing?
- self.is_sass = os.path.splitext(path)[1] == '.sass'
+ # TODO autodetect from the contents if the extension is bogus
+ # or missing?
+ if origin:
+ self.is_sass = relpath.suffix == '.sass'
+ else:
+ self.is_sass = False
else:
self.is_sass = is_sass
self.contents = self.prepare_source(contents)
- self.is_real_file = is_real_file
+
+ @property
+ def path(self):
+ """Concatenation of ``origin`` and ``relpath``, as a string. Used in
+ stack traces and other debugging places.
+ """
+ if self.origin:
+ return six.text_type(self.origin / self.relpath)
+ else:
+ return six.text_type(self.relpath)
def __repr__(self):
- return "<{0} {1!r}>".format(type(self).__name__, self.path)
+ return "<{0} {1!r} from {2!r}>".format(
+ type(self).__name__, self.relpath, self.origin)
def __hash__(self):
- return hash(self.path)
+ return hash(self.key)
def __eq__(self, other):
if self is other:
@@ -88,40 +141,82 @@ class SourceFile(object):
if not isinstance(other, SourceFile):
return NotImplemented
- return self.path == other.path
+ return self.key == other.key
def __ne__(self, other):
return not self == other
@classmethod
- def from_filename(cls, fn, path=None, **kwargs):
- """Read Sass source from a file on disk."""
+ def _key_from_path(cls, path, origin=MISSING):
+ # Given an origin (which may be MISSING) and an absolute path,
+ # return a key.
+ if origin is MISSING:
+ # Resolve only the parent, in case the file itself is a symlink
+ origin = path.parent.resolve()
+ relpath = Path(path.name)
+ else:
+ # Again, resolving the origin is fine; we just don't want to
+ # resolve anything inside it, lest we ruin some intended symlink
+ # structure
+ origin = origin.resolve()
+ # pathlib balks if this requires lexically ascending <3
+ relpath = path.relative_to(origin)
+
+ return origin, relpath
+
+ @classmethod
+ def read(cls, origin, relpath, **kwargs):
+ """Read a source file from an ``(origin, relpath)`` tuple, as would
+ happen from an ``@import`` statement.
+ """
+ path = origin / relpath
+ with path.open('rb') as f:
+ return cls.from_file(f, origin, relpath, **kwargs)
+
+ @classmethod
+ def from_path(cls, path, origin=MISSING, **kwargs):
+ """Read Sass source from a :class:`pathlib.Path`.
+
+ If no origin is given, it's assumed to be the file's parent directory.
+ """
+ origin, relpath = cls._key_from_path(path, origin)
+
# Open in binary mode so we can reliably detect the encoding
- with open(fn, 'rb') as f:
- return cls.from_file(f, path=path or fn, **kwargs)
+ with path.open('rb') as f:
+ return cls.from_file(f, origin, relpath, **kwargs)
+
+ # back-compat
+ from_filename = from_path
@classmethod
- def from_file(cls, f, path=None, **kwargs):
- """Read Sass source from a file or file-like object."""
+ def from_file(cls, f, origin=MISSING, relpath=MISSING, **kwargs):
+ """Read Sass source from a file or file-like object.
+
+ If `origin` or `relpath` are missing, they are derived from the file's
+ ``.name`` attribute as with `from_path`. If it doesn't have one, the
+ origin becomes None and the relpath becomes the file's repr.
+ """
contents = f.read()
encoding = determine_encoding(contents)
if isinstance(contents, six.binary_type):
contents = contents.decode(encoding)
- is_real_file = False
- if path is None:
- path = getattr(f, 'name', repr(f))
- elif os.path.exists(path):
- path = os.path.normpath(os.path.abspath(path))
- is_real_file = True
+ if origin is MISSING or relpath is MISSING:
+ filename = getattr(f, 'name', None)
+ if filename is None:
+ origin = None
+ relpath = repr(f)
+ else:
+ origin, relpath = cls._key_from_path(Path(filename), origin)
- return cls(
- path, contents, encoding=encoding, is_real_file=is_real_file,
- **kwargs)
+ return cls(origin, relpath, contents, encoding=encoding, **kwargs)
@classmethod
- def from_string(cls, string, path=None, encoding=None, is_sass=None):
- """Read Sass source from the contents of a string."""
+ def from_string(cls, string, relpath=None, encoding=None, is_sass=None):
+ """Read Sass source from the contents of a string.
+
+ The origin is always None. `relpath` defaults to "string:...".
+ """
if isinstance(string, six.text_type):
# Already decoded; we don't know what encoding to use for output,
# though, so still check for a @charset.
@@ -139,17 +234,15 @@ class SourceFile(object):
else:
raise TypeError("Expected text or bytes, got {0!r}".format(string))
- is_real_file = False
- if path is None:
+ origin = None
+ if relpath is None:
m = hashlib.sha256()
m.update(byte_contents)
- path = 'string:' + m.hexdigest()
- elif os.path.exists(path):
- path = os.path.normpath(os.path.abspath(path))
- is_real_file = True
+ relpath = repr("string:{0}:{1}".format(
+ m.hexdigest()[:16], text_contents[:100]))
return cls(
- path, text_contents, encoding=encoding, is_real_file=is_real_file,
+ origin, relpath, text_contents, encoding=encoding,
is_sass=is_sass,
)
diff --git a/scss/tool.py b/scss/tool.py
index 915d2a1..08abb44 100644
--- a/scss/tool.py
+++ b/scss/tool.py
@@ -176,7 +176,7 @@ def do_build(options, args):
source_files = []
for path in args:
if path == '-':
- source = SourceFile.from_file(sys.stdin, "<stdin>", is_sass=options.is_sass)
+ source = SourceFile.from_file(sys.stdin, relpath="<stdin>", is_sass=options.is_sass)
else:
source = SourceFile.from_filename(path, is_sass=options.is_sass)
source_files.append(source)
diff --git a/setup.py b/setup.py
index f3f8f39..3a7d8a0 100644
--- a/setup.py
+++ b/setup.py
@@ -16,6 +16,7 @@ exec(open('scss/scss_meta.py').read())
install_requires = ['six']
if sys.version_info < (3, 4):
install_requires.append('enum34')
+ install_requires.append('pathlib')
if sys.version_info < (2, 7):
install_requires.append('ordereddict')