diff --git a/src/function.py b/src/function.py index 7fa7274..040ee13 100644 --- a/src/function.py +++ b/src/function.py @@ -1,7 +1,17 @@ -"""This is the main function that will be executed when the automation is triggered. +"""This is the main entry point for the Speckle Automate function. -It will receive the inputs from the user, and the context of the run. -It will then apply the rules to the objects in the model, and report back the results. +The Speckle Automate system works as follows: +1. When a model is committed to Speckle, it triggers automations associated with the project +2. For each automation, Speckle Automate prepares a runtime environment and context +3. The automation context includes the model data and function inputs +4. This function is executed to process the model and provide results +5. Results are attached to objects in the model, creating an annotated view + +This function implements a configurable rule-based validation system that: +- Reads validation rules from an external spreadsheet +- Applies these rules to objects in the Speckle model +- Reports validation results back to the Speckle platform +- Provides an annotated view of the model showing validation issues """ from speckle_automate import AutomationContext @@ -19,38 +29,77 @@ def automate_function( automate_context: AutomationContext, function_inputs: FunctionInputs, ) -> None: - """This VERSION of the function will add a check for the new provide inputs. + """Main entry point for the Speckle Automate function. + + This function is called by the Speckle Automate system when the automation is triggered. + It orchestrates the entire validation process: + + 1. Receiving and flattening the model data + 2. Detecting the Speckle object schema version + 3. Loading and grouping rules from the external spreadsheet + 4. Applying rules to objects and collecting results + 5. Reporting results back to the Speckle platform Args: - automate_context: A context helper object, that carries relevant information - about the runtime context of this function. - It gives access to the Speckle project data, that triggered this run. - It also has convenience methods attach result data to the Speckle model. - function_inputs: An instance object matching the defined schema. + automate_context: A context helper provided by Speckle Automate that: + - Provides access to the Speckle model data + - Handles result reporting and view management + - Manages run status (success, failure, exception) + function_inputs: User-provided inputs defined in the FunctionInputs schema, + particularly the URL to the rules spreadsheet """ - # the context provides a convenient way, to receive the triggering VERSION + # ------------------------------------------------------------------------- + # Step 1: Receive and process the model data + # ------------------------------------------------------------------------- + + # The AutomationContext provides a convenient way to access the model data + # that triggered this automation run version_root_object: Base = automate_context.receive_version() - # We can continue to work with a flattened list of objects. + # Flatten the object tree into a list of objects + # The Speckle object model is hierarchical, but for validation purposes, + # it's easier to work with a flat list of objects flat_list_of_objects = list(flatten_base(version_root_object)) - # If it is a next_gen model, we can get the VERSION from the root object - # This function's rules don't make use of this check, but it is here for reference if you want to. + # ------------------------------------------------------------------------- + # Step 2: Detect Speckle object schema version + # ------------------------------------------------------------------------- + + # The Speckle object schema has evolved over time + # In newer models, we can detect the version from the root object + # This version information helps our validation logic handle different schemas global VERSION VERSION = getattr(version_root_object, "version", 2) # noqa: F841SION = getattr(version_root_object,"version", 2) # noqa: F841 # noqa: F841 - # Read and group rules + # In v2, parameters are stored in a 'parameters' dictionary on each object + # In v3, they are nested in 'properties.Parameters' with categorization + speckle_print(f"Detected Speckle object schema version: {VERSION}") + + # ------------------------------------------------------------------------- + # Step 3: Load and process rules from the spreadsheet + # ------------------------------------------------------------------------- + + # The rules are defined in an external spreadsheet (TSV format) + # This allows non-technical users to define and modify rules + # without changing the code grouped_rules, messages = read_rules_from_spreadsheet(function_inputs.spreadsheet_url) - # Handle any validation messages + # Handle any validation messages from rule processing for message in messages: speckle_print(message) # or log them appropriately + # If rule processing failed, mark the run as failed and exit if grouped_rules is None: automate_context.mark_run_exception("Failed to process rules") return - # apply the rules to the objects + # ------------------------------------------------------------------------- + # Step 4: Apply rules to objects and collect results + # ------------------------------------------------------------------------- + + # This is where the actual validation happens + # Each rule is applied to relevant objects, and results are collected + # Results are attached to objects in the model to create an annotated view apply_rules_to_objects( flat_list_of_objects, grouped_rules, @@ -59,10 +108,16 @@ def automate_function( hide_skipped=function_inputs.hide_skipped, ) - # set the automation context view, to the original model / VERSION view + # ------------------------------------------------------------------------- + # Step 5: Finalize the automation run + # ------------------------------------------------------------------------- + + # Set the context view to the original model/version view + # This ensures that the results are displayed in the correct context automate_context.set_context_view() - # report success + # Mark the run as successful and provide a summary message + # This message will be displayed to the user in the Speckle UI automate_context.mark_run_success( f"Successfully applied {len(grouped_rules)} rules to {len(flat_list_of_objects)} version {VERSION} objects." ) diff --git a/src/rule_processor.py b/src/rule_processor.py index 73cbdca..59bf1d3 100644 --- a/src/rule_processor.py +++ b/src/rule_processor.py @@ -1,4 +1,16 @@ -"""Module for processing rules against Speckle objects and updating the automate context with the results.""" +"""Module for processing rules against Speckle objects and updating the automate context with the results. + +This module implements the core rule processing logic that: +1. Validates rule structure and logic +2. Evaluates rule conditions against Speckle objects +3. Separates filtering conditions and final check conditions +4. Processes rule groups and tracks results +5. Reports results back to the Speckle Automate context + +The rule processing follows a "filter then validate" approach: +- Filter conditions (WHERE, AND) narrow down which objects to check +- The final check condition (CHECK or last AND) determines pass/fail +""" import json from enum import Enum @@ -18,6 +30,11 @@ from src.rules import PropertyRules def validate_rule_structure(rule_group: pd.DataFrame) -> None: """Validates the structure and logic of a rule group. + This ensures the rule follows the proper format: + - First condition must be WHERE + - Following conditions can be AND + - Only one CHECK condition is allowed (and must be last) + Args: rule_group: DataFrame containing the rule conditions @@ -58,47 +75,61 @@ def validate_rule_structure(rule_group: pd.DataFrame) -> None: def evaluate_condition( speckle_object: Base, condition: pd.Series, rule_number: str | None = None, case_number: int | None = None ) -> bool: - """Given a Speckle object and a condition, evaluates the condition and returns a boolean value. + """Evaluates a single condition against a Speckle object. - A condition is a pandas Series object with the following keys: - - 'Property Name': The name of the property to evaluate. - - 'Predicate': The predicate to use for evaluation. - - 'Value': The value to compare against. + This function is the bridge between the rules defined in the spreadsheet + and the property checking methods in PropertyRules. It: + 1. Extracts the property name, predicate, and value from the condition + 2. Maps the predicate to the corresponding method in PropertyRules + 3. Calls the method with the object, property name, and value Args: - rule_number (string): For information the rule number. - case_number (int): For information the rule clause number. - speckle_object (Base): The Speckle object to evaluate. - condition (pd.Series): The condition to evaluate. + speckle_object: The Speckle object to evaluate against + condition: A pandas Series containing the condition details + - 'Property Name': The name of the property to check + - 'Predicate': The comparison operation (like 'equals', 'greater than') + - 'Value': The value to compare against + rule_number: For tracking, the rule number being evaluated + case_number: For tracking, the condition number within the rule Returns: - bool: The result of the evaluation. True if the condition is met, False otherwise. + True if the condition is met, False otherwise """ property_name = condition["Property Name"] predicate_key = condition["Predicate"] value = condition["Value"] + # Debugging info _ = rule_number _ = case_number + # Look up the method name in the predicate map + # This map connects spreadsheet predicates to PropertyRules methods if predicate_key in PREDICATE_METHOD_MAP: method_name = PREDICATE_METHOD_MAP[predicate_key] method = getattr(PropertyRules, method_name, None) if method: + # Call the method with the object, property name, and value return method(speckle_object, property_name, value) return False def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]: - """Separates rule conditions into filters and final check. + """Separates rule conditions into filtering conditions and the final check condition. + + This function handles two rule formats: + 1. Explicit format: WHERE + AND... + CHECK + 2. Legacy format: WHERE + AND... (last AND is implicitly the check) + + This separation enables the "filter then validate" approach. Args: rule_group: DataFrame containing rule conditions Returns: - Tuple containing filter conditions and final check condition + Tuple containing (filter_conditions, final_check_condition) """ if rule_group.empty: return pd.DataFrame(), pd.Series() @@ -135,16 +166,21 @@ def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Se def process_rule( speckle_objects: list[Base], rule_group: pd.DataFrame ) -> tuple[list[Any], list[Any]] | tuple[list[Base], list[Base]]: - """Processes a set of rules against Speckle objects, returning those that pass and fail. + """Processes a rule group against a list of Speckle objects. - The first rule is used as a filter ('WHERE'), and subsequent rules as conditions ('AND'). + This function implements the "filter then validate" approach: + 1. Apply filter conditions sequentially to narrow down objects + 2. Apply the final check condition to determine pass/fail + + This approach is efficient for large models as it reduces the number + of objects that need full validation. Args: - speckle_objects: List of Speckle objects to be processed. - rule_group: DataFrame defining the filter and conditions. + speckle_objects: List of Speckle objects to be processed + rule_group: DataFrame defining the filter and check conditions Returns: - A tuple of lists containing objects that passed and failed the rule. + A tuple of lists (pass_objects, fail_objects) """ if not speckle_objects or rule_group.empty: return [], [] @@ -177,6 +213,7 @@ def process_rule( return [], [] # For remaining objects, evaluate the final check + # This separates objects into pass/fail groups pass_objects = [] fail_objects = [] @@ -198,14 +235,23 @@ def apply_rules_to_objects( minimum_severity: MinimumSeverity = MinimumSeverity.INFO, hide_skipped: bool = False, ) -> dict[str, tuple[list[Base], list[Base]]]: - """Applies defined rules to a list of objects and updates the automate context based on the results. + """Applies defined rules to a list of objects and updates the automate context with the results. + + This is the main orchestration function that: + 1. Processes each rule group against all objects + 2. Filters results based on severity levels + 3. Attaches results to objects in the Speckle Automate context + 4. Reports skipped rules (where no objects matched filters) Args: - speckle_objects (List[Base]): The list of objects to which rules are applied. - grouped_rules (pd.DataFrameGroupBy): The DataFrame containing rule definitions. - automate_context (Any): Context manager for attaching rule results. + speckle_objects: The list of objects to which rules are applied + grouped_rules: The rules grouped by rule number + automate_context: Context manager for attaching results to objects minimum_severity: Minimum severity level to report - hide_skipped: Whether to hide skipped tests + hide_skipped: Whether to hide skipped rules in results + + Returns: + Dictionary mapping rule IDs to (pass_objects, fail_objects) tuples """ grouped_results = {} rules_processed = 0 @@ -249,7 +295,13 @@ def apply_rules_to_objects( class SeverityLevel(Enum): - """Enum for severity levels.""" + """Enumeration for severity levels of rule results. + + These severity levels determine how rule failures are displayed: + - INFO: Informational, no action required + - WARNING: Potential issue that should be reviewed + - ERROR: Critical issue requiring attention + """ INFO = "Info" WARNING = "Warning" @@ -257,13 +309,19 @@ class SeverityLevel(Enum): def get_severity(rule_info: pd.Series) -> SeverityLevel: - """Convert a string severity level to the corresponding SeverityLevel enum. + """Convert a string severity level from the spreadsheet to the corresponding SeverityLevel enum. - This function normalizes input strings (because processing user entered dead is hard), handling: + This function normalizes user input with robust handling for: - Case insensitivity (e.g., "info", "WARNING" → "Info", "Warning") - Shorthand mappings (e.g., "WARN" → "Warning") - - Stripping whitespace - - Defaults to SeverityLevel.ERROR if the input is invalid + - Whitespace handling + - Default fallback to ERROR for invalid input + + Args: + rule_info: Series containing rule information with 'Report Severity' key + + Returns: + Appropriate SeverityLevel enum value """ severity = rule_info.get("Report Severity") # Extract severity from input data @@ -291,15 +349,18 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel: def get_metadata( rule_id: str, rule_info: pd.Series, passed: bool, speckle_objects: list[Base] ) -> dict[str, str | int | Any]: - """Function that generates metadata with severity validation and ensures JSON serializability. + """Generates structured metadata for rule results. - Reasoning is that non-valid metadata fails inside the Automate context. So let's ensure it's valid. + This metadata is attached to objects in the Speckle platform and is: + 1. Validated for JSON serializability + 2. Structured for consistent representation + 3. Includes key information about the rule and results Args: rule_id: Identifier for the rule rule_info: Series containing rule information passed: Boolean indicating if the rule passed - speckle_objects: List of Speckle objects + speckle_objects: List of Speckle objects affected Returns: Dictionary containing metadata if valid JSON serializable, empty dict otherwise @@ -330,14 +391,19 @@ def attach_results( context: AutomationContext, passed: bool, ) -> None: - """Attaches the results of a rule to the objects in the context. + """Attaches rule results to objects in the Speckle Automate context. + + This function is the interface to the Speckle platform for reporting results: + - For failing objects, attaches results with appropriate severity levels + - For passing objects, attaches informational results + - Includes structured metadata for consistent reporting Args: - speckle_objects (List[Base]): The list of objects to which the rule was applied. - rule_info (pd.Series): The information about the rule. - rule_id (str): The ID of the rule. - context (AutomationContext): The context manager for attaching results. - passed (bool): Whether the rule passed or failed. + speckle_objects: The list of objects affected by the rule + rule_info: Information about the rule + rule_id: Identifier for the rule + context: The Speckle Automate context for result attachment + passed: Whether the objects passed the rule """ if not speckle_objects: return @@ -372,7 +438,16 @@ def attach_results( def format_message(rule_info): - """Format the message for the rule.""" + """Format the message for the rule result. + + Handles cases where the message might be None or NaN. + + Args: + rule_info: Series containing rule information with 'Message' key + + Returns: + Formatted message string + """ message = ( str(rule_info["Message"]) if rule_info["Message"] is not None and not pd.isna(rule_info["Message"]) diff --git a/src/rules.py b/src/rules.py index 40e0c57..8f9cd14 100644 --- a/src/rules.py +++ b/src/rules.py @@ -1,4 +1,15 @@ -"""A collection of rules for processing Speckle objects and their properties.""" +"""A collection of rules for processing Speckle objects and their properties. + +This module provides essential utilities for: +1. Accessing and comparing properties across different Speckle object versions (v2/v3) +2. Handling nested property paths with a flexible search mechanism +3. Converting between different value types (strings, booleans, numbers) +4. Implementing various comparison predicates for validation rules + +The core challenge addressed by this module is the evolving schema of Speckle objects. +In v2, parameters were stored directly in a 'parameters' dictionary, while in v3, +they are nested within a more complex 'properties.Parameters' structure with categories. +""" import math import re @@ -11,13 +22,30 @@ PRIMITIVE_TYPES = (bool, int, float, str, type(None)) class Rules: - """A collection of rules for processing properties in Speckle objects.""" + """A collection of rules for processing properties in Speckle objects. + + This class provides utilities for working with displayable objects + in the Speckle ecosystem. + """ @staticmethod def try_get_display_value( speckle_object: Base, ) -> list[Base] | None: - """Try fetching the display value from a Speckle object.""" + """Try fetching the display value from a Speckle object. + + Speckle objects might store display geometry in various ways: + - 'displayValue' (newer versions) + - '@displayValue' (older versions) + + This method handles both cases transparently. + + Args: + speckle_object: The Speckle object to extract display value from + + Returns: + List of Base objects representing display geometry, or None if not found + """ raw_display_value = getattr(speckle_object, "displayValue", None) or getattr( speckle_object, "@displayValue", None ) @@ -34,7 +62,21 @@ class Rules: @staticmethod def is_displayable_object(speckle_object: Base) -> bool: - """Determines if a given Speckle object is displayable.""" + """Determines if a given Speckle object is displayable. + + A Speckle object is considered displayable if: + 1. It has an ID and displayable geometry, OR + 2. It has a definition with an ID and displayable geometry + (typically for instanced objects) + + This is useful for filtering out non-visible/utility objects. + + Args: + speckle_object: The Speckle object to check + + Returns: + True if the object is displayable, False otherwise + """ display_values = Rules.try_get_display_value(speckle_object) if display_values and getattr(speckle_object, "id", None) is not None: return True @@ -49,7 +91,17 @@ class Rules: @staticmethod def get_displayable_objects(flat_list_of_objects: list[Base]) -> list[Base]: - """Filters a list of Speckle objects to only include displayable objects.""" + """Filters a list of Speckle objects to only include displayable objects. + + This is useful when processing a flattened object tree but only wanting + to work with objects that have visual representation. + + Args: + flat_list_of_objects: A list of Speckle objects to filter + + Returns: + A filtered list containing only displayable objects with IDs + """ return [ speckle_object for speckle_object in flat_list_of_objects @@ -58,19 +110,30 @@ class Rules: class PropertyRules: - """A collection of rules for processing parameters in Speckle objects.""" + """A collection of rules for processing parameters in Speckle objects. + + This class provides the core functionality for: + - Locating properties in complex object hierarchies + - Converting between different value types + - Comparing values with appropriate type handling + - Implementing various comparison predicates for validation rules + + It's designed to work with both Speckle v2 and v3 object schemas. + """ @staticmethod def is_parameter_value_not_containing(speckle_object: Base, parameter_name: str, substring: str) -> bool: """Checks if parameter value does not contain the given substring. + This is the logical inverse of is_parameter_value_containing. + Args: - speckle_object: The Speckle object to check - parameter_name: Name of the parameter to check - substring: The substring to look for + speckle_object: The Speckle object to check + parameter_name: Name of the parameter to check + substring: The substring to look for Returns: - bool: True if the parameter value does not contain the substring + True if the parameter value does not contain the substring """ # Invert the result of contains check return not PropertyRules.is_parameter_value_containing(speckle_object, parameter_name, substring) @@ -79,13 +142,16 @@ class PropertyRules: def is_parameter_value_containing(speckle_object: Base, parameter_name: str, substring: str) -> bool: """Checks if parameter value contains the given substring. + Case-insensitive substring matching for parameters. + If the parameter doesn't exist, returns False. + Args: speckle_object: The Speckle object to check parameter_name: Name of the parameter to check substring: The substring to look for Returns: - bool: True if the parameter value contains the substring + True if the parameter value contains the substring """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if parameter_value is None: @@ -102,14 +168,41 @@ class PropertyRules: @staticmethod def normalize_path(path: str) -> str: - """Remove technical path prefixes like 'properties' and 'parameters'.""" + """Remove technical path prefixes like 'properties' and 'parameters'. + + This helps make property paths version-agnostic by focusing on the + meaningful parts of the path rather than the container structure. + + Examples: + - 'properties.Parameters.Type Parameters.Construction.Width' becomes 'Type Parameters.Construction.Width' + - 'parameters.WALL_ATTR_WIDTH_PARAM' becomes 'WALL_ATTR_WIDTH_PARAM' + + Args: + path: The parameter path to normalize + + Returns: + A normalized path with technical prefixes removed + """ parts = path.split(".") filtered = [p for p in parts if p.lower() not in ("properties", "parameters")] return ".".join(filtered) @staticmethod def convert_revit_boolean(value: Any) -> Any: - """Convert Revit-style Yes/No strings to boolean values.""" + """Convert Revit-style Yes/No strings to boolean values. + + Revit and some other BIM applications use "Yes"/"No" strings + instead of boolean values. This function converts them: + - "Yes" → True + - "No" → False + - Other values remain unchanged + + Args: + value: The value to potentially convert + + Returns: + Converted boolean if applicable, otherwise original value + """ # Handle None case if value is None: return None @@ -131,7 +224,20 @@ class PropertyRules: @staticmethod def get_obj_value(obj: Any, get_raw: bool = False) -> Any: - """Extract appropriate value from an object, handling special cases.""" + """Extract appropriate value from an object, handling special cases. + + This function handles the various ways values might be stored: + - In v2 Parameter objects (with .value property) + - In v3 dictionary structures (with 'value' key) + - As primitive values directly + + Args: + obj: The object to extract value from + get_raw: If True, return the object itself without extracting value + + Returns: + The extracted value, possibly with Yes/No conversion + """ if get_raw: return obj @@ -155,7 +261,19 @@ class PropertyRules: @staticmethod def search_obj(obj: Any, parts: list[str]) -> tuple[bool, Any]: - """Recursively search an object following a path.""" + """Recursively search an object following a path. + + This is a key part of the property access mechanism, allowing + navigation through nested object structures using dot notation. + The search is case-insensitive to handle inconsistencies. + + Args: + obj: The object to search within + parts: List of path components to follow + + Returns: + Tuple of (found: bool, value: Any) + """ if not parts: return True, obj @@ -184,6 +302,14 @@ class PropertyRules: def find_property(root: Any, search_path: str, get_raw: bool = False) -> tuple[bool, Any]: """Find a property by searching through nested objects. + This method implements a flexible property search that: + 1. First attempts a direct path match + 2. Then recursively searches through nested object structures + 3. Uses cycle detection to prevent infinite recursion + + The approach handles both v2 and v3 Speckle object schemas and + supports fuzzy property matching by normalizing paths. + Args: root: The root object to search search_path: Path to the property to find @@ -241,7 +367,17 @@ class PropertyRules: @staticmethod def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool: - """Check if a parameter exists in the Speckle object.""" + """Check if a parameter exists in the Speckle object. + + This method is version-agnostic and works with both v2 and v3 objects. + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to look for + + Returns: + True if parameter exists, False otherwise + """ found, _ = PropertyRules.find_property(speckle_object, parameter_name) return found @@ -252,29 +388,58 @@ class PropertyRules: default_value: Any = None, get_raw: bool = False, ) -> Any: - """Get a parameter value from the Speckle object using strict path matching. + """Get a parameter value from the Speckle object using path matching. + + This is the core property access method that: + 1. Handles both v2 and v3 object structures + 2. Supports direct and nested property paths + 3. Applies appropriate value extraction and conversion Args: speckle_object: The Speckle object to search - parameter_name: Exact parameter path to find + parameter_name: Parameter path to find default_value: Value to return if parameter not found get_raw: Whether to return raw values without conversion Returns: - The parameter value if found using exact path matching, otherwise default_value + The parameter value if found, otherwise default_value """ found, value = PropertyRules.find_property(speckle_object, parameter_name, get_raw) return value if found else default_value @staticmethod def is_parameter_value(speckle_object: Base, parameter_name: str, value_to_match: Any) -> bool: - """Checks if the value of the specified parameter matches the given value.""" + """Checks if the value of the specified parameter matches the given value. + + This is a basic equality check that leverages the parameter access system. + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + value_to_match: The value to compare against + + Returns: + True if values match, False otherwise + """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) return parameter_value == value_to_match @staticmethod def parse_number_from_string(input_string: str): - """Attempts to parse a number from a string.""" + """Attempts to parse a number from a string. + + First tries to parse as integer, then as float if that fails. + Raises ValueError if the string is not a valid number. + + Args: + input_string: The string to parse + + Returns: + int or float value + + Raises: + ValueError: If the string is not a valid number + """ try: return int(input_string) except ValueError: @@ -287,8 +452,19 @@ class PropertyRules: def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool: """Checks if parameter value is greater than threshold. + This implements the 'greater than' predicate for numeric comparisons. + Note: From a UX perspective, if someone writes 'height greater than 2401', - they mean "flag an error if height <= 2401". So we flip the comparison. + they mean "flag an error if height <= 2401". So we implement the check to match + that intuitive interpretation. + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + threshold: The threshold value as a string + + Returns: + True if parameter value > threshold, False otherwise """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if parameter_value is None: @@ -303,8 +479,19 @@ class PropertyRules: def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool: """Checks if parameter value is less than threshold. + This implements the 'less than' predicate for numeric comparisons. + Note: From a UX perspective, if someone writes 'height less than 2401', - they mean "flag an error if height >= 2401". So we flip the comparison. + they mean "flag an error if height >= 2401". So we implement the check to match + that intuitive interpretation. + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + threshold: The threshold value as a string + + Returns: + True if parameter value < threshold, False otherwise """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if parameter_value is None: @@ -318,10 +505,21 @@ class PropertyRules: @staticmethod def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool: - """Checks if parameter value falls outside specified range. + """Checks if parameter value falls within specified range. + + This implements the 'in range' predicate for numeric comparisons. + The range is specified as "min,max" and is inclusive. Note: From a UX perspective, if someone writes 'height in range 2401,3000', they mean "flag an error if height < 2401 or height > 3000". + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + value_range: Range specification as "min,max" + + Returns: + True if min <= parameter value <= max, False otherwise """ min_value, max_value = value_range.split(",") min_value = PropertyRules.parse_number_from_string(min_value) @@ -345,7 +543,22 @@ class PropertyRules: fuzzy: bool = False, threshold: float = 0.8, ) -> bool: - """Checks if parameter value matches pattern.""" + """Checks if parameter value matches pattern. + + This implements the 'is like' predicate with two modes: + 1. Regular expression matching (fuzzy=False) + 2. Levenshtein distance-based fuzzy matching (fuzzy=True) + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + pattern: Regex pattern or string to match + fuzzy: Whether to use fuzzy matching + threshold: Similarity threshold for fuzzy matching (0.0-1.0) + + Returns: + True if the parameter value matches the pattern, False otherwise + """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if parameter_value is None: return False @@ -358,7 +571,20 @@ class PropertyRules: @staticmethod def is_parameter_value_in_list(speckle_object: Base, parameter_name: str, value_list: list[Any] | str) -> bool: - """Checks if parameter value is in list.""" + """Checks if parameter value is in list. + + This implements the 'in list' predicate, supporting both: + 1. Python lists + 2. Comma-separated string lists + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + value_list: List of values or comma-separated string + + Returns: + True if parameter value is in the list, False otherwise + """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if isinstance(value_list, str): @@ -373,7 +599,19 @@ class PropertyRules: @staticmethod def check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool: - """Check if a value matches any target value in expected format.""" + """Check if a value matches any target value in expected format. + + This is a helper for boolean parameter checking that handles: + - Boolean literals (True/False) + - String representations ("yes", "true", "1", etc.) + + Args: + value: The value to check + values_to_match: Tuple of string values representing the target state + + Returns: + True if value matches any target value, False otherwise + """ if isinstance(value, bool): return value is (True if "true" in values_to_match else False) @@ -384,35 +622,103 @@ class PropertyRules: @staticmethod def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool: - """Check if parameter value represents true.""" + """Check if parameter value represents true. + + This implements the 'is true' predicate, handling various + representations of true values ("yes", "true", "1"). + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + + Returns: + True if parameter value represents true, False otherwise + """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) return PropertyRules.check_boolean_value(parameter_value, ("yes", "true", "1")) @staticmethod def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool: - """Check if parameter value represents false.""" + """Check if parameter value represents false. + + This implements the 'is false' predicate, handling various + representations of false values ("no", "false", "0"). + + Args: + speckle_object: The Speckle object to check + parameter_name: The parameter name/path to check + + Returns: + True if parameter value represents false, False otherwise + """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) return PropertyRules.check_boolean_value(parameter_value, ("no", "false", "0")) @staticmethod def has_category(speckle_object: Base) -> bool: - """Check if object has category.""" + """Check if object has category. + + This is a convenience method specifically for checking + the existence of the 'category' property. + + Args: + speckle_object: The Speckle object to check + + Returns: + True if object has a category property, False otherwise + """ return PropertyRules.has_parameter(speckle_object, "category") @staticmethod def is_category(speckle_object: Base, category_input: str) -> bool: - """Check if object matches category.""" + """Check if object matches category. + + This is a convenience method for filtering objects by category, + which is a common operation in Speckle. + + Args: + speckle_object: The Speckle object to check + category_input: The category value to match + + Returns: + True if object's category matches input, False otherwise + """ category_value = PropertyRules.get_parameter_value(speckle_object, "category") return category_value == category_input @staticmethod def get_category_value(speckle_object: Base) -> str: - """Get object's category value.""" + """Get object's category value. + + This is a convenience method for retrieving an object's category. + + Args: + speckle_object: The Speckle object to get category from + + Returns: + The category value as a string + """ return PropertyRules.get_parameter_value(speckle_object, "category") @staticmethod def try_boolean_comparison(value1: Any, value2: Any, allow_yes_no: bool) -> tuple[bool, bool]: - """Attempts to compare two values as booleans.""" + """Attempts to compare two values as booleans. + + This handles various boolean representations: + - Boolean literals (True/False) + - String representations ("true"/"false") + - Revit-style "Yes"/"No" strings (if allow_yes_no=True) + + Args: + value1: First value to compare + value2: Second value to compare + allow_yes_no: Whether to convert Yes/No strings to booleans + + Returns: + Tuple of (can_compare: bool, result: bool) where: + - can_compare indicates if both values could be interpreted as booleans + - result is the comparison result if can_compare is True + """ def strict_convert_boolean(value: Any) -> Any: """Convert 'True'/'False' strings to booleans, and use `convert_revit_boolean` for Yes/No.""" @@ -451,15 +757,25 @@ class PropertyRules: ) -> bool: """Core logic for comparing two values with type handling and tolerance. + This is the comprehensive value comparison function that: + 1. Tries boolean comparison first + 2. Handles numeric string conversion + 3. Implements case sensitivity options for strings + 4. Uses tolerance-based floating point comparison + 5. Falls back to regular equality + + This function is used by multiple predicates. + Args: value1: First value to compare value2: Second value to compare case_sensitive: Whether to perform case-sensitive string comparison tolerance: Tolerance for floating point comparisons - allow_yes_no_bools: Whether to convert Yes/No strings to booleans when comparing with boolean values + allow_yes_no_bools: Whether to convert Yes/No strings to booleans use_exact: Whether to use exact equality for numeric comparisons + Returns: - bool: True if values are considered equal, False otherwise + True if values are considered equal, False otherwise """ # Try boolean comparison first can_compare, result = PropertyRules.try_boolean_comparison(value1, value2, allow_yes_no_bools) @@ -502,15 +818,20 @@ class PropertyRules: ) -> bool: """Compares a parameter value from a Speckle object with the provided value. + This implements the 'equal to' predicate with flexible comparison rules: + - Case insensitivity option for strings + - Tolerance-based comparison for floating point numbers + - Type conversion for common scenarios (numeric strings, Yes/No) + Args: - speckle_object (Base): The Speckle object containing the parameter - parameter_name (str): Name of the parameter to compare - value_to_match: The value to compare against (float, string, int, etc.) - case_sensitive (bool): Whether to perform case-sensitive comparison for strings - tolerance (float): Tolerance for floating point comparisons + speckle_object: The Speckle object containing the parameter + parameter_name: Name of the parameter to compare + value_to_match: The value to compare against + case_sensitive: Whether to perform case-sensitive comparison for strings + tolerance: Tolerance for floating point comparisons Returns: - bool: True if values are considered equal, False otherwise + True if values are considered equal, False otherwise """ parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) if parameter_value is None: diff --git a/src/rules_old.py b/src/rules_old.py deleted file mode 100644 index e445931..0000000 --- a/src/rules_old.py +++ /dev/null @@ -1,756 +0,0 @@ -# import re -# from typing import Any -# -# from Levenshtein import ratio -# from specklepy.objects.base import Base -# -# from src.helpers import get_item, has_item, speckle_print -# from src.inputs import PropertyMatchMode - -# We're going to define a set of rules that will allow us to filter and -# process parameters in our Speckle objects. These rules will be encapsulated -# in a class called `ParameterRules`. - - -# class Rules: -# """A collection of rules for processing properties in Speckle objects. -# -# Simple rules can be straightforwardly implemented as static methods that -# return boolean value to be used either as a filter or a condition. -# These can then be abstracted into returning lambda functions that we can -# use in our main processing logic. By encapsulating these rules, we can easily -# extend or modify them in the future. -# """ -# -# @staticmethod -# def try_get_display_value( -# speckle_object: Base, -# ) -> list[Base] | None: -# """Try fetching the display value from a Speckle object. -# -# This method encapsulates the logic for attempting to retrieve the display value from a -# Speckle object. It returns a list containing the display values if found, -# otherwise it returns None. -# -# Args: -# speckle_object (Base): The Speckle object to extract the display value from. -# -# Returns: -# Optional[List[Base]]: A list containing the display values. -# If no display value is found, returns None. -# """ -# # Attempt to get the display value from the speckle_object -# raw_display_value = getattr(speckle_object, "displayValue", None) or getattr( -# speckle_object, "@displayValue", None -# ) -# -# # If no display value found, return None -# if raw_display_value is None: -# return None -# -# # If display value found, filter out non-Base objects -# display_values = [value for value in raw_display_value if isinstance(value, Base)] -# -# # If no valid display values found, return None -# if not display_values: -# return None -# -# return display_values -# -# @staticmethod -# def is_displayable_object(speckle_object: Base) -> bool: -# """Determines if a given Speckle object is displayable. -# -# This method encapsulates the logic for determining if a Speckle object is displayable. -# It checks if the speckle_object has a display value and returns True if it does, otherwise it returns False. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# -# Returns: -# bool: True if the object has a display value, False otherwise. -# """ -# # Check for direct displayable state using try_get_display_value -# display_values = Rules.try_get_display_value(speckle_object) -# if display_values and getattr(speckle_object, "id", None) is not None: -# return True -# -# # Check for displayable state via definition, using try_get_display_value on the definition object -# definition = getattr(speckle_object, "definition", None) -# if definition: -# definition_display_values = Rules.try_get_display_value(definition) -# if definition_display_values and getattr(definition, "id", None) is not None: -# return True -# -# return False -# -# @staticmethod -# def get_displayable_objects(flat_list_of_objects: list[Base]) -> list[Base]: -# """Filters a list of Speckle objects to only include displayable objects. -# -# This function takes a list of Speckle objects and filters out the objects that are displayable. -# It returns a list containing only the displayable objects. -# -# Args: -# flat_list_of_objects (List[Base]): The list of Speckle objects to filter. -# """ -# return [ -# speckle_object -# for speckle_object in flat_list_of_objects -# if Rules.is_displayable_object(speckle_object) and getattr(speckle_object, "id", None) -# ] -# -# -# class PropertyRules: -# """A collection of rules for processing Revit parameters in Speckle objects.""" -# -# @staticmethod -# def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool: -# """Checks if the speckle_object has a parameter with the given name.""" -# found, _ = ParameterSearch.lookup_parameter(speckle_object, parameter_name) -# return found -# -# @staticmethod -# def get_parameter_value( -# speckle_object: Base, -# parameter_name: str, -# match_mode: PropertyMatchMode = PropertyMatchMode.MIXED, -# default_value: Any = None, -# ) -> Any: -# """Gets the value of a parameter if it exists.""" -# found, value = ParameterSearch.lookup_parameter(speckle_object, parameter_name, match_mode) -# return value if found else default_value -# -# @staticmethod -# def is_v3(speckle_object: Base) -> bool: -# """Determines if a Speckle object uses v3 parameter structure. -# -# Args: -# speckle_object (Base): The Speckle object to check -# -# Returns: -# bool: True if object uses v3 structure, False otherwise -# """ -# properties = get_item(speckle_object, "properties") -# return bool(properties and has_item(properties, "Parameters")) -# -# # @staticmethod -# # def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool: -# # """Checks if the speckle_object has a Revit parameter with the given name. -# # -# # First checks direct properties, then determines if it's a v2 or v3 object structure -# # and searches in the appropriate parameter hierarchy. -# # -# # Args: -# # speckle_object (Base): The Speckle object to check. -# # parameter_name (str): The name of the parameter to check for. -# # *_args: Extra positional arguments which are ignored. -# # **_kwargs: Extra keyword arguments which are ignored. -# # -# # Returns: -# # bool: True if the object has the parameter, False otherwise. -# # """ -# # # Check direct property first regardless of version -# # if has_item(speckle_object, parameter_name): -# # return True -# # -# # if PropertyRules.is_v3(speckle_object): -# # properties = get_item(speckle_object, "properties") -# # parameters = get_item(properties, "Parameters") -# # if parameters: -# # -# # def search_v3_params(params: dict, search_name: str) -> bool: -# # for key, value in params.items(): -# # if isinstance(value, dict): -# # # Check direct name match -# # if key.lower() == search_name.lower(): -# # return True -# # # Check nested parameters -# # if search_v3_params(value, search_name): -# # return True -# # return False -# # -# # return search_v3_params(parameters, parameter_name) -# # else: -# # # Handle v2 structure -# # parameters = get_item(speckle_object, "parameters") -# # if not parameters: -# # return False -# # -# # # Check direct parameter name match -# # if has_item(parameters, parameter_name): -# # return True -# # -# # # Check nested parameters with name property -# # def check_nested_name(value: Any) -> bool: -# # if isinstance(value, dict): -# # return get_item(value, "name") == parameter_name -# # return get_item(value, "name") == parameter_name if hasattr(value, "name") else False -# # -# # return any(check_nested_name(param_value) for param_value in parameters.values() if param_value is not None) -# # -# # return False -# # -# # @staticmethod -# # def get_parameter_value( -# # speckle_object: Base, -# # parameter_name: str, -# # match_mode: PropertyMatchMode = PropertyMatchMode.MIXED, -# # default_value: Any = None, -# # ) -> Any | None: -# # """Retrieves the value of the specified parameter from the speckle_object. -# # -# # First checks direct properties, then determines if it's a v2 or v3 object structure -# # and retrieves from the appropriate parameter hierarchy. -# # -# # Args: -# # speckle_object (Base): The Speckle object to retrieve the parameter value from. -# # parameter_name (str): The name of the parameter to retrieve the value for. -# # match_mode (PropertyMatchMode): The matching mode to use for parameter lookup -# # default_value: The default value to return if parameter not found. -# # -# # Returns: -# # The value of the parameter if found, else default_value. -# # """ -# # # Check direct property first regardless of version -# # if has_item(speckle_object, parameter_name): -# # value = get_item(speckle_object, parameter_name) -# # return value if value is not None else default_value -# # -# # if PropertyRules.is_v3(speckle_object): -# # return PropertyRules.get_v3_parameter(speckle_object, parameter_name, match_mode, default_value) -# # else: -# # return PropertyRules.get_v2_parameter(speckle_object, parameter_name, match_mode, default_value) -# -# # @staticmethod -# # def get_v2_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any: -# # """Get parameter value from v2 Speckle object structure. -# # -# # Args: -# # obj: Speckle object to get parameter from -# # name: Parameter name to retrieve -# # mode: Match mode for parameter lookup -# # default: Default value if parameter not found -# # -# # Returns: -# # Parameter value if found, else default -# # """ -# # parameters = get_item(obj, "parameters") -# # if not parameters: -# # return default -# # -# # if mode == PropertyMatchMode.STRICT: -# # return PropertyRules.strict_parameter_lookup(name, parameters, default) -# # -# # def search_params(param_dict: dict, search_name: str, fuzzy: bool) -> Any: -# # for key, value in param_dict.items(): -# # key_match = (key.lower() == search_name.lower()) or (fuzzy and search_name.lower() in key.lower()) -# # if key_match: -# # # Handle both direct values and nested parameter objects -# # return get_item(value, "value", value) -# # return None -# # -# # result = search_params(parameters, name, mode == PropertyMatchMode.FUZZY) -# # return result if result is not None else default -# # -# # @staticmethod -# # def get_v3_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any: -# # """Get parameter value from v3 Speckle object structure. -# # -# # Args: -# # obj: Speckle object to get parameter from -# # name: Parameter name to retrieve -# # mode: Match mode for parameter lookup -# # default: Default value if parameter not found -# # -# # Returns: -# # Parameter value if found, else default -# # """ -# # properties = get_item(obj, "properties") -# # if not properties or not has_item(properties, "Parameters"): -# # return default -# # -# # parameters = get_item(properties, "Parameters") -# # if not parameters: -# # return default -# # -# # if mode == PropertyMatchMode.STRICT: -# # return PropertyRules.strict_parameter_lookup(name, parameters, default) -# # -# # def search_nested(data: dict, search_name: str, fuzzy: bool) -> Any: -# # for nested_key, value in data.items(): -# # if isinstance(value, dict): -# # key_match = (nested_key.lower() == search_name.lower()) or ( -# # fuzzy and search_name.lower() in nested_key.lower() -# # ) -# # -# # if key_match and has_item(value, "value"): -# # return get_item(value, "value") -# # -# # nested_result = search_nested(value, search_name, fuzzy) -# # if nested_result is not None: -# # return nested_result -# # return None -# # -# # result = search_nested(parameters, name, mode == PropertyMatchMode.FUZZY) -# # return result if result is not None else default -# # -# # @staticmethod -# # def strict_parameter_lookup(name: str, parameters: dict, default: Any) -> Any: -# # """Perform strict parameter lookup following exact path. -# # -# # Args: -# # name: Parameter path (dot separated) -# # parameters: Parameters dictionary -# # default: Default value if not found -# # -# # Returns: -# # Parameter value if found, else default -# # """ -# # path_parts = name.split(".") -# # current = parameters -# # -# # for part in path_parts: -# # if not current or not isinstance(current, dict): -# # return default -# # -# # # Find exact case-insensitive match -# # key = next((k for k in current.keys() if k.lower() == part.lower()), None) -# # if not key: -# # return default -# # -# # current = get_item(current, key) -# # -# # # Handle both direct values and parameter objects -# # if isinstance(current, dict): -# # return get_item(current, "value", current) -# # return current -# -# @staticmethod -# def is_parameter_value(speckle_object: Base, parameter_name: str, value_to_match: Any) -> bool: -# """Checks if the value of the specified parameter matches the given value. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# value_to_match (Any): The value to match against. -# -# Returns: -# bool: True if the parameter value matches the given value, False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# return parameter_value == value_to_match -# -# @staticmethod -# def is_parameter_value_like( -# speckle_object: Base, -# parameter_name: str, -# pattern: str, -# fuzzy: bool = False, -# threshold: float = 0.8, -# ) -> bool: -# """Checks if the value of the specified parameter matches the given pattern. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# pattern (str): The pattern to match against. -# fuzzy (bool): If True, performs fuzzy matching using Levenshtein distance. -# If False (default), performs exact pattern matching using regular expressions. -# threshold (float): The similarity threshold for fuzzy matching (default: 0.8). -# Only applicable when fuzzy=True. -# -# Returns: -# bool: True if the parameter value matches the pattern (exact or fuzzy), False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# if parameter_value is None: -# return False -# -# if fuzzy: -# similarity = ratio(str(parameter_value), pattern) -# return similarity >= threshold -# else: -# return bool(re.match(pattern, str(parameter_value))) -# -# @staticmethod -# def parse_number_from_string(input_string: str): -# """Attempts to parse an integer or float from a given string. -# -# Args: -# input_string (str): The string containing the number to be parsed. -# -# Returns: -# int or float: The parsed number, or raises ValueError if parsing is not possible. -# """ -# try: -# # First try to convert it to an integer -# return int(input_string) -# except ValueError: -# # If it fails to convert to an integer, try to convert to a float -# try: -# return float(input_string) -# except ValueError: -# # Raise an error if neither conversion is possible -# raise ValueError("Input string is not a valid integer or float") -# -# @staticmethod -# def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool: -# """Checks if the value of the specified parameter is greater than the given threshold. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# threshold (Union[int, float]): The threshold value to compare against. -# -# Returns: -# bool: True if the parameter value is greater than the threshold, False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# if parameter_value is None: -# return False -# -# if not isinstance(parameter_value, int | float): -# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}") -# return parameter_value > PropertyRules.parse_number_from_string(threshold) -# -# @staticmethod -# def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool: -# """Checks if the value of the specified parameter is less than the given threshold. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# threshold (Union[int, float]): The threshold value to compare against. -# -# Returns: -# bool: True if the parameter value is less than the threshold, False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# if parameter_value is None: -# return False -# if not isinstance(parameter_value, int | float): -# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}") -# return parameter_value < PropertyRules.parse_number_from_string(threshold) -# -# @staticmethod -# def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool: -# """Checks if the value of the specified parameter falls within the given range. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# value_range (str): The range to check against, in the format "min_value, max_value". -# -# Returns: -# bool: True if the parameter value falls within the range (inclusive), False otherwise. -# """ -# min_value, max_value = value_range.split(",") -# min_value = PropertyRules.parse_number_from_string(min_value) -# max_value = PropertyRules.parse_number_from_string(max_value) -# -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# if parameter_value is None: -# return False -# if not isinstance(parameter_value, int | float): -# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}") -# -# return min_value <= parameter_value <= max_value -# -# @staticmethod -# def is_parameter_value_in_range_expanded( -# speckle_object: Base, -# parameter_name: str, -# min_value: int | float, -# max_value: int | float, -# inclusive: bool = True, -# ) -> bool: -# """Checks if the value of the specified parameter falls within the given range. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# min_value (Union[int, float]): The minimum value of the range. -# max_value (Union[int, float]): The maximum value of the range. -# inclusive (bool): If True (default), the range is inclusive (min <= value <= max). -# If False, the range is exclusive (min < value < max). -# -# Returns: -# bool: True if the parameter value falls within the range (inclusive), False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# if parameter_value is None: -# return False -# if not isinstance(parameter_value, int | float): -# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}") -# -# return min_value <= parameter_value <= max_value if inclusive else min_value < parameter_value < max_value -# -# @staticmethod -# def is_parameter_value_in_list(speckle_object: Base, parameter_name: str, value_list: list[Any] | str) -> bool: -# """Checks if the value of the specified parameter is present in the given list of values. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# parameter_name (str): The name of the parameter to check. -# value_list (List[Any]): The list of values to check against. -# -# Returns: -# bool: True if the parameter value is found in the list, False otherwise. -# """ -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# -# if isinstance(value_list, str): -# value_list = [value.strip() for value in value_list.split(",")] -# -# # parameter_value is effectively Any type, so to find its value in the value_list -# def is_value_in_list(value: Any, my_list: Any) -> bool: -# # Ensure that my_list is actually a list -# if isinstance(my_list, list): -# return value in my_list or str(value) in my_list -# else: -# speckle_print(f"Expected a list, got {type(my_list)} instead.") -# return False -# -# return is_value_in_list(parameter_value, value_list) -# -# @staticmethod -# def _check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool: -# """Check if a value matches any target value in expected format.""" -# if isinstance(value, bool): -# return value is (True if "true" in values_to_match else False) -# -# if isinstance(value, str): -# return value.lower() in values_to_match -# -# return False -# -# @staticmethod -# def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool: -# """Check if parameter value represents true (boolean True, 'yes', 'true', '1').""" -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# return PropertyRules._check_boolean_value(parameter_value, ("yes", "true", "1")) -# -# @staticmethod -# def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool: -# """Check if parameter value represents false (boolean False, 'no', 'false', '0').""" -# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name) -# return PropertyRules._check_boolean_value(parameter_value, ("no", "false", "0")) -# -# @staticmethod -# def has_category(speckle_object: Base) -> bool: -# """Checks if the speckle_object has a 'category' parameter. -# -# This method checks if the speckle_object has a 'category' parameter. -# If the 'category' parameter exists, it returns True; otherwise, it returns False. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# -# Returns: -# bool: True if the object has the 'category' parameter, False otherwise. -# """ -# return PropertyRules.has_parameter(speckle_object, "category") -# -# @staticmethod -# def is_category(speckle_object: Base, category_input: str) -> bool: -# """Checks if the value of the 'category' property matches the given input. -# -# This method checks if the 'category' property of the speckle_object -# matches the given category_input. If they match, it returns True; -# otherwise, it returns False. -# -# Args: -# speckle_object (Base): The Speckle object to check. -# category_input (str): The category value to compare against. -# -# Returns: -# bool: True if the 'category' property matches the input, False otherwise. -# """ -# category_value = PropertyRules.get_parameter_value(speckle_object, "category") -# return category_value == category_input -# -# @staticmethod -# def get_category_value(speckle_object: Base) -> str: -# """Retrieves the value of the 'category' parameter from the speckle_object. -# -# This method retrieves the value of the 'category' parameter from the speckle_object. -# If the 'category' parameter exists and its value is not None, it returns the value. -# If the 'category' parameter does not exist or its value is None, it returns an empty string. -# -# Args: -# speckle_object (Base): The Speckle object to retrieve the 'category' parameter value from. -# -# Returns: -# str: The value of the 'category' parameter if it exists and is not None, or an empty string otherwise. -# """ -# return PropertyRules.get_parameter_value(speckle_object, "category") -# -# -# class ParameterSearch: -# """Unified parameter search functionality for Speckle objects.""" -# -# @staticmethod -# def convert_revit_boolean(value: Any) -> Any: -# """Convert Revit-style Yes/No strings to boolean values. -# -# Args: -# value: The value to potentially convert -# -# Returns: -# bool if value is a Revit boolean string, original value otherwise -# """ -# if isinstance(value, str): -# if value.lower() == "yes": -# return True -# if value.lower() == "no": -# return False -# return value -# -# @staticmethod -# def search_parameters( -# params: dict, search_name: str, mode: PropertyMatchMode = PropertyMatchMode.STRICT -# ) -> tuple[bool, Any]: -# """Search for parameters using consistent matching logic. -# -# Supports flexible property chain matching that can find paths like "Instance Parameters.Dimensions.Length" -# within longer chains like "properties.Parameters.Instance Parameters.Dimensions.Length.value". -# Uses STRICT matching by default for more predictable results. -# -# Args: -# params: Parameter dictionary to search -# search_name: Name of parameter to find, can be dot-separated chain -# mode: Matching mode to use (STRICT by default, or FUZZY/MIXED for looser matching) -# -# Returns: -# Tuple of (value_found: bool, value: Any) -# """ -# -# def matches_name(match_key: str, target: str, match_mode: PropertyMatchMode) -> bool: -# if match_mode == PropertyMatchMode.STRICT: -# return match_key.lower() == target.lower() -# elif match_mode == PropertyMatchMode.FUZZY: -# return target.lower() in match_key.lower() -# else: # MIXED mode -# return match_key.lower() == target.lower() or target.lower() in match_key.lower() -# -# def try_get_value(obj: Any) -> Any: -# """Extract value from parameter object or return as is. -# -# Handles both dict and Base objects, checking for 'value' property in both cases. -# Returns the 'value' if found, otherwise returns the original object. -# """ -# # Handle dictionary objects -# if isinstance(obj, dict): -# return obj.get("value", obj) -# -# # Handle Base objects -# if isinstance(obj, Base): -# return getattr(obj, "value", obj) -# -# # For all other types, return as is -# return obj -# -# # First try property chain lookup -# if "." in search_name: -# search_parts = search_name.split(".") -# -# def try_match_path(current: dict, remaining_search_parts: list[str], depth: int = 0) -> tuple[bool, Any]: -# if not isinstance(current, dict): -# return False, None -# -# if not remaining_search_parts: # We've matched all parts -# return True, try_get_value(current) -# -# current_search = remaining_search_parts[0] -# -# # Try each key at current level -# for key, item_value in current.items(): -# if matches_name(key, current_search, mode): -# # Found a match for current part, recurse with rest -# match_found, result = try_match_path(item_value, remaining_search_parts[1:], depth + 1) -# if match_found: -# return True, result -# -# # If no match found and value is a dict, try searching deeper -# if isinstance(item_value, dict): -# match_found, result = try_match_path(item_value, remaining_search_parts, depth) -# if match_found: -# return True, result -# -# return False, None -# -# try: -# found, value = try_match_path(params, search_parts) -# if found: -# return True, value -# except Exception: -# pass # Fall through to recursive search if chain lookup fails -# -# # Recursive search through nested dictionaries -# def recursive_search(data: dict | Base, target: str) -> tuple[bool, Any]: -# if not isinstance(data, dict | Base): -# return False, None -# -# # Handle both dict and Base objects for iteration -# if isinstance(data, dict): -# items = data.items() -# else: -# items = [(k, getattr(data, k)) for k in dir(data) if not k.startswith("_")] -# -# # First check current level -# for key, item_value in items: -# if matches_name(key, target, mode): -# return True, try_get_value(item_value) -# -# # Then check nested levels -# for _, item_value in items: -# if isinstance(item_value, dict | Base): -# item_found, result = recursive_search(item_value, target) -# if item_found: -# return True, result -# -# return False, None -# -# return recursive_search(params, search_name.split(".")[-1] if "." in search_name else search_name) -# -# @staticmethod -# def lookup_parameter( -# obj: Base, param_name: str, mode: PropertyMatchMode = PropertyMatchMode.MIXED -# ) -> tuple[bool, Any]: -# """Unified parameter lookup for both checking existence and getting values. -# -# Args: -# obj: Speckle object to search -# param_name: Parameter name to find -# mode: Matching mode to use -# -# Returns: -# Tuple of (found: bool, value: Any) -# """ -# # Check direct property first -# if has_item(obj, param_name): -# value = get_item(obj, param_name) -# # Check if the direct property has a value field -# if isinstance(value, dict) and "value" in value: -# return True, value["value"] -# return True, value -# -# # Handle v3 structure -# if PropertyRules.is_v3(obj): -# properties = get_item(obj, "properties") -# if not properties or not has_item(properties, "Parameters"): -# return False, None -# -# parameters = get_item(properties, "Parameters") -# if not parameters: -# return False, None -# -# return ParameterSearch.search_parameters(parameters, param_name, mode) -# -# # Handle v2 structure -# parameters = get_item(obj, "parameters") -# if not parameters: -# return False, None -# -# return ParameterSearch.search_parameters(parameters, param_name, mode) diff --git a/src/spreadsheet.py b/src/spreadsheet.py index 7559ac3..d4167b8 100644 --- a/src/spreadsheet.py +++ b/src/spreadsheet.py @@ -1,4 +1,24 @@ -"""Module for reading and processing rules from a cloud hosted TSV file.""" +"""Module for reading and processing rules from a cloud hosted TSV file. + +This module handles the loading and processing of validation rules from external +spreadsheet data, enabling non-technical users to define and modify rules. + +Key features: +1. Reading from hosted TSV files (e.g., from Google Sheets) +2. Processing rule numbers for consistent grouping +3. Handling mixed data types in spreadsheet columns +4. Validating rule structure and providing feedback +5. Grouping related rule conditions for execution + +The spreadsheet format used follows a specific structure: +- Rule Number: Groups related conditions together +- Logic: WHERE/AND/CHECK to define condition relationships +- Property Name: The property path to check +- Predicate: The comparison operation (equals, greater than, etc.) +- Value: The value to compare against +- Message: The message to display for rule results +- Severity: INFO/WARNING/ERROR level for failures +""" import traceback @@ -10,14 +30,20 @@ from pandas.core.groupby import DataFrameGroupBy def process_rule_numbers(df: DataFrame) -> DataFrame: """Process rule numbers in a DataFrame while preserving original rule identifiers. - Makes no assumptions about rule number format - preserves them exactly as provided. - Only generates new numbers (as integers) when no rule number exists. + This function handles various rule numbering scenarios: + 1. Preserves existing rule numbers exactly as provided + 2. Generates sequential numbers for missing rule numbers + 3. Ensures all rows in a logical rule group have the same rule number + + This is important because rule numbers determine how conditions are grouped + and executed together. Args: df: DataFrame with columns including 'Rule Number' and 'Logic' Returns: - DataFrame with processed rule numbers + DataFrame with processed rule numbers, where all related conditions + have the same rule number """ # Create a copy to avoid modifying original df = df.copy() @@ -64,7 +90,16 @@ def process_rule_numbers(df: DataFrame) -> DataFrame: def validate_rule_numbers(df: DataFrame) -> list[str]: - """Validate rule numbers and return any warnings or errors. + """ " + Validate rule numbers and return any warnings or errors. + + This checks for issues like: + 1. Missing rule numbers + 2. Non-integer rule numbers + 3. Duplicate rule numbers + + These validations help ensure rule integrity without being overly strict, + allowing for different user approaches to rule numbering. Args: df: DataFrame with processed rule numbers @@ -93,10 +128,18 @@ def validate_rule_numbers(df: DataFrame) -> list[str]: def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] | tuple[None, list[str]]: - """Reads a TSV file from a provided URL, processes rule numbers, and returns grouped rules. + """ " + Reads rules from a TSV file at the provided URL, processes them, and returns grouped rules. + + This function is the main entry point for rule loading: + 1. Reads the TSV file from the provided URL + 2. Converts mixed type columns to appropriate types + 3. Processes rule numbers for consistent grouping + 4. Validates rule numbers and collects messages + 5. Groups rules by rule number for execution Args: - url (str): The URL to the TSV file + url: The URL to the TSV file containing rule definitions Returns: Tuple containing: @@ -105,23 +148,30 @@ def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] """ try: # Read the TSV file + # The TSV format is chosen for compatibility with Google Sheets + # and other spreadsheet applications df = pd.read_csv(url, sep="\t") # Convert mixed type columns + # This handles inconsistencies in spreadsheet data df = convert_mixed_columns(df) # Process rule numbers + # This ensures all related conditions have the same rule number df = process_rule_numbers(df) # Get validation messages + # These are warnings about potential issues with the rules messages = validate_rule_numbers(df) # Group by rule number + # This creates a DataFrameGroupBy object that groups related conditions grouped_rules = df.groupby("Rule Number") return grouped_rules, messages except Exception as e: + # Handle any errors in reading or processing the spreadsheet traceback.print_exc() return None, [f"Failed to read the TSV from the URL: {str(e)}:{e.with_traceback(None)}"] @@ -129,10 +179,17 @@ def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] def convert_mixed_columns(df: DataFrame) -> DataFrame: """Converts columns in a DataFrame to appropriate types based on their content. - null or empty strings are converted to empty strings instead of NaN. + This handles common issues with spreadsheet data: + 1. Numeric columns that contain strings + 2. Mixed type columns + 3. Empty cells and NaN values + + The approach is to convert each column appropriately: + - Numeric columns remain as numbers + - Other columns are converted to strings, with empty strings for missing values Args: - df (DataFrame): The DataFrame whose columns are to be converted + df: The DataFrame whose columns are to be converted Returns: DataFrame with columns converted to appropriate types