Merge lp:~allenap/postgresfixture/multi-version into lp:~lazr-developers/postgresfixture/trunk
- multi-version
- Merge into trunk
Proposed by
Gavin Panella
Status: | Merged |
---|---|
Approved by: | Graham Binns |
Approved revision: | 9 |
Merged at revision: | 7 |
Proposed branch: | lp:~allenap/postgresfixture/multi-version |
Merge into: | lp:~lazr-developers/postgresfixture/trunk |
Diff against target: |
361 lines (+131/-30) 6 files modified
postgresfixture/cluster.py (+30/-11) postgresfixture/clusterfixture.py (+10/-7) postgresfixture/main.py (+10/-2) postgresfixture/tests/test_cluster.py (+24/-9) postgresfixture/tests/test_main.py (+56/-1) requirements.txt (+1/-0) |
To merge this branch: | bzr merge lp:~allenap/postgresfixture/multi-version |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Graham Binns (community) | Approve | ||
Review via email:
|
Commit message
Work with multiple version of PostgreSQL.
Previously only 9.1 was supported.
Description of the change
Get postgresfixture working with the most up-to-date PostgreSQL installation on a machine, by default, and allow selection of a different one.
To post a comment you must log in.
- 10. By Gavin Panella
-
Use LooseVersion instead of float when comparing PostgreSQL versions.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'postgresfixture/cluster.py' | |||
2 | --- postgresfixture/cluster.py 2012-07-16 20:00:28 +0000 | |||
3 | +++ postgresfixture/cluster.py 2014-01-17 14:25:09 +0000 | |||
4 | @@ -12,18 +12,22 @@ | |||
5 | 12 | __metaclass__ = type | 12 | __metaclass__ = type |
6 | 13 | __all__ = [ | 13 | __all__ = [ |
7 | 14 | "Cluster", | 14 | "Cluster", |
8 | 15 | "PG_VERSION_MAX", | ||
9 | 16 | "PG_VERSIONS", | ||
10 | 15 | ] | 17 | ] |
11 | 16 | 18 | ||
12 | 17 | from contextlib import ( | 19 | from contextlib import ( |
13 | 18 | closing, | 20 | closing, |
14 | 19 | contextmanager, | 21 | contextmanager, |
15 | 20 | ) | 22 | ) |
16 | 23 | from distutils.version import LooseVersion | ||
17 | 21 | from fcntl import ( | 24 | from fcntl import ( |
18 | 22 | LOCK_EX, | 25 | LOCK_EX, |
19 | 23 | LOCK_UN, | 26 | LOCK_UN, |
20 | 24 | lockf, | 27 | lockf, |
21 | 25 | ) | 28 | ) |
22 | 26 | from functools import wraps | 29 | from functools import wraps |
23 | 30 | from glob import iglob | ||
24 | 27 | from os import ( | 31 | from os import ( |
25 | 28 | devnull, | 32 | devnull, |
26 | 29 | environ, | 33 | environ, |
27 | @@ -40,18 +44,32 @@ | |||
28 | 40 | import psycopg2 | 44 | import psycopg2 |
29 | 41 | 45 | ||
30 | 42 | 46 | ||
37 | 43 | PG_VERSION = "9.1" | 47 | PG_BASE = "/usr/lib/postgresql" |
38 | 44 | PG_BIN = "/usr/lib/postgresql/%s/bin" % PG_VERSION | 48 | |
39 | 45 | 49 | PG_VERSION_BINS = { | |
40 | 46 | 50 | path.basename(pgdir): path.join(pgdir, "bin") | |
41 | 47 | def path_with_pg_bin(exe_path): | 51 | for pgdir in iglob(path.join(PG_BASE, "*")) |
42 | 48 | """Return `exe_path` with `PG_BIN` added.""" | 52 | if path.exists(path.join(pgdir, "bin", "pg_ctl")) |
43 | 53 | } | ||
44 | 54 | |||
45 | 55 | PG_VERSION_MAX = max(PG_VERSION_BINS, key=LooseVersion) | ||
46 | 56 | PG_VERSIONS = sorted(PG_VERSION_BINS, key=LooseVersion) | ||
47 | 57 | |||
48 | 58 | |||
49 | 59 | def get_pg_bin(version): | ||
50 | 60 | """Return the PostgreSQL ``bin`` directory for the given `version`.""" | ||
51 | 61 | return PG_VERSION_BINS[version] | ||
52 | 62 | |||
53 | 63 | |||
54 | 64 | def path_with_pg_bin(exe_path, version): | ||
55 | 65 | """Return `exe_path` with the PostgreSQL ``bin`` directory added.""" | ||
56 | 49 | exe_path = [ | 66 | exe_path = [ |
57 | 50 | elem for elem in exe_path.split(path.pathsep) | 67 | elem for elem in exe_path.split(path.pathsep) |
58 | 51 | if len(elem) != 0 and not elem.isspace() | 68 | if len(elem) != 0 and not elem.isspace() |
59 | 52 | ] | 69 | ] |
62 | 53 | if PG_BIN not in exe_path: | 70 | pg_bin = get_pg_bin(version) |
63 | 54 | exe_path.insert(0, PG_BIN) | 71 | if pg_bin not in exe_path: |
64 | 72 | exe_path.insert(0, pg_bin) | ||
65 | 55 | return path.pathsep.join(exe_path) | 73 | return path.pathsep.join(exe_path) |
66 | 56 | 74 | ||
67 | 57 | 75 | ||
68 | @@ -67,8 +85,9 @@ | |||
69 | 67 | class Cluster: | 85 | class Cluster: |
70 | 68 | """Represents a PostgreSQL cluster, running or not.""" | 86 | """Represents a PostgreSQL cluster, running or not.""" |
71 | 69 | 87 | ||
73 | 70 | def __init__(self, datadir): | 88 | def __init__(self, datadir, version=PG_VERSION_MAX): |
74 | 71 | self.datadir = path.abspath(datadir) | 89 | self.datadir = path.abspath(datadir) |
75 | 90 | self.version = version | ||
76 | 72 | self.lockfile = path.join( | 91 | self.lockfile = path.join( |
77 | 73 | path.dirname(self.datadir), | 92 | path.dirname(self.datadir), |
78 | 74 | ".%s.lock" % path.basename(self.datadir)) | 93 | ".%s.lock" % path.basename(self.datadir)) |
79 | @@ -92,7 +111,7 @@ | |||
80 | 92 | def execute(self, *command, **options): | 111 | def execute(self, *command, **options): |
81 | 93 | """Execute a command with an environment suitable for this cluster.""" | 112 | """Execute a command with an environment suitable for this cluster.""" |
82 | 94 | env = options.pop("env", environ).copy() | 113 | env = options.pop("env", environ).copy() |
84 | 95 | env["PATH"] = path_with_pg_bin(env.get("PATH", "")) | 114 | env["PATH"] = path_with_pg_bin(env.get("PATH", ""), self.version) |
85 | 96 | env["PGDATA"] = env["PGHOST"] = self.datadir | 115 | env["PGDATA"] = env["PGHOST"] = self.datadir |
86 | 97 | check_call(command, env=env, **options) | 116 | check_call(command, env=env, **options) |
87 | 98 | 117 | ||
88 | @@ -121,7 +140,7 @@ | |||
89 | 121 | with open(devnull, "wb") as null: | 140 | with open(devnull, "wb") as null: |
90 | 122 | try: | 141 | try: |
91 | 123 | self.execute("pg_ctl", "status", stdout=null) | 142 | self.execute("pg_ctl", "status", stdout=null) |
93 | 124 | except CalledProcessError, error: | 143 | except CalledProcessError as error: |
94 | 125 | if error.returncode == 1: | 144 | if error.returncode == 1: |
95 | 126 | return False | 145 | return False |
96 | 127 | else: | 146 | else: |
97 | 128 | 147 | ||
98 | === modified file 'postgresfixture/clusterfixture.py' | |||
99 | --- postgresfixture/clusterfixture.py 2012-05-22 16:15:44 +0000 | |||
100 | +++ postgresfixture/clusterfixture.py 2014-01-17 14:25:09 +0000 | |||
101 | @@ -29,7 +29,10 @@ | |||
102 | 29 | ) | 29 | ) |
103 | 30 | 30 | ||
104 | 31 | from fixtures import Fixture | 31 | from fixtures import Fixture |
106 | 32 | from postgresfixture.cluster import Cluster | 32 | from postgresfixture.cluster import ( |
107 | 33 | Cluster, | ||
108 | 34 | PG_VERSION_MAX, | ||
109 | 35 | ) | ||
110 | 33 | 36 | ||
111 | 34 | 37 | ||
112 | 35 | class ProcessSemaphore: | 38 | class ProcessSemaphore: |
113 | @@ -49,7 +52,7 @@ | |||
114 | 49 | def acquire(self): | 52 | def acquire(self): |
115 | 50 | try: | 53 | try: |
116 | 51 | makedirs(self.lockdir) | 54 | makedirs(self.lockdir) |
118 | 52 | except OSError, error: | 55 | except OSError as error: |
119 | 53 | if error.errno != EEXIST: | 56 | if error.errno != EEXIST: |
120 | 54 | raise | 57 | raise |
121 | 55 | open(self.lockfile, "w").close() | 58 | open(self.lockfile, "w").close() |
122 | @@ -57,7 +60,7 @@ | |||
123 | 57 | def release(self): | 60 | def release(self): |
124 | 58 | try: | 61 | try: |
125 | 59 | unlink(self.lockfile) | 62 | unlink(self.lockfile) |
127 | 60 | except OSError, error: | 63 | except OSError as error: |
128 | 61 | if error.errno != ENOENT: | 64 | if error.errno != ENOENT: |
129 | 62 | raise | 65 | raise |
130 | 63 | 66 | ||
131 | @@ -65,7 +68,7 @@ | |||
132 | 65 | def locked(self): | 68 | def locked(self): |
133 | 66 | try: | 69 | try: |
134 | 67 | rmdir(self.lockdir) | 70 | rmdir(self.lockdir) |
136 | 68 | except OSError, error: | 71 | except OSError as error: |
137 | 69 | if error.errno == ENOTEMPTY: | 72 | if error.errno == ENOTEMPTY: |
138 | 70 | return True | 73 | return True |
139 | 71 | elif error.errno == ENOENT: | 74 | elif error.errno == ENOENT: |
140 | @@ -82,7 +85,7 @@ | |||
141 | 82 | int(name) if name.isdigit() else name | 85 | int(name) if name.isdigit() else name |
142 | 83 | for name in listdir(self.lockdir) | 86 | for name in listdir(self.lockdir) |
143 | 84 | ] | 87 | ] |
145 | 85 | except OSError, error: | 88 | except OSError as error: |
146 | 86 | if error.errno == ENOENT: | 89 | if error.errno == ENOENT: |
147 | 87 | return [] | 90 | return [] |
148 | 88 | else: | 91 | else: |
149 | @@ -92,12 +95,12 @@ | |||
150 | 92 | class ClusterFixture(Cluster, Fixture): | 95 | class ClusterFixture(Cluster, Fixture): |
151 | 93 | """A fixture for a `Cluster`.""" | 96 | """A fixture for a `Cluster`.""" |
152 | 94 | 97 | ||
154 | 95 | def __init__(self, datadir, preserve=False): | 98 | def __init__(self, datadir, preserve=False, version=PG_VERSION_MAX): |
155 | 96 | """ | 99 | """ |
156 | 97 | @param preserve: Leave the cluster and its databases behind, even if | 100 | @param preserve: Leave the cluster and its databases behind, even if |
157 | 98 | this fixture creates them. | 101 | this fixture creates them. |
158 | 99 | """ | 102 | """ |
160 | 100 | super(ClusterFixture, self).__init__(datadir) | 103 | super(ClusterFixture, self).__init__(datadir, version=version) |
161 | 101 | self.preserve = preserve | 104 | self.preserve = preserve |
162 | 102 | self.shares = ProcessSemaphore( | 105 | self.shares = ProcessSemaphore( |
163 | 103 | path.join(self.datadir, "shares")) | 106 | path.join(self.datadir, "shares")) |
164 | 104 | 107 | ||
165 | === modified file 'postgresfixture/main.py' | |||
166 | --- postgresfixture/main.py 2012-05-22 21:51:08 +0000 | |||
167 | +++ postgresfixture/main.py 2014-01-17 14:25:09 +0000 | |||
168 | @@ -26,6 +26,10 @@ | |||
169 | 26 | import sys | 26 | import sys |
170 | 27 | from time import sleep | 27 | from time import sleep |
171 | 28 | 28 | ||
172 | 29 | from postgresfixture.cluster import ( | ||
173 | 30 | PG_VERSION_MAX, | ||
174 | 31 | PG_VERSIONS, | ||
175 | 32 | ) | ||
176 | 29 | from postgresfixture.clusterfixture import ClusterFixture | 33 | from postgresfixture.clusterfixture import ClusterFixture |
177 | 30 | 34 | ||
178 | 31 | 35 | ||
179 | @@ -142,6 +146,10 @@ | |||
180 | 142 | "preserve the cluster and its databases when exiting, " | 146 | "preserve the cluster and its databases when exiting, " |
181 | 143 | "even if it was necessary to create and start it " | 147 | "even if it was necessary to create and start it " |
182 | 144 | "(default: %(default)s)")) | 148 | "(default: %(default)s)")) |
183 | 149 | argument_parser.add_argument( | ||
184 | 150 | "--version", dest="version", choices=PG_VERSIONS, | ||
185 | 151 | default=PG_VERSION_MAX, help=( | ||
186 | 152 | "The version of PostgreSQL to use (default: %(default)s)")) | ||
187 | 145 | argument_subparsers = argument_parser.add_subparsers( | 153 | argument_subparsers = argument_parser.add_subparsers( |
188 | 146 | title="actions") | 154 | title="actions") |
189 | 147 | 155 | ||
190 | @@ -191,9 +199,9 @@ | |||
191 | 191 | setup() | 199 | setup() |
192 | 192 | cluster = ClusterFixture( | 200 | cluster = ClusterFixture( |
193 | 193 | datadir=args.datadir, | 201 | datadir=args.datadir, |
195 | 194 | preserve=args.preserve) | 202 | preserve=args.preserve, version=args.version) |
196 | 195 | args.handler(cluster, args) | 203 | args.handler(cluster, args) |
198 | 196 | except CalledProcessError, error: | 204 | except CalledProcessError as error: |
199 | 197 | raise SystemExit(error.returncode) | 205 | raise SystemExit(error.returncode) |
200 | 198 | except KeyboardInterrupt: | 206 | except KeyboardInterrupt: |
201 | 199 | pass | 207 | pass |
202 | 200 | 208 | ||
203 | === modified file 'postgresfixture/tests/test_cluster.py' | |||
204 | --- postgresfixture/tests/test_cluster.py 2012-05-22 22:27:47 +0000 | |||
205 | +++ postgresfixture/tests/test_cluster.py 2014-01-17 14:25:09 +0000 | |||
206 | @@ -28,11 +28,13 @@ | |||
207 | 28 | import postgresfixture.cluster | 28 | import postgresfixture.cluster |
208 | 29 | from postgresfixture.cluster import ( | 29 | from postgresfixture.cluster import ( |
209 | 30 | Cluster, | 30 | Cluster, |
210 | 31 | get_pg_bin, | ||
211 | 31 | path_with_pg_bin, | 32 | path_with_pg_bin, |
213 | 32 | PG_BIN, | 33 | PG_VERSIONS, |
214 | 33 | ) | 34 | ) |
215 | 34 | from postgresfixture.main import repr_pid | 35 | from postgresfixture.main import repr_pid |
216 | 35 | from postgresfixture.testing import TestCase | 36 | from postgresfixture.testing import TestCase |
217 | 37 | from testscenarios import WithScenarios | ||
218 | 36 | from testtools.content import text_content | 38 | from testtools.content import text_content |
219 | 37 | from testtools.matchers import ( | 39 | from testtools.matchers import ( |
220 | 38 | DirExists, | 40 | DirExists, |
221 | @@ -42,13 +44,19 @@ | |||
222 | 42 | ) | 44 | ) |
223 | 43 | 45 | ||
224 | 44 | 46 | ||
226 | 45 | class TestFunctions(TestCase): | 47 | class TestFunctions(WithScenarios, TestCase): |
227 | 48 | |||
228 | 49 | scenarios = sorted( | ||
229 | 50 | (version, {"version": version}) | ||
230 | 51 | for version in PG_VERSIONS | ||
231 | 52 | ) | ||
232 | 46 | 53 | ||
233 | 47 | def test_path_with_pg_bin(self): | 54 | def test_path_with_pg_bin(self): |
235 | 48 | self.assertEqual(PG_BIN, path_with_pg_bin("")) | 55 | pg_bin = get_pg_bin(self.version) |
236 | 56 | self.assertEqual(pg_bin, path_with_pg_bin("", self.version)) | ||
237 | 49 | self.assertEqual( | 57 | self.assertEqual( |
240 | 50 | PG_BIN + path.pathsep + "/bin:/usr/bin", | 58 | pg_bin + path.pathsep + "/bin:/usr/bin", |
241 | 51 | path_with_pg_bin("/bin:/usr/bin")) | 59 | path_with_pg_bin("/bin:/usr/bin", self.version)) |
242 | 52 | 60 | ||
243 | 53 | def test_repr_pid_not_a_number(self): | 61 | def test_repr_pid_not_a_number(self): |
244 | 54 | self.assertEqual("alice", repr_pid("alice")) | 62 | self.assertEqual("alice", repr_pid("alice")) |
245 | @@ -62,9 +70,16 @@ | |||
246 | 62 | self.assertThat(repr_pid(pid), StartsWith("%d (" % pid)) | 70 | self.assertThat(repr_pid(pid), StartsWith("%d (" % pid)) |
247 | 63 | 71 | ||
248 | 64 | 72 | ||
252 | 65 | class TestCluster(TestCase): | 73 | class TestCluster(WithScenarios, TestCase): |
253 | 66 | 74 | ||
254 | 67 | make = Cluster | 75 | scenarios = sorted( |
255 | 76 | (version, {"version": version}) | ||
256 | 77 | for version in PG_VERSIONS | ||
257 | 78 | ) | ||
258 | 79 | |||
259 | 80 | def make(self, *args, **kwargs): | ||
260 | 81 | kwargs.setdefault("version", self.version) | ||
261 | 82 | return Cluster(*args, **kwargs) | ||
262 | 68 | 83 | ||
263 | 69 | def test_init(self): | 84 | def test_init(self): |
264 | 70 | # The datadir passed into the Cluster constructor is resolved to an | 85 | # The datadir passed into the Cluster constructor is resolved to an |
265 | @@ -133,7 +148,7 @@ | |||
266 | 133 | self.assertEqual(cluster.datadir, env.get("PGHOST")) | 148 | self.assertEqual(cluster.datadir, env.get("PGHOST")) |
267 | 134 | self.assertThat( | 149 | self.assertThat( |
268 | 135 | env.get("PATH", ""), | 150 | env.get("PATH", ""), |
270 | 136 | StartsWith(PG_BIN + path.pathsep)) | 151 | StartsWith(get_pg_bin(self.version) + path.pathsep)) |
271 | 137 | 152 | ||
272 | 138 | def test_exists(self): | 153 | def test_exists(self): |
273 | 139 | cluster = self.make(self.make_dir()) | 154 | cluster = self.make(self.make_dir()) |
274 | 140 | 155 | ||
275 | === modified file 'postgresfixture/tests/test_main.py' | |||
276 | --- postgresfixture/tests/test_main.py 2012-05-22 16:15:44 +0000 | |||
277 | +++ postgresfixture/tests/test_main.py 2014-01-17 14:25:09 +0000 | |||
278 | @@ -19,6 +19,10 @@ | |||
279 | 19 | 19 | ||
280 | 20 | from fixtures import EnvironmentVariableFixture | 20 | from fixtures import EnvironmentVariableFixture |
281 | 21 | from postgresfixture import main | 21 | from postgresfixture import main |
282 | 22 | from postgresfixture.cluster import ( | ||
283 | 23 | PG_VERSION_MAX, | ||
284 | 24 | PG_VERSIONS, | ||
285 | 25 | ) | ||
286 | 22 | from postgresfixture.clusterfixture import ClusterFixture | 26 | from postgresfixture.clusterfixture import ClusterFixture |
287 | 23 | from postgresfixture.testing import TestCase | 27 | from postgresfixture.testing import TestCase |
288 | 24 | from testtools.matchers import StartsWith | 28 | from testtools.matchers import StartsWith |
289 | @@ -35,7 +39,7 @@ | |||
290 | 35 | def parse_args(self, *args): | 39 | def parse_args(self, *args): |
291 | 36 | try: | 40 | try: |
292 | 37 | return main.argument_parser.parse_args(args) | 41 | return main.argument_parser.parse_args(args) |
294 | 38 | except SystemExit, error: | 42 | except SystemExit as error: |
295 | 39 | self.fail("parse_args%r failed with %r" % (args, error)) | 43 | self.fail("parse_args%r failed with %r" % (args, error)) |
296 | 40 | 44 | ||
297 | 41 | def test_run(self): | 45 | def test_run(self): |
298 | @@ -174,3 +178,54 @@ | |||
299 | 174 | sys.stderr.getvalue(), StartsWith( | 178 | sys.stderr.getvalue(), StartsWith( |
300 | 175 | "%s: cluster is locked by:" % cluster.datadir)) | 179 | "%s: cluster is locked by:" % cluster.datadir)) |
301 | 176 | self.assertTrue(cluster.exists) | 180 | self.assertTrue(cluster.exists) |
302 | 181 | |||
303 | 182 | |||
304 | 183 | class TestVersion(TestCase): | ||
305 | 184 | |||
306 | 185 | def patch_pg_versions(self, versions): | ||
307 | 186 | PG_VERSIONS[:] = versions | ||
308 | 187 | |||
309 | 188 | def test_uses_supplied_version(self): | ||
310 | 189 | # Reset PG_VERSIONS after the test has run. | ||
311 | 190 | self.addCleanup(self.patch_pg_versions, list(PG_VERSIONS)) | ||
312 | 191 | self.patch_pg_versions(["1.1", "2.2", "3.3"]) | ||
313 | 192 | |||
314 | 193 | # Record calls to our patched handler. | ||
315 | 194 | handler_calls = [] | ||
316 | 195 | |||
317 | 196 | def handler(cluster, args): | ||
318 | 197 | handler_calls.append((cluster, args)) | ||
319 | 198 | |||
320 | 199 | self.patch( | ||
321 | 200 | main.get_action("status"), "_defaults", | ||
322 | 201 | {"handler": handler}) | ||
323 | 202 | |||
324 | 203 | # Prevent main() from altering terminal settings. | ||
325 | 204 | self.patch(main, "setup", lambda: None) | ||
326 | 205 | |||
327 | 206 | # The version chosen is picked up by the argument parser and | ||
328 | 207 | # passed into the Cluster constructor. | ||
329 | 208 | main.main(["--version", "2.2", "status"]) | ||
330 | 209 | self.assertEqual( | ||
331 | 210 | [("2.2", "2.2")], | ||
332 | 211 | [(cluster.version, args.version) | ||
333 | 212 | for (cluster, args) in handler_calls]) | ||
334 | 213 | |||
335 | 214 | def test_uses_default_version(self): | ||
336 | 215 | # Record calls to our patched handler. | ||
337 | 216 | handler_calls = [] | ||
338 | 217 | |||
339 | 218 | def handler(cluster, args): | ||
340 | 219 | handler_calls.append((cluster, args)) | ||
341 | 220 | |||
342 | 221 | self.patch( | ||
343 | 222 | main.get_action("status"), "_defaults", | ||
344 | 223 | {"handler": handler}) | ||
345 | 224 | |||
346 | 225 | # The argument parser has the default version and passes it into | ||
347 | 226 | # the Cluster constructor. | ||
348 | 227 | main.main(["status"]) | ||
349 | 228 | self.assertEqual( | ||
350 | 229 | [(PG_VERSION_MAX, PG_VERSION_MAX)], | ||
351 | 230 | [(cluster.version, args.version) | ||
352 | 231 | for (cluster, args) in handler_calls]) | ||
353 | 177 | 232 | ||
354 | === modified file 'requirements.txt' | |||
355 | --- requirements.txt 2012-05-21 11:13:40 +0000 | |||
356 | +++ requirements.txt 2014-01-17 14:25:09 +0000 | |||
357 | @@ -1,3 +1,4 @@ | |||
358 | 1 | fixtures >= 0.3.8 | 1 | fixtures >= 0.3.8 |
359 | 2 | psycopg2 >= 2.4.4 | 2 | psycopg2 >= 2.4.4 |
360 | 3 | testtools >= 0.9.14 | 3 | testtools >= 0.9.14 |
361 | 4 | testscenarios >= 0.4 |
Sweet. Imagine that, people hard coding dependency versions. You'd never get that at a company like Canonical.
Oh, wait...