From 07e3b7cf267b01a19fe176b4063ed18b155dcad1 Mon Sep 17 00:00:00 2001 From: Jordan Matelsky Date: Mon, 6 Jun 2022 14:01:16 -0400 Subject: [PATCH] Add edge aliasing and edge constraints (#119) * Add edge aliases to the language * Centralize all constraint processing and defer to end of transform * Add some type and edge-case cleanup * Add tests for named edge constraints * Add simple-graph edge attribute matching * Add tests for in-memory graph executors * Add cypher syntax generation for dynamic edge constraints * Add support for quoted attribute names --- dotmotif/__init__.py | 8 +- dotmotif/executors/GrandIsoExecutor.py | 7 +- dotmotif/executors/Neo4jExecutor.py | 22 +++ dotmotif/executors/NetworkXExecutor.py | 71 ++++++++-- dotmotif/executors/test_dm_cypher.py | 16 +++ dotmotif/executors/test_grandisoexecutor.py | 135 ++++++++++++++++++ dotmotif/executors/test_networkxexecutor.py | 106 ++++++++++++++ dotmotif/parsers/v2/__init__.py | 146 +++++++++++++++++--- dotmotif/parsers/v2/grammar.lark | 104 +++++++------- dotmotif/parsers/v2/test_v2_parser.py | 97 +++++++++++++ setup.py | 2 +- 11 files changed, 623 insertions(+), 91 deletions(-) diff --git a/dotmotif/__init__.py b/dotmotif/__init__.py index 26c349d..874ad9f 100644 --- a/dotmotif/__init__.py +++ b/dotmotif/__init__.py @@ -29,13 +29,7 @@ from .executors.NetworkXExecutor import NetworkXExecutor from .executors.GrandIsoExecutor import GrandIsoExecutor -try: - # For backwards compatibility: - from .executors.Neo4jExecutor import Neo4jExecutor -except ImportError: - pass - -__version__ = "0.11.0" +__version__ = "0.12.0" DEFAULT_MOTIF_PARSER = ParserV2 diff --git a/dotmotif/executors/GrandIsoExecutor.py b/dotmotif/executors/GrandIsoExecutor.py index 40d1d0f..33fd48f 100644 --- a/dotmotif/executors/GrandIsoExecutor.py +++ b/dotmotif/executors/GrandIsoExecutor.py @@ -93,14 +93,15 @@ def _node_attr_match_fn( else self._validate_multigraph_any_edge_constraints ) ) + _edge_dynamic_constraint_validator = self._validate_dynamic_edge_constraints results = [] for r in graph_matches: if _doesnt_have_any_of_motifs_negative_edges(r) and ( _edge_constraint_validator(r, self.graph, motif.list_edge_constraints()) - # and self._validate_node_constraints( - # r, self.graph, motif.list_node_constraints() - # ) + and _edge_dynamic_constraint_validator( + r, self.graph, motif.list_dynamic_edge_constraints() + ) and self._validate_dynamic_node_constraints( r, self.graph, motif.list_dynamic_node_constraints() ) diff --git a/dotmotif/executors/Neo4jExecutor.py b/dotmotif/executors/Neo4jExecutor.py index 23649ee..5c6eb0c 100644 --- a/dotmotif/executors/Neo4jExecutor.py +++ b/dotmotif/executors/Neo4jExecutor.py @@ -470,6 +470,28 @@ def motif_to_cypher( ) ) + # Dynamic edge constraints: + # Constraints are of the form: + # {('A', 'B'): {'weight': {'==': ['A', 'C', 'weight']}}} + for (u, v), constraints in motif.list_dynamic_edge_constraints().items(): + for this_attr, ops in constraints.items(): + for op, (that_u, that_v, that_attr) in ops.items(): + this_edge_name = edge_mapping[(u, v)] + that_edge_name = edge_mapping[(that_u, that_v)] + cypher_edge_constraints.append( + ( + "NOT ({}[{}] {} {}[{}])" + if _operator_negation_infix(op) + else "{}[{}] {} {}[{}]" + ).format( + this_edge_name, + _quoted_if_necessary(this_attr), + _remapped_operator(op), + that_edge_name, + _quoted_if_necessary(that_attr), + ) + ) + conditions.extend([*cypher_node_constraints, *cypher_edge_constraints]) if count_only: diff --git a/dotmotif/executors/NetworkXExecutor.py b/dotmotif/executors/NetworkXExecutor.py index b9ffd78..ae52965 100644 --- a/dotmotif/executors/NetworkXExecutor.py +++ b/dotmotif/executors/NetworkXExecutor.py @@ -1,5 +1,5 @@ """ -Copyright 2021 The Johns Hopkins University Applied Physics Laboratory. +Copyright 2022 The Johns Hopkins University Applied Physics Laboratory. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -61,7 +61,9 @@ def _edge_satisfies_constraints(edge_attributes: dict, constraints: dict) -> boo return True -def _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attributes: dict, constraints: dict) -> List[Tuple[str, str, str]]: +def _edge_satisfies_many_constraints_for_muligraph_any_edges( + edge_attributes: dict, constraints: dict +) -> List[Tuple[str, str, str]]: """ Returns a subset of constraints that this edge matches, in the form (key, op, val). """ @@ -82,6 +84,7 @@ def _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attributes: di matched_constraints.append((key, operator, value)) return matched_constraints + def _node_satisfies_constraints(node_attributes: dict, constraints: dict) -> bool: """ Check if a single node satisfies the constraints. @@ -134,7 +137,10 @@ def __init__(self, **kwargs) -> None: if self.graph.is_multigraph(): self._host_is_multigraph = True self._multigraph_edge_match = kwargs.get("multigraph_edge_match", "any") - assert self._multigraph_edge_match in ("all", "any"), "_multigraph_edge_match must be one of 'all' or 'any'." + assert self._multigraph_edge_match in ( + "all", + "any", + ), "_multigraph_edge_match must be one of 'all' or 'any'." def _validate_node_constraints( self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict @@ -235,6 +241,45 @@ def _validate_edge_constraints( return False return True + def _validate_dynamic_edge_constraints( + self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict + ): + """ + Validate all edge constraints on a subgraph. + + Constraints are of the form: + + {('A', 'B'): {'weight': {'==': ['A', 'C', 'weight']}}} + + Arguments: + node_isomorphism_map (dict[nodename:str, nodeID:str]): A mapping of + node names to node IDs (where name comes from the motif and the + ID comes from the haystack graph). + graph (nx.DiGraph): The haystack graph + constraints (dict[(motif_u, motif_v), dict[operator, value]]): Map + of constraints on the MOTIF node names. + + Returns: + bool: If the isomorphism satisfies the edge constraints + + """ + for (motif_U, motif_V), constraint_list in constraints.items(): + for (this_attr, ops) in constraint_list.items(): + for op, (that_u, that_v, that_attr) in ops.items(): + this_graph_u = node_isomorphism_map[motif_U] + this_graph_v = node_isomorphism_map[motif_V] + that_graph_u = node_isomorphism_map[that_u] + that_graph_v = node_isomorphism_map[that_v] + this_edge_attr = graph.get_edge_data( + this_graph_u, this_graph_v + ).get(this_attr) + that_edge_attr = graph.get_edge_data( + that_graph_u, that_graph_v + ).get(that_attr) + if not _OPERATORS[op](this_edge_attr, that_edge_attr): + return False + return True + def _validate_multigraph_all_edge_constraints( self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict ): @@ -283,7 +328,11 @@ def _validate_multigraph_any_edge_constraints( # Check each edge in graph for constraints constraint_list_copy = copy.deepcopy(constraint_list) for _, _, edge_attrs in graph.edges((graph_u, graph_v), data=True): - matched_constraints = _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attrs, constraint_list_copy) + matched_constraints = ( + _edge_satisfies_many_constraints_for_muligraph_any_edges( + edge_attrs, constraint_list_copy + ) + ) if matched_constraints: # Remove matched constraints from the list for constraint in matched_constraints: @@ -302,7 +351,6 @@ def _validate_multigraph_any_edge_constraints( return True - def count(self, motif: "dotmotif.Motif", limit: int = None): """ Count the occurrences of a motif in a graph. @@ -325,7 +373,6 @@ def find(self, motif: "dotmotif.Motif", limit: int = None): # We need to first remove "negative" nodes from the motif, and then # filter them out later on. Though this reduces the speed of the graph- # matching, NetworkX does not seem to support this out of the box. - # TODO: Confirm that networkx does not support this out of the box. if motif.ignore_direction or not self.graph.is_directed: graph_constructor = nx.Graph @@ -367,19 +414,23 @@ def _doesnt_have_any_of_motifs_negative_edges(mapping): ] _edge_constraint_validator = ( - self._validate_edge_constraints if not self._host_is_multigraph else ( + self._validate_edge_constraints + if not self._host_is_multigraph + else ( self._validate_multigraph_all_edge_constraints if self._multigraph_edge_match == "all" else self._validate_multigraph_any_edge_constraints ) ) + _edge_dynamic_constraint_validator = self._validate_dynamic_edge_constraints # Now, filter on attributes: res = [ r for r in results if ( - _edge_constraint_validator( - r, self.graph, motif.list_edge_constraints() + _edge_constraint_validator(r, self.graph, motif.list_edge_constraints()) + and _edge_dynamic_constraint_validator( + r, self.graph, motif.list_dynamic_edge_constraints() ) and self._validate_node_constraints( r, self.graph, motif.list_node_constraints() @@ -397,4 +448,4 @@ def _doesnt_have_any_of_motifs_negative_edges(mapping): ) ) ] - return res + return res[:limit] if limit is not None else res diff --git a/dotmotif/executors/test_dm_cypher.py b/dotmotif/executors/test_dm_cypher.py index db86c40..895ad44 100644 --- a/dotmotif/executors/test_dm_cypher.py +++ b/dotmotif/executors/test_dm_cypher.py @@ -215,6 +215,22 @@ def test_dynamic_constraints_in_cypher(self): ) +class TestDynamicEdgeConstraints(unittest.TestCase): + def test_dynamic_constraints_in_cypher(self): + dm = dotmotif.Motif(enforce_inequality=True) + dm.from_motif( + """ + A -> B as AB + A -> C as AC + AB.weight >= AC.weight + """ + ) + self.assertIn( + """WHERE A_B["weight"] >= A_C["weight"]""", + Neo4jExecutor.motif_to_cypher(dm).strip(), + ) + + class BugReports(unittest.TestCase): def test_fix_where_clause__github_35(self): dm = dotmotif.Motif(enforce_inequality=True) diff --git a/dotmotif/executors/test_grandisoexecutor.py b/dotmotif/executors/test_grandisoexecutor.py index 7c7bdd1..61a525e 100644 --- a/dotmotif/executors/test_grandisoexecutor.py +++ b/dotmotif/executors/test_grandisoexecutor.py @@ -367,3 +367,138 @@ def test_dynamic_constraints_in_macros_two_result(self): dm = dotmotif.Motif(parser=ParserV2) res = GrandIsoExecutor(graph=G).find(dm.from_motif(exp)) self.assertEqual(len(res), 2) + + +class TestNamedEdgeConstraints(unittest.TestCase): + def test_equality_edge_attributes(self): + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=1) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight == A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 2) + + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=2) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight == A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 0) + + def test_inequality_edge_attributes(self): + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=1) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight != A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 0) + + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=2) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight != A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 2) + + def test_aliased_edge_comparison(self): + exp = """\ + A -> B as ab + A -> C as ac + ab.type = ac.type + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "B", type="a") + host.add_edge("A", "C", type="b") + host.add_edge("A", "D", type="b") + res = GrandIsoExecutor(graph=host).find(dm) + self.assertEqual(len(res), 2) + + def test_aliased_edge_comparisons(self): + exp = """\ + A -> B as ab + B -> C as bc + C -> D as cd + + ab.length >= bc.length + bc.length >= cd.length + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "B", length=1) + host.add_edge("B", "C", length=1) + host.add_edge("C", "D", length=1) + res = GrandIsoExecutor(graph=host).find(dm) + self.assertEqual(len(res), 1) + + def test_aliased_edge_comparisons_with_different_edge_attributes(self): + exp = """\ + B -> C as bc + C -> D as cd + + bc.length > cd.weight + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "C", length=2) + host.add_edge("B", "C", length=2) + host.add_edge("C", "D", length=1, weight=1) + res = GrandIsoExecutor(graph=host).find(dm) + self.assertEqual(len(res), 2) + + +# class TestEdgeConstraintsInMacros(unittest.TestCase): +# def test_edge_comparison_in_macro(self): +# host = nx.DiGraph() +# host.add_edge("A", "B", foo=1) +# host.add_edge("A", "C", foo=2) +# host.add_edge("B", "C", foo=0.5) +# host.add_edge("C", "D", foo=0.25) +# host.add_edge("D", "C", foo=1) +# host.add_edge("C", "B", foo=2) +# host.add_edge("B", "A", foo=2) +# E = GrandIsoExecutor(graph=host) + +# M = Motif( +# """ + +# descending(a, b, c) { +# a -> b as Edge1 +# b -> c as Edge2 +# Edge1.foo > Edge2.foo +# } + +# descending(a, b, c) +# descending(b, c, d) + +# """ +# ) +# assert E.count(M) == 1 diff --git a/dotmotif/executors/test_networkxexecutor.py b/dotmotif/executors/test_networkxexecutor.py index fb1919e..a36fee0 100644 --- a/dotmotif/executors/test_networkxexecutor.py +++ b/dotmotif/executors/test_networkxexecutor.py @@ -590,3 +590,109 @@ def test_not_in(self): res = NetworkXExecutor(graph=host).find(m) self.assertEqual(len(res), 1) + + +class TestNamedEdgeConstraints(unittest.TestCase): + def test_equality_edge_attributes(self): + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=1) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight == A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = NetworkXExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 2) + + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=2) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight == A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = NetworkXExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 0) + + def test_inequality_edge_attributes(self): + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=1) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight != A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = NetworkXExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 0) + + host = nx.DiGraph() + host.add_edge("A", "B", weight=1) + host.add_edge("A", "C", weight=2) + + exp = """\ + A -> B as A_B + A -> C as A_C + A_B.weight != A_C.weight + """ + + dm = dotmotif.Motif(parser=ParserV2) + res = NetworkXExecutor(graph=host).find(dm.from_motif(exp)) + self.assertEqual(len(res), 2) + + def test_aliased_edge_comparison(self): + exp = """\ + A -> B as ab + A -> C as ac + ab.type = ac.type + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "B", type="a") + host.add_edge("A", "C", type="b") + host.add_edge("A", "D", type="b") + res = NetworkXExecutor(graph=host).find(dm) + self.assertEqual(len(res), 2) + + def test_aliased_edge_comparisons(self): + exp = """\ + A -> B as ab + B -> C as bc + C -> D as cd + + ab.length >= bc.length + bc.length >= cd.length + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "B", length=1) + host.add_edge("B", "C", length=1) + host.add_edge("C", "D", length=1) + res = NetworkXExecutor(graph=host).find(dm) + self.assertEqual(len(res), 1) + + def test_aliased_edge_comparisons_with_different_edge_attributes(self): + exp = """\ + B -> C as bc + C -> D as cd + + bc.length > cd.weight + """ + dm = dotmotif.Motif(exp) + host = nx.DiGraph() + host.add_edge("A", "C", length=2) + host.add_edge("B", "C", length=2) + host.add_edge("C", "D", length=1, weight=1) + res = NetworkXExecutor(graph=host).find(dm) + self.assertEqual(len(res), 2) diff --git a/dotmotif/parsers/v2/__init__.py b/dotmotif/parsers/v2/__init__.py index 24bc7c2..5df5b82 100644 --- a/dotmotif/parsers/v2/__init__.py +++ b/dotmotif/parsers/v2/__init__.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 from typing import List +import os from lark import Lark, Transformer import networkx as nx -import os from ...utils import untype_string from .. import Parser @@ -13,6 +13,17 @@ dm_parser = Lark(open(os.path.join(os.path.dirname(__file__), "grammar.lark"), "r")) +def _unquote_string(s): + """ + Remove quotes from a string. + """ + if s[0] == '"' and s[-1] == '"': + return s[1:-1] + if s[0] == "'" and s[-1] == "'": + return s[1:-1] + return s + + class DotMotifTransformer(Transformer): """ This transformer converts a parsed Lark tree into a networkx.MultiGraph. @@ -22,15 +33,91 @@ def __init__(self, validators: List[Validator] = None, *args, **kwargs) -> None: self.validators = validators if validators else [] self.macros: dict = {} self.G = nx.MultiDiGraph() + self._constraints = {} + self._dynamic_constraints = {} + self.edge_constraints: dict = {} self.node_constraints: dict = {} self.dynamic_edge_constraints: dict = {} self.dynamic_node_constraints: dict = {} + self.named_edges: dict = {} self.automorphisms: list = [] super().__init__(*args, **kwargs) def transform(self, tree): self._transform_tree(tree) + + # Sort _constraints and _dynamic_constraints into edges and nodes. + # We need to defer this to the very end of transformation because + # we don't require entities to be introduced in any particular order. + + for entity_id, constraints in self._constraints.items(): + # First check to see if this exists in the graph as a node: + if entity_id in self.G.nodes: + # This is a node, and will be sorted into the node_constraints + if entity_id not in self.node_constraints: + self.node_constraints[entity_id] = {} + for key, ops in constraints.items(): + if key not in self.node_constraints[entity_id]: + self.node_constraints[entity_id][key] = {} + for op, values in ops.items(): + if op not in self.node_constraints[entity_id][key]: + self.node_constraints[entity_id][key][op] = [] + self.node_constraints[entity_id][key][op].extend(values) + elif entity_id in self.named_edges: + # This is a named edge: + (u, v, attrs) = self.named_edges[entity_id] + if (u, v) not in self.edge_constraints: + self.edge_constraints[(u, v)] = {} + for key, ops in constraints.items(): + if key not in self.edge_constraints[(u, v)]: + self.edge_constraints[(u, v)][key] = {} + for op, values in ops.items(): + if op not in self.edge_constraints[(u, v)][key]: + self.edge_constraints[(u, v)][key][op] = [] + self.edge_constraints[(u, v)][key][op].extend(values) + else: + raise KeyError( + f"Entity {entity_id} is neither a node nor a named edge in this motif." + ) + + # Now do the same thing for dynamic constraints: + for entity_id, constraints in self._dynamic_constraints.items(): + # First check to see if this exists in the graph as a node: + if entity_id in self.G.nodes: + # This is a node, and will be sorted into the node_constraints + if entity_id not in self.dynamic_node_constraints: + self.dynamic_node_constraints[entity_id] = {} + for key, ops in constraints.items(): + if key not in self.dynamic_node_constraints[entity_id]: + self.dynamic_node_constraints[entity_id][key] = {} + for op, values in ops.items(): + if op not in self.dynamic_node_constraints[entity_id][key]: + self.dynamic_node_constraints[entity_id][key][op] = [] + self.dynamic_node_constraints[entity_id][key][op].extend(values) + + elif entity_id in self.named_edges: + # This is a named edge dynamic correspondence: + (u, v, attrs) = self.named_edges[entity_id] + if (u, v) not in self.dynamic_edge_constraints: + self.dynamic_edge_constraints[(u, v)] = {} + for key, ops in constraints.items(): + if key not in self.dynamic_edge_constraints[(u, v)]: + self.dynamic_edge_constraints[(u, v)][key] = {} + for op, values in ops.items(): + for value in values: + that_edge, that_attr = value + tu, tv, _ = self.named_edges[that_edge] + if op not in self.dynamic_edge_constraints[(u, v)][key]: + self.dynamic_edge_constraints[(u, v)][key][op] = [] + self.dynamic_edge_constraints[(u, v)][key][op].extend( + (tu, tv, that_attr) + ) + else: + raise KeyError( + f"Entity {entity_id} is neither a node nor a named edge in this motif." + ) + return ( self.G, self.edge_constraints, @@ -56,21 +143,22 @@ def edge_clause(self, tup): return str(key), str(op), val def node_constraint(self, tup): - if len(tup) == 4: # This is of the form "Node.Key [OP] Value" node_id, key, op, val = tup node_id = str(node_id) key = str(key) + key = _unquote_string(key) op = str(op) val = untype_string(val) - if node_id not in self.node_constraints: - self.node_constraints[node_id] = {} - if key not in self.node_constraints[node_id]: - self.node_constraints[node_id][key] = {} - if op not in self.node_constraints[node_id][key]: - self.node_constraints[node_id][key][op] = [] - self.node_constraints[node_id][key][op].append(val) + + if node_id not in self._constraints: + self._constraints[node_id] = {} + if key not in self._constraints[node_id]: + self._constraints[node_id][key] = {} + if op not in self._constraints[node_id][key]: + self._constraints[node_id][key][op] = [] + self._constraints[node_id][key][op].append(val) elif len(tup) == 5: # This is of the form "ThisNode.Key [OP] ThatNode.Key" @@ -78,18 +166,20 @@ def node_constraint(self, tup): this_node_id = str(this_node_id) this_key = str(this_key) + this_key = _unquote_string(this_key) that_node_id = str(that_node_id) that_key = str(that_key) + that_key = _unquote_string(that_key) op = str(op) - if this_node_id not in self.dynamic_node_constraints: - self.dynamic_node_constraints[this_node_id] = {} + if this_node_id not in self._dynamic_constraints: + self._dynamic_constraints[this_node_id] = {} - if this_key not in self.dynamic_node_constraints[this_node_id]: - self.dynamic_node_constraints[this_node_id][this_key] = {} - if op not in self.dynamic_node_constraints[this_node_id][this_key]: - self.dynamic_node_constraints[this_node_id][this_key][op] = [] - self.dynamic_node_constraints[this_node_id][this_key][op].append( + if this_key not in self._dynamic_constraints[this_node_id]: + self._dynamic_constraints[this_node_id][this_key] = {} + if op not in self._dynamic_constraints[this_node_id][this_key]: + self._dynamic_constraints[this_node_id][this_key][op] = [] + self._dynamic_constraints[this_node_id][this_key][op].append( (that_node_id, that_key) ) @@ -140,6 +230,8 @@ def edge(self, tup): attrs = {} elif len(tup) == 4: u, rel, v, attrs = tup + else: + raise ValueError(f"Invalid edge definition {tup}") u = str(u) v = str(v) for val in self.validators: @@ -160,6 +252,18 @@ def edge(self, tup): u, v, exists=rel["exists"], action=rel["type"], constraints=attrs ) + def named_edge(self, tup): + if len(tup) == 4: + u, rel, v, name = tup + attrs = {} + elif len(tup) == 5: + u, rel, v, attrs, name = tup + else: + raise ValueError("Something is wrong with the named edge", tup) + + self.named_edges[name] = (u, v, attrs) + self.edge(tup[:-1]) + def automorphism_notation(self, tup): self.automorphisms.append(tup) @@ -214,8 +318,8 @@ def edge_macro(self, tup): attrs = {} elif len(tup) == 4: u, rel, v, attrs = tup - # else: - # print(tup, len(tup)) + else: + raise ValueError(f"Invalid edge definition {tup} in macro.") u = str(u) v = str(v) return (str(u), rel, str(v), attrs) @@ -276,6 +380,8 @@ def macro_call(self, tup): this_node = args[macro_args.index(this_node)] self.node_constraint((this_node, this_key, op, that_node, that_key)) continue + else: + raise ValueError(f"Invalid macro call. Failed on rule: {rule}") # Get the arguments in-place. For example, if left is A, # and A is the first arg in macro["args"], then replace # all instances of A in the rules with the first arg @@ -330,8 +436,7 @@ def __init__(self, validators: List[Validator] = None) -> None: self.validators = validators def parse(self, dm: str) -> nx.MultiDiGraph: - """ - """ + """ """ G = nx.MultiDiGraph() tree = dm_parser.parse(dm) @@ -351,4 +456,3 @@ def parse(self, dm: str) -> nx.MultiDiGraph: dynamic_node_constraints, automorphisms, ) - diff --git a/dotmotif/parsers/v2/grammar.lark b/dotmotif/parsers/v2/grammar.lark index 5363600..0bd7470 100644 --- a/dotmotif/parsers/v2/grammar.lark +++ b/dotmotif/parsers/v2/grammar.lark @@ -1,24 +1,24 @@ // See Extended Backus-Naur Form for more details. -start: comment_or_block+ +start : comment_or_block+ // Contents may be either comment or block. -comment_or_block: block - | block ";" +comment_or_block : block + | block ";" // Comments are signified by a hash followed by anything. Any line that follows // a comment hash is thrown away. -?comment : "#" COMMENT +?comment : "#" COMMENT -// A block may consist of either an edge ("A -> B") or a "macro", which is -// essentially an alias capability. -block : edge - | macro - | macro_call - | node_constraint - | automorphism_notation - | comment +// A block may consist of either an edge ("A -> B"), a constraint, or a "macro". +block : edge + | named_edge + | macro + | macro_call + | node_constraint + | automorphism_notation + | comment @@ -28,27 +28,27 @@ block : edge // is the same as: // A -> B // While this is a simple case, this is an immensely powerful tool. -macro : variable "(" arglist ")" "{" macro_rules "}" +macro : variable "(" arglist ")" "{" macro_rules "}" // A series of arguments to a macro -?arglist : variable ("," variable)* +?arglist : variable ("," variable)* -macro_rules : macro_block+ +macro_rules : macro_block+ -?macro_block : edge_macro - | macro_call_re - | comment - | macro_node_constraint +?macro_block : edge_macro + | macro_call_re + | comment + | macro_node_constraint // A "hypothetical" edge that forms a subgraph structure. -edge_macro : node_id relation node_id - | node_id relation node_id "[" macro_edge_clauses "]" +edge_macro : node_id relation node_id + | node_id relation node_id "[" macro_edge_clauses "]" // A macro is called like a function: foo(args). -macro_call : variable "(" arglist ")" -?macro_call_re : variable "(" arglist ")" +macro_call : variable "(" arglist ")" +?macro_call_re : variable "(" arglist ")" @@ -56,29 +56,35 @@ macro_call : variable "(" arglist ")" // words, an arbitrary word, a relation between them, and then another node. // Edges can also have optional edge attributes, delimited from the original // structure with square brackets. -edge : node_id relation node_id - | node_id relation node_id "[" edge_clauses "]" +edge : node_id relation node_id + | node_id relation node_id "[" edge_clauses "]" + +named_edge : node_id relation node_id "as" edge_alias + | node_id relation node_id "[" edge_clauses "]" "as" edge_alias + +// An edge alias is any variable-like token: +?edge_alias : variable // A Node ID is any contiguous (that is, no whitespace) word. -?node_id : variable +?node_id : variable // A relation is a bipartite: The first character is an indication of whether // the relation exists or not. The following characters indicate if a relation // has a type other than the default, positive, and negative types offered // by default. -relation : relation_exist relation_type +relation : relation_exist relation_type // A "-" means the relation exists; "~" means the relation does not exist. -relation_exist : "-" -> rel_exist - | "~" -> rel_nexist - | "!" -> rel_nexist +relation_exist : "-" -> rel_exist + | "~" -> rel_nexist + | "!" -> rel_nexist // The valid types of relation are single-character, except for the custom // relation type which is user-defined and lives inside square brackets. -relation_type : ">" -> rel_def - | "+" -> rel_pos - | "-" -> rel_neg - | "|" -> rel_neg +relation_type : ">" -> rel_def + | "+" -> rel_pos + | "-" -> rel_neg + | "|" -> rel_neg // Edge attributes are separated from the main edge declaration with sqbrackets edge_clauses : edge_clause ("," edge_clause)* @@ -131,25 +137,25 @@ automorphism_notation: node_id "===" node_id ?value_or_quoted_value: WORD | NUMBER | DOUBLE_QUOTED_STRING -?key : WORD | variable -?flex_key : WORD | variable | FLEXIBLE_KEY -?value : WORD | NUMBER -?op : OPERATOR | iter_ops +?key : WORD | variable +?flex_key : WORD | variable | FLEXIBLE_KEY +?value : WORD | NUMBER +?op : OPERATOR | iter_ops -variable : NAME +variable : NAME -iter_ops : "contains" -> iter_op_contains - | "in" -> iter_op_in - | "!contains" -> iter_op_not_contains - | "!in" -> iter_op_not_in +iter_ops : "contains" -> iter_op_contains + | "in" -> iter_op_in + | "!contains" -> iter_op_not_contains + | "!in" -> iter_op_not_in -NAME : /[a-zA-Z_-]\w*/ -FLEXIBLE_KEY : "\"" /.+?/ /(?\<][=]?)|(?:\<\>)/ -VAR_SEP : /[\_\-]/ -COMMENT : /#[^\n]+/ -DOUBLE_QUOTED_STRING : /"[^"]*"/ +NAME : /[a-zA-Z_-]\w*/ +FLEXIBLE_KEY : "\"" /.+?/ /(?\<][=]?)|(?:\<\>)/ +VAR_SEP : /[\_\-]/ +COMMENT : /#[^\n]+/ +DOUBLE_QUOTED_STRING : /"[^"]*"/ %ignore COMMENT %import common.WORD -> WORD diff --git a/dotmotif/parsers/v2/test_v2_parser.py b/dotmotif/parsers/v2/test_v2_parser.py index d3efcf9..4afacdf 100644 --- a/dotmotif/parsers/v2/test_v2_parser.py +++ b/dotmotif/parsers/v2/test_v2_parser.py @@ -41,6 +41,22 @@ def test_dm_parser_edge_exists(self): dm = dotmotif.Motif(_THREE_CYCLE_NEG_INH) self.assertEqual([e[2]["exists"] for e in dm._g.edges(data=True)], [False] * 3) + def test_can_create_variables(self): + dm = dotmotif.Motif("""A -> B""") + self.assertEqual(len(dm._g.nodes()), 2) + + def test_can_create_variables_with_underscores(self): + dm = dotmotif.Motif("""A -> B_""") + self.assertEqual(len(dm._g.nodes()), 2) + + def test_cannot_create_variables_with_dashes(self): + with self.assertRaises(Exception): + dm = dotmotif.Motif("""A -> B-""") + + def test_can_create_variables_with_numbers(self): + dm = dotmotif.Motif("""A_2 -> B1""") + self.assertEqual(len(dm._g.nodes()), 2) + class TestDotmotif_Parserv2_DM_Macros(unittest.TestCase): def test_macro_not_added(self): @@ -474,3 +490,84 @@ def test_dynamic_constraints_bracketed_in_macro(self): """ dm = dotmotif.Motif(exp) self.assertEqual(len(dm.list_dynamic_node_constraints()), 1) + + def test_failed_node_lookup(self): + """ + Test that comparisons may be made between variables, e.g.: + + A.type != B.type + + """ + exp = """\ + A -> B + C.radius < B.radius + """ + with self.assertRaises(KeyError): + dm = dotmotif.Motif(exp) + + +class TestEdgeAliasConstraints(unittest.TestCase): + def test_can_create_aliases(self): + dotmotif.Motif("""A -> B as ab""") + assert True + + def test_can_create_aliases_with_constraints(self): + dotmotif.Motif("""A -> B [type != 1] as ab_2""") + assert True + + def test_can_create_alised_edge_constraints_nondynamic(self): + dotmotif.Motif( + """ + A -> B [type != 1] as ab_2 + ab_2.flavor = "excitatory" + """ + ) + assert True + + def test_can_create_alised_edge_constraints_dynamic(self): + dotmotif.Motif( + """ + A -> B [type != 1] as ab_2 + B -> A as ba + ab_2.flavor = ba.flavor + """ + ) + assert True + + def test_failed_edge_lookup(self): + """ + Test that comparisons may be made between variables, e.g.: + + A.type != B.type + + """ + exp = """\ + A -> B as ab + acb.radius = 3 + """ + with self.assertRaises(KeyError): + dm = dotmotif.Motif(exp) + + def test_quoted_attribute_edge_constraint(self): + exp = """\ + A -> B as ab + ab["flavor"] = "excitatory" + """ + dm = dotmotif.Motif(exp) + self.assertEqual(len(dm.list_edge_constraints()), 1) + self.assertEqual( + dm.list_edge_constraints()[("A", "B")]["flavor"]["="], ["excitatory"] + ) + + def test_quoted_attribute_dynamic_edge_constraint(self): + exp = """\ + A -> B as ab + B -> A as ba + ab["flavor"] = ba["flavor"] + """ + dm = dotmotif.Motif(exp) + self.assertEqual(len(dm.list_dynamic_edge_constraints()), 1) + self.assertEqual( + dm.list_dynamic_edge_constraints()[("A", "B")]["flavor"]["="], + ["B", "A", "flavor"], + ) diff --git a/setup.py b/setup.py index 7319304..f5e231d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ twine upload dist/* """ -VERSION = "0.11.0" +VERSION = "0.12.0" here = os.path.abspath(os.path.dirname(__file__)) with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: