Commits

jhsware committed 4fc5fe9 Merge

merge

  • Participants
  • Parent commits 8a0e507, 909c473

Comments (0)

Files changed (9)

 0.11 (unreleased)
 =================
 
-- Nothing changed yet.
+- Improved sorting of resources for inclusion on web page. This is to
+  prepare for bundling support. Ordering is now more consistent, no
+  matter in which order resources are .needed(). As long as you marked
+  dependencies right this shouldn't break applications; if your
+  resources are included in the wrong order now, fix resource dependencies.
 
+- base_url is not required anymore (as in the past); improve base_url
+  management API so that integration packages like zope.fanstatic have
+  a more explicit way to manage this information.
 
 0.10.1 (2011-02-06)
 ===================

File doc/configuration.rst

 be published on a sub-URL. By default, there is no ``base_url``, and
 resources are served in the script root.
 
-Note that this can also be set as an attribute on an
+Note that this can also be set using the ``set_base_url`` method on a
 :py:class:`NeededResources` instance during run-time, as this URL is
 generally not known when :py:class:`NeededResources` is instantiated.
 

File doc/quickstart.rst

 
   from fanstatic import Fanstatic
 
-  fanstatic_app = Fanstatic(app, base_url='127.0.0.1:8080')
+  fanstatic_app = Fanstatic(app)
 
 When you use ``fanstatic_app``, Fanstatic will take care of serving
 static resources for you, and will include them on web pages when

File fanstatic/__init__.py

                             clear_needed,
                             register_inclusion_renderer,
                             UnknownResourceExtension,
+                            LibraryDependencyCycle,
                             ConfigurationError)
 
 from fanstatic.registry import get_library_registry, LibraryRegistry

File fanstatic/codegen.py

     result.append("")
 
     # sort resources in the order we want them to be
-    resources = sort_resources(
-        sort_resources_topological(resources))
+    resources = sort_resources_topological(resources)
 
     # now generate resource code
     for resource in resources:

File fanstatic/core.py

     pass
 
 
+class LibraryDependencyCycle(Exception):
+    pass
+
+
 class Library(object):
     """The resource library.
 
     """
 
     _signature = None
+    _library_deps = None
 
     def __init__(self, name, rootpath, ignores=None, version=None):
         self.name = name
         self.ignores = ignores or []
         self.path = os.path.join(caller_dir(), rootpath)
         self.version = version
+        self._library_deps = set()
+
+    def check_dependency_cycle(self, resource):
+        for dependency in resource.resources():
+            self._library_deps.add(dependency.library)
+        for dep in self._library_deps:
+            if dep is self:
+                continue
+            if self in dep._library_deps:
+                raise LibraryDependencyCycle(
+                    'Library cycle detected in resource %s' % resource)
 
     def signature(self, recompute_hashes=False):
         """Get a unique signature for this Library.
       a :py:class:`Resource` instance is constructed that has the same
       library as the resource.
 
+    :param dont_bundle: Don't bundle this resource in any bundles
+      (if bundling is enabled).
+
     :param minified: optionally, a minified version of the resource.
       The argument is a :py:class:`Resource` instance, or a string that
       indicates a relative path to the resource. In the latter case
                  bottom=False,
                  renderer=None,
                  debug=None,
+                 dont_bundle=False,
                  minified=None):
         self.library = library
         self.relpath = relpath
+        self.dirname = os.path.dirname(relpath)
         self.bottom = bottom
+        self.dont_bundle = dont_bundle
 
         self.ext = os.path.splitext(self.relpath)[1]
 
         depends = depends or []
         self.depends = normalize_resources(library, depends)
 
+        # Check for library dependency cycles.
+        self.library.check_dependency_cycle(self)
+
+        # generate an internal number for sorting the resource
+        # on dependency within the library
+        init_dependency_nr(self)
+
         self.modes = {}
         if debug is not None:
             self.modes[DEBUG] = normalize_resource(library, debug)
+            self.modes[DEBUG].dependency_nr = self.dependency_nr
+            self.modes[DEBUG].library_nr = self.library_nr
         if minified is not None:
             self.modes[MINIFIED] = normalize_resource(library, minified)
+            self.modes[MINIFIED].dependency_nr = self.dependency_nr
+            self.modes[MINIFIED].library_nr = self.library_nr
 
         assert not isinstance(supersedes, basestring)
         self.supersedes = supersedes or []
     """
     def __init__(self, depends):
         self.depends = depends
+        init_dependency_nr(self)
 
     def need(self):
         """Need this group resource.
             result.extend(depend.resources())
         return result
 
+def init_dependency_nr(resource):
+    # on dependency within the library
+    dependency_nr = 0
+    library_nr = 0
+    for depend in resource.depends:
+        if (not isinstance(resource, GroupResource) and
+            not isinstance(depend, GroupResource)):
+            if depend.library is not resource.library:
+                library_nr = max(depend.library_nr + 1, library_nr)
+            else:
+                library_nr = max(depend.library_nr, library_nr)
+        else:
+            library_nr = max(depend.library_nr, library_nr)
+        dependency_nr = max(depend.dependency_nr + 1,
+                            dependency_nr)
+    resource.dependency_nr = dependency_nr
+    resource.library_nr = library_nr
 
 def normalize_resources(library, resources):
     return [normalize_resource(library, resource)
     :param base_url: This URL will be prefixed in front of all resource
       URLs. This can be useful if your web framework wants the resources
       to be published on a sub-URL. Note that this can also be set
-      as an attribute on an ``NeededResources`` instance.
+      with the set_base_url method on a ``NeededResources`` instance.
 
     :param publisher_signature: The name under which resource libraries
       should be served in the URL. By default this is ``fanstatic``, so
       URLs to resources will start with ``/fanstatic/``.
 
+    :param bundle: If set to True, Fanstatic will attempt to bundle
+      resources that fit together into larger Bundle objects. These
+      can then be rendered as single URLs to these bundles.
+
     :param resources: Optionally, a list of resources we want to
       include. Normally you specify resources to include by calling
       ``.need()`` on them, or alternatively by calling ``.need()``
 
     """
 
-    base_url = None
+    _base_url = None
     """The base URL.
 
     This URL will be prefixed in front of all resource
                  rollup=False,
                  base_url=None,
                  publisher_signature=DEFAULT_SIGNATURE,
+                 bundle=False,
                  resources=None,
                  ):
         self._versioning = versioning
         self._recompute_hashes = recompute_hashes
         self._bottom = bottom
         self._force_bottom = force_bottom
-        self.base_url = base_url
+        self.set_base_url(base_url)
         self._publisher_signature = publisher_signature
         self._rollup = rollup
+        self._bundle = bundle
         self._resources = resources or []
         self._url_cache = {}  # prevent multiple computations per request
         if (debug and minified):
         """
         return bool(self._resources)
 
+    def has_base_url(self):
+        """Returns True if base_url has been set.
+        """
+        return bool(self._base_url)
+
+    def set_base_url(self, url):
+        """Set the base_url. The base_url can only be set (1) if it has not
+        been set in the NeededResources configuration and (2) if it has not
+        been set before using this method.
+        """
+        if not self.has_base_url():
+            self._base_url = url
+
     def need(self, resource):
         """Add a particular resource to the needed resources.
 
 
         if self._rollup:
             resources = consolidate(resources)
-        # sort only by extension, not dependency, as we can rely on
-        # python's stable sort to keep resource inclusion order intact
         resources = sort_resources(resources)
         resources = remove_duplicates(resources)
-
+        if self._bundle:
+            resources = bundle_resources(resources)
         return resources
 
     def clear(self):
 
         :param library: A :py:class:`Library` instance.
         """
-        if self.base_url is None:
-            raise ConfigurationError(
-                'No base_url: Set a base_url at configuration time or '
-                'at request-time in your framework.')
-        path = [self.base_url]
+        path = [self._base_url or '']
         if self._publisher_signature:
             path.append(self._publisher_signature)
         path.append(library.name)
     needed are dropped to the floor.
     """
 
-    base_url = None
-
     def need(self, resource):
         pass
 
             result.append(resource)
     return result
 
+def sort_resources(resources):
+    """Sort resources for inclusion on web page.
 
