Repository: getpelican/pelican

Issue:
Images can be anywhere in the content directory, not only static paths
Hello
Using Pelican version 3.2.2

```
→ pelican --version
3.2.2
```

With the current file

```
Title: Network Panel in Firefox 23 Developer Tools
Date: 2013-08-07
Slug: network-panel-firefox
Status: draft

[…]
![Firefox 22 Screenshot with developer tools](|filename|firefox22-devtools.jpg)

[…]
![Firefox 23 Screenshot with developer tools](|filename|firefox23-devtools.jpg)
```

In the current structure

```
→ ls -1 content/2013/08/07/
firefox22-devtools.jpg
firefox23-devtools.jpg
network-panel.md
```

And generating the html

```
→ make html
```

creates the output among other lines.

```
WARNING: Unable to find 2013/08/07/firefox22-devtools.jpg, skipping url replacement
WARNING: Unable to find 2013/08/07/firefox23-devtools.jpg, skipping url replacement
```

Then accessing the draft folder

```
http://localhost:8000/drafts/network-panel-firefox.html
```

Images are not displayed. Inside the HTML, the code is

```
<p><img alt="Firefox 23 Screenshot with developer tools" 
       src="|filename|firefox23-devtools.jpg" /></p>
```

`make rsync_upload` has the same issue

Using 

```
RELATIVE_URLS = True
```

doesn't solve the issue.
## What I would expect

The images to be displayed and put at the right place on `2013/08/07/firefox23-devtools.jpg`.

Thanks.



Discussion:
This seems like a dup of #865, does it not?

@justinmayer maybe. Let me do another test without the draft status to be sure.

OK. Similar issue, but not related to draft status only.

```
WARNING: Unable to find 2012/10/26/script-not-working.png, skipping url replacement
```

On this  [blog post](http://www.otsukare.info/2012/10/26/usability-of-browsers-error-messages)

View source 

```
<p><img alt="Modal window" src="|filename|script-not-working.png" /></p>
```

In https://github.com/getpelican/pelican/blob/master/pelican/contents.py#L212

```
if value in self._context['filenames']:
```

is a table of all objects supposed to be part of the site (there will be a memory tax here for big sites, but that's another topic ;) ). I just checked and see that the image files which are side by side with the current blog post are not listed at all. So the table is incomplete and then the else is executed.

> [...] the image files which are side by side with the current blog post are not listed at all

If I'm not mistaken, this is how the `STATIC_PATHS` setting is designed — i.e., only images within directories listed in `STATIC_PATHS` will be copied over.

Oh so you can't do that?

```
→ ls -1 content/2013/08/07/
firefox22-devtools.jpg
firefox23-devtools.jpg
network-panel.md
```

Putting everything in one folder is not wise. It's good for logos and stuff but for the rest not really a good idea. clutter the namespace makes the folder a mess, far from home (aka the article).

OK I guess I will have to hack a bit myself to find out how to modify the code. :/

Yes, upon further review, this is actually documented quite thoroughly (probably by me) in the [Linking to internal content](http://docs.getpelican.com/en/latest/getting_started.html#linking-to-internal-content) section.

That said, I agree that additional flexibility would be beneficial. I'm sorry this is not currently available out-of-the-box. I'm sure any assistance you could provide in implementing said flexibility would be appreciated by others as well.  :^)

I'm changing the title to make it easier to find the feature for future search.

+1 I have this use case. I’d like to explain the rationale for keeping all content together (rather than text in one folder, and relevant image, binary, or other content formats in a separate asset folder).
1. If I write a post that includes one or several content images (illustration, figures, screenshots), or self-hosted videos, or relevant PDFs, it makes logical sense to keep related content together in the same folder. This is especially true — and useful — if I use one folder per post or page.
2. When people routinely wrote HTML sites by hand, this is the kind of pattern they used: keep related content together.
3. CMSs introduced technical limitations: either the text was in a database, or it was in a specific repository of text files (a XML database for instance). So virtually every CMS decided to use a separate directory for “static” or “media” files, called `uploads` or `media` or something similar. See for instance the infamous `wp-content/uploads` in WordPress.
4. In my view this is an antipattern, justified by technical limitations and the database/media dichotomy, and it’s regrettable to reproduce it in content management applications that use the folder and file structure for organizing and editing content.

Anyway, I understand that Pelican was designed with this (anti)pattern, and that offering the behavior in this feature request as an option could create as many usability issues as it solves.
(So I’m going to use a different tool for my website. Probably Wintersmith.)

I do understand the rationale behind this choice, pelican was build without this in mind so its current implementation doesn't allow this, but it doesn't mean it cannot evolve to take this into consideration.

However, the challenge here is to have a way to attach images with other static content, in the .md or .rst files. One simple way to solve this would be to list the images in a metadata directive, but that sounds a bit complicated to me (for the user).

Another way to solve this is parse the rendered HTML to find links and check which ones are starting with `:filename:`. We're already doing this [here](https://github.com/getpelican/pelican/blob/master/pelican/contents.py#L177) and I believe one could extend this to allow what you're talking about.

If someone wants to step-up and implement it, I would be happy to review.

I put together a hackish workaround for this: a plugin to copy over the static files: https://gist.github.com/agateau/7043146 and a [patch](https://github.com/agateau/pelican/tree/wip/dirname) to allow referring to them using `{dirname}` in the `src` attribute of images.

@agateau I tried your plugin briefly and it seemed to work nicely.

@fvsch good to know. I found out it is currently broken if pages are not stored in the root folder of the repository though. I need to find some time to think about it.

A simple flag to copy over all static images would be a good work-a-round for the time being.
Much more smarter would it be to include the statics that are being linked to in the source files.
`![foo](image.png)` could be a trigger to copy over `image.png` at the same dir as the current file.
Not thought through this ... but to be honest I kind of assumed it would work that way when I found it is not.

So we all agree that this would be a useful feature. Would someone like to implement an appropriate solution?

So Ive had the same exact problem; and been screwing around with this pretty much all night tonight :) First-time Python + never used Pelican before doesn't help much. 

Here's what I think a problem with @agateau's approach is... 

You're using a Generator. The problem is; your Generator is called _after_ all the default Generators are already done, and with all the content written to disk. So at that point it's too late. I toyed around with trying to post-process html files and that doesn't work since you never know where your img tag might show up at the end (it's up to the Template, based on Content). 

