config.py: add command line options for querying the config table.

For example:
 $ ./config has_moca -kname,ether,ip6,cpu,is_tv
 t0-2,42:42:42:42:42:12,fe80::4042:42ff:fe42:4212,BCM7425B0,1
 t0-3,42:21:06:66:aa:03,fe80::4021:6ff:fe66:aa03,BCM7425B0,1
 b2-1,00:1a:11:30:63:77,fe80::21a:11ff:fe30:6377,BCM7425B2,
 t0-4,00:03:42:21:03:69,fe80::203:42ff:fe21:369,BCM7425B0,1

Change-Id: I54679b19ea9c4ac5aee72312e539d9e441b2811f
diff --git a/Makefile b/Makefile
index 02ee80b..e3d52a3 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@
 lint:
 	gpylint *.py configs/core.py
 
-test: install-wvtest
+test:
 	wvtest/wvtestrun $(MAKE) runtests
 
 #TODO(apenwarr): use a smarter allocator.
@@ -18,22 +18,26 @@
 # looping through them one by one.  But then we end up running multiple
 # tests on the same device at once, which would cause conflicts.  We need
 # a smarter allocator first.
-runtests:
+runtests: localtests install-wvtest
 	for d in $(addsuffix .run,$(wildcard [0-9]*.sh [0-9]*.py)); do \
 	  $(MAKE) $$d; \
 	done
 
-install-wvtest: map.stamp
-	tar -cf - *.py *.sh wvtest/ | \
+localtests:
+	./wvtest/wvtest.py *_test.py configs/*_test.py
+
+install-wvtest: map.stamp *.py *.sh wvtest/*
+	@echo 'Testing "install-wvtest" in Makefile:'
+	tar -cf - $^ | \
 	$(PORTSH) $(PORT) \
 		'mkdir -p /tmp/tests && cd /tmp/tests && tar -xf -'
 
-%.sh.run: %.sh install-wvtest
+%.sh.run: %.sh
 	echo "Testing $<"
 	$(PORTSH) $(PORT) \
 		'cd /tmp/tests && sh ./$<' </dev/null
 
-%.py.run: %.py install-wvtest
+%.py.run: %.py
 	echo "Testing $<"
 	$(PORTSH) $(PORT) \
 		'cd /tmp/tests && python ./wvtest/wvtest.py $<' </dev/null
diff --git a/config b/config
new file mode 120000
index 0000000..f85c6b1
--- /dev/null
+++ b/config
@@ -0,0 +1 @@
+config.py
\ No newline at end of file
diff --git a/config.py b/config.py
old mode 100644
new mode 100755
index 701d05c..9f65977
--- a/config.py
+++ b/config.py
@@ -3,13 +3,67 @@
 #
 """Test configurations.  Includes a list of auto-probed bruno devices."""
 
+import re
+import sys
 from configs import hosts
+from configs.core import Host
+from configs.core import QueryError
+from portsh import options
 
 __author__ = 'Avery Pennarun (apenwarr@google.com)'
 
 
+optspec = """
+config [options...] <bool|^bool|key=value>...
+--
+random     Choose random matches instead of the first ones
+max=       Return no more than max= matches
+min=       If less than min= matches, return an error code
+n,num=     Return exactly num= matches
+v,verbose  Display the entire config entry for each match, not just the name
+k,keys=    If -v isn't given, show these comma/space-separated attrs [name]
+"""
+
+
 def main():
-  print hosts
+  o = options.Options(optspec)
+  opt, _, extra = o.parse(sys.argv[1:])
+  if opt.num:
+    if opt.min or opt.max:
+      o.fatal("don't specify --num with --min/--max")
+    opt.min = opt.max = opt.num
+
+  kwargs = dict(is_alive=True)
+  for kvs in extra:
+    kv = kvs.split('=', 1) + [None]
+    key = kv[0]
+    value = kv[1]
+    if not hasattr(Host, key):
+      o.fatal('no key named %r; valid keys: %s'
+              % (key, ', '.join(Host.__slots__)))
+      sys.exit(1)
+    if key.startswith('^'):
+      kwargs[key[1:]] = False
+    elif value is not None:
+      kwargs[key] = value
+    else:
+      kwargs[key] = True
+  try:
+    matches = hosts.Query(mincount=opt.min, maxcount=opt.max,
+                          randomize=opt.random, **kwargs)
+  except QueryError, e:
+    o.fatal(str(e))
+  if opt.verbose:
+    for match in matches:
+      print match
+  else:
+    keys = re.split(r'[,\s]', opt.keys)
+    for key in keys:
+      if not hasattr(Host, key):
+        o.fatal('no key named %r; valid keys: %s'
+                % (key, ', '.join(Host.__slots__)))
+    for match in matches:
+      print ','.join(str(getattr(match, key) or '') for key in keys)
 
 
 if __name__ == '__main__':
diff --git a/configs/core.py b/configs/core.py
index 251c99a..42e17a1 100644
--- a/configs/core.py
+++ b/configs/core.py
@@ -6,6 +6,13 @@
 __author__ = 'Avery Pennarun (apenwarr@google.com)'
 
 
+import random
+
+
+class QueryError(Exception):
+  pass
+
+
 class AttrSet(object):
   """A simple set of key-value pairs represented as a class with members."""
 
@@ -27,6 +34,8 @@
       #gpylint: disable-msg=C6403
       if ival == False: ival = None
       if value == False: value = None
+      if value == True and ival:
+        return True  # any nonempty value is true, so match it
       if ival != value:
         return False
     return True
@@ -89,6 +98,35 @@
       h.name = h.ether or h.ip or h.ip6
     return h
 
+  def Query(self, mincount=None, maxcount=None, randomize=False, **kwargs):
+    """Query the hosts list according to the given parameters.
+
+    Args:
+      mincount: the minimum number of hosts to return.
+      maxcount: the maximum number of hosts to return.
+      randomize: return a random subset or the first maxcount matches.
+      **kwargs: key=value pairs to pass to hosts.FindAll().
+    Returns:
+      A list of matching Host objects.
+    Raises:
+      QueryError: if mincount > maxcount or other invalid options.
+    """
+    matches = self.FindAll(**kwargs)
+    if maxcount and maxcount < 0:
+      raise QueryError('max(%s) must be >= 0' % maxcount)
+    if maxcount and mincount > maxcount:
+      raise QueryError('min(%s) > max(%s)' % (mincount, maxcount))
+    if mincount > len(matches):
+      raise QueryError('not enough matches found (got=%d, wanted=%r)'
+                       % (len(matches), mincount))
+    if randomize:
+      random.shuffle(matches)
+    else:
+      matches.sort(key=lambda i: (i.name, i.serialno, i.ether, i.ip6, i.ip))
+    if maxcount is not None and len(matches) > maxcount:
+      matches = matches[:maxcount]
+    return matches
+
   def __str__(self):
     return '\n'.join(str(i) for i in self)
 
diff --git a/configs/core_test.py b/configs/core_test.py
new file mode 100644
index 0000000..db97834
--- /dev/null
+++ b/configs/core_test.py
@@ -0,0 +1,33 @@
+#!/usr/bin/python
+# Copyright 2012 Google Inc. All Rights Reserved.
+#
+# Disable some checks that aren't important for tests:
+#gpylint: disable-msg=E0602,C6409,C6111,C0111
+
+__author__ = 'Avery Pennarun (apenwarr@google.com)'
+
+from wvtest import *
+import core
+
+
+@wvtest
+def testConfigCore():
+  hosts = core.Hosts()
+  WVPASSEQ(len(hosts), 0)
+  h = hosts.FindOrAdd(ether='11:22:33:44:55:66')
+  h.Set(name='testname', ip='1.2.3.4')
+  h.platform = 'bob'
+  WVPASSEQ(len(hosts), 1)
+  h2 = hosts.FindOrAdd(ip='1.2.3.4')
+  WVPASSEQ(len(hosts), 1)
+  WVPASSEQ(h2.name, 'testname')
+  WVPASSEQ(h2.ether, '11:22:33:44:55:66')
+  WVPASSEQ(h, h2)
+
+  # test Host.Query()
+  WVPASSEQ(hosts.Query(ip=True), [h])
+  WVPASSEQ(hosts.Query(ip=False), [])
+  WVPASSEQ(hosts.Query(ip='1.2.3.4'), [h])
+  WVPASSEQ(hosts.Query(ip='1.2.3.5'), [])
+  WVPASSEQ(hosts.Query(ip='1.2.3.4', maxcount=0), [])
+  WVEXCEPT(core.QueryError, hosts.Query, ip='1.2.3.5', mincount=1)
diff --git a/portsh/__init__.py b/portsh/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/portsh/__init__.py