From b2a5ec1119a75d2adcffe04888ba8454a883cb3b Mon Sep 17 00:00:00 2001 From: Jonathon Broughton Date: Tue, 12 Nov 2024 16:37:42 +0000 Subject: [PATCH] all exercises --- .../{exercise-0 => exercise_0}/function.py | 0 .../{exercise-0 => exercise_0}/inputs.py | 0 Exercises/exercise_1/function.py | 120 +++ Exercises/exercise_1/inputs.py | 22 + Exercises/exercise_2/function.py | 116 +++ Exercises/exercise_2/inputs.py | 20 + Exercises/exercise_2/rules.py | 529 +++++++++++++ Exercises/exercise_3/function.py | 41 + Exercises/exercise_3/inputs.py | 17 + Exercises/exercise_3/rules.py | 745 ++++++++++++++++++ Utilities/helpers.py | 119 +++ Utilities/spreadsheet.py | 18 + poetry.lock | 423 +++++++++- pyproject.toml | 8 +- tests/conftest.py | 24 + tests/test_exercise_0.py | 147 ++++ tests/test_exercise_1.py | 144 ++++ tests/test_exercise_2.py | 144 ++++ tests/test_exercise_3.py | 149 ++++ tests/test_function.py | 31 - tests/tests.py | 146 ++++ 21 files changed, 2929 insertions(+), 34 deletions(-) rename Exercises/{exercise-0 => exercise_0}/function.py (100%) rename Exercises/{exercise-0 => exercise_0}/inputs.py (100%) create mode 100644 Exercises/exercise_1/function.py create mode 100644 Exercises/exercise_1/inputs.py create mode 100644 Exercises/exercise_2/function.py create mode 100644 Exercises/exercise_2/inputs.py create mode 100644 Exercises/exercise_2/rules.py create mode 100644 Exercises/exercise_3/function.py create mode 100644 Exercises/exercise_3/inputs.py create mode 100644 Exercises/exercise_3/rules.py create mode 100644 Utilities/helpers.py create mode 100644 Utilities/spreadsheet.py create mode 100644 tests/conftest.py create mode 100644 tests/test_exercise_0.py create mode 100644 tests/test_exercise_1.py create mode 100644 tests/test_exercise_2.py create mode 100644 tests/test_exercise_3.py delete mode 100644 tests/test_function.py create mode 100644 tests/tests.py diff --git a/Exercises/exercise-0/function.py b/Exercises/exercise_0/function.py similarity index 100% rename from Exercises/exercise-0/function.py rename to Exercises/exercise_0/function.py diff --git a/Exercises/exercise-0/inputs.py b/Exercises/exercise_0/inputs.py similarity index 100% rename from Exercises/exercise-0/inputs.py rename to Exercises/exercise_0/inputs.py diff --git a/Exercises/exercise_1/function.py b/Exercises/exercise_1/function.py new file mode 100644 index 0000000..0291b18 --- /dev/null +++ b/Exercises/exercise_1/function.py @@ -0,0 +1,120 @@ +import random + +from speckle_automate import AutomationContext + +from inputs import FunctionInputs +from Utilities.flatten import flatten_base + + +def automate_function( + automate_context: AutomationContext, + function_inputs: FunctionInputs, +) -> None: + """This is an example Speckle Automate function. + + 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. + """ + + # the context provides a convenient way, to receive the triggering version + version_root_object = automate_context.receive_version() + + flat_list_of_objects = list(flatten_base(version_root_object)) + + # filter the list to only include objects that are displayable. + # this is a simple example, that checks if the object has a displayValue + displayable_objects = [ + speckle_object + for speckle_object in flat_list_of_objects + if ( + getattr(speckle_object, "displayValue", None) + or getattr(speckle_object, "@displayValue", None) + ) and getattr(speckle_object, "id", None) is not None + ] + + # a better displayable_objects should also include those instance objects that have a definition property + # that cross-references to a speckle id, that is in turn displayable, so we need to add those objects to the list + displayable_objects += [ + instance_object + for instance_object in flat_list_of_objects + if ( + getattr(instance_object, "definition", None) + and ( + ( + getattr( + getattr(instance_object, "definition"), "displayValue", None + ) + or getattr( + getattr(instance_object, "definition"), "@displayValue", None + ) + ) + and getattr(getattr(instance_object, "definition"), "id", None) + is not None + ) + ) + ] + + if len(displayable_objects) == 0: + automate_context.mark_run_failed( + "Automation failed: No displayable objects found." + ) + + else: + # select a random object from the list + # random_object = random.choice(displayable_objects) + + # instead of a single object we will select a random subset of displayable objects from the provided dataset + real_number_of_elements = min( + # We cant take more elements than we have + function_inputs.number_of_elements, + len(displayable_objects), + ) + + selected_objects = random.sample( + displayable_objects, + real_number_of_elements, + ) + + # create a list of object ids for all selected objects + selected_object_ids = [obj.id for obj in selected_objects] + + # ACTIONS + + # attach comment phrase to all selected objects + # it is possible to attach the same comment phrase to multiple objects + # the category "Selected Objects" is used to group the objects in the viewer + # grouping results in this way is a clean way to organize the objects in the viewer + comment_message = f"{function_inputs.comment_phrase}" + automate_context.attach_info_to_objects( + category="Selected Objects", + object_ids=selected_object_ids, + message=comment_message, + ) + + # attach index as gradient value for all selected objects. this will be used for visualisation purposes + # the category "Index Visualisation" is used to group the objects in the viewer + gradient_values = { + object_id: {"gradientValue": index + 1} + for index, object_id in enumerate(selected_object_ids) + } + + automate_context.attach_info_to_objects( + category="Index Visualisation", + metadata={ + "gradient": True, + "gradientValues": gradient_values, + }, + message="Object Indexes", + object_ids=selected_object_ids, + ) + + automate_context.mark_run_success( + f"Added comment to {real_number_of_elements} random objects." + ) + + # set the automation context view, to the original model / version view + automate_context.set_context_view() diff --git a/Exercises/exercise_1/inputs.py b/Exercises/exercise_1/inputs.py new file mode 100644 index 0000000..50b3410 --- /dev/null +++ b/Exercises/exercise_1/inputs.py @@ -0,0 +1,22 @@ +from pydantic import Field +from speckle_automate import AutomateBase + + +class FunctionInputs(AutomateBase): + """These are function author defined values. + + Automate will make sure to supply them matching the types specified here. + Please use the pydantic model schema to define your inputs: + https://docs.pydantic.dev/latest/usage/models/ + """ + + comment_phrase: str = Field( + title="Comment Phrase", + description="This phrase will be added to a random model element.", + ) + + # We now want to specify the number of elements to which the comment phrase will be added. + number_of_elements: int = Field( + title="Number of Elements", + description="The number of elements to which the comment phrase will be added.", + ) diff --git a/Exercises/exercise_2/function.py b/Exercises/exercise_2/function.py new file mode 100644 index 0000000..3768bfd --- /dev/null +++ b/Exercises/exercise_2/function.py @@ -0,0 +1,116 @@ +import random + +from speckle_automate import AutomationContext + +from inputs import FunctionInputs +from Utilities.flatten import flatten_base + + +def automate_function( + automate_context: AutomationContext, + function_inputs: FunctionInputs, +) -> None: + """This version of the function will add a check for the new provide inputs. + + 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. + """ + + # the context provides a convenient way, to receive the triggering version + version_root_object = automate_context.receive_version() + + # We can continue to work with a flattened list of objects. + flat_list_of_objects = list(flatten_base(version_root_object)) + + # filter to only include objects that are in the specified category + in_category_objects = [ + speckle_object + for speckle_object in flat_list_of_objects + if RevitRules.is_category(speckle_object, function_inputs.category) + ] + + # check if the property exists on the objects + non_property_objects = [ + obj + for obj in in_category_objects + if not RevitRules.has_parameter(obj, function_inputs.property) + ] + + property_objects = [ + obj + for obj in in_category_objects + if RevitRules.has_parameter(obj, function_inputs.property) + ] + + # property_objects should be those where while the property is present, + # is not an empty string or the default value + valid_property_objects = [ + obj + for obj in property_objects + if RevitRules.get_parameter_value(obj, function_inputs.property) + not in ["", "Default", None] + ] + + for obj in valid_property_objects: + speckle_print(RevitRules.get_parameter_value(obj, function_inputs.property)) + + # invalid_property_objects property_objects not in valid_property_objects + invalid_property_objects = [ + obj for obj in property_objects if obj not in valid_property_objects + ] + + # mark all the non-property objects as failed + + ( + automate_context.attach_error_to_objects( + category=f"Missing Property {function_inputs.category} Objects", + object_ids=[obj.id for obj in non_property_objects], + message=f"This {function_inputs.category} does not have the specified property {function_inputs.property}", + ) + if non_property_objects + else None + ) + + # mark all the invalid property objects as warning + ( + automate_context.attach_warning_to_objects( + category=f"Invalid Property {function_inputs.category} Objects", + object_ids=[obj.id for obj in invalid_property_objects], + message=f"This {function_inputs.category} has the specified property {function_inputs.property} but it is " + f"empty or default", + ) + if invalid_property_objects + else None + ) + + # mark all the property objects as successful + ( + automate_context.attach_info_to_objects( + category=f"Valid Property {function_inputs.category} Objects", + object_ids=[obj.id for obj in property_objects], + message=f"This {function_inputs.category} has the specified property {function_inputs.property}", + ) + if property_objects + else None + ) + + if len(non_property_objects) > 0: + automate_context.mark_run_failed( + "Some objects do not have the specified property." + ) + elif len(invalid_property_objects) > 0: + automate_context.mark_run_success( + "Some objects have the specified property but it is empty or default.", + ) + + else: + automate_context.mark_run_success( + f"All {function_inputs.category} objects have the {function_inputs.property} property." + ) + + # set the automation context view, to the original model / version view + automate_context.set_context_view() diff --git a/Exercises/exercise_2/inputs.py b/Exercises/exercise_2/inputs.py new file mode 100644 index 0000000..1872568 --- /dev/null +++ b/Exercises/exercise_2/inputs.py @@ -0,0 +1,20 @@ +from pydantic import Field +from speckle_automate import AutomateBase + + +class FunctionInputs(AutomateBase): + """These are function author defined values. + Automate will make sure to supply them matching the types specified here. + Please use the pydantic model schema to define your inputs: + https://docs.pydantic.dev/latest/usage/models/ + """ + + # In this exercise, we will add two new input fields to the FunctionInputs class. + category: str = Field( + title="Revit Category", + description="This is the category objects to check.", + ) + property: str = Field( + title="Property Name", + description="This is the property to check.", + ) diff --git a/Exercises/exercise_2/rules.py b/Exercises/exercise_2/rules.py new file mode 100644 index 0000000..7071d4b --- /dev/null +++ b/Exercises/exercise_2/rules.py @@ -0,0 +1,529 @@ +from typing import List, Optional, Tuple, Callable, Dict, Any, cast, Union +from specklepy.objects.base import Base +from Levenshtein import ratio +import re + +# 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 `Rules`. We'll also define a set of rules specific to Revit +# objects in a class called `RevitRules`. + + +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, + ) -> Optional[List[Base]]: + """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 if the speckle_object has a display value using the try_get_display_value method + 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 + + # Below are more speculatively defined rules that could be used in a traversal of flat list parsing + + @staticmethod + def speckle_type_rule( + desired_type: str, + ) -> Callable[[Base], bool]: + """ + Rule: Check if a parameter's speckle_type matches the desired type. + """ + return lambda prop: getattr(prop, "speckle_type", None) == desired_type + + @staticmethod + def is_speckle_type(prop: Base, desired_type: str) -> bool: + """ + Rule: Check if a parameter's speckle_type matches the desired type. + """ + return getattr(prop, "speckle_type", None) == desired_type + + @staticmethod + def has_missing_value(prop: Dict[str, str]) -> bool: + """ + Rule: Missing Value Check. + + The AEC industry often requires all parameters to have meaningful values. + This rule checks if a parameter is missing its value, potentially indicating + an oversight during data entry or transfer. + """ + return not prop.get("value") + + @staticmethod + def has_default_value(prop: Dict[str, str], default="Default") -> bool: + """ + Rule: Default Value Check. + + Default values can sometimes creep into final datasets due to software defaults. + This rule identifies parameters that still have their default values, helping + to highlight areas where real, meaningful values need to be provided. + """ + return prop.get("value") == default + + @staticmethod + def parameter_exists(prop_name: str, parent_object: Dict[str, str]) -> bool: + """ + Rule: Parameter Existence Check. + + For certain critical parameters, their mere presence (or lack thereof) is vital. + This rule verifies if a specific parameter exists within an object, allowing + teams to ensure that key data points are always present. + """ + return prop_name in parent_object.get("parameters", {}) + + +def get_displayable_objects(flat_list_of_objects: List[Base]) -> List[Base]: + # modify this lambda from before to use the static method from the Checks class + return [ + speckle_object + for speckle_object in flat_list_of_objects + if Rules.is_displayable_object(speckle_object) + and getattr(speckle_object, "id", None) + ] + + # and the same logic that could be modified to traverse a tree of objects + + +# Now we're going to define a set of rules that are specific to Revit objects. +class RevitRules: + @staticmethod + def has_parameter(speckle_object: Base, parameter_name: str) -> bool: + """ + Checks if the speckle_object has a Revit parameter with the given name. + + This method checks if the speckle_object has a parameter with the specified name, + considering the following cases: + 1. The parameter is a named property at the root object level. + 2. The parameter is stored as a key in the "parameters" dictionary. + 3. The parameter is stored as a nested dictionary within the "parameters" property, + and the parameter name is stored as the value of the "name" property within each nested dictionary. + + If the parameter exists, it returns True; otherwise, it returns False. + + Args: + speckle_object (Base): The Speckle object to check. + parameter_name (str): The name of the parameter to check for. + + Returns: + bool: True if the object has the parameter, False otherwise. + """ + if hasattr(speckle_object, parameter_name): + return True + + parameters = cast(Base, getattr(speckle_object, "parameters", None)) + + if parameters is None: + return False + + # the parameters object can function like a dict but isn't one. + # convert a Base object to a dict + parameters_dict = {} + + for parameter_key in parameters.get_dynamic_member_names(): + parameters_dict[parameter_key] = getattr(parameters, parameter_key, None) + + if parameter_name in parameters_dict: + return True + + return any( + getattr(param_value, "name", None) == parameter_name + for param_value in parameters_dict.values() + ) + + @staticmethod + def get_parameter_value( + speckle_object: Base, + parameter_name: str, + default_value: Any = None, + ) -> Any | None: + """ + Retrieves the value of the specified Revit parameter from the speckle_object. + + This method checks if the speckle_object has a parameter with the specified name, + considering the following cases: + 1. The parameter is a named property at the root object level. + 2. The parameter is stored as a key in the "parameters" dictionary. + 3. The parameter is stored as a nested dictionary within the "parameters" property, + and the parameter name is stored as the value of the "name" property within each nested dictionary. + + If the parameter exists and its value is not None or the specified default_value, it returns the value. + If the parameter does not exist or its value is None or the specified default_value, it returns None. + + 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. + default_value: The default value to compare against. If the parameter value matches this value, + it will be treated the same as None. + + Returns: + The value of the parameter if it exists and is not None or the specified default_value, or None otherwise. + """ + # Attempt to retrieve the parameter from the root object level + value = getattr(speckle_object, parameter_name, None) + if value not in [None, default_value]: + return value + + # If the "parameters" attribute is a Base object, extract its dynamic members + parameters = getattr(speckle_object, "parameters", None) + if parameters is None: + return None + + # Prepare a dictionary of parameter values from the dynamic members of the parameters attribute + parameters_dict = { + key: getattr(parameters, key) + for key in parameters.get_dynamic_member_names() + } + + # Search for a direct match or a nested match in the parameters dictionary + param_value = parameters_dict.get(parameter_name) + if param_value is not None: + if isinstance(param_value, Base): + # Extract the nested value from a Base object if available + nested_value = getattr(param_value, "value", None) + if nested_value not in [None, default_value]: + return nested_value + elif param_value not in [None, default_value]: + return param_value + + # Use a generator to find the first matching 'value' for shared parameters stored in Base objects + return next( + ( + getattr(p, "value", None) + for p in parameters_dict.values() + if isinstance(p, Base) and getattr(p, "name", None) == parameter_name + ), + None, + ) + + @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 = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value == value_to_match + + @staticmethod + def is_like_parameter_value( + 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 = RevitRules.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 is_parameter_value_greater_than( + speckle_object: Base, parameter_name: str, threshold: Union[int, float] + ) -> 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 = RevitRules.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 > threshold + + @staticmethod + def is_parameter_value_less_than( + speckle_object: Base, parameter_name: str, threshold: Union[int, float] + ) -> 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 = RevitRules.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 < threshold + + @staticmethod + def is_parameter_value_in_range( + speckle_object: Base, + parameter_name: str, + min_value: Union[int, float], + max_value: Union[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 = RevitRules.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] + ) -> 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 = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value in value_list + + @staticmethod + def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool: + """ + Checks if the value of the specified parameter is True. + + Args: + speckle_object (Base): The Speckle object to check. + parameter_name (str): The name of the parameter to check. + + Returns: + bool: True if the parameter value is True, False otherwise. + """ + parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value is True + + @staticmethod + def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool: + """ + Checks if the value of the specified parameter is False. + + Args: + speckle_object (Base): The Speckle object to check. + parameter_name (str): The name of the parameter to check. + + Returns: + bool: True if the parameter value is False, False otherwise. + """ + parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value is False + + @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 RevitRules.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 = RevitRules.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 RevitRules.get_parameter_value(speckle_object, "category") + + +def filter_objects_by_category( + speckle_objects: List[Base], category_input: str +) -> Tuple[List[Base], List[Base]]: + """ + Filters objects by category value and test. + + This function takes a list of Speckle objects, filters out the objects + with a matching category value and satisfies the test, and returns + both the matching and non-matching objects. + + Args: + speckle_objects (List[Base]): The list of Speckle objects to filter. + category_input (str): The category value to match against. + + Returns: + Tuple[List[Base], List[Base]]: A tuple containing two lists: + - The first list contains objects with matching category and test. + - The second list contains objects without matching category or test. + """ + matching_objects = [] + non_matching_objects = [] + + for speckle_object in speckle_objects: + if RevitRules.is_category(speckle_object, category_input): + matching_objects.append(speckle_object) + else: + non_matching_objects.append(speckle_object) + + return matching_objects, non_matching_objects diff --git a/Exercises/exercise_3/function.py b/Exercises/exercise_3/function.py new file mode 100644 index 0000000..666de03 --- /dev/null +++ b/Exercises/exercise_3/function.py @@ -0,0 +1,41 @@ +from pydantic import Field +from speckle_automate import AutomationContext, AutomateBase + +from Exercises.exercise_3.rules import apply_rules_to_objects +from Utilities.helpers import flatten_base +from Utilities.spreadsheet import read_rules_from_spreadsheet + + +def automate_function( + automate_context: AutomationContext, + function_inputs: FunctionInputs, +) -> None: + """This version of the function will add a check for the new provide inputs. + + 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. + """ + + # the context provides a convenient way, to receive the triggering version + version_root_object = automate_context.receive_version() + + # We can continue to work with a flattened list of objects. + flat_list_of_objects = list(flatten_base(version_root_object)) + + # read the rules from the spreadsheet + rules = read_rules_from_spreadsheet(function_inputs.spreadsheet_url) + + # apply the rules to the objects + apply_rules_to_objects(flat_list_of_objects, rules, automate_context) + + # set the automation context view, to the original model / version view + automate_context.set_context_view() + + # report success + automate_context.mark_run_success( + f"Successfully applied rules to {len(flat_list_of_objects)} objects." + ) diff --git a/Exercises/exercise_3/inputs.py b/Exercises/exercise_3/inputs.py new file mode 100644 index 0000000..14ced3f --- /dev/null +++ b/Exercises/exercise_3/inputs.py @@ -0,0 +1,17 @@ +from pydantic import Field +from speckle_automate import AutomateBase + + +class FunctionInputs(AutomateBase): + """These are function author defined values. + + Automate will make sure to supply them matching the types specified here. + Please use the pydantic model schema to define your inputs: + https://docs.pydantic.dev/latest/usage/models/ + """ + + # In this exercise, we will move rules to an external source so not to hardcode them. + spreadsheet_url: str = Field( + title="Spreadsheet URL", + description="This is the URL of the spreadsheet to check. It should be a TSV format data source.", + ) diff --git a/Exercises/exercise_3/rules.py b/Exercises/exercise_3/rules.py new file mode 100644 index 0000000..ef7bf08 --- /dev/null +++ b/Exercises/exercise_3/rules.py @@ -0,0 +1,745 @@ +from typing import List, Optional, Tuple, Any, cast +from speckle_automate import AutomationContext, ObjectResultLevel +from specklepy.objects.base import Base +from Levenshtein import ratio +import pandas as pd +import re + +from Utilities.helpers import speckle_print + + +# 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, + ) -> Optional[List[Base]]: + """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 + + +def get_displayable_objects(flat_list_of_objects: List[Base]) -> List[Base]: + # modify this lambda from before to use the static method from the Checks class + return [ + speckle_object + for speckle_object in flat_list_of_objects + if Rules.is_displayable_object(speckle_object) + and getattr(speckle_object, "id", None) + ] + + # and the same logic that could be modified to traverse a tree of objects + + +def filter_objects_by_category( + speckle_objects: List[Base], category_input: str +) -> Tuple[List[Base], List[Base]]: + """ + Filters objects by category value and test. + + This function takes a list of Speckle objects, filters out the objects + with a matching category value and satisfies the test, and returns + both the matching and non-matching objects. + + Args: + speckle_objects (List[Base]): The list of Speckle objects to filter. + category_input (str): The category value to match against. + + Returns: + Tuple[List[Base], List[Base]]: A tuple containing two lists: + - The first list contains objects with matching category and test. + - The second list contains objects without matching category or test. + """ + matching_objects = [] + non_matching_objects = [] + + for obj in speckle_objects: + if RevitRules.is_category(obj, category_input): + matching_objects.append(obj) + else: + non_matching_objects.append(obj) + + return matching_objects, non_matching_objects + + +class RevitRules: + @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. + + This method checks if the speckle_object has a parameter with the specified name, + considering the following cases: + 1. The parameter is a named property at the root object level. + 2. The parameter is stored as a key in the "parameters" dictionary. + 3. The parameter is stored as a nested dictionary within the "parameters" property, + and the parameter name is stored as the value of the "name" property within each nested dictionary. + + If the parameter exists, it returns True; otherwise, it returns False. + + 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. + """ + if hasattr(speckle_object, parameter_name): + return True + + parameters = cast(Base, getattr(speckle_object, "parameters", None)) + + if parameters is None: + return False + + # the parameters object can function like a dict but isn't one. + # convert a Base object to a dict + parameters_dict = {} + + for parameter_key in parameters.get_dynamic_member_names(): + parameters_dict[parameter_key] = getattr(parameters, parameter_key, None) + + if parameter_name in parameters_dict: + return True + + return any( + getattr(param_value, "name", None) == parameter_name + for param_value in parameters_dict.values() + ) + + @staticmethod + def get_parameter_value( + speckle_object: Base, + parameter_name: str, + default_value: Any = None, + ) -> Any | None: + """ + Retrieves the value of the specified Revit parameter from the speckle_object. + + This method checks if the speckle_object has a parameter with the specified name, + considering the following cases: + 1. The parameter is a named property at the root object level. + 2. The parameter is stored as a key in the "parameters" dictionary. + 3. The parameter is stored as a nested dictionary within the "parameters" property, + and the parameter name is stored as the value of the "name" property within each nested dictionary. + + If the parameter exists and its value is not None or the specified default_value, it returns the value. + If the parameter does not exist or its value is None or the specified default_value, it returns None. + + 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. + default_value: The default value to compare against. If the parameter value matches this value, + it will be treated the same as None. + + Returns: + The value of the parameter if it exists and is not None or the specified default_value, or None otherwise. + """ + # Attempt to retrieve the parameter from the root object level + value = getattr(speckle_object, parameter_name, None) + if value not in [None, default_value]: + return value + + # If the "parameters" attribute is a Base object, extract its dynamic members + parameters = getattr(speckle_object, "parameters", None) + if parameters is None: + return None + + # Prepare a dictionary of parameter values from the dynamic members of the parameters attribute + parameters_dict = { + key: getattr(parameters, key) + for key in parameters.get_dynamic_member_names() + } + + # Search for a direct match or a nested match in the parameters dictionary + param_value = parameters_dict.get(parameter_name) + if param_value is not None: + if isinstance(param_value, Base): + # Extract the nested value from a Base object if available + nested_value = getattr(param_value, "value", None) + if nested_value not in [None, default_value]: + return nested_value + elif param_value not in [None, default_value]: + return param_value + + # Use a generator to find the first matching 'value' for shared parameters stored in Base objects + return next( + ( + getattr(p, "value", None) + for p in parameters_dict.values() + if isinstance(p, Base) and getattr(p, "name", None) == parameter_name + ), + None, + ) + + from typing import Any, Union, List + + @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 = RevitRules.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 = RevitRules.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 = RevitRules.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 > RevitRules.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 = RevitRules.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 < RevitRules.parse_number_from_string(threshold) + + @staticmethod + def is_parameter_value_in_range( + speckle_object: Base, parameter_name: str, 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. + 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 = range.split(",") + min_value = RevitRules.parse_number_from_string(min_value) + max_value = RevitRules.parse_number_from_string(max_value) + + parameter_value = RevitRules.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: Union[int, float], + max_value: Union[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 = RevitRules.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] + ) -> 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 = RevitRules.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 is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool: + """ + Checks if the value of the specified parameter is True. + + Args: + speckle_object (Base): The Speckle object to check. + parameter_name (str): The name of the parameter to check. + + Returns: + bool: True if the parameter value is True, False otherwise. + """ + parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value is True + + @staticmethod + def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool: + """ + Checks if the value of the specified parameter is False. + + Args: + speckle_object (Base): The Speckle object to check. + parameter_name (str): The name of the parameter to check. + + Returns: + bool: True if the parameter value is False, False otherwise. + """ + parameter_value = RevitRules.get_parameter_value(speckle_object, parameter_name) + return parameter_value is False + + @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 RevitRules.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 = RevitRules.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 RevitRules.get_parameter_value(speckle_object, "category") + + +# Mapping of input predicates to the corresponding methods in RevitRules +input_predicate_mapping = { + "exists": "has_parameter", + "matches": "is_parameter_value", + "greater than": "is_parameter_value_greater_than", + "less than": "is_parameter_value_less_than", + "in range": "is_parameter_value_in_range", + "in list": "is_parameter_value_in_list", + "equals": "is_parameter_value", + "true": "is_parameter_value_true", + "false": "is_parameter_value_false", + "is like": "is_parameter_value_like", +} + + +def evaluate_condition(speckle_object: Base, condition: pd.Series) -> bool: + """ + Given a Speckle object and a condition, evaluates the condition and returns a boolean value. + 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. + + Args: + speckle_object (Base): The Speckle object to evaluate. + condition (pd.Series): The condition to evaluate. + + Returns: + bool: The result of the evaluation. True if the condition is met, False otherwise. + """ + property_name = condition["Property Name"] + predicate_key = condition["Predicate"] + value = condition["Value"] + + if predicate_key in input_predicate_mapping: + method_name = input_predicate_mapping[predicate_key] + method = getattr(RevitRules, method_name, None) + + # speckle_print(f"Checking {property_name} {predicate_key} {value}") + + if method: + check_answer = method(speckle_object, property_name, value) + + return check_answer + return False + + +def process_rule( + speckle_objects: List[Base], rule_group: pd.DataFrame +) -> Tuple[List[Base], List[Base]]: + """ + Processes a set of rules against Speckle objects, returning those that pass and fail. + The first rule is used as a filter ('WHERE'), and subsequent rules as conditions ('AND'). + + Args: + speckle_objects: List of Speckle objects to be processed. + rule_group: DataFrame defining the filter and conditions. + + Returns: + A tuple of lists containing objects that passed and failed the rule. + """ + + # Extract the 'WHERE' condition and subsequent 'AND' conditions + filter_condition = rule_group.iloc[0] + subsequent_conditions = rule_group.iloc[1:] + + # get the last row of the rule_group and get the Message and Report Severity + rule_info = rule_group.iloc[-1] + + # Filter objects based on the 'WHERE' condition + filtered_objects = [ + speckle_object + for speckle_object in speckle_objects + if evaluate_condition(speckle_object, filter_condition) + ] + + rule_number = rule_info["Rule Number"] + + speckle_print( + f"{ filter_condition['Logic']} {filter_condition['Property Name']} " + f"{filter_condition['Predicate']} {filter_condition['Value']}" + ) + + speckle_print( + f"{rule_number}: {len(list(filtered_objects))} objects passed the filter." + ) + + # Initialize lists for passed and failed objects + pass_objects, fail_objects = [], [] + + # Evaluate each filtered object against the 'AND' conditions + for speckle_object in filtered_objects: + if all( + evaluate_condition(speckle_object, cond) + for _, cond in subsequent_conditions.iterrows() + ): + pass_objects.append(speckle_object) + else: + fail_objects.append(speckle_object) + + return pass_objects, fail_objects + + +def apply_rules_to_objects( + speckle_objects: List[Base], + rules_df: pd.DataFrame, + automate_context: AutomationContext, +) -> dict[str, Tuple[List[Base], List[Base]]]: + """ + Applies defined rules to a list of objects and updates the automate context based on the results. + + Args: + speckle_objects (List[Base]): The list of objects to which rules are applied. + rules_df (pd.DataFrame): The DataFrame containing rule definitions. + automate_context (Any): Context manager for attaching rule results. + """ + grouped_rules = rules_df.groupby("Rule Number") + + grouped_results = {} + + for rule_id, rule_group in grouped_rules: + rule_id_str = str(rule_id) # Convert rule_id to string + + # Ensure rule_group has necessary columns + if ( + "Message" not in rule_group.columns + or "Report Severity" not in rule_group.columns + ): + continue # Or raise an exception if these columns are mandatory + + pass_objects, fail_objects = process_rule(speckle_objects, rule_group) + + attach_results( + pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True + ) + attach_results( + fail_objects, rule_group.iloc[-1], rule_id_str, automate_context, False + ) + + grouped_results[rule_id_str] = (pass_objects, fail_objects) + + # return pass_objects, fail_objects for each rule + return grouped_results + + +def attach_results( + speckle_objects: List[Base], + rule_info: pd.Series, + rule_id: str, + context: AutomationContext, + passed: bool, +) -> None: + """ + Attaches the results of a rule to the objects in the context. + + 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. + """ + + if not speckle_objects: + return + + message = f"{rule_info['Message']} - {'Passed' if passed else 'Failed'}" + if passed: + context.attach_info_to_objects( + category=f"Rule {rule_id} Success", + object_ids=[speckle_object.id for speckle_object in speckle_objects], + message=message, + ) + else: + + speckle_print(rule_info["Report Severity"]) + + severity = ( + ObjectResultLevel.WARNING + if rule_info["Report Severity"].capitalize() == "Warning" + or rule_info["Report Severity"].capitalize() == "Warn" + else ObjectResultLevel.ERROR + ) + context.attach_result_to_objects( + category=f"Rule {rule_id} Results", + object_ids=[speckle_object.id for speckle_object in speckle_objects], + message=message, + level=severity, + ) diff --git a/Utilities/helpers.py b/Utilities/helpers.py new file mode 100644 index 0000000..9f28bb0 --- /dev/null +++ b/Utilities/helpers.py @@ -0,0 +1,119 @@ +"""Helper module for a speckle object tree flattening.""" + +from collections.abc import Iterable +from typing import Optional, Tuple, List + +from specklepy.objects import Base +from specklepy.objects.other import Instance, Transform + + +def speckle_print(log_string: str = "banana") -> None: + + print("\033[92m" + str(log_string) + "\033[0m") + + +def flatten_base(base: Base) -> Iterable[Base]: + """Flatten a base object into an iterable of bases.""" + elements = getattr(base, "elements", getattr(base, "@elements", None)) + if elements is not None: + for element in elements: + yield from flatten_base(element) + yield base + + +def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]: + """Take a base and flatten it to an iterable of bases. + + Args: + base: The base object to flatten. + parent_type: The type of the parent object, if any. + + Yields: + Base: A flattened base object. + """ + if isinstance(base, Base): + base["parent_type"] = parent_type + + elements = getattr(base, "elements", getattr(base, "@elements", None)) + if elements: + try: + for element in elements: + # Recursively yield flattened elements of the child + yield from flatten_base_thorough(element, base.speckle_type) + except KeyError: + pass + elif hasattr(base, "@Lines"): + categories = base.get_dynamic_member_names() + + # could be old revit + try: + for category in categories: + print(category) + if category.startswith("@"): + category_object: Base = getattr(base, category)[0] + yield from flatten_base_thorough( + category_object, category_object.speckle_type + ) + + except KeyError: + pass + + yield base + + +def extract_base_and_transform( + base: Base, + inherited_instance_id: Optional[str] = None, + transform_list: Optional[List[Transform]] = None, +) -> Tuple[Base, str, Optional[List[Transform]]]: + """ + Traverses Speckle object hierarchies to yield `Base` objects and their transformations. + Tailored to Speckle's AEC data structures, it covers the newer hierarchical structures + with Collections and also with patterns found in older Revit specific data. + + Parameters: + - base (Base): The starting point `Base` object for traversal. + - inherited_instance_id (str, optional): The inherited identifier for `Base` objects without a unique ID. + - transform_list (List[Transform], optional): Accumulated list of transformations from parent to child objects. + + Yields: + - tuple: A `Base` object, its identifier, and a list of applicable `Transform` objects or None. + + The id of the `Base` object is either the inherited identifier for a definition from an instance + or the one defined in the object. + """ + # Derive the identifier for the current `Base` object, defaulting to an inherited one if needed. + current_id = getattr(base, "id", inherited_instance_id) + transform_list = transform_list or [] + + if isinstance(base, Instance): + # Append transformation data and dive into the definition of `Instance` objects. + if base.transform: + transform_list.append(base.transform) + if base.definition: + yield from extract_base_and_transform( + base.definition, current_id, transform_list.copy() + ) + else: + # Initial yield for the current `Base` object. + yield base, current_id, transform_list + + # Process 'elements' and '@elements', typical containers for `Base` objects in AEC models. + elements_attr = getattr(base, "elements", []) or getattr(base, "@elements", []) + for element in elements_attr: + if isinstance(element, Base): + # Recurse into each `Base` object within 'elements' or '@elements'. + yield from extract_base_and_transform( + element, current_id, transform_list.copy() + ) + + # Recursively process '@'-prefixed properties that are Base objects with 'elements'. + # This is a common pattern in older Speckle data models, such as those used for Revit commits. + for attr_name in dir(base): + if attr_name.startswith("@"): + attr_value = getattr(base, attr_name) + # If the attribute is a Base object containing 'elements', recurse into it. + if isinstance(attr_value, Base) and hasattr(attr_value, "elements"): + yield from extract_base_and_transform( + attr_value, current_id, transform_list.copy() + ) diff --git a/Utilities/spreadsheet.py b/Utilities/spreadsheet.py new file mode 100644 index 0000000..265d683 --- /dev/null +++ b/Utilities/spreadsheet.py @@ -0,0 +1,18 @@ +import pandas as pd + + +def read_rules_from_spreadsheet(url): + """Reads a TSV file from a provided URL and returns a DataFrame. + + Args: + url (str): The URL to the TSV file. + + Returns: + DataFrame: Pandas DataFrame containing the TSV data. + """ + try: + # Since the output is a TSV, we use `pd.read_csv` with `sep='\t'` to specify tab-separated values. + return pd.read_csv(url, sep="\t") + except Exception as e: + print(f"Failed to read the TSV from the URL: {e}") + return None diff --git a/poetry.lock b/poetry.lock index 9abf88c..9a50887 100644 --- a/poetry.lock +++ b/poetry.lock @@ -406,6 +406,117 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "levenshtein" +version = "0.26.1" +description = "Python extension for computing string edit distances and similarities." +optional = false +python-versions = ">=3.9" +files = [ + {file = "levenshtein-0.26.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8dc4a4aecad538d944a1264c12769c99e3c0bf8e741fc5e454cc954913befb2e"}, + {file = "levenshtein-0.26.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec108f368c12b25787c8b1a4537a1452bc53861c3ee4abc810cc74098278edcd"}, + {file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69229d651c97ed5b55b7ce92481ed00635cdbb80fbfb282a22636e6945dc52d5"}, + {file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79dcd157046d62482a7719b08ba9e3ce9ed3fc5b015af8ea989c734c702aedd4"}, + {file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f53f9173ae21b650b4ed8aef1d0ad0c37821f367c221a982f4d2922b3044e0d"}, + {file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3956f3c5c229257dbeabe0b6aacd2c083ebcc1e335842a6ff2217fe6cc03b6b"}, + {file = "levenshtein-0.26.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1e83af732726987d2c4cd736f415dae8b966ba17b7a2239c8b7ffe70bfb5543"}, + {file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4f052c55046c2a9c9b5f742f39e02fa6e8db8039048b8c1c9e9fdd27c8a240a1"}, + {file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9895b3a98f6709e293615fde0dcd1bb0982364278fa2072361a1a31b3e388b7a"}, + {file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a3777de1d8bfca054465229beed23994f926311ce666f5a392c8859bb2722f16"}, + {file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:81c57e1135c38c5e6e3675b5e2077d8a8d3be32bf0a46c57276c092b1dffc697"}, + {file = "levenshtein-0.26.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:91d5e7d984891df3eff7ea9fec8cf06fdfacc03cd074fd1a410435706f73b079"}, + {file = "levenshtein-0.26.1-cp310-cp310-win32.whl", hash = "sha256:f48abff54054b4142ad03b323e80aa89b1d15cabc48ff49eb7a6ff7621829a56"}, + {file = "levenshtein-0.26.1-cp310-cp310-win_amd64.whl", hash = "sha256:79dd6ad799784ea7b23edd56e3bf94b3ca866c4c6dee845658ee75bb4aefdabf"}, + {file = "levenshtein-0.26.1-cp310-cp310-win_arm64.whl", hash = "sha256:3351ddb105ef010cc2ce474894c5d213c83dddb7abb96400beaa4926b0b745bd"}, + {file = "levenshtein-0.26.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44c51f5d33b3cfb9db518b36f1288437a509edd82da94c4400f6a681758e0cb6"}, + {file = "levenshtein-0.26.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56b93203e725f9df660e2afe3d26ba07d71871b6d6e05b8b767e688e23dfb076"}, + {file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:270d36c5da04a0d89990660aea8542227cbd8f5bc34e9fdfadd34916ff904520"}, + {file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:480674c05077eeb0b0f748546d4fcbb386d7c737f9fff0010400da3e8b552942"}, + {file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13946e37323728695ba7a22f3345c2e907d23f4600bc700bf9b4352fb0c72a48"}, + {file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceb673f572d1d0dc9b1cd75792bb8bad2ae8eb78a7c6721e23a3867d318cb6f2"}, + {file = "levenshtein-0.26.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42d6fa242e3b310ce6bfd5af0c83e65ef10b608b885b3bb69863c01fb2fcff98"}, + {file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8b68295808893a81e0a1dbc2274c30dd90880f14d23078e8eb4325ee615fc68"}, + {file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b01061d377d1944eb67bc40bef5d4d2f762c6ab01598efd9297ce5d0047eb1b5"}, + {file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9d12c8390f156745e533d01b30773b9753e41d8bbf8bf9dac4b97628cdf16314"}, + {file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:48825c9f967f922061329d1481b70e9fee937fc68322d6979bc623f69f75bc91"}, + {file = "levenshtein-0.26.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d8ec137170b95736842f99c0e7a9fd8f5641d0c1b63b08ce027198545d983e2b"}, + {file = "levenshtein-0.26.1-cp311-cp311-win32.whl", hash = "sha256:798f2b525a2e90562f1ba9da21010dde0d73730e277acaa5c52d2a6364fd3e2a"}, + {file = "levenshtein-0.26.1-cp311-cp311-win_amd64.whl", hash = "sha256:55b1024516c59df55f1cf1a8651659a568f2c5929d863d3da1ce8893753153bd"}, + {file = "levenshtein-0.26.1-cp311-cp311-win_arm64.whl", hash = "sha256:e52575cbc6b9764ea138a6f82d73d3b1bc685fe62e207ff46a963d4c773799f6"}, + {file = "levenshtein-0.26.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc741ca406d3704dc331a69c04b061fc952509a069b79cab8287413f434684bd"}, + {file = "levenshtein-0.26.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:821ace3b4e1c2e02b43cf5dc61aac2ea43bdb39837ac890919c225a2c3f2fea4"}, + {file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92694c9396f55d4c91087efacf81297bef152893806fc54c289fc0254b45384"}, + {file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51ba374de7a1797d04a14a4f0ad3602d2d71fef4206bb20a6baaa6b6a502da58"}, + {file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7aa5c3327dda4ef952769bacec09c09ff5bf426e07fdc94478c37955681885b"}, + {file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e2517e8d3c221de2d1183f400aed64211fcfc77077b291ed9f3bb64f141cdc"}, + {file = "levenshtein-0.26.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9092b622765c7649dd1d8af0f43354723dd6f4e570ac079ffd90b41033957438"}, + {file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fc16796c85d7d8b259881d59cc8b5e22e940901928c2ff6924b2c967924e8a0b"}, + {file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4370733967f5994ceeed8dc211089bedd45832ee688cecea17bfd35a9eb22b9"}, + {file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3535ecfd88c9b283976b5bc61265855f59bba361881e92ed2b5367b6990c93fe"}, + {file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:90236e93d98bdfd708883a6767826fafd976dac8af8fc4a0fb423d4fa08e1bf0"}, + {file = "levenshtein-0.26.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04b7cabb82edf566b1579b3ed60aac0eec116655af75a3c551fee8754ffce2ea"}, + {file = "levenshtein-0.26.1-cp312-cp312-win32.whl", hash = "sha256:ae382af8c76f6d2a040c0d9ca978baf461702ceb3f79a0a3f6da8d596a484c5b"}, + {file = "levenshtein-0.26.1-cp312-cp312-win_amd64.whl", hash = "sha256:fd091209798cfdce53746f5769987b4108fe941c54fb2e058c016ffc47872918"}, + {file = "levenshtein-0.26.1-cp312-cp312-win_arm64.whl", hash = "sha256:7e82f2ea44a81ad6b30d92a110e04cd3c8c7c6034b629aca30a3067fa174ae89"}, + {file = "levenshtein-0.26.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:790374a9f5d2cbdb30ee780403a62e59bef51453ac020668c1564d1e43438f0e"}, + {file = "levenshtein-0.26.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7b05c0415c386d00efda83d48db9db68edd02878d6dbc6df01194f12062be1bb"}, + {file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3114586032361722ddededf28401ce5baf1cf617f9f49fb86b8766a45a423ff"}, + {file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2532f8a13b68bf09f152d906f118a88da2063da22f44c90e904b142b0a53d534"}, + {file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:219c30be6aa734bf927188d1208b7d78d202a3eb017b1c5f01ab2034d2d4ccca"}, + {file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397e245e77f87836308bd56305bba630010cd8298c34c4c44bd94990cdb3b7b1"}, + {file = "levenshtein-0.26.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeff6ea3576f72e26901544c6c55c72a7b79b9983b6f913cba0e9edbf2f87a97"}, + {file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a19862e3539a697df722a08793994e334cd12791e8144851e8a1dee95a17ff63"}, + {file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:dc3b5a64f57c3c078d58b1e447f7d68cad7ae1b23abe689215d03fc434f8f176"}, + {file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bb6c7347424a91317c5e1b68041677e4c8ed3e7823b5bbaedb95bffb3c3497ea"}, + {file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b817376de4195a207cc0e4ca37754c0e1e1078c2a2d35a6ae502afde87212f9e"}, + {file = "levenshtein-0.26.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b50c3620ff47c9887debbb4c154aaaac3e46be7fc2e5789ee8dbe128bce6a17"}, + {file = "levenshtein-0.26.1-cp313-cp313-win32.whl", hash = "sha256:9fb859da90262eb474c190b3ca1e61dee83add022c676520f5c05fdd60df902a"}, + {file = "levenshtein-0.26.1-cp313-cp313-win_amd64.whl", hash = "sha256:8adcc90e3a5bfb0a463581d85e599d950fe3c2938ac6247b29388b64997f6e2d"}, + {file = "levenshtein-0.26.1-cp313-cp313-win_arm64.whl", hash = "sha256:c2599407e029865dc66d210b8804c7768cbdbf60f061d993bb488d5242b0b73e"}, + {file = "levenshtein-0.26.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc54ced948fc3feafce8ad4ba4239d8ffc733a0d70e40c0363ac2a7ab2b7251e"}, + {file = "levenshtein-0.26.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6516f69213ae393a220e904332f1a6bfc299ba22cf27a6520a1663a08eba0fb"}, + {file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4cfea4eada1746d0c75a864bc7e9e63d4a6e987c852d6cec8d9cb0c83afe25b"}, + {file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a323161dfeeac6800eb13cfe76a8194aec589cd948bcf1cdc03f66cc3ec26b72"}, + {file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c23e749b68ebc9a20b9047317b5cd2053b5856315bc8636037a8adcbb98bed1"}, + {file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f80dd7432d4b6cf493d012d22148db7af769017deb31273e43406b1fb7f091c"}, + {file = "levenshtein-0.26.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ae7cd6e4312c6ef34b2e273836d18f9fff518d84d823feff5ad7c49668256e0"}, + {file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dcdad740e841d791b805421c2b20e859b4ed556396d3063b3aa64cd055be648c"}, + {file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e07afb1613d6f5fd99abd4e53ad3b446b4efaa0f0d8e9dfb1d6d1b9f3f884d32"}, + {file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f1add8f1d83099a98ae4ac472d896b7e36db48c39d3db25adf12b373823cdeff"}, + {file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1010814b1d7a60833a951f2756dfc5c10b61d09976ce96a0edae8fecdfb0ea7c"}, + {file = "levenshtein-0.26.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33fa329d1bb65ce85e83ceda281aea31cee9f2f6e167092cea54f922080bcc66"}, + {file = "levenshtein-0.26.1-cp39-cp39-win32.whl", hash = "sha256:488a945312f2f16460ab61df5b4beb1ea2254c521668fd142ce6298006296c98"}, + {file = "levenshtein-0.26.1-cp39-cp39-win_amd64.whl", hash = "sha256:9f942104adfddd4b336c3997050121328c39479f69de702d7d144abb69ea7ab9"}, + {file = "levenshtein-0.26.1-cp39-cp39-win_arm64.whl", hash = "sha256:c1d8f85b2672939f85086ed75effcf768f6077516a3e299c2ba1f91bc4644c22"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6cf8f1efaf90ca585640c5d418c30b7d66d9ac215cee114593957161f63acde0"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d5b2953978b8c158dd5cd93af8216a5cfddbf9de66cf5481c2955f44bb20767a"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b952b3732c4631c49917d4b15d78cb4a2aa006c1d5c12e2a23ba8e18a307a055"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07227281e12071168e6ae59238918a56d2a0682e529f747b5431664f302c0b42"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8191241cd8934feaf4d05d0cc0e5e72877cbb17c53bbf8c92af9f1aedaa247e9"}, + {file = "levenshtein-0.26.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9e70d7ee157a9b698c73014f6e2b160830e7d2d64d2e342fefc3079af3c356fc"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0eb3059f826f6cb0a5bca4a85928070f01e8202e7ccafcba94453470f83e49d4"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c389e44da12d6fb1d7ba0a709a32a96c9391e9be4160ccb9269f37e040599ee"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e9de292f2c51a7d34a0ae23bec05391b8f61f35781cd3e4c6d0533e06250c55"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d87215113259efdca8716e53b6d59ab6d6009e119d95d45eccc083148855f33"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f00a3eebf68a82fb651d8d0e810c10bfaa60c555d21dde3ff81350c74fb4c2"}, + {file = "levenshtein-0.26.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b3554c1b59de63d05075577380340c185ff41b028e541c0888fddab3c259a2b4"}, + {file = "levenshtein-0.26.1.tar.gz", hash = "sha256:0d19ba22330d50609b2349021ec3cf7d905c6fe21195a2d0d876a146e7ed2575"}, +] + +[package.dependencies] +rapidfuzz = ">=3.9.0,<4.0.0" + +[[package]] +name = "more-itertools" +version = "10.5.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +files = [ + {file = "more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6"}, + {file = "more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef"}, +] + [[package]] name = "multidict" version = "6.1.0" @@ -570,6 +681,70 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "numpy" +version = "2.1.3" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4"}, + {file = "numpy-2.1.3-cp310-cp310-win32.whl", hash = "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23"}, + {file = "numpy-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0"}, + {file = "numpy-2.1.3-cp311-cp311-win32.whl", hash = "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9"}, + {file = "numpy-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0"}, + {file = "numpy-2.1.3-cp312-cp312-win32.whl", hash = "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9"}, + {file = "numpy-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef"}, + {file = "numpy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f"}, + {file = "numpy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17"}, + {file = "numpy-2.1.3-cp313-cp313t-win32.whl", hash = "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48"}, + {file = "numpy-2.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb"}, + {file = "numpy-2.1.3.tar.gz", hash = "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761"}, +] + [[package]] name = "packaging" version = "24.2" @@ -581,6 +756,91 @@ files = [ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] +[[package]] +name = "pandas" +version = "2.2.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"}, + {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"}, + {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"}, + {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"}, + {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"}, + {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"}, + {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"}, + {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"}, + {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"}, + {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"}, + {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"}, + {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"}, + {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"}, + {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"}, + {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"}, + {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"}, + {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"}, + {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"}, + {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"}, + {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"}, + {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"}, + {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"}, + {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"}, + {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"}, + {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + [[package]] name = "pathspec" version = "0.12.1" @@ -894,6 +1154,20 @@ pluggy = ">=0.12,<2.0" [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.0.1" @@ -908,6 +1182,131 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-levenshtein" +version = "0.26.1" +description = "Python extension for computing string edit distances and similarities." +optional = false +python-versions = ">=3.9" +files = [ + {file = "python_Levenshtein-0.26.1-py3-none-any.whl", hash = "sha256:8ef5e529dd640fb00f05ee62d998d2ee862f19566b641ace775d5ae16167b2ef"}, + {file = "python_levenshtein-0.26.1.tar.gz", hash = "sha256:24ba578e28058ebb4afa2700057e1678d7adf27e43cd1f17700c09a9009d5d3a"}, +] + +[package.dependencies] +Levenshtein = "0.26.1" + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "rapidfuzz" +version = "3.10.1" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.9" +files = [ + {file = "rapidfuzz-3.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f17d9f21bf2f2f785d74f7b0d407805468b4c173fa3e52c86ec94436b338e74a"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b31f358a70efc143909fb3d75ac6cd3c139cd41339aa8f2a3a0ead8315731f2b"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4f43f2204b56a61448ec2dd061e26fd344c404da99fb19f3458200c5874ba2"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d81bf186a453a2757472133b24915768abc7c3964194406ed93e170e16c21cb"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3611c8f45379a12063d70075c75134f2a8bd2e4e9b8a7995112ddae95ca1c982"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c3b537b97ac30da4b73930fa8a4fe2f79c6d1c10ad535c5c09726612cd6bed9"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231ef1ec9cf7b59809ce3301006500b9d564ddb324635f4ea8f16b3e2a1780da"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed4f3adc1294834955b7e74edd3c6bd1aad5831c007f2d91ea839e76461a5879"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7b6015da2e707bf632a71772a2dbf0703cff6525732c005ad24987fe86e8ec32"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1b35a118d61d6f008e8e3fb3a77674d10806a8972c7b8be433d6598df4d60b01"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc308d79a7e877226f36bdf4e149e3ed398d8277c140be5c1fd892ec41739e6d"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f017dbfecc172e2d0c37cf9e3d519179d71a7f16094b57430dffc496a098aa17"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-win32.whl", hash = "sha256:36c0e1483e21f918d0f2f26799fe5ac91c7b0c34220b73007301c4f831a9c4c7"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:10746c1d4c8cd8881c28a87fd7ba0c9c102346dfe7ff1b0d021cdf093e9adbff"}, + {file = "rapidfuzz-3.10.1-cp310-cp310-win_arm64.whl", hash = "sha256:dfa64b89dcb906835e275187569e51aa9d546a444489e97aaf2cc84011565fbe"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:92958ae075c87fef393f835ed02d4fe8d5ee2059a0934c6c447ea3417dfbf0e8"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba7521e072c53e33c384e78615d0718e645cab3c366ecd3cc8cb732befd94967"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d02cbd75d283c287471b5b3738b3e05c9096150f93f2d2dfa10b3d700f2db9"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efa1582a397da038e2f2576c9cd49b842f56fde37d84a6b0200ffebc08d82350"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f12912acee1f506f974f58de9fdc2e62eea5667377a7e9156de53241c05fdba8"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666d5d8b17becc3f53447bcb2b6b33ce6c2df78792495d1fa82b2924cd48701a"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26f71582c0d62445067ee338ddad99b655a8f4e4ed517a90dcbfbb7d19310474"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a2ef08b27167bcff230ffbfeedd4c4fa6353563d6aaa015d725dd3632fc3de7"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:365e4fc1a2b95082c890f5e98489b894e6bf8c338c6ac89bb6523c2ca6e9f086"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1996feb7a61609fa842e6b5e0c549983222ffdedaf29644cc67e479902846dfe"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:cf654702f144beaa093103841a2ea6910d617d0bb3fccb1d1fd63c54dde2cd49"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec108bf25de674781d0a9a935030ba090c78d49def3d60f8724f3fc1e8e75024"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-win32.whl", hash = "sha256:031f8b367e5d92f7a1e27f7322012f3c321c3110137b43cc3bf678505583ef48"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:f98f36c6a1bb9a6c8bbec99ad87c8c0e364f34761739b5ea9adf7b48129ae8cf"}, + {file = "rapidfuzz-3.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:f1da2028cb4e41be55ee797a82d6c1cf589442504244249dfeb32efc608edee7"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1340b56340896bede246f612b6ecf685f661a56aabef3d2512481bfe23ac5835"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2316515169b7b5a453f0ce3adbc46c42aa332cae9f2edb668e24d1fc92b2f2bb"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e06fe6a12241ec1b72c0566c6b28cda714d61965d86569595ad24793d1ab259"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d99c1cd9443b19164ec185a7d752f4b4db19c066c136f028991a480720472e23"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1d9aa156ed52d3446388ba4c2f335e312191d1ca9d1f5762ee983cf23e4ecf6"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54bcf4efaaee8e015822be0c2c28214815f4f6b4f70d8362cfecbd58a71188ac"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0c955e32afdbfdf6e9ee663d24afb25210152d98c26d22d399712d29a9b976b"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:191633722203f5b7717efcb73a14f76f3b124877d0608c070b827c5226d0b972"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:195baad28057ec9609e40385991004e470af9ef87401e24ebe72c064431524ab"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0fff4a6b87c07366662b62ae994ffbeadc472e72f725923f94b72a3db49f4671"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4ffed25f9fdc0b287f30a98467493d1e1ce5b583f6317f70ec0263b3c97dbba6"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d02cf8e5af89a9ac8f53c438ddff6d773f62c25c6619b29db96f4aae248177c0"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-win32.whl", hash = "sha256:f3bb81d4fe6a5d20650f8c0afcc8f6e1941f6fecdb434f11b874c42467baded0"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:aaf83e9170cb1338922ae42d320699dccbbdca8ffed07faeb0b9257822c26e24"}, + {file = "rapidfuzz-3.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:c5da802a0d085ad81b0f62828fb55557996c497b2d0b551bbdfeafd6d447892f"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc22d69a1c9cccd560a5c434c0371b2df0f47c309c635a01a913e03bbf183710"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38b0dac2c8e057562b8f0d8ae5b663d2d6a28c5ab624de5b73cef9abb6129a24"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fde3bbb14e92ce8fcb5c2edfff72e474d0080cadda1c97785bf4822f037a309"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9141fb0592e55f98fe9ac0f3ce883199b9c13e262e0bf40c5b18cdf926109d16"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:237bec5dd1bfc9b40bbd786cd27949ef0c0eb5fab5eb491904c6b5df59d39d3c"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18123168cba156ab5794ea6de66db50f21bb3c66ae748d03316e71b27d907b95"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b75fe506c8e02769cc47f5ab21ce3e09b6211d3edaa8f8f27331cb6988779be"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da82aa4b46973aaf9e03bb4c3d6977004648c8638febfc0f9d237e865761270"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c34c022d5ad564f1a5a57a4a89793bd70d7bad428150fb8ff2760b223407cdcf"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e96c84d6c2a0ca94e15acb5399118fff669f4306beb98a6d8ec6f5dccab4412"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e8e154b84a311263e1aca86818c962e1fa9eefdd643d1d5d197fcd2738f88cb9"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:335fee93188f8cd585552bb8057228ce0111bd227fa81bfd40b7df6b75def8ab"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-win32.whl", hash = "sha256:6729b856166a9e95c278410f73683957ea6100c8a9d0a8dbe434c49663689255"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:0e06d99ad1ad97cb2ef7f51ec6b1fedd74a3a700e4949353871cf331d07b382a"}, + {file = "rapidfuzz-3.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:8d1b7082104d596a3eb012e0549b2634ed15015b569f48879701e9d8db959dbb"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:779027d3307e1a2b1dc0c03c34df87a470a368a1a0840a9d2908baf2d4067956"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:440b5608ab12650d0390128d6858bc839ae77ffe5edf0b33a1551f2fa9860651"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cac41a411e07a6f3dc80dfbd33f6be70ea0abd72e99c59310819d09f07d945"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:958473c9f0bca250590200fd520b75be0dbdbc4a7327dc87a55b6d7dc8d68552"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ef60dfa73749ef91cb6073be1a3e135f4846ec809cc115f3cbfc6fe283a5584"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7fbac18f2c19fc983838a60611e67e3262e36859994c26f2ee85bb268de2355"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a0d519ff39db887cd73f4e297922786d548f5c05d6b51f4e6754f452a7f4296"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bebb7bc6aeb91cc57e4881b222484c26759ca865794187217c9dcea6c33adae6"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe07f8b9c3bb5c5ad1d2c66884253e03800f4189a60eb6acd6119ebaf3eb9894"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:bfa48a4a2d45a41457f0840c48e579db157a927f4e97acf6e20df8fc521c79de"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2cf44d01bfe8ee605b7eaeecbc2b9ca64fc55765f17b304b40ed8995f69d7716"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e6bbca9246d9eedaa1c84e04a7f555493ba324d52ae4d9f3d9ddd1b740dcd87"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-win32.whl", hash = "sha256:567f88180f2c1423b4fe3f3ad6e6310fc97b85bdba574801548597287fc07028"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6b2cd7c29d6ecdf0b780deb587198f13213ac01c430ada6913452fd0c40190fc"}, + {file = "rapidfuzz-3.10.1-cp39-cp39-win_arm64.whl", hash = "sha256:9f912d459e46607ce276128f52bea21ebc3e9a5ccf4cccfef30dd5bddcf47be8"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ac4452f182243cfab30ba4668ef2de101effaedc30f9faabb06a095a8c90fd16"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:565c2bd4f7d23c32834652b27b51dd711814ab614b4e12add8476be4e20d1cf5"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:187d9747149321607be4ccd6f9f366730078bed806178ec3eeb31d05545e9e8f"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:616290fb9a8fa87e48cb0326d26f98d4e29f17c3b762c2d586f2b35c1fd2034b"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:073a5b107e17ebd264198b78614c0206fa438cce749692af5bc5f8f484883f50"}, + {file = "rapidfuzz-3.10.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39c4983e2e2ccb9732f3ac7d81617088822f4a12291d416b09b8a1eadebb3e29"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ac7adee6bcf0c6fee495d877edad1540a7e0f5fc208da03ccb64734b43522d7a"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:425f4ac80b22153d391ee3f94bc854668a0c6c129f05cf2eaf5ee74474ddb69e"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65a2fa13e8a219f9b5dcb9e74abe3ced5838a7327e629f426d333dfc8c5a6e66"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75561f3df9a906aaa23787e9992b228b1ab69007932dc42070f747103e177ba8"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edd062490537e97ca125bc6c7f2b7331c2b73d21dc304615afe61ad1691e15d5"}, + {file = "rapidfuzz-3.10.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfcc8feccf63245a22dfdd16e222f1a39771a44b870beb748117a0e09cbb4a62"}, + {file = "rapidfuzz-3.10.1.tar.gz", hash = "sha256:5a15546d847a915b3f42dc79ef9b0c78b998b4e2c53b252e7166284066585979"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "requests" version = "2.32.3" @@ -969,6 +1368,17 @@ files = [ {file = "ruff-0.0.271.tar.gz", hash = "sha256:be4590137a31c47e7f6ef4488d60102c68102f842453355d8073193a30199aa7"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1022,6 +1432,17 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2024.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + [[package]] name = "ujson" version = "5.10.0" @@ -1383,4 +1804,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "526c43b554462b8b6ea2fa0d825e64940bfaac2c9b6b3e478acd1389a7e857e7" +content-hash = "92038df090abe6e58028a1f7cfb04c65e5eaa804cdbc91da784f6a26b9f84fcc" diff --git a/pyproject.toml b/pyproject.toml index 5f57c6f..ae5b0b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,17 @@ [tool.poetry] name = "speckle-automate-py" version = "0.1.0" -description = "Example function for Speckle Automate using specklepy" -authors = ["Gergő Jedlicska "] +description = "Template function for SpeckleCon Coding workshop" +authors = ["Jonathon Broughton "] readme = "README.md" [tool.poetry.dependencies] python = "^3.11" specklepy = "^2.20.0" +python-levenshtein = "^0.26.1" +more-itertools = "^10.5.0" +pandas = "^2.2.2" +python-dotenv = "^1.0.1" [tool.poetry.group.dev.dependencies] black = "^23.3.0" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3fbff20 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import os + +from dotenv import load_dotenv + + +def pytest_configure(config): + load_dotenv(dotenv_path=".env") + + token_var = "SPECKLE_TOKEN" + server_var = "SPECKLE_SERVER_URL" + token = os.getenv(token_var) + server = os.getenv(server_var) + + if not token: + raise ValueError(f"Cannot run tests without a {token_var} environment variable") + + if not server: + raise ValueError( + f"Cannot run tests without a {server_var} environment variable" + ) + + # Set the token as an attribute on the config object + config.SPECKLE_TOKEN = token + config.SPECKLE_SERVER_URL = server diff --git a/tests/test_exercise_0.py b/tests/test_exercise_0.py new file mode 100644 index 0000000..a1ec67a --- /dev/null +++ b/tests/test_exercise_0.py @@ -0,0 +1,147 @@ +import os +import secrets +import string + +import pytest +from gql import gql +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) +from specklepy.api.client import SpeckleClient + +from Exercises.exercise_0.function import automate_function +from Exercises.exercise_0.inputs import FunctionInputs + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token() -> str: + """Provide a speckle token for the test suite.""" + env_var = "SPECKLE_TOKEN" + token = os.getenv(env_var) + if not token: + raise ValueError(f"Cannot run tests without a {env_var} environment variable") + return token + + +@pytest.fixture() +def speckle_server_url() -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return os.getenv("SPECKLE_SERVER_URL", "http://127.0.0.1:3000") + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient( + speckle_server_url, speckle_server_url.startswith("https") + ) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +# fixture to mock the AutomationRunData that would be generated by a full Automation Run +def fake_automation_run_data(request, test_client: SpeckleClient) -> AutomationRunData: + server_url = request.config.SPECKLE_SERVER_URL + project_id = "ce0f229748" + model_id = "5e44c03b0b" + + function_name = "Exercise 0" + + automation_id = crypto_random_string(10) + automation_name = "Local Test" + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + fake_run_data = AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name="exercise 1", + version_id="136fe819e0", + speckle_server_url=server_url, + # These ids would be available with a valid registered Automation definition. + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=crypto_random_string(12), + # These ids would be available with a valid registered Function definition. Can also be faked. + function_id="12345", + function_name=function_name, + function_logo=None, + ) + + return fake_run_data + + +@pytest.fixture +def context(fake_automation_run_data: AutomationRunData, speckle_token: str): + return AutomationContext.initialize(fake_automation_run_data, speckle_token) + + +def test_function_run(fake_automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + context = AutomationContext.initialize(fake_automation_run_data, speckle_token) + + automate_sdk = run_function( + context, automate_function, FunctionInputs(commentPhrase="Tested Locally") + ) + + assert automate_sdk.run_status == AutomationStatus.SUCCEEDED diff --git a/tests/test_exercise_1.py b/tests/test_exercise_1.py new file mode 100644 index 0000000..66cd570 --- /dev/null +++ b/tests/test_exercise_1.py @@ -0,0 +1,144 @@ +import os +import secrets +import string + +import pytest +from gql import gql +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) +from specklepy.api.client import SpeckleClient + +from Exercises.exercise_1.function import automate_function +from Exercises.exercise_1.inputs import FunctionInputs + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token() -> str: + """Provide a speckle token for the test suite.""" + env_var = "SPECKLE_TOKEN" + token = os.getenv(env_var) + if not token: + raise ValueError(f"Cannot run tests without a {env_var} environment variable") + return token + + +@pytest.fixture() +def speckle_server_url() -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return os.getenv("SPECKLE_SERVER_URL", "http://127.0.0.1:3000") + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient( + speckle_server_url, speckle_server_url.startswith("https") + ) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +# fixture to mock the AutomationRunData that would be generated by a full Automation Run +def fake_automation_run_data(request, test_client: SpeckleClient) -> AutomationRunData: + server_url = request.config.SPECKLE_SERVER_URL + project_id = "ce0f229748" + model_id = "7658a4f311" + + function_name = "Local Test" + + automation_id = crypto_random_string(10) + automation_name = "Exercise 1" + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + fake_run_data = AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name="exercise 1", + version_id="bbfa404f93", + speckle_server_url=server_url, + # These ids would be available with a valid registered Automation definition. + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=crypto_random_string(12), + # These ids would be available with a valid registered Function definition. Can also be faked. + function_id="12345", + function_name=function_name, + function_logo=None, + ) + + return fake_run_data + + +def test_function_run(fake_automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + context = AutomationContext.initialize(fake_automation_run_data, speckle_token) + + automate_sdk = run_function( + context, + automate_function, + FunctionInputs(commentPhrase="Tested Locally", numberOfElements=10000), + ) + + assert automate_sdk.run_status == AutomationStatus.SUCCEEDED diff --git a/tests/test_exercise_2.py b/tests/test_exercise_2.py new file mode 100644 index 0000000..c116109 --- /dev/null +++ b/tests/test_exercise_2.py @@ -0,0 +1,144 @@ +import os +import secrets +import string + +import pytest +from gql import gql +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) +from specklepy.api.client import SpeckleClient + +from Exercises.exercise_2.function import automate_function +from Exercises.exercise_2.inputs import FunctionInputs + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token() -> str: + """Provide a speckle token for the test suite.""" + env_var = "SPECKLE_TOKEN" + token = os.getenv(env_var) + if not token: + raise ValueError(f"Cannot run tests without a {env_var} environment variable") + return token + + +@pytest.fixture() +def speckle_server_url() -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return os.getenv("SPECKLE_SERVER_URL", "http://127.0.0.1:3000") + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient( + speckle_server_url, speckle_server_url.startswith("https") + ) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +# fixture to mock the AutomationRunData that would be generated by a full Automation Run +def fake_automation_run_data(request, test_client: SpeckleClient) -> AutomationRunData: + server_url = request.config.SPECKLE_SERVER_URL + project_id = "ce0f229748" + model_id = "71fc3bfac6" + + function_name = "Local Test" + + automation_id = crypto_random_string(10) + automation_name = "Exercise 2" + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + fake_run_data = AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name="exercise 3", + version_id="227572481f", + speckle_server_url=server_url, + # These ids would be available with a valid registered Automation definition. + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=crypto_random_string(12), + # These ids would be available with a valid registered Function definition. Can also be faked. + function_id="12345", + function_name=function_name, + function_logo=None, + ) + + return fake_run_data + + +def test_function_run(fake_automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + context = AutomationContext.initialize(fake_automation_run_data, speckle_token) + + automate_sdk = run_function( + context, + automate_function, + FunctionInputs(category="Doors", property="SPECKLE_Classification"), + ) + + assert automate_sdk.run_status == AutomationStatus.SUCCEEDED diff --git a/tests/test_exercise_3.py b/tests/test_exercise_3.py new file mode 100644 index 0000000..cf665a1 --- /dev/null +++ b/tests/test_exercise_3.py @@ -0,0 +1,149 @@ +import os +import secrets +import string + +import pytest +from gql import gql +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) +from specklepy.api.client import SpeckleClient + +from Exercises.exercise_3.function import automate_function +from Exercises.exercise_3.inputs import FunctionInputs + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token() -> str: + """Provide a speckle token for the test suite.""" + env_var = "SPECKLE_TOKEN" + token = os.getenv(env_var) + if not token: + raise ValueError(f"Cannot run tests without a {env_var} environment variable") + return token + + +@pytest.fixture() +def speckle_server_url() -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return os.getenv("SPECKLE_SERVER_URL", "http://127.0.0.1:3000") + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient( + speckle_server_url, speckle_server_url.startswith("https") + ) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +# fixture to mock the AutomationRunData that would be generated by a full Automation Run +def fake_automation_run_data(request, test_client: SpeckleClient) -> AutomationRunData: + server_url = request.config.SPECKLE_SERVER_URL + project_id = "ce0f229748" + model_id = "6dadd92d7f" + + function_name = "Local Test" + + automation_id = crypto_random_string(10) + automation_name = "Exercise 3" + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + fake_run_data = AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name="exercise 3", + version_id="8fe89b87d9", + speckle_server_url=server_url, + # These ids would be available with a valid registered Automation definition. + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=crypto_random_string(12), + # These ids would be available with a valid registered Function definition. Can also be faked. + function_id="12345", + function_name=function_name, + function_logo=None, + ) + + return fake_run_data + + +def test_function_run(fake_automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + context = AutomationContext.initialize(fake_automation_run_data, speckle_token) + + default_url: str = ( + "https://docs.google.com/spreadsheets/d/e/2PACX-1vSFmjLfqxPKXJHg-wEs1cp_nJEJJhESGVTLCvWLG_" + "IgIuRZ4CmMDCSceOYFvuo8IqcmT4sj9qPiLfCx/pub?gid=0&single=true&output=tsv" + ) + + automate_sdk = run_function( + context, + automate_function, + FunctionInputs(spreadsheet_url=default_url), + ) + + assert automate_sdk.run_status == AutomationStatus.SUCCEEDED diff --git a/tests/test_function.py b/tests/test_function.py deleted file mode 100644 index dbc33b1..0000000 --- a/tests/test_function.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Run integration tests with a speckle server.""" - -from pydantic import SecretStr - -from speckle_automate import ( - AutomationContext, - AutomationRunData, - AutomationStatus, - run_function -) - -from main import FunctionInputs, automate_function - -from speckle_automate.fixtures import * - - -def test_function_run(test_automation_run_data: AutomationRunData, test_automation_token: str): - """Run an integration test for the automate function.""" - automation_context = AutomationContext.initialize( - test_automation_run_data, test_automation_token - ) - automate_sdk = run_function( - automation_context, - automate_function, - FunctionInputs( - forbidden_speckle_type="None", - whisper_message=SecretStr("testing automatically"), - ), - ) - - assert automate_sdk.run_status == AutomationStatus.SUCCEEDED diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..576ab80 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,146 @@ +import os +import secrets +import string + +import pytest +from gql import gql +from speckle_automate import ( + AutomationContext, + AutomationRunData, + AutomationStatus, + run_function, +) +from specklepy.api.client import SpeckleClient + +from main import FunctionInputs, automate_function + + +def crypto_random_string(length: int) -> str: + """Generate a semi crypto random string of a given length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def register_new_automation( + project_id: str, + model_id: str, + speckle_client: SpeckleClient, + automation_id: str, + automation_name: str, + automation_revision_id: str, +): + """Register a new automation in the speckle server.""" + query = gql( + """ + mutation CreateAutomation( + $projectId: String! + $modelId: String! + $automationName: String! + $automationId: String! + $automationRevisionId: String! + ) { + automationMutations { + create( + input: { + projectId: $projectId + modelId: $modelId + automationName: $automationName + automationId: $automationId + automationRevisionId: $automationRevisionId + } + ) + } + } + """ + ) + params = { + "projectId": project_id, + "modelId": model_id, + "automationName": automation_name, + "automationId": automation_id, + "automationRevisionId": automation_revision_id, + } + speckle_client.httpclient.execute(query, params) + + +@pytest.fixture() +def speckle_token() -> str: + """Provide a speckle token for the test suite.""" + env_var = "SPECKLE_TOKEN" + token = os.getenv(env_var) + if not token: + raise ValueError(f"Cannot run tests without a {env_var} environment variable") + return token + + +@pytest.fixture() +def speckle_server_url() -> str: + """Provide a speckle server url for the test suite, default to localhost.""" + return os.getenv("SPECKLE_SERVER_URL", "http://127.0.0.1:3000") + + +@pytest.fixture() +def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient: + """Initialize a SpeckleClient for testing.""" + test_client = SpeckleClient( + speckle_server_url, speckle_server_url.startswith("https") + ) + test_client.authenticate_with_token(speckle_token) + return test_client + + +@pytest.fixture() +# fixture to mock the AutomationRunData that would be generated by a full Automation Run +def fake_automation_run_data(request, test_client: SpeckleClient) -> AutomationRunData: + server_url = request.config.SPECKLE_SERVER_URL + project_id = "ce0f229748" + model_id = "634a39fa45" + + function_name = "BILT Riga Workshop" + + automation_id = crypto_random_string(10) + automation_name = "Local Test" + automation_revision_id = crypto_random_string(10) + + register_new_automation( + project_id, + model_id, + test_client, + automation_id, + automation_name, + automation_revision_id, + ) + + fake_run_data = AutomationRunData( + project_id=project_id, + model_id=model_id, + branch_name="basic", + version_id="df0f86a3fd", + speckle_server_url=server_url, + # These ids would be available with a valid registered Automation definition. + automation_id=automation_id, + automation_revision_id=automation_revision_id, + automation_run_id=crypto_random_string(12), + # These ids would be available with a valid registered Function definition. Can also be faked. + function_id="12345", + function_name=function_name, + function_logo=None, + ) + + return fake_run_data + + +def test_function_run(fake_automation_run_data: AutomationRunData, speckle_token: str): + """Run an integration test for the automate function.""" + context = AutomationContext.initialize(fake_automation_run_data, speckle_token) + + automate_sdk = run_function( + context, + automate_function, + FunctionInputs(commentPhrase="Test Locally"), + # FunctionInputs( + # tolerance=0.1, tolerance_unit="mm", static_model_name="simple beams" + # ), + ) + + assert automate_sdk.run_status == AutomationStatus.SUCCEEDED