Merge lp:~allenap/launchpad/ec2-test-race-bug-422433-the-revenge into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~allenap/launchpad/ec2-test-race-bug-422433-the-revenge
Merge into: lp:launchpad
Diff against target: 642 lines
10 files modified
lib/devscripts/ec2test/account.py (+58/-48)
lib/devscripts/ec2test/builtins.py (+12/-7)
lib/devscripts/ec2test/instance.py (+20/-15)
lib/devscripts/ec2test/session.py (+90/-0)
lib/devscripts/ec2test/tests/__init__.py (+2/-0)
lib/devscripts/ec2test/tests/test_session.py (+69/-0)
lib/devscripts/ec2test/tests/test_utils.py (+61/-0)
lib/devscripts/ec2test/utils.py (+55/-0)
setup.py (+1/-0)
versions.cfg (+2/-0)
To merge this branch: bzr merge lp:~allenap/launchpad/ec2-test-race-bug-422433-the-revenge
Reviewer Review Type Date Requested Status
Michael Nelson (community) code Approve
Review via email: mp+13064@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

This is a resubmission of an earlier branch:

  https://code.edge.launchpad.net/~allenap/launchpad/ec2-test-race-bug-422433/+merge/12778

It got merged, but had to be reverted because of failures in buildbot. This branch restores it, with some additional fixes to make it pass in buildbot:

  http://pastebin.ubuntu.com/288542/

No need to review the whole branch :)

This depends on https://code.edge.launchpad.net/~allenap/lp-source-dependencies/ec2-test-race-bug-422433-the-revenge/+merge/13063.

Revision history for this message
Michael Nelson (michael.nelson) wrote :

> This is a resubmission of an earlier branch:
>
> https://code.edge.launchpad.net/~allenap/launchpad/ec2-test-race-
> bug-422433/+merge/12778
>
> It got merged, but had to be reverted because of failures in buildbot.

<noodles775> allenap: so the previous mp failed on buildbot because it didn't have paramiko?
* andrea-bs (n=andrea@ubuntu/member/beeseek.developer.andrea-bs) has joined #launchpad-reviews
* henninge has quit (Read error: 113 (No route to host))
<allenap> noodles775: Yes, the buildbot AMI seems to be out of date, which is another bug. However, I thought it was a good idea to put the dep into the tree anyway.
<noodles775> allenap: ok, and where is pycrypto used?
<allenap> noodles775: It's required by paramiko :)
<noodles775> allenap: r=me
<allenap> noodles775: Thanks :)

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/devscripts/ec2test/account.py'
2--- lib/devscripts/ec2test/account.py 2009-10-03 08:19:54 +0000
3+++ lib/devscripts/ec2test/account.py 2009-10-08 14:53:12 +0000
4@@ -13,7 +13,10 @@
5 import sys
6 import urllib
7
8+from datetime import datetime
9+
10 from boto.exception import EC2ResponseError
11+from devscripts.ec2test.session import EC2SessionName
12
13 import paramiko
14
15@@ -24,6 +27,13 @@
16 # ...anyone else want in on the fun?
17 )
18
19+AUTH_FAILURE_MESSAGE = """\
20+POSSIBLE CAUSES OF ERROR:
21+- Did you sign up for EC2?
22+- Did you put a credit card number in your AWS account?
23+Please double-check before reporting a problem.
24+"""
25+
26
27 def get_ip():
28 """Uses AWS checkip to obtain this machine's IP address.
29@@ -64,44 +74,29 @@
30 sys.stdout.write(msg)
31 sys.stdout.flush()
32
33+ def _find_expired_artifacts(self, artifacts):
34+ now = datetime.utcnow()
35+ for artifact in artifacts:
36+ session_name = EC2SessionName(artifact.name)
37+ if (session_name in (self.name, self.name.base) or (
38+ session_name.base == self.name.base and
39+ session_name.expires is not None and
40+ session_name.expires < now)):
41+ yield artifact
42+
43 def acquire_security_group(self, demo_networks=None):
44 """Get a security group with the appropriate configuration.
45
46 "Appropriate" means configured to allow this machine to connect via
47 SSH, HTTP and HTTPS.
48
49- If a group is already configured with this name for this connection,
50- then re-use that. Otherwise, create a new security group and configure
51- it appropriately.
52-
53 The name of the security group is the `EC2Account.name` attribute.
54
55 :return: A boto security group.
56 """
57 if demo_networks is None:
58 demo_networks = []
59- try:
60- group = self.conn.get_all_security_groups(self.name)[0]
61- except EC2ResponseError, e:
62- if e.code != 'InvalidGroup.NotFound':
63- raise
64- else:
65- # If an existing security group was configured, try deleting it
66- # since our external IP might have changed.
67- try:
68- group.delete()
69- except EC2ResponseError, e:
70- if e.code != 'InvalidGroup.InUse':
71- raise
72- # Otherwise, it means that an instance is already using
73- # it, so simply re-use it. It's unlikely that our IP changed!
74- #
75- # XXX: JonathanLange 2009-06-05: If the security group exists
76- # already, verify that the current IP is permitted; if it is
77- # not, make an INFO log and add the current IP.
78- self.log("Security group already in use, so reusing.\n")
79- return group
80-
81+ # Create the security group.
82 security_group = self.conn.create_security_group(
83 self.name, 'Authorization to access the test runner instance.')
84 # Authorize SSH and HTTP.
85@@ -117,34 +112,49 @@
86 security_group.authorize('tcp', 443, 443, network)
87 return security_group
88
89+ def delete_previous_security_groups(self):
90+ """Delete previously used security groups, if found."""
91+ expired_groups = self._find_expired_artifacts(
92+ self.conn.get_all_security_groups())
93+ for group in expired_groups:
94+ try:
95+ group.delete()
96+ except EC2ResponseError, e:
97+ if e.code != 'InvalidGroup.InUse':
98+ raise
99+ self.log('Cannot delete; security group '
100+ '%r in use.\n' % group.name)
101+ else:
102+ self.log('Deleted security group %r.\n' % group.name)
103+
104 def acquire_private_key(self):
105 """Create & return a new key pair for the test runner."""
106 key_pair = self.conn.create_key_pair(self.name)
107 return paramiko.RSAKey.from_private_key(
108 cStringIO.StringIO(key_pair.material.encode('ascii')))
109
110- def delete_previous_key_pair(self):
111- """Delete previously used keypair, if it exists."""
112- try:
113- # Only one keypair will match 'self.name' since it's a unique
114- # identifier.
115- key_pairs = self.conn.get_all_key_pairs(self.name)
116- assert len(key_pairs) == 1, (
117- "Should be only one keypair, found %d (%s)"
118- % (len(key_pairs), key_pairs))
119- key_pair = key_pairs[0]
120- key_pair.delete()
121- except EC2ResponseError, e:
122- if e.code != 'InvalidKeyPair.NotFound':
123- if e.code == 'AuthFailure':
124- # Inserted because of previous support issue.
125- self.log(
126- 'POSSIBLE CAUSES OF ERROR:\n'
127- ' Did you sign up for EC2?\n'
128- ' Did you put a credit card number in your AWS '
129- 'account?\n'
130- 'Please doublecheck before reporting a problem.\n')
131- raise
132+ def delete_previous_key_pairs(self):
133+ """Delete previously used keypairs, if found."""
134+ expired_key_pairs = self._find_expired_artifacts(
135+ self.conn.get_all_key_pairs())
136+ for key_pair in expired_key_pairs:
137+ try:
138+ key_pair.delete()
139+ except EC2ResponseError, e:
140+ if e.code != 'InvalidKeyPair.NotFound':
141+ if e.code == 'AuthFailure':
142+ # Inserted because of previous support issue.
143+ self.log(AUTH_FAILURE_MESSAGE)
144+ raise
145+ self.log('Cannot delete; key pair not '
146+ 'found %r\n' % key_pair.name)
147+ else:
148+ self.log('Deleted key pair %r.\n' % key_pair.name)
149+
150+ def collect_garbage(self):
151+ """Remove any old keys and security groups."""
152+ self.delete_previous_security_groups()
153+ self.delete_previous_key_pairs()
154
155 def acquire_image(self, machine_id):
156 """Get the image.
157
158=== modified file 'lib/devscripts/ec2test/builtins.py'
159--- lib/devscripts/ec2test/builtins.py 2009-10-05 13:31:40 +0000
160+++ lib/devscripts/ec2test/builtins.py 2009-10-08 14:53:12 +0000
161@@ -22,6 +22,7 @@
162 from devscripts.ec2test.credentials import EC2Credentials
163 from devscripts.ec2test.instance import (
164 AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance)
165+from devscripts.ec2test.session import EC2SessionName
166 from devscripts.ec2test.testrunner import EC2TestRunner, TRUNK_BRANCH
167
168
169@@ -240,8 +241,8 @@
170 postmortem_option,
171 Option(
172 'headless',
173- help=('After building the instance and test, run the remote tests '
174- 'headless. Cannot be used with postmortem '
175+ help=('After building the instance and test, run the remote '
176+ 'tests headless. Cannot be used with postmortem '
177 'or file.')),
178 debug_option,
179 Option(
180@@ -285,8 +286,9 @@
181 "Submitting to PQM with non-default test options isn't "
182 "supported")
183
184+ session_name = EC2SessionName.make(EC2TestRunner.name)
185 instance = EC2Instance.make(
186- EC2TestRunner.name, instance_type, machine)
187+ session_name, instance_type, machine)
188
189 runner = EC2TestRunner(
190 test_branch, email=email, file=file,
191@@ -426,8 +428,9 @@
192 branches, test_branch = _get_branches_and_test_branch(
193 trunk, branch, test_branch)
194
195+ session_name = EC2SessionName.make(EC2TestRunner.name)
196 instance = EC2Instance.make(
197- EC2TestRunner.name, instance_type, machine, demo)
198+ session_name, instance_type, machine, demo)
199
200 runner = EC2TestRunner(
201 test_branch, branches=branches,
202@@ -480,8 +483,9 @@
203 ListOption(
204 'extra-update-image-command', type=str,
205 help=('Run this command (with an ssh agent) on the image before '
206- 'running the default update steps. Can be passed more than '
207- 'once, the commands will be run in the order specified.')),
208+ 'running the default update steps. Can be passed more '
209+ 'than once, the commands will be run in the order '
210+ 'specified.')),
211 Option(
212 'public',
213 help=('Remove proprietary code from the sourcecode directory '
214@@ -498,8 +502,9 @@
215
216 credentials = EC2Credentials.load_from_file()
217
218+ session_name = EC2SessionName.make(EC2TestRunner.name)
219 instance = EC2Instance.make(
220- EC2TestRunner.name, instance_type, machine,
221+ session_name, instance_type, machine,
222 credentials=credentials)
223 instance.check_bundling_prerequisites()
224
225
226=== modified file 'lib/devscripts/ec2test/instance.py'
227--- lib/devscripts/ec2test/instance.py 2009-10-04 15:40:46 +0000
228+++ lib/devscripts/ec2test/instance.py 2009-10-08 14:53:12 +0000
229@@ -24,6 +24,7 @@
230 import paramiko
231
232 from devscripts.ec2test.credentials import EC2Credentials
233+from devscripts.ec2test.session import EC2SessionName
234
235
236 DEFAULT_INSTANCE_TYPE = 'c1.xlarge'
237@@ -160,6 +161,18 @@
238 """
239
240
241+postmortem_banner = """\
242+Postmortem Console. EC2 instance is not yet dead.
243+It will shut down when you exit this prompt (CTRL-D)
244+
245+Tab-completion is enabled.
246+EC2Instance is available as `instance`.
247+Also try these:
248+ http://%(dns)s/current_test.log
249+ ssh -A %(dns)s
250+"""
251+
252+
253 class EC2Instance:
254 """A single EC2 instance."""
255
256@@ -170,6 +183,7 @@
257
258 :param name: The name to use for the key pair and security group for
259 the instance.
260+ :type name: `EC2SessionName`
261 :param instance_type: One of the AVAILABLE_INSTANCE_TYPES.
262 :param machine_id: The AMI to use, or None to do the usual regexp
263 matching. If you put 'based-on:' before the AMI id, it is assumed
264@@ -180,6 +194,7 @@
265 to allow access to the instance.
266 :param credentials: An `EC2Credentials` object.
267 """
268+ assert isinstance(name, EC2SessionName)
269 if instance_type not in AVAILABLE_INSTANCE_TYPES:
270 raise ValueError('unknown instance_type %s' % (instance_type,))
271
272@@ -200,7 +215,7 @@
273 # We always recreate the keypairs because there is no way to
274 # programmatically retrieve the private key component, unless we
275 # generate it.
276- account.delete_previous_key_pair()
277+ account.collect_garbage()
278
279 if machine_id and machine_id.startswith('based-on:'):
280 from_scratch = True
281@@ -345,8 +360,8 @@
282 if our_connection:
283 connection.close()
284 self.log(
285- 'You can now use ssh -A ec2test@%s to log in the instance.\n' %
286- self.hostname)
287+ 'You can now use ssh -A ec2test@%s to '
288+ 'log in the instance.\n' % self.hostname)
289 self._ec2test_user_has_keys = True
290
291 def connect(self):
292@@ -401,18 +416,8 @@
293 try:
294 if postmortem:
295 console = code.InteractiveConsole(locals())
296- console.interact((
297- 'Postmortem Console. EC2 instance is not yet dead.\n'
298- 'It will shut down when you exit this prompt '
299- '(CTRL-D).\n'
300- '\n'
301- 'Tab-completion is enabled.'
302- '\n'
303- 'EC2Instance is available as `instance`.\n'
304- 'Also try these:\n'
305- ' http://%(dns)s/current_test.log\n'
306- ' ssh -A %(dns)s') %
307- {'dns': self.hostname})
308+ console.interact(
309+ postmortem_banner % {'dns': self.hostname})
310 print 'Postmortem console closed.'
311 finally:
312 if shutdown:
313
314=== added file 'lib/devscripts/ec2test/session.py'
315--- lib/devscripts/ec2test/session.py 1970-01-01 00:00:00 +0000
316+++ lib/devscripts/ec2test/session.py 2009-10-08 14:53:12 +0000
317@@ -0,0 +1,90 @@
318+# Copyright 2009 Canonical Ltd. This software is licensed under the
319+# GNU Affero General Public License version 3 (see the file LICENSE).
320+
321+"""Code to represent a single session of EC2 use."""
322+
323+__metaclass__ = type
324+__all__ = [
325+ 'EC2SessionName',
326+ ]
327+
328+from datetime import datetime, timedelta
329+
330+from devscripts.ec2test.utils import (
331+ find_datetime_string, make_datetime_string, make_random_string)
332+
333+
334+DEFAULT_LIFETIME = timedelta(hours=6)
335+
336+
337+class EC2SessionName(str):
338+ """A name for an EC2 session.
339+
340+ This is used when naming key pairs and security groups, so it's
341+ useful to be unique. However, to aid garbage collection of old key
342+ pairs and security groups, the name contains a common element and
343+ an expiry timestamp. The form taken should always be:
344+
345+ <base-name>/<expires-timestamp>/<random-data>
346+
347+ None of the parts should contain forward-slashes, and the
348+ timestamp should be acceptable input to `find_datetime_string`.
349+
350+ `EC2SessionName.make()` will generate a suitable name given a
351+ suitable base name.
352+ """
353+
354+ @classmethod
355+ def make(cls, base, expires=None):
356+ """Create an `EC2SessionName`.
357+
358+ This checks that `base` does not contain a forward-slash, and
359+ provides some convenient functionality for `expires`:
360+
361+ - If `expires` is None, it defaults to now (UTC) plus
362+ `DEFAULT_LIFETIME`.
363+
364+ - If `expires` is a `datetime`, it is converted to a timestamp
365+ in the correct form.
366+
367+ - If `expires` is a `timedelta`, it is added to now (UTC) then
368+ converted to a timestamp.
369+
370+ - Otherwise `expires` is assumed to be a string, so is checked
371+ for the absense of forward-slashes, and that a correctly
372+ formed timestamp can be discovered.
373+
374+ """
375+ assert '/' not in base
376+ if expires is None:
377+ expires = DEFAULT_LIFETIME
378+ if isinstance(expires, timedelta):
379+ expires = datetime.utcnow() + expires
380+ if isinstance(expires, datetime):
381+ expires = make_datetime_string(expires)
382+ else:
383+ assert '/' not in expires
384+ assert find_datetime_string(expires) is not None
385+ rand = make_random_string(8)
386+ return cls("%s/%s/%s" % (base, expires, rand))
387+
388+ @property
389+ def base(self):
390+ parts = self.split('/')
391+ if len(parts) != 3:
392+ return None
393+ return parts[0]
394+
395+ @property
396+ def expires(self):
397+ parts = self.split('/')
398+ if len(parts) != 3:
399+ return None
400+ return find_datetime_string(parts[1])
401+
402+ @property
403+ def rand(self):
404+ parts = self.split('/')
405+ if len(parts) != 3:
406+ return None
407+ return parts[2]
408
409=== added directory 'lib/devscripts/ec2test/tests'
410=== added file 'lib/devscripts/ec2test/tests/__init__.py'
411--- lib/devscripts/ec2test/tests/__init__.py 1970-01-01 00:00:00 +0000
412+++ lib/devscripts/ec2test/tests/__init__.py 2009-10-08 14:53:12 +0000
413@@ -0,0 +1,2 @@
414+# Copyright 2009 Canonical Ltd. This software is licensed under the
415+# GNU Affero General Public License version 3 (see the file LICENSE).
416
417=== added file 'lib/devscripts/ec2test/tests/test_session.py'
418--- lib/devscripts/ec2test/tests/test_session.py 1970-01-01 00:00:00 +0000
419+++ lib/devscripts/ec2test/tests/test_session.py 2009-10-08 14:53:12 +0000
420@@ -0,0 +1,69 @@
421+# Copyright 2009 Canonical Ltd. This software is licensed under the
422+# GNU Affero General Public License version 3 (see the file LICENSE).
423+
424+"""Test the session module."""
425+
426+__metaclass__ = type
427+
428+import re
429+import unittest
430+
431+from datetime import datetime, timedelta
432+
433+from devscripts.ec2test import session
434+
435+
436+class TestEC2SessionName(unittest.TestCase):
437+ """Tests for EC2SessionName."""
438+
439+ def test_make(self):
440+ # EC2SessionName.make() is the most convenient way to create
441+ # valid names.
442+ name = session.EC2SessionName.make("fred")
443+ check = re.compile(
444+ r'^fred/\d{4}-\d{2}-\d{2}-\d{4}/[0-9a-zA-Z]{8}$').match
445+ self.failIf(check(name) is None, "Did not match %r" % name)
446+ possible_expires = [
447+ None, '1986-04-26-0123', timedelta(hours=10),
448+ datetime(1986, 04, 26, 1, 23)
449+ ]
450+ for expires in possible_expires:
451+ name = session.EC2SessionName.make("fred", expires)
452+ self.failIf(check(name) is None, "Did not match %r" % name)
453+
454+ def test_properties(self):
455+ # A valid EC2SessionName has properies to access the three
456+ # components of its name.
457+ base = "fred"
458+ timestamp = datetime(1986, 4, 26, 1, 23)
459+ timestamp_string = '1986-04-26-0123'
460+ rand = 'abcdef123456'
461+ name = session.EC2SessionName(
462+ "%s/%s/%s" % (base, timestamp_string, rand))
463+ self.failUnlessEqual(base, name.base)
464+ self.failUnlessEqual(timestamp, name.expires)
465+ self.failUnlessEqual(rand, name.rand)
466+
467+ def test_invalid_base(self):
468+ # If the given base contains a forward-slash, an
469+ # AssertionError should be raised.
470+ self.failUnlessRaises(
471+ AssertionError, session.EC2SessionName.make, "forward/slash")
472+
473+ def test_invalid_timestamp(self):
474+ # If the given expiry timestamp contains a forward-slash, an
475+ # AssertionError should be raised.
476+ self.failUnlessRaises(
477+ AssertionError, session.EC2SessionName.make, "fred", "/")
478+ # If the given expiry timestamp does not contain a timestamp
479+ # in the correct form, an AssertionError should be raised.
480+ self.failUnlessRaises(
481+ AssertionError, session.EC2SessionName.make, "fred", "1986.04.26")
482+
483+ def test_form_not_correct(self):
484+ # If the form of the string is not base/timestamp/rand then
485+ # the corresponding properties should all return None.
486+ broken_name = session.EC2SessionName('bob')
487+ self.failUnless(broken_name.base is None)
488+ self.failUnless(broken_name.expires is None)
489+ self.failUnless(broken_name.rand is None)
490
491=== added file 'lib/devscripts/ec2test/tests/test_utils.py'
492--- lib/devscripts/ec2test/tests/test_utils.py 1970-01-01 00:00:00 +0000
493+++ lib/devscripts/ec2test/tests/test_utils.py 2009-10-08 14:53:12 +0000
494@@ -0,0 +1,61 @@
495+# Copyright 2009 Canonical Ltd. This software is licensed under the
496+# GNU Affero General Public License version 3 (see the file LICENSE).
497+
498+"""Test the utils module."""
499+
500+__metaclass__ = type
501+
502+import unittest
503+
504+from datetime import datetime
505+
506+from devscripts.ec2test import utils
507+
508+
509+class TestDateTimeUtils(unittest.TestCase):
510+ """Tests for date/time related utilities."""
511+
512+ example_date = datetime(1986, 4, 26, 1, 23)
513+ example_date_string = '1986-04-26-0123'
514+ example_date_text = (
515+ 'blah blah foo blah 23545 646 ' +
516+ example_date_string + ' 435 blah')
517+
518+ def test_make_datetime_string(self):
519+ self.failUnlessEqual(
520+ self.example_date_string,
521+ utils.make_datetime_string(self.example_date))
522+
523+ def test_find_datetime_string(self):
524+ self.failUnlessEqual(
525+ self.example_date,
526+ utils.find_datetime_string(self.example_date_string))
527+ self.failUnlessEqual(
528+ self.example_date,
529+ utils.find_datetime_string(self.example_date_text))
530+
531+
532+class TestRandomUtils(unittest.TestCase):
533+ """Tests for randomness related utilities."""
534+
535+ hex_chars = frozenset('0123456789abcdefABCDEF')
536+
537+ def test_make_random_string(self):
538+ rand_a = utils.make_random_string()
539+ rand_b = utils.make_random_string()
540+ self.failIfEqual(rand_a, rand_b)
541+ self.failUnlessEqual(32, len(rand_a))
542+ self.failUnlessEqual(32, len(rand_b))
543+ self.failUnless(self.hex_chars.issuperset(rand_a))
544+ self.failUnless(self.hex_chars.issuperset(rand_b))
545+
546+ def test_make_random_string_with_length(self):
547+ for length in (8, 16, 64):
548+ rand = utils.make_random_string(length)
549+ self.failUnlessEqual(length, len(rand))
550+ self.failUnless(self.hex_chars.issuperset(rand))
551+
552+ def test_make_random_string_with_bad_length(self):
553+ # length must be a multiple of 2.
554+ self.failUnlessRaises(
555+ AssertionError, utils.make_random_string, 15)
556
557=== added file 'lib/devscripts/ec2test/utils.py'
558--- lib/devscripts/ec2test/utils.py 1970-01-01 00:00:00 +0000
559+++ lib/devscripts/ec2test/utils.py 2009-10-08 14:53:12 +0000
560@@ -0,0 +1,55 @@
561+# Copyright 2009 Canonical Ltd. This software is licensed under the
562+# GNU Affero General Public License version 3 (see the file LICENSE).
563+
564+"""General useful stuff."""
565+
566+__metaclass__ = type
567+__all__ = [
568+ 'find_datetime_string',
569+ 'make_datetime_string',
570+ 'make_random_string',
571+ ]
572+
573+
574+import binascii
575+import datetime
576+import os
577+import re
578+
579+
580+def make_datetime_string(when=None):
581+ """Generate a simple formatted date and time string.
582+
583+ This is intended to be embedded in text to be later found by
584+ `find_datetime_string`.
585+ """
586+ if when is None:
587+ when = datetime.datetime.utcnow()
588+ return when.strftime('%Y-%m-%d-%H%M')
589+
590+
591+re_find_datetime = re.compile(
592+ r'(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})')
593+
594+def find_datetime_string(text):
595+ """Search for a simple date and time in arbitrary text.
596+
597+ The format searched for is %Y-%m-%d-%H%M - the same as produced by
598+ `make_datetime_string`.
599+ """
600+ match = re_find_datetime.search(text)
601+ if match is None:
602+ return None
603+ else:
604+ return datetime.datetime(
605+ *(int(part) for part in match.groups()))
606+
607+
608+def make_random_string(length=32):
609+ """Return a simple random UUID.
610+
611+ The uuid module is only available in Python 2.5 and above, but a
612+ simple non-RFC-compliant hack here is sufficient.
613+ """
614+ assert length % 2 == 0, "length must be a multiple of 2"
615+ return binascii.hexlify(os.urandom(length/2))
616
617=== modified file 'setup.py'
618--- setup.py 2009-08-17 19:16:23 +0000
619+++ setup.py 2009-10-08 14:53:12 +0000
620@@ -41,6 +41,7 @@
621 'mechanize',
622 'mocker',
623 'oauth',
624+ 'paramiko',
625 'python-openid',
626 'pytz',
627 # This appears to be a broken indirect dependency from zope.security:
628
629=== modified file 'versions.cfg'
630--- versions.cfg 2009-10-01 16:38:03 +0000
631+++ versions.cfg 2009-10-08 14:53:12 +0000
632@@ -35,8 +35,10 @@
633 mocker = 0.10.1
634 mozrunner = 1.3.4
635 oauth = 1.0
636+paramiko = 1.7.4
637 Paste = 1.7.2
638 PasteDeploy = 1.3.3
639+pycrypto = 2.0.1
640 python-openid = 2.2.1
641 pytz = 2009l
642 RestrictedPython = 3.4.2