class TestMultiDict(TestCase): def setUp(self): super(TestMultiDict, self).setUp() self.index = MultiDict() def test_add_single(self): self.index.add("k", "v") self.assertTrue(self.index.contains("k", "v")) self.assertEqual(set(self.index.iter_values("k")), set(["v"])) def test_add_remove_single(self): self.index.add("k", "v") self.index.discard("k", "v") self.assertFalse(self.index.contains("k", "v")) self.assertEqual(self.index._index, {}) def test_empty(self): self.assertFalse(bool(self.index)) self.assertEqual(self.index.num_items("k"), 0) self.assertEqual(list(self.index.iter_values("k")), []) def test_add_multiple(self): self.index.add("k", "v") self.assertTrue(bool(self.index)) self.assertEqual(self.index.num_items("k"), 1) self.index.add("k", "v") self.assertEqual(self.index.num_items("k"), 1) self.index.add("k", "v2") self.assertEqual(self.index.num_items("k"), 2) self.index.add("k", "v3") self.assertEqual(self.index.num_items("k"), 3) self.assertIn("k", self.index) self.assertNotIn("k2", self.index) self.assertTrue(self.index.contains("k", "v")) self.assertTrue(self.index.contains("k", "v2")) self.assertTrue(self.index.contains("k", "v3")) self.assertEqual(self.index._index, {"k": set(["v", "v2", "v3"])}) self.assertEqual(set(self.index.iter_values("k")), set(["v", "v2", "v3"])) self.index.discard("k", "v") self.index.discard("k", "v2") self.assertTrue(self.index.contains("k", "v3")) self.index.discard("k", "v3") self.assertEqual(self.index._index, {})
class LinearScanLabelIndex(object): """ A label index matches a set of SelectorExpressions against a set of label dicts. As the matches between the two collections change, it triggers calls to on_match_started/on_match_stopped. LabelNode dicts are identified by their "item_id", which is an opaque (hashable) ID for the item that the labels apply to. Similarly, selector expressions are identified by an opaque expr_id. A simple implementation of a label index. Every update is handled by a full linear scan. This class has a few purposes: - it provides a benchmark against which other implementations can be measured - since it's simple, it's useful for comparative testing; any other label index implementation should give the same end result. - it's a base class for more advanced implementations. """ def __init__(self): # Cache of raw label dicts by item_id. self.labels_by_item_id = {} # All expressions by ID. self.expressions_by_id = {} # Map from expression ID to matching item_ids. self.matches_by_expr_id = MultiDict() self.matches_by_item_id = MultiDict() def on_expression_update(self, expr_id, expr): """ Called to update a particular expression. Triggers events for match changes. :param expr_id: an opaque (hashable) ID to associate with the expression. There can only be one expression per ID. :param expr: The SelectorExpression to add to the index or None to remove it. """ _log.debug("Expression %s updated to %s", expr_id, expr) self._scan_all_labels(expr_id, expr) self._store_expression(expr_id, expr) def on_labels_update(self, item_id, new_labels): """ Called to update a particular set of labels. Triggers events for match changes. :param item_id: an opaque (hashable) ID to associate with the labels. There can only be one set of labels per ID. :param new_labels: The labels dict to add to the index or None to remove it. """ _log.debug("Labels for %s now %s", item_id, new_labels) self._scan_all_expressions(item_id, new_labels) self._store_labels(item_id, new_labels) def _scan_all_labels(self, expr_id, expr): """ Check the given expression against all label dicts and emit events for changes in the matching labels. """ _log.debug("Doing full label scan against expression %s", expr_id) for item_id, label_values in self.labels_by_item_id.iteritems(): self._update_matches(expr_id, expr, item_id, label_values) def _scan_all_expressions(self, item_id, new_labels): """ Check the given labels against all expressions and emit events for changes in the matching labels. """ _log.debug("Doing full expression scan against item %s", item_id) for expr_id, expr in self.expressions_by_id.iteritems(): self._update_matches(expr_id, expr, item_id, new_labels) def _store_expression(self, expr_id, expr): """Updates expressions_by_id with the new value for an expression.""" if expr is not None: self.expressions_by_id[expr_id] = expr else: self.expressions_by_id.pop(expr_id, None) def _store_labels(self, item_id, new_labels): """Updates labels_by_item_id with the new labels for an item.""" if new_labels is not None: self.labels_by_item_id[item_id] = new_labels else: self.labels_by_item_id.pop(item_id, None) def _update_matches(self, expr_id, expr, item_id, label_values): """ (Re-)evaluates the given expression against the given labels and stores the result. """ _log.debug("Re-evaluating %s against %s (%s)", expr_id, item_id, label_values) if expr is not None and label_values is not None: now_matches = expr.evaluate(label_values) _log.debug("After evaluation, now matches: %s", now_matches) else: _log.debug("Expr or labels missing: no match") now_matches = False # Update the index and generate events. These methods are idempotent # so they'll ignore duplicate updates. if now_matches: self._store_match(expr_id, item_id) else: self._discard_match(expr_id, item_id) def _store_match(self, expr_id, item_id): """ Stores that an expression matches an item. Calls on_match_started() as a side-effect. Idempotent, does nothing if the match is already recorded. """ previously_matched = self.matches_by_expr_id.contains(expr_id, item_id) if not previously_matched: _log.debug("%s now matches: %s", expr_id, item_id) self.matches_by_expr_id.add(expr_id, item_id) self.matches_by_item_id.add(item_id, expr_id) self.on_match_started(expr_id, item_id) def _discard_match(self, expr_id, item_id): """ Stores that an expression does not match an item. Calls on_match_stopped() as a side-effect. Idempotent, does nothing if the non-match is already recorded. """ previously_matched = self.matches_by_expr_id.contains(expr_id, item_id) if previously_matched: _log.debug("%s no longer matches %s", expr_id, item_id) self.matches_by_expr_id.discard(expr_id, item_id) self.matches_by_item_id.discard(item_id, expr_id) self.on_match_stopped(expr_id, item_id) def on_match_started(self, expr_id, item_id): """ Called when an expression starts matching a particular set of labels. Intended to be assigned/overriden. """ _log.debug("SelectorExpression %s now matches item %s", expr_id, item_id) def on_match_stopped(self, expr_id, item_id): """ Called when an expression stops matching a particular set of labels. Intended to be assigned/overriden. """ _log.debug("SelectorExpression %s no longer matches item %s", expr_id, item_id)