1# Purpose: Query language and manipulation object for DXF entities
2# Created: 27.04.13
3# Copyright (C) 2013, Manfred Moitzi
4# License: MIT License
5from typing import TYPE_CHECKING, Iterable, Callable, Hashable, Dict, List, Any, Sequence, Union
6import re
7import operator
8
9from collections import abc
10from ezdxf.queryparser import EntityQueryParser
11from ezdxf.groupby import groupby
12
13if TYPE_CHECKING:  # import forward references
14    from ezdxf.eztypes import DXFEntity
15
16
17class EntityQuery(abc.Sequence):
18    """
19
20    EntityQuery is a result container, which is filled with dxf entities matching the query string.
21    It is possible to add entities to the container (extend), remove entities from the container and
22    to filter the container.
23
24    Query String
25    ============
26
27    QueryString := EntityQuery ("[" AttribQuery "]")*
28
29    The query string is the combination of two queries, first the required entity query and second the
30    optional attribute query, enclosed in square brackets.
31
32    Entity Query
33    ------------
34
35    The entity query is a whitespace separated list of DXF entity names or the special name ``*``.
36    Where ``*`` means all DXF entities, exclude some entity types by appending their names with a preceding ``!``
37    (e.g. all entities except LINE = ``* !LINE``). All DXF names have to be uppercase.
38
39    Attribute Query
40    ---------------
41
42    The attribute query is used to select DXF entities by its DXF attributes. The attribute query is an addition to the
43    entity query and matches only if the entity already match the entity query.
44    The attribute query is a boolean expression, supported operators are:
45      - not: !term is true, if term is false
46      - and: term & term is true, if both terms are true
47      - or: term | term is true, if one term is true
48      - and arbitrary nested round brackets
49
50    Attribute selection is a term: "name comparator value", where name is a DXF entity attribute in lowercase,
51    value is a integer, float or double quoted string, valid comparators are:
52      - "==" equal "value"
53      - "!=" not equal "value"
54      - "<" lower than "value"
55      - "<=" lower or equal than "value"
56      - ">" greater than "value"
57      - ">=" greater or equal than "value"
58      - "?" match regular expression "value"
59      - "!?" does not match regular expression "value"
60
61    Query Result
62    ------------
63
64    The EntityQuery() class based on the abstract Sequence() class, contains all DXF entities of the source collection,
65    which matches one name of the entity query AND the whole attribute query.
66    If a DXF entity does not have or support a required attribute, the corresponding attribute search term is false.
67    example: 'LINE[text ? ".*"]' is always empty, because the LINE entity has no text attribute.
68
69    examples:
70        'LINE CIRCLE[layer=="construction"]' => all LINE and CIRCLE entities on layer "construction"
71        '*[!(layer=="construction" & color<7)]' => all entities except those on layer == "construction" and color < 7
72
73    """
74
75    def __init__(self, entities: Iterable['DXFEntity'] = None, query: str = '*'):
76        """
77        Setup container with entities matching the initial query.
78
79        Args:
80            entities: sequence of wrapped DXF entities (at least GraphicEntity class)
81            query: query string, see class documentation
82
83        """
84        if entities is None:
85            self.entities = []
86        elif query == '*':
87            self.entities = list(entities)
88        else:
89            match = entity_matcher(query)
90            self.entities = [entity for entity in entities if match(entity)]
91
92    def __len__(self) -> int:
93        """ Returns count of DXF entities. """
94        return len(self.entities)
95
96    def __getitem__(self, item: int) -> 'DXFEntity':
97        """ Returns DXFEntity at index `item`, supports negative indices and slicing. """
98        return self.entities.__getitem__(item)
99
100    def __iter__(self) -> Iterable['DXFEntity']:
101        """ Returns iterable of DXFEntity objects. """
102        return iter(self.entities)
103
104    @property
105    def first(self):
106        """ First entity or ``None``. """
107        if len(self.entities):
108            return self.entities[0]
109        else:
110            return None
111
112    @property
113    def last(self):
114        """ Last entity or ``None``. """
115        if len(self.entities):
116            return self.entities[-1]
117        else:
118            return None
119
120    def extend(self, entities: Iterable['DXFEntity'], query: str = '*', unique: bool = True) -> 'EntityQuery':
121        """ Extent the :class:`EntityQuery` container by entities matching an additional query. """
122        self.entities.extend(EntityQuery(entities, query))
123        if unique:
124            self.entities = list(unique_entities(self.entities))
125        return self
126
127    def remove(self, query: str = '*') -> None:
128        """ Remove all entities from :class:`EntityQuery` container matching this additional query. """
129        handles_of_entities_to_remove = frozenset(entity.dxf.handle for entity in self.query(query))
130        self.entities = [entity for entity in self.entities if entity.dxf.handle not in handles_of_entities_to_remove]
131
132    def query(self, query: str = '*') -> 'EntityQuery':
133        """
134        Returns a new :class:`EntityQuery` container with all entities matching this additional query.
135
136        raises: ParseException (pyparsing.py)
137
138        """
139        return EntityQuery(self.entities, query)
140
141    def groupby(self, dxfattrib: str = '', key: Callable[['DXFEntity'], Hashable] = None) \
142            -> Dict[Hashable, List['DXFEntity']]:
143        """
144        Returns a dict of entity lists, where entities are grouped by a DXF attribute or a key function.
145
146        Args:
147            dxfattrib: grouping DXF attribute as string like ``'layer'``
148            key: key function, which accepts a DXFEntity as argument, returns grouping key of this entity or None for
149                 ignore this object. Reason for ignoring: a queried DXF attribute is not supported by this entity
150
151        """
152        return groupby(self.entities, dxfattrib, key)
153
154
155def entity_matcher(query: str) -> Callable[['DXFEntity'], bool]:
156    query_args = EntityQueryParser.parseString(query, parseAll=True)
157    entity_matcher_ = build_entity_name_matcher(query_args.EntityQuery)
158    attrib_matcher = build_entity_attributes_matcher(query_args.AttribQuery, query_args.AttribQueryOptions)
159
160    def matcher(entity: 'DXFEntity') -> bool:
161        return entity_matcher_(entity) and attrib_matcher(entity)
162
163    return matcher
164
165
166def build_entity_name_matcher(names: Sequence[str]) -> Callable[['DXFEntity'], bool]:
167    def match(e: 'DXFEntity') -> bool:
168        return _match(e.dxftype())
169
170    _match = name_matcher(query=' '.join(names))
171    return match
172
173
174class Relation:
175    CMP_OPERATORS = {
176        '==': operator.eq,
177        '!=': operator.ne,
178        '<': operator.lt,
179        '<=': operator.le,
180        '>': operator.gt,
181        '>=': operator.ge,
182        '?': lambda e, regex: regex.match(e) is not None,
183        '!?': lambda e, regex: regex.match(e) is None,
184    }
185    VALID_CMP_OPERATORS = frozenset(CMP_OPERATORS.keys())
186
187    def __init__(self, relation: Sequence, ignore_case: bool):
188        name, op, value = relation
189        self.dxf_attrib = name
190        self.compare = Relation.CMP_OPERATORS[op]
191        self.convert_case = to_lower if ignore_case else lambda x: x
192
193        re_flags = re.IGNORECASE if ignore_case else 0
194        if '?' in op:
195            self.value = re.compile(value + '$', flags=re_flags)  # always match whole pattern
196        else:
197            self.value = self.convert_case(value)
198
199    def evaluate(self, entity: 'DXFEntity') -> bool:
200        try:
201            value = self.convert_case(entity.get_dxf_attrib(self.dxf_attrib))
202            return self.compare(value, self.value)
203        except AttributeError:  # entity does not support this attribute
204            return False
205        except ValueError:  # entity supports this attribute, but has no value for it
206            return False
207
208
209def to_lower(value):
210    return value.lower() if hasattr(value, 'lower') else value
211
212
213class BoolExpression:
214    OPERATORS = {
215        '&': operator.and_,
216        '|': operator.or_,
217    }
218
219    def __init__(self, tokens: Sequence):
220        self.tokens = tokens
221
222    def __iter__(self):
223        return iter(self.tokens)
224
225    def evaluate(self, entity: 'DXFEntity') -> bool:
226        if isinstance(self.tokens, Relation):  # expression is just one relation, no bool operations
227            return self.tokens.evaluate(entity)
228
229        values = []  # first in, first out
230        operators = []  # first in, first out
231        for token in self.tokens:
232            if hasattr(token, 'evaluate'):
233                values.append(token.evaluate(entity))
234            else:  # bool operator
235                operators.append(token)
236        values.reverse()  # revert values -> pop() == pop(0) & append(value) == insert(0, value)
237        for op in operators:  # as queue -> first in, first out
238            if op == '!':
239                value = not values.pop()
240            else:
241                value = BoolExpression.OPERATORS[op](values.pop(), values.pop())
242            values.append(value)
243        return values.pop()
244
245
246def _compile_tokens(tokens: Union[str, Sequence], ignore_case: bool) -> Union[str, Relation, BoolExpression]:
247    def is_relation(tokens: Sequence) -> bool:
248        return len(tokens) == 3 and tokens[1] in Relation.VALID_CMP_OPERATORS
249
250    if isinstance(tokens, str):  # bool operator as string
251        return tokens
252
253    tokens = tuple(tokens)
254    if is_relation(tokens):
255        return Relation(tokens, ignore_case)
256    else:
257        return BoolExpression([_compile_tokens(token, ignore_case) for token in tokens])
258
259
260def build_entity_attributes_matcher(tokens: Sequence, options: str) -> Callable[['DXFEntity'], bool]:
261    if not len(tokens):
262        return lambda x: True
263    ignore_case = 'i' == options  # at this time just one option is supported
264    expr = BoolExpression(_compile_tokens(tokens, ignore_case))
265
266    def match_bool_expr(entity: 'DXFEntity') -> bool:
267        return expr.evaluate(entity)
268
269    return match_bool_expr
270
271
272def unique_entities(entities: Iterable['DXFEntity']) -> Iterable['DXFEntity']:
273    """
274    Yield all unique entities, order of all entities will be preserved.
275    """
276    handles = set()
277    for entity in entities:
278        handle = entity.dxf.handle
279        if handle not in handles:
280            handles.add(handle)
281            yield entity
282
283
284def name_query(names: Iterable[str], query: str = "*") -> Iterable[str]:
285    """
286    Filters `names` by `query` string. The `query` string of entity names divided by spaces. The special name "*"
287    matches any given name, a preceding "!" means exclude this name. Excluding names is only useful if the match any
288    name is also given (e.g. "LINE !CIRCLE" is equal to just "LINE", where "* !CIRCLE" matches everything except
289    CIRCLE").
290
291    Args:
292        names: iterable of names to test
293        query: query string of entity names separated by spaces
294
295    Returns: yield matching names
296
297    """
298    match = name_matcher(query)
299    return (name for name in names if match(name))
300
301
302def name_matcher(query: str = "*") -> Callable[[str], bool]:
303    def match(e: str) -> bool:
304        if take_all:
305            return e not in exclude
306        else:
307            return e in include
308
309    match_strings = set(query.upper().split())
310    take_all = False
311    exclude = set()
312    include = set()
313    for name in match_strings:
314        if name == '*':
315            take_all = True
316        elif name.startswith('!'):
317            exclude.add(name[1:])
318        else:
319            include.add(name)
320
321    return match
322
323
324def new(entities: Iterable['DXFEntity'] = None, query: str = '*') -> EntityQuery:
325    """
326    Start a new query based on sequence `entities`. The `entities` argument has to be an iterable of
327    :class:`~ezdxf.entities.DXFEntity` or inherited objects and returns an :class:`EntityQuery` object.
328
329    """
330    return EntityQuery(entities, query)
331