Merge lp:~allenap/postgresfixture/it-begins into lp:~lazr-developers/postgresfixture/trunk

Proposed by Gavin Panella on 2012-05-14
Status: Merged
Merged at revision: 2
Proposed branch: lp:~allenap/postgresfixture/it-begins
Merge into: lp:~lazr-developers/postgresfixture/trunk
Diff against target: 1265 lines (+1192/-0)
14 files modified
.bzrignore (+10/-0)
Makefile (+25/-0)
README.txt (+43/-0)
postgresfixture/__init__.py (+17/-0)
postgresfixture/__main__.py (+17/-0)
postgresfixture/cluster.py (+167/-0)
postgresfixture/clusterfixture.py (+142/-0)
postgresfixture/main.py (+185/-0)
postgresfixture/testing/__init__.py (+25/-0)
postgresfixture/tests/test_cluster.py (+186/-0)
postgresfixture/tests/test_clusterfixture.py (+168/-0)
postgresfixture/tests/test_main.py (+163/-0)
requirements.txt (+3/-0)
setup.py (+41/-0)
To merge this branch: bzr merge lp:~allenap/postgresfixture/it-begins
Reviewer Review Type Date Requested Status
Richard Harding code 2012-05-14 Approve on 2012-05-15
Review via email: mp+105708@code.launchpad.net

Commit Message

It starts now.

Description of the Change

This is taken from an unlanded branch for the MAAS project, lp:~allenap/maas/database-run. I've then split it up and projectified it. I started using buildout until I just couldn't bear it any longer - it would not Just Work - so I switched to virtualenv+pip and have been much happier since. It's less powerful, but equally I have spent *much* less time toying with it.

To post a comment you must log in.
Robert Collins (lifeless) wrote :

How is this better than van.pg which we already use? Looks like
little/no difference. Did you try using van.pg?

Gavin Panella (allenap) wrote :

> How is this better than van.pg which we already use? Looks like
> little/no difference. Did you try using van.pg?

I did try using van.pg and started writing wrappers around it to get
the behaviour I wanted, until it became apparent that van.pg made too
many assumptions that were not suitable.

Additionally:

- van.pg has 2 tests. This has 69.

- IMO, this models a cluster much better than van.pg. The model for a
  pre-existing cluster and one that this code creates is the same;
  van.pg has RunningCluster and Cluster, the former supporting a
  subset of the operations of the latter.

- This has the concept of creating a cluster but preserving it instead
  of destroying it. This makes subsequent runs quicker, and also helps
  when using the cluster for local development.

- This has a command-line tool for working with the cluster. With this
  it's easy to run a cluster, open a shell, stop, or destroy it.

- This has the concept of multiple users of the cluster. Only the last
  user will stop or destroy the cluster. Again, this is useful for
  local development, where a cluster is used both for the development
  service and running tests.

- I believe that this is a better platform for future development; I
  think it's higher quality.

- van.pg contains unavoidable code that seems very specific to someone
  else's problem, in createdb() for example:

        assert template is None or template.startswith('test_db'), template
        assert template is None or int(template[7:]) <= self._db_counter, (template, self._db_counter)

Things van.pg has that this does not:

- Can be used as a test resource. However, so can postgresfixture via
  FixtureResource.

- Integration with the transaction module when using the test resource
  class, DatabaseManager (not sure why it's limited in this way). I
  developed postgresfixture for Django in MAAS, which has its own way
  of sorting out the database for tests, but it would be fairly simple
  to add something like this if there was a need. Also,

- When not using the test resource, van.pg has ConnWrapper to try and
  detect dirtied databases. It's a leaky abstraction though, and I
  think there are better ways of doing this.

- Background creation of new databases; while one test runs a pristine
  database is prepared. I wonder if this actually buys much.

Richard Harding (rharding) wrote :

Phew, so I've not used this or van.pg, but from a pure code review stand point. Thanks for the lesson that assertions will return items. I hadn't seen things like the .code on the assertRaises before.

- The dropdb test: does it provide no feedback that you're dropping a db that doesn't exist? Since the test asserts nothing, is it basically just that it runs?

- Just a suggestion to move the testtools requirement to a tests_require and try to wrap the test script into a test_suite entry in the setup.py so that the normal setup.py test can work and it'll fetch the extra dependency.

- As a new user, a little more in the readme, at least a hint as to the cmd line client name so I can hit up --help ootb would be useful. It links to the LP project page, but there's no additional usage/etc there I can see.

- There's a TODO, should this follow our XXX style in LP more? I'm not sure on how this works for outside projects.

review: Approve (code)
Gavin Panella (allenap) wrote :

> Phew, so I've not used this or van.pg, but from a pure code review
> stand point. Thanks for the lesson that assertions will return
> items. I hadn't seen things like the .code on the assertRaises
> before.
>
> - The dropdb test: does it provide no feedback that you're dropping
> a db that doesn't exist? Since the test asserts nothing, is it
> basically just that it runs?

Yes. I've added a comment to make that clear.

>
> - Just a suggestion to move the testtools requirement to a
> tests_require and try to wrap the test script into a test_suite
> entry in the setup.py so that the normal setup.py test can work and
> it'll fetch the extra dependency.

Oh, that's perfect, great suggestion, thanks.

>
> - As a new user, a little more in the readme, at least a hint as to the cmd
> line client name so I can hit up --help ootb would be useful. It links to the
> LP project page, but there's no additional usage/etc there I can see.

Yes, I'll work on this before landing.

>
> - There's a TODO, should this follow our XXX style in LP more? I'm not sure on
> how this works for outside projects.

Oops, that's done, so I've removed it.

Thanks for the review!

25. By Gavin Panella on 2012-05-21

Comment about the dropdb test.

26. By Gavin Panella on 2012-05-21

Move test stuff into setup.py.

27. By Gavin Panella on 2012-05-21

Remove test script; use setup.py instead (via Makefile).

28. By Gavin Panella on 2012-05-21

Remove TODO; it's done.

29. By Gavin Panella on 2012-05-21

testtools is needed for running as well as tests.

30. By Gavin Panella on 2012-05-21

More docs.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file '.bzrignore'
2--- .bzrignore 1970-01-01 00:00:00 +0000
3+++ .bzrignore 2012-05-21 11:22:18 +0000
4@@ -0,0 +1,10 @@
5+*.egg
6+./bin
7+./build
8+./dist
9+./include
10+./lib
11+./local
12+./postgresfixture.egg-info
13+./TAGS
14+./tags
15
16=== added file 'Makefile'
17--- Makefile 1970-01-01 00:00:00 +0000
18+++ Makefile 2012-05-21 11:22:18 +0000
19@@ -0,0 +1,25 @@
20+# Copyright 2012 Canonical Ltd. This software is licensed under the
21+# GNU Affero General Public License version 3 (see the file LICENSE).
22+
23+PYTHON := python
24+
25+build: bin/python bin/pip requirements.txt
26+ bin/pip install --requirement=requirements.txt
27+
28+dist: setup.py
29+ bin/python setup.py egg_info -r sdist
30+
31+test: bin/python
32+ bin/python setup.py test
33+
34+clean:
35+ $(RM) -r bin build dist include lib local TAGS tags
36+ find . -name '*.py[co]' -print0 | xargs -r0 $(RM) -r
37+ find . -name '*.egg' -print0 | xargs -r0 $(RM) -r
38+ find . -name '*.egg-info' -print0 | xargs -r0 $(RM) -r
39+ find . -name '*~' -print0 | xargs -r0 $(RM)
40+
41+bin/python bin/pip:
42+ virtualenv --python=$(PYTHON) $(PWD)
43+
44+.PHONY: build dist clean
45
46=== added file 'README.txt'
47--- README.txt 1970-01-01 00:00:00 +0000
48+++ README.txt 2012-05-21 11:22:18 +0000
49@@ -0,0 +1,43 @@
50+.. -*- mode: rst -*-
51+
52+***************
53+postgresfixture
54+***************
55+
56+A Python fixture for creating PostgreSQL clusters and databases, and
57+tearing them down again, intended for use during development and
58+testing.
59+
60+For more information see the `Launchpad project page`_.
61+
62+.. _Launchpad project page: https://launchpad.net/postgresfixture
63+
64+
65+Getting started
66+===============
67+
68+Use like any other fixture::
69+
70+ from contextlib import closing
71+ from postgresfixture import ClusterFixture
72+
73+ def test_something(self):
74+ cluster = self.useFixture(ClusterFixture("db"))
75+ cluster.createdb("example")
76+ with closing(cluster.connect("example")) as conn:
77+ ...
78+ cluster.dropbdb("example") # Optional.
79+
80+This will create a new cluster, create a database called "example",
81+then tear it all down at the end; nothing will remain on disk. If you
82+want the cluster and its databases to remain on disk, pass
83+``preserve=True`` to the ``ClusterFixture`` constructor.
84+
85+
86+From the command line
87+=====================
88+
89+Once this package is installed, you'll have a ``postgresfixture``
90+script. Alternatively you can use ``python -m postgresfixture`` to
91+achieve the same thing. Use ``--help`` to discover the options
92+available to you.
93
94=== added directory 'postgresfixture'
95=== added file 'postgresfixture/__init__.py'
96--- postgresfixture/__init__.py 1970-01-01 00:00:00 +0000
97+++ postgresfixture/__init__.py 2012-05-21 11:22:18 +0000
98@@ -0,0 +1,17 @@
99+# Copyright 2012 Canonical Ltd. This software is licensed under the
100+# GNU Affero General Public License version 3 (see the file LICENSE).
101+
102+"""PostgreSQL cluster fixture."""
103+
104+from __future__ import (
105+ absolute_import,
106+ print_function,
107+ unicode_literals,
108+ )
109+
110+__metaclass__ = type
111+__all__ = [
112+ "ClusterFixture",
113+ ]
114+
115+from postgresfixture.clusterfixture import ClusterFixture
116
117=== added file 'postgresfixture/__main__.py'
118--- postgresfixture/__main__.py 1970-01-01 00:00:00 +0000
119+++ postgresfixture/__main__.py 2012-05-21 11:22:18 +0000
120@@ -0,0 +1,17 @@
121+# Copyright 2012 Canonical Ltd. This software is licensed under the
122+# GNU Affero General Public License version 3 (see the file LICENSE).
123+
124+"""Entry point for `postgresfixture` on the command-line."""
125+
126+from __future__ import (
127+ absolute_import,
128+ print_function,
129+ unicode_literals,
130+ )
131+
132+__metaclass__ = type
133+__all__ = []
134+
135+from postgresfixture.main import main
136+
137+main()
138
139=== added file 'postgresfixture/cluster.py'
140--- postgresfixture/cluster.py 1970-01-01 00:00:00 +0000
141+++ postgresfixture/cluster.py 2012-05-21 11:22:18 +0000
142@@ -0,0 +1,167 @@
143+# Copyright 2012 Canonical Ltd. This software is licensed under the
144+# GNU Affero General Public License version 3 (see the file LICENSE).
145+
146+"""Manage a PostgreSQL cluster."""
147+
148+from __future__ import (
149+ absolute_import,
150+ print_function,
151+ unicode_literals,
152+ )
153+
154+__metaclass__ = type
155+__all__ = [
156+ "Cluster",
157+ ]
158+
159+from contextlib import closing
160+from os import (
161+ devnull,
162+ environ,
163+ makedirs,
164+ path,
165+ )
166+import pipes
167+from shutil import rmtree
168+from subprocess import (
169+ CalledProcessError,
170+ check_call,
171+ )
172+
173+import psycopg2
174+
175+
176+PG_VERSION = "9.1"
177+PG_BIN = "/usr/lib/postgresql/%s/bin" % PG_VERSION
178+
179+
180+def path_with_pg_bin(exe_path):
181+ """Return `exe_path` with `PG_BIN` added."""
182+ exe_path = [
183+ elem for elem in exe_path.split(path.pathsep)
184+ if len(elem) != 0 and not elem.isspace()
185+ ]
186+ if PG_BIN not in exe_path:
187+ exe_path.insert(0, PG_BIN)
188+ return path.pathsep.join(exe_path)
189+
190+
191+class Cluster:
192+ """Represents a PostgreSQL cluster, running or not."""
193+
194+ def __init__(self, datadir):
195+ self.datadir = path.abspath(datadir)
196+
197+ def execute(self, *command, **options):
198+ """Execute a command with an environment suitable for this cluster."""
199+ env = options.pop("env", environ).copy()
200+ env["PATH"] = path_with_pg_bin(env.get("PATH", ""))
201+ env["PGDATA"] = env["PGHOST"] = self.datadir
202+ check_call(command, env=env, **options)
203+
204+ @property
205+ def exists(self):
206+ """Whether or not this cluster exists on disk."""
207+ version_file = path.join(self.datadir, "PG_VERSION")
208+ return path.exists(version_file)
209+
210+ @property
211+ def pidfile(self):
212+ """The (expected) pidfile for a running cluster.
213+
214+ Does *not* guarantee that the pidfile exists.
215+ """
216+ return path.join(self.datadir, "postmaster.pid")
217+
218+ @property
219+ def logfile(self):
220+ """The log file used (or will be used) by this cluster."""
221+ return path.join(self.datadir, "backend.log")
222+
223+ @property
224+ def running(self):
225+ """Whether this cluster is running or not."""
226+ with open(devnull, "rb") as null:
227+ try:
228+ self.execute("pg_ctl", "status", stdout=null)
229+ except CalledProcessError, error:
230+ if error.returncode == 1:
231+ return False
232+ else:
233+ raise
234+ else:
235+ return True
236+
237+ def create(self):
238+ """Create this cluster, if it does not exist."""
239+ if not self.exists:
240+ if not path.isdir(self.datadir):
241+ makedirs(self.datadir)
242+ self.execute("pg_ctl", "init", "-s", "-o", "-E utf8 -A trust")
243+
244+ def start(self):
245+ """Start this cluster, if it's not already started."""
246+ if not self.running:
247+ self.create()
248+ # pg_ctl options:
249+ # -l <file> -- log file.
250+ # -s -- no informational messages.
251+ # -w -- wait until startup is complete.
252+ # postgres options:
253+ # -h <arg> -- host name; empty arg means Unix socket only.
254+ # -F -- don't bother fsync'ing.
255+ # -k -- socket directory.
256+ self.execute(
257+ "pg_ctl", "start", "-l", self.logfile, "-s", "-w",
258+ "-o", "-h '' -F -k %s" % pipes.quote(self.datadir))
259+
260+ def connect(self, database="template1", autocommit=True):
261+ """Connect to this cluster.
262+
263+ Starts the cluster if necessary.
264+ """
265+ self.start()
266+ connection = psycopg2.connect(
267+ database=database, host=self.datadir)
268+ connection.autocommit = autocommit
269+ return connection
270+
271+ def shell(self, database="template1"):
272+ self.execute("psql", "--quiet", "--", database)
273+
274+ @property
275+ def databases(self):
276+ """The names of databases in this cluster."""
277+ with closing(self.connect("postgres")) as conn:
278+ with closing(conn.cursor()) as cur:
279+ cur.execute("SELECT datname FROM pg_catalog.pg_database")
280+ return {name for (name,) in cur.fetchall()}
281+
282+ def createdb(self, name):
283+ """Create the named database."""
284+ with closing(self.connect()) as conn:
285+ with closing(conn.cursor()) as cur:
286+ cur.execute("CREATE DATABASE %s" % name)
287+
288+ def dropdb(self, name):
289+ """Drop the named database."""
290+ with closing(self.connect()) as conn:
291+ with closing(conn.cursor()) as cur:
292+ cur.execute("DROP DATABASE %s" % name)
293+
294+ def stop(self):
295+ """Stop this cluster, if started."""
296+ if self.running:
297+ # pg_ctl options:
298+ # -w -- wait for shutdown to complete.
299+ # -m <mode> -- shutdown mode.
300+ self.execute("pg_ctl", "stop", "-s", "-w", "-m", "fast")
301+
302+ def destroy(self):
303+ """Destroy this cluster, if it exists.
304+
305+ The cluster will be stopped if it's started.
306+ """
307+ if self.exists:
308+ self.stop()
309+ rmtree(self.datadir)
310
311=== added file 'postgresfixture/clusterfixture.py'
312--- postgresfixture/clusterfixture.py 1970-01-01 00:00:00 +0000
313+++ postgresfixture/clusterfixture.py 2012-05-21 11:22:18 +0000
314@@ -0,0 +1,142 @@
315+# Copyright 2012 Canonical Ltd. This software is licensed under the
316+# GNU Affero General Public License version 3 (see the file LICENSE).
317+
318+"""Manage a PostgreSQL cluster."""
319+
320+from __future__ import (
321+ absolute_import,
322+ print_function,
323+ unicode_literals,
324+ )
325+
326+__metaclass__ = type
327+__all__ = [
328+ "ClusterFixture",
329+ ]
330+
331+from errno import (
332+ EEXIST,
333+ ENOENT,
334+ ENOTEMPTY,
335+ )
336+from os import (
337+ getpid,
338+ listdir,
339+ makedirs,
340+ path,
341+ rmdir,
342+ unlink,
343+ )
344+
345+from fixtures import Fixture
346+from postgresfixture.cluster import Cluster
347+
348+
349+class ProcessSemaphore:
350+ """A sort-of-semaphore where it is considered locked if a directory cannot
351+ be removed.
352+
353+ The locks are taken out one per-process, so this is a way of keeping a
354+ reference to a shared resource between processes.
355+ """
356+
357+ def __init__(self, lockdir):
358+ super(ProcessSemaphore, self).__init__()
359+ self.lockdir = lockdir
360+ self.lockfile = path.join(
361+ self.lockdir, "%d" % getpid())
362+
363+ def acquire(self):
364+ try:
365+ makedirs(self.lockdir)
366+ except OSError, error:
367+ if error.errno != EEXIST:
368+ raise
369+ open(self.lockfile, "w").close()
370+
371+ def release(self):
372+ try:
373+ unlink(self.lockfile)
374+ except OSError, error:
375+ if error.errno != ENOENT:
376+ raise
377+
378+ @property
379+ def locked(self):
380+ try:
381+ rmdir(self.lockdir)
382+ except OSError, error:
383+ if error.errno == ENOTEMPTY:
384+ return True
385+ elif error.errno == ENOENT:
386+ return False
387+ else:
388+ raise
389+ else:
390+ return False
391+
392+ @property
393+ def locked_by(self):
394+ try:
395+ return [
396+ int(name) if name.isdigit() else name
397+ for name in listdir(self.lockdir)
398+ ]
399+ except OSError, error:
400+ if error.errno == ENOENT:
401+ return []
402+ else:
403+ raise
404+
405+
406+class ClusterFixture(Cluster, Fixture):
407+ """A fixture for a `Cluster`."""
408+
409+ def __init__(self, datadir, preserve=False):
410+ """
411+ @param preserve: Leave the cluster and its databases behind, even if
412+ this fixture creates them.
413+ """
414+ super(ClusterFixture, self).__init__(datadir)
415+ self.preserve = preserve
416+ self.lock = ProcessSemaphore(
417+ path.join(self.datadir, "locks"))
418+
419+ def setUp(self):
420+ super(ClusterFixture, self).setUp()
421+ # Only destroy the cluster if we create it...
422+ if not self.exists:
423+ # ... unless we've been asked to preserve it.
424+ if not self.preserve:
425+ self.addCleanup(self.destroy)
426+ self.create()
427+ self.addCleanup(self.stop)
428+ self.start()
429+ self.addCleanup(self.lock.release)
430+ self.lock.acquire()
431+
432+ def createdb(self, name):
433+ """Create the named database if it does not exist already.
434+
435+ Arranges to drop the named database during clean-up, unless `preserve`
436+ has been specified.
437+ """
438+ if name not in self.databases:
439+ super(ClusterFixture, self).createdb(name)
440+ if not self.preserve:
441+ self.addCleanup(self.dropdb, name)
442+
443+ def dropdb(self, name):
444+ """Drop the named database if it exists."""
445+ if name in self.databases:
446+ super(ClusterFixture, self).dropdb(name)
447+
448+ def stop(self):
449+ """Stop the cluster, but only if there are no other users."""
450+ if not self.lock.locked:
451+ super(ClusterFixture, self).stop()
452+
453+ def destroy(self):
454+ """Destroy the cluster, but only if there are no other users."""
455+ if not self.lock.locked:
456+ super(ClusterFixture, self).destroy()
457
458=== added file 'postgresfixture/main.py'
459--- postgresfixture/main.py 1970-01-01 00:00:00 +0000
460+++ postgresfixture/main.py 2012-05-21 11:22:18 +0000
461@@ -0,0 +1,185 @@
462+# Copyright 2012 Canonical Ltd. This software is licensed under the
463+# GNU Affero General Public License version 3 (see the file LICENSE).
464+
465+"""Manage a PostgreSQL cluster."""
466+
467+from __future__ import (
468+ absolute_import,
469+ print_function,
470+ unicode_literals,
471+ )
472+
473+__metaclass__ = type
474+__all__ = [
475+ "main",
476+ ]
477+
478+import argparse
479+from itertools import imap
480+from os import (
481+ environ,
482+ fdopen,
483+ )
484+import pipes
485+import signal
486+from subprocess import CalledProcessError
487+import sys
488+from time import sleep
489+
490+from postgresfixture.clusterfixture import ClusterFixture
491+
492+
493+def setup():
494+ # Ensure stdout and stderr are line-bufferred.
495+ sys.stdout = fdopen(sys.stdout.fileno(), "ab", 1)
496+ sys.stderr = fdopen(sys.stderr.fileno(), "ab", 1)
497+ # Run the SIGINT handler on SIGTERM; `svc -d` sends SIGTERM.
498+ signal.signal(signal.SIGTERM, signal.default_int_handler)
499+
500+
501+def repr_pid(pid):
502+ if isinstance(pid, int) or pid.isdigit():
503+ try:
504+ with open("/proc/%s/cmdline" % pid, "rb") as fd:
505+ cmdline = fd.read().rstrip("\0").split("\0")
506+ except IOError:
507+ return "%s (*unknown*)" % pid
508+ else:
509+ return "%s (%s)" % (
510+ pid, " ".join(imap(pipes.quote, cmdline)))
511+ else:
512+ return pipes.quote(pid)
513+
514+
515+def locked_by_description(lock):
516+ pids = sorted(lock.locked_by)
517+ return "locked by:\n* %s" % (
518+ "\n* ".join(imap(repr_pid, pids)))
519+
520+
521+def error(*args, **kwargs):
522+ kwargs.setdefault("file", sys.stderr)
523+ return print(*args, **kwargs)
524+
525+
526+def get_database_name(default="data"):
527+ """Return the desired database name, used by some commands.
528+
529+ Obtained from the ``PGDATABASE`` environment variable.
530+ """
531+ return environ.get("PGDATABASE", default)
532+
533+
534+def action_destroy(cluster):
535+ """Destroy a cluster."""
536+ action_stop(cluster)
537+ cluster.destroy()
538+ if cluster.exists:
539+ if cluster.lock.locked:
540+ message = "%s: cluster is %s" % (
541+ cluster.datadir, locked_by_description(cluster.lock))
542+ else:
543+ message = "%s: cluster could not be removed." % cluster.datadir
544+ error(message)
545+ raise SystemExit(2)
546+
547+
548+def action_run(cluster):
549+ """Create and run a cluster.
550+
551+ If specified in the ``PGDATABASE`` environment variable, a database will
552+ also be created within the cluster.
553+ """
554+ database_name = get_database_name(default=None)
555+ with cluster:
556+ if database_name is not None:
557+ cluster.createdb(database_name)
558+ while cluster.running:
559+ sleep(5.0)
560+
561+
562+def action_shell(cluster):
563+ """Spawn a ``psql`` shell for a database in the cluster.
564+
565+ The database name can be specified in the ``PGDATABASE`` environment
566+ variable.
567+ """
568+ database_name = get_database_name()
569+ with cluster:
570+ cluster.createdb(database_name)
571+ cluster.shell(database_name)
572+
573+
574+def action_status(cluster):
575+ """Display a message about the state of the cluster.
576+
577+ The return code is also set:
578+
579+ - 0: cluster is running.
580+ - 1: cluster exists, but is not running.
581+ - 2: cluster does not exist.
582+
583+ """
584+ if cluster.exists:
585+ if cluster.running:
586+ print("%s: running" % cluster.datadir)
587+ raise SystemExit(0)
588+ else:
589+ print("%s: not running" % cluster.datadir)
590+ raise SystemExit(1)
591+ else:
592+ print("%s: not created" % cluster.datadir)
593+ raise SystemExit(2)
594+
595+
596+def action_stop(cluster):
597+ """Stop a cluster."""
598+ cluster.stop()
599+ if cluster.running:
600+ if cluster.lock.locked:
601+ message = "%s: cluster is %s" % (
602+ cluster.datadir, locked_by_description(cluster.lock))
603+ else:
604+ message = "%s: cluster is still running." % cluster.datadir
605+ error(message)
606+ raise SystemExit(2)
607+
608+
609+actions = {
610+ "destroy": action_destroy,
611+ "run": action_run,
612+ "shell": action_shell,
613+ "status": action_status,
614+ "stop": action_stop,
615+ }
616+
617+
618+argument_parser = argparse.ArgumentParser(description=__doc__)
619+argument_parser.add_argument(
620+ "action", choices=sorted(actions), nargs="?", default="shell",
621+ help="the action to perform (default: %(default)s)")
622+argument_parser.add_argument(
623+ "-D", "--datadir", dest="datadir", action="store_true",
624+ default="db", help=(
625+ "the directory in which to place, or find, the cluster "
626+ "(default: %(default)s)"))
627+argument_parser.add_argument(
628+ "--preserve", dest="preserve", action="store_true",
629+ default=False, help=(
630+ "preserve the cluster and its databases when exiting, "
631+ "even if it was necessary to create and start it "
632+ "(default: %(default)s)"))
633+
634+
635+def main(args=None):
636+ args = argument_parser.parse_args(args)
637+ try:
638+ setup()
639+ action = actions[args.action]
640+ cluster = ClusterFixture(
641+ datadir=args.datadir, preserve=args.preserve)
642+ action(cluster)
643+ except CalledProcessError, error:
644+ raise SystemExit(error.returncode)
645+ except KeyboardInterrupt:
646+ pass
647
648=== added directory 'postgresfixture/testing'
649=== added file 'postgresfixture/testing/__init__.py'
650--- postgresfixture/testing/__init__.py 1970-01-01 00:00:00 +0000
651+++ postgresfixture/testing/__init__.py 2012-05-21 11:22:18 +0000
652@@ -0,0 +1,25 @@
653+# Copyright 2012 Canonical Ltd. This software is licensed under the
654+# GNU Affero General Public License version 3 (see the file LICENSE).
655+
656+"""Testing resources for `postgresfixture`."""
657+
658+from __future__ import (
659+ absolute_import,
660+ print_function,
661+ unicode_literals,
662+ )
663+
664+__metaclass__ = type
665+__all__ = [
666+ "TestCase",
667+ ]
668+
669+from fixtures import TempDir
670+import testtools
671+
672+
673+class TestCase(testtools.TestCase):
674+ """Convenience subclass."""
675+
676+ def make_dir(self):
677+ return self.useFixture(TempDir()).path
678
679=== added directory 'postgresfixture/tests'
680=== added file 'postgresfixture/tests/__init__.py'
681=== added file 'postgresfixture/tests/test_cluster.py'
682--- postgresfixture/tests/test_cluster.py 1970-01-01 00:00:00 +0000
683+++ postgresfixture/tests/test_cluster.py 2012-05-21 11:22:18 +0000
684@@ -0,0 +1,186 @@
685+# Copyright 2012 Canonical Ltd. This software is licensed under the
686+# GNU Affero General Public License version 3 (see the file LICENSE).
687+
688+"""Tests for `postgresfixture.cluster`."""
689+
690+from __future__ import (
691+ absolute_import,
692+ print_function,
693+ unicode_literals,
694+ )
695+
696+__metaclass__ = type
697+__all__ = []
698+
699+from contextlib import closing
700+from os import (
701+ getpid,
702+ path,
703+ )
704+from subprocess import CalledProcessError
705+
706+import postgresfixture.cluster
707+from postgresfixture.cluster import (
708+ Cluster,
709+ path_with_pg_bin,
710+ PG_BIN,
711+ )
712+from postgresfixture.main import repr_pid
713+from postgresfixture.testing import TestCase
714+from testtools.matchers import (
715+ DirExists,
716+ FileExists,
717+ Not,
718+ StartsWith,
719+ )
720+
721+
722+class TestFunctions(TestCase):
723+
724+ def test_path_with_pg_bin(self):
725+ self.assertEqual(PG_BIN, path_with_pg_bin(""))
726+ self.assertEqual(
727+ PG_BIN + path.pathsep + "/bin:/usr/bin",
728+ path_with_pg_bin("/bin:/usr/bin"))
729+
730+ def test_repr_pid_not_a_number(self):
731+ self.assertEqual("alice", repr_pid("alice"))
732+ self.assertEqual("'alice and bob'", repr_pid("alice and bob"))
733+
734+ def test_repr_pid_not_a_process(self):
735+ self.assertEqual("0 (*unknown*)", repr_pid(0))
736+
737+ def test_repr_pid_this_process(self):
738+ pid = getpid()
739+ self.assertThat(repr_pid(pid), StartsWith("%d (" % pid))
740+
741+
742+class TestCluster(TestCase):
743+
744+ make = Cluster
745+
746+ def test_init(self):
747+ # The datadir passed into the Cluster constructor is resolved to an
748+ # absolute path.
749+ datadir = path.join(self.make_dir(), "locks")
750+ cluster = self.make(path.relpath(datadir))
751+ self.assertEqual(datadir, cluster.datadir)
752+
753+ def patch_check_call(self, returncode=0):
754+ calls = []
755+
756+ def check_call(command, **options):
757+ calls.append((command, options))
758+ if returncode != 0:
759+ raise CalledProcessError(returncode, command)
760+
761+ self.patch(postgresfixture.cluster, "check_call", check_call)
762+ return calls
763+
764+ def test_execute(self):
765+ calls = self.patch_check_call()
766+ cluster = self.make(self.make_dir())
767+ cluster.execute("true")
768+ [(command, options)] = calls
769+ self.assertEqual(("true",), command)
770+ self.assertIn("env", options)
771+ env = options["env"]
772+ self.assertEqual(cluster.datadir, env.get("PGDATA"))
773+ self.assertEqual(cluster.datadir, env.get("PGHOST"))
774+ self.assertThat(
775+ env.get("PATH", ""),
776+ StartsWith(PG_BIN + path.pathsep))
777+
778+ def test_exists(self):
779+ cluster = self.make(self.make_dir())
780+ # The PG_VERSION file is used as a marker of existence.
781+ version_file = path.join(cluster.datadir, "PG_VERSION")
782+ self.assertThat(version_file, Not(FileExists()))
783+ self.assertFalse(cluster.exists)
784+ open(version_file, "wb").close()
785+ self.assertTrue(cluster.exists)
786+
787+ def test_pidfile(self):
788+ self.assertEqual(
789+ "/some/where/postmaster.pid",
790+ self.make("/some/where").pidfile)
791+
792+ def test_logfile(self):
793+ self.assertEqual(
794+ "/some/where/backend.log",
795+ self.make("/some/where").logfile)
796+
797+ def test_running(self):
798+ calls = self.patch_check_call(returncode=0)
799+ cluster = self.make("/some/where")
800+ self.assertTrue(cluster.running)
801+ [(command, options)] = calls
802+ self.assertEqual(("pg_ctl", "status"), command)
803+
804+ def test_running_not(self):
805+ self.patch_check_call(returncode=1)
806+ cluster = self.make("/some/where")
807+ self.assertFalse(cluster.running)
808+
809+ def test_running_error(self):
810+ self.patch_check_call(returncode=2)
811+ cluster = self.make("/some/where")
812+ self.assertRaises(
813+ CalledProcessError, getattr, cluster, "running")
814+
815+ def test_create(self):
816+ cluster = self.make(self.make_dir())
817+ cluster.create()
818+ self.assertTrue(cluster.exists)
819+ self.assertFalse(cluster.running)
820+
821+ def test_start_and_stop(self):
822+ cluster = self.make(self.make_dir())
823+ cluster.create()
824+ try:
825+ cluster.start()
826+ self.assertTrue(cluster.running)
827+ finally:
828+ cluster.stop()
829+ self.assertFalse(cluster.running)
830+
831+ def test_connect(self):
832+ cluster = self.make(self.make_dir())
833+ cluster.create()
834+ self.addCleanup(cluster.stop)
835+ cluster.start()
836+ with closing(cluster.connect()) as conn:
837+ with closing(conn.cursor()) as cur:
838+ cur.execute("SELECT 1")
839+ self.assertEqual([(1,)], cur.fetchall())
840+
841+ def test_databases(self):
842+ cluster = self.make(self.make_dir())
843+ cluster.create()
844+ self.addCleanup(cluster.stop)
845+ cluster.start()
846+ self.assertEqual(
847+ {"postgres", "template0", "template1"},
848+ cluster.databases)
849+
850+ def test_createdb_and_dropdb(self):
851+ cluster = self.make(self.make_dir())
852+ cluster.create()
853+ self.addCleanup(cluster.stop)
854+ cluster.start()
855+ cluster.createdb("setherial")
856+ self.assertEqual(
857+ {"postgres", "template0", "template1", "setherial"},
858+ cluster.databases)
859+ cluster.dropdb("setherial")
860+ self.assertEqual(
861+ {"postgres", "template0", "template1"},
862+ cluster.databases)
863+
864+ def test_destroy(self):
865+ cluster = self.make(self.make_dir())
866+ cluster.create()
867+ cluster.destroy()
868+ self.assertFalse(cluster.exists)
869+ self.assertFalse(cluster.running)
870+ self.assertThat(cluster.datadir, Not(DirExists()))
871
872=== added file 'postgresfixture/tests/test_clusterfixture.py'
873--- postgresfixture/tests/test_clusterfixture.py 1970-01-01 00:00:00 +0000
874+++ postgresfixture/tests/test_clusterfixture.py 2012-05-21 11:22:18 +0000
875@@ -0,0 +1,168 @@
876+# Copyright 2012 Canonical Ltd. This software is licensed under the
877+# GNU Affero General Public License version 3 (see the file LICENSE).
878+
879+"""Tests for `postgresfixture.clusterfixture`."""
880+
881+from __future__ import (
882+ absolute_import,
883+ print_function,
884+ unicode_literals,
885+ )
886+
887+__metaclass__ = type
888+__all__ = []
889+
890+from os import (
891+ getpid,
892+ path,
893+ )
894+
895+from postgresfixture.clusterfixture import (
896+ ClusterFixture,
897+ ProcessSemaphore,
898+ )
899+from postgresfixture.testing import TestCase
900+from postgresfixture.tests.test_cluster import TestCluster
901+from testtools.matchers import (
902+ FileExists,
903+ Not,
904+ )
905+
906+
907+class TestProcessSemaphore(TestCase):
908+
909+ def test_init(self):
910+ lockdir = self.make_dir()
911+ psem = ProcessSemaphore(lockdir)
912+ self.assertEqual(lockdir, psem.lockdir)
913+ self.assertEqual(
914+ path.join(lockdir, "%s" % getpid()),
915+ psem.lockfile)
916+
917+ def test_acquire(self):
918+ psem = ProcessSemaphore(
919+ path.join(self.make_dir(), "locks"))
920+ psem.acquire()
921+ self.assertThat(psem.lockfile, FileExists())
922+ self.assertTrue(psem.locked)
923+ self.assertEqual([getpid()], psem.locked_by)
924+
925+ def test_release(self):
926+ psem = ProcessSemaphore(
927+ path.join(self.make_dir(), "locks"))
928+ psem.acquire()
929+ psem.release()
930+ self.assertThat(psem.lockfile, Not(FileExists()))
931+ self.assertFalse(psem.locked)
932+ self.assertEqual([], psem.locked_by)
933+
934+
935+class TestClusterFixture(TestCluster):
936+
937+ def make(self, *args, **kwargs):
938+ fixture = ClusterFixture(*args, **kwargs)
939+ # Run the basic fixture set-up so that clean-ups can be added.
940+ super(ClusterFixture, fixture).setUp()
941+ return fixture
942+
943+ def test_init_fixture(self):
944+ fixture = self.make("/some/where")
945+ self.assertEqual(False, fixture.preserve)
946+ self.assertIsInstance(fixture.lock, ProcessSemaphore)
947+ self.assertEqual(
948+ path.join(fixture.datadir, "locks"),
949+ fixture.lock.lockdir)
950+
951+ def test_createdb_no_preserve(self):
952+ fixture = self.make(self.make_dir(), preserve=False)
953+ self.addCleanup(fixture.stop)
954+ fixture.start()
955+ fixture.createdb("danzig")
956+ self.assertIn("danzig", fixture.databases)
957+ # The database is only created if it does not already exist.
958+ fixture.createdb("danzig")
959+ # Creating a database arranges for it to be dropped when stopping the
960+ # fixture.
961+ fixture.cleanUp()
962+ self.assertNotIn("danzig", fixture.databases)
963+
964+ def test_createdb_preserve(self):
965+ fixture = self.make(self.make_dir(), preserve=True)
966+ self.addCleanup(fixture.stop)
967+ fixture.start()
968+ fixture.createdb("emperor")
969+ self.assertIn("emperor", fixture.databases)
970+ # The database is only created if it does not already exist.
971+ fixture.createdb("emperor")
972+ # Creating a database arranges for it to be dropped when stopping the
973+ # fixture.
974+ fixture.cleanUp()
975+ self.assertIn("emperor", fixture.databases)
976+
977+ def test_dropdb(self):
978+ fixture = self.make(self.make_dir())
979+ self.addCleanup(fixture.stop)
980+ fixture.start()
981+ # The database is only dropped if it exists.
982+ fixture.dropdb("diekrupps")
983+ fixture.dropdb("diekrupps")
984+ # The test is that we arrive here without error.
985+
986+ def test_stop_locked(self):
987+ # The cluster is not stopped if a lock is held.
988+ fixture = self.make(self.make_dir())
989+ self.addCleanup(fixture.stop)
990+ fixture.start()
991+ fixture.lock.acquire()
992+ fixture.stop()
993+ self.assertTrue(fixture.running)
994+ fixture.lock.release()
995+ fixture.stop()
996+ self.assertFalse(fixture.running)
997+
998+ def test_destroy_locked(self):
999+ # The cluster is not destroyed if a lock is held.
1000+ fixture = self.make(self.make_dir())
1001+ fixture.create()
1002+ fixture.lock.acquire()
1003+ fixture.destroy()
1004+ self.assertTrue(fixture.exists)
1005+ fixture.lock.release()
1006+ fixture.destroy()
1007+ self.assertFalse(fixture.exists)
1008+
1009+ def test_use_no_preserve(self):
1010+ # The cluster is stopped and destroyed when preserve=False.
1011+ with self.make(self.make_dir(), preserve=False) as fixture:
1012+ self.assertTrue(fixture.exists)
1013+ self.assertTrue(fixture.running)
1014+ self.assertFalse(fixture.exists)
1015+ self.assertFalse(fixture.running)
1016+
1017+ def test_use_no_preserve_cluster_already_exists(self):
1018+ # The cluster is stopped but *not* destroyed when preserve=False if it
1019+ # existed before the fixture was put into use.
1020+ fixture = self.make(self.make_dir(), preserve=False)
1021+ fixture.create()
1022+ with fixture:
1023+ self.assertTrue(fixture.exists)
1024+ self.assertTrue(fixture.running)
1025+ self.assertTrue(fixture.exists)
1026+ self.assertFalse(fixture.running)
1027+
1028+ def test_use_preserve(self):
1029+ # The cluster is not stopped and destroyed when preserve=True.
1030+ with self.make(self.make_dir(), preserve=True) as fixture:
1031+ self.assertTrue(fixture.exists)
1032+ self.assertTrue(fixture.running)
1033+ fixture.createdb("gallhammer")
1034+ self.assertTrue(fixture.exists)
1035+ self.assertFalse(fixture.running)
1036+ self.addCleanup(fixture.stop)
1037+ fixture.start()
1038+ self.assertIn("gallhammer", fixture.databases)
1039+
1040+ def test_namespace(self):
1041+ # ClusterFixture is in the postgresfixture namespace.
1042+ import postgresfixture
1043+ self.assertIs(postgresfixture.ClusterFixture, ClusterFixture)
1044
1045=== added file 'postgresfixture/tests/test_main.py'
1046--- postgresfixture/tests/test_main.py 1970-01-01 00:00:00 +0000
1047+++ postgresfixture/tests/test_main.py 2012-05-21 11:22:18 +0000
1048@@ -0,0 +1,163 @@
1049+# Copyright 2012 Canonical Ltd. This software is licensed under the
1050+# GNU Affero General Public License version 3 (see the file LICENSE).
1051+
1052+"""Tests for `postgresfixture.main`."""
1053+
1054+from __future__ import (
1055+ absolute_import,
1056+ print_function,
1057+ unicode_literals,
1058+ )
1059+
1060+__metaclass__ = type
1061+__all__ = []
1062+
1063+from base64 import b16encode
1064+from os import urandom
1065+from StringIO import StringIO
1066+import sys
1067+
1068+from fixtures import EnvironmentVariableFixture
1069+from postgresfixture import main
1070+from postgresfixture.clusterfixture import ClusterFixture
1071+from postgresfixture.testing import TestCase
1072+from testtools.matchers import StartsWith
1073+
1074+
1075+class TestActions(TestCase):
1076+
1077+ class Finished(Exception):
1078+ """A marker exception used for breaking out."""
1079+
1080+ def get_random_database_name(self):
1081+ return "db%s" % b16encode(urandom(8)).lower()
1082+
1083+ def test_run(self):
1084+ cluster = ClusterFixture(self.make_dir())
1085+ self.addCleanup(cluster.stop)
1086+
1087+ database_name = self.get_random_database_name()
1088+ self.useFixture(
1089+ EnvironmentVariableFixture("PGDATABASE", database_name))
1090+
1091+ # Instead of sleeping, check the cluster is running, then break out.
1092+ def sleep_patch(time):
1093+ self.assertTrue(cluster.running)
1094+ self.assertIn(database_name, cluster.databases)
1095+ raise self.Finished
1096+
1097+ self.patch(main, "sleep", sleep_patch)
1098+ self.assertRaises(self.Finished, main.action_run, cluster)
1099+
1100+ def test_run_without_database(self):
1101+ # A database is not created if it's not specified in the PGDATABASE
1102+ # environment variable.
1103+ cluster = ClusterFixture(self.make_dir())
1104+ self.addCleanup(cluster.stop)
1105+
1106+ # Erase the PGDATABASE environment variable, if it's set.
1107+ self.useFixture(
1108+ EnvironmentVariableFixture("PGDATABASE", None))
1109+
1110+ # Instead of sleeping, check the cluster is running, then break out.
1111+ def sleep_patch(time):
1112+ self.assertTrue(cluster.running)
1113+ self.assertEqual(
1114+ {"template0", "template1", "postgres"},
1115+ cluster.databases)
1116+ raise self.Finished
1117+
1118+ self.patch(main, "sleep", sleep_patch)
1119+ self.assertRaises(self.Finished, main.action_run, cluster)
1120+
1121+ def test_shell(self):
1122+ cluster = ClusterFixture(self.make_dir())
1123+ self.addCleanup(cluster.stop)
1124+
1125+ database_name = self.get_random_database_name()
1126+ self.useFixture(
1127+ EnvironmentVariableFixture("PGDATABASE", database_name))
1128+
1129+ def shell_patch(database):
1130+ self.assertEqual(database_name, database)
1131+ raise self.Finished
1132+
1133+ self.patch(cluster, "shell", shell_patch)
1134+ self.assertRaises(self.Finished, main.action_shell, cluster)
1135+
1136+ def test_status_running(self):
1137+ cluster = ClusterFixture(self.make_dir())
1138+ self.addCleanup(cluster.stop)
1139+ cluster.start()
1140+ self.patch(sys, "stdout", StringIO())
1141+ code = self.assertRaises(
1142+ SystemExit, main.action_status, cluster).code
1143+ self.assertEqual(0, code)
1144+ self.assertEqual(
1145+ "%s: running\n" % cluster.datadir,
1146+ sys.stdout.getvalue())
1147+
1148+ def test_status_not_running(self):
1149+ cluster = ClusterFixture(self.make_dir())
1150+ cluster.create()
1151+ self.patch(sys, "stdout", StringIO())
1152+ code = self.assertRaises(
1153+ SystemExit, main.action_status, cluster).code
1154+ self.assertEqual(1, code)
1155+ self.assertEqual(
1156+ "%s: not running\n" % cluster.datadir,
1157+ sys.stdout.getvalue())
1158+
1159+ def test_status_not_created(self):
1160+ cluster = ClusterFixture(self.make_dir())
1161+ self.patch(sys, "stdout", StringIO())
1162+ code = self.assertRaises(
1163+ SystemExit, main.action_status, cluster).code
1164+ self.assertEqual(2, code)
1165+ self.assertEqual(
1166+ "%s: not created\n" % cluster.datadir,
1167+ sys.stdout.getvalue())
1168+
1169+ def test_stop(self):
1170+ cluster = ClusterFixture(self.make_dir())
1171+ self.addCleanup(cluster.stop)
1172+ cluster.start()
1173+ main.action_stop(cluster)
1174+ self.assertFalse(cluster.running)
1175+ self.assertTrue(cluster.exists)
1176+
1177+ def test_stop_when_locked(self):
1178+ cluster = ClusterFixture(self.make_dir())
1179+ self.addCleanup(cluster.stop)
1180+ cluster.start()
1181+ self.addCleanup(cluster.lock.release)
1182+ cluster.lock.acquire()
1183+ self.patch(sys, "stderr", StringIO())
1184+ error = self.assertRaises(
1185+ SystemExit, main.action_stop, cluster)
1186+ self.assertEqual(2, error.code)
1187+ self.assertThat(
1188+ sys.stderr.getvalue(), StartsWith(
1189+ "%s: cluster is locked by:" % cluster.datadir))
1190+ self.assertTrue(cluster.running)
1191+
1192+ def test_destroy(self):
1193+ cluster = ClusterFixture(self.make_dir())
1194+ self.addCleanup(cluster.stop)
1195+ cluster.start()
1196+ main.action_destroy(cluster)
1197+ self.assertFalse(cluster.running)
1198+ self.assertFalse(cluster.exists)
1199+
1200+ def test_destroy_when_locked(self):
1201+ cluster = ClusterFixture(self.make_dir())
1202+ cluster.create()
1203+ cluster.lock.acquire()
1204+ self.patch(sys, "stderr", StringIO())
1205+ error = self.assertRaises(
1206+ SystemExit, main.action_destroy, cluster)
1207+ self.assertEqual(2, error.code)
1208+ self.assertThat(
1209+ sys.stderr.getvalue(), StartsWith(
1210+ "%s: cluster is locked by:" % cluster.datadir))
1211+ self.assertTrue(cluster.exists)
1212
1213=== added file 'requirements.txt'
1214--- requirements.txt 1970-01-01 00:00:00 +0000
1215+++ requirements.txt 2012-05-21 11:22:18 +0000
1216@@ -0,0 +1,3 @@
1217+fixtures >= 0.3.8
1218+psycopg2 >= 2.4.4
1219+testtools >= 0.9.14
1220
1221=== added file 'setup.py'
1222--- setup.py 1970-01-01 00:00:00 +0000
1223+++ setup.py 2012-05-21 11:22:18 +0000
1224@@ -0,0 +1,41 @@
1225+#!/usr/bin/env python
1226+# Copyright 2012 Canonical Ltd. This software is licensed under the
1227+# GNU Affero General Public License version 3 (see the file LICENSE).
1228+
1229+"""Distutils installer for postgresfixture."""
1230+
1231+from __future__ import (
1232+ absolute_import,
1233+ print_function,
1234+ unicode_literals,
1235+ )
1236+
1237+__metaclass__ = type
1238+
1239+from setuptools import setup
1240+
1241+
1242+with open("requirements.txt", "rb") as fd:
1243+ requirements = {line.strip() for line in fd}
1244+
1245+
1246+setup(
1247+ name='postgresfixture',
1248+ version="0.1",
1249+ packages={b'postgresfixture'},
1250+ package_dir={'postgresfixture': 'postgresfixture'},
1251+ install_requires=requirements,
1252+ tests_require={"testtools >= 0.9.14"},
1253+ test_suite="postgresfixture.tests",
1254+ include_package_data=True,
1255+ zip_safe=False,
1256+ description=(
1257+ "A fixture for creating PostgreSQL clusters and databases, and "
1258+ "tearing them down again, intended for use during development "
1259+ "and testing."),
1260+ entry_points={
1261+ "console_scripts": [
1262+ "postgresfixture = postgresfixture.main:main",
1263+ ],
1264+ },
1265+ )

Subscribers

People subscribed via source and target branches

to all changes: