summaryrefslogtreecommitdiff
path: root/scss/source.py
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 /scss/source.py
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.
Diffstat (limited to 'scss/source.py')
-rw-r--r--scss/source.py179
1 files changed, 136 insertions, 43 deletions
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,
)