Skip to content

Commit b57bb18

Browse files
committed
Added ability to filter Vagrant's output for data
In the output from `vagrant up` is information that could be critical to programatically interfacing with the machine once it's running. This commit adds a new parameter to `Vagrant.up()` called `output_filter`, which allows the user to specify a set of regular expressions to look for in the command's output, and which is returned as a dict. The user-specified output context manager (given when creating the Vagrant instance) will still continue to receive the output of the command as well.
1 parent 790e6ee commit b57bb18

File tree

2 files changed

+135
-4
lines changed

2 files changed

+135
-4
lines changed

tests/test_vagrant.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,41 @@ def test_multivm_config():
520520
eq_(keyfile, parsed_config["IdentityFile"].lstrip('"').rstrip('"'))
521521

522522

523+
@with_setup(make_setup_vm(), teardown_vm)
524+
def test_output_filter():
525+
"""
526+
Test output filters with good input.
527+
"""
528+
v = vagrant.Vagrant(TD, out_cm=vagrant.stdout_cm)
529+
of = {'string_pat': {'pat': r"Importing base box '(\S+)'",
530+
'group': 1},
531+
'compiled_pat': {'pat': re.compile(r'22 \(guest\) => (\d+) \(host\)'),
532+
'group': 1}
533+
}
534+
matches = v.up(output_filter=of)
535+
536+
assert matches['string_pat'] == TEST_BOX_NAME
537+
538+
assert matches['compiled_pat'] is not None # Usually '2222', but may vary depending on the test env
539+
540+
541+
@with_setup(make_setup_vm(), teardown_vm)
542+
def test_output_filter_fail():
543+
"""
544+
Test output filters with bad input.
545+
"""
546+
v = vagrant.Vagrant(TD, out_cm=vagrant.stdout_cm)
547+
of = {'failing_test': {'pat': ['invalid', 'regex', 'pattern'],
548+
'group': 0}
549+
}
550+
try:
551+
v.up(output_filter=of)
552+
except TypeError:
553+
pass
554+
else:
555+
assert False
556+
557+
523558
def test_make_file_cm():
524559
filename = os.path.join(TD, 'test.log')
525560
if os.path.exists(filename):

vagrant/__init__.py

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def init(self, box_name=None, box_url=None):
300300
self._call_vagrant_command(['init', box_name, box_url])
301301

302302
def up(self, no_provision=False, provider=None, vm_name=None,
303-
provision=None, provision_with=None):
303+
provision=None, provision_with=None, output_filter=None):
304304
'''
305305
Launch the Vagrant box.
306306
vm_name=None: name of VM.
@@ -323,16 +323,23 @@ def up(self, no_provision=False, provider=None, vm_name=None,
323323
no_provision_arg = '--no-provision' if no_provision else None
324324
provision_arg = None if provision is None else '--provision' if provision else '--no-provision'
325325

326-
self._call_vagrant_command(['up', vm_name, no_provision_arg,
327-
provision_arg, provider_arg,
328-
prov_with_arg, providers_arg])
326+
args = ['up', vm_name, no_provision_arg, provision_arg, provider_arg, prov_with_arg, providers_arg]
327+
filter_results = None
328+
if isinstance(output_filter, dict):
329+
filter_results = self._filter_vagrant_command(args, output_filter)
330+
else:
331+
self._call_vagrant_command(args)
332+
329333
try:
330334
self.conf(vm_name=vm_name) # cache configuration
331335
except subprocess.CalledProcessError:
332336
# in multi-VM environments, up() can be used to start all VMs,
333337
# however vm_name is required for conf() or ssh_config().
334338
pass
335339

340+
if filter_results:
341+
return filter_results
342+
336343
def provision(self, vm_name=None, provision_with=None):
337344
'''
338345
Runs the provisioners defined in the Vagrantfile.
@@ -944,6 +951,95 @@ def _run_vagrant_command(self, args):
944951
return compat.decode(subprocess.check_output(command, cwd=self.root,
945952
env=self.env, stderr=err_fh))
946953

