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