Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
An example of using the GitHub API to make several changes to a repo in a single new commit.
{
"metadata": {
"name": "",
"signature": "sha256:8f675fcb8622453789e6286410f3a67283ddbee438532119a7c27bc0d8470cc2"
},
"nbformat": 3,
"nbformat_minor": 0,
"worksheets": [
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"I wrote a bit ago about \n",
"[making commits via the GitHub API](http://penandpants.com/2014/02/26/making-commits-via-the-github-api/).\n",
"That post outlined making changes in two simplified situations: \n",
"making changes to a single file and making updates to two existing files at \n",
"the root of the repository. \n",
"Here I show a more general solution that allows arbitrary changes\n",
"anywhere in the repo.\n",
"\n",
"I want to be able to specify a repo and branch and say\n",
"\"here are the contents of files that have changed or been created and\n",
"here are the names of files that have been deleted,\n",
"please take all that and this message and make a new commit for me.\"\n",
"Because the [GitHub API](http://developer.github.com/)\n",
"is so rudimentary when it comes to making commits that will\n",
"end up being a many-stepped process, but it's mostly the same steps\n",
"repeated many times so it's not a nightmare to code up.\n",
"At a high level the process goes like this:\n",
"\n",
"- Get the current repo state from GitHub\n",
" - This is the names and hashes of all the files and directories,\n",
" but not the actual file contents.\n",
"- Construct a local, malleable representation of the repo\n",
"- Modify the local representation according to the given\n",
" updates, creations, and deletions\n",
"- Walk though the modified local \"repo\" and upload new/changed\n",
" files and directories to GitHub\n",
" - This must be done from the bottom up because a change at\n",
" the low level means every directory above that level will need\n",
" to be changed.\n",
"- Make a new commit pointed at the new root tree\n",
" (I'll explain trees soon.)\n",
"- Update the working branch to point to the new commit\n",
"\n",
"I'll start off with the preliminaries that allow me to pull\n",
"down the current repo state.\n",
"I use the [github3.py](http://github3py.readthedocs.org/en/latest/index.html)\n",
"library for abstracting the GitHub API requests."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"import os.path"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 1
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"from github3 import login"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 2
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Basic information required for connecting to GitHub \n",
"and [which repo](https://github.com/jiffyclub/demodemo)\n",
"and branch to work on:"
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"username = 'jiffyclub'\n",
"token = 'zzz'\n",
"repo_name = 'demodemo'\n",
"branch_name = 'master'"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 3
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"A [Repository](http://github3py.readthedocs.org/en/latest/repos.html#repository-objects)\n",
"instance will be the main interface to the repo."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"gh = login(username=username, token=token)\n",
"repo = gh.repository(username, repo_name)"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 4
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To actually see repo contents we have to pick a specific branch.\n",
"\"[Recursing](https://developer.github.com/v3/git/trees/#get-a-tree-recursively)\"\n",
"on the tree is how I get one long list of all the things in the repo."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"# get the current repo layout\n",
"branch = repo.branch(branch_name)\n",
"tree = branch.commit.commit.tree.recurse()"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 5
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The repository file structure is represented by a\n",
"[Tree](http://github3py.readthedocs.org/en/latest/git.html#github3.git.Tree)\n",
"object and individual things within the repo are represented by\n",
"[Hash](http://github3py.readthedocs.org/en/latest/git.html#github3.git.Hash)\n",
"objects."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"h = tree.tree[0]"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 6
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"h.path, h.mode, h.sha, h.type"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 7,
"text": [
"('README.md', '100644', 'c385d5f2330a39aca84f2f7999346244bbf0a997', 'blob')"
]
}
],
"prompt_number": 7
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By looping over the tree I can print out the whole repo structure:"
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"for h in tree.tree:\n",
" print(h.path)"
],
"language": "python",
"metadata": {},
"outputs": [
{
"output_type": "stream",
"stream": "stdout",
"text": [
"README.md\n",
"dir1\n",
"dir1/dir1-1.txt\n",
"dir1/dir1-2.txt\n",
"dir1/dir2\n",
"dir1/dir2/dir2-1.txt\n",
"dir1/dir2/dir2-2.txt\n",
"dir1/dir2/dir3\n",
"dir1/dir2/dir3/dir3-1.txt\n",
"dir1/dir2/dir3/dir3-2.txt\n",
"dir4\n",
"dir4/dir4-1.txt\n",
"dir5\n",
"dir5/dir5-1.txt\n",
"dir5/dir5-2.txt\n",
"dir8\n",
"dir8/dir8-1.txt\n",
"dir8/dir8-2.txt\n",
"root1.txt\n",
"root2.txt\n",
"setup.fish\n"
]
}
],
"prompt_number": 8
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"Malleable Local Repo"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Git tracks repository state using two kinds of objects:\n",
"[blobs](http://git-scm.com/book/en/Git-Internals-Git-Objects),\n",
"which contain file contents,\n",
"and [trees](http://git-scm.com/book/en/Git-Internals-Git-Objects#Tree-Objects),\n",
"which contain file and directory names pointing to blobs and other trees.\n",
"\n",
"My plan is to represent the current repository state locally,\n",
"modify that local state, and finally add the changes to GitHub\n",
"via the API."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"def split_one(path):\n",
" \"\"\"\n",
" Utility function for splitting off the very first part of a path.\n",
" \n",
" Parameters\n",
" ----------\n",
" path : str\n",
" \n",
" Returns\n",
" -------\n",
" head, tail : str\n",
" \n",
" Examples\n",
" --------\n",
" >>> split_one('a/b/c')\n",
" ('a', 'b/c')\n",
" >>> split_one('d')\n",
" ('', 'd')\n",
" \n",
" \"\"\"\n",
" s = path.split('/', 1)\n",
" if len(s) == 1:\n",
" return '', s[0]\n",
" else:\n",
" return tuple(s)"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 9
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"split_one('dir1/dir2/dir3')"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 10,
"text": [
"('dir1', 'dir2/dir3')"
]
}
],
"prompt_number": 10
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"To match Git's blobs and trees the core of my code will be two classes: \n",
"`File` and `Directory`. `File` will be quite simple; it will know\n",
"how to post a new blob to GitHub and not much else:"
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"class File(object):\n",
" \"\"\"\n",
" Represents a file/blob in the repo.\n",
" \n",
" Parameters\n",
" ----------\n",
" name : str\n",
" Name of this file. Should contain no path components.\n",
" mode : str\n",
" '100644' for regular files, \n",
" '100755' for executable files.\n",
" sha : str\n",
" Git sha for an existing file, \n",
" omitted or None for a new/changed file.\n",
" content : str\n",
" File's contents as text. \n",
" Omitted or None for an existing file,\n",
" must be given for a changed or new file.\n",
" \n",
" \"\"\"\n",
" def __init__(self, name, mode, sha=None, content=None):\n",
" self.name = name\n",
" self.mode = mode\n",
" self.sha = sha\n",
" self.content = content\n",
" \n",
" def create_blob(self, repo):\n",
" \"\"\"\n",
" Post this file to GitHub as a new blob.\n",
" \n",
" If this file is unchanged nothing will be done.\n",
" \n",
" Parameters\n",
" ----------\n",
" repo : github3.repos.repo.Repository\n",
" Authorized github3.py repository instance.\n",
" \n",
" Returns\n",
" -------\n",
" dict\n",
" Dictionary of info about the blob:\n",
" \n",
" path: blob's name\n",
" type: 'blob'\n",
" mode: blob's mode\n",
" sha: blob's up-to-date sha\n",
" changed: True if a new blob was created\n",
" \n",
" \"\"\"\n",
" if self.sha:\n",
" # already up to date\n",
" print('Blob unchanged for {}'.format(self.name))\n",
" changed = False\n",
" else:\n",
" assert self.content is not None\n",
" print('Making blob for {}'.format(self.name))\n",
" self.sha = repo.create_blob(self.content, encoding='utf-8')\n",
" changed = True\n",
" \n",
" return {'path': self.name,\n",
" 'type': 'blob',\n",
" 'mode': self.mode,\n",
" 'sha': self.sha,\n",
" 'changed': changed}"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 11
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The `Directory`, with its listing of files and other directories,\n",
"ties everything together.\n",
"With the root directory we can find anything else in the repo.\n",
"In fact, the hash of the root tree of a repo is what Git keeps\n",
"a record of when you make a commit.\n",
"Everything else is referenced off that tree and any trees it contains."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"class Directory(object):\n",
" \"\"\"\n",
" Represents a directory/tree in the repo.\n",
" \n",
" Parameters\n",
" ----------\n",
" name : str\n",
" Name of directory. Should not contain any path components.\n",
" sha : str\n",
" Hash for an existing tree, omitted or None for a new tree.\n",
" \n",
" \"\"\"\n",
" def __init__(self, name, sha=None):\n",
" self.name = name\n",
" self.sha = sha\n",
" self.files = {}\n",
" self.directories = {}\n",
" self.changed = False\n",
" \n",
" def add_directory(self, name, sha=None):\n",
" \"\"\"\n",
" Add a new subdirectory or return an existing one.\n",
" \n",
" Parameters\n",
" ----------\n",
" name : str\n",
" If this contains any path components new directories\n",
" will be made to a depth necessary to construct the full path.\n",
" sha : str\n",
" Hash for an existing directory, omitted or None for a new directory.\n",
" \n",
" Returns\n",
" -------\n",
" `Directory`\n",
" Reference to the named directory.\n",
" If `name` contained multiple path components only the\n",
" reference to the last directory referenced is returned.\n",
" \n",
" \"\"\"\n",
" head, tail = split_one(name)\n",
" if head and head not in self.directories:\n",
" self.directories[head] = Directory(head)\n",
" \n",
" elif not head:\n",
" # the input directory is a child of the current directory\n",
" if name not in self.directories:\n",
" self.directories[name] = Directory(name, sha)\n",
" return self.directories[name]\n",
" \n",
" return self.directories[head].add_directory(tail, sha)\n",
" \n",
" def add_file(self, name, mode, sha=None, content=None):\n",
" \"\"\"\n",
" Add a new file. An existing file with the same name\n",
" will be replaced.\n",
" \n",
" Parameters\n",
" ----------\n",
" name : str\n",
" Name of file. If it contains path components new\n",
" directories will be made as necessary until the\n",
" file can be made in the appropriate location.\n",
" mode : str\n",
" '100644' for regular files, \n",
" '100755' for executable files.\n",
" sha : str\n",
" Git hash for file. Required for existing files,\n",
" omitted or None for new files.\n",
" content : str\n",
" Content of a new or changed file. Omit for existing files.\n",
" \n",
" Returns\n",
" -------\n",
" `File`\n",
" \n",
" \"\"\"\n",
" head, tail = os.path.split(name)\n",
" if not head:\n",
" # this file belongs in this directory\n",
" if mode is None:\n",
" if tail in self.files:\n",
" # we're getting an update to an existing file\n",
" assert content is not None\n",
" mode = self.files[tail].mode\n",
" assert mode\n",
" else:\n",
" raise ValueError('Adding a new file with no mode.')\n",
" \n",
" self.files[tail] = File(name, mode, sha, content)\n",
" else:\n",
" self.add_directory(head).add_file(tail, mode, sha, content)\n",
" \n",
" def delete_file(self, name):\n",
" \"\"\"\n",
" Delete a named file.\n",
" \n",
" Parameters\n",
" ----------\n",
" name : str\n",
" Name of file to delete. May contain path components.\n",
" \n",
" \"\"\"\n",
" head, tail = os.path.split(name)\n",
" \n",
" if not head:\n",
" # should be in this directory\n",
" del self.files[tail]\n",
" self.changed = True\n",
" else:\n",
" self.add_directory(head).delete_file(tail)\n",
" \n",
" def create_tree(self, repo):\n",
" \"\"\"\n",
" Post a new tree to GitHub.\n",
" \n",
" If this directory and everything in/below it \n",
" are unchanged nothing will be done.\n",
" \n",
" Parameters\n",
" ----------\n",
" repo : github3.repos.repo.Repository\n",
" Authorized github3.py repository instance.\n",
" \n",
" Returns\n",
" -------\n",
" tree_info : dict\n",
" 'path': directory's name\n",
" 'mode': '040000'\n",
" 'sha': directory's up-to-date hash\n",
" 'type': 'tree'\n",
" 'changed': True if a new tree was posted to GitHub\n",
" \n",
" \"\"\"\n",
" tree = [f.create_blob(repo) for f in self.files.values()]\n",
" tree = tree + [d.create_tree(repo) for d in self.directories.values()]\n",
" tree = list(filter(None, tree))\n",
"\n",
" if not tree:\n",
" # nothing left in this directory, it should be discarded\n",
" return None\n",
"\n",
" # have any subdirectories or files changed (or been deleted)?\n",
" changed = any(t['changed'] for t in tree) or self.changed\n",
" \n",
" if changed:\n",
" print('Creating tree for {}'.format(self.name))\n",
" tree = [{k: v for k, v in t.items() if k != 'changed'} for t in tree]\n",
" self.sha = repo.create_tree(tree).sha\n",
" else:\n",
" print('Tree unchanged for {}'.format(self.name))\n",
" assert self.sha\n",
" return {'path': self.name,\n",
" 'mode': '040000',\n",
" 'sha': self.sha,\n",
" 'type': 'tree',\n",
" 'changed': changed}"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 12
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"With the `File` and `Directory` classes defined I can construct\n",
"the current repo state. \n",
"Everything starts with the unnamed root directory.\n",
"I filter out the blobs and trees so I can add the directories\n",
"first, though this isn't strictly necessary."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"trees = [h for h in tree.tree if h.type == 'tree']\n",
"blobs = [h for h in tree.tree if h.type == 'blob']\n",
"\n",
"root = Directory('', branch.commit.commit.tree.sha)\n",
"\n",
"for h in trees:\n",
" root.add_directory(h.path, h.sha)\n",
"\n",
"for h in blobs:\n",
" root.add_file(h.path, h.mode, h.sha)"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 13
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"Set up some changes and deletions"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"With the repo state reconstructed locally I'll configure some changes.\n",
"There are changes to existing files, new files, and file deletions."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"# 'mode': None indicates it's an existing file and the mode should be kept as is\n",
"# New files must give a valid 'mode' parameter\n",
"updates = [{'path': 'README.md', \n",
" 'content': 'a', \n",
" 'mode': None},\n",
" {'path': 'dir1/dir1-1.txt', \n",
" 'content': 'b', \n",
" 'mode': None},\n",
" {'path': 'dir1/dir2/dir3/dir3-2.txt', \n",
" 'content': 'c', \n",
" 'mode': None},\n",
" {'path': 'dir1/dir2/dir3/dir3-3.txt', \n",
" 'content': 'e', \n",
" 'mode': '100644'},\n",
" {'path': 'root3.txt', \n",
" 'content': 'f', \n",
" 'mode': '100644'},\n",
" {'path': 'dir6/dir7/dir7-1.txt', \n",
" 'content': 'g', \n",
" 'mode': '100644'}]\n",
"\n",
"# paths to deleted files\n",
"deleted = ['root1.txt',\n",
" 'dir1/dir2/dir2-1.txt',\n",
" 'dir1/dir2/dir2-2.txt',\n",
" 'dir4/dir4-1.txt',\n",
" 'dir8/dir8-1.txt']"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 15
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The next step is to update the local repo representation:"
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"# make our local repo reflect how we want it to look\n",
"# after changing/adding/deleting files\n",
"for thing in updates:\n",
" root.add_file(thing['path'], thing['mode'], content=thing['content'])\n",
"\n",
"for d in deleted:\n",
" root.delete_file(d)"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 16
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"Make all the new blobs and trees created by the changes"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The local repo representation now has the same structure\n",
"I want the repo on GitHub to have.\n",
"To get all the updates sent to GitHub I call the\n",
"`.create_tree` method on the root directory.\n",
"That method in turn calls the `.create_tree`\n",
"and `.create_blob` methods on all the directories\n",
"and files below, which in turn do the same.\n",
"One by one each changed file and directory will have\n",
"its data sent to GitHub and finally I'll have the\n",
"hash of the new root tree that I can use in a commit."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"root_info = root.create_tree(repo)"
],
"language": "python",
"metadata": {},
"outputs": [
{
"output_type": "stream",
"stream": "stdout",
"text": [
"Making blob for root3.txt\n",
"Blob unchanged for root2.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Making blob for README.md\n",
"Blob unchanged for setup.fish"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Blob unchanged for dir1-2.txt\n",
"Making blob for dir1-1.txt\n",
"Blob unchanged for dir3-1.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Making blob for dir3-3.txt\n",
"Making blob for dir3-2.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir3"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir2"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir1"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Making blob for dir7-1.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir7"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir6"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Blob unchanged for dir8-2.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Creating tree for dir8\n",
"Blob unchanged for dir5-1.txt"
]
},
{
"output_type": "stream",
"stream": "stdout",
"text": [
"\n",
"Blob unchanged for dir5-2.txt\n",
"Tree unchanged for dir5\n",
"Creating tree for \n"
]
}
],
"prompt_number": 17
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"root_info"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 18,
"text": [
"{'sha': '3f1e781ebde83629df62de0a869169d29c10e435',\n",
" 'path': '',\n",
" 'type': 'tree',\n",
" 'mode': '040000',\n",
" 'changed': True}"
]
}
],
"prompt_number": 18
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"Make a new commit"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"At this point GitHub has all of my new data but there's\n",
"nothing in the history of my repo pointing at this new state.\n",
"That requires making a new commit.\n",
"The ingredients for a new commit are a message,\n",
"the sha hash of a tree (from which can be derived the\n",
"entire repo state), and a parent commit(s) for linking the\n",
"new commit to the rest of the project history."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"new_commit = repo.create_commit('Making a whole bunch of changes all over via the GitHub API.',\n",
" tree=root_info['sha'],\n",
" parents=[branch.commit.sha])"
],
"language": "python",
"metadata": {},
"outputs": [],
"prompt_number": 19
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"new_commit"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 20,
"text": [
"<Commit [Matt Davis:753e75b9891afac88ecc9fae86ec0bc11fa009c6]>"
]
}
],
"prompt_number": 20
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"new_commit.html_url"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 21,
"text": [
"'https://github.com/jiffyclub/demodemo/commit/753e75b9891afac88ecc9fae86ec0bc11fa009c6'"
]
}
],
"prompt_number": 21
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"Update master branch to point to new commit"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The commit is now part of my project's history,\n",
"but my working branch has not been updated to point\n",
"at the new commit. \n",
"This happens implicitly when you work with Git at the\n",
"command line, but when working via the API it has\n",
"to be done manually.\n",
"\n",
"The procedure for this is to get a \n",
"[Reference](http://github3py.readthedocs.org/en/latest/git.html#github3.git.Reference)\n",
"instance for the working branch and use its `.update`\n",
"method to point it at the new commit."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [
"ref = repo.ref('heads/{}'.format(branch_name))\n",
"ref.update(new_commit.sha)"
],
"language": "python",
"metadata": {},
"outputs": [
{
"metadata": {},
"output_type": "pyout",
"prompt_number": 22,
"text": [
"True"
]
}
],
"prompt_number": 22
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"A return value of `True` indicates success."
]
},
{
"cell_type": "heading",
"level": 1,
"metadata": {},
"source": [
"What's Missing"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"I haven't made any attempt here to test symlinks or\n",
"binary content like images.\n",
"Those could require some special handling,\n",
"but I think it'll be maneagable."
]
},
{
"cell_type": "code",
"collapsed": false,
"input": [],
"language": "python",
"metadata": {},
"outputs": []
}
],
"metadata": {}
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment