Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/query.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

632 lines
21 KiB
Python

# Purpose: Query language and manipulation object for DXF entities
# Copyright (c) 2013-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Iterable,
Iterator,
Callable,
Hashable,
Sequence,
Union,
Optional,
)
import re
import operator
from collections import abc
from ezdxf.entities.dxfentity import DXFEntity
from ezdxf.groupby import groupby
from ezdxf.math import Vec3, Vec2
from ezdxf.queryparser import EntityQueryParser
class _AttributeDescriptor:
def __init__(self, name: str):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__getitem__(self.name)
def __set__(self, obj, value):
obj.__setitem__(self.name, value)
def __delete__(self, obj):
obj.__delitem__(self.name)
class EntityQuery(abc.Sequence):
"""EntityQuery is a result container, which is filled with dxf entities
matching the query string. It is possible to add entities to the container
(extend), remove entities from the container and to filter the container.
Query String
============
QueryString := EntityQuery ("[" AttribQuery "]")*
The query string is the combination of two queries, first the required
entity query and second the optional attribute query, enclosed in square
brackets.
Entity Query
------------
The entity query is a whitespace separated list of DXF entity names or the
special name ``*``. Where ``*`` means all DXF entities, exclude some entity
types by appending their names with a preceding ``!`` (e.g. all entities
except LINE = ``* !LINE``). All DXF names have to be uppercase.
Attribute Query
---------------
The attribute query is used to select DXF entities by its DXF attributes.
The attribute query is an addition to the entity query and matches only if
the entity already match the entity query.
The attribute query is a boolean expression, supported operators are:
- not: !term is true, if term is false
- and: term & term is true, if both terms are true
- or: term | term is true, if one term is true
- and arbitrary nested round brackets
Attribute selection is a term: "name comparator value", where name is a DXF
entity attribute in lowercase, value is a integer, float or double quoted
string, valid comparators are:
- "==" equal "value"
- "!=" not equal "value"
- "<" lower than "value"
- "<=" lower or equal than "value"
- ">" greater than "value"
- ">=" greater or equal than "value"
- "?" match regular expression "value"
- "!?" does not match regular expression "value"
Query Result
------------
The EntityQuery() class based on the abstract Sequence() class, contains all
DXF entities of the source collection, which matches one name of the entity
query AND the whole attribute query. If a DXF entity does not have or
support a required attribute, the corresponding attribute search term is
false.
Examples:
- 'LINE[text ? ".*"]' is always empty, because the LINE entity has no
text attribute.
- 'LINE CIRCLE[layer=="construction"]' => all LINE and CIRCLE entities
on layer "construction"
- '*[!(layer=="construction" & color<7)]' => all entities except those
on layer == "construction" and color < 7
"""
layer = _AttributeDescriptor("layer")
color = _AttributeDescriptor("color")
linetype = _AttributeDescriptor("linetype")
lineweight = _AttributeDescriptor("lineweight")
ltscale = _AttributeDescriptor("ltscale")
invisible = _AttributeDescriptor("invisible")
true_color = _AttributeDescriptor("true_color")
transparency = _AttributeDescriptor("transparency")
def __init__(
self, entities: Optional[Iterable[DXFEntity]] = None, query: str = "*"
):
"""
Setup container with entities matching the initial query.
Args:
entities: sequence of wrapped DXF entities (at least GraphicEntity class)
query: query string, see class documentation
"""
# Selected DXF attribute for operator selection:
self.selected_dxf_attribute: str = ""
# Text selection mode, but only for operator comparisons:
self.ignore_case = True
self.entities: list[DXFEntity]
if entities is None:
self.entities = []
elif query == "*":
self.entities = list(entities)
else:
match = entity_matcher(query)
self.entities = [entity for entity in entities if match(entity)]
def __len__(self) -> int:
"""Returns count of DXF entities."""
return len(self.entities)
def __iter__(self) -> Iterator[DXFEntity]:
"""Returns iterable of DXFEntity objects."""
return iter(self.entities)
def __getitem__(self, item):
"""Returns DXFEntity at index `item`, supports negative indices and
slicing. Returns all entities which support a specific DXF attribute,
if `item` is a DXF attribute name as string.
"""
if isinstance(item, str):
return self._get_entities_with_supported_attribute(item)
return self.entities.__getitem__(item)
def __setitem__(self, key, value):
"""Set the DXF attribute `key` for all supported DXF entities to `value`.
"""
if not isinstance(key, str):
raise TypeError("key has to be a string (DXF attribute name)")
self._set_dxf_attribute_for_all(key, value)
def __delitem__(self, key):
"""Discard the DXF attribute `key` from all supported DXF entities."""
if not isinstance(key, str):
raise TypeError("key has to be a string (DXF attribute name)")
self._discard_dxf_attribute_for_all(key)
def purge(self) -> EntityQuery:
"""Remove destroyed entities."""
self.entities = [e for e in self.entities if e.is_alive]
return self # fluent interface
def _get_entities_with_supported_attribute(
self, attribute: str
) -> EntityQuery:
query = self.__class__(
e for e in self.entities if e.dxf.is_supported(attribute)
)
query.selected_dxf_attribute = attribute
return query
def _set_dxf_attribute_for_all(self, key, value):
for e in self.entities:
try:
e.dxf.set(key, value)
except AttributeError: # ignore unsupported attributes
pass
# But raises ValueError/TypeError for invalid values!
def _discard_dxf_attribute_for_all(self, key):
for e in self.entities:
e.dxf.discard(key)
def __eq__(self, other):
"""Equal selector (self == other).
Returns all entities where the selected DXF attribute is equal to
`other`.
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.eq)
def __ne__(self, other):
"""Not equal selector (self != other). Returns all entities where the
selected DXF attribute is not equal to `other`.
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.ne)
def __lt__(self, other):
"""Less than selector (self < other). Returns all entities where the
selected DXF attribute is less than `other`.
Raises:
TypeError: for vector based attributes like `center` or `insert`
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.lt, vectors=False)
def __gt__(self, other):
"""Greater than selector (self > other). Returns all entities where the
selected DXF attribute is greater than `other`.
Raises:
TypeError: for vector based attributes like `center` or `insert`
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.gt, vectors=False)
def __le__(self, other):
"""Less equal selector (self <= other). Returns all entities where the
selected DXF attribute is less or equal `other`.
Raises:
TypeError: for vector based attributes like `center` or `insert`
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.le, vectors=False)
def __ge__(self, other):
"""Greater equal selector (self >= other). Returns all entities where
the selected DXF attribute is greater or equal `other`.
Raises:
TypeError: for vector based attributes like `center` or `insert`
"""
if not self.selected_dxf_attribute:
raise TypeError("no DXF attribute selected")
return self._select_by_operator(other, operator.ge, vectors=False)
def __or__(self, other):
"""Union operator, see :meth:`union`."""
if isinstance(other, EntityQuery):
return self.union(other)
return NotImplemented
def __and__(self, other):
"""Intersection operator, see :meth:`intersection`."""
if isinstance(other, EntityQuery):
return self.intersection(other)
return NotImplemented
def __sub__(self, other):
"""Difference operator, see :meth:`difference`."""
if isinstance(other, EntityQuery):
return self.difference(other)
return NotImplemented
def __xor__(self, other):
"""Symmetric difference operator, see :meth:`symmetric_difference`."""
if isinstance(other, EntityQuery):
return self.symmetric_difference(other)
return NotImplemented
def _select_by_operator(self, value, op, vectors=True) -> EntityQuery:
attribute = self.selected_dxf_attribute
if self.ignore_case and isinstance(value, str):
value = value.lower()
query = self.__class__()
query.selected_dxf_attribute = attribute
entities = query.entities
if attribute:
for entity in self.entities:
try:
entity_value = entity.dxf.get_default(attribute)
except AttributeError:
continue
if not vectors and isinstance(entity_value, (Vec2, Vec3)):
raise TypeError(
f"unsupported operation '{str(op.__name__)}' for DXF "
f"attribute {attribute}"
)
if self.ignore_case and isinstance(entity_value, str):
entity_value = entity_value.lower()
if op(entity_value, value):
entities.append(entity)
return query
def match(self, pattern: str) -> EntityQuery:
"""Returns all entities where the selected DXF attribute matches the
regular expression `pattern`.
Raises:
TypeError: for non-string based attributes
"""
def match(value, regex):
if isinstance(value, str):
return regex.match(value) is not None
raise TypeError(
f"cannot apply regular expression to DXF attribute: "
f"{self.selected_dxf_attribute}"
)
return self._regex_match(pattern, match)
def _regex_match(self, pattern: str, func) -> EntityQuery:
ignore_case = self.ignore_case
self.ignore_case = False # deactivate string manipulation
re_flags = re.IGNORECASE if ignore_case else 0
# always match whole pattern
if not pattern.endswith("$"):
pattern += "$"
result = self._select_by_operator(
re.compile(pattern, flags=re_flags), func
)
self.ignore_case = ignore_case # restore state
return result
@property
def first(self):
"""First entity or ``None``."""
if len(self.entities):
return self.entities[0]
else:
return None
@property
def last(self):
"""Last entity or ``None``."""
if len(self.entities):
return self.entities[-1]
else:
return None
def extend(
self,
entities: Iterable[DXFEntity],
query: str = "*",
) -> EntityQuery:
"""Extent the :class:`EntityQuery` container by entities matching an
additional query.
"""
self.entities = self.union(self.__class__(entities, query)).entities
return self # fluent interface
def remove(self, query: str = "*") -> EntityQuery:
"""Remove all entities from :class:`EntityQuery` container matching this
additional query.
"""
self.entities = self.difference(
self.__class__(self.entities, query)
).entities
return self # fluent interface
def query(self, query: str = "*") -> EntityQuery:
"""Returns a new :class:`EntityQuery` container with all entities
matching this additional query.
Raises:
pyparsing.ParseException: query string parsing error
"""
return self.__class__(self.entities, query)
def groupby(
self,
dxfattrib: str = "",
key: Optional[Callable[[DXFEntity], Hashable]] = None,
) -> dict[Hashable, list[DXFEntity]]:
"""Returns a dict of entity lists, where entities are grouped by a DXF
attribute or a key function.
Args:
dxfattrib: grouping DXF attribute as string like ``'layer'``
key: key function, which accepts a DXFEntity as argument, returns
grouping key of this entity or ``None`` for ignore this object.
Reason for ignoring: a queried DXF attribute is not supported by
this entity
"""
return groupby(self.entities, dxfattrib, key)
def filter(self, func: Callable[[DXFEntity], bool]) -> EntityQuery:
"""Returns a new :class:`EntityQuery` with all entities from this
container for which the callable `func` returns ``True``.
Build your own operator to filter by attributes which are not DXF
attributes or to build complex queries::
result = msp.query().filter(
lambda e: hasattr(e, "rgb") and e.rbg == (0, 0, 0)
)
"""
return self.__class__(filter(func, self.entities))
def union(self, other: EntityQuery) -> EntityQuery:
"""Returns a new :class:`EntityQuery` with entities from `self` and
`other`. All entities are unique - no duplicates.
"""
return self.__class__(set(self.entities) | set(other.entities))
def intersection(self, other: EntityQuery) -> EntityQuery:
"""Returns a new :class:`EntityQuery` with entities common to `self`
and `other`.
"""
return self.__class__(set(self.entities) & set(other.entities))
def difference(self, other: EntityQuery) -> EntityQuery:
"""Returns a new :class:`EntityQuery` with all entities from `self` that
are not in `other`.
"""
return self.__class__(set(self.entities) - set(other.entities))
def symmetric_difference(self, other: EntityQuery) -> EntityQuery:
"""Returns a new :class:`EntityQuery` with entities in either `self` or
`other` but not both.
"""
return self.__class__(set(self.entities) ^ set(other.entities))
def entity_matcher(query: str) -> Callable[[DXFEntity], bool]:
query_args = EntityQueryParser.parseString(query, parseAll=True)
entity_matcher_ = build_entity_name_matcher(query_args.EntityQuery)
attrib_matcher = build_entity_attributes_matcher(
query_args.AttribQuery, query_args.AttribQueryOptions
)
def matcher(entity: DXFEntity) -> bool:
return entity_matcher_(entity) and attrib_matcher(entity)
return matcher
def build_entity_name_matcher(
names: Sequence[str],
) -> Callable[[DXFEntity], bool]:
def match(e: DXFEntity) -> bool:
return _match(e.dxftype())
_match = name_matcher(query=" ".join(names))
return match
class Relation:
CMP_OPERATORS = {
"==": operator.eq,
"!=": operator.ne,
"<": operator.lt,
"<=": operator.le,
">": operator.gt,
">=": operator.ge,
"?": lambda e, regex: regex.match(e) is not None,
"!?": lambda e, regex: regex.match(e) is None,
}
VALID_CMP_OPERATORS = frozenset(CMP_OPERATORS.keys())
def __init__(self, relation: Sequence, ignore_case: bool):
name, op, value = relation
self.dxf_attrib = name
self.compare = Relation.CMP_OPERATORS[op]
self.convert_case = to_lower if ignore_case else lambda x: x
re_flags = re.IGNORECASE if ignore_case else 0
if "?" in op:
self.value = re.compile(
value + "$", flags=re_flags
) # always match whole pattern
else:
self.value = self.convert_case(value)
def evaluate(self, entity: DXFEntity) -> bool:
try:
value = self.convert_case(entity.dxf.get_default(self.dxf_attrib))
return self.compare(value, self.value)
except AttributeError: # entity does not support this attribute
return False
except ValueError: # entity supports this attribute, but has no value for it
return False
def to_lower(value):
return value.lower() if hasattr(value, "lower") else value
class BoolExpression:
OPERATORS = {
"&": operator.and_,
"|": operator.or_,
}
def __init__(self, tokens: Sequence):
self.tokens = tokens
def __iter__(self):
return iter(self.tokens)
def evaluate(self, entity: DXFEntity) -> bool:
if isinstance(
self.tokens, Relation
): # expression is just one relation, no bool operations
return self.tokens.evaluate(entity)
values = [] # first in, first out
operators = [] # first in, first out
for token in self.tokens:
if hasattr(token, "evaluate"):
values.append(token.evaluate(entity))
else: # bool operator
operators.append(token)
values.reverse()
for op in operators: # as queue -> first in, first out
if op == "!":
value = not values.pop()
else:
value = BoolExpression.OPERATORS[op](values.pop(), values.pop())
values.append(value)
return values.pop()
def _compile_tokens(
tokens: Union[str, Sequence], ignore_case: bool
) -> Union[str, Relation, BoolExpression]:
def is_relation(tokens: Sequence) -> bool:
return len(tokens) == 3 and tokens[1] in Relation.VALID_CMP_OPERATORS
if isinstance(tokens, str): # bool operator as string
return tokens
tokens = tuple(tokens)
if is_relation(tokens):
return Relation(tokens, ignore_case)
else:
return BoolExpression(
[_compile_tokens(token, ignore_case) for token in tokens]
)
def build_entity_attributes_matcher(
tokens: Sequence, options: str
) -> Callable[[DXFEntity], bool]:
if not len(tokens):
return lambda x: True
ignore_case = "i" == options # at this time just one option is supported
expr = BoolExpression(_compile_tokens(tokens, ignore_case)) # type: ignore
def match_bool_expr(entity: DXFEntity) -> bool:
return expr.evaluate(entity)
return match_bool_expr
def unique_entities(entities: Iterable[DXFEntity]) -> Iterator[DXFEntity]:
"""Yield all unique entities, order of all entities will be preserved."""
done: set[DXFEntity] = set()
for entity in entities:
if entity not in done:
done.add(entity)
yield entity
def name_query(names: Iterable[str], query: str = "*") -> Iterator[str]:
"""Filters `names` by `query` string. The `query` string of entity names
divided by spaces. The special name "*" matches any given name, a
preceding "!" means exclude this name. Excluding names is only useful if
the match any name is also given (e.g. "LINE !CIRCLE" is equal to just
"LINE", where "* !CIRCLE" matches everything except CIRCLE").
Args:
names: iterable of names to test
query: query string of entity names separated by spaces
Returns: yield matching names
"""
match = name_matcher(query)
return (name for name in names if match(name))
def name_matcher(query: str = "*") -> Callable[[str], bool]:
def match(e: str) -> bool:
if take_all:
return e not in exclude
else:
return e in include
match_strings = set(query.upper().split())
take_all = False
exclude = set()
include = set()
for name in match_strings:
if name == "*":
take_all = True
elif name.startswith("!"):
exclude.add(name[1:])
else:
include.add(name)
return match
def new(
entities: Optional[Iterable[DXFEntity]] = None, query: str = "*"
) -> EntityQuery:
"""Start a new query based on sequence `entities`. The `entities` argument
has to be an iterable of :class:`~ezdxf.entities.DXFEntity` or inherited
objects and returns an :class:`EntityQuery` object.
"""
return EntityQuery(entities, query)