diff options
author | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-10-06 15:03:11 -0700 |
---|---|---|
committer | Eevee (Alex Munroe) <eevee.git@veekun.com> | 2014-10-06 15:03:11 -0700 |
commit | d3366e69c90701d6bf1f53497a2fb8cec8d40c4e (patch) | |
tree | 29514a85ee8371777ecb2f537d74c4172d9d0003 | |
parent | 6d9c167b53066f3dbfaa179775f2af675fff65f4 (diff) | |
download | pyscss-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.py | 20 | ||||
-rw-r--r-- | scss/compiler.py | 47 | ||||
-rw-r--r-- | scss/errors.py | 2 | ||||
-rw-r--r-- | scss/extension/api.py | 3 | ||||
-rw-r--r-- | scss/extension/core.py | 65 | ||||
-rw-r--r-- | scss/legacy.py | 29 | ||||
-rw-r--r-- | scss/source.py | 179 | ||||
-rw-r--r-- | scss/tool.py | 2 | ||||
-rw-r--r-- | setup.py | 1 |
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) @@ -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') |