@karlcow , Im hitting exactly the same problem as you -- in contents.py; _update_content(); it wants the file to be there in the context to replace a relative link; except that it's not there (I didn't go down the path of figuring out why :) ). 

So  I cheated a bit. Added a plugin that kicks in on article_generator_finalized. At this point, there's enough state to know where the articles will end up with (so that the images can be placed in the right directories); yet the html is not yet generated -- allowing you to do your replacement routine on the content inside the Article object. Im doing a simple search-and-replace of {article} -- yes, naive me thinks that I will never use that combination of characters anywhere but a relative image reference. It obviously can be updated with a fancier regexp; at that point in execution the article's already HTML minus the template. 

With a quick modification and last line uncommented, this can be adopted for pages too (Ill do it tomorrow). 

Here's the code. Pardon bad Python; it's my first time ;) 

``` python
import os
import logging
import shutil
import re

from pprint import pprint

from sys import exit;

from pelican import signals
from pelican.generators import Generator
from pelican.readers import BaseReader
from pelican.utils import mkdir_p, copy


logger = logging.getLogger(__name__)

INLINE_STATIC_EXTENSIONS = ('png', 'jpeg', 'jpg');
ARTICLE_TAG = '{article}';

def process_images(generator):
  print("Image processing generator called");

  for article in generator.articles:
    article_path = os.path.dirname(article.source_path);

    out_path = os.path.join(article._context['OUTPUT_PATH'], 
                            article.save_as);
    out_dir = os.path.dirname(out_path);

    base_url = os.path.join(article._context['SITEURL'], 
                            os.path.dirname(article.save_as));
    base_url = base_url.replace('\\', '/');

    os.makedirs(out_dir);

    for f in generator.get_files(article_path, 
                                 extensions=INLINE_STATIC_EXTENSIONS):

         src = os.path.join(article._context['PATH'], f);
         print(" ", src, "\n", out_dir);
         shutil.copy(src, out_dir);

    article._content = article._content.replace(ARTICLE_TAG, base_url);

def register():
  signals.article_generator_finalized.connect(process_images);
#  signals.page_generator_finalized.connect(process_images);
```