954+
def _filter_vagrant_command(self, args, output_filter):
955+
"""Execute the Vagrant command, return matches to the output filters.
956+
957+
Output filter must have the following form:
958+
{
959+
'filter_name': {'pat': r'regex pattern',
960+
'group': <int group number to return>},
961+
...
962+
}
963+
964+
The `group` key is actually optional, but defaults to 1 when omitted.
965+
966+
:param args: Arguments for the Vagrant command.
967+
:param output_filter: Dictionary of output filters.
968+
:type output_filter: dict
969+
:return: Dictionary of results with the following form:
970+
{'filter_name': 'matching result', ...}. If no match is found, the
971+
value will be None.
972+
:rtype: dict
973+
"""
974+
assert isinstance(output_filter, dict)
975+
if sys.version_info > (3, 0):
976+
py3 = True
977+
else:
978+
py3 = False
979+
980+
# Create dictionary that will store the results from the filters
981+
filter_results = dict.fromkeys(output_filter)
982+
983+
for f in output_filter.keys():
984+
# Check the filter values, compile as regular expression objects if necessary
985+
if not isinstance(output_filter[f]['pat'], type(re.compile(''))):
986+
try:
987+
output_filter[f]['pat'] = re.compile(output_filter[f]['pat'])
988+
except TypeError:
989+
raise TypeError('Output filters must have either a compiled regular expression or a regular '
990+
'expression string stored in key ["pat"], got: {}'.
991+
format(type(output_filter[f]['pat'])))
992+
993+
# Check that the group is an int, set to 1 if omitted
994+
if output_filter[f].get('group') is None:
995+
output_filter[f]['group'] = 1
996+
elif not isinstance(output_filter[f]['group'], int):
997+
raise TypeError('For output filters, value for key `group` must be an int, got {}'.
998+
format(type(output_filter[f]['group'])))
999+
1000+
# Make subprocess command
1001+
command = self._make_vagrant_command(args)
1002+
1003+
# Don't override the user-specified output and error context managers
1004+
with self.out_cm() as out_fh, self.err_cm() as err_fh:
1005+
sp_args = dict(args=command, cwd=self.root, env=self.env,
1006+
stdout=subprocess.PIPE, stderr=err_fh, bufsize=1)
1007+
1008+
# Parse command output for the specified filters. Method used depends on version of Python.
1009+
# See http://stackoverflow.com/questions/2715847/python-read-streaming-input-from-subprocess-communicate#17698359
1010+
if not py3: # Python < 3.0
1011+
p = subprocess.Popen(**sp_args)
1012+
with p.stdout:
1013+
for line in iter(p.stdout.readline, b''):
1014+
pop_key = None
1015+
for f in output_filter.keys():
1016+
m = re.search(output_filter[f]['pat'], line)
1017+
if m:
1018+
filter_results[f] = m.group(output_filter[f]['group'])
1019+
# No need to search for this pattern again in future lines
1020+
pop_key = f
1021+
break
1022+
if pop_key:
1023+
output_filter.pop(pop_key)
1024+
out_fh.write(line)
1025+
p.wait()
1026+
else: # Python 3.0+
1027+
with subprocess.Popen(**sp_args) as p:
1028+
for line in p.stdout:
1029+
pop_key = None
1030+
for f in output_filter.keys():
1031+
m = re.search(output_filter[f]['pat'], line)
1032+
if m:
1033+
filter_results[f] = m.group(output_filter[f]['group'])
1034+
# No need to search for this pattern again in future lines
1035+
pop_key = f
1036+
break
1037+
if pop_key:
1038+
output_filter.pop(pop_key)
1039+
out_fh.write(line)
1040+
1041+
return filter_results
1042+
9471043

9481044
class SandboxVagrant(Vagrant):
9491045
'''

0 commit comments

Comments
 (0)