batou - multi(component|host|environment|.*) deployment
DESCRIPTION
Talk given at EuroPython 2013TRANSCRIPT
![Page 1: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/1.jpg)
BATOUmulti-(component|host|environment|.*)
deployment
Wednesday, 3.July 13
![Page 2: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/2.jpg)
@theuni
Wednesday, 3.July 13
![Page 3: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/3.jpg)
Wednesday, 3.July 13
![Page 4: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/4.jpg)
Wednesday, 3.July 13
![Page 5: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/5.jpg)
Wednesday, 3.July 13
![Page 6: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/6.jpg)
AUTOMATING DEPLOYMENTS IS HARD
Wednesday, 3.July 13
![Page 7: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/7.jpg)
HOW DOES
CONVERGENCE HELP?
Wednesday, 3.July 13
![Page 8: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/8.jpg)
HOW DOES THIS WORK WITH BATOU?
Wednesday, 3.July 13
![Page 9: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/9.jpg)
SOME PERSPECTIVE
Wednesday, 3.July 13
![Page 10: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/10.jpg)
Wednesday, 3.July 13
![Page 11: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/11.jpg)
IT'S NOT THAT BAD.
Wednesday, 3.July 13
![Page 12: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/12.jpg)
Wednesday, 3.July 13
![Page 13: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/13.jpg)
service deployment
Fabric, Capistrano, ...
system configuration Puppet, Chef, ...
provisioning kickstart, Razor, imaging ...
Wednesday, 3.July 13
![Page 14: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/14.jpg)
FTP
bashmkzopeinstance
zc.buildoutfabric
Wednesday, 3.July 13
![Page 15: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/15.jpg)
CONVERGENCE
Wednesday, 3.July 13
![Page 16: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/16.jpg)
"Everything that follows is a result of what you see here."(Dr. Alfred Lanning; I, Robot)
Wednesday, 3.July 13
![Page 17: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/17.jpg)
SIMPLEos.mkdir('foo')with open('foo/bar', 'w') as myfile: myfile.write('asdf')os.chmod('foo/bar', 0755)
Wednesday, 3.July 13
![Page 18: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/18.jpg)
•unexpected system state
•can't resume
•unnecessary updates
os.mkdir('foo')with open('foo/bar', 'w') as myfile: myfile.write('asdf')os.chmod('foo/bar', 0755)
SIMPLISTIC
Wednesday, 3.July 13
![Page 19: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/19.jpg)
CORRECT(?)if not os.path.isdir('foo'): os.unlink('foo')if not os.path.exists('foo'): os.mkdir('foo')try: os.lstat('foo/bar')except OSError: passelse: if os.path.isdir('foo/bar'): shutil.rmtree('foo/bar') else: os.unlink('foo/bar')if (os.path.exists('foo/bar') and open('foo/bar', 'r').read() != 'asdf'): open('foo/bar', 'w').write('asdf'):current = os.stat('foo/bar').st_modeif stat.S_IMODE(current) != 0755: os.chmod('foo', 0755)
Wednesday, 3.July 13
![Page 20: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/20.jpg)
SIMPLE
File('foo/bar', content='asdf', mode=0755, leading=True)
Wednesday, 3.July 13
![Page 21: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/21.jpg)
class File(Component):
namevar = 'path'
def configure(self): self += Presence( self.path, leading=self.leading) self += Mode(self.path, self.mode) self += Content(self.path, self.content)
File('foo/bar', content='asdf', mode=0755, leading=True)
Wednesday, 3.July 13
![Page 22: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/22.jpg)
class File(Component):
namevar = 'path'
def configure(self): self += Presence( self.path, leading=self.leading) self += Mode(self.path, self.mode) self += Content(self.path, self.content)
File('foo/bar', content='asdf', mode=0755, leading=True)
compute target state (no touching!)
Wednesday, 3.July 13
![Page 23: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/23.jpg)
class File(Component):
namevar = 'path'
def configure(self): self += Presence( self.path, leading=self.leading) self += Mode(self.path, self.mode) self += Content(self.path, self.content)
File('foo/bar', content='asdf', mode=0755, leading=True)
compute target state (no touching!)
composition operator
Wednesday, 3.July 13
![Page 24: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/24.jpg)
class File(Component):
namevar = 'path'
def configure(self): self += Presence( self.path, leading=self.leading) self += Mode(self.path, self.mode) self += Content(self.path, self.content)
File('foo/bar', content='asdf', mode=0755, leading=True)
compute target state (no touching!)
composition operator
order matters
Wednesday, 3.July 13
![Page 25: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/25.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
Wednesday, 3.July 13
![Page 26: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/26.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
run "anywhere"
Wednesday, 3.July 13
![Page 27: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/27.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
run on target
run "anywhere"
Wednesday, 3.July 13
![Page 28: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/28.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
run on target
run "anywhere"
after all sub-components
Wednesday, 3.July 13
![Page 29: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/29.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
run on target
only if needed
run "anywhere"
after all sub-components
Wednesday, 3.July 13
![Page 30: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/30.jpg)
class Presence(Component):
namevar = 'path' leading = False
def configure(self): if self.leading: self += Directory( os.path.dirname(self.path), leading=self.leading)
def verify(self): if not os.path.isfile(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) with open(self.path, 'w'): pass
run on target
only if needed
run "anywhere"
after all sub-components
keep delegating!
Wednesday, 3.July 13
![Page 31: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/31.jpg)
class Directory(Component):
namevar = 'path' leading = False
def verify(self): if not os.path.isdir(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) if self.leading: os.makedirs(self.path) else: os.mkdir(self.path)
Wednesday, 3.July 13
![Page 32: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/32.jpg)
class Directory(Component):
namevar = 'path' leading = False
def verify(self): if not os.path.isdir(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) if self.leading: os.makedirs(self.path) else: os.mkdir(self.path)
could be done with recursive composition
Wednesday, 3.July 13
![Page 33: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/33.jpg)
class Directory(Component):
namevar = 'path' leading = False
def verify(self): if not os.path.isdir(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) if self.leading: os.makedirs(self.path) else: os.mkdir(self.path)
could be done with recursive composition
refactor with sub-components if too
complex
Wednesday, 3.July 13
![Page 34: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/34.jpg)
class Directory(Component):
namevar = 'path' leading = False
def verify(self): if not os.path.isdir(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) if self.leading: os.makedirs(self.path) else: os.mkdir(self.path)
all methods optional: no configure()
could be done with recursive composition
refactor with sub-components if too
complex
Wednesday, 3.July 13
![Page 35: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/35.jpg)
class Directory(Component):
namevar = 'path' leading = False
def verify(self): if not os.path.isdir(self.path): raise batou.UpdateNeeded()
def update(self): ensure_path_nonexistent(self.path) if self.leading: os.makedirs(self.path) else: os.mkdir(self.path)
all methods optional: no configure()
could be done with recursive composition
pattern: just wipe out what's wrong
refactor with sub-components if too
complex
Wednesday, 3.July 13
![Page 36: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/36.jpg)
CONVERGENCE
resume where needed
handle many system states transparently avoid
unnecessary updates
Wednesday, 3.July 13
![Page 37: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/37.jpg)
COMPONENTS
composition of simple components
no magic bullet, just a lot easier to factor
your code
configure - verify - update
Wednesday, 3.July 13
![Page 38: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/38.jpg)
Wednesday, 3.July 13
![Page 39: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/39.jpg)
SINGLE-COMMAND
Wednesday, 3.July 13
![Page 40: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/40.jpg)
REPEATABLERELIABLE
Wednesday, 3.July 13
![Page 41: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/41.jpg)
SIMPLE
Wednesday, 3.July 13
![Page 42: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/42.jpg)
ENTROPY
Wednesday, 3.July 13
![Page 43: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/43.jpg)
EXPRESSIVENESSREADABILITY
Wednesday, 3.July 13
![Page 44: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/44.jpg)
REUSABLE
Wednesday, 3.July 13
![Page 45: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/45.jpg)
PLATFORM INDEPENDENCE
Wednesday, 3.July 13
![Page 46: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/46.jpg)
DOMAIN AGNOSTIC
Wednesday, 3.July 13
![Page 47: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/47.jpg)
NO ADDITIONAL RUNTIME
DEPENDENCIES
Wednesday, 3.July 13
![Page 48: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/48.jpg)
CONTINUITY
Wednesday, 3.July 13
![Page 49: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/49.jpg)
MINIMAL DOWNTIMES
Wednesday, 3.July 13
![Page 50: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/50.jpg)
Wednesday, 3.July 13
![Page 51: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/51.jpg)
PRACTICAL USAGE
Wednesday, 3.July 13
![Page 52: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/52.jpg)
REQUIREMENTS
Python 2.7
SSH
virtualenvMercurial
Wednesday, 3.July 13
![Page 53: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/53.jpg)
ENVIRONMENTS
[environment]service_user = myservicehost_domain = flyingcircus.iobranch = production
[hosts]multikarl00 = nginx, haproxymultikarl01 = postgres, redis, memcached, crontabmultikarl12 = supervisor, logrotate, doctotext, myappmultikarl13 = supervisor, logrotate, doctotext, myapp
Wednesday, 3.July 13
![Page 54: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/54.jpg)
LOCAL
$ bin/batou-local dev localhostUpdating Hello > File(hello) > Presence(hello)Updating Hello > File(hello) > Content(hello)$ bin/batou-local dev localhost$
Wednesday, 3.July 13
![Page 55: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/55.jpg)
REMOTE$ bin/batou-remote prodtest02.gocept.net: connectingtest01.gocept.net: connectingtest01.gocept.net: bootstrappingtest02.gocept.net: bootstrappingOKOKDeploying test01.gocept.net/helloUpdating Hello > File(hello) > Presence(hello)Updating Hello > File(hello) > Content(hello)OKDeploying test02.gocept.net/helloUpdating Hello > File(hello) > Presence(hello)Updating Hello > File(hello) > Content(hello)OK
Wednesday, 3.July 13
![Page 56: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/56.jpg)
OVERRIDES
class Hello(Component):
hostname = "foo"
[environment]...
[component:hello]hostname = bar
Wednesday, 3.July 13
![Page 57: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/57.jpg)
SECRETS
class Hello(Component):
db_password = none
secrets/production.cfg
[hello]db_password = reallysecretstuff
Wednesday, 3.July 13
![Page 58: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/58.jpg)
SECRETS
class Hello(Component):
db_password = none
secrets/production.cfg
[hello]db_password = reallysecretstuff
SciFibut close
Wednesday, 3.July 13
![Page 59: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/59.jpg)
PROVIDE/REQUIREclass MyApp(Component):
def configure(self): self.provide('appserver', self.host.fqdn)
class HAProxy(Component):
def configure(self): self.backends = \ self.require('appserver')
Wednesday, 3.July 13
![Page 60: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/60.jpg)
PLATFORMSclass HAProxy(Component): ...
@platform('flyingcircus.io', HAProxy)class SystemWideHAProxy(Component):
def configure(self): self += File('/etc/haproxy', ensure='symlink', link_to=self.parent.haproxy_cfg.path)
Wednesday, 3.July 13
![Page 61: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/61.jpg)
VFS MAPPING
./
...
./work
./work/_/etc/haproxy.cfg
class HAProxy(Component):
def configure(self): self += File('/etc/haproxy')
[environment]...[vfs]sandbox = Developer
Wednesday, 3.July 13
![Page 62: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/62.jpg)
FEATURESclass MyApp(Component):
features = ['instance', 'jobrunner']
def configure(self): if 'instance' in self.features: ...
[hosts]hosta = myapp:instancehostb = myapp:jobrunnerhostc = myapp:instance, myapp:jobrunnerhostd = myapp
Wednesday, 3.July 13
![Page 63: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/63.jpg)
Wednesday, 3.July 13
![Page 64: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/64.jpg)
CONVERGENCE
COMPOSITION
DETAILSWednesday, 3.July 13
![Page 65: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/65.jpg)
QUESTIONS?
Wednesday, 3.July 13
![Page 66: batou - multi(component|host|environment|.*) deployment](https://reader034.vdocument.in/reader034/viewer/2022042713/5453745eb1af9f95228b4607/html5/thumbnails/66.jpg)
batou.readthedocs.org
pypi.python.org/pypi/batou
bitbucket.org/gocept/batou
Wednesday, 3.July 13