Arbitrary Update 3099

Mon Mar 2, 2015

There's not much going on, but I figured I'd keep you in the loop anyhow.

Shameless Advertising

We've got SICPv2 starting at the Toronto Computer Science Reading Group. Or rather, it started last week. Anyway, when we did this the first time, a few people found out about it three-quarters of the way through, and expressed sentiments like "I wish I found out about this when you were starting out". It was enough people that they've managed to organize a second round. And, yes, if there are 9 or fewer core group members, we'll totally be handing these out:

The Squires of the Lambda Calculus badge

wik

Last time, I mentioned getting nix-the-package-manager up and running on my machine. And I mentioned setting up a Haskell environment with it. What I didn't mention is that some Haskell libraries are currently failing to install. As of this writing, that seems to include all of the Haskell web-frameworks other than scotty and snap. Yesod and happstack both error at compilation time with some odd type failures that I don't know enough about to diagnose. The specific problem I had this week involved that last one, which also happens to be the server used internally by gitit.

...
[ 6 of 38] Compiling Happstack.Server.Internal.TimeoutIO ( src/Happstack/Server/Internal/TimeoutIO.hs, dist/build/Happstack/Server/Internal/TimeoutIO.o )
[ 7 of 38] Compiling Happstack.Server.Internal.TimeoutSocket ( src/Happstack/Server/Internal/TimeoutSocket.hs, dist/build/Happstack/Server/Internal/TimeoutSocket.o )
[ 8 of 38] Compiling Happstack.Server.SURI.ParseURI ( src/Happstack/Server/SURI/ParseURI.hs, dist/build/Happstack/Server/SURI/ParseURI.o )
[ 9 of 38] Compiling Happstack.Server.SURI ( src/Happstack/Server/SURI.hs, dist/build/Happstack/Server/SURI.o )
[10 of 38] Compiling Happstack.Server.Internal.RFC822Headers ( src/Happstack/Server/Internal/RFC822Headers.hs, dist/build/Happstack/Server/Internal/RFC822Headers.o )
[11 of 38] Compiling Paths_happstack_server ( dist/build/autogen/Paths_happstack_server.hs, dist/build/Paths_happstack_server.o )
[12 of 38] Compiling Happstack.Server.Internal.Clock ( src/Happstack/Server/Internal/Clock.hs, dist/build/Happstack/Server/Internal/Clock.o )
[13 of 38] Compiling Happstack.Server.Internal.Cookie ( src/Happstack/Server/Internal/Cookie.hs, dist/build/Happstack/Server/Internal/Cookie.o )
[14 of 38] Compiling Happstack.Server.Internal.Types ( src/Happstack/Server/Internal/Types.hs, dist/build/Happstack/Server/Internal/Types.o )
[15 of 38] Compiling Happstack.Server.Internal.Multipart ( src/Happstack/Server/Internal/Multipart.hs, dist/build/Happstack/Server/Internal/Multipart.o )
[16 of 38] Compiling Happstack.Server.Internal.MessageWrap ( src/Happstack/Server/Internal/MessageWrap.hs, dist/build/Happstack/Server/Internal/MessageWrap.o )
[17 of 38] Compiling Happstack.Server.Types ( src/Happstack/Server/Types.hs, dist/build/Happstack/Server/Types.o )
[18 of 38] Compiling Happstack.Server.Internal.Monads ( src/Happstack/Server/Internal/Monads.hs, dist/build/Happstack/Server/Internal/Monads.o )

src/Happstack/Server/Internal/Monads.hs:69:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StT’
    In the instance declaration for ‘MonadTransControl ServerPartT’

src/Happstack/Server/Internal/Monads.hs:76:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StM’
    In the instance declaration for
      ‘MonadBaseControl b (ServerPartT m)’

src/Happstack/Server/Internal/Monads.hs:262:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StT’
    In the instance declaration for ‘MonadTransControl (FilterT a)’

src/Happstack/Server/Internal/Monads.hs:267:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StM’
    In the instance declaration for ‘MonadBaseControl b (FilterT a m)’

src/Happstack/Server/Internal/Monads.hs:315:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StT’
    In the instance declaration for ‘MonadTransControl WebT’

src/Happstack/Server/Internal/Monads.hs:327:5:
    Wrong category of family instance; declaration was for a type synonym
    In the newtype instance declaration for ‘StM’
    In the instance declaration for ‘MonadBaseControl b (WebT m)’
builder for ‘/nix/store/0rfsb0b07r0n0bq8d9mfn87r5hb391zb-haskell-happstack-server-ghc7.8.4-7.3.9-shared.drv’ failed with exit code 1
cannot build derivation ‘/nix/store/ihx7hcmpa10fpl73mwwrck65zpjgrmlr-haskell-gitit-ghc7.8.4-0.10.6.1-shared.drv’: 1 dependencies couldn't be built
error: build of ‘/nix/store/ihx7hcmpa10fpl73mwwrck65zpjgrmlr-haskell-gitit-ghc7.8.4-0.10.6.1-shared.drv’ failed

Given that literally all I needed at the time was

I just said "fuck it" and built my own. It's not generally the sort of thing I do, but judged that it would be a lot more fun and somewhat easier than installing Mediawiki and its markdown plugin. And I think I happened to be right in this case; the whole thing took about two hours or so, plus a half hour of cosmetic changes for very mild ease-of-use.

### wiki.py

from markdown2 import markdown
from subprocess import call, check_output
import datetime, os

def view_page(path, wiki="."):
    return markdown(view_raw_page(path, wiki=wiki))

def view_raw_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    try:
        with open(os.path.join(wiki, path), 'r') as f:
            return f.read()
    except IOError:
        raise PageNotFound()

def delete_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    full = os.path.join(wiki, path)
    if os.path.isfile(full):
        os.remove(full)
        commit(path, "Deleted '" + path + "'", repo=wiki)
        try:
            os.rmdir(os.path.dirname(full))
        except OSError:
            None
    elif os.path.isdir(full):
        raise IsADirectory()
    else:
        raise PageNotFound()

def create_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    full = os.path.join(wiki, path)
    if os.path.exists(full):
        raise PageExists()
    d = os.path.dirname(full)
    if d and (not os.path.exists(d)): os.makedirs(d)
    with open(full, 'w') as f:
        f.write("# " + path)
    commit(path, "Created '{0}'".format(path), repo=wiki)

def edit_page(path, contents, message="Minor edit", wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    try:
        full = os.path.join(wiki, path)
        with open(full, 'w') as f:
            f.write(contents)
        commit(path, message, repo=wiki)
    except IOError:
        raise PageNotFound()

########## git-related stuff
def initialize(repo="."):
    call(["git", "init"], cwd=repo)

def commit(path, message="Minor edit", repo="."):
    call(["git", "add", "--all", path], cwd=repo)
    call(["git", "commit", "-m", message], cwd=repo)

def log_of(path, repo="."):
    fmt = "--pretty=format:%x01%H%x00%ct%x00%an%x00%ae%x00%B"
    raw = check_output(["git", "whatchanged", "-z", fmt, "--", path], cwd=repo)
    entries = raw.split("\x01")
    for entry in entries:
        if entry:
            split = filter(identity, entry.split("\x00"))
            yield { "commit_hash": split[0],
                    "timestamp": datetime.datetime.utcfromtimestamp(int(split[1])),
                    "author_name": split[2],
                    "author_email": split[3],
                    "body": split[4:] }

def identity(a):
    return a

def is_in(a, b):
    [ra, rb] = map(os.path.realpath, [a, b])
    return os.path.commonprefix([ra, rb]) == rb

def is_in_repo(path, repo="."):
    p = os.path.join(repo, path)
    return is_in(p, repo) and not is_in(p, os.path.join(repo, ".git"))

########## custom exceptions
class NotInRepo(Exception):
    pass

class IsADirectory(Exception):
    pass

class PageNotFound(Exception):
    pass

class PageExists(Exception):
    pass
### main.py

import tornado.ioloop, tornado.web, json, os, sys, re
import wiki

##### General handlers
class ShowPage(tornado.web.RequestHandler):
    def get(self, path):
        if path == "" or is_dir(path):
            self.write(list_template(path))
        else:
            try:
                pg = wiki.view_page(path, wiki=WIKI_ROOT)
                self.write(view_template(path, pg))
            except wiki.PageNotFound:
                self.write(create_template(path))

class EditPage(tornado.web.RequestHandler):
    def get(self, path):
        pg = wiki.view_raw_page(path, wiki=WIKI_ROOT)
        self.write(edit_template(path, pg))

class DeleteAPI(tornado.web.RequestHandler):
    def post(self, path):
        wiki.delete_page(path, wiki=WIKI_ROOT)
        self.redirect("/" + os.path.dirname(path))

class CreateAPI(tornado.web.RequestHandler):
    def post(self, path):
        wiki.create_page(path, wiki=WIKI_ROOT)
        self.redirect("/edit/" + path)

class EditAPI(tornado.web.RequestHandler):
    def post(self, path):
        new_contents = self.get_argument("new_contents")
        message = self.get_argument("commit_message")
        if not message:
            message = "Minor edit"
        wiki.edit_page(path, new_contents, message, wiki=WIKI_ROOT)
        self.redirect("/" + path)

##### Cosmetics
def main_template(path, contents):
    return """
    <html>
      <head>
        <link rel="stylesheet" href="/static/css/wiki.css" type="text/css" media="screen" />
      </head>
      <body>
        {0}
        <div id="content">{1}</div>
      </body>
    </html>
    """.format(breadcrumbs(path), contents)

def edit_template(path, contents):
    return main_template(path, """
    <form action="/api/edit/{0}" method="POST">
      <textarea id="new_contents" name="new_contents">{1}</textarea>
      <textarea id="commit_message" name="commit_message"></textarea>
      <input type="submit" value="Submit" />
    </form>""".format(path, contents))

def create_template(path):
    return main_template(path, """
    <p>Page '{0}' not found.</p>
    <form action="/api/create/{0}" method="POST">
       <input type="submit" value="Create" />
    </form>
    """.format(path))

def view_template(path, contents):
    return main_template(path, """
    <div class="controls">
       <form action="/api/delete/{0}" method="POST">
          <input type="submit" value="Delete" />
       </form>
       <a href="/edit/{0}">Edit</a>
    </div>
    {1}
    """.format(path, contents))

def list_template(path):
    fs = file_list(path)
    LIs = "".join(["""<li><a href="/{0}">{1}</a></li>""".format(p, name) for (name, p) in fs])
    UL = "<ul>{0}</ul>".format(LIs)
    return main_template(path, UL)

def file_list(path):
    if path:
        local = os.path.join(WIKI_ROOT, path)
    else:
        local = WIKI_ROOT
    full = os.listdir(local)
    return ((f, os.path.join(path, f)) for f in full if not is_hidden(f))

def breadcrumbs(path):
    if path == "":
        return """<div class="breadcrumbs">home</div>"""
    s = re.split(r"[/\\]", path)
    template = """<div class="breadcrumbs"><a href="/">home</a>/{0}</div>"""
    if len(s) == 1:
        return template.format(s[0])
    elif len(s) == 2 and s[0] == "":
        return template.format(s[1])
    else:
        res = []
        for end in xrange(1, len(s)):
            elem = s[end-1].strip("/\\")
            link = "/" + ("/".join(s[0:end]))
            res.append("""<a href="{0}">{1}</a>""".format(link, elem))
        return template.format("/".join(res) + "/" + s[-1])

def is_dir(path):
    return os.path.isdir(os.path.join(WIKI_ROOT, path))

def is_file(path):
    return os.path.isfile(os.path.join(WIKI_ROOT, path))

def is_hidden(path):
    return path.startswith(".")

##### URI Dispatch and Settings
urls = [
    (r"/edit/(.*)", EditPage),
    (r"/api/edit/(.*)", EditAPI),
    (r"/api/create/(.*)", CreateAPI),
    (r"/api/delete/(.*)", DeleteAPI),
    (r"/(.*)", ShowPage)
]

settings = {
    "static_path": os.path.join(os.path.dirname(__file__), "static")
}

##### Main thing
app = tornado.web.Application(urls, **settings)
WIKI_ROOT = "."
if __name__ == "__main__":
    if len(sys.argv) > 1:
        WIKI_ROOT = sys.argv[1]
    print "Starting in", WIKI_ROOT
    app.listen(4848)
    tornado.ioloop.IOLoop.instance().start()

Man its been a while. Hopefully, I remember how to do this.

from markdown2 import markdown
from subprocess import call, check_output
import datetime, os

Module import boilerplate. Interestingly, though I put it up top out of habit, Python seems to allow you to keep your imports 'till the end so that they don't have to destroy reader flow. I've made a mental note to do something about that.

def view_page(path, wiki="."):
    return markdown(view_raw_page(path, wiki=wiki))

def view_raw_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    try:
        with open(os.path.join(wiki, path), 'r') as f:
            return f.read()
    except IOError:
        raise PageNotFound()

A page in the wiki is represented as a file on disk. A wiki is actually just a directory with a git repo for history support. There are two ways we might want to look at a single file; either as raw markdown when we're making edits, or as HTML when we're just reading. The view_raw_page function takes a relative path as well as a wiki directory, and loads the given file from that wiki. If the specified file exists somewhere outside of the given wiki directory, we raise a NotInRepo error instead of doing anything. This prevents requesters from getting arbitrary file-system access to our machine by passing .. as part of their request paths. If the given path would be inside of the given wiki, and merely doesn't exist, we instead raise a PageNotFound error. We'll exploit this for page creation code later.

def delete_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    full = os.path.join(wiki, path)
    if os.path.isfile(full):
        os.remove(full)
        commit(path, "Deleted '" + path + "'", repo=wiki)
        try:
            os.rmdir(os.path.dirname(full))
        except OSError:
            None
    elif os.path.isdir(full):
        raise IsADirectory()
    else:
        raise PageNotFound()

Unlike the view_(raw_)?page functions above, delete_page makes changes to the underlying filesystem. Specifically, it deletes a file in the repo and additionally deletes its containing directory if it's empty after the initial deletion|1|. Just as in view_raw_page, we check that the page we've been given exists inside the given repo. As much as we don't want to let random HTTP requesters see arbitrary files on our system, letting them delete arbitrary files would probably be worse. If the page exists, we delete it, then run rmdir on its containing directory|2|, then commit the changes with a mildly descriptive message. If the path given to delete_page is actually a directory, we instead throw a IsADirectory error. Arguably, we should let users delete subdirectories and do the obvious thing as a result, but I can't see it coming up in the kind of uses I'm planning to put this to. Finally, if the specified page doesn't exist, we raise a PageNotFound error. Again, arguably, we could just silently eat this error, since the result is still "the specified page no longer exists", but I'm being explicit for the moment.

def create_page(path, wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    full = os.path.join(wiki, path)
    if os.path.exists(full):
        raise PageExists()
    d = os.path.dirname(full)
    if d and (not os.path.exists(d)): os.makedirs(d)
    with open(full, 'w') as f:
        f.write("# " + path)
    commit(path, "Created '{0}'".format(path), repo=wiki)

Creating a page follows the same principles as delete_page. First, we check that the specified path will fall inside of the target wiki. If the page already exists, we return the explicit PageExists error rather than silently ignoring the condition. Then, we make sure that the full directory tree leading up to our new file exists, create the file with a default title equal to its path, and finally commit the changes.

def edit_page(path, contents, message="Minor edit", wiki="."):
    if not is_in_repo(path, wiki): raise NotInRepo()
    try:
        full = os.path.join(wiki, path)
        with open(full, 'w') as f:
            f.write(contents)
        commit(path, message, repo=wiki)
    except IOError:
        raise PageNotFound()

Having seen the previous three functions, it should be perfectly obvious how we go about editing an existing page. Sing along this time.

Now for the internals.

def initialize(repo="."):
    call(["git", "init"], cwd=repo)

initialize is actually not called anywhere at the moment. We instead assume that the user has set up their own repo somewhere before telling wik to serve it. If we were automating that step, this is how we'd do it.

def commit(path, message="Minor edit", repo="."):
    call(["git", "add", "--all", path], cwd=repo)
    call(["git", "commit", "-m", message], cwd=repo)

The commit procedure, called from all wiki mutators, just calls git add --all on the given path|3| followed by git commit with the specified message.

def log_of(path, repo="."):
    fmt = "--pretty=format:%x01%H%x00%ct%x00%an%x00%ae%x00%B"
    raw = check_output(["git", "whatchanged", "-z", fmt, "--", path], cwd=repo)
    entries = raw.split("\x01")
    for entry in entries:
        if entry:
            split = filter(identity, entry.split("\x00"))
            yield { "commit_hash": split[0],
                    "timestamp": datetime.datetime.utcfromtimestamp(int(split[1])),
                    "author_name": split[2],
                    "author_email": split[3],
                    "body": split[4:] }

This is another function that isn't really being called yet. It will be at some point, but at the moment I'm not extending a reversion interface to HTTP clients, so we just have the definition.

def identity(a):
    return a

Apparently Python doesn't have a built-in identity. Even though some built-in higher-order functions assume the identity function in certain argument slots. I guess "there should only be one way to do it" doesn't quite translate to "if many users want it, we should implement it once".

def is_in(a, b):
    [ra, rb] = map(os.path.realpath, [a, b])
    return os.path.commonprefix([ra, rb]) == rb

def is_in_repo(path, repo="."):
    p = os.path.join(repo, path)
    return is_in(p, repo) and not is_in(p, os.path.join(repo, ".git"))

Almost done. is_in_repo is the function that takes a path and a repo and checks if the first is inside the second. It does this by checking that the given path both is_in the given repo and that it's not is_in that repos' .git subdirectory. is_in just takes two pathnames, canonicalizes them using os.path.realpath, and check if the first has the second as a prefix.

########## custom exceptions
class NotInRepo(Exception):
    pass

class IsADirectory(Exception):
    pass

class PageNotFound(Exception):
    pass

class PageExists(Exception):
    pass

The last bit of wiki.py just defines the custom exceptions you've seen being thrown above. They don't do anything other than pass, because the only thing we really care about is that we can tell them apart form built in errors. We don't actually need to store any additional information for our purposes at this point, though I do reserve the right to changes that in the future.

On to main.py

import tornado.ioloop, tornado.web, json, os, sys, re
import wiki

Again, import boilerplate; forgiveness please. Though I guess that I should point out I'm building this mini wiki on top of the tornado asynchronous web server.

class ShowPage(tornado.web.RequestHandler):
    def get(self, path):
        if path == "" or is_dir(path):
            self.write(list_template(path))
        else:
            try:
                pg = wiki.view_page(path, wiki=WIKI_ROOT)
                self.write(view_template(path, pg))
            except wiki.PageNotFound:
                self.write(create_template(path))

class EditPage(tornado.web.RequestHandler):
    def get(self, path):
        pg = wiki.view_raw_page(path, wiki=WIKI_ROOT)
        self.write(edit_template(path, pg))

The ShowPage handler takes a path variable. If that path designates a directory, or the wiki root "", we instead list the given directory by calling the list_template. If that path designates an existing file, we show it by calling wiki.view_page, and writing the result into the view_template. Finally, if the path doesn't designate an existing file, we show the create_template. We'll see all of those templates shortly.

The EditPage handler takes a path, and just writes out the edit_template, filled with the result of a call to wiki.view_raw_page.

Those were the only two handlers that return actual HTML. The rest of them, as you're about to see, merely redirect the caller. Ideally, they'd only return some kind of JSON-encoded ack, but that would complicate writing a dumb interface. Maybe something for a future version.

class DeleteAPI(tornado.web.RequestHandler):
    def post(self, path):
        wiki.delete_page(path, wiki=WIKI_ROOT)
        self.redirect("/" + os.path.dirname(path))

class CreateAPI(tornado.web.RequestHandler):
    def post(self, path):
        wiki.create_page(path, wiki=WIKI_ROOT)
        self.redirect("/edit/" + path)

class EditAPI(tornado.web.RequestHandler):
    def post(self, path):
        new_contents = self.get_argument("new_contents")
        message = self.get_argument("commit_message")
        if not message:
            message = "Minor edit"
        wiki.edit_page(path, new_contents, message, wiki=WIKI_ROOT)
        self.redirect("/" + path)

Those three handlers do the appropriate thing for the wiki calls delete_page, create_page and edit_page respectively. The only one that's even mildly complicated is EditAPI, which potentially has to pass along a commit_message from the client as well as a path. Before we get to the cosmetics, lets skip ahead a bit and see where all these path parameters to our handlers are coming from.

urls = [
    (r"/edit/(.*)", EditPage),
    (r"/api/edit/(.*)", EditAPI),
    (r"/api/create/(.*)", CreateAPI),
    (r"/api/delete/(.*)", DeleteAPI),
    (r"/(.*)", ShowPage)
]

settings = {
    "static_path": os.path.join(os.path.dirname(__file__), "static")
}

As you can see, the URL dispatch table pairs a regex to a particular handler class. That group in each one is going to be passed as an argument to the appropriate method. Note that in this case, they all capture most of the incoming URI, but that's certainly not a requirement. You can capture path pieces exactly how you'd think. The only setting we're interested in setting is the static_path; and that should be the static directory relative to this file rather than relative to the directory in which wik will eventually be run.

app = tornado.web.Application(urls, **settings)
WIKI_ROOT = "."
if __name__ == "__main__":
    if len(sys.argv) > 1:
        WIKI_ROOT = sys.argv[1]
    print "Starting in", WIKI_ROOT
    app.listen(4848)
    tornado.ioloop.IOLoop.instance().start()

Last couple of things. I'm keeping WIKI_ROOT as a global constant, because I'm working under the assumption that a particular instance of tornado will only serve one wiki. This may end up being a faulty assumption later on, in which case I'll need to re-think where and how the directory gets stored. As it stands, it'll be a single global, and as you can see from the __main__ block, we set it from the first and only command-line arg. At the moment, I'm not even parameterizing the port number, opting instead to use the literal 4848. That's a note to self; the right thing to do in this situation is would be importing and appropriately configuring/calling argparse so that we could pass in a target directory, as well as a port, and maybe some other configuration options. So, you know. Get on that, self.

The last bit we need to go over is the code defining our basic cosmetic templates. I'm fully aware of tornado-template, but didn't bother with it for stuff this minimal|4|.

def main_template(path, contents):
    return """
    <html>
      <head>
        <link rel="stylesheet" href="/static/css/wiki.css" type="text/css" media="screen" />
      </head>
      <body>
        {0}
        <div id="content">{1}</div>
      </body>
    </html>
    """.format(breadcrumbs(path), contents)

The main_template contains the basic html/head/body tags, and expects to be passed some contents and a path. The contents are naively templated into a div#content tag, while the path is passed to breadcrumbs for processing.

def breadcrumbs(path):
    if path == "":
        return """<div class="breadcrumbs">home</div>"""
    s = re.split(r"[/\\]", path)
    template = """<div class="breadcrumbs"><a href="/">home</a>/{0}</div>"""
    if len(s) == 1:
        return template.format(s[0])
    elif len(s) == 2 and s[0] == "":
        return template.format(s[1])
    else:
        res = []
        for end in xrange(1, len(s)):
            elem = s[end-1].strip("/\\")
            link = "/" + ("/".join(s[0:end]))
            res.append("""<a href="{0}">{1}</a>""".format(link, elem))
        return template.format("/".join(res) + "/" + s[-1])

I found it kind of odd that this was the most complicated single procedure in the entire application. Nope, not the exposing a named directory without allowing URL injection, not tracking edits or even figuring out the history of a particular file. It's that stupid little breadcrumb trail of links across the top of every page. So it goes sometimes. If the given path is the root, we just return home. No links or paths or any other kind of processing. Otherwise, we split the path on slashes and see what we get back. If the result is a list of 1 element, we return something like home/foo, where home is a link to the root and foo is the name of the single path element. We do basically the same thing with a path of len 2 that has the empty string in the first position. The reason both of these are conditions here is that I did some interpreter testing and found that certain versions of Python split a path like /blah into ["blah"], while others did ["", "blah"], and I wanted to cover at least all the options I've personally observed. Finally, if none of the above are the case, we return something like home/foo/bar/baz/mumble/file, and make sure that every path element except for the last one has the appropriate link attached.

def edit_template(path, contents):
    return main_template(path, """
    <form action="/api/edit/{0}" method="POST">
      <textarea id="new_contents" name="new_contents">{1}</textarea>
      <textarea id="commit_message" name="commit_message"></textarea>
      <input type="submit" value="Submit" />
    </form>""".format(path, contents))

def create_template(path):
    return main_template(path, """
    <p>Page '{0}' not found.</p>
    <form action="/api/create/{0}" method="POST">
       <input type="submit" value="Create" />
    </form>
    """.format(path))

def view_template(path, contents):
    return main_template(path, """
    <div class="controls">
       <form action="/api/delete/{0}" method="POST">
          <input type="submit" value="Delete" />
       </form>
       <a href="/edit/{0}">Edit</a>
    </div>
    {1}
    """.format(path, contents))

The edit, create and view templates aren't interesting enough to dwell on. They each show some basic controls, and do the appropriate thing on submit. I should say, they're not interesting enough to dwell on yet. I'm still planning to drop codemirror into this project so that you can have pretty highlighting and a comfortable experience in the edit interface, but that's about it. From the create template, you can create a new page, and from the view template, you can either edit or delete the current page.

The last template we've got is

def list_template(path):
    fs = file_list(path)
    LIs = "".join(["""<li><a href="/{0}">{1}</a></li>""".format(p, name) for (name, p) in fs])
    UL = "<ul>{0}</ul>".format(LIs)
    return main_template(path, UL)

def file_list(path):
    if path:
        local = os.path.join(WIKI_ROOT, path)
    else:
        local = WIKI_ROOT
    full = os.listdir(local)
    return ((f, os.path.join(path, f)) for f in full if not is_hidden(f))

And it does exactly what you'd expect; returns a giant ul tag with links to each file and directory visible from the specified path into the wiki. This is another place I'm planning some improvements. Specifically, it would be nice if the entries were arranged alphabetically, with all directories coming before any files, and with appropriate file/directory icons marking them as appropriate. I'll let you know how it goes.

Oh, actually, I guess there were a few utility functions still left to go over, though they're all hopefully self-explanatory.

def is_dir(path):
    return os.path.isdir(os.path.join(WIKI_ROOT, path))

def is_file(path):
    return os.path.isfile(os.path.join(WIKI_ROOT, path))

def is_hidden(path):
    return path.startswith(".")

entr

As a complete aside, writing wik was the first time I used entr seriously. Because editing the above, especially those templates, required a lot of server restarting, eventually I just started up a separate terminal running

ls *py static/css/*css | entr -r python main.py ~/wiki-data

which started up my server, and killed/restarted it each time I saved any .py or .css files I was working on. It's pretty useful having this sort of thing automated, though it doesn't quite do what I want for C development. Really, what I'd want there is something more like hsandbox, but running on a file I specified. That's something I may put some work into at some point soon.

Khan Academy

Something I've been seriously meaning to get into is some basic math. It's surprising, and somewhat embarrassing, how long I've gone without doing that. So this past week, I finally registered an account over at Khan Academy and plowed through the Combinatorics/Probability lessons as well as I could. It still feels like I need to practice and study more, but I have a less shaky grasp of n-choose-k problems than I used to. I'm not prepared to swear by the information yet, given that I haven't battle-tested it at this point, but I can tentatively recommend the lessons|5|. They certainly help retention over the moderate term.

Finally

I was going to mention the recent Cabal memory-management-fest, in which the current core members got together to discuss the implementations they'd spent the week building. Mine's up here, while Scott's are over here|6|, and dann hasn't posted anything yet as far as I know. I was going to go over each of those, but this piece is already quite a bit longer than I was expecting. Fuck, also, I've been putting some work into exercises for Learn Lisp the Hard Way. At the moment, I'm just working on section 1-04, but I'm hoping to claw some time together over the next couple of weeks. It's an interesting effort, and I guess technically the second book I've contributed to. I can't wait to see what kind of impact it has.

Now that I've done an initial proof of this article, it occurs that I opened with "There's not much going on".

Given that the above just gives you some minor thumbnails, and doesn't include anything from my personal life, I have no idea why I did that.


Footnotes

1 - |back| - It does not, as of this writing, do that recursively, but probably should. Note to self.

2 - |back| - Ignoring the potential OSError thrown if the directory still has something in it.

3 - |back| - The --all is really only necessary for deletions, but it's easier to call it everywhere instead of dispatching, or exposing an extra flag argument to let the caller decide whether to add it.

4 - |back| - Also, I'm not sold on the idea of mixing HTML with random code in arbitrary languages. :cl-who and similar have taught me to expect somewhat more elegant generation machinery.

5 - |back| - Though I will say that I'm not sure I'd recommend any of the lessons that have anything to do with code. They all take an extremely imperative bent and pretty severely over-complicate some problems. The particular offender that sticks out to me is Merge sort, which I learned through the very simple functional approach, but they expect you to do in-place. Not that knowing that is bad, but it seems backwards to teach it first.

6 - |back| - Though the first one is still going through editing phases.


Creative Commons License

all articles at langnostic are licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License

Reprint, rehost and distribute freely (even for profit), but attribute the work and allow your readers the same freedoms. Here's a license widget you can use.

The menu background image is Jewel Wash, taken from Dan Zen's flickr stream and released under a CC-BY license