""" 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", ]