I wanted this for my site so I created a plugin for it: [pelican-autostatic](https://github.com/AlexJF/pelican-autostatic).

It basically only copies those static elements that are indeed referenced and is pluggable (with [pelican-advthumbnailer](https://github.com/AlexJF/pelican-advthumbnailer) for instance).

Personally find it fits my workflow better so it might help someone else.

Here's an [example usage](https://raw.githubusercontent.com/AlexJF/alexjf.net/fe0e1ed6a58b05fc2428a4d319f7cb53a6ec99cb/content/blog/personal/2013/07/14/barcelona-review/post.md) and [result](http://www.alexjf.net/blog/personal/barcelona-review/)

@ametaireau and @justinmayer, I'm interested in working on this.

Each of the attempts I've seen here so far has one or more of these problems:
1. Duplicating `StaticGenerator`'s job. Since we already have a generator for static files, I'd like it to handle all of them. This would minimize duplicate code, consistently update internal variables, and ensure that all static files respect the same config settings.
2. Requiring the user to maintain a list of filename suffixes to be copied as static files. It would be a hassle to track down all those suffixes when migrating a site to Pelican, and would result in broken links if the user forgets to add a suffix to that list. I'd rather just designate a directory as containing both static and content source files, and let Pelican figure out which is which. That would mean less work for the user and less chance of error.
3. No way to copy static files that are not linked by any pages/articles. This is a problem for sites with standalone files, such as secret downloads, or music files linked by playlists instead of pages.
4. No way to make static files end up in the same output directory as the articles that link to them. This is a non-issue when a site's output directory tree looks just like its source tree, because a static file's `SAVE_AS` path only has to replicate its source path in order to end up in the right place. It is mainly a problem for blog posts, because their `SAVE_AS` paths often do not match their source paths, but are instead generated based on metadata that the static files not share.

I believe problems 1-3 can be solved with just a couple of steps:
- Configure `STATIC_PATHS` to include the content directory. Anyone can do this with Pelican today. It will copy the static files into the output directory, but will also copy the content source files, which is not what most of us want. The next step fixes that.
- Teach `StaticGenerator` to ignore content source files. I have already implemented this, and can create a pull request at any time. It's a pretty lightweight patch.

I haven't submitted a pull request yet because I've been pondering how best to solve problem 4. I think it makes sense to create a config setting for directories whose static files should be copied as siblings of the articles that link to them, effectively overriding their normal `SAVE_AS` paths. That would work well when only one article links to a file, but what happens when two or more articles link to the same file, and those articles live in different (generated) directories? How should Pelican decide which article the static file should follow?

The best answer I have so far is to copy the file to both directories, which would work nicely for users, but it would change the current 1:1 mapping between source path and save-as path, and would mean generating context (by adding new `Static` objects) after all the context generators had already finished running. Maybe the latter wouldn't be so bad if done through a signal interface? What do the Pelican maintainers think about this?  Does someone have a better suggestion?

Now that I think of it, we don't truly have a 1:1 mapping between source paths and save-as paths today. If someone points `STATIC_PATHS` and `PAGE_PATHS` at the same directory, Pelican 3.4 will create two different `Content` objects (a `Static` object and a `Page` object) for each source path. So, maybe my concern about preserving 1:1 isn't important.

A simpler answer: Let a static file's target directory be overridden by the first article found linking to it, but no others. Any additional articles' links would be rewritten to reference it in the already-overridden location. This has a few advantages over copying the file to multiple locations:
- No need to create new `Static` objects after the context generators have been run.
- No need for multiple save-as paths per source path.
- Reduced storage and bandwidth usage on the web server.

This would solve problem 4: allowing static files to live in the same generated directory as their linking articles, for what I expect are the vast majority of cases. For cases where several articles in different generated directories link to the same file, the links would still work; they just wouldn't be as pretty.

One thing still bothers me a little: If the first article found linking to a file is the one that determines its location, and another such article is ever added with a different save-as directory, the new one might be found first and change the static file's location in future site builds. Any off-site links to that file (perhaps on a third-party site or in an email message) would then break. Maybe that's okay, though. It could only happen for files that had been explicitly marked as belonging to articles in generated directories, which basically means blog posts, and I expect most people would want to discourage off-site deep links to those files anyway. The article locations would remain stable, so linking to them instead would be just fine. A note about this in the documentation might take care of it.

I created a pull request that solves problems 1-3. I believe I have a solid design that will build on that work and solve problem 4, and I plan to implement it soon. I'd like to have this all done in time for others to try it before the upcoming Debian freeze.

By the way, is anyone reading this stuff? I hope I'm not wasting my time here.

@foresto, we welcome your contribution. I share your concern about the Debian freeze. The best you can do is approach people and devs on IRC and keep nagging people and make them test it :wink: 

Hey Forest. I am indeed reading this.  (^_^)  This is a long-requested feature, and it would certainly be nice to include your work in the upcoming 3.5 release.

Great. Thanks for saying so, both of you. In that case, I'll continue working on problem 4 (sticking/attaching static files to articles).

Awesome — sounds great, Forest.

Speaking of the Debian freeze, anyone available to sprint on Pelican on, say, the Nov. 1-2 weekend?

I'm nearly ready to submit a patch for attaching static files to articles. It depends on changes I made in my earlier patch, but since the two are conceptually separate and since some folks have already reviewed the first one, I don't think squashing them together makes sense.

Should I just add this patch as a second changeset to PR #1509, or should I open a new PR along with a note that the first one must be merged first?

Please open a new PR along with a note that the first one must be merged first. My goal is to set aside some time tomorrow morning to review in greater detail.


Resolving patch:
diff --git a/pelican/__init__.py b/pelican/__init__.py
--- a/pelican/__init__.py
+++ b/pelican/__init__.py
@@ -208,7 +208,9 @@ def get_generator_classes(self):
                     logger.debug('Found generator: %s', v)
                     generators.append(v)
 
-        # StaticGenerator runs last so it can see which files the others handle
+        # StaticGenerator must run last, so it can identify files that
+        # were skipped by the other generators, and so static files can
+        # have their output paths overridden by the {attach} link syntax.
         generators.append(StaticGenerator)
         return generators
 
diff --git a/pelican/contents.py b/pelican/contents.py
--- a/pelican/contents.py
+++ b/pelican/contents.py
@@ -219,7 +219,7 @@ def replacer(m):
             origin = m.group('path')
 
             # XXX Put this in a different location.
-            if what == 'filename':
+            if what in {'filename', 'attach'}:
                 if path.startswith('/'):
                     path = path[1:]
                 else:
@@ -234,9 +234,16 @@ def replacer(m):
                     if unquoted_path in self._context['filenames']:
                         path = unquoted_path
 
-                if self._context['filenames'].get(path):
-                    origin = '/'.join((siteurl,
-                             self._context['filenames'][path].url))
+                linked_content = self._context['filenames'].get(path)
+                if linked_content:
+                    if what == 'attach':
+                        if isinstance(linked_content, Static):
+                            linked_content.attach_to(self)
+                        else:
+                            logger.warning("%s used {attach} link syntax on a "
+                                "non-static file. Use {filename} instead.",
+                                self.get_relative_source_path())
+                    origin = '/'.join((siteurl, linked_content.url))
                     origin = origin.replace('\\', '/')  # for Windows paths.
                 else:
                     logger.warning(
@@ -359,6 +366,10 @@ class Quote(Page):
 
 @python_2_unicode_compatible
 class Static(Page):
+    def __init__(self, *args, **kwargs):
+        super(Static, self).__init__(*args, **kwargs)
+        self._output_location_referenced = False
+
     @deprecated_attribute(old='filepath', new='source_path', since=(3, 2, 0))
     def filepath():
         return None
@@ -371,6 +382,65 @@ def src():
     def dst():
         return None
 
+    @property
+    def url(self):
+        # Note when url has been referenced, so we can avoid overriding it.
+        self._output_location_referenced = True
+        return super(Static, self).url
+
+    @property
+    def save_as(self):
+        # Note when save_as has been referenced, so we can avoid overriding it.
+        self._output_location_referenced = True
+        return super(Static, self).save_as
+
+    def attach_to(self, content):
+        """Override our output directory with that of the given content object.
+        """
+        # Determine our file's new output path relative to the linking document.
+        # If it currently lives beneath the linking document's source directory,
+        # preserve that relationship on output. Otherwise, make it a sibling.
+        linking_source_dir = os.path.dirname(content.source_path)
+        tail_path = os.path.relpath(self.source_path, linking_source_dir)
+        if tail_path.startswith(os.pardir + os.sep):
+            tail_path = os.path.basename(tail_path)
+        new_save_as = os.path.join(
+            os.path.dirname(content.save_as), tail_path)
+
+        # We do not build our new url by joining tail_path with the linking
+        # document's url, because we cannot know just by looking at the latter
+        # whether it points to the document itself or to its parent directory.
+        # (An url like 'some/content' might mean a directory named 'some'
+        # with a file named 'content', or it might mean a directory named
+        # 'some/content' with a file named 'index.html'.) Rather than trying
+        # to figure it out by comparing the linking document's url and save_as
+        # path, we simply build our new url from our new save_as path.
+        new_url = path_to_url(new_save_as)
+
+        def _log_reason(reason):
+            logger.warning("The {attach} link in %s cannot relocate %s "
+                "because %s. Falling back to {filename} link behavior instead.",
+                content.get_relative_source_path(),
+                self.get_relative_source_path(), reason,
+                extra={'limit_msg': "More {attach} warnings silenced."})
+
+        # We never override an override, because we don't want to interfere
+        # with user-defined overrides that might be in EXTRA_PATH_METADATA.
+        if hasattr(self, 'override_save_as') or hasattr(self, 'override_url'):
+            if new_save_as != self.save_as or new_url != self.url:
+                _log_reason("its output location was already overridden")
+            return
+
+        # We never change an output path that has already been referenced,
+        # because we don't want to break links that depend on that path.
+        if self._output_location_referenced:
+            if new_save_as != self.save_as or new_url != self.url:
+                _log_reason("another link already referenced its location")
+            return
+
+        self.override_save_as = new_save_as
+        self.override_url = new_url
+
 
 def is_valid_content(content, f):
     try:


Test patch:
diff --git a/pelican/tests/test_contents.py b/pelican/tests/test_contents.py
--- a/pelican/tests/test_contents.py
+++ b/pelican/tests/test_contents.py
@@ -4,12 +4,13 @@
 import six
 from sys import platform
 import locale
+import os.path
 
 from pelican.tests.support import unittest, get_settings
 
-from pelican.contents import Page, Article, URLWrapper
+from pelican.contents import Page, Article, Static, URLWrapper
 from pelican.settings import DEFAULT_CONFIG
-from pelican.utils import truncate_html_words, SafeDatetime
+from pelican.utils import path_to_url, truncate_html_words, SafeDatetime
 from pelican.signals import content_object_init
 from jinja2.utils import generate_lorem_ipsum
 
@@ -401,6 +402,148 @@ def test_slugify_category_author(self):
         self.assertEqual(article.save_as, 'obrien/csharp-stuff/fnord/index.html')
 
 
+class TestStatic(unittest.TestCase):
+
+    def setUp(self):
+
+        self.settings = get_settings(
+            STATIC_SAVE_AS='{path}',
+            STATIC_URL='{path}',
+            PAGE_SAVE_AS=os.path.join('outpages', '{slug}.html'),
+            PAGE_URL='outpages/{slug}.html')
+        self.context = self.settings.copy()
+
+        self.static = Static(content=None, metadata={}, settings=self.settings,
+            source_path=os.path.join('dir', 'foo.jpg'), context=self.context)
+
+        self.context['filenames'] = {self.static.source_path: self.static}
+
+    def tearDown(self):
+        pass
+
+    def test_attach_to_same_dir(self):
+        """attach_to() overrides a static file's save_as and url.
+        """
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'fakepage.md'))
+        self.static.attach_to(page)
+
+        expected_save_as = os.path.join('outpages', 'foo.jpg')
+        self.assertEqual(self.static.save_as, expected_save_as)
+        self.assertEqual(self.static.url, path_to_url(expected_save_as))
+
+    def test_attach_to_parent_dir(self):
+        """attach_to() preserves dirs inside the linking document dir.
+        """
+        page = Page(content="fake page", metadata={'title': 'fakepage'},
+            settings=self.settings, source_path='fakepage.md')
+        self.static.attach_to(page)
+
+        expected_save_as = os.path.join('outpages', 'dir', 'foo.jpg')
+        self.assertEqual(self.static.save_as, expected_save_as)
+        self.assertEqual(self.static.url, path_to_url(expected_save_as))
+
+    def test_attach_to_other_dir(self):
+        """attach_to() ignores dirs outside the linking document dir.
+        """
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'otherdir', 'fakepage.md'))
+        self.static.attach_to(page)
+
+        expected_save_as = os.path.join('outpages', 'foo.jpg')
+        self.assertEqual(self.static.save_as, expected_save_as)
+        self.assertEqual(self.static.url, path_to_url(expected_save_as))
+
+    def test_attach_to_ignores_subsequent_calls(self):
+        """attach_to() does nothing when called a second time.
+        """
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'fakepage.md'))
+
+        self.static.attach_to(page)
+
+        otherdir_settings = self.settings.copy()
+        otherdir_settings.update(dict(
+            PAGE_SAVE_AS=os.path.join('otherpages', '{slug}.html'),
+            PAGE_URL='otherpages/{slug}.html'))
+        otherdir_page = Page(content="other page",
+            metadata={'title': 'otherpage'}, settings=otherdir_settings,
+            source_path=os.path.join('dir', 'otherpage.md'))
+
+        self.static.attach_to(otherdir_page)
+
+        otherdir_save_as = os.path.join('otherpages', 'foo.jpg')
+        self.assertNotEqual(self.static.save_as, otherdir_save_as)
+        self.assertNotEqual(self.static.url, path_to_url(otherdir_save_as))
+
+    def test_attach_to_does_nothing_after_save_as_referenced(self):
+        """attach_to() does nothing if the save_as was already referenced.
+        (For example, by a {filename} link an a document processed earlier.)
+        """
+        original_save_as = self.static.save_as
+
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'fakepage.md'))
+        self.static.attach_to(page)
+
+        self.assertEqual(self.static.save_as, original_save_as)
+        self.assertEqual(self.static.url, path_to_url(original_save_as))
+
+    def test_attach_to_does_nothing_after_url_referenced(self):
+        """attach_to() does nothing if the url was already referenced.
+        (For example, by a {filename} link an a document processed earlier.)
+        """
+        original_url = self.static.url
+
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'fakepage.md'))
+        self.static.attach_to(page)
+
+        self.assertEqual(self.static.save_as, self.static.source_path)
+        self.assertEqual(self.static.url, original_url)
+
+    def test_attach_to_does_not_override_an_override(self):
+        """attach_to() does not override paths that were overridden elsewhere.
+        (For example, by the user with EXTRA_PATH_METADATA)
+        """
+        customstatic = Static(content=None,
+            metadata=dict(save_as='customfoo.jpg', url='customfoo.jpg'),
+            settings=self.settings,
+            source_path=os.path.join('dir', 'foo.jpg'),
+            context=self.settings.copy())
+
+        page = Page(content="fake page",
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'fakepage.md'))
+
+        customstatic.attach_to(page)
+
+        self.assertEqual(customstatic.save_as, 'customfoo.jpg')
+        self.assertEqual(customstatic.url, 'customfoo.jpg')
+
+    def test_attach_link_syntax(self):
+        """{attach} link syntax triggers output path override & url replacement.
+        """
+        html = '<a href="{attach}../foo.jpg">link</a>'
+        page = Page(content=html,
+            metadata={'title': 'fakepage'}, settings=self.settings,
+            source_path=os.path.join('dir', 'otherdir', 'fakepage.md'),
+            context=self.context)
+        content = page.get_content('')
+
+        self.assertNotEqual(content, html,
+            "{attach} link syntax did not trigger URL replacement.")
+
+        expected_save_as = os.path.join('outpages', 'foo.jpg')
+        self.assertEqual(self.static.save_as, expected_save_as)
+        self.assertEqual(self.static.url, path_to_url(expected_save_as))
+
+
 class TestURLWrapper(unittest.TestCase):
     def test_comparisons(self):
         # URLWrappers are sorted by name
diff --git a/pelican/tests/test_pelican.py b/pelican/tests/test_pelican.py
--- a/pelican/tests/test_pelican.py
+++ b/pelican/tests/test_pelican.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals, print_function
 
+import collections
 import os
 import sys
 from tempfile import mkdtemp
@@ -77,14 +78,17 @@ def assertDirsEqual(self, left_path, right_path):
         assert not err, err
 
     def test_order_of_generators(self):
-        # StaticGenerator must run last, so it can find files that were
-        # skipped by the other generators.
+        # StaticGenerator must run last, so it can identify files that
+        # were skipped by the other generators, and so static files can
+        # have their output paths overridden by the {attach} link syntax.
 
         pelican = Pelican(settings=read_settings(path=None))
         generator_classes = pelican.get_generator_classes()
 
         self.assertTrue(generator_classes[-1] is StaticGenerator,
             "StaticGenerator must be the last generator, but it isn't!")
+        self.assertIsInstance(generator_classes, collections.Sequence,
+            "get_generator_classes() must return a Sequence to preserve order")
 
     def test_basic_generation_works(self):
         # when running pelican without settings, it should pick up the default