-def sort_resources(resources):
+    A number of rules are followed:
+
+    * resources are always grouped per renderer (.js, .css, etc)
+    * resources that depend on other resources are sorted later
+    * resources are grouped by library, if the dependencies allow it
+    * libraries are sorted by name, if dependencies allow it
+    * resources are sorted by resource path if they both would be
+      sorted the same otherwise.
+
+    The only purpose of sorting on library is so we can
+    group resources per library, so that bundles can later be created
+    of them if bundling support is enabled.
+
+    Note this sorting algorithm guarantees a consistent ordering, no
+    matter in what order resources were needed.
+    """
+    library_nrs = {}
+    for resource in resources:
+        library_nr = library_nrs.get(resource.library, 0)
+        library_nr = max(resource.library_nr, library_nr)
+        library_nrs[resource.library] = library_nr
+
     def key(resource):
-        return resource.order
+        return (
+            resource.order,
+            library_nrs[resource.library],
+            resource.library.name,
+            resource.dependency_nr,
+            resource.relpath)
     return sorted(resources, key=key)
 
+# XXX there is a concept of a 'renderable' perhaps, that's
+# base to all resources?
+class Bundle(object):
+    def __init__(self):
+        self._resources = []
+
+    def resources(self):
+        return self._resources
+
+    def fits(self, resource):
+        if resource.dont_bundle:
+            return False
+        # an empty resource fits anything
+        if not self._resources:
+            return True
+        # group resources cannot be bundled XXX does this happen? make tests
+        if isinstance(resource, GroupResource):
+            return False
+        # a resource fits if it's like the resources already inside
+        bundle_resource = self._resources[0]
+        return (resource.library is bundle_resource.library and
+                resource.renderer is bundle_resource.renderer and
+                resource.dirname == bundle_resource.dirname)
+
+    def append(self, resource):
+        self._resources.append(resource)
+
+    def render(self, library_url):
+        # XXX how?
+        pass
+
+    def add_to_list(self, result):
+        """Add the bundle to list, taking single-resource bundles into account.
+        """
+        amount = len(self._resources)
+        if amount == 0:
+            # empty bundle; don't add it to list
+            return
+        elif amount == 1:
+            # if it only contains a single entry, add it by itself
+            result.append(self._resources[0])
+        else:
+            # add the bundle itself
+            result.append(self)
+
+def bundle_resources(resources):
+    """Bundle sorted resources together.
+
+    resources is expected to be a list previously sorted by sorted_resources.
+
+    Returns a list of renderable resources, which can include several
+    resources bundled together into Bundles.
+    """
+    result = []
+    bundle = Bundle()
+    for resource in resources:
+        if bundle.fits(resource):
+            bundle.append(resource)
+        else:
+            # add the previous bundle to the list and create new bundle
+            bundle.add_to_list(result)
+            bundle = Bundle()
+            if resource.dont_bundle:
+                result.append(resource)
+            else:
+                bundle.append(resource)
+    # add the last bundle to the list
+    bundle.add_to_list(result)
+    return result
 
 def sort_resources_topological(resources):
     """Sort resources by dependency and supersedes.

File fanstatic/test_core.py

                        register_inclusion_renderer,
                        sort_resources_topological,
                        ConfigurationError,
+                       LibraryDependencyCycle,
                        UnknownResourceExtension)
 
 from fanstatic.core import inclusion_renderers, normalize_resource
 
     needed = NeededResources()
     needed.need(a3)
+
     assert needed.resources() == [a1, a2, a3]
     needed.need(a4)
-    assert needed.resources() == [a1, a2, a3, a4]
+    # a4 is sorted before a3, because it is less deep
+    # in the dependency tree
+    assert needed.resources() == [a1, a2, a4, a3]
 
 
 def test_redundant_more_complicated_reversed():
     needed = NeededResources()
     needed.need(a4)
     needed.need(a3)
-
-    assert needed.resources() == [a1, a4, a2, a3]
+    # this will always be consistent, no matter
+    # in what order we need the resources
+    assert needed.resources() == [a1, a2, a4, a3]
 
 
 def test_redundant_more_complicated_depends_on_all():
 
     needed = NeededResources()
     needed.need(a5)
-
-    assert needed.resources() == [a1, a4, a2, a3, a5]
+    assert needed.resources() == [a1, a2, a4, a3, a5]
 
 
 def test_redundant_more_complicated_depends_on_all_reorder():
     needed.need(a3)
     needed.need(a5)
 
-    assert needed.resources() == [a1, a2, a3, a4, a5]
+    assert needed.resources() == [a1, a2, a4, a3, a5]
 
 
 def test_mode_fully_specified():
     x2 = Resource(foo, 'b.css')
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(y1)
 
     assert needed.render() == '''\
 
     needed = NeededResources()
     needed.need(y1)
-    with pytest.raises(ConfigurationError):
-        needed.render()
-
-    # We need a base_url in order to render URLs to resources.
-    needed.base_url = ''
     assert needed.render() == '''\
 <link rel="stylesheet" type="text/css" href="/fanstatic/foo/b.css" />
 <script type="text/javascript" src="/fanstatic/foo/a.js"></script>
 <link rel="stylesheet" type="text/css" href="http://localhost/static/fanstatic/foo/b.css" />
 <script type="text/javascript" src="http://localhost/static/fanstatic/foo/a.js"></script>
 <script type="text/javascript" src="http://localhost/static/fanstatic/foo/c.js"></script>'''
+    # The base_url has been set.
+    assert needed.has_base_url()
+
+    needed.set_base_url('foo')
+    # The base_url can only be set once.
+    assert needed._base_url == 'http://localhost/static'
 
 
 def test_empty_base_url_and_publisher_signature():
-    ''' When the base_url and publisher_signature are both empty strings,
+    ''' When the base_url is not set and the publisher_signature is an empty string,
     render a URL without them. '''
     foo = Library('foo', '')
     x1 = Resource(foo, 'a.js')
-    needed = NeededResources(base_url='', publisher_signature='')
+    needed = NeededResources(publisher_signature='')
     needed.need(x1)
 
     assert needed.render() == '''\
 
     needed = NeededResources()
     needed.need(y1)
-
-    needed.base_url = 'http://localhost/static'
-
+    needed.set_base_url('http://localhost/static')
     assert needed.render() == '''\
 <link rel="stylesheet" type="text/css" href="http://localhost/static/fanstatic/foo/b.css" />
 <script type="text/javascript" src="http://localhost/static/fanstatic/foo/a.js"></script>
 def test_library_url_default_publisher_signature():
     foo = Library('foo', '')
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
 
     assert needed.library_url(foo) == '/fanstatic/foo'
 
 def test_library_url_publisher_signature():
     foo = Library('foo', '')
 
-    needed = NeededResources(base_url='', publisher_signature='waku')
+    needed = NeededResources(publisher_signature='waku')
 
     assert needed.library_url(foo) == '/waku/foo'
 
 def test_library_url_version_hashing(tmpdir):
     foo = Library('foo', tmpdir.strpath)
 
-    needed = NeededResources(base_url='', versioning=True)
+    needed = NeededResources(versioning=True)
 
     assert (needed.library_url(foo) ==
             '/fanstatic/foo/:version:d41d8cd98f00b204e9800998ecf8427e')
 def test_library_url_hashing_norecompute(tmpdir):
     foo = Library('foo', tmpdir.strpath)
 
-    needed = NeededResources(
-        base_url='', versioning=True, recompute_hashes=False)
+    needed = NeededResources(versioning=True, recompute_hashes=False)
 
     url = needed.library_url(foo)
 
 def test_library_url_hashing_recompute(tmpdir):
     foo = Library('foo', tmpdir.strpath)
 
-    needed = NeededResources(
-        base_url='', versioning=True, recompute_hashes=True)
+    needed = NeededResources(versioning=True, recompute_hashes=True)
 
     url = needed.library_url(foo)
 
     x2 = Resource(foo, 'b.css')
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(y1)
 
     html = "<html><head>something more</head></html>"
     x2 = Resource(foo, 'b.css')
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(y1)
 
     top, bottom = needed.render_topbottom()
     x2 = Resource(foo, 'b.css')
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
 
-    needed = NeededResources(base_url='', bottom=True)
+    needed = NeededResources(bottom=True)
     needed.need(y1)
 
     top, bottom = needed.render_topbottom()
     x2 = Resource(foo, 'b.css')
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
 
-    needed = NeededResources(base_url='', bottom=True, force_bottom=True)
+    needed = NeededResources(bottom=True, force_bottom=True)
     needed.need(y1)
 
     top, bottom = needed.render_topbottom()
     y1 = Resource(foo, 'c.js', depends=[x1, x2])
     y2 = Resource(foo, 'y2.js', bottom=True)
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(y1)
     needed.need(y2)
     top, bottom = needed.render_topbottom()
     assert top == '''\
 <link rel="stylesheet" type="text/css" href="/fanstatic/foo/b.css" />
 <script type="text/javascript" src="/fanstatic/foo/a.js"></script>
-<script type="text/javascript" src="/fanstatic/foo/c.js"></script>
-<script type="text/javascript" src="/fanstatic/foo/y2.js"></script>'''
+<script type="text/javascript" src="/fanstatic/foo/y2.js"></script>
+<script type="text/javascript" src="/fanstatic/foo/c.js"></script>'''
     assert bottom == ''
 
-    needed = NeededResources(base_url='', bottom=True)
+    needed = NeededResources(bottom=True)
     needed.need(y1)
     needed.need(y2)
     top, bottom = needed.render_topbottom()
     assert bottom == '''\
 <script type="text/javascript" src="/fanstatic/foo/y2.js"></script>'''
 
-    needed = NeededResources(base_url='', bottom=True, force_bottom=True)
+    needed = NeededResources(bottom=True, force_bottom=True)
     needed.need(y1)
     needed.need(y2)
     top, bottom = needed.render_topbottom()
 <link rel="stylesheet" type="text/css" href="/fanstatic/foo/b.css" />'''
     assert bottom == '''\
 <script type="text/javascript" src="/fanstatic/foo/a.js"></script>
-<script type="text/javascript" src="/fanstatic/foo/c.js"></script>
-<script type="text/javascript" src="/fanstatic/foo/y2.js"></script>'''
+<script type="text/javascript" src="/fanstatic/foo/y2.js"></script>
+<script type="text/javascript" src="/fanstatic/foo/c.js"></script>'''
 
 # XXX add sanity checks: cannot declare something bottom safe while
 # what it depends on isn't bottom safe
 
     html = "<html><head>rest of head</head><body>rest of body</body></html>"
 
-    needed = NeededResources(base_url='', bottom=True, force_bottom=True)
+    needed = NeededResources(bottom=True, force_bottom=True)
     needed.need(y1)
     assert needed.render_topbottom_into_html(html) == '''\
 <html><head>
 <script type="text/javascript" src="/fanstatic/foo/c.js"></script></body></html>'''
 
 
-def test_sorting_resources():
-    foo = Library('foo', '')
-
-    a1 = Resource(foo, 'a1.js')
-    a2 = Resource(foo, 'a2.js', depends=[a1])
-    a3 = Resource(foo, 'a3.js', depends=[a2])
-    a4 = Resource(foo, 'a4.js', depends=[a1])
-    a5 = Resource(foo, 'a5.js', depends=[a4, a3])
-
-    assert sort_resources_topological([a5, a3, a1, a2, a4]) == [
-        a1, a4, a2, a3, a5]
-
-
 def test_inclusion_renderers():
     assert sorted(
         [(order, key) for key, (order, _) in inclusion_renderers.items()]) == [
     register_inclusion_renderer('.unknown', render_unknown)
     a = Resource(foo, 'nothing.unknown')
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(a)
     assert needed.render() == ('<link rel="unknown" href="/fanstatic/foo/nothing.unknown" />')
 
     c = Resource(foo, 'something.css')
     d = Resource(foo, 'something.ico')
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(a)
     needed.need(b)
     needed.need(c)
                 url)
 
     a = Resource(foo, 'printstylesheet.css', renderer=render_print_css)
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(a)
     assert needed.render() == """\
 <link rel="stylesheet" type="text/css" href="/fanstatic/foo/printstylesheet.css" media="print"/>"""
     b = Resource(foo, 'regular.css')
     c = Resource(foo, 'something.js')
 
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(a)
     needed.need(b)
     needed.need(c)
             return '<myresource reference="%s/%s"/>' % (library_url, self.relpath)
 
     a = MyResource(foo, 'printstylesheet.css')
-    needed = NeededResources(base_url='')
+    needed = NeededResources()
     needed.need(a)
     assert needed.render() == """\
 <myresource reference="/fanstatic/foo/printstylesheet.css"/>"""
     assert needed.resources() == []
     needed.need(a4)
     needed.need(a5)
-    assert needed.resources() == [a1, a4, a2, a3, a5]
+    assert needed.resources() == [a1, a2, a4, a3, a5]
 
 
 def test_convenience_clear():
     clear_needed()
     assert needed.resources() == []
     z2.need()
-    assert needed.resources() == [z1, x1, z2]
-
+    assert needed.resources() == [x1, z1, z2]
 
 def test_normalize_resource():
     foo = Library('foo', '')
     r1 = Resource(foo, 'f.js')
     assert normalize_resource(foo, r1) == r1
 
+def test_sort_group_per_renderer():
+    foo = Library('foo', '')
+    a_js = Resource(foo, 'a.js')
+    b_css = Resource(foo, 'b.css')
+    c_js = Resource(foo, 'c.js')
+    a1_js = Resource(foo, 'a1.js', depends=[b_css])
+
+    needed = NeededResources()
+    needed.need(a_js)
+    needed.need(b_css)
+    needed.need(c_js)
+    needed.need(a1_js)
+
+    assert needed.resources() == [b_css, a_js, c_js, a1_js]
+
+def test_sort_group_per_library():
+    foo = Library('foo', '')
+    bar = Library('bar', '')
+
+    e = Resource(foo, 'e.js')
+    d = Resource(foo, 'd.js', depends=[e])
+    c = Resource(bar, 'c.js', depends=[e])
+    b = Resource(bar, 'b.js')
+    a = Resource(bar, 'a.js', depends=[c])
+
+    needed = NeededResources()
+    needed.need(a)
+    needed.need(b)
+    needed.need(c)
+    needed.need(d)
+    needed.need(e)
+
+    assert needed.resources() == [e, d, b, c, a]
+
+def test_sort_library_by_name():
+    b_lib = Library('b_lib', '')
+    a_lib = Library('a_lib', '')
+
+    a_a = Resource(a_lib, 'a.js')
+    a_b = Resource(b_lib, 'a.js')
+
+    needed = NeededResources()
+    needed.need(a_b)
+    needed.need(a_a)
+
+    assert needed.resources() == [a_a, a_b]
+
+def test_sort_resources_libraries_together():
+    K = Library('K', '')
+    L = Library('L', '')
+    M = Library('M', '')
+    N = Library('N', '')
+
+    k1 = Resource(K, 'k1.js')
+    l1 = Resource(L, 'l1.js')
+    m1 = Resource(M, 'm1.js', depends=[k1])
+    m2 = Resource(M, 'm2.js', depends=[l1])
+    n1 = Resource(N, 'n1.js', depends=[m1])
+
+    needed = NeededResources()
+    needed.need(m1)
+    needed.need(m2)
+    # sort_resources makes an efficient ordering, grouping m1 and m2 together
+    # after their dependencies (they are in the same library)
+    assert needed.resources() == [k1, l1, m1, m2]
+
+    needed = NeededResources()
+    needed.need(n1)
+    needed.need(m2)
+    # the order is unaffected by the ordering of inclusions
+    assert needed.resources() == [k1, l1, m1, m2, n1]
+
+def test_sort_resources_library_sorting():
+    # a complicated example that makes sure libraries are sorted
+    # correctly to obey ordering constraints but still groups them
+    X = Library('X', '')
+    Y = Library('Y', '')
+    Z = Library('Z', '')
+
+    a = Resource(X, 'a.js')
+    b = Resource(Z, 'b.js', depends=[a])
+
+    c = Resource(Y, 'c.js')
+    c1 = Resource(Y, 'c1.js', depends=[c])
+    c2 = Resource(Y, 'c2.js', depends=[c1])
+    d = Resource(Z, 'd.js', depends=[c])
+    e = Resource(Z, 'e.js')
+
+    needed = NeededResources()
+    needed.need(b)
+    needed.need(c2)
+    needed.need(d)
+    needed.need(e)
+
+    assert needed.resources() == [a, c, c1, c2, e, b, d]
+
+def test_sort_resources_library_sorting_by_name():
+    # these libraries are all at the same level so should be sorted by name
+    X = Library('X', '')
+    Y = Library('Y', '')
+    Z = Library('Z', '')
+
+    a = Resource(X, 'a.js')
+    b = Resource(Y, 'b.js')
+    c = Resource(Z, 'c.js')
+
+    needed = NeededResources()
+    needed.need(a)
+    needed.need(b)
+    needed.need(c)
+
+    assert needed.resources() == [a, b, c]
+
+def test_sort_resources_library_sorting_by_name_deeper():
+    X = Library('X', '')
+    Y = Library('Y', '')
+    Z = Library('Z', '')
+
+    # only X and Z will be at the same level now
+    a = Resource(X, 'a.js')
+    c = Resource(Z, 'c.js')
+    b = Resource(Y, 'b.js', depends=[a, c])
+
+    needed = NeededResources()
+    needed.need(b)
+    assert needed.resources() == [a, c, b]
+
+def test_library_nr():
+    X = Library('X', '')
+    Y = Library('Y', '')
+    Z = Library('Z', '')
+
+    # only X and Z will be at the same level now
+    a = Resource(X, 'a.js')
+    c = Resource(Z, 'c.js')
+    b = Resource(Y, 'b.js', depends=[a, c])
+
+    assert a.library_nr == 0
+    assert c.library_nr == 0
+    assert b.library_nr == 1
+
+def test_library_dependency_cycles():
+    A = Library('A', '')
+    B = Library('B', '')
+
+    a1 = Resource(A, 'a1.js')
+    b1 = Resource(B, 'b1.js')
+    a2 = Resource(A, 'a2.js', depends=[b1])
+
+    # This definition would create a library dependency cycle if permitted.
+    with pytest.raises(LibraryDependencyCycle):
+        b2 = Resource(B, 'b2.js', depends=[a1])
+
+    # This is an example of an indirect library dependency cycle.
+    C = Library('C', '')
+    D = Library('D', '')
+    E = Library('E', '')
+    c1 = Resource(C, 'c1.js')
+    d1 = Resource(D, 'd1.js', depends=[c1])
+    d2 = Resource(D, 'd2.js')
+    e1 = Resource(E, 'e1.js', depends=[d2])
+
+    # ASCII ART
+    #
+    #  C      E      D
+    #
+    #  c1 <--------- d1
+    #
+    #  c2 --> e1 --> d2
+    #
+    with pytest.raises(LibraryDependencyCycle):
+        c2 = Resource(C, 'c2.js', depends=[e1])
+
+
+def test_sort_resources_topological():
+    foo = Library('foo', '')
+
+    a1 = Resource(foo, 'a1.js')
+    a2 = Resource(foo, 'a2.js', depends=[a1])
+    a3 = Resource(foo, 'a3.js', depends=[a2])
+    a4 = Resource(foo, 'a4.js', depends=[a1])
+    a5 = Resource(foo, 'a5.js', depends=[a4, a3])
+
+    assert sort_resources_topological([a5, a3, a1, a2, a4]) == [
+        a1, a4, a2, a3, a5]
+
+def test_bundle():
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.css')
+    b = Resource(foo, 'b.css')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+
+    assert len(needed.resources()) == 1
+    bundle = needed.resources()[0]
+    assert bundle.resources() == [a, b]
+
+def test_bundle_dont_bundle_at_the_end():
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.css')
+    b = Resource(foo, 'b.css')
+    c = Resource(foo, 'c.css', dont_bundle=True)
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+    needed.need(c)
+
+    resources = needed.resources()
+    assert len(resources) == 2
+    assert resources[0].resources() == [a, b]
+    assert resources[-1] is c
+
+def test_bundle_dont_bundle_at_the_start():
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.css', dont_bundle=True)
+    b = Resource(foo, 'b.css')
+    c = Resource(foo, 'c.css')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+    needed.need(c)
+
+    resources = needed.resources()
+    assert len(resources) == 2
+    assert resources[0] is a
+    assert resources[1].resources() == [b, c]
+
+def test_bundle_dont_bundle_in_the_middle():
+    # now construct a scenario where a dont_bundle resource is in the way
+    # of bundling
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.css')
+    b = Resource(foo, 'b.css', dont_bundle=True)
+    c = Resource(foo, 'c.css')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+    needed.need(c)
+
+    resources = needed.resources()
+    assert len(resources) == 3
+    assert resources[0] is a
+    assert resources[1] is b
+    assert resources[2] is c
+
+def test_bundle_different_renderer():
+    # resources with different renderers aren't bundled
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.css')
+    b = Resource(foo, 'b.js')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+
+    resources = needed.resources()
+
+    assert len(resources) == 2
+    assert resources[0] is a
+    assert resources[1] is b
+
+def test_bundle_different_library():
+    # resources with different libraries aren't bundled
+    l1 = Library('l1', '')
+    l2 = Library('l2', '')
+    a = Resource(l1, 'a.js')
+    b = Resource(l2, 'b.js')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+
+    resources = needed.resources()
+
+    assert len(resources) == 2
+    assert resources[0] is a
+    assert resources[1] is b
+
+def test_bundle_different_directory():
+    # resources with different directories aren't bundled
+    foo = Library('foo', '')
+    a = Resource(foo, 'first/a.css')
+    b = Resource(foo, 'second/b.css')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    needed.need(b)
+
+    resources = needed.resources()
+
+    assert len(resources) == 2
+    assert resources[0] is a
+    assert resources[1] is b
+
+def test_bundle_empty_list():
+    # we can successfully bundle an empty list of resources
+    needed = NeededResources(bundle=True)
+
+    resources = needed.resources()
+    assert resources == []
+
+def test_bundle_single_entry():
+    # we can successfully bundle a single resource (it's not bundled though)
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.js')
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    resources = needed.resources()
+
+    assert resources == [a]
+
+def test_bundle_single_dont_bundle_entry():
+    foo = Library('foo', '')
+    a = Resource(foo, 'a.js', dont_bundle=True)
+
+    needed = NeededResources(bundle=True)
+    needed.need(a)
+    resources = needed.resources()
+
+    assert resources == [a]
+
 # XXX tests for hashed resources when this is enabled. Needs some plausible
 # directory to test for hashes
 

File fanstatic/test_injector.py

         start_response('200 OK', [])
         needed = get_needed()
         needed.need(y1)
-        needed.base_url = 'http://testapp'
+        needed.set_base_url('http://testapp')
         return ['<html><head></head><body</body></html>']
 
     wrapped_app = Injector(app)
         start_response('200 OK', [('Content-Type', 'text/plain')])
         needed = get_needed()
         needed.need(y1)
-        needed.base_url = 'http://testapp'
         return ['<html><head></head><body</body></html>']
 
     wrapped_app = Injector(app)

File fanstatic/test_wsgi.py

         start_response('200 OK', [])
         needed = get_needed()
         needed.need(y1)
-        needed.base_url = 'http://testapp'
         return ['<html><head></head><body</body></html>']
 
-    wrapped_app = Fanstatic(app)
+    wrapped_app = Fanstatic(app, base_url='http://testapp')
 
     request = webob.Request.blank('/')
     response = request.get_response(wrapped_app)