| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623 |
- """
- Query Parser for Satellite Access
- This module implements the query parsing system for advanced satellite access patterns
- as specified in CLAUDE.md. It supports query-based satellite selection using expressions like:
- - "status=connected"
- - "room=kitchen"
- - "status=connected,room=kitchen"
- - "capability=wakeword_detection"
- The query parser supports various operators and can be extended for complex filtering.
- """
- import re
- from typing import List, Dict, Any, Optional, Union, Callable, Set
- from enum import Enum
- from dataclasses import dataclass
- from ..satellites.satellite import Satellite, SatelliteStatus, SatelliteCapability
- def pprint(message: str) -> None:
- """Query parser logging function."""
- print(f"[QUERY_PARSER] {message}")
- class QueryOperator(Enum):
- """Supported query operators."""
- EQUALS = "="
- NOT_EQUALS = "!="
- CONTAINS = "contains"
- NOT_CONTAINS = "!contains"
- STARTS_WITH = "starts_with"
- ENDS_WITH = "ends_with"
- GREATER_THAN = ">"
- LESS_THAN = "<"
- GREATER_EQUAL = ">="
- LESS_EQUAL = "<="
- IN = "in"
- NOT_IN = "!in"
- REGEX = "regex"
- EXISTS = "exists"
- NOT_EXISTS = "!exists"
- @dataclass
- class QueryCondition:
- """Represents a single query condition."""
- field: str
- operator: QueryOperator
- value: Any
- case_sensitive: bool = False
-
- def __str__(self) -> str:
- """String representation of the condition."""
- return f"{self.field} {self.operator.value} {self.value}"
-
- def __repr__(self) -> str:
- """Detailed representation of the condition."""
- return f"QueryCondition(field='{self.field}', operator={self.operator}, value={self.value})"
- class QueryError(Exception):
- """Base exception for query parsing errors."""
- pass
- class QuerySyntaxError(QueryError):
- """Raised when query syntax is invalid."""
- pass
- class QueryValidationError(QueryError):
- """Raised when query validation fails."""
- pass
- class QueryParser:
- """
- Advanced query parser for satellite filtering and selection.
-
- Supports complex query expressions with multiple conditions, operators,
- and field types. Provides case-insensitive matching by default as
- specified in CLAUDE.md.
- """
-
- # Supported fields and their types
- SUPPORTED_FIELDS = {
- "status": str,
- "room": str,
- "room_id": str,
- "alias": str,
- "alias_name": str,
- "ip": str,
- "ip_address": str,
- "mac": str,
- "mac_address": str,
- "version": str,
- "capability": str,
- "capabilities": list,
- "uptime": float,
- "connection_count": int,
- "message_count": int,
- "error_count": int,
- "has_errors": bool,
- "last_seen": str,
- "connected_at": str,
- }
-
- # Field aliases for convenience
- FIELD_ALIASES = {
- "room": "room_id",
- "alias": "alias_name",
- "ip": "ip_address",
- "mac": "mac_address",
- }
-
- # Special value processors
- VALUE_PROCESSORS = {
- "status": lambda x: SatelliteStatus(x.lower()).value if isinstance(x, str) else x,
- "capability": lambda x: SatelliteCapability(x.lower()).value if isinstance(x, str) else x,
- "has_errors": lambda x: str(x).lower() in ("true", "1", "yes", "on"),
- }
-
- def __init__(self, case_sensitive: bool = False):
- """
- Initialize the query parser.
-
- Args:
- case_sensitive: Whether string comparisons are case-sensitive
- """
- self._case_sensitive = case_sensitive
- self._compiled_regex_cache: Dict[str, re.Pattern] = {}
-
- def parse_query(self, query: str) -> List[QueryCondition]:
- """
- Parse a query string into a list of conditions.
-
- Args:
- query: Query string (e.g., "status=connected,room=kitchen")
-
- Returns:
- List[QueryCondition]: Parsed query conditions
-
- Raises:
- QuerySyntaxError: If query syntax is invalid
- """
- if not query or not query.strip():
- return []
-
- query = query.strip()
- pprint(f"Parsing query: '{query}'")
-
- try:
- # Split by comma for multiple conditions
- condition_strings = [c.strip() for c in query.split(",")]
- conditions = []
-
- for condition_str in condition_strings:
- if not condition_str:
- continue
-
- condition = self._parse_single_condition(condition_str)
- conditions.append(condition)
-
- pprint(f"Parsed {len(conditions)} conditions")
- return conditions
-
- except Exception as e:
- raise QuerySyntaxError(f"Invalid query syntax: {e}") from e
-
- def _parse_single_condition(self, condition_str: str) -> QueryCondition:
- """Parse a single condition string."""
- # Define operator patterns (order matters - longer operators first)
- operator_patterns = [
- (r'!=', QueryOperator.NOT_EQUALS),
- (r'>=', QueryOperator.GREATER_EQUAL),
- (r'<=', QueryOperator.LESS_EQUAL),
- (r'>', QueryOperator.GREATER_THAN),
- (r'<', QueryOperator.LESS_THAN),
- (r'=', QueryOperator.EQUALS),
- (r'\bcontains\b', QueryOperator.CONTAINS),
- (r'!contains\b', QueryOperator.NOT_CONTAINS),
- (r'\bstarts_with\b', QueryOperator.STARTS_WITH),
- (r'\bends_with\b', QueryOperator.ENDS_WITH),
- (r'\bin\b', QueryOperator.IN),
- (r'!in\b', QueryOperator.NOT_IN),
- (r'\bregex\b', QueryOperator.REGEX),
- (r'\bexists\b', QueryOperator.EXISTS),
- (r'!exists\b', QueryOperator.NOT_EXISTS),
- ]
-
- # Try to match operators
- for pattern, operator in operator_patterns:
- match = re.search(pattern, condition_str)
- if match:
- field = condition_str[:match.start()].strip()
- value_str = condition_str[match.end():].strip()
-
- # Handle existence checks (no value needed)
- if operator in [QueryOperator.EXISTS, QueryOperator.NOT_EXISTS]:
- value_str = "true"
-
- # Validate field
- field = self._validate_and_normalize_field(field)
-
- # Parse value
- value = self._parse_value(field, value_str, operator)
-
- return QueryCondition(
- field=field,
- operator=operator,
- value=value,
- case_sensitive=self._case_sensitive
- )
-
- raise QuerySyntaxError(f"No valid operator found in condition: '{condition_str}'")
-
- def _validate_and_normalize_field(self, field: str) -> str:
- """Validate and normalize a field name."""
- field = field.lower().strip()
-
- # Apply aliases
- if field in self.FIELD_ALIASES:
- field = self.FIELD_ALIASES[field]
-
- # Check if field is supported
- if field not in self.SUPPORTED_FIELDS:
- raise QueryValidationError(
- f"Unsupported field: '{field}'. "
- f"Supported fields: {', '.join(self.SUPPORTED_FIELDS.keys())}"
- )
-
- return field
-
- def _parse_value(self, field: str, value_str: str, operator: QueryOperator) -> Any:
- """Parse a value string based on the field type and operator."""
- if not value_str and operator not in [QueryOperator.EXISTS, QueryOperator.NOT_EXISTS]:
- raise QuerySyntaxError(f"Empty value for field '{field}'")
-
- # Remove quotes if present
- if value_str.startswith('"') and value_str.endswith('"'):
- value_str = value_str[1:-1]
- elif value_str.startswith("'") and value_str.endswith("'"):
- value_str = value_str[1:-1]
-
- # Apply field-specific processing
- if field in self.VALUE_PROCESSORS:
- try:
- return self.VALUE_PROCESSORS[field](value_str)
- except Exception as e:
- raise QueryValidationError(f"Invalid value '{value_str}' for field '{field}': {e}")
-
- # Handle list operators (IN, NOT_IN)
- if operator in [QueryOperator.IN, QueryOperator.NOT_IN]:
- # Parse comma-separated list
- if value_str.startswith('[') and value_str.endswith(']'):
- value_str = value_str[1:-1]
- values = [v.strip().strip('"\'') for v in value_str.split(',')]
- return values
-
- # Type conversion based on field
- expected_type = self.SUPPORTED_FIELDS.get(field, str)
-
- if expected_type == str:
- return value_str if self._case_sensitive else value_str.lower()
- elif expected_type == int:
- try:
- return int(value_str)
- except ValueError:
- raise QueryValidationError(f"Invalid integer value '{value_str}' for field '{field}'")
- elif expected_type == float:
- try:
- return float(value_str)
- except ValueError:
- raise QueryValidationError(f"Invalid float value '{value_str}' for field '{field}'")
- elif expected_type == bool:
- return value_str.lower() in ("true", "1", "yes", "on")
- else:
- return value_str
-
- def filter_satellites(
- self,
- satellites: List[Satellite],
- query: Union[str, List[QueryCondition]]
- ) -> List[Satellite]:
- """
- Filter satellites based on query conditions.
-
- Args:
- satellites: List of satellites to filter
- query: Query string or list of conditions
-
- Returns:
- List[Satellite]: Filtered satellites
- """
- if isinstance(query, str):
- conditions = self.parse_query(query)
- else:
- conditions = query
-
- if not conditions:
- return satellites.copy()
-
- pprint(f"Filtering {len(satellites)} satellites with {len(conditions)} conditions")
-
- filtered = []
- for satellite in satellites:
- if self._matches_all_conditions(satellite, conditions):
- filtered.append(satellite)
-
- pprint(f"Filter result: {len(filtered)} satellites matched")
- return filtered
-
- def _matches_all_conditions(self, satellite: Satellite, conditions: List[QueryCondition]) -> bool:
- """Check if a satellite matches all conditions."""
- for condition in conditions:
- if not self._matches_condition(satellite, condition):
- return False
- return True
-
- def _matches_condition(self, satellite: Satellite, condition: QueryCondition) -> bool:
- """Check if a satellite matches a single condition."""
- try:
- # Get the field value from the satellite
- field_value = self._get_field_value(satellite, condition.field)
-
- # Apply the operator
- return self._apply_operator(field_value, condition.operator, condition.value, condition.case_sensitive)
-
- except Exception as e:
- pprint(f"Error matching condition {condition} on satellite {satellite.alias_name}: {e}")
- return False
-
- def _get_field_value(self, satellite: Satellite, field: str) -> Any:
- """Get the value of a field from a satellite."""
- # Direct property access
- if field == "status":
- return satellite.status.value
- elif field == "room_id":
- return satellite.room_id
- elif field == "alias_name":
- return satellite.alias_name
- elif field == "ip_address":
- return satellite.ip_address or ""
- elif field == "mac_address":
- return satellite.mac_address
- elif field == "version":
- return satellite.version
- elif field == "uptime":
- return satellite.uptime
- elif field == "capabilities":
- return [cap.value for cap in satellite.capabilities]
- elif field == "capability":
- # Special handling for single capability checks
- return [cap.value for cap in satellite.capabilities]
- elif field == "has_errors":
- status = satellite.get_status()
- return status["statistics"]["error_count"] > 0
- elif field == "connection_count":
- status = satellite.get_status()
- return status["statistics"]["connection_count"]
- elif field == "message_count":
- status = satellite.get_status()
- return status["statistics"]["message_count"]
- elif field == "error_count":
- status = satellite.get_status()
- return status["statistics"]["error_count"]
- elif field == "last_seen":
- return satellite._info.last_seen.isoformat() if satellite._info.last_seen else ""
- elif field == "connected_at":
- status = satellite.get_status()
- return status["connection"]["connected_at"] or ""
-
- # If field not found, return empty string
- return ""
-
- def _apply_operator(
- self,
- field_value: Any,
- operator: QueryOperator,
- condition_value: Any,
- case_sensitive: bool
- ) -> bool:
- """Apply an operator to compare field value with condition value."""
-
- # Handle None values
- if field_value is None:
- field_value = ""
-
- # Convert to string for string operations if needed
- if operator in [
- QueryOperator.CONTAINS, QueryOperator.NOT_CONTAINS,
- QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH,
- QueryOperator.REGEX
- ]:
- field_str = str(field_value)
- condition_str = str(condition_value)
-
- if not case_sensitive:
- field_str = field_str.lower()
- condition_str = condition_str.lower()
-
- # Apply operators
- if operator == QueryOperator.EQUALS:
- # Special handling for lists (like capabilities)
- if isinstance(field_value, list):
- if not case_sensitive:
- return any(str(item).lower() == str(condition_value).lower() for item in field_value)
- return condition_value in field_value
- elif isinstance(field_value, str) and not case_sensitive:
- return field_value.lower() == str(condition_value).lower()
- return field_value == condition_value
-
- elif operator == QueryOperator.NOT_EQUALS:
- if isinstance(field_value, str) and not case_sensitive:
- return field_value.lower() != str(condition_value).lower()
- return field_value != condition_value
-
- elif operator == QueryOperator.CONTAINS:
- return condition_str in field_str
-
- elif operator == QueryOperator.NOT_CONTAINS:
- return condition_str not in field_str
-
- elif operator == QueryOperator.STARTS_WITH:
- return field_str.startswith(condition_str)
-
- elif operator == QueryOperator.ENDS_WITH:
- return field_str.endswith(condition_str)
-
- elif operator == QueryOperator.GREATER_THAN:
- try:
- return float(field_value) > float(condition_value)
- except (ValueError, TypeError):
- return str(field_value) > str(condition_value)
-
- elif operator == QueryOperator.LESS_THAN:
- try:
- return float(field_value) < float(condition_value)
- except (ValueError, TypeError):
- return str(field_value) < str(condition_value)
-
- elif operator == QueryOperator.GREATER_EQUAL:
- try:
- return float(field_value) >= float(condition_value)
- except (ValueError, TypeError):
- return str(field_value) >= str(condition_value)
-
- elif operator == QueryOperator.LESS_EQUAL:
- try:
- return float(field_value) <= float(condition_value)
- except (ValueError, TypeError):
- return str(field_value) <= str(condition_value)
-
- elif operator == QueryOperator.IN:
- if isinstance(field_value, list):
- # Check if any item in field_value is in condition_value
- return any(item in condition_value for item in field_value)
- else:
- # Check if field_value is in the list of condition values
- if not case_sensitive and isinstance(field_value, str):
- return any(field_value.lower() == str(cv).lower() for cv in condition_value)
- return field_value in condition_value
-
- elif operator == QueryOperator.NOT_IN:
- if isinstance(field_value, list):
- return not any(item in condition_value for item in field_value)
- else:
- if not case_sensitive and isinstance(field_value, str):
- return not any(field_value.lower() == str(cv).lower() for cv in condition_value)
- return field_value not in condition_value
-
- elif operator == QueryOperator.REGEX:
- pattern = condition_value
- if not case_sensitive:
- pattern = f"(?i){pattern}"
-
- # Use cached compiled regex for performance
- if pattern not in self._compiled_regex_cache:
- try:
- self._compiled_regex_cache[pattern] = re.compile(pattern)
- except re.error as e:
- raise QueryValidationError(f"Invalid regex pattern '{condition_value}': {e}")
-
- regex = self._compiled_regex_cache[pattern]
- return bool(regex.search(str(field_value)))
-
- elif operator == QueryOperator.EXISTS:
- return field_value is not None and field_value != ""
-
- elif operator == QueryOperator.NOT_EXISTS:
- return field_value is None or field_value == ""
-
- else:
- raise QueryError(f"Unsupported operator: {operator}")
-
- def get_supported_fields(self) -> Dict[str, type]:
- """Get all supported fields and their types."""
- return self.SUPPORTED_FIELDS.copy()
-
- def get_field_aliases(self) -> Dict[str, str]:
- """Get field aliases mapping."""
- return self.FIELD_ALIASES.copy()
-
- def validate_query(self, query: str) -> bool:
- """
- Validate a query string without executing it.
-
- Args:
- query: Query string to validate
-
- Returns:
- bool: True if valid, False otherwise
- """
- try:
- self.parse_query(query)
- return True
- except QueryError:
- return False
- # Global parser instance for convenience
- _default_parser = QueryParser(case_sensitive=False)
- def parse_query(query: str) -> List[QueryCondition]:
- """
- Parse a query string using the default parser.
-
- Args:
- query: Query string to parse
-
- Returns:
- List[QueryCondition]: Parsed conditions
- """
- return _default_parser.parse_query(query)
- def validate_query(query: str) -> bool:
- """
- Validate a query string using the default parser.
-
- Args:
- query: Query string to validate
-
- Returns:
- bool: True if valid, False otherwise
- """
- return _default_parser.validate_query(query)
- def filter_satellites(satellites: List[Satellite], query: str) -> List[Satellite]:
- """
- Filter satellites using the default parser.
-
- Args:
- satellites: List of satellites to filter
- query: Query string
-
- Returns:
- List[Satellite]: Filtered satellites
- """
- return _default_parser.filter_satellites(satellites, query)
- def create_query_parser(case_sensitive: bool = False) -> QueryParser:
- """
- Create a new query parser instance.
-
- Args:
- case_sensitive: Whether string comparisons are case-sensitive
-
- Returns:
- QueryParser: New parser instance
- """
- return QueryParser(case_sensitive=case_sensitive)
- # Example usage and testing functions
- def test_query_examples():
- """Test some example queries for validation."""
- test_queries = [
- "status=connected",
- "room=kitchen",
- "status=connected,room=kitchen",
- "capability=wakeword_detection",
- "uptime>3600",
- "error_count=0",
- "alias contains kitchen",
- "ip starts_with 192.168",
- "status in [connected,connecting]",
- "capability exists",
- ]
-
- parser = QueryParser()
-
- for query in test_queries:
- try:
- conditions = parser.parse_query(query)
- pprint(f"✓ '{query}' -> {len(conditions)} conditions")
- except Exception as e:
- pprint(f"✗ '{query}' -> Error: {e}")
- if __name__ == "__main__":
- # Run tests if module is executed directly
- test_query_examples()
- # Module exports
- __all__ = [
- "QueryParser",
- "QueryCondition",
- "QueryOperator",
- "QueryError",
- "QuerySyntaxError",
- "QueryValidationError",
- "parse_query",
- "validate_query",
- "filter_satellites",
- "create_query_parser",
- ]
|