Merge lp:~david-schwarz/lava-dispatcher/multi-target into lp:lava-dispatcher

Proposed by David Schwarz
Status: Rejected
Rejected by: Michael Hudson-Doyle
Proposed branch: lp:~david-schwarz/lava-dispatcher/multi-target
Merge into: lp:lava-dispatcher
Diff against target: 672 lines (+464/-64) (has conflicts)
9 files modified
doc/multi-target_test.json (+46/-0)
doc/multi-target_test_2.json (+81/-0)
lava-dispatch (+3/-0)
lava_dispatcher/__init__.py (+162/-45)
lava_dispatcher/actions/launch_control.py (+53/-19)
lava_dispatcher/actions/shell_commands.py (+42/-0)
lava_dispatcher/actions/sync_to_label.py (+22/-0)
lava_dispatcher/sync.py (+47/-0)
lava_dispatcher/utils.py (+8/-0)
Text conflict in lava_dispatcher/__init__.py
To merge this branch: bzr merge lp:~david-schwarz/lava-dispatcher/multi-target
Reviewer Review Type Date Requested Status
Linaro Validation Team Pending
Review via email: mp+68917@code.launchpad.net

Description of the change

The changes on this branch give lava-dispatcher the capability to manage and coordinate tests on multiple target machines concurrently. This allows the possibility of tests requiring coordination between multiple machines or multiple groups of machines.

Note that the Python module IPy must be installed on the dispatcher host.

To post a comment you must log in.

Unmerged revisions

80. By David Schwarz

actions: Add scp and verify_file_present actions and example

79. By David Schwarz

multi-target: Run tests involving multiple target machines

Each target machines' action sequence runs in a separate thread.
At the end of the run, results are pooled from all threads and
sent as a single bundle to the dashboard server.

