Merge lp:~thomir-deactivatedaccount/autopilot/1.3-fix-scan-speed into lp:autopilot/1.3

Proposed by Thomi Richards
Status: Merged
Approved by: Thomi Richards
Approved revision: 336
Merged at revision: 333
Proposed branch: lp:~thomir-deactivatedaccount/autopilot/1.3-fix-scan-speed
Merge into: lp:autopilot/1.3
Diff against target: 540 lines (+287/-161)
5 files modified
autopilot/introspection/__init__.py (+29/-19)
autopilot/testcase.py (+35/-11)
autopilot/tests/functional/__init__.py (+137/-0)
autopilot/tests/functional/test_autopilot_functional.py (+5/-131)
autopilot/tests/functional/test_autopilot_performance.py (+81/-0)
To merge this branch: bzr merge lp:~thomir-deactivatedaccount/autopilot/1.3-fix-scan-speed
Reviewer Review Type Date Requested Status
PS Jenkins bot continuous-integration Approve
Martin Pitt (community) Approve
Review via email: mp+186113@code.launchpad.net

Commit message

Performance improvments for launching and closing applications under test with autopilot.

Description of the change

Merge performance improvments for autopilot.

To post a comment you must log in.
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Needs Fixing (continuous-integration)
336. By Thomi Richards

Fix PEP8 issues.

Revision history for this message
Martin Pitt (pitti) wrote :

I'm a bit sceptical that this is going to be long enough for ARM:

+ with maximum_runtime(5.0):

Also, I don't believe the rather complicated retry loop in _kill_process() isn't necessary, as in both py2 and py3 communicate() is documented to actually wait for the process to terminate.

Otherwise, LGTM. Still waiting for CI tests.

review: Approve
Revision history for this message
PS Jenkins bot (ps-jenkins) wrote :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'autopilot/introspection/__init__.py'
2--- autopilot/introspection/__init__.py 2013-09-11 16:16:00 +0000
3+++ autopilot/introspection/__init__.py 2013-09-17 18:50:22 +0000
4@@ -33,6 +33,7 @@
5 from time import sleep
6 from functools import partial
7 import os
8+import psutil
9
10 from autopilot.introspection.backends import DBusAddress
11 from autopilot.introspection.constants import (
12@@ -239,8 +240,10 @@
13 :param emulator_base: The custom emulator to create the resulting proxy
14 object with.
15
16- :raises: RuntimeError if no search criteria match.
17- :raises: RuntimeError if the search criteria results in many matches.
18+ :raises ProcessSearchError: if no search criteria match.
19+ :raises RuntimeError: if the search criteria results in many matches.
20+ :raises RuntimeError: if both ``process`` and ``pid`` are supplied, but
21+ ``process.pid != pid``.
22
23 """
24 if process is not None:
25@@ -282,6 +285,7 @@
26 _reset_known_connection_list()
27
28 for i in range(10):
29+ _get_child_pids.reset_cache()
30 if process is not None and not _process_is_running(process):
31 return_code = process.poll()
32 raise ProcessSearchError(
33@@ -430,26 +434,32 @@
34 obj_iface.GetVersion()
35
36
37-def _get_child_pids(pid):
38+class _cached_get_child_pids(object):
39 """Get a list of all child process Ids, for the given parent.
40
41+ Since we call this often, and it's a very expensive call, we optimise this
42+ such that the return value will be cached for each scan through the dbus
43+ bus.
44+
45+ Calling reset_cache() at the end of each dbus scan will ensure that you get
46+ fresh values on the next call.
47 """
48- def get_children(pid):
49- command = ['ps', '-o', 'pid', '--ppid', str(pid), '--noheaders']
50- try:
51- raw_output = subprocess.check_output(command)
52- except subprocess.CalledProcessError:
53- return []
54- return [int(p) for p in raw_output.split()]
55-
56- result = []
57- data = get_children(pid)
58- while data:
59- pid = data.pop(0)
60- result.append(pid)
61- data.extend(get_children(pid))
62-
63- return result
64+
65+ def __init__(self):
66+ self._cached_result = None
67+
68+ def __call__(self, pid):
69+ if self._cached_result is None:
70+ self._cached_result = [
71+ p.pid for p in psutil.Process(pid).get_children(recursive=True)
72+ ]
73+ return self._cached_result
74+
75+ def reset_cache(self):
76+ self._cached_result = None
77+
78+
79+_get_child_pids = _cached_get_child_pids()
80
81
82 def _make_proxy_object(data_source, emulator_base):
83
84=== modified file 'autopilot/testcase.py'
85--- autopilot/testcase.py 2013-09-13 18:52:37 +0000
86+++ autopilot/testcase.py 2013-09-17 18:50:22 +0000
87@@ -345,7 +345,7 @@
88 if app_id in line and "start/running" in line:
89 target_pid = int(line.split()[-1])
90
91- self.addCleanup(self._kill_process, target_pid)
92+ self.addCleanup(self._kill_pid, target_pid)
93 logger.info(
94 "Click package %s has been launched with PID %d",
95 app_id,
96@@ -525,16 +525,11 @@
97 # default implementation is in autopilot.introspection:
98 return get_application_launcher(app_path)
99
100- def _attach_process_logs(self, process):
101- stdout, stderr = process.communicate()
102- return_code = process.returncode
103- self.addDetail('process-return-code', text_content(str(return_code)))
104- self.addDetail('process-stdout', text_content(stdout))
105- self.addDetail('process-stderr', text_content(stderr))
106-
107- def _kill_process(self, pid):
108+ def _kill_pid(self, pid):
109+ """Kill the process with the specified pid."""
110 logger.info("waiting for process to exit.")
111 try:
112+ logger.info("Killing process %d", pid)
113 os.killpg(pid, signal.SIGTERM)
114 except OSError:
115 logger.info("Appears process has already exited.")
116@@ -548,10 +543,39 @@
117 )
118 os.killpg(pid, signal.SIGKILL)
119 sleep(1)
120+ return stdout, stderr, process.returncode
121+
122+ def _kill_process(self, process):
123+ """Kill the process, and return the stdout, stderr and return code."""
124+ stdout = ""
125+ stderr = ""
126+ return_code = -1
127+ logger.info("waiting for process to exit.")
128+ try:
129+ logger.info("Killing process %d", process.pid)
130+ os.killpg(process.pid, signal.SIGTERM)
131+ except OSError:
132+ logger.info("Appears process has already exited.")
133+ for i in range(10):
134+ tmp_out, tmp_err = process.communicate()
135+ stdout += tmp_out
136+ stderr += tmp_err
137+ if not _is_process_running(process.pid):
138+ break
139+ if i == 9:
140+ logger.info(
141+ "Killing process group, since it hasn't exited after "
142+ "10 seconds."
143+ )
144+ os.killpg(process.pid, signal.SIGKILL)
145+ sleep(1)
146+ return stdout, stderr, process.returncode
147
148 def _kill_process_and_attach_logs(self, process):
149- self._kill_process(process.pid)
150- self._attach_process_logs(process)
151+ stdout, stderr, return_code = self._kill_process(process)
152+ self.addDetail('process-return-code', text_content(str(return_code)))
153+ self.addDetail('process-stdout', text_content(stdout))
154+ self.addDetail('process-stderr', text_content(stderr))
155
156
157 def _is_process_running(pid):
158
159=== modified file 'autopilot/tests/functional/__init__.py'
160--- autopilot/tests/functional/__init__.py 2013-05-05 02:39:14 +0000
161+++ autopilot/tests/functional/__init__.py 2013-09-17 18:50:22 +0000
162@@ -16,3 +16,140 @@
163 # You should have received a copy of the GNU General Public License
164 # along with this program. If not, see <http://www.gnu.org/licenses/>.
165 #
166+
167+
168+from __future__ import absolute_import
169+
170+from codecs import open
171+import os
172+import os.path
173+import logging
174+from shutil import rmtree
175+import subprocess
176+from tempfile import mkdtemp
177+from testtools.content import Content, text_content
178+from testtools.content_type import ContentType
179+
180+from autopilot.testcase import AutopilotTestCase
181+
182+
183+def remove_if_exists(path):
184+ if os.path.exists(path):
185+ if os.path.isdir(path):
186+ rmtree(path)
187+ else:
188+ os.remove(path)
189+
190+
191+logger = logging.getLogger(__name__)
192+
193+
194+class AutopilotRunTestBase(AutopilotTestCase):
195+
196+ """The base class for the autopilot functional tests."""
197+
198+ def setUp(self):
199+ super(AutopilotRunTestBase, self).setUp()
200+ self.base_path = self.create_empty_test_module()
201+
202+ def create_empty_test_module(self):
203+ """Create an empty temp directory, with an empty test directory inside
204+ it.
205+
206+ This method handles cleaning up the directory once the test completes.
207+
208+ Returns the full path to the temp directory.
209+
210+ """
211+
212+ # create the base directory:
213+ base_path = mkdtemp()
214+ self.addDetail('base path', text_content(base_path))
215+ self.addCleanup(rmtree, base_path)
216+
217+ # create the tests directory:
218+ os.mkdir(
219+ os.path.join(base_path, 'tests')
220+ )
221+
222+ # make tests importable:
223+ open(
224+ os.path.join(
225+ base_path,
226+ 'tests',
227+ '__init__.py'),
228+ 'w').write('# Auto-generated file.')
229+ return base_path
230+
231+ def run_autopilot(self, arguments):
232+ ap_base_path = os.path.abspath(
233+ os.path.join(
234+ os.path.dirname(__file__),
235+ '..',
236+ '..',
237+ '..'
238+ )
239+ )
240+
241+ environment_patch = dict(DISPLAY=':0')
242+ if not os.getcwd().startswith('/usr/'):
243+ environment_patch['PYTHONPATH'] = ap_base_path
244+ bin_path = os.path.join(ap_base_path, 'bin', 'autopilot')
245+ if not os.path.exists(bin_path):
246+ bin_path = subprocess.check_output(['which', 'autopilot']).strip()
247+ logger.info(
248+ "Not running from source, setting bin_path to %s", bin_path)
249+
250+ environ = os.environ
251+ environ.update(environment_patch)
252+
253+ logger.info("Starting autopilot command with:")
254+ logger.info("Autopilot command = %s", bin_path)
255+ logger.info("Arguments = %s", arguments)
256+ logger.info("CWD = %r", self.base_path)
257+
258+ arg = [bin_path]
259+ arg.extend(arguments)
260+ process = subprocess.Popen(
261+ arg,
262+ cwd=self.base_path,
263+ env=environ,
264+ stdout=subprocess.PIPE,
265+ stderr=subprocess.PIPE,
266+ )
267+
268+ stdout, stderr = process.communicate()
269+ retcode = process.poll()
270+
271+ self.addDetail('retcode', text_content(str(retcode)))
272+ self.addDetail(
273+ 'stdout',
274+ Content(
275+ ContentType('text', 'plain', {'charset': 'iso-8859-1'}),
276+ lambda: [stdout]
277+ )
278+ )
279+ self.addDetail(
280+ 'stderr',
281+ Content(
282+ ContentType('text', 'plain', {'charset': 'iso-8859-1'}),
283+ lambda: [stderr]
284+ )
285+ )
286+
287+ return (retcode, stdout, stderr)
288+
289+ def create_test_file(self, name, contents):
290+ """Create a test file with the given name and contents.
291+
292+ 'name' must end in '.py' if it is to be importable.
293+ 'contents' must be valid python code.
294+
295+ """
296+ open(
297+ os.path.join(
298+ self.base_path,
299+ 'tests',
300+ name),
301+ 'w',
302+ encoding='utf8').write(contents)
303
304=== modified file 'autopilot/tests/functional/test_autopilot_functional.py'
305--- autopilot/tests/functional/test_autopilot_functional.py 2013-09-13 15:00:35 +0000
306+++ autopilot/tests/functional/test_autopilot_functional.py 2013-09-17 18:50:22 +0000
307@@ -23,142 +23,16 @@
308 from codecs import open
309 import os
310 import os.path
311-import logging
312-from shutil import rmtree
313-import subprocess
314-from tempfile import mktemp, mkdtemp
315-from testtools.content import Content, text_content
316-from testtools.content_type import ContentType
317+from tempfile import mktemp
318 from testtools.matchers import Contains, Equals, MatchesRegex, Not
319 from textwrap import dedent
320 import re
321
322 from autopilot.testcase import AutopilotTestCase
323-
324-
325-def remove_if_exists(path):
326- if os.path.exists(path):
327- if os.path.isdir(path):
328- rmtree(path)
329- else:
330- os.remove(path)
331-
332-
333-logger = logging.getLogger(__name__)
334-
335-
336-class AutopilotFunctionalTestsBase(AutopilotTestCase):
337-
338- """The base class for the autopilot functional tests."""
339-
340- def setUp(self):
341- super(AutopilotFunctionalTestsBase, self).setUp()
342- self.base_path = self.create_empty_test_module()
343-
344- def create_empty_test_module(self):
345- """Create an empty temp directory, with an empty test directory inside
346- it.
347-
348- This method handles cleaning up the directory once the test completes.
349-
350- Returns the full path to the temp directory.
351-
352- """
353-
354- # create the base directory:
355- base_path = mkdtemp()
356- self.addDetail('base path', text_content(base_path))
357- self.addCleanup(rmtree, base_path)
358-
359- # create the tests directory:
360- os.mkdir(
361- os.path.join(base_path, 'tests')
362- )
363-
364- # make tests importable:
365- open(
366- os.path.join(
367- base_path,
368- 'tests',
369- '__init__.py'),
370- 'w').write('# Auto-generated file.')
371- return base_path
372-
373- def run_autopilot(self, arguments):
374- ap_base_path = os.path.abspath(
375- os.path.join(
376- os.path.dirname(__file__),
377- '..',
378- '..',
379- '..'
380- )
381- )
382-
383- environment_patch = dict(DISPLAY=':0')
384- if not os.getcwd().startswith('/usr/'):
385- environment_patch['PYTHONPATH'] = ap_base_path
386- bin_path = os.path.join(ap_base_path, 'bin', 'autopilot')
387- if not os.path.exists(bin_path):
388- bin_path = subprocess.check_output(['which', 'autopilot']).strip()
389- logger.info(
390- "Not running from source, setting bin_path to %s", bin_path)
391-
392- environ = os.environ
393- environ.update(environment_patch)
394-
395- logger.info("Starting autopilot command with:")
396- logger.info("Autopilot command = %s", bin_path)
397- logger.info("Arguments = %s", arguments)
398- logger.info("CWD = %r", self.base_path)
399-
400- arg = [bin_path]
401- arg.extend(arguments)
402- process = subprocess.Popen(
403- arg,
404- cwd=self.base_path,
405- env=environ,
406- stdout=subprocess.PIPE,
407- stderr=subprocess.PIPE,
408- )
409-
410- stdout, stderr = process.communicate()
411- retcode = process.poll()
412-
413- self.addDetail('retcode', text_content(str(retcode)))
414- self.addDetail(
415- 'stdout',
416- Content(
417- ContentType('text', 'plain', {'charset': 'iso-8859-1'}),
418- lambda: [stdout]
419- )
420- )
421- self.addDetail(
422- 'stderr',
423- Content(
424- ContentType('text', 'plain', {'charset': 'iso-8859-1'}),
425- lambda: [stderr]
426- )
427- )
428-
429- return (retcode, stdout, stderr)
430-
431- def create_test_file(self, name, contents):
432- """Create a test file with the given name and contents.
433-
434- 'name' must end in '.py' if it is to be importable.
435- 'contents' must be valid python code.
436-
437- """
438- open(
439- os.path.join(
440- self.base_path,
441- 'tests',
442- name),
443- 'w',
444- encoding='utf8').write(contents)
445-
446-
447-class AutopilotFunctionalTests(AutopilotFunctionalTestsBase):
448+from autopilot.tests.functional import AutopilotRunTestBase, remove_if_exists
449+
450+
451+class AutopilotFunctionalTestsBase(AutopilotRunTestBase):
452
453 """A collection of functional tests for autopilot."""
454
455
456=== added file 'autopilot/tests/functional/test_autopilot_performance.py'
457--- autopilot/tests/functional/test_autopilot_performance.py 1970-01-01 00:00:00 +0000
458+++ autopilot/tests/functional/test_autopilot_performance.py 2013-09-17 18:50:22 +0000
459@@ -0,0 +1,81 @@
460+# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-
461+#
462+# Autopilot Functional Test Tool
463+# Copyright (C) 2012-2013 Canonical
464+#
465+# This program is free software: you can redistribute it and/or modify
466+# it under the terms of the GNU General Public License as published by
467+# the Free Software Foundation, either version 3 of the License, or
468+# (at your option) any later version.
469+#
470+# This program is distributed in the hope that it will be useful,
471+# but WITHOUT ANY WARRANTY; without even the implied warranty of
472+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
473+# GNU General Public License for more details.
474+#
475+# You should have received a copy of the GNU General Public License
476+# along with this program. If not, see <http://www.gnu.org/licenses/>.
477+#
478+
479+from __future__ import absolute_import
480+
481+
482+from contextlib import contextmanager
483+from time import time
484+from textwrap import dedent
485+from testtools.matchers import Equals
486+
487+import logging
488+from autopilot.tests.functional import AutopilotRunTestBase
489+
490+
491+logger = logging.getLogger(__name__)
492+
493+
494+@contextmanager
495+def maximum_runtime(max_time):
496+ start_time = time()
497+ yield
498+ total_time = abs(time() - start_time)
499+ if total_time >= max_time:
500+ raise AssertionError(
501+ "Runtime of %f was not within defined "
502+ "limit of %f" % (total_time, max_time)
503+ )
504+ else:
505+ logger.info(
506+ "Test completed in %f seconds, which is below the "
507+ " threshold of %f.",
508+ total_time,
509+ max_time
510+ )
511+
512+
513+class AutopilotPerformanceTests(AutopilotRunTestBase):
514+
515+ """A suite of functional tests that will fail if autopilot performance
516+ regresses below certain strictly defined limits.
517+
518+ Each test must be named after the feature we are benchmarking, and should
519+ use the maximum_runtime contextmanager defined above.
520+
521+ """
522+
523+ def test_autopilot_launch_test_app(self):
524+ self.create_test_file(
525+ 'test_something.py',
526+ dedent("""
527+ from autopilot.testcase import AutopilotTestCase
528+
529+ class LaunchTestAppTests(AutopilotTestCase):
530+
531+ def test_launch_test_app(self):
532+ app_proxy = self.launch_test_application(
533+ 'window-mocker',
534+ app_type='qt'
535+ )
536+ """)
537+ )
538+ with maximum_runtime(5.0):
539+ rc, out, err = self.run_autopilot(['run', 'tests'])
540+ self.assertThat(rc, Equals(0))

Subscribers

People subscribed via source and target branches

to all changes: