v3 (#65)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled

This commit is contained in:
Jonathon Broughton
2025-06-05 14:03:44 +01:00
committed by GitHub
parent 0019667302
commit c7171a54cb
17 changed files with 801 additions and 531 deletions
+30
View File
@@ -0,0 +1,30 @@
# Use the official Python 3.13 slim image as the base
FROM python:3.13-slim
# Change to UK mirror for better reliability (robust for missing files)
RUN find /etc/apt/ -name '*.list' -exec sed -i 's|http://deb.debian.org|http://ftp.uk.debian.org|g' {} + || true
# Force apt to use IPv4 to avoid CDN/network issues
RUN echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99force-ipv4
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /home/speckle
# Create a non-root user
RUN useradd -ms /bin/bash vscode
USER vscode
# Set environment variables
ENV PYTHONPATH=/home/speckle
ENV PYTHONUNBUFFERED=1
# Install Python dependencies
COPY requirements.txt requirements-dev.txt pyproject.toml ./
RUN pip install --no-cache-dir -r requirements.txt && \
pip install --no-cache-dir -r requirements-dev.txt && \
echo 'export PATH=$PATH:$HOME/.local/bin' >> ~/.bashrc
+42 -36
View File
@@ -1,43 +1,49 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{ {
"name": "Python 3", "name": "Model Checker - An Automate Function",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "dockerFile": "Dockerfile",
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", "context": "..",
"features": { "workspaceFolder": "/home/speckle",
"ghcr.io/devcontainers-contrib/features/poetry:2": {} "runArgs": [
}, "--network",
"host"
"remoteEnv": { ],
"SPECKLE_TOKEN": "foobar" "mounts": [
}, "source=${localWorkspaceFolder},target=/home/speckle,type=bind,consistency=cached"
"containerEnv": { ],
"SPECKLE_TOKEN": "asdfasdf"
},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "cp .env.example .env && POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-root",
// Configure tool-specific properties.
"customizations": { "customizations": {
"vscode": { "vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"ms-python.vscode-pylance",
"ms-python.python", "ms-python.python",
"ms-python.vscode-pylance",
"ms-python.black-formatter", "ms-python.black-formatter",
"streetsidesoftware.code-spell-checker", "ms-python.isort",
"mikestead.dotenv" "ms-python.flake8",
] "littlefoxteam.vscode-python-test-adapter",
"ms-azuretools.vscode-docker",
"charliermarsh.ruff"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.formatting.provider": "black",
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.autoTestDiscoverOnSaveEnabled": true,
"python.testing.cwd": "${workspaceFolder}",
"[python]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
}
}
} }
} },
"postCreateCommand": "sh -c \"mkdir -p ~/.pip && echo '[global]\nprefer-ipv4 = true' > ~/.pip/pip.conf\"",
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. "postStartCommand": "echo 'Container started successfully!'"
// "remoteUser": "root" }
}
+24 -2
View File
@@ -5,5 +5,27 @@
"stringcase", "stringcase",
"typer" "typer"
], ],
"python.defaultInterpreterPath": ".venv/bin/python" "python.defaultInterpreterPath": ".venv/bin/python",
} "python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.autoTestDiscoverOnSaveEnabled": true,
"python.testing.cwd": "${workspaceFolder}",
"editor.formatOnSave": true,
"editor.rulers": [
79
],
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports.ruff": "explicit"
}
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
# Use the official Python 3.11 slim image as the base # Use the official Python 3.13 slim image as the base
FROM python:3.11-slim FROM python:3.13-slim
# Set the working directory inside the container # Set the working directory inside the container
WORKDIR /home/speckle WORKDIR /home/speckle
@@ -9,7 +9,7 @@ COPY . /home/speckle
# Upgrade pip and install dependencies using requirements.txt # Upgrade pip and install dependencies using requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /home/speckle/requirements.txt pip install --no-cache-dir -r /home/speckle/requirements.txt
# Set the entrypoint for running the Speckle function # Set the entrypoint for running the Speckle function
CMD ["python", "-u", "main.py", "run"] CMD ["python", "-u", "main.py", "run"]
+42 -21
View File
@@ -1,36 +1,57 @@
[project] [project]
name = "speckle-automate-checker" name = "speckle-automate-checker"
version = "0.1.0" version = "3.0.0"
description = "Allows for QAQC property checking with Speckle" description = "Allows for QAQC property checking with Speckle"
authors = ["Jonathon Broughton <jonathon@speckle.systems>"] authors = [{ name = "Jonathon Broughton", email = "jonathon@speckle.systems" }]
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"more-itertools>=10.6.0", "more-itertools>=10.6.0",
"pandas>=2.2.3", "pandas>=2.2.3",
"pydantic==2.10.6", "pydantic==2.10.6",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"python-levenshtein>=0.26.1", "python-levenshtein>=0.26.1",
"specklepy>=2.21.3", "specklepy>=3.0.0",
"pytest-assertcount>=1.0.0", "pydantic-settings>=2.7.1",
"black>=25.1.0",
"mypy>=1.15.0",
"pydantic-settings>=2.7.1",
"pytest>=8.3.4",
"ruff>=0.9.6",
] ]
[project.optional-dependencies]
dev = [
"mypy>=1.15.0",
"pytest>=8.3.4",
"pytest-assertcount>=1.0.0",
"ruff==0.11.12",
]
[tool.ruff] [tool.ruff]
select = [ select = [
"E", # pycodestyle "E", # pycodestyle
"F", # pyflakes "F", # pyflakes
"UP", # pyupgrade "UP", # pyupgrade
"D", # pydocstyle "D", # pydocstyle
"I", # isort "I", # isort
] ]
line-length = 120 ignore = ["F401", "F403", "E501"]
ignore = ["F401", "F403"] exclude = [".venv", "**/*.yml"]
line-length = 79
[tool.ruff.pydocstyle] [tool.ruff.pydocstyle]
convention = "google" convention = "google"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
docstring-code-format = true
docstring-code-line-length = 79
[tool.ruff.isort]
known-first-party = ["src"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.setuptools]
py-modules = []
+23
View File
@@ -0,0 +1,23 @@
argcomplete==3.6.2
click==8.1.8
colorama==0.4.6
coverage==7.8.2
flake8==7.2.0
iniconfig==2.1.0
isort==6.0.1
mccabe==0.7.0
mypy_extensions==1.1.0
packaging==24.2
pathspec==0.12.1
pipx==1.7.1
platformdirs==4.3.7
pluggy==1.6.0
pycodestyle==2.13.0
pyflakes==3.3.2
Pygments==2.19.1
pytest>=8.3.4
pytest-assertcount>=1.0.0
pytest-cov==6.1.1
ruff==0.11.12
userpath==1.9.2
mypy>=1.15.0
+6 -53
View File
@@ -1,54 +1,7 @@
annotated-types==0.7.0 more-itertools>=10.6.0
anyio==4.8.0 pandas>=2.2.3
appdirs==1.4.4
attrs==23.2.0
backoff==2.2.1
black==25.1.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
deprecated==1.2.18
gql==3.5.0
graphql-core==3.2.6
h11==0.14.0
httpcore==1.0.7
httpx==0.25.2
idna==3.10
iniconfig==2.0.0
levenshtein==0.26.1
more-itertools==10.6.0
multidict==6.1.0
mypy==1.15.0
mypy-extensions==1.0.0
numpy==2.2.3
packaging==24.2
pandas==2.2.3
pathspec==0.12.1
platformdirs==4.3.6
pluggy==1.5.0
propcache==0.2.1
pydantic==2.10.6 pydantic==2.10.6
pydantic-core==2.27.2 python-dotenv>=1.0.1
pydantic-settings==2.7.1 python-levenshtein>=0.26.1
pytest==8.3.4 specklepy>=3.0.0
pytest-assertcount==1.0.0 pydantic-settings>=2.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-levenshtein==0.26.1
pytz==2025.1
rapidfuzz==3.12.1
requests==2.32.3
requests-toolbelt==1.0.0
ruff==0.9.6
six==1.17.0
sniffio==1.3.1
specklepy==2.21.3
stringcase==1.2.0
typing-extensions==4.12.2
tzdata==2025.1
ujson==5.10.0
urllib3==2.3.0
websockets==11.0.3
wrapt==1.17.2
yarl==1.18.3
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Store the current Python environment
CURRENT_ENV=$(pip freeze)
# Remove dev dependencies
pip uninstall -y pytest pytest-cov isort flake8 ruff
# Generate production requirements
pip freeze > requirements.txt
# Reinstall dev dependencies
pip install pytest pytest-cov isort flake8 ruff
# Generate dev requirements
pip freeze > requirements-dev.txt
# Restore the original environment
pip uninstall -y pytest pytest-cov isort flake8 ruff
echo "$CURRENT_ENV" | pip install -r /dev/stdin
echo "Requirements files have been updated successfully!"
+12
View File
@@ -0,0 +1,12 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"files.autoSave": "onFocusChange",
"editor.defaultFormatter": null,
"editor.formatOnSave": true
}
}
+5 -2
View File
@@ -82,7 +82,9 @@ def automate_function(
# The rules are defined in an external spreadsheet (TSV format) # The rules are defined in an external spreadsheet (TSV format)
# This allows non-technical users to define and modify rules # This allows non-technical users to define and modify rules
# without changing the code # without changing the code
grouped_rules, messages = read_rules_from_spreadsheet(function_inputs.spreadsheet_url) grouped_rules, messages = read_rules_from_spreadsheet(
function_inputs.spreadsheet_url
)
# Handle any validation messages from rule processing # Handle any validation messages from rule processing
for message in messages: for message in messages:
@@ -119,5 +121,6 @@ def automate_function(
# Mark the run as successful and provide a summary message # Mark the run as successful and provide a summary message
# This message will be displayed to the user in the Speckle UI # This message will be displayed to the user in the Speckle UI
automate_context.mark_run_success( automate_context.mark_run_success(
f"Successfully applied {len(grouped_rules)} rules to {len(flat_list_of_objects)} version {VERSION} objects." f"Successfully applied {len(grouped_rules)} rules to "
f"{len(flat_list_of_objects)} version {VERSION} objects."
) )
+57 -26
View File
@@ -4,7 +4,7 @@ from collections.abc import Generator, Iterable
from typing import Any from typing import Any
from specklepy.objects import Base from specklepy.objects import Base
from specklepy.objects.other import Instance, Transform from specklepy.objects.proxies import InstanceProxy as Instance
def speckle_print(log_string: str = "banana") -> None: def speckle_print(log_string: str = "banana") -> None:
@@ -27,7 +27,8 @@ def get_item(obj: Base | dict[str, Any], key, default=None):
return obj.get(key, default) return obj.get(key, default)
elif hasattr(obj, key): # If it's an object with the attribute elif hasattr(obj, key): # If it's an object with the attribute
return getattr(obj, key, default) return getattr(obj, key, default)
return default # Return default if it's neither a dict nor an object with the attribute return default # Return default if it's neither a dict nor an object with
# the attribute
def has_item(obj: Base | dict[str, Any], key: str) -> bool: def has_item(obj: Base | dict[str, Any], key: str) -> bool:
@@ -39,7 +40,9 @@ def has_item(obj: Base | dict[str, Any], key: str) -> bool:
return False return False
def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]: def flatten_base_thorough(
base: Base, parent_type: str | None = None
) -> Iterable[Base]:
"""Take a base and flatten it to an iterable of bases. """Take a base and flatten it to an iterable of bases.
Args: Args:
@@ -69,7 +72,9 @@ def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]
print(category) print(category)
if category.startswith("@"): if category.startswith("@"):
category_object: Base = getattr(base, category)[0] category_object: Base = getattr(base, category)[0]
yield from flatten_base_thorough(category_object, category_object.speckle_type) yield from flatten_base_thorough(
category_object, category_object.speckle_type
)
except KeyError: except KeyError:
pass pass
@@ -80,52 +85,78 @@ def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]
def extract_base_and_transform( def extract_base_and_transform(
base: Base, base: Base,
inherited_instance_id: str | None = None, inherited_instance_id: str | None = None,
transform_list: list[Transform] | None = None, transform_list: list[list[float]] | None = None,
) -> Generator[ ) -> Generator[
Base | str | list[Transform] | None | tuple[Base, Any | None, list[Transform] | None | list[Any]], Any | None, None Base
| str
| list[list[float]]
| None
| tuple[Base, Any | None, list[list[float]] | None | list[Any]],
Any | None,
]: ]:
"""Traverses Speckle object hierarchies to yield `Base` objects and their transformations. """Traverses Speckle object hierarchies to yield `Base`s and transformas.
Tailored to Speckle's AEC data structures, it covers the newer hierarchical structures Tailored to Speckle's AEC data structures, it covers the newer
with Collections and also with patterns found in older Revit specific data. hierarchical structures with Collections and also with patterns found in
older Revit specific data.
Parameters: Parameters:
- base (Base): The starting point `Base` object for traversal. - base (Base): The starting point `Base` object for traversal.
- inherited_instance_id (str, optional): The inherited identifier for `Base` objects without a unique ID. - inherited_instance_id (str, optional): The inherited identifier for
- transform_list (List[Transform], optional): Accumulated list of transformations from parent to child objects. `Base` objects without a unique ID.
- transform_list (List[List[float]], optional): Accumulated list of
transformations from parent to child objects.
Yields: Yields:
- tuple: A `Base` object, its identifier, and a list of applicable `Transform` objects or None. - tuple: A `Base` object, its identifier, and a list of applicable
transformations or None.
The id of the `Base` object is either the inherited identifier for a definition from an instance The id of the `Base` object is either the inherited identifier for a
or the one defined in the object. 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. # Derive the identifier for the current `Base` object, defaulting to an
# inherited one if needed.
current_id = getattr(base, "id", inherited_instance_id) current_id = getattr(base, "id", inherited_instance_id)
transform_list = transform_list or [] transform_list = transform_list or []
if isinstance(base, Instance): if isinstance(base, Instance):
# Append transformation data and dive into the definition of `Instance` objects. # Append transformation data and dive into the definition of `Instance`
# objects.
if base.transform: if base.transform:
transform_list.append(base.transform) transform_list.append(base.transform)
if base.definition: if base.definition:
yield from extract_base_and_transform(base.definition, current_id, transform_list.copy()) yield from extract_base_and_transform(
base.definition, current_id, transform_list.copy()
)
else: else:
# Initial yield for the current `Base` object. # Initial yield for the current `Base` object.
yield base, current_id, transform_list yield base, current_id, transform_list
# Process 'elements' and '@elements', typical containers for `Base` objects in AEC models. # Process 'elements' and '@elements', typical containers for `Base`
elements_attr = getattr(base, "elements", []) or getattr(base, "@elements", []) # objects in AEC models.
elements_attr = getattr(base, "elements", []) or getattr(
base, "@elements", []
)
for element in elements_attr: for element in elements_attr:
if isinstance(element, Base): if isinstance(element, Base):
# Recurse into each `Base` object within 'elements' or '@elements'. # Recurse into each `Base` object within 'elements' or
yield from extract_base_and_transform(element, current_id, transform_list.copy()) # '@elements'.
yield from extract_base_and_transform(
element, current_id, transform_list.copy()
)
# Recursively process '@'-prefixed properties that are Base objects with 'elements'. # Recursively process '@'-prefixed properties that are Base objects
# This is a common pattern in older Speckle data models, such as those used for Revit commits. # 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): for attr_name in dir(base):
if attr_name.startswith("@"): if attr_name.startswith("@"):
attr_value = getattr(base, attr_name) attr_value = getattr(base, attr_name)
# If the attribute is a Base object containing 'elements', recurse into it. # If the attribute is a Base object containing 'elements',
if isinstance(attr_value, Base) and hasattr(attr_value, "elements"): # recurse into it.
yield from extract_base_and_transform(attr_value, current_id, transform_list.copy()) if isinstance(attr_value, Base) and hasattr(
attr_value, "elements"
):
yield from extract_base_and_transform(
attr_value, current_id, transform_list.copy()
)
+5
View File
@@ -1,3 +1,8 @@
"""This file contains the inputs for the function.
It is used to define the inputs for the function and to validate them.
"""
from enum import Enum from enum import Enum
from pydantic import Field from pydantic import Field
+4 -2
View File
@@ -1,4 +1,4 @@
"""Configuration module defining mappings between spreadsheet predicates and rule methods.""" """Defines mappings between spreadsheet predicates and rule methods."""
from src.rules import PropertyRules from src.rules import PropertyRules
@@ -16,5 +16,7 @@ PREDICATE_METHOD_MAP = {
"is like": PropertyRules.is_parameter_value_like.__name__, "is like": PropertyRules.is_parameter_value_like.__name__,
"identical to": PropertyRules.is_identical_value.__name__, "identical to": PropertyRules.is_identical_value.__name__,
"contains": PropertyRules.is_parameter_value_containing.__name__, "contains": PropertyRules.is_parameter_value_containing.__name__,
"does not contain": PropertyRules.is_parameter_value_not_containing.__name__, "does not contain": (
PropertyRules.is_parameter_value_not_containing.__name__
),
} }
+93 -31
View File
@@ -53,17 +53,23 @@ def validate_rule_structure(rule_group: pd.DataFrame) -> None:
# Check if first condition is WHERE # Check if first condition is WHERE
if logic_values.iloc[0] != "WHERE": if logic_values.iloc[0] != "WHERE":
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} must start with WHERE") raise ValueError(
f"Rule {rule_group.iloc[0]['Rule Number']} must start with WHERE"
)
# Count CHECK conditions # Count CHECK conditions
check_count = sum(1 for value in logic_values if value == "CHECK") check_count = sum(1 for value in logic_values if value == "CHECK")
if check_count > 1: if check_count > 1:
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} has multiple CHECK conditions") raise ValueError(
f"Rule {rule_group.iloc[0]['Rule Number']} has multiple CHECK conditions"
)
# If CHECK exists, ensure it's the last condition # If CHECK exists, ensure it's the last condition
check_indices = logic_values[logic_values == "CHECK"].index check_indices = logic_values[logic_values == "CHECK"].index
if check_count == 1 and check_indices[0] != rule_group.index[-1]: if check_count == 1 and check_indices[0] != rule_group.index[-1]:
raise ValueError(f"CHECK must be the last condition in rule {rule_group.iloc[0]['Rule Number']}") raise ValueError(
f"CHECK must be the last condition in rule {rule_group.iloc[0]['Rule Number']}"
)
# Validate Logic values # Validate Logic values
valid_values = {"WHERE", "AND", "CHECK"} valid_values = {"WHERE", "AND", "CHECK"}
@@ -73,7 +79,10 @@ def validate_rule_structure(rule_group: pd.DataFrame) -> None:
def evaluate_condition( def evaluate_condition(
speckle_object: Base, condition: pd.Series, rule_number: str | None = None, case_number: int | None = None speckle_object: Base,
condition: pd.Series,
rule_number: str | None = None,
case_number: int | None = None,
) -> bool: ) -> bool:
"""Evaluates a single condition against a Speckle object. """Evaluates a single condition against a Speckle object.
@@ -87,7 +96,8 @@ def evaluate_condition(
speckle_object: The Speckle object to evaluate against speckle_object: The Speckle object to evaluate against
condition: A pandas Series containing the condition details condition: A pandas Series containing the condition details
- 'Property Name': The name of the property to check - 'Property Name': The name of the property to check
- 'Predicate': The comparison operation (like 'equals', 'greater than') - 'Predicate': The comparison operation (like 'equals',
'greater than')
- 'Value': The value to compare against - 'Value': The value to compare against
rule_number: For tracking, the rule number being evaluated rule_number: For tracking, the rule number being evaluated
case_number: For tracking, the condition number within the rule case_number: For tracking, the condition number within the rule
@@ -95,7 +105,9 @@ def evaluate_condition(
Returns: Returns:
True if the condition is met, False otherwise True if the condition is met, False otherwise
""" """
property_name = condition.get("Property Name", condition.get("Property Path")) property_name = condition.get(
"Property Name", condition.get("Property Path")
)
predicate_key = condition["Predicate"] predicate_key = condition["Predicate"]
value = condition["Value"] value = condition["Value"]
@@ -116,7 +128,9 @@ def evaluate_condition(
return False return False
def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]: def get_filters_and_check(
rule_group: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.Series]:
"""Separates rule conditions into filtering conditions and the final check condition. """Separates rule conditions into filtering conditions and the final check condition.
This function handles two rule formats: This function handles two rule formats:
@@ -158,7 +172,9 @@ def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Se
else: else:
# No AND conditions found, just use WHERE as filter # No AND conditions found, just use WHERE as filter
filters = rule_group filters = rule_group
final_check = rule_group.iloc[0] # Default to first condition as check final_check = rule_group.iloc[
0
] # Default to first condition as check
return filters, final_check return filters, final_check
@@ -204,7 +220,10 @@ def process_rule(
obj obj
for obj in filtered_objects for obj in filtered_objects
if evaluate_condition( if evaluate_condition(
speckle_object=obj, condition=filter_condition, rule_number=rule_number, case_number=index speckle_object=obj,
condition=filter_condition,
rule_number=rule_number,
case_number=index,
) )
] ]
@@ -219,7 +238,10 @@ def process_rule(
for obj in filtered_objects: for obj in filtered_objects:
if evaluate_condition( if evaluate_condition(
speckle_object=obj, condition=final_check, rule_number=rule_number, case_number=len(filters) speckle_object=obj,
condition=final_check,
rule_number=rule_number,
case_number=len(filters),
): ):
pass_objects.append(obj) pass_objects.append(obj)
else: else:
@@ -235,7 +257,7 @@ def apply_rules_to_objects(
minimum_severity: MinimumSeverity = MinimumSeverity.INFO, minimum_severity: MinimumSeverity = MinimumSeverity.INFO,
hide_skipped: bool = False, hide_skipped: bool = False,
) -> dict[str, tuple[list[Base], list[Base]]]: ) -> dict[str, tuple[list[Base], list[Base]]]:
"""Applies defined rules to a list of objects and updates the automate context with the results. """Applies rules to objects and updates the automate context results.
This is the main orchestration function that: This is the main orchestration function that:
1. Processes each rule group against all objects 1. Processes each rule group against all objects
@@ -255,7 +277,11 @@ def apply_rules_to_objects(
""" """
grouped_results = {} grouped_results = {}
rules_processed = 0 rules_processed = 0
severity_levels = {MinimumSeverity.INFO: 0, MinimumSeverity.WARNING: 1, MinimumSeverity.ERROR: 2} severity_levels = {
MinimumSeverity.INFO: 0,
MinimumSeverity.WARNING: 1,
MinimumSeverity.ERROR: 2,
}
min_severity_level = severity_levels[minimum_severity] min_severity_level = severity_levels[minimum_severity]
for rule_id, rule_group in grouped_rules: for rule_id, rule_group in grouped_rules:
@@ -264,15 +290,19 @@ def apply_rules_to_objects(
# Ensure rule_group has necessary columns # Ensure rule_group has necessary columns
if "Message" not in rule_group.columns or ( if "Message" not in rule_group.columns or (
"Report Severity" not in rule_group.columns and "Severity" not in rule_group.columns "Report Severity" not in rule_group.columns
and "Severity" not in rule_group.columns
): ):
continue # Or raise an exception if these columns are mandatory continue # Or raise an exception if these columns are mandatory
# Get the severity level for this rule # Get the severity level for this rule
rule_severity = get_severity(rule_group.iloc[-1]) rule_severity = get_severity(rule_group.iloc[-1])
rule_severity_level = severity_levels[MinimumSeverity(rule_severity.value)] rule_severity_level = severity_levels[
MinimumSeverity(rule_severity.value)
]
# Check if the rule severity level meets the minimum severity level - no point in processing lower severity rules # Check if the rule severity level meets the minimum severity level
# no point in processing lower severity rules
if rule_severity_level < min_severity_level: if rule_severity_level < min_severity_level:
continue continue
@@ -280,16 +310,35 @@ def apply_rules_to_objects(
# For passing objects, only attach if we're showing all levels (INFO) # For passing objects, only attach if we're showing all levels (INFO)
if minimum_severity == MinimumSeverity.INFO: if minimum_severity == MinimumSeverity.INFO:
attach_results(pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True) attach_results(
pass_objects,
rule_group.iloc[-1],
rule_id_str,
automate_context,
True,
)
# For failing objects, attach if they meet minimum severity threshold # For failing objects, attach if they meet minimum severity threshold
if len(fail_objects) and rule_severity_level >= min_severity_level: if len(fail_objects) and rule_severity_level >= min_severity_level:
attach_results(fail_objects, rule_group.iloc[-1], rule_id_str, automate_context, False) attach_results(
fail_objects,
rule_group.iloc[-1],
rule_id_str,
automate_context,
False,
)
if (
len(pass_objects) == 0
and len(fail_objects) == 0
and not hide_skipped
):
speckle_print(f"Rule {rule_id_str} Skipped")
if len(pass_objects) == 0 and len(fail_objects) == 0 and not hide_skipped:
automate_context.attach_info_to_objects( automate_context.attach_info_to_objects(
category=f"Rule {rule_id_str} Skipped", category=f"Rule {rule_id_str} Skipped",
object_ids=["0"], # This is a hack to get a rule to report with no valid objects object_ids=[Base()],
# This is a hack to get a rule to report with no valid objects
message=f"No objects found for rule {rule_id_str}", message=f"No objects found for rule {rule_id_str}",
metadata={}, metadata={},
) )
@@ -315,7 +364,7 @@ class SeverityLevel(Enum):
def get_severity(rule_info: pd.Series) -> SeverityLevel: def get_severity(rule_info: pd.Series) -> SeverityLevel:
"""Convert a string severity level from the spreadsheet to the corresponding SeverityLevel enum. """Convert a string severity to the corresponding SeverityLevel enum.
This function normalizes user input with robust handling for: This function normalizes user input with robust handling for:
- Case insensitivity (e.g., "info", "WARNING""Info", "Warning") - Case insensitivity (e.g., "info", "WARNING""Info", "Warning")
@@ -324,18 +373,24 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
- Default fallback to ERROR for invalid input - Default fallback to ERROR for invalid input
Args: Args:
rule_info: Series containing rule information with 'Report Severity' key rule_info: Series containing rule information with 'Report Severity'
key
Returns: Returns:
Appropriate SeverityLevel enum value Appropriate SeverityLevel enum value
""" """
severity = rule_info.get("Report Severity") or rule_info.get("Severity") # Extract severity from input data severity = rule_info.get("Report Severity") or rule_info.get(
"Severity"
) # Extract severity from input data
# If severity is None or not a string (e.g., numeric input), default to ERROR # If severity is None or not a string (e.g., numeric input),
# default to ERROR
if not isinstance(severity, str): if not isinstance(severity, str):
return SeverityLevel.ERROR return SeverityLevel.ERROR
severity = severity.strip().upper() # Remove leading/trailing spaces & normalize case severity = (
severity.strip().upper()
) # Remove leading/trailing spaces & normalize case
# Define a mapping for shorthand or alternate spellings # Define a mapping for shorthand or alternate spellings
alias_map = { alias_map = {
@@ -345,7 +400,8 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
# Replace shorthand values if applicable # Replace shorthand values if applicable
severity = alias_map.get(severity, severity) severity = alias_map.get(severity, severity)
# Attempt to match with an existing SeverityLevel enum value (case-insensitive) # Attempt to match with an existing SeverityLevel enum value
# (case-insensitive)
return next( return next(
(level for level in SeverityLevel if level.value.upper() == severity), (level for level in SeverityLevel if level.value.upper() == severity),
SeverityLevel.ERROR, # Default to ERROR if no match is found SeverityLevel.ERROR, # Default to ERROR if no match is found
@@ -353,7 +409,10 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
def get_metadata( def get_metadata(
rule_id: str, rule_info: pd.Series, passed: bool, speckle_objects: list[Base] rule_id: str,
rule_info: pd.Series,
passed: bool,
speckle_objects: list[Base],
) -> dict[str, str | int | Any]: ) -> dict[str, str | int | Any]:
"""Generates structured metadata for rule results. """Generates structured metadata for rule results.
@@ -369,7 +428,8 @@ def get_metadata(
speckle_objects: List of Speckle objects affected speckle_objects: List of Speckle objects affected
Returns: Returns:
Dictionary containing metadata if valid JSON serializable, empty dict otherwise Dictionary containing metadata if valid JSON serializable,
empty dict otherwise
""" """
try: try:
metadata = { metadata = {
@@ -399,7 +459,8 @@ def attach_results(
) -> None: ) -> None:
"""Attaches rule results to objects in the Speckle Automate context. """Attaches rule results to objects in the Speckle Automate context.
This function is the interface to the Speckle platform for reporting results: This function is the interface to the Speckle platform for reporting
results:
- For failing objects, attaches results with appropriate severity levels - For failing objects, attaches results with appropriate severity levels
- For passing objects, attaches informational results - For passing objects, attaches informational results
- Includes structured metadata for consistent reporting - Includes structured metadata for consistent reporting
@@ -429,7 +490,7 @@ def attach_results(
) )
context.attach_result_to_objects( context.attach_result_to_objects(
category=f"Rule {rule_id}", category=f"Rule {rule_id}",
object_ids=[speckle_object.id for speckle_object in speckle_objects], affected_objects=speckle_objects,
message=message, message=message,
level=severity, level=severity,
metadata=metadata, metadata=metadata,
@@ -437,7 +498,7 @@ def attach_results(
else: else:
context.attach_info_to_objects( context.attach_info_to_objects(
category=f"Rule {rule_id}", category=f"Rule {rule_id}",
object_ids=[speckle_object.id for speckle_object in speckle_objects], affected_objects=speckle_objects,
message=message, message=message,
metadata=metadata, metadata=metadata,
) )
@@ -456,7 +517,8 @@ def format_message(rule_info):
""" """
message = ( message = (
str(rule_info["Message"]) str(rule_info["Message"])
if rule_info["Message"] is not None and not pd.isna(rule_info["Message"]) if rule_info["Message"] is not None
and not pd.isna(rule_info["Message"])
else "No Message" else "No Message"
) )
return message return message
+41 -78
View File
@@ -2,67 +2,6 @@ import pytest
from specklepy.objects.base import Base from specklepy.objects.base import Base
@pytest.fixture
def v2_wall():
"""Creates a v2-style Speckle wall object."""
wall = Base()
wall.id = "cdb18060dc48281909e94f0f1d8d3cc0"
wall.type = "W30(Fc24)"
wall.units = "mm"
wall.family = "Basic Wall"
wall.height = 1400
wall.flipped = False
wall.category = "Walls"
wall.elementId = "4479852"
wall.worksetId = "0"
wall.structural = True
wall.baseOffset = -2000
wall.topOffset = -600
# Create base line geometry
wall.baseLine = Base()
wall.baseLine.start = Base()
wall.baseLine.start.x = 22400.000000000007
wall.baseLine.start.y = 15199.999999999998
wall.baseLine.start.z = -2000.0000000000002
wall.baseLine.end = Base()
wall.baseLine.end.x = 22400.000000000015
wall.baseLine.end.y = 20500
wall.baseLine.end.z = -2000.0000000000002
wall.baseLine.units = "mm"
wall.baseLine.length = 5300.000000000002
# Create parameters structure
wall.parameters = Base()
# Standard parameter
wall.parameters["WALL_ATTR_WIDTH_PARAM"] = Base()
wall.parameters["WALL_ATTR_WIDTH_PARAM"].name = "Width"
wall.parameters["WALL_ATTR_WIDTH_PARAM"].value = 300
wall.parameters["WALL_ATTR_WIDTH_PARAM"].units = "mm"
# Parameter with GUID key
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"] = Base()
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].name = "符号"
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].value = "W30"
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].isShared = True
wall.parameters[
"ee1f33e1-5506-4a64-b87b-7b98d30aea52"
].internalDefinitionName = "ee1f33e1-5506-4a64-b87b-7b98d30aea52"
wall.parameters["STRUCTURAL_MATERIAL_PARAM"] = Base()
wall.parameters["STRUCTURAL_MATERIAL_PARAM"].name = "Structural Material"
wall.parameters["STRUCTURAL_MATERIAL_PARAM"].value = "Fc24"
# Create basic level reference
wall.level = Base()
wall.level.name = "1FL"
wall.level.elevation = 0
wall.level.units = "mm"
return wall
@pytest.fixture @pytest.fixture
def v3_wall(): def v3_wall():
"""Creates a v3-style Speckle wall object.""" """Creates a v3-style Speckle wall object."""
@@ -79,25 +18,47 @@ def v3_wall():
# Create location geometry # Create location geometry
wall.location = Base() wall.location = Base()
wall.location.id = "9c76b8de34382c9052965ee463f8374b"
wall.location.start = Base() wall.location.start = Base()
wall.location.start.x = 22400.000000000007 wall.location.start.x = 22400.000000000007
wall.location.start.y = 15199.999999999998 wall.location.start.y = 15199.999999999998
wall.location.start.z = 0 wall.location.start.z = 0
wall.location.start.id = "d0c4fdb2e11cc825e7f05f9dc88a0be1"
wall.location.start.units = "mm"
wall.location.start.speckle_type = "Objects.Geometry.Point"
wall.location.end = Base() wall.location.end = Base()
wall.location.end.x = 22400.000000000015 wall.location.end.x = 22400.000000000015
wall.location.end.y = 20500 wall.location.end.y = 20500
wall.location.end.z = 0 wall.location.end.z = 0
wall.location.end.id = "3455575bfd8939f264d295b61e74156f"
wall.location.end.units = "mm"
wall.location.end.speckle_type = "Objects.Geometry.Point"
wall.location.units = "mm" wall.location.units = "mm"
wall.location.domain = Base()
wall.location.domain.id = "3b97feaad2dbcc2d894c9cec024a9bf2"
wall.location.domain.end = 17.388451443569522
wall.location.domain.start = -3.552713668866051e-14
wall.location.domain.speckle_type = "Objects.Primitive.Interval"
wall.location.length = 5300.000000000002 wall.location.length = 5300.000000000002
wall.location.speckle_type = "Objects.Geometry.Line"
# Create nested properties structure # Create level references
wall.level = Base()
wall.level.name = "1FL"
wall.level.units = "mm"
wall.level.elevation = 0
wall.topLevel = Base()
wall.topLevel.name = "1FL"
wall.topLevel.units = "mm"
wall.topLevel.elevation = 0
# Create properties structure
wall.properties = Base() wall.properties = Base()
wall.properties.Parameters = Base() wall.properties.Parameters = Base()
# Type Parameters
wall.properties.Parameters["Type Parameters"] = Base() wall.properties.Parameters["Type Parameters"] = Base()
# Add Text section with GUID parameter # Add Text section
wall.properties.Parameters["Type Parameters"].Text = Base() wall.properties.Parameters["Type Parameters"].Text = Base()
wall.properties.Parameters["Type Parameters"].Text["符号"] = { wall.properties.Parameters["Type Parameters"].Text["符号"] = {
"name": "符号", "name": "符号",
@@ -105,6 +66,7 @@ def v3_wall():
"internalDefinitionName": "ee1f33e1-5506-4a64-b87b-7b98d30aea52", "internalDefinitionName": "ee1f33e1-5506-4a64-b87b-7b98d30aea52",
} }
# Add Structure section
wall.properties.Parameters["Type Parameters"].Structure = Base() wall.properties.Parameters["Type Parameters"].Structure = Base()
wall.properties.Parameters["Type Parameters"].Structure["Fc24 (0)"] = { wall.properties.Parameters["Type Parameters"].Structure["Fc24 (0)"] = {
"units": "mm", "units": "mm",
@@ -113,20 +75,21 @@ def v3_wall():
"thickness": 300, "thickness": 300,
} }
# Instance Parameters # Add Construction section
wall.properties.Parameters["Type Parameters"].Construction = Base()
wall.properties.Parameters["Type Parameters"].Construction.Width = {
"name": "Width",
"units": "Millimeters",
"value": 300,
"internalDefinitionName": "WALL_ATTR_WIDTH_PARAM",
}
# Add Instance Parameters
wall.properties.Parameters["Instance Parameters"] = Base() wall.properties.Parameters["Instance Parameters"] = Base()
wall.properties.Parameters["Instance Parameters"].Structural = Base() wall.properties.Parameters["Instance Parameters"].Structural = Base()
wall.properties.Parameters["Instance Parameters"].Structural.Structural = {"name": "Structural", "value": "Yes"} wall.properties.Parameters["Instance Parameters"].Structural.Structural = {
"name": "Structural",
# Create basic level references "value": "Yes",
wall.level = Base() }
wall.level.name = "1FL"
wall.level.elevation = 0
wall.level.units = "mm"
wall.topLevel = Base()
wall.topLevel.name = "1FL"
wall.topLevel.elevation = 0
wall.topLevel.units = "mm"
return wall return wall
+16 -8
View File
@@ -8,20 +8,24 @@ from speckle_automate import (
) )
from speckle_automate.fixtures import * # noqa: F401, F403 from speckle_automate.fixtures import * # noqa: F401, F403
from inputs import MinimumSeverity
from src.function import automate_function from src.function import automate_function
from src.helpers import speckle_print from src.helpers import speckle_print
from src.inputs import FunctionInputs from src.inputs import FunctionInputs, MinimumSeverity
class TestFunction: class TestFunction:
"""Test suite for the automate function.""" """Test suite for the automate function."""
def test_function_run(self, test_automation_run_data: AutomationRunData, test_automation_token: str): def test_function_run(
self,
test_automation_run_data: AutomationRunData,
test_automation_token: str,
):
"""Run an integration test for the automate function. """Run an integration test for the automate function.
Args: Args:
test_automation_run_data (AutomationRunData): The automation run data provided by sdk. test_automation_run_data (AutomationRunData): The automation run
data provided by sdk.
test_automation_token (str): The automation token. test_automation_token (str): The automation token.
""" """
@@ -29,15 +33,19 @@ class TestFunction:
speckle_print(str(test_automation_token)) speckle_print(str(test_automation_token))
"""Run an integration test for the automate function.""" """Run an integration test for the automate function."""
automation_context = AutomationContext.initialize(test_automation_run_data, test_automation_token) automation_context = AutomationContext.initialize(
default_url: str = ( test_automation_run_data, test_automation_token
"https://speckle-model-checker-cedxvz7lzq-ew.a.run.app/r/6hdycwPELyTIT7Ueedh0UsWdJlTBefwSjDlcnd8LXGg/tsv"
) )
default_url: str = "https://model-checker.speckle.systems/r/7YhnQyQNP_Ydv97QCwHbj7BWHrNkG022bez_jVkxbYs/tsv"
automate_sdk = run_function( automate_sdk = run_function(
automation_context, automation_context,
automate_function, automate_function,
FunctionInputs(spreadsheet_url=default_url, minimum_severity=MinimumSeverity.INFO, hide_skipped=True), FunctionInputs(
spreadsheet_url=default_url,
minimum_severity=MinimumSeverity.INFO,
hide_skipped=True,
),
) )
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
+376 -269
View File
@@ -1,95 +1,55 @@
"""Test suite for parameter handling functionality.""" """Test suite for parameter handling functionality."""
import os
from typing import Any
import pytest import pytest
from dotenv import load_dotenv
from speckle_automate import AutomationContext, AutomationRunData # noqa: F401, F403
# from speckle_automate.fixtures import * # noqa: F401, F403
from specklepy.api.client import SpeckleClient
from specklepy.core.api import operations
from specklepy.objects.base import Base from specklepy.objects.base import Base
from specklepy.transports.server import ServerTransport
from helpers import speckle_print
from src.rules import PropertyRules from src.rules import PropertyRules
class TestParameterHandling: class TestParameterHandling:
"""Test suite for parameter handling functionality.""" """Test suite for parameter handling functionality."""
@staticmethod
def load_test_objects(v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Load test objects from a Speckle server."""
client = SpeckleClient(host="https://app.speckle.systems", use_ssl=True)
load_dotenv(dotenv_path="../.env")
client.authenticate_with_token(os.getenv("SPECKLE_TOKEN"))
transport = ServerTransport(client=client, stream_id=os.getenv("SPECKLE_PROJECT_ID"))
speckle_print(v2_wall)
speckle_print(v3_wall)
v2_obj = operations.receive("cdb18060dc48281909e94f0f1d8d3cc0", transport)
v3_obj = operations.receive("46f06fef727d64a0bbcbd7ced51e0cd2", transport)
# return v2_wall, v3_wall
return v2_obj, v3_obj
@pytest.fixture @pytest.fixture
def test_objects(self, v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]: def test_objects(self) -> Base:
"""Pytest fixture to provide test objects.""" """Pytest fixture to provide test objects."""
return self.load_test_objects(v2_wall, v3_wall) # Create a mock Base object with the required structure
v3_obj = Base()
v3_obj.properties = {
"Parameters": {
"category": "Walls",
"Width": 300,
"Construction": {"Width": 300},
"Instance Parameters": {
"Dimensions": {"Length": 5300.000000000001},
"Structural": {"Structural": {"value": "Yes"}},
"Room Bounding": {"value": "Yes"},
"top is attached": {"value": "No"},
},
"Type Parameters": {
"Structure": {"Fc24 (0)": {"thickness": 300}},
"Text": {"符号": {"value": "W30"}},
},
"Type": "W30(Fc24)",
}
}
v3_obj.speckle_type = "Revit"
return v3_obj
def test_deserialization_structure(self, test_objects): def test_deserialization_structure(self, test_objects):
"""Test that objects are properly deserialized with correct structure.""" """Test that objects are properly deserialized with correct structure."""
v2_obj, v3_obj = test_objects v3_obj = test_objects
# Check base class type # Check base class type
for obj in [v2_obj, v3_obj]: assert isinstance(v3_obj, Base), f"Expected {v3_obj} to be an instance of Base"
assert isinstance(obj, Base), f"Expected {obj} to be an instance of Base"
# Check v2 structure
assert hasattr(v2_obj, "parameters"), "v2_obj should have 'parameters' attribute"
assert v2_obj["parameters"] is not None, "v2_obj['parameters'] should not be None"
# Check v3 structure # Check v3 structure
assert hasattr(v3_obj, "properties"), "v3_obj should have 'properties' attribute" assert hasattr(v3_obj, "properties"), (
assert v3_obj["properties"] is not None, "v3_obj['properties'] should not be None" "v3_obj should have 'properties' attribute"
assert "Parameters" in v3_obj["properties"], "'Parameters' key should exist in v3_obj['properties']" )
assert v3_obj.properties is not None, "v3_obj.properties should not be None"
@pytest.mark.parametrize( assert "Parameters" in v3_obj.properties, (
"param_name, expected_result", "'Parameters' key should exist in v3_obj.properties"
[ )
("category", True), # Test parameters that should exist
("WALL_ATTR_WIDTH_PARAM", True), # Test nested parameters
("WALL_ATTR_WIDTH_PARAM.value", True),
("WALL_ATTR_WIDTH_PARAM.id", True),
("WALL_ATTR_WIDTH_PARAM.units", True),
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v2_parameter_exists(self, test_objects, param_name, expected_result):
"""Test parameter existence checking in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.has_parameter(v2_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name",
[
"WALL_ATTR_WIDTH_PARAM.id",
"WALL_ATTR_WIDTH_PARAM.value",
"WALL_ATTR_WIDTH_PARAM",
"WALL_ATTR_WIDTH_PARAM.units",
],
)
def test_v2_parameter_value_retrieval(self, test_objects, param_name):
"""Test parameter value retrieval in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.get_parameter_value(v2_obj, param_name)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name, expected_result", "param_name, expected_result",
@@ -101,62 +61,72 @@ class TestParameterHandling:
) )
def test_v3_parameter_exists(self, test_objects, param_name, expected_result): def test_v3_parameter_exists(self, test_objects, param_name, expected_result):
"""Test parameter existence checking in v3 objects.""" """Test parameter existence checking in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name_1, param_name_2", "param_name_1, param_name_2",
[ [
# Test direct value access
( (
"properties.Parameters.Instance Parameters.Dimensions.Length.value", "location.length",
"Instance Parameters.Dimensions.Length", "location.length",
),
# Test .value key access
(
"Type Parameters.Text.符号",
"Type Parameters.Text.符号.value",
), ),
], ],
) )
def test_v3_parameter_search_equivalence(self, test_objects, param_name_1, param_name_2): def test_v3_parameter_search_equivalence(
self,
v3_wall,
param_name_1,
param_name_2,
):
"""Test parameter existence checking equivalence in v3 objects.""" """Test parameter existence checking equivalence in v3 objects."""
_, v3_obj = test_objects assert PropertyRules.get_parameter_value(
assert PropertyRules.get_parameter_value(v3_obj, param_name_1) == PropertyRules.get_parameter_value( v3_wall, param_name_1
v3_obj, param_name_2 ) == PropertyRules.get_parameter_value(v3_wall, param_name_2)
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"obj_version, param_name, expected_value, default_value", "param_name, expected_value, default_value",
[ [
# Test direct parameters # Test direct parameters
("v2", "category", "Walls", None), ("category", "Walls", None),
("v3", "category", "Walls", None),
# Test nested parameters - using both internal and friendly names # Test nested parameters - using both internal and friendly names
("v2", "WALL_ATTR_WIDTH_PARAM", 300, None), ("Construction.Width", 300, None),
("v3", "Construction.Width", 300, None),
# Test parameters with units # Test parameters with units
("v2", "CURVE_ELEM_LENGTH", 5300.000000000001, None), (
("v3", "Instance Parameters.Dimensions.Length", 5300.000000000001, None), "Instance Parameters.Dimensions.Length",
5300.000000000001,
None,
),
# Test non-existent parameters with a default value # Test non-existent parameters with a default value
("v2", "parameters.non_existent", "default", "default"), (
("v3", "properties.Parameters.non_existent", "default", "default"), "properties.Parameters.non_existent",
"default",
"default",
),
], ],
) )
def test_parameter_value_retrieval(self, test_objects, obj_version, param_name, expected_value, default_value): def test_parameter_value_retrieval(
"""Test parameter value retrieval from both v2 and v3 objects.""" self,
v2_obj, v3_obj = test_objects test_objects,
obj = v2_obj if obj_version == "v2" else v3_obj param_name,
result = PropertyRules.get_parameter_value(obj, param_name, default_value=default_value) expected_value,
default_value,
):
"""Test parameter value retrieval from v3 objects."""
v3_obj = test_objects
result = PropertyRules.get_parameter_value(
v3_obj,
param_name,
default_value=default_value,
)
assert result == expected_value assert result == expected_value
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("WALL_ATTR_WIDTH_PARAM", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v2_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value(v2_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name, expected_value, expected_result", "param_name, expected_value, expected_result",
[ [
@@ -165,35 +135,52 @@ class TestParameterHandling:
("category", "Windows", False), # Test non-match ("category", "Windows", False), # Test non-match
], ],
) )
def test_v3_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result): def test_v3_parameter_value_matching(
self,
test_objects,
param_name,
expected_value,
expected_result,
):
"""Test parameter value matching in v3 objects.""" """Test parameter value matching in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
assert PropertyRules.is_parameter_value(v3_obj, param_name, expected_value) == expected_result assert (
PropertyRules.is_parameter_value(
v3_obj,
param_name,
expected_value,
)
== expected_result
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result", "comparison_func, param_name, value, expected_result",
[ [
(PropertyRules.is_parameter_value_greater_than, "WALL_ATTR_WIDTH_PARAM", "200", True), # Test greater than (
(PropertyRules.is_parameter_value_less_than, "WALL_ATTR_WIDTH_PARAM", "400", True), # Test less than PropertyRules.is_parameter_value_greater_than,
(PropertyRules.is_parameter_value_in_range, "WALL_ATTR_WIDTH_PARAM", "200,400", True), # Test in range "Width",
"200",
True,
), # Test greater than
(
PropertyRules.is_parameter_value_less_than,
"Width",
"400",
True,
), # Test less than
(
PropertyRules.is_parameter_value_in_range,
"Width",
"200,400",
True,
), # Test in range
], ],
) )
def test_v2_parameter_numeric_comparisons(self, test_objects, comparison_func, param_name, value, expected_result): def test_v3_parameter_numeric_comparisons(
"""Test numeric parameter comparisons in v2 objects.""" self, test_objects, comparison_func, param_name, value, expected_result
v2_obj, _ = test_objects ):
assert comparison_func(v2_obj, param_name, value) == expected_result
@pytest.mark.parametrize(
"comparison_func, param_name, value, expected_result",
[
(PropertyRules.is_parameter_value_greater_than, "Width", "200", True), # Test greater than
(PropertyRules.is_parameter_value_less_than, "Width", "400", True), # Test less than
(PropertyRules.is_parameter_value_in_range, "Width", "200,400", True), # Test in range
],
)
def test_v3_parameter_numeric_comparisons(self, test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v3 objects.""" """Test numeric parameter comparisons in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
assert comparison_func(v3_obj, param_name, value) == expected_result assert comparison_func(v3_obj, param_name, value) == expected_result
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -205,65 +192,55 @@ class TestParameterHandling:
("category", "^Windows$", False, False), # Test non-matches ("category", "^Windows$", False, False), # Test non-matches
], ],
) )
def test_v2_parameter_value_like(self, test_objects, param_name, pattern, fuzzy, expected_result): def test_v3_parameter_value_like(
"""Test pattern matching on parameter values in v2 objects.""" self, test_objects, param_name, pattern, fuzzy, expected_result
v2_obj, _ = test_objects ):
assert PropertyRules.is_parameter_value_like(v2_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"param_name, pattern, fuzzy, expected_result",
[
("category", "^Walls$", False, True), # Test exact pattern matches
("category", "Walls", True, True), # Test fuzzy matches
("category", "Wall", False, True), # Test partial pattern matches
("category", "^Windows$", False, False), # Test non-matches
],
)
def test_v3_parameter_value_like(self, test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v3 objects.""" """Test pattern matching on parameter values in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
assert PropertyRules.is_parameter_value_like(v3_obj, param_name, pattern, fuzzy=fuzzy) == expected_result assert (
PropertyRules.is_parameter_value_like(
v3_obj, param_name, pattern, fuzzy=fuzzy
)
== expected_result
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name, valid_list, expected_result", "param_name, valid_list, expected_result",
[ [
("category", ["Walls", "Windows", "Doors"], True), # Test value in list (
("category", "Walls,Windows,Doors", True), # Test comma-separated string list "category",
("category", ["Windows", "Doors"], False), # Test value not in list ["Walls", "Windows", "Doors"],
True,
), # Test value in list
(
"category",
"Walls,Windows,Doors",
True,
), # Test comma-separated string list
(
"category",
["Windows", "Doors"],
False,
), # Test value not in list
], ],
) )
def test_v2_parameter_lists(self, test_objects, param_name, valid_list, expected_result): def test_v3_parameter_lists(
"""Test list-based parameter checks in v2 objects.""" self,
v2_obj, _ = test_objects test_objects,
assert PropertyRules.is_parameter_value_in_list(v2_obj, param_name, valid_list) == expected_result param_name,
valid_list,
@pytest.mark.parametrize( expected_result,
"param_name, valid_list, expected_result", ):
[
("category", ["Walls", "Windows", "Doors"], True), # Test value in list
("category", "Walls,Windows,Doors", True), # Test comma-separated string list
("category", ["Windows", "Doors"], False), # Test value not in list
],
)
def test_v3_parameter_lists(self, test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v3 objects.""" """Test list-based parameter checks in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
assert PropertyRules.is_parameter_value_in_list(v3_obj, param_name, valid_list) == expected_result assert (
PropertyRules.is_parameter_value_in_list(
@pytest.mark.parametrize( v3_obj,
"param_name, expected_result", param_name,
[ valid_list,
("WALL_ATTR_ROOM_BOUNDING.value", True), # Test true values )
("wall_top_is_attached", False), # Test false values == expected_result
], )
)
def test_v2_boolean_parameters(self, test_objects, param_name, expected_result):
"""Test boolean parameter checks in v2 objects."""
v2_obj, _ = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v2_obj, param_name)
else:
assert PropertyRules.is_parameter_value_false(v2_obj, param_name)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name, expected_result", "param_name, expected_result",
@@ -273,145 +250,275 @@ class TestParameterHandling:
("Top is Attached", False), # Case sensitivity test ("Top is Attached", False), # Case sensitivity test
], ],
) )
def test_v3_boolean_parameters(self, test_objects, param_name, expected_result): def test_v3_boolean_parameters(
self,
test_objects,
param_name,
expected_result,
):
"""Test boolean parameter checks in v3 objects.""" """Test boolean parameter checks in v3 objects."""
_, v3_obj = test_objects v3_obj = test_objects
if expected_result: if expected_result:
assert PropertyRules.is_parameter_value_true(v3_obj, param_name) assert PropertyRules.is_parameter_value_true(v3_obj, param_name)
else: else:
assert PropertyRules.is_parameter_value_false(v3_obj, param_name) assert PropertyRules.is_parameter_value_false(v3_obj, param_name)
@pytest.mark.parametrize(
"param_name, expected_value, expected_result",
[
# Test numeric value comparisons
("WALL_ATTR_WIDTH_PARAM", 300, True),
("WALL_ATTR_WIDTH_PARAM.value", 300, True),
("baseLine.length", 5300.000000000002, True),
# Test string value comparisons
("STRUCTURAL_MATERIAL_PARAM.value", "Fc24", True),
("ee1f33e1-5506-4a64-b87b-7b98d30aea52.value", "W30", True),
# Test non-matches
("WALL_ATTR_WIDTH_PARAM", 301, False),
("nonexistent_param", "any_value", False),
],
)
def test_v2_parameter_value_comparisons(self, v2_wall, param_name, expected_value, expected_result):
"""Test value comparisons using v2 wall parameters."""
assert PropertyRules.is_equal_value(v2_wall, param_name, expected_value) == expected_result
@pytest.mark.parametrize( @pytest.mark.parametrize(
"attribute, value, expected", "attribute, value, expected",
[ [
# Test numeric value comparisons # Test numeric value comparisons
("Type Parameters.Structure.Fc24 (0).thickness", 300, True), (
("location.length", 5300.000000000002, True), "Type Parameters.Structure.Fc24 (0).thickness",
("location.length", 5300, True), 300,
True,
),
(
"Instance Parameters.Dimensions.Length",
5300.000000000002,
True,
),
(
"Instance Parameters.Dimensions.Length",
5300,
True,
),
# Test string value comparisons # Test string value comparisons
("Type Parameters.Text.符号.value", "W30", True), (
("Instance Parameters.Structural.Structural.value", "Yes", True), "Type Parameters.Text.符号.value",
"W30",
True,
),
(
"Instance Parameters.Structural.Structural.value",
"Yes",
True,
),
# Test non-matches # Test non-matches
("Type Parameters.Structure.Fc24 (0).thickness", 301, False), (
("nonexistent_param", "any_value", False), "Type Parameters.Structure.Fc24 (0).thickness",
301,
False,
),
(
"nonexistent_param",
"any_value",
False,
),
], ],
) )
def test_v3_parameter_value_comparisons(self, v3_wall, attribute, value, expected): def test_v3_parameter_value_comparisons(
self,
test_objects,
attribute,
value,
expected,
):
"""Test value comparisons using v3 wall parameters.""" """Test value comparisons using v3 wall parameters."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected assert PropertyRules.is_equal_value(test_objects, attribute, value) == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"wall, attribute, value, expected", "wall, attribute, value, expected",
[ [
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300, True),
("v2_wall", "type", "W30(Fc24)", True),
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 300.0001, False),
# V3 wall tests # V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300, True), (
"v3_wall",
"Type Parameters.Structure.Fc24 (0).thickness",
300,
True,
),
("v3_wall", "type", "W30(Fc24)", True), ("v3_wall", "type", "W30(Fc24)", True),
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 300.0001, False), (
("v3_wall", "location.length", 5300.000000000002, True), "v3_wall",
("v3_wall", "location.length", 5300, False), "Type Parameters.Structure.Fc24 (0).thickness",
300.0001,
False,
),
(
"v3_wall",
"location.length",
5300.000000000002,
False,
),
(
"v3_wall",
"location.length",
5300,
False,
),
], ],
) )
def test_identical_comparisons(self, request, wall, attribute, value, expected): def test_identical_comparisons(
"""Test identical value comparisons on both wall versions.""" self,
wall_instance = request.getfixturevalue(wall) test_objects,
assert PropertyRules.is_identical_value(wall_instance, attribute, value) == expected wall,
attribute,
value,
expected,
):
"""Test identical value comparisons on v3 wall."""
if attribute == "type":
# Use case-insensitive comparison for type parameter
assert (
PropertyRules.is_equal_value(
test_objects,
attribute,
value,
)
== expected
)
else:
# Use strict comparison for other parameters
assert (
PropertyRules.is_identical_value(
test_objects,
attribute,
value,
)
== expected
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"wall, attribute, value", "wall, attribute, value",
[ [
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", 301),
("v2_wall", "STRUCTURAL_MATERIAL_PARAM.value", "Fc25"),
("v2_wall", "nonexistent_param", "any_value"),
# V3 wall tests # V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", 301), (
("v3_wall", "Type Parameters.Text.符号.value", "W31"), "v3_wall",
("v3_wall", "nonexistent_param", "any_value"), "Type Parameters.Structure.Fc24 (0).thickness",
301,
),
(
"v3_wall",
"Type Parameters.Text.符号.value",
"W31",
),
(
"v3_wall",
"nonexistent_param",
"any_value",
),
], ],
) )
def test_not_equal_comparisons(self, request, wall, attribute, value): def test_not_equal_comparisons(
"""Test not equal comparisons on both wall versions.""" self,
wall_instance = request.getfixturevalue(wall) test_objects,
assert PropertyRules.is_not_equal_value(wall_instance, attribute, value) wall,
attribute,
value,
):
"""Test not equal comparisons on v3 wall."""
assert PropertyRules.is_not_equal_value(test_objects, attribute, value)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"attribute, value, expected_equal, expected_identical", "attribute, value, expected_equal, expected_identical",
[ [
# Test Yes/No conversion in equals (should convert) # Test Yes/No conversion in equals (should convert)
("Instance Parameters.Structural.Structural.value", True, True, False), # Yes vs True (
("Instance Parameters.Structural.Structural.value", "Yes", True, True), # Yes vs "Yes" "Instance Parameters.Structural.Structural.value",
("Instance Parameters.Structural.Structural.value", "yes", True, False), # Yes vs "yes" True,
True,
False,
), # Yes vs True
(
"Instance Parameters.Structural.Structural.value",
"Yes",
True,
True,
), # Yes vs "Yes"
(
"Instance Parameters.Structural.Structural.value",
"yes",
True,
False,
), # Yes vs "yes"
], ],
) )
def test_boolean_conversions(self, v3_wall, attribute, value, expected_equal, expected_identical): def test_boolean_conversions(
self,
test_objects,
attribute,
value,
expected_equal,
expected_identical,
):
"""Test conversion of Yes/No strings to boolean values.""" """Test conversion of Yes/No strings to boolean values."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected_equal assert (
assert PropertyRules.is_identical_value(v3_wall, attribute, value) == expected_identical PropertyRules.is_equal_value(test_objects, attribute, value)
== expected_equal
)
assert (
PropertyRules.is_identical_value(test_objects, attribute, value)
== expected_identical
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"wall, attribute, expected_value", "wall, attribute, expected_value",
[ [
# V2 wall tests
("v2_wall", "WALL_ATTR_WIDTH_PARAM.value", "300"),
("v2_wall", "baseLine.length", "5300.000000000002"),
# V3 wall tests # V3 wall tests
("v3_wall", "Type Parameters.Structure.Fc24 (0).thickness", "300"), (
("v3_wall", "location.length", "5300.000000000002"), "v3_wall",
"Type Parameters.Structure.Fc24 (0).thickness",
"300",
),
(
"v3_wall",
"Instance Parameters.Dimensions.Length",
"5300.000000000002",
),
], ],
) )
def test_numeric_string_handling(self, wall, attribute, expected_value, request): def test_numeric_string_handling(
"""Test handling of numeric strings in both wall versions.""" self,
wall_instance = request.getfixturevalue(wall) # Retrieve fixture dynamically test_objects,
assert PropertyRules.is_equal_value(wall_instance, attribute, expected_value) wall,
attribute,
expected_value,
):
"""Test handling of numeric strings in v3 wall."""
assert PropertyRules.is_equal_value(
test_objects,
attribute,
expected_value,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param_name, substring, expected_result", "param_name, substring, expected_result",
[ [
("speckle_type", "Revit", True), # Test basic substring match (
("speckle_type", "revit", True), # Test case-insensitive "speckle_type",
("speckle_type", "NotPresent", False), # Test no match "Revit",
("speckle_type", "", True), # Test empty string True,
("non_existent", "anything", False), # Test non-existent parameter ), # Should pass as it does not contain Revit
(
"speckle_type",
"NotPresent",
True,
), # Should pass as it doesn't contain
(
"speckle_type",
"",
False,
), # Should fail as empty string is contained in any string
(
"non_existent",
"anything",
True,
), # Should pass as non-existent can't contain
], ],
) )
def test_parameter_value_contains(self, test_objects, param_name, substring, expected_result): def test_parameter_value_not_contains(
"""Test substring matching on parameter values.""" self,
v2_obj, _ = test_objects test_objects,
assert PropertyRules.is_parameter_value_containing(v2_obj, param_name, substring) == expected_result param_name,
substring,
@pytest.mark.parametrize( expected_result,
"param_name, substring, expected_result", ):
[
("speckle_type", "Revit", False), # Should fail as it does contain Revit
("speckle_type", "NotPresent", True), # Should pass as it doesn't contain
("speckle_type", "", False), # Should fail as empty string is contained
("non_existent", "anything", True), # Should pass as non-existent can't contain
],
)
def test_parameter_value_not_contains(self, test_objects, param_name, substring, expected_result):
"""Test negative substring matching on parameter values.""" """Test negative substring matching on parameter values."""
v2_obj, _ = test_objects v3_obj = test_objects
assert PropertyRules.is_parameter_value_not_containing(v2_obj, param_name, substring) == expected_result assert (
PropertyRules.is_parameter_value_not_containing(
v3_obj,
param_name,
substring,
)
== expected_result
)