Note that package python-ipy must be installed on the
system running the dispatcher.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'doc/multi-target_test.json'
2--- doc/multi-target_test.json 1970-01-01 00:00:00 +0000
3+++ doc/multi-target_test.json 2011-07-22 22:31:22 +0000
4@@ -0,0 +1,46 @@
5+{
6+ "job_name": "multi-target_test",
7+ "client_groups": [
8+ {
9+ "name": "group1",
10+ "target_hosts": ["192.168.10.1-192.168.10.10"]
11+ },
12+ {
13+ "name": "group2",
14+ "target_hosts": ["192.168.85.11",
15+ "192.168.87.28",
16+ "host1.linaro.org",
17+ "host2",
18+ "host16.hyphenated-domain.com"]
19+ }
20+ ],
21+ "timeout": 18000,
22+ "actions": [
23+ {
24+ "command": "ls",
25+ "target_groups": ["group1"]
26+ },
27+ {
28+ "command": "sync_to_label",
29+ "target_groups": ["group1","group2"],
30+ "parameters":
31+ {
32+ "label": "ls_done",
33+ "block": true
34+ }
35+ },
36+ {
37+ "command": "date",
38+ "target_groups": ["group1", "group2"]
39+ },
40+ {
41+ "command": "submit_results",
42+ "parameters":
43+ {
44+ "server": "http://validation.linaro.org/launch-control",
45+ "stream": "/anonymous/ls-test/",
46+ "skip_remote": true
47+ }
48+ }
49+ ]
50+}
51
52=== added file 'doc/multi-target_test_2.json'
53--- doc/multi-target_test_2.json 1970-01-01 00:00:00 +0000
54+++ doc/multi-target_test_2.json 2011-07-22 22:31:22 +0000
55@@ -0,0 +1,81 @@
56+{
57+ "job_name": "multi-target_test_2",
58+ "client_groups": [
59+ {
60+ "name": "group1",
61+ "target_hosts": ["panda01"]
62+ },
63+ {
64+ "name": "group2",
65+ "target_hosts": ["panda02"]
66+ }
67+ ],
68+ "timeout": 18000,
69+ "actions": [
70+ {
71+ "command": "sync_to_label",
72+ "target_groups": ["group1","group2"],
73+ "parameters":
74+ {
75+ "label": "all_clients_up",
76+ "block": true
77+ }
78+ },
79+ {
80+ "target_groups": ["group1"],
81+ "command": "scp",
82+ "parameters":
83+ {
84+ "username": "tester",
85+ "host": "10.1.1.1",
86+ "filename": "~/exists.txt",
87+ "target_path": ""
88+ }
89+ },
90+ {
91+ "target_groups": ["group1"],
92+ "command": "scp",
93+ "parameters":
94+ {
95+ "username": "tester",
96+ "host": "10.1.1.1",
97+ "filename": "~/doesnotexist.txt",
98+ "target_path": ""
99+ }
100+ },
101+ {
102+ "command": "sync_to_label",
103+ "target_groups": ["group1","group2"],
104+ "parameters":
105+ {
106+ "label": "wait_for_scp",
107+ "block": true
108+ }
109+ },
110+ {
111+ "command": "verify_file_present",
112+ "target_groups": ["group2"],
113+ "parameters":
114+ {
115+ "file": "~/exists.txt"
116+ }
117+ },
118+ {
119+ "command": "verify_file_present",
120+ "target_groups": ["group2"],
121+ "parameters":
122+ {
123+ "file": "~/doesnotexist.txt"
124+ }
125+ },
126+ {
127+ "command": "submit_results",
128+ "parameters":
129+ {
130+ "server": "http://validation.linaro.org/launch-control",
131+ "stream": "/anonymous/panda_multi_scp/",
132+ "skip_remote": true
133+ }
134+ }
135+ ]
136+}
137
138=== modified file 'lava-dispatch'
139--- lava-dispatch 2011-06-27 04:55:08 +0000
140+++ lava-dispatch 2011-07-22 22:31:22 +0000
141@@ -20,6 +20,7 @@
142 # with this program; if not, see <http://www.gnu.org/licenses>.
143
144 import sys
145+import threading
146
147 from lava_dispatcher import LavaTestJob
148
149@@ -28,6 +29,8 @@
150 print >> sys.stderr, "Usage:\n lava-dispatch <json job file>"
151 sys.exit(status)
152
153+threading.stack_size(256000);
154+
155 if len(sys.argv) != 2:
156 usage(1)
157
158
159=== modified file 'lava_dispatcher/__init__.py'
160--- lava_dispatcher/__init__.py 2011-07-21 16:55:47 +0000
161+++ lava_dispatcher/__init__.py 2011-07-22 22:31:22 +0000
162@@ -25,51 +25,185 @@
163 from uuid import uuid1
164 import base64
165 import pexpect
166+import re
167+from operator import add
168
169 from lava_dispatcher.actions import get_all_cmds
170 from lava_dispatcher.client import LavaClient, CriticalError, GeneralError
171 from lava_dispatcher.android_client import LavaAndroidClient
172+<<<<<<< TREE
173
174 __version__ = "0.1.0"
175
176 class LavaTestJob(object):
177+=======
178+from threading import Thread
179+from lava_dispatcher.utils import ip_addr_range
180+from lava_dispatcher.sync import SyncMaster
181+
182+class LavaTestJob(SyncMaster):
183+
184+>>>>>>> MERGE-SOURCE
185 def __init__(self, job_json):
186+ super(LavaTestJob, self).__init__()
187 self.job_status = 'pass'
188 self.load_job_data(job_json)
189- self.context = LavaContext(self.target, self.image_type)
190+ self.client_group_list = dict()
191+ self._init_client_threads()
192+ self.lava_commands = get_all_cmds()
193
194 def load_job_data(self, job_json):
195 self.job_data = json.loads(job_json)
196
197+ def _init_client_threads(self):
198+ if not self.client_groups:
199+ thread = ClientThread(self.target, self.image_type,
200+ self.target_type, self)
201+ self.client_group_list['default'] = thread
202+ else:
203+ for group in self.client_groups:
204+ group_name = group['name']
205+ self.client_group_list[group_name] = []
206+ for hostname in self._gen_host(group):
207+ image_type = group.get('image_type')
208+ target_type = group.get('target_type')
209+ thread = ClientThread(hostname, image_type, target_type,
210+ self)
211+ self.client_group_list[group_name].append(thread)
212+
213+ def _gen_host(self, group):
214+ for host_str in group['target_hosts']:
215+ if re.match('^[\d\.]+-[\d\.]+$', host_str):
216+ range = host_str.split('-')
217+ assert len(range) == 2
218+ for host in ip_addr_range(*range):
219+ yield host
220+ else:
221+ yield host_str
222+
223+ def _iter_threads(self, thread_group_list):
224+ for thread_group in thread_group_list.itervalues():
225+ for thread in thread_group:
226+ yield thread
227+
228 @property
229 def target(self):
230- return self.job_data['target']
231+ return self.job_data.get('target')
232
233 @property
234 def image_type(self):
235- if self.job_data.has_key('image_type'):
236- return self.job_data['image_type']
237+ return self.job_data.get('image_type')
238+
239+ @property
240+ def target_type(self):
241+ return self.job_data.get('target_type')
242+
243+ @property
244+ def client_groups(self):
245+ return self.job_data.get('client_groups')
246
247 def run(self):
248- lava_commands = get_all_cmds()
249-
250 if self.job_data['actions'][-1]['command'] == 'submit_results':
251 submit_results = self.job_data['actions'].pop(-1)
252 else:
253 submit_results = None
254
255+ for cmd in self.job_data['actions']:
256+ target_groups = cmd.get('target_groups')
257+ if not target_groups:
258+ target_groups = {'default' : self.job_data['target']}
259+ params = cmd.get('parameters', {})
260+ metadata = cmd.get('metadata', {})
261+
262+ if cmd['command'] == 'sync_to_label':
263+ self.init_sync_label(target_groups, params)
264+
265+ for group_name in target_groups:
266+ for client_thread in self.client_group_list[group_name]:
267+ client_thread.add_action(self.lava_commands[cmd['command']],
268+ cmd['command'],
269+ params,
270+ metadata)
271+ thread_iter = self._iter_threads(self.client_group_list)
272+ for client_thread in thread_iter:
273+ client_thread.start()
274+ thread_iter = self._iter_threads(self.client_group_list)
275+ for client_thread in thread_iter:
276+ client_thread.join()
277+
278+ if submit_results:
279+ results_context = LavaContext(self)
280+ thread_iter = self._iter_threads(self.client_group_list)
281+ for client_thread in thread_iter:
282+ results_context.subcontext_list.append(client_thread.context)
283+
284+ params = submit_results.get('parameters', {})
285+ action = self.lava_commands[submit_results['command']](
286+ results_context)
287+ action.run(**params)
288+
289+ def init_sync_label(self, host_groups, params):
290+ label = params.get('label')
291+ if not label:
292+ print 'Job Error in sync_to_label action: No label'
293+ raise
294+
295+ groups = map(self.client_group_list.get, host_groups)
296+ size = reduce(add, map(len, groups))
297+ self.add_sync_label(label, size)
298+
299+
300+class LavaContext(object):
301+ def __init__(self, sync_master):
302+ self.test_data = LavaTestData()
303+ self.sync_master = sync_master
304+ self.subcontext_list = []
305+
306+ @property
307+ def client(self):
308+ return self._client
309+
310+
311+class ActionThunk(object):
312+ def __init__(self, action, params, metadata, action_name):
313+ self.action = action
314+ self.params = params
315+ self.metadata = metadata
316+ self.action_name = action_name
317+
318+ def run(self):
319+ self.action.context.test_data.add_metadata(self.metadata)
320+ self.action.run(**self.params)
321+
322+
323+class ClientThread(Thread):
324+ def __init__(self, hostname, image_type, target_type, sync_master):
325+ super(ClientThread, self).__init__()
326+ self.action_list = []
327+ self.context = LavaContext(sync_master)
328+ if image_type == "android":
329+ self.context.client_class = LavaAndroidClient
330+ self.context.image_type = image_type
331+ self.context.hostname = hostname
332+ else:
333+ # conmux / serial
334+ self.context.client_class = LavaClient
335+ self.context.hostname = hostname
336+
337+ def add_action(self, action_class, action_name, params, metadata):
338+ action = action_class(self.context)
339+ self.action_list.append(ActionThunk(action, params, metadata, action_name))
340+
341+ def run(self):
342+ self.context._client = self.context.client_class(self.context.hostname)
343+
344 try:
345- for cmd in self.job_data['actions']:
346- params = cmd.get('parameters', {})
347- metadata = cmd.get('metadata', {})
348- metadata['target.hostname'] = self.target
349- self.context.test_data.add_metadata(metadata)
350- action = lava_commands[cmd['command']](self.context)
351+ for action in self.action_list:
352 try:
353 status = 'fail'
354- action.run(**params)
355+ action.run()
356 except CriticalError, err:
357- raise err
358+ raise
359 except (pexpect.TIMEOUT, GeneralError), err:
360 pass
361 except Exception, err:
362@@ -77,45 +211,28 @@
363 else:
364 status = 'pass'
365 finally:
366+ err_msg = ""
367+ command = action.action_name
368 if status == 'fail':
369- err_msg = "Lava failed at action " + cmd['command'] \
370- + " with error: " + str(err) + "\n"
371- if cmd['command'] == 'lava_test_run':
372- err_msg = err_msg + "Lava failed with test: " \
373- + test_name
374+ err_msg = "Lava failed at action %s with error: %s\n" %\
375+ (command, str(err))
376+ if command == 'lava_test_run':
377+ err_msg += "Lava failed on test: %s" %\
378+ action.params.get('test_name')
379 exc_type, exc_value, exc_traceback = sys.exc_info()
380- err_msg = err_msg + repr(traceback.format_tb(exc_traceback))
381+ err_msg += repr(traceback.format_tb(exc_traceback))
382 print >> sys.stderr, err_msg
383- else:
384- err_msg = ""
385- self.context.test_data.add_result(cmd['command'],
386+ self.context.test_data.add_result(action.action_name,
387 status, err_msg)
388 except:
389- #Capture all user-defined and non-user-defined critical errors
390+ # Capture all user-defined and non-user-defined critical errors
391+ # Do not raise--allow thread to exit gracefully
392+ print "Exception in thread for %s. "\
393+ "Exiting thread." % self.context.hostname
394 self.context.test_data.job_status='fail'
395- raise
396- finally:
397- if submit_results:
398- params = submit_results.get('parameters', {})
399- action = lava_commands[submit_results['command']](
400- self.context)
401- action.run(**params)
402-
403-
404-class LavaContext(object):
405- def __init__(self, target, image_type):
406- if image_type != "android":
407- self._client = LavaClient(target)
408- else:
409- self._client = LavaAndroidClient(target)
410- self.test_data = LavaTestData()
411-
412- @property
413- def client(self):
414- return self._client
415-
416
417 class LavaTestData(object):
418+
419 def __init__(self, test_id='lava'):
420 self.job_status = 'pass'
421 self.metadata = {}
422
423=== modified file 'lava_dispatcher/actions/launch_control.py'
424--- lava_dispatcher/actions/launch_control.py 2011-06-27 04:55:08 +0000
425+++ lava_dispatcher/actions/launch_control.py 2011-07-22 22:31:22 +0000
426@@ -32,8 +32,8 @@
427 class cmd_submit_results_on_host(BaseAction):
428 def run(self, server, stream):
429 xmlrpc_url = "%s/xml-rpc/" % server
430- srv = xmlrpclib.ServerProxy(xmlrpc_url,
431- allow_none=True, use_datetime=True)
432+ srv = xmlrpclib.ServerProxy(xmlrpc_url, allow_none=True,
433+ use_datetime=True)
434
435 client = self.client
436 call("cd /tmp/%s/; ls *.bundle > bundle.lst" % LAVA_RESULT_DIR,
437@@ -68,16 +68,7 @@
438 class cmd_submit_results(BaseAction):
439 all_bundles = []
440
441- def run(self, server, stream, result_disk="testrootfs"):
442- """Submit test results to a launch-control server
443- :param server: URL of the launch-control server
444- :param stream: Stream on the launch-control server to save the result to
445- """
446- #Create l-c server connection
447- xmlrpc_url = "%s/xml-rpc/" % server
448- srv = xmlrpclib.ServerProxy(xmlrpc_url,
449- allow_none=True, use_datetime=True)
450-
451+ def retrieve_remote_results(self, client):
452 client = self.client
453 try:
454 self.in_master_shell()
455@@ -133,17 +124,60 @@
456
457 self.all_bundles.append(json.loads(content))
458
459+ def run(self, server, stream, result_disk="testrootfs", skip_remote=False):
460+ """Submit test results to a launch-control server
461+ :param server: URL of the launch-control server
462+ :param stream: Stream on the launch-control server to save the result to
463+ """
464+ #Create l-c server connection
465+ xmlrpc_url = "%s/xml-rpc/" % server
466+ srv = xmlrpclib.ServerProxy(xmlrpc_url,
467+ allow_none=True, use_datetime=True)
468+
469 main_bundle = self.combine_bundles()
470- self.context.test_data.add_seriallog(
471- self.context.client.get_seriallog())
472- main_bundle['test_runs'].append(self.context.test_data.get_test_run())
473- for test_run in main_bundle['test_runs']:
474- attributes = test_run.get('attributes',{})
475- attributes.update(self.context.test_data.get_metadata())
476- test_run['attributes'] = attributes
477+ main_test_runs = main_bundle['test_runs']
478+ counter=0
479+
480+ if len(self.context.subcontext_list) > 0:
481+ for context in self.context.subcontext_list:
482+ if not skip_remote:
483+ self.retrieve_remote_results(context.client)
484+
485+ client_bundle = {
486+ "test_runs": [],
487+ "format": "Dashboard Bundle Format 1.2"
488+ }
489+ context.test_data.add_seriallog(
490+ context.client.get_seriallog())
491+ client_bundle['test_runs'].append(context.test_data.get_test_run())
492+ for test_run in client_bundle['test_runs']:
493+ attributes = test_run.get('attributes',{})
494+ attributes.update(context.test_data.get_metadata())
495+ test_run['attributes'] = attributes
496+ if len(main_test_runs) == 0:
497+ main_test_runs.append(test_run.copy())
498+ main_test_runs[0]['test_results'] = []
499+ main_test_runs[0]['attachments'] = []
500+ self.merge_thread_run(main_test_runs,
501+ test_run,
502+ context.client.hostname,
503+ counter)
504+ counter += 1
505 json_bundle = json.dumps(main_bundle)
506 srv.put(json_bundle, 'lava-dispatcher.bundle', stream)
507
508+ def merge_thread_run(self, main_run, new_run, hostname, serial_number):
509+ for test_case in new_run['test_results']:
510+ new_id = "%s_%s_%d" % (hostname, test_case['test_case_id'], serial_number)
511+ new_test_case = test_case
512+ new_test_case['test_case_id'] = new_id
513+ main_run[0]['test_results'].append(new_test_case)
514+
515+ for attachment in new_run['attachments']:
516+ new_attachment = attachment
517+ new_attachment['pathname'] = "%s_%s_%d" % (hostname, attachment['pathname'], serial_number)
518+ main_run[0]['attachments'].append(new_attachment)
519+
520 def combine_bundles(self):
521 if not self.all_bundles:
522 return {
523
524=== added file 'lava_dispatcher/actions/shell_commands.py'
525--- lava_dispatcher/actions/shell_commands.py 1970-01-01 00:00:00 +0000
526+++ lava_dispatcher/actions/shell_commands.py 2011-07-22 22:31:22 +0000
527@@ -0,0 +1,42 @@
528+# Copyright (C) 2011 Calxeda, Inc.
529+#
530+# This file is part of LAVA Dispatcher.
531+#
532+# LAVA Dispatcher is free software; you can redistribute it and/or modify
533+# it under the terms of the GNU General Public License as published by
534+# the Free Software Foundation; either version 2 of the License, or
535+# (at your option) any later version.
536+#
537+# LAVA Dispatcher is distributed in the hope that it will be useful,
538+# but WITHOUT ANY WARRANTY; without even the implied warranty of
539+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
540+# GNU General Public License for more details.
541+#
542+# You should have received a copy of the GNU General Public License
543+# along with this program; if not, see <http://www.gnu.org/licenses>.
544+
545+from lava_dispatcher.actions import BaseAction
546+from lava_dispatcher.client import OperationFailed
547+from lava_dispatcher.config import MASTER_STR
548+
549+class cmd_scp(BaseAction):
550+
551+ def run(self, username, host, filename, target_path):
552+ client = self.client
553+ cmd = 'scp %s %s@%s:%s' % (filename, username, host, target_path)
554+ client.run_shell_command(cmd, MASTER_STR)
555+ # TODO XXX: This does not handle authentication issues (password,
556+ # new SSH key, changed SSH key, etc.)
557+
558+
559+# TODO XXX: This class should derive from TestAction, as defined in
560+# the result-reporting branch, in order to report pass/fail results
561+#based on pattern matching
562+class cmd_verify_file_present(BaseAction):
563+
564+# fail_patterns = ['No such file or directory']
565+
566+ def run(self, file):
567+ client = self.client
568+ cmd = 'ls %s' % (file)
569+ client.run_shell_command(cmd, MASTER_STR)
570
571=== added file 'lava_dispatcher/actions/sync_to_label.py'
572--- lava_dispatcher/actions/sync_to_label.py 1970-01-01 00:00:00 +0000
573+++ lava_dispatcher/actions/sync_to_label.py 2011-07-22 22:31:22 +0000
574@@ -0,0 +1,22 @@
575+# Copyright (C) 2011 Calxeda, Inc.
576+#
577+# This file is part of LAVA Dispatcher.
578+#
579+# LAVA Dispatcher is free software; you can redistribute it and/or modify
580+# it under the terms of the GNU General Public License as published by
581+# the Free Software Foundation; either version 2 of the License, or
582+# (at your option) any later version.
583+#
584+# LAVA Dispatcher is distributed in the hope that it will be useful,
585+# but WITHOUT ANY WARRANTY; without even the implied warranty of
586+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
587+# GNU General Public License for more details.
588+#
589+# You should have received a copy of the GNU General Public License
590+# along with this program; if not, see <http://www.gnu.org/licenses>.
591+
592+from lava_dispatcher.actions import BaseAction
593+
594+class cmd_sync_to_label(BaseAction):
595+ def run(self, label, block):
596+ self.context.sync_master.sync_to(label, block)
597
598=== added file 'lava_dispatcher/sync.py'
599--- lava_dispatcher/sync.py 1970-01-01 00:00:00 +0000
600+++ lava_dispatcher/sync.py 2011-07-22 22:31:22 +0000
601@@ -0,0 +1,47 @@
602+# Copyright (C) 2011 Calxeda, Inc.
603+#
604+# This file is part of LAVA Dispatcher.
605+#
606+# LAVA Dispatcher is free software; you can redistribute it and/or modify
607+# it under the terms of the GNU General Public License as published by
608+# the Free Software Foundation; either version 2 of the License, or
609+# (at your option) any later version.
610+#
611+# LAVA Dispatcher is distributed in the hope that it will be useful,
612+# but WITHOUT ANY WARRANTY; without even the implied warranty of
613+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
614+# GNU General Public License for more details.
615+#
616+# You should have received a copy of the GNU General Public License
617+# along with this program; if not, see <http://www.gnu.org/licenses>.
618+
619+from threading import Event, Lock
620+
621+class SyncMaster(object):
622+ def __init__(self):
623+ self.eventlist_lock = Lock()
624+ self.eventlist = dict()
625+
626+ class EventCount(object):
627+ def __init__(self):
628+ self.count = 0
629+ self.event = Event()
630+
631+ def add_sync_label(self, label, count):
632+ if not self.eventlist.get(label):
633+ self.eventlist[label] = self.EventCount()
634+
635+ self.eventlist[label].count += count
636+
637+ def sync_to(self, label, block):
638+ self.eventlist_lock.acquire()
639+ try:
640+ self.eventlist[label].count -= 1
641+ if self.eventlist[label].count == 0:
642+ self.eventlist[label].event.set()
643+ finally:
644+ self.eventlist_lock.release()
645+ if block:
646+ self.eventlist[label].event.wait()
647+
648+
649\ No newline at end of file
650
651=== modified file 'lava_dispatcher/utils.py'
652--- lava_dispatcher/utils.py 2011-06-27 04:55:08 +0000
653+++ lava_dispatcher/utils.py 2011-07-22 22:31:22 +0000
654@@ -22,6 +22,7 @@
655 import shutil
656 import urllib2
657 import urlparse
658+from IPy import IP
659
660 from lava_dispatcher.config import LAVA_CACHEDIR
661
662@@ -66,3 +67,10 @@
663 path = os.path.join(LAVA_CACHEDIR, url_parts.netloc,
664 url_parts.path.lstrip(os.sep))
665 return path
666+
667+def ip_addr_range(a, b):
668+ int_a = IP(a).int()
669+ int_b = IP(b).int()
670+
671+ return [IP(x) for x in range(int_a, int_b + 1)]
672+

Subscribers

People subscribed via source and target branches