Compare commits

...

28 Commits

Author SHA1 Message Date
renovate[bot] 72478b010f Update dependency pydantic-settings to v2.8.1 2025-02-27 10:38:03 +00:00
Jonathon Broughton 38d2073dbb Refactor footgun (#57)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
- Added traceback import for better error logging.
- Enhanced exception handling to include traceback details.
- Commented out non-integer rule number checks for now.
2025-02-21 13:21:08 +00:00
Jonathon Broughton 091a272185 Add new predicates for value checks (#54)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
- Removed "matches" and "not equal" predicates.
- Added "not equal to" and "does not contain" predicates.
- Introduced a method to check if a parameter value does not contain a substring.
2025-02-20 18:30:05 +00:00
Jonathon Broughton 0e95f3998a Update rules.py (#53)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2025-02-20 17:32:29 +00:00
Jonathon Broughton 05a5383060 Add 'contains' predicate method (#52)
- Introduced a new predicate for checking if a parameter value contains a substring.
- Added the corresponding method to handle the logic and error management.
2025-02-20 17:22:02 +00:00
Jonathon Broughton f3c56a48b5 Rule numbers and metdata validation (#51)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
* Update Python version and clean up dependencies

- Bumped required Python version from 3.12 to 3.13.
- Removed the lock file for dependency management.
- Cleaned up unnecessary dependency groups in the configuration.

* Refine rule number processing and type conversion

- Updated rule number handling to preserve original identifiers.
- Improved logic for generating missing rule numbers.
- Changed duplicate handling to keep original values intact.
- Adjusted column type conversion to replace NaN with empty strings.

* Refactor message handling in rule processor

- Introduced a new function to format rule messages.
- Replaced direct access to the message with the new formatting function.
- Cleaned up metadata creation for better readability.

* Enhance metadata generation with JSON validation

- Added JSON serialisability check for generated metadata
- Included error handling to log issues during creation
- Updated docstring to clarify function purpose and arguments
2025-02-20 15:41:33 +00:00
Jonathon Broughton a704aded80 Refactor parameter value checks to use float conversion (#49)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
- Updated methods to convert parameter values to float.
- Removed type checks and exceptions for non-numeric types.
- Simplified return logic for invalid conversions.
2025-02-20 12:23:43 +00:00
Jonathon Broughton 90c5051fc6 Update test_user_entry.py (#44) 2025-02-18 20:37:06 +00:00
Jonathon Broughton ec6bdf3485 Update requirements.txt (#43)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2025-02-18 19:15:21 +00:00
Jonathon Broughton ceaa75d40a Update requirements.txt (#41) 2025-02-18 19:12:26 +00:00
renovate[bot] 0566f7d890 Update python Docker tag to v3.13 (#38)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 19:09:55 +00:00
Jonathon Broughton b431662031 Update rule_processor.py (#39) 2025-02-18 19:08:25 +00:00
renovate[bot] e520d9bc91 Update python Docker tag to v3.13 (#37)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:59:18 +00:00
renovate[bot] b6dcfe57df Update dependency httpx to v0.28.1 (#26)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:58:52 +00:00
renovate[bot] ba8443ce92 Update dependency pydantic-core to v2.29.0 (#27)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:58:36 +00:00
renovate[bot] 0bab18d2f2 Update python Docker tag to v3.13 (#28)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:58:21 +00:00
renovate[bot] dffb7ea7ba Update dependency attrs to v25 (#29)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:57:59 +00:00
renovate[bot] 4420fd31f4 Update dependency websockets to v15 (#30)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:57:42 +00:00
Jonathon Broughton 168a1f517a Robust rule validation and UX (#36)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
* Add minimum severity and hide skipped options

- Introduced `minimum_severity` to filter reported results.
- Added `hide_skipped` option to control visibility of skipped tests.
- Updated rule application logic to respect new parameters.

* Improve rule processing and validation

- Enhanced rule reading to group rules and handle validation messages.
- Added functions to process and validate rule numbers in the spreadsheet.
- Improved error handling when reading TSV files.

* Improve docstrings and clean up code

- Added docstring for `PropertyMatchMode` enum.
- Removed unnecessary blank lines in `FunctionInputs`.
- Commented out unused `property_match_mode` field with explanation.

* Improve rule validation logic

Added a new function to validate the structure of rule groups. Key updates include:
- Checks for required columns and conditions.
- Ensures rules start with "WHERE" and have at most one "CHECK".
- Validates that "CHECK" is the last condition if present.
- Raises descriptive errors for invalid structures.

* Refactor rule processing logic

- Added a new function to separate filters and final checks.
- Simplified condition evaluation by using the new function.
- Improved handling of empty rule groups and speckle objects.
- Enhanced clarity in filtering logic for better maintainability.

* Refactor number conversion logic in comparison

- Updated boolean comparison method call for clarity.
- Introduced a helper function to safely convert strings to numbers, handling whitespace and negative values.
- Simplified the type-checking process for string-to-float conversion.

* Update comparison logic in parameter checks

Flipped the comparison logic to match user expectations for parameter value checks. Updated docstrings for better clarity on UX perspective. Added error handling for non-numeric values to improve robustness.

* Refactor method names for clarity

Updated method names to remove leading underscores for better readability and consistency. Added a docstring to describe the purpose of the rules module.

* Add tests for comparison and rule processing

- Introduced a new test suite for value comparisons, covering numeric strings, boolean strings, case sensitivity in string comparisons, and float comparison with tolerance.
- Created a test suite for parameter handling functionality to validate the existence and retrieval of parameters in v2 and v3 objects.
- Added tests for rule processing functionality using both explicit CHECK format and legacy format rules.
2025-02-18 18:56:02 +00:00
Jonathon Broughton e49bf225ec Update main.yml (#35) 2025-02-18 09:23:57 +00:00
Jonathon Broughton f3987fced9 Update main.yml (#34) 2025-02-18 08:21:30 +00:00
Jonathon Broughton 1ae3372f42 Update main.yml (#33)
* Update main.yml

* Update Dockerfile
2025-02-18 08:07:09 +00:00
Jonathon Broughton b071380a4f Update main.yml (#32) 2025-02-18 00:07:30 +00:00
Jonathon Broughton 460b21772a Update Dockerfile (#31)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2025-02-17 23:57:56 +00:00
renovate[bot] bb40f185b5 Update dependency numpy to v2.2.3 (#24)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 23:52:05 +00:00
renovate[bot] ee12143504 Update dependency specklepy to v2.21.3 (#25)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 23:51:50 +00:00
Jonathon Broughton 8582444e56 Add V3 compatibility (#23)
* Update project configuration and name

- Excluded virtual environment folder from module content.
- Changed JDK name to "WSL Checker" for clarity.
- Updated project name in the configuration file.

* Update project configuration and dependencies

- Added source folders for `src` and `tests`.
- Excluded `.devcontainer` and `.idea` directories.
- Updated Black settings in the project configuration.
- Changed Ruff version from 0.9.5 to 0.9.6.
- Enhanced rule processing by grouping rules before application.
- Introduced a new property match mode for better parameter matching.
- Removed unused test files and configurations to clean up the codebase.

* Shifting to local uv - editing toml

* Update CI workflow and project dependencies

- Added steps to the CI workflow for better clarity.
- Switched from Poetry to pip for dependency management.
- Removed poetry.lock file as it's no longer needed.
- Updated Python version specification to 3.12.
- Adjusted project configuration files for new environment settings.

* Update dependencies for pydantic and related packages

- Added pydantic version 2.11.0a2 to dependencies.
- Updated pydantic-core to version 2.29.0.
- Adjusted URLs and hashes for the new package versions in lock file.
- Ensured compatibility with existing requirements in project configuration.

* Update dependencies to stable versions

- Downgraded Pydantic from 2.11.0a2 to 2.10.6
- Updated Pydantic-core version from 2.29.0 to 2.27.2
- Adjusted URLs and hashes for the new package versions in lock file

* Update dependencies to latest versions

- Upgraded Pydantic from 2.10.6 to 2.11.0a2
- Updated Pydantic Core from 2.27.2 to 2.29.0
- Adjusted source URLs and hashes for new package versions

* Update dependencies to stable versions

- Downgraded Pydantic from 2.11.0a2 to 2.10.6
- Updated Pydantic-core version from 2.29.0 to 2.27.2
- Adjusted URLs and hashes for the new package versions in lock file

* Add pre-commit hook and setup script

- Introduced a pre-commit hook to manage dependencies.
- Automatically installs and exports requirements before commits.
- Added a setup script to copy the hook and set permissions.

* Update project config and clean up unused files

- Removed Black configuration options from the project settings.
- Changed Ruff to run on save instead of reformat.
- Deleted example function inputs JSON file.
- Removed flatten helper module as it's no longer needed.
- Updated pyproject.toml with new line length and ignore rules.
- Cleaned up import statements in local test file.

* Update line length and tidy up code comments

- Increased the maximum line length from 100 to 120.
- Cleaned up import statements for better readability.
- Improved docstrings for clarity and consistency.
- Removed unnecessary blank lines and commented-out code.

* Refactor rules handling and improve documentation

- Updated class names from `RevitRules` to `PropertyRules`.
- Adjusted method calls to reflect the new class structure.
- Cleaned up code formatting for better readability.
- Enhanced developer README with clearer setup instructions.

* Add filtering and rule processing features

- Introduced a new filter function to sort objects by category.
- Created a predicates module for mapping spreadsheet predicates to rule methods.
- Refactored rules handling, simplifying parameter checks and value retrievals.
- Enhanced the main automation function with improved input handling and context management.

* Past WIP rules for processing Speckle parameters

- Introduced a new class to manage parameter rules.
- Added methods to check and retrieve display values from Speckle objects.
- Implemented filtering for displayable objects based on defined criteria.
- Created functions to handle Revit parameter checks and value retrievals.
- Included various utility methods for comparing parameter values against thresholds, ranges, and lists.

* Add new JSON test data for building elements

- Introduced a new JSON file containing detailed specifications for various building elements.
- Included parameters such as ID, type, level details, and multiple attributes related to structural components.
- Added information on dimensions, materials, and classifications relevant to the construction context.

* Rename test files and add new parameter tests

- Renamed localtest.py to test_function.py for clarity.
- Added a new test file for parameter checks, covering:
  - Object deserialization structure
  - Parameter existence and value retrieval in v2 and v3 objects
  - Numeric comparisons and pattern matching on parameters
  - List-based and boolean parameter checks

* Enhance helper functions and add rule processing

- Updated helper functions for better item retrieval and checks.
- Added new functions to evaluate conditions and process rules against Speckle objects.
- Improved logging with a dedicated print function.
- Streamlined flattening logic in existing methods.

* Add test runner config and new web resources

- Added pytest as the project test runner.
- Created a new XML file for web resource paths.
- Included pytest-assertcount in development dependencies.

* Add robust value comparison methods

- Introduced new static methods for comparing values:
  - `is_equal_value`: Compares two values with type handling and optional case sensitivity.
  - `is_not_equal_value`: Checks if two values are not equal.
  - `is_identical_value`: Verifies if two values are exactly identical, considering case sensitivity and no tolerance for floats.
  - `is_not_identical_value`: Checks if two values are not identical.
- Enhanced handling of strings that represent numbers.

* Refactor rules and parameter handling

- Commented out large sections of code for clarity

* Update predicates and enhance tests

- Renamed equality-related methods for clarity.
- Added new methods for "not equal" and "not identical" checks.
- Updated test to return specific objects instead of walls.
- Changed property access in tests to use `.keys()`.
- Introduced multiple tests for stringified number comparisons, case sensitivity, and floating-point precision.

* Add tests for severity level conversion

Created a new test file to validate the `get_severity` function.
- Added parameterised tests for various input cases including valid and invalid severity strings, empty inputs, and non-string types.
- Ensured that all edge cases default to the correct enum value.

* Refactor parameter tests for clarity and efficiency

- Simplified assertions in parameter existence tests.
- Added parameterisation to reduce redundancy across tests.
- Enhanced error messages for better debugging.
- Consolidated similar test cases for v2 and v3 objects.
- Improved structure of boolean and numeric comparison tests.

* Enhance spreadsheet data handling

- Added a function to convert mixed column types.
- Updated the main reading function to use this new conversion.
- Improved error handling for reading TSV files.

* Update version handling in automation function

- Added a global VERSION variable for tracking.
- Updated comments to reflect changes from 'version' to 'VERSION'.
- Adjusted logic to retrieve the version from the root object.
- Enhanced success message to include the current version.

* Improve boolean handling and property search

- Added None handling in boolean conversion.
- Enhanced `find_property` to return raw values if needed.
- Updated traversal logic to avoid revisiting objects.
- Refined comparison methods for better type handling and tolerance.
- Introduced new methods for strict value comparisons with detailed arguments.

* Update predicate method mappings

Switched from string references to actual method names in the predicate mapping. This improves clarity and reduces errors by directly linking predicates to their corresponding methods in PropertyRules. Adjusted some predicate phrases for consistency as well.

* Enhance rule evaluation with severity handling

- Added optional parameters for rule and case numbers in condition evaluation.
- Introduced an Enum for severity levels to standardise reporting.
- Created a function to convert string severities into the SeverityLevel enum, handling various input formats.
- Refactored metadata generation to use the new severity function.
2025-02-17 23:50:34 +00:00
Jonathon Broughton f2e06f165e Update Result Annotations (#20)
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
* Add project configuration and refactor logic

- Created .gitignore to exclude IDE files.
- Added project module configuration for Python.
- Set up inspection profiles for code quality checks.
- Refactored main function logic into separate modules for better organisation.
- Introduced helper functions for object manipulation and rule processing.
- Implemented spreadsheet reading functionality to dynamically load rules.
- Added tests for integration with the Speckle server.

* Update import paths for consistency

- Changed relative imports to absolute imports for clarity.
- Ensured all module references are consistent across files.

* Update package versions and add new dependencies

Bumped several package versions to their latest releases:
- Updated `anyio` to 4.8.0
- Updated `certifi` to 2025.1.31
- Updated `charset-normalizer` to 3.4.1
- Updated `click` to 8.1.8
- Updated `deprecated` to 1.2.18
- Updated `graphql-core` to 3.2.6

Added a new dependency:
- Introduced `levenshtein` version 0.26.1.

Removed some unnecessary extras from the dev dependencies for cleaner management.

* Add Ruff configuration file

Set up a new configuration for Ruff with options to run on save and use the server.

* Refactor rules processing for Data Analysis

- Enhanced the metadata structure in the results attachment function for better data analysis.
- Improved logic handling in result attachment based on rule pass/fail status.
2025-02-07 19:22:14 +00:00
33 changed files with 6566 additions and 2642 deletions
+20 -11
View File
@@ -11,26 +11,35 @@ jobs:
FUNCTION_SCHEMA_FILE_NAME: functionSchema.json
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-python@v5
# Step 1: Checkout the repository
- name: Checkout Repository
uses: actions/checkout@v4.2.2
# Step 2: Set up Python
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install poetry
# Step 3: Install dependencies using pip
- name: Install Dependencies
run: |
pip install poetry==1.8.4 &&
poetry config virtualenvs.create false &&
poetry config virtualenvs.in-project false &&
poetry config installer.parallel true
- name: Restore dependencies
run: poetry install --no-root
python -m pip install --upgrade pip
pip install -r requirements.txt
# Step 4: Generate the schema
- name: Extract functionInputSchema
id: extract_schema
run: |
python main.py generate_schema ${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}
python main.py generate_schema "${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}"
echo "Checking if functionSchema.json exists after generation..."
ls -lah "${HOME}/${{ env.FUNCTION_SCHEMA_FILE_NAME }}"
# Step 5: Build and publish the Speckle function
- name: Speckle Automate Function - Build and Publish
uses: specklesystems/speckle-automate-github-composite-action@0.9.0
with:
speckle_automate_url: ${{ env.SPECKLE_AUTOMATE_URL || vars.SPECKLE_AUTOMATE_URL || 'https://automate.speckle.dev' }}
speckle_automate_url: ${{ env.SPECKLE_AUTOMATE_URL || 'https://automate.speckle.dev' }}
speckle_token: ${{ secrets.SPECKLE_FUNCTION_TOKEN }}
speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }}
speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }}
+9 -2
View File
@@ -1,12 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.11.4 WSL (UbuntuDev): (/home/jsdbroughton/.virtualenvs/speckle_automate-mesh_density_checker/bin/python)" jdkType="Python SDK" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="uv (Checker)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.11.4 WSL (UbuntuDev): (/home/jsdbroughton/.virtualenvs/speckle_automate-mesh_density_checker/bin/python)" />
<option name="sdkName" value="WSL Checker" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11.4 WSL (UbuntuDev): (/home/jsdbroughton/.virtualenvs/speckle_automate-mesh_density_checker/bin/python)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="uv (Checker)" project-jdk-type="Python SDK" />
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="enableLsp" value="false" />
<option name="runRuffOnSave" value="true" />
<option name="useRuffFormat" value="true" />
<option name="useRuffServer" value="true" />
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$/test_data" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>
+1
View File
@@ -0,0 +1 @@
3.13
+3 -1
View File
@@ -3,11 +3,13 @@
## Setup
1. Install dependencies:
```bash
poetry shell && poetry install
```
2. Configure `.env`:
```
SPECKLE_TOKEN=your_speckle_token
SPECKLE_SERVER_URL=app.speckle.systems
@@ -32,7 +34,7 @@ poetry run pytest
## Extending Rules
1. Add new predicate to `input_predicate_mapping` in `rules.py`
2. Create corresponding method in `RevitRules` class
2. Create corresponding method in `PropertyRules` class
3. Update tests
## Building
+10 -11
View File
@@ -1,16 +1,15 @@
# We use the official Python 3.11 image as our base image and will add our code to it. For more details, see https://hub.docker.com/_/python
FROM python:3.13-slim
# Use the official Python 3.11 slim image as the base
FROM python:3.11-slim
# We install poetry to generate a list of dependencies which will be required by our application
RUN pip install poetry==1.8.4
# We set the working directory to be the /home/speckle directory; all of our files will be copied here.
# Set the working directory inside the container
WORKDIR /home/speckle
# Copy all of our code and assets from the local directory into the /home/speckle directory of the container.
# We also ensure that the user 'speckle' owns these files, so it can access them
# This assumes that the Dockerfile is in the same directory as the rest of the code
# Copy the application files to the working directory
COPY . /home/speckle
# Using poetry, we generate a list of requirements, save them to requirements.txt, and then use pip to install them
RUN poetry export --format requirements.txt --output /home/speckle/requirements.txt && pip install --requirement /home/speckle/requirements.txt
# Upgrade pip and install dependencies using requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /home/speckle/requirements.txt
# Set the entrypoint for running the Speckle function
CMD ["python", "-u", "main.py", "run"]
-20
View File
@@ -1,20 +0,0 @@
{
"speckleToken": "YOUR SPEKCLE TOKEN",
"functionInputs": {
"whisperMessage": "you are doing something weird",
"forbiddenSpeckleType": "wall"
},
"automationRunData": {
"project_id": "project id",
"speckle_server_url": "https://latest.speckle.systems",
"automation_id": "automation id",
"automation_run_id": "automation run id",
"function_run_id": "function run id",
"triggers": [
{
"payload": { "modelId": "model id", "versionId": "version id" },
"triggerType": "versionCreation"
}
]
}
}
-27
View File
@@ -1,27 +0,0 @@
"""Helper module for a simple speckle object tree flattening."""
from collections.abc import Iterable
from specklepy.objects import Base
def flatten_base(base: Base) -> Iterable[Base]:
"""Flatten a base object into an iterable of bases.
This function recursively traverses the `elements` or `@elements` attribute of the
base object, yielding each nested base object.
Args:
base (Base): The base object to flatten.
Yields:
Base: Each nested base object in the hierarchy.
"""
# Attempt to get the elements attribute, fallback to @elements if necessary
elements = getattr(base, "elements", getattr(base, "@elements", None))
if elements is not None:
for element in elements:
yield from flatten_base(element)
yield base
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
echo "Running pre-commit hook..."
# Ensure dependencies are installed with uv
uv pip install --requirement requirements.txt
# Export dependencies with uv (and overwrite requirements.txt)
uv pip freeze > requirements.txt
# Add generated requirements.txt to git
git add requirements.txt
echo "Pre-commit hook completed successfully!"
Generated
-1797
View File
File diff suppressed because it is too large Load Diff
+24 -27
View File
@@ -1,39 +1,36 @@
[tool.poetry]
name = "speckle-automate-py"
[project]
name = "speckle-automate-checker"
version = "0.1.0"
description = "Allows for QAQC property checking with Speckle"
authors = ["Jonathon Broughton <jonathon@speckle.systems>"]
readme = "README.md"
package-mode = false
requires-python = ">=3.13"
dependencies = [
"more-itertools>=10.6.0",
"pandas>=2.2.3",
"pydantic==2.10.6",
"python-dotenv>=1.0.1",
"python-levenshtein>=0.26.1",
"specklepy>=2.21.3",
"pytest-assertcount>=1.0.0",
"black>=25.1.0",
"mypy>=1.15.0",
"pydantic-settings>=2.7.1",
"pytest>=8.3.4",
"ruff>=0.9.6",
]
[tool.poetry.dependencies]
more-itertools = "^10.6.0"
pandas = "^2.2.3"
python = "^3.11"
python-dotenv = "^1.0.1"
python-levenshtein = "^0.26.1"
specklepy = "^2.21.0"
[tool.poetry.group.dev.dependencies]
black = "^25.0.0"
mypy = "^1.3.0"
pydantic-settings = "^2.3.0"
pytest = "^8.0.0"
ruff = "^0.9.5"
# specklepy = { path = "../specklepy", develop = true }
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
select = [
"E", # pycodestyle
"F", # pyflakes
"UP", # pyupgrade
"D", # pydocstyle
"I", # isort
"E", # pycodestyle
"F", # pyflakes
"UP", # pyupgrade
"D", # pydocstyle
"I", # isort
]
line-length = 120
ignore = ["F401", "F403"]
[tool.ruff.pydocstyle]
convention = "google"
+54
View File
@@ -0,0 +1,54 @@
annotated-types==0.7.0
anyio==4.8.0
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-core==2.27.2
pydantic-settings==2.8.1
pytest==8.3.4
pytest-assertcount==1.0.0
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
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Copy the pre-commit hook to the .git/hooks/ directory
cp hooks/pre-commit .git/hooks/pre-commit
# Ensure the hook is executable
chmod +x .git/hooks/pre-commit
echo "Git hooks have been set up!"
+31
View File
@@ -0,0 +1,31 @@
from specklepy.objects.base import Base
from src.rules import PropertyRules
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 PropertyRules.is_category(obj, category_input):
matching_objects.append(obj)
else:
non_matching_objects.append(obj)
return matching_objects, non_matching_objects
+38 -16
View File
@@ -1,21 +1,25 @@
"""This module contains the function's business logic.
"""This is the main function that will be executed when the automation is triggered.
Use the automation_context module to wrap your function in an Automate context helper.
It will receive the inputs from the user, and the context of the run.
It will then apply the rules to the objects in the model, and report back the results.
"""
from speckle_automate import AutomationContext, AutomateBase
from speckle_automate import AutomationContext
from specklepy.objects.base import Base
from src.rules import apply_rules_to_objects
from src.helpers import flatten_base, speckle_print
from src.inputs import FunctionInputs
from src.helpers import flatten_base
from src.rule_processor import apply_rules_to_objects
from src.spreadsheet import read_rules_from_spreadsheet
VERSION: int = 2
def automate_function(
automate_context: AutomationContext,
function_inputs: FunctionInputs,
automate_context: AutomationContext,
function_inputs: FunctionInputs,
) -> None:
"""This version of the function will add a check for the new provide inputs.
"""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
@@ -24,23 +28,41 @@ def automate_function(
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()
# the context provides a convenient way, to receive the triggering VERSION
version_root_object: Base = 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)
# If it is a next_gen model, we can get the VERSION from the root object
# This function's rules don't make use of this check, but it is here for reference if you want to.
global VERSION
VERSION = getattr(version_root_object, "version", 2) # noqa: F841SION = getattr(version_root_object,"version", 2) # noqa: F841 # noqa: F841
# Read and group rules
grouped_rules, messages = read_rules_from_spreadsheet(function_inputs.spreadsheet_url)
# Handle any validation messages
for message in messages:
speckle_print(message) # or log them appropriately
if grouped_rules is None:
automate_context.mark_run_exception("Failed to process rules")
return
# apply the rules to the objects
apply_rules_to_objects(flat_list_of_objects, rules, automate_context)
apply_rules_to_objects(
flat_list_of_objects,
grouped_rules,
automate_context,
minimum_severity=function_inputs.minimum_severity,
hide_skipped=function_inputs.hide_skipped,
)
# set the automation context view, to the original model / version view
# 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."
f"Successfully applied {len(grouped_rules)} rules to {len(flat_list_of_objects)} version {VERSION} objects."
)
+32 -20
View File
@@ -1,14 +1,14 @@
"""Helper module for a speckle object tree flattening."""
from collections.abc import Iterable
from typing import Optional, Tuple, List
from collections.abc import Generator, Iterable
from typing import Any
from specklepy.objects import Base
from specklepy.objects.other import Instance, Transform
def speckle_print(log_string: str = "banana") -> None:
"""Print a string to the console with a green color."""
print("\033[92m" + str(log_string) + "\033[0m")
@@ -21,6 +21,24 @@ def flatten_base(base: Base) -> Iterable[Base]:
yield base
def get_item(obj: Base | dict[str, Any], key, default=None):
"""Get an item from a dictionary or an object with a default value."""
if isinstance(obj, dict): # If it's a dictionary
return obj.get(key, default)
elif hasattr(obj, key): # If it's an object with the attribute
return getattr(obj, key, default)
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:
"""Check if an object has a key or an attribute."""
if isinstance(obj, dict):
return key in obj
elif hasattr(obj, key):
return True
return False
def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]:
"""Take a base and flatten it to an iterable of bases.
@@ -51,9 +69,7 @@ def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]
print(category)
if category.startswith("@"):
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:
pass
@@ -63,11 +79,13 @@ def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[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.
inherited_instance_id: str | None = None,
transform_list: list[Transform] | None = None,
) -> Generator[
Base | str | list[Transform] | None | tuple[Base, Any | None, list[Transform] | None | list[Any]], Any | None, None
]:
"""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.
@@ -91,9 +109,7 @@ def extract_base_and_transform(
if base.transform:
transform_list.append(base.transform)
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:
# Initial yield for the current `Base` object.
yield base, current_id, transform_list
@@ -103,9 +119,7 @@ def extract_base_and_transform(
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()
)
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.
@@ -114,6 +128,4 @@ def extract_base_and_transform(
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()
)
yield from extract_base_and_transform(attr_value, current_id, transform_list.copy())
+39
View File
@@ -1,7 +1,25 @@
from enum import Enum
from pydantic import Field
from speckle_automate import AutomateBase
class PropertyMatchMode(Enum):
"""Controls how strictly parameter names must match."""
STRICT = "strict" # Exact parameter path match
FUZZY = "fuzzy" # Search all parameters ignoring hierarchy
MIXED = "mixed" # Exact match first, fuzzy fallback
class MinimumSeverity(str, Enum):
"""Enum for minimum severity level to report."""
INFO = "Info"
WARNING = "Warning"
ERROR = "Error"
class FunctionInputs(AutomateBase):
"""These are function author defined values.
@@ -15,3 +33,24 @@ class FunctionInputs(AutomateBase):
title="Spreadsheet URL",
description="This is the URL of the spreadsheet to check. It should be a TSV format data source.",
)
minimum_severity: MinimumSeverity = Field(
default=MinimumSeverity.INFO,
title="Minimum Severity Level",
description="Only report test results with this severity level or higher. Info will show all results, Warning will show warnings and errors, Error will show only errors.",
)
hide_skipped: bool = Field(
default=False,
title="Hide Skipped Tests",
description="If enabled, tests that were skipped (no matching objects found) will not be reported.",
)
# property_match_mode: PropertyMatchMode = Field(
# default=PropertyMatchMode.MIXED,
# title="Property Match Mode",
# description='Controls how strictly parameter names must match. ' +
# 'STRICT will only match exact parameter paths, ' +
# 'FUZZY will search all parameters ignoring hierarchy, ' +
# 'MIXED will exact match first, fuzzy fallback.'
# )
+20
View File
@@ -0,0 +1,20 @@
"""Configuration module defining mappings between spreadsheet predicates and rule methods."""
from src.rules import PropertyRules
# Mapping of input predicates to the corresponding methods in PropertyRules
PREDICATE_METHOD_MAP = {
"exists": PropertyRules.has_parameter.__name__,
"greater than": PropertyRules.is_parameter_value_greater_than.__name__,
"less than": PropertyRules.is_parameter_value_less_than.__name__,
"in range": PropertyRules.is_parameter_value_in_range.__name__,
"in list": PropertyRules.is_parameter_value_in_list.__name__,
"equal to": PropertyRules.is_equal_value.__name__,
"not equal to": PropertyRules.is_not_equal_value.__name__,
"is true": PropertyRules.is_parameter_value_true.__name__,
"is false": PropertyRules.is_parameter_value_false.__name__,
"is like": PropertyRules.is_parameter_value_like.__name__,
"identical to": PropertyRules.is_identical_value.__name__,
"contains": PropertyRules.is_parameter_value_containing.__name__,
"does not contain": PropertyRules.is_parameter_value_not_containing.__name__,
}
+381
View File
@@ -0,0 +1,381 @@
"""Module for processing rules against Speckle objects and updating the automate context with the results."""
import json
from enum import Enum
from typing import Any
import pandas as pd
from pandas.core.groupby import DataFrameGroupBy
from speckle_automate import AutomationContext, ObjectResultLevel
from specklepy.objects.base import Base
from src.helpers import speckle_print
from src.inputs import MinimumSeverity
from src.predicates import PREDICATE_METHOD_MAP
from src.rules import PropertyRules
def validate_rule_structure(rule_group: pd.DataFrame) -> None:
"""Validates the structure and logic of a rule group.
Args:
rule_group: DataFrame containing the rule conditions
Raises:
ValueError: If rule structure is invalid
"""
if rule_group.empty:
return
# Validate Logic column exists
if "Logic" not in rule_group.columns:
raise ValueError("Rule must have a 'Logic' column")
# Get uppercase Logic values for case-insensitive comparison
logic_values = rule_group["Logic"].str.upper()
# Check if first condition is WHERE
if logic_values.iloc[0] != "WHERE":
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} must start with WHERE")
# Count CHECK conditions
check_count = sum(1 for value in logic_values if value == "CHECK")
if check_count > 1:
raise ValueError(f"Rule {rule_group.iloc[0]['Rule Number']} has multiple CHECK conditions")
# If CHECK exists, ensure it's the last condition
check_indices = logic_values[logic_values == "CHECK"].index
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']}")
# Validate Logic values
valid_values = {"WHERE", "AND", "CHECK"}
invalid_values = set(logic_values.unique()) - valid_values
if invalid_values:
raise ValueError(f"Invalid Logic values found: {invalid_values}")
def evaluate_condition(
speckle_object: Base, condition: pd.Series, rule_number: str | None = None, case_number: int | None = None
) -> bool:
"""Given a Speckle object and a condition, evaluates the condition and returns a boolean value.
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:
rule_number (string): For information the rule number.
case_number (int): For information the rule clause number.
speckle_object (Base): The Speckle object to evaluate.
condition (pd.Series): The condition to evaluate.
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"]
_ = rule_number
_ = case_number
if predicate_key in PREDICATE_METHOD_MAP:
method_name = PREDICATE_METHOD_MAP[predicate_key]
method = getattr(PropertyRules, method_name, None)
if method:
return method(speckle_object, property_name, value)
return False
def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
"""Separates rule conditions into filters and final check.
Args:
rule_group: DataFrame containing rule conditions
Returns:
Tuple containing filter conditions and final check condition
"""
if rule_group.empty:
return pd.DataFrame(), pd.Series()
# Get uppercase Logic values for case-insensitive comparison
logic_values = rule_group["Logic"].str.upper()
# Look for explicit CHECK
check_conditions = rule_group[logic_values == "CHECK"]
has_explicit_check = not check_conditions.empty
if has_explicit_check:
# Use first CHECK condition as final check
final_check = check_conditions.iloc[0]
# All other conditions are filters
filters = rule_group[logic_values != "CHECK"]
else:
# Legacy behavior: use last AND as check if present
and_conditions = rule_group[logic_values == "AND"]
if not and_conditions.empty:
# Get the last AND as the check
final_check = and_conditions.iloc[-1]
# All conditions up to the last AND are filters
last_and_idx = and_conditions.index[-1]
filters = rule_group[rule_group.index < last_and_idx]
else:
# No AND conditions found, just use WHERE as filter
filters = rule_group
final_check = rule_group.iloc[0] # Default to first condition as check
return filters, final_check
def process_rule(
speckle_objects: list[Base], rule_group: pd.DataFrame
) -> tuple[list[Any], list[Any]] | tuple[list[Base], list[Base]]:
"""Processes a set of rules against Speckle objects, returning those that pass and fail.
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.
"""
if not speckle_objects or rule_group.empty:
return [], []
try:
validate_rule_structure(rule_group)
except ValueError as e:
speckle_print(f"Rule validation error: {str(e)}")
return [], []
# Get filters and final check
filters, final_check = get_filters_and_check(rule_group)
# Start with all objects
filtered_objects = speckle_objects.copy()
rule_number = rule_group.iloc[0]["Rule Number"]
# Apply each filter condition sequentially
for index, (_, filter_condition) in enumerate(filters.iterrows()):
filtered_objects = [
obj
for obj in filtered_objects
if evaluate_condition(
speckle_object=obj, condition=filter_condition, rule_number=rule_number, case_number=index
)
]
# Early exit if no objects pass filters
if not filtered_objects:
return [], []
# For remaining objects, evaluate the final check
pass_objects = []
fail_objects = []
for obj in filtered_objects:
if evaluate_condition(
speckle_object=obj, condition=final_check, rule_number=rule_number, case_number=len(filters)
):
pass_objects.append(obj)
else:
fail_objects.append(obj)
return pass_objects, fail_objects
def apply_rules_to_objects(
speckle_objects: list[Base],
grouped_rules: DataFrameGroupBy,
automate_context: AutomationContext,
minimum_severity: MinimumSeverity = MinimumSeverity.INFO,
hide_skipped: bool = False,
) -> dict[str, tuple[list[Base], list[Base]]]:
"""Applies defined rules to a list of objects and updates the automate context based on the results.
Args:
speckle_objects (List[Base]): The list of objects to which rules are applied.
grouped_rules (pd.DataFrameGroupBy): The DataFrame containing rule definitions.
automate_context (Any): Context manager for attaching rule results.
minimum_severity: Minimum severity level to report
hide_skipped: Whether to hide skipped tests
"""
grouped_results = {}
rules_processed = 0
severity_levels = {MinimumSeverity.INFO: 0, MinimumSeverity.WARNING: 1, MinimumSeverity.ERROR: 2}
min_severity_level = severity_levels[minimum_severity]
for rule_id, rule_group in grouped_rules:
rule_id_str = str(rule_id) # Convert rule_id to string
rules_processed += 1
# 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)
# Get the severity level for this rule
rule_severity = get_severity(rule_group.iloc[-1])
rule_severity_level = severity_levels[MinimumSeverity(rule_severity.value)]
# For passing objects, only attach if we're showing all levels (INFO)
if minimum_severity == MinimumSeverity.INFO:
attach_results(pass_objects, rule_group.iloc[-1], rule_id_str, automate_context, True)
# For failing objects, attach if they meet minimum severity threshold
if rule_severity_level >= min_severity_level:
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:
automate_context.attach_info_to_objects(
category=f"Rule {rule_id_str} Skipped",
object_ids=["0"], # This is a hack to get a rule to report with no valid objects
message=f"No objects found for rule {rule_id_str}",
metadata={},
)
grouped_results[rule_id_str] = (pass_objects, fail_objects)
# return pass_objects, fail_objects for each rule
return grouped_results
class SeverityLevel(Enum):
"""Enum for severity levels."""
INFO = "Info"
WARNING = "Warning"
ERROR = "Error"
def get_severity(rule_info: pd.Series) -> SeverityLevel:
"""Convert a string severity level to the corresponding SeverityLevel enum.
This function normalizes input strings (because processing user entered dead is hard), handling:
- Case insensitivity (e.g., "info", "WARNING""Info", "Warning")
- Shorthand mappings (e.g., "WARN""Warning")
- Stripping whitespace
- Defaults to SeverityLevel.ERROR if the input is invalid
"""
severity = rule_info.get("Report Severity") # Extract severity from input data
# If severity is None or not a string (e.g., numeric input), default to ERROR
if not isinstance(severity, str):
return SeverityLevel.ERROR
severity = severity.strip().upper() # Remove leading/trailing spaces & normalize case
# Define a mapping for shorthand or alternate spellings
alias_map = {
"WARN": "WARNING", # Treat "WARN" as "WARNING"
}
# Replace shorthand values if applicable
severity = alias_map.get(severity, severity)
# Attempt to match with an existing SeverityLevel enum value (case-insensitive)
return next(
(level for level in SeverityLevel if level.value.upper() == severity),
SeverityLevel.ERROR, # Default to ERROR if no match is found
)
def get_metadata(
rule_id: str, rule_info: pd.Series, passed: bool, speckle_objects: list[Base]
) -> dict[str, str | int | Any]:
"""Function that generates metadata with severity validation and ensures JSON serializability.
Reasoning is that non-valid metadata fails inside the Automate context. So let's ensure it's valid.
Args:
rule_id: Identifier for the rule
rule_info: Series containing rule information
passed: Boolean indicating if the rule passed
speckle_objects: List of Speckle objects
Returns:
Dictionary containing metadata if valid JSON serializable, empty dict otherwise
"""
try:
metadata = {
"rule_id": rule_id,
"status": "PASS" if passed else "FAIL",
"severity": get_severity(rule_info).value,
"rule_message": format_message(rule_info),
"object_count": len(speckle_objects),
}
# Validate JSON serializability
json.dumps(metadata)
return metadata
except (TypeError, ValueError, json.JSONDecodeError) as e:
# Log the error for debugging purposes
print(f"Error creating metadata: {str(e)}")
return {}
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
# Create structured metadata for onward data analysis uses
metadata = get_metadata(rule_id, rule_info, passed, speckle_objects)
message = format_message(rule_info)
if not passed:
speckle_print(rule_info["Report Severity"])
severity = (
ObjectResultLevel.WARNING
if rule_info["Report Severity"].capitalize() in ["Warning", "Warn"]
else ObjectResultLevel.ERROR
)
context.attach_result_to_objects(
category=f"Rule {rule_id}",
object_ids=[speckle_object.id for speckle_object in speckle_objects],
message=message,
level=severity,
metadata=metadata,
)
else:
context.attach_info_to_objects(
category=f"Rule {rule_id}",
object_ids=[speckle_object.id for speckle_object in speckle_objects],
message=message,
metadata=metadata,
)
def format_message(rule_info):
"""Format the message for the rule."""
message = (
str(rule_info["Message"])
if rule_info["Message"] is not None and not pd.isna(rule_info["Message"])
else "No Message"
)
return message
+456 -630
View File
File diff suppressed because it is too large Load Diff
+756
View File
@@ -0,0 +1,756 @@
# import re
# from typing import Any
#
# from Levenshtein import ratio
# from specklepy.objects.base import Base
#
# from src.helpers import get_item, has_item, speckle_print
# from src.inputs import PropertyMatchMode
# We're going to define a set of rules that will allow us to filter and
# process parameters in our Speckle objects. These rules will be encapsulated
# in a class called `ParameterRules`.
# class Rules:
# """A collection of rules for processing properties in Speckle objects.
#
# Simple rules can be straightforwardly implemented as static methods that
# return boolean value to be used either as a filter or a condition.
# These can then be abstracted into returning lambda functions that we can
# use in our main processing logic. By encapsulating these rules, we can easily
# extend or modify them in the future.
# """
#
# @staticmethod
# def try_get_display_value(
# speckle_object: Base,
# ) -> list[Base] | None:
# """Try fetching the display value from a Speckle object.
#
# This method encapsulates the logic for attempting to retrieve the display value from a
# Speckle object. It returns a list containing the display values if found,
# otherwise it returns None.
#
# Args:
# speckle_object (Base): The Speckle object to extract the display value from.
#
# Returns:
# Optional[List[Base]]: A list containing the display values.
# If no display value is found, returns None.
# """
# # Attempt to get the display value from the speckle_object
# raw_display_value = getattr(speckle_object, "displayValue", None) or getattr(
# speckle_object, "@displayValue", None
# )
#
# # If no display value found, return None
# if raw_display_value is None:
# return None
#
# # If display value found, filter out non-Base objects
# display_values = [value for value in raw_display_value if isinstance(value, Base)]
#
# # If no valid display values found, return None
# if not display_values:
# return None
#
# return display_values
#
# @staticmethod
# def is_displayable_object(speckle_object: Base) -> bool:
# """Determines if a given Speckle object is displayable.
#
# This method encapsulates the logic for determining if a Speckle object is displayable.
# It checks if the speckle_object has a display value and returns True if it does, otherwise it returns False.
#
# Args:
# speckle_object (Base): The Speckle object to check.
#
# Returns:
# bool: True if the object has a display value, False otherwise.
# """
# # Check for direct displayable state using try_get_display_value
# display_values = Rules.try_get_display_value(speckle_object)
# if display_values and getattr(speckle_object, "id", None) is not None:
# return True
#
# # Check for displayable state via definition, using try_get_display_value on the definition object
# definition = getattr(speckle_object, "definition", None)
# if definition:
# definition_display_values = Rules.try_get_display_value(definition)
# if definition_display_values and getattr(definition, "id", None) is not None:
# return True
#
# return False
#
# @staticmethod
# def get_displayable_objects(flat_list_of_objects: list[Base]) -> list[Base]:
# """Filters a list of Speckle objects to only include displayable objects.
#
# This function takes a list of Speckle objects and filters out the objects that are displayable.
# It returns a list containing only the displayable objects.
#
# Args:
# flat_list_of_objects (List[Base]): The list of Speckle objects to filter.
# """
# return [
# speckle_object
# for speckle_object in flat_list_of_objects
# if Rules.is_displayable_object(speckle_object) and getattr(speckle_object, "id", None)
# ]
#
#
# class PropertyRules:
# """A collection of rules for processing Revit parameters in Speckle objects."""
#
# @staticmethod
# def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool:
# """Checks if the speckle_object has a parameter with the given name."""
# found, _ = ParameterSearch.lookup_parameter(speckle_object, parameter_name)
# return found
#
# @staticmethod
# def get_parameter_value(
# speckle_object: Base,
# parameter_name: str,
# match_mode: PropertyMatchMode = PropertyMatchMode.MIXED,
# default_value: Any = None,
# ) -> Any:
# """Gets the value of a parameter if it exists."""
# found, value = ParameterSearch.lookup_parameter(speckle_object, parameter_name, match_mode)
# return value if found else default_value
#
# @staticmethod
# def is_v3(speckle_object: Base) -> bool:
# """Determines if a Speckle object uses v3 parameter structure.
#
# Args:
# speckle_object (Base): The Speckle object to check
#
# Returns:
# bool: True if object uses v3 structure, False otherwise
# """
# properties = get_item(speckle_object, "properties")
# return bool(properties and has_item(properties, "Parameters"))
#
# # @staticmethod
# # def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool:
# # """Checks if the speckle_object has a Revit parameter with the given name.
# #
# # First checks direct properties, then determines if it's a v2 or v3 object structure
# # and searches in the appropriate parameter hierarchy.
# #
# # Args:
# # speckle_object (Base): The Speckle object to check.
# # parameter_name (str): The name of the parameter to check for.
# # *_args: Extra positional arguments which are ignored.
# # **_kwargs: Extra keyword arguments which are ignored.
# #
# # Returns:
# # bool: True if the object has the parameter, False otherwise.
# # """
# # # Check direct property first regardless of version
# # if has_item(speckle_object, parameter_name):
# # return True
# #
# # if PropertyRules.is_v3(speckle_object):
# # properties = get_item(speckle_object, "properties")
# # parameters = get_item(properties, "Parameters")
# # if parameters:
# #
# # def search_v3_params(params: dict, search_name: str) -> bool:
# # for key, value in params.items():
# # if isinstance(value, dict):
# # # Check direct name match
# # if key.lower() == search_name.lower():
# # return True
# # # Check nested parameters
# # if search_v3_params(value, search_name):
# # return True
# # return False
# #
# # return search_v3_params(parameters, parameter_name)
# # else:
# # # Handle v2 structure
# # parameters = get_item(speckle_object, "parameters")
# # if not parameters:
# # return False
# #
# # # Check direct parameter name match
# # if has_item(parameters, parameter_name):
# # return True
# #
# # # Check nested parameters with name property
# # def check_nested_name(value: Any) -> bool:
# # if isinstance(value, dict):
# # return get_item(value, "name") == parameter_name
# # return get_item(value, "name") == parameter_name if hasattr(value, "name") else False
# #
# # return any(check_nested_name(param_value) for param_value in parameters.values() if param_value is not None)
# #
# # return False
# #
# # @staticmethod
# # def get_parameter_value(
# # speckle_object: Base,
# # parameter_name: str,
# # match_mode: PropertyMatchMode = PropertyMatchMode.MIXED,
# # default_value: Any = None,
# # ) -> Any | None:
# # """Retrieves the value of the specified parameter from the speckle_object.
# #
# # First checks direct properties, then determines if it's a v2 or v3 object structure
# # and retrieves from the appropriate parameter hierarchy.
# #
# # Args:
# # speckle_object (Base): The Speckle object to retrieve the parameter value from.
# # parameter_name (str): The name of the parameter to retrieve the value for.
# # match_mode (PropertyMatchMode): The matching mode to use for parameter lookup
# # default_value: The default value to return if parameter not found.
# #
# # Returns:
# # The value of the parameter if found, else default_value.
# # """
# # # Check direct property first regardless of version
# # if has_item(speckle_object, parameter_name):
# # value = get_item(speckle_object, parameter_name)
# # return value if value is not None else default_value
# #
# # if PropertyRules.is_v3(speckle_object):
# # return PropertyRules.get_v3_parameter(speckle_object, parameter_name, match_mode, default_value)
# # else:
# # return PropertyRules.get_v2_parameter(speckle_object, parameter_name, match_mode, default_value)
#
# # @staticmethod
# # def get_v2_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any:
# # """Get parameter value from v2 Speckle object structure.
# #
# # Args:
# # obj: Speckle object to get parameter from
# # name: Parameter name to retrieve
# # mode: Match mode for parameter lookup
# # default: Default value if parameter not found
# #
# # Returns:
# # Parameter value if found, else default
# # """
# # parameters = get_item(obj, "parameters")
# # if not parameters:
# # return default
# #
# # if mode == PropertyMatchMode.STRICT:
# # return PropertyRules.strict_parameter_lookup(name, parameters, default)
# #
# # def search_params(param_dict: dict, search_name: str, fuzzy: bool) -> Any:
# # for key, value in param_dict.items():
# # key_match = (key.lower() == search_name.lower()) or (fuzzy and search_name.lower() in key.lower())
# # if key_match:
# # # Handle both direct values and nested parameter objects
# # return get_item(value, "value", value)
# # return None
# #
# # result = search_params(parameters, name, mode == PropertyMatchMode.FUZZY)
# # return result if result is not None else default
# #
# # @staticmethod
# # def get_v3_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any:
# # """Get parameter value from v3 Speckle object structure.
# #
# # Args:
# # obj: Speckle object to get parameter from
# # name: Parameter name to retrieve
# # mode: Match mode for parameter lookup
# # default: Default value if parameter not found
# #
# # Returns:
# # Parameter value if found, else default
# # """
# # properties = get_item(obj, "properties")
# # if not properties or not has_item(properties, "Parameters"):
# # return default
# #
# # parameters = get_item(properties, "Parameters")
# # if not parameters:
# # return default
# #
# # if mode == PropertyMatchMode.STRICT:
# # return PropertyRules.strict_parameter_lookup(name, parameters, default)
# #
# # def search_nested(data: dict, search_name: str, fuzzy: bool) -> Any:
# # for nested_key, value in data.items():
# # if isinstance(value, dict):
# # key_match = (nested_key.lower() == search_name.lower()) or (
# # fuzzy and search_name.lower() in nested_key.lower()
# # )
# #
# # if key_match and has_item(value, "value"):
# # return get_item(value, "value")
# #
# # nested_result = search_nested(value, search_name, fuzzy)
# # if nested_result is not None:
# # return nested_result
# # return None
# #
# # result = search_nested(parameters, name, mode == PropertyMatchMode.FUZZY)
# # return result if result is not None else default
# #
# # @staticmethod
# # def strict_parameter_lookup(name: str, parameters: dict, default: Any) -> Any:
# # """Perform strict parameter lookup following exact path.
# #
# # Args:
# # name: Parameter path (dot separated)
# # parameters: Parameters dictionary
# # default: Default value if not found
# #
# # Returns:
# # Parameter value if found, else default
# # """
# # path_parts = name.split(".")
# # current = parameters
# #
# # for part in path_parts:
# # if not current or not isinstance(current, dict):
# # return default
# #
# # # Find exact case-insensitive match
# # key = next((k for k in current.keys() if k.lower() == part.lower()), None)
# # if not key:
# # return default
# #
# # current = get_item(current, key)
# #
# # # Handle both direct values and parameter objects
# # if isinstance(current, dict):
# # return get_item(current, "value", current)
# # return current
#
# @staticmethod
# def is_parameter_value(speckle_object: Base, parameter_name: str, value_to_match: Any) -> bool:
# """Checks if the value of the specified parameter matches the given value.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# value_to_match (Any): The value to match against.
#
# Returns:
# bool: True if the parameter value matches the given value, False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# return parameter_value == value_to_match
#
# @staticmethod
# def is_parameter_value_like(
# speckle_object: Base,
# parameter_name: str,
# pattern: str,
# fuzzy: bool = False,
# threshold: float = 0.8,
# ) -> bool:
# """Checks if the value of the specified parameter matches the given pattern.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# pattern (str): The pattern to match against.
# fuzzy (bool): If True, performs fuzzy matching using Levenshtein distance.
# If False (default), performs exact pattern matching using regular expressions.
# threshold (float): The similarity threshold for fuzzy matching (default: 0.8).
# Only applicable when fuzzy=True.
#
# Returns:
# bool: True if the parameter value matches the pattern (exact or fuzzy), False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# if parameter_value is None:
# return False
#
# if fuzzy:
# similarity = ratio(str(parameter_value), pattern)
# return similarity >= threshold
# else:
# return bool(re.match(pattern, str(parameter_value)))
#
# @staticmethod
# def parse_number_from_string(input_string: str):
# """Attempts to parse an integer or float from a given string.
#
# Args:
# input_string (str): The string containing the number to be parsed.
#
# Returns:
# int or float: The parsed number, or raises ValueError if parsing is not possible.
# """
# try:
# # First try to convert it to an integer
# return int(input_string)
# except ValueError:
# # If it fails to convert to an integer, try to convert to a float
# try:
# return float(input_string)
# except ValueError:
# # Raise an error if neither conversion is possible
# raise ValueError("Input string is not a valid integer or float")
#
# @staticmethod
# def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
# """Checks if the value of the specified parameter is greater than the given threshold.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# threshold (Union[int, float]): The threshold value to compare against.
#
# Returns:
# bool: True if the parameter value is greater than the threshold, False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# if parameter_value is None:
# return False
#
# if not isinstance(parameter_value, int | float):
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
# return parameter_value > PropertyRules.parse_number_from_string(threshold)
#
# @staticmethod
# def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
# """Checks if the value of the specified parameter is less than the given threshold.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# threshold (Union[int, float]): The threshold value to compare against.
#
# Returns:
# bool: True if the parameter value is less than the threshold, False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# if parameter_value is None:
# return False
# if not isinstance(parameter_value, int | float):
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
# return parameter_value < PropertyRules.parse_number_from_string(threshold)
#
# @staticmethod
# def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool:
# """Checks if the value of the specified parameter falls within the given range.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# value_range (str): The range to check against, in the format "min_value, max_value".
#
# Returns:
# bool: True if the parameter value falls within the range (inclusive), False otherwise.
# """
# min_value, max_value = value_range.split(",")
# min_value = PropertyRules.parse_number_from_string(min_value)
# max_value = PropertyRules.parse_number_from_string(max_value)
#
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# if parameter_value is None:
# return False
# if not isinstance(parameter_value, int | float):
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
#
# return min_value <= parameter_value <= max_value
#
# @staticmethod
# def is_parameter_value_in_range_expanded(
# speckle_object: Base,
# parameter_name: str,
# min_value: int | float,
# max_value: int | float,
# inclusive: bool = True,
# ) -> bool:
# """Checks if the value of the specified parameter falls within the given range.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# min_value (Union[int, float]): The minimum value of the range.
# max_value (Union[int, float]): The maximum value of the range.
# inclusive (bool): If True (default), the range is inclusive (min <= value <= max).
# If False, the range is exclusive (min < value < max).
#
# Returns:
# bool: True if the parameter value falls within the range (inclusive), False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# if parameter_value is None:
# return False
# if not isinstance(parameter_value, int | float):
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
#
# return min_value <= parameter_value <= max_value if inclusive else min_value < parameter_value < max_value
#
# @staticmethod
# def is_parameter_value_in_list(speckle_object: Base, parameter_name: str, value_list: list[Any] | str) -> bool:
# """Checks if the value of the specified parameter is present in the given list of values.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# parameter_name (str): The name of the parameter to check.
# value_list (List[Any]): The list of values to check against.
#
# Returns:
# bool: True if the parameter value is found in the list, False otherwise.
# """
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
#
# if isinstance(value_list, str):
# value_list = [value.strip() for value in value_list.split(",")]
#
# # parameter_value is effectively Any type, so to find its value in the value_list
# def is_value_in_list(value: Any, my_list: Any) -> bool:
# # Ensure that my_list is actually a list
# if isinstance(my_list, list):
# return value in my_list or str(value) in my_list
# else:
# speckle_print(f"Expected a list, got {type(my_list)} instead.")
# return False
#
# return is_value_in_list(parameter_value, value_list)
#
# @staticmethod
# def _check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool:
# """Check if a value matches any target value in expected format."""
# if isinstance(value, bool):
# return value is (True if "true" in values_to_match else False)
#
# if isinstance(value, str):
# return value.lower() in values_to_match
#
# return False
#
# @staticmethod
# def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool:
# """Check if parameter value represents true (boolean True, 'yes', 'true', '1')."""
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# return PropertyRules._check_boolean_value(parameter_value, ("yes", "true", "1"))
#
# @staticmethod
# def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool:
# """Check if parameter value represents false (boolean False, 'no', 'false', '0')."""
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
# return PropertyRules._check_boolean_value(parameter_value, ("no", "false", "0"))
#
# @staticmethod
# def has_category(speckle_object: Base) -> bool:
# """Checks if the speckle_object has a 'category' parameter.
#
# This method checks if the speckle_object has a 'category' parameter.
# If the 'category' parameter exists, it returns True; otherwise, it returns False.
#
# Args:
# speckle_object (Base): The Speckle object to check.
#
# Returns:
# bool: True if the object has the 'category' parameter, False otherwise.
# """
# return PropertyRules.has_parameter(speckle_object, "category")
#
# @staticmethod
# def is_category(speckle_object: Base, category_input: str) -> bool:
# """Checks if the value of the 'category' property matches the given input.
#
# This method checks if the 'category' property of the speckle_object
# matches the given category_input. If they match, it returns True;
# otherwise, it returns False.
#
# Args:
# speckle_object (Base): The Speckle object to check.
# category_input (str): The category value to compare against.
#
# Returns:
# bool: True if the 'category' property matches the input, False otherwise.
# """
# category_value = PropertyRules.get_parameter_value(speckle_object, "category")
# return category_value == category_input
#
# @staticmethod
# def get_category_value(speckle_object: Base) -> str:
# """Retrieves the value of the 'category' parameter from the speckle_object.
#
# This method retrieves the value of the 'category' parameter from the speckle_object.
# If the 'category' parameter exists and its value is not None, it returns the value.
# If the 'category' parameter does not exist or its value is None, it returns an empty string.
#
# Args:
# speckle_object (Base): The Speckle object to retrieve the 'category' parameter value from.
#
# Returns:
# str: The value of the 'category' parameter if it exists and is not None, or an empty string otherwise.
# """
# return PropertyRules.get_parameter_value(speckle_object, "category")
#
#
# class ParameterSearch:
# """Unified parameter search functionality for Speckle objects."""
#
# @staticmethod
# def convert_revit_boolean(value: Any) -> Any:
# """Convert Revit-style Yes/No strings to boolean values.
#
# Args:
# value: The value to potentially convert
#
# Returns:
# bool if value is a Revit boolean string, original value otherwise
# """
# if isinstance(value, str):
# if value.lower() == "yes":
# return True
# if value.lower() == "no":
# return False
# return value
#
# @staticmethod
# def search_parameters(
# params: dict, search_name: str, mode: PropertyMatchMode = PropertyMatchMode.STRICT
# ) -> tuple[bool, Any]:
# """Search for parameters using consistent matching logic.
#
# Supports flexible property chain matching that can find paths like "Instance Parameters.Dimensions.Length"
# within longer chains like "properties.Parameters.Instance Parameters.Dimensions.Length.value".
# Uses STRICT matching by default for more predictable results.
#
# Args:
# params: Parameter dictionary to search
# search_name: Name of parameter to find, can be dot-separated chain
# mode: Matching mode to use (STRICT by default, or FUZZY/MIXED for looser matching)
#
# Returns:
# Tuple of (value_found: bool, value: Any)
# """
#
# def matches_name(match_key: str, target: str, match_mode: PropertyMatchMode) -> bool:
# if match_mode == PropertyMatchMode.STRICT:
# return match_key.lower() == target.lower()
# elif match_mode == PropertyMatchMode.FUZZY:
# return target.lower() in match_key.lower()
# else: # MIXED mode
# return match_key.lower() == target.lower() or target.lower() in match_key.lower()
#
# def try_get_value(obj: Any) -> Any:
# """Extract value from parameter object or return as is.
#
# Handles both dict and Base objects, checking for 'value' property in both cases.
# Returns the 'value' if found, otherwise returns the original object.
# """
# # Handle dictionary objects
# if isinstance(obj, dict):
# return obj.get("value", obj)
#
# # Handle Base objects
# if isinstance(obj, Base):
# return getattr(obj, "value", obj)
#
# # For all other types, return as is
# return obj
#
# # First try property chain lookup
# if "." in search_name:
# search_parts = search_name.split(".")
#
# def try_match_path(current: dict, remaining_search_parts: list[str], depth: int = 0) -> tuple[bool, Any]:
# if not isinstance(current, dict):
# return False, None
#
# if not remaining_search_parts: # We've matched all parts
# return True, try_get_value(current)
#
# current_search = remaining_search_parts[0]
#
# # Try each key at current level
# for key, item_value in current.items():
# if matches_name(key, current_search, mode):
# # Found a match for current part, recurse with rest
# match_found, result = try_match_path(item_value, remaining_search_parts[1:], depth + 1)
# if match_found:
# return True, result
#
# # If no match found and value is a dict, try searching deeper
# if isinstance(item_value, dict):
# match_found, result = try_match_path(item_value, remaining_search_parts, depth)
# if match_found:
# return True, result
#
# return False, None
#
# try:
# found, value = try_match_path(params, search_parts)
# if found:
# return True, value
# except Exception:
# pass # Fall through to recursive search if chain lookup fails
#
# # Recursive search through nested dictionaries
# def recursive_search(data: dict | Base, target: str) -> tuple[bool, Any]:
# if not isinstance(data, dict | Base):
# return False, None
#
# # Handle both dict and Base objects for iteration
# if isinstance(data, dict):
# items = data.items()
# else:
# items = [(k, getattr(data, k)) for k in dir(data) if not k.startswith("_")]
#
# # First check current level
# for key, item_value in items:
# if matches_name(key, target, mode):
# return True, try_get_value(item_value)
#
# # Then check nested levels
# for _, item_value in items:
# if isinstance(item_value, dict | Base):
# item_found, result = recursive_search(item_value, target)
# if item_found:
# return True, result
#
# return False, None
#
# return recursive_search(params, search_name.split(".")[-1] if "." in search_name else search_name)
#
# @staticmethod
# def lookup_parameter(
# obj: Base, param_name: str, mode: PropertyMatchMode = PropertyMatchMode.MIXED
# ) -> tuple[bool, Any]:
# """Unified parameter lookup for both checking existence and getting values.
#
# Args:
# obj: Speckle object to search
# param_name: Parameter name to find
# mode: Matching mode to use
#
# Returns:
# Tuple of (found: bool, value: Any)
# """
# # Check direct property first
# if has_item(obj, param_name):
# value = get_item(obj, param_name)
# # Check if the direct property has a value field
# if isinstance(value, dict) and "value" in value:
# return True, value["value"]
# return True, value
#
# # Handle v3 structure
# if PropertyRules.is_v3(obj):
# properties = get_item(obj, "properties")
# if not properties or not has_item(properties, "Parameters"):
# return False, None
#
# parameters = get_item(properties, "Parameters")
# if not parameters:
# return False, None
#
# return ParameterSearch.search_parameters(parameters, param_name, mode)
#
# # Handle v2 structure
# parameters = get_item(obj, "parameters")
# if not parameters:
# return False, None
#
# return ParameterSearch.search_parameters(parameters, param_name, mode)
+136 -8
View File
@@ -1,18 +1,146 @@
"""Module for reading and processing rules from a cloud hosted TSV file."""
import traceback
import pandas as pd
from pandas import DataFrame
from pandas.core.groupby import DataFrameGroupBy
def read_rules_from_spreadsheet(url):
"""Reads a TSV file from a provided URL and returns a DataFrame.
def process_rule_numbers(df: DataFrame) -> DataFrame:
"""Process rule numbers in a DataFrame while preserving original rule identifiers.
Makes no assumptions about rule number format - preserves them exactly as provided.
Only generates new numbers (as integers) when no rule number exists.
Args:
url (str): The URL to the TSV file.
df: DataFrame with columns including 'Rule Number' and 'Logic'
Returns:
DataFrame: Pandas DataFrame containing the TSV data.
DataFrame with processed rule numbers
"""
# Create a copy to avoid modifying original
df = df.copy()
# Initialize tracking variables
used_rule_nums = set()
processed_rule_nums = []
next_auto_num = 1 # For generating missing rule numbers only
# Find indices where Logic is 'WHERE' to identify rule group starts
where_indices = df[df["Logic"].str.upper() == "WHERE"].index
# Process each group
for i in range(len(where_indices)):
start_idx = where_indices[i]
end_idx = where_indices[i + 1] if i + 1 < len(where_indices) else len(df)
# Get slice of rows for this group
group_slice = df.iloc[start_idx:end_idx]
# Try to get rule number from first row
group_rule_num = group_slice["Rule Number"].iloc[0]
if pd.isna(group_rule_num):
# If no rule number, generate next available number
while str(next_auto_num) in used_rule_nums:
next_auto_num += 1
group_rule_num = str(next_auto_num)
next_auto_num += 1
else:
# Keep the original rule number exactly as is
group_rule_num = str(group_rule_num)
# Update tracking
used_rule_nums.add(group_rule_num)
# Fill rule numbers for this group
processed_rule_nums.extend([group_rule_num] * len(group_slice))
# Update DataFrame with processed rule numbers
df["Rule Number"] = processed_rule_nums
return df
def validate_rule_numbers(df: DataFrame) -> list[str]:
"""Validate rule numbers and return any warnings or errors.
Args:
df: DataFrame with processed rule numbers
Returns:
List of warning/error messages
"""
messages = []
# Check for missing rule numbers
if df["Rule Number"].isna().any():
messages.append("Warning: Some rules are missing rule numbers")
# # Check for non-integer rule numbers
# non_int_mask = df["Rule Number"].apply(lambda x: not pd.isna(x) and not float(x).is_integer())
# if non_int_mask.any():
# messages.append("Warning: Some rule numbers are not integers")
# Check for duplicate rule numbers in WHERE rows
where_rules = df[df["Logic"].str.upper() == "WHERE"]["Rule Number"]
duplicates = where_rules[where_rules.duplicated()]
if not duplicates.empty:
messages.append(f"Warning: Duplicate rule numbers found: {list(duplicates)}")
return messages
def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] | tuple[None, list[str]]:
"""Reads a TSV file from a provided URL, processes rule numbers, and returns grouped rules.
Args:
url (str): The URL to the TSV file
Returns:
Tuple containing:
- DataFrameGroupBy object with rules grouped by rule number (or None if error)
- List of validation messages/warnings
"""
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")
# Read the TSV file
df = pd.read_csv(url, sep="\t")
# Convert mixed type columns
df = convert_mixed_columns(df)
# Process rule numbers
df = process_rule_numbers(df)
# Get validation messages
messages = validate_rule_numbers(df)
# Group by rule number
grouped_rules = df.groupby("Rule Number")
return grouped_rules, messages
except Exception as e:
print(f"Failed to read the TSV from the URL: {e}")
return None
traceback.print_exc()
return None, [f"Failed to read the TSV from the URL: {str(e)}:{e.with_traceback(None)}"]
def convert_mixed_columns(df: DataFrame) -> DataFrame:
"""Converts columns in a DataFrame to appropriate types based on their content.
null or empty strings are converted to empty strings instead of NaN.
Args:
df (DataFrame): The DataFrame whose columns are to be converted
Returns:
DataFrame with columns converted to appropriate types
"""
df = df.apply(
lambda c: c
if c.dropna().apply(lambda x: str(x).replace(".", "", 1).isdigit()).any()
else c.map(lambda x: "" if pd.isna(x) else str(x))
)
return df
File diff suppressed because it is too large Load Diff
+696
View File
@@ -0,0 +1,696 @@
[
{
"id": "46f06fef727d64a0bbcbd7ced51e0cd2",
"name": "Walls - W30(Fc24)",
"type": "W30(Fc24)",
"level": {
"name": "1FL",
"units": "mm",
"elevation": 0
},
"units": "mm",
"family": "Basic Wall",
"flipped": false,
"category": "Walls",
"elements": [],
"location": {
"id": "9c76b8de34382c9052965ee463f8374b",
"end": {
"x": 22400.000000000015,
"y": 20500,
"z": 0,
"id": "3455575bfd8939f264d295b61e74156f",
"units": "mm",
"speckle_type": "Objects.Geometry.Point",
"applicationId": null
},
"bbox": null,
"start": {
"x": 22400.000000000007,
"y": 15199.999999999998,
"z": 0,
"id": "d0c4fdb2e11cc825e7f05f9dc88a0be1",
"units": "mm",
"speckle_type": "Objects.Geometry.Point",
"applicationId": null
},
"units": "mm",
"domain": {
"id": "3b97feaad2dbcc2d894c9cec024a9bf2",
"end": 17.388451443569522,
"start": -3.552713668866051e-14,
"speckle_type": "Objects.Primitive.Interval",
"applicationId": null
},
"length": 5300.000000000002,
"speckle_type": "Objects.Geometry.Line",
"applicationId": null
},
"topLevel": {
"name": "1FL",
"units": "mm",
"elevation": 0
},
"__closure": {
"0ad4db1fe261a5b640ad9f315a46a6fc": 100,
"0d5518a7a3e63fe345e198ad5d6acc4e": 100,
"ec2040af4bd9c8619f9029a43df61a2e": 100
},
"elementId": "4479852",
"worksetId": "0",
"properties": {
"Parameters": {
"Type Parameters": {
"Text": {
"符号": {
"name": "符号",
"value": "W30",
"internalDefinitionName": "ee1f33e1-5506-4a64-b87b-7b98d30aea52"
}
},
"Other": {
"Family Name": {
"name": "Family Name",
"value": "Basic Wall",
"internalDefinitionName": "SYMBOL_FAMILY_NAME_PARAM"
},
"横筋ピッチ": {
"name": "横筋ピッチ",
"units": "General",
"value": 0,
"internalDefinitionName": "cd19c040-0788-4424-8a67-7127427e311f"
},
"縦筋ピッチ": {
"name": "縦筋ピッチ",
"units": "General",
"value": 0,
"internalDefinitionName": "6796d143-ef09-4441-9ecc-3411fe837a65"
}
},
"Rebar Set": {
"横筋 主筋径1": {
"name": "横筋 主筋径1",
"value": "D13",
"internalDefinitionName": "横筋 主筋径1"
},
"横筋 主筋径2": {
"name": "横筋 主筋径2",
"value": "D16",
"internalDefinitionName": "横筋 主筋径2"
},
"縦筋 主筋径1": {
"name": "縦筋 主筋径1",
"value": "D13",
"internalDefinitionName": "縦筋 主筋径1"
},
"縦筋 主筋径2": {
"name": "縦筋 主筋径2",
"value": "D16",
"internalDefinitionName": "縦筋 主筋径2"
},
"横筋 主筋ピッチ": {
"name": "横筋 主筋ピッチ",
"value": "200",
"internalDefinitionName": "横筋 主筋ピッチ"
},
"縦筋 主筋ピッチ": {
"name": "縦筋 主筋ピッチ",
"value": "200",
"internalDefinitionName": "縦筋 主筋ピッチ"
},
"開口補強筋 斜筋 本数": {
"name": "開口補強筋 斜筋 本数",
"value": "―",
"internalDefinitionName": "開口補強筋 斜筋 本数"
},
"開口補強筋 横筋 本数": {
"name": "開口補強筋 横筋 本数",
"value": "―",
"internalDefinitionName": "開口補強筋 横筋 本数"
},
"開口補強筋 縦筋 本数": {
"name": "開口補強筋 縦筋 本数",
"value": "―",
"internalDefinitionName": "開口補強筋 縦筋 本数"
}
},
"Structure": {
"Fc24 (0)": {
"units": "mm",
"function": "Structure",
"material": "Fc24",
"thickness": 300
}
},
"Construction": {
"Width": {
"name": "Width",
"units": "Millimeters",
"value": 300,
"internalDefinitionName": "WALL_ATTR_WIDTH_PARAM"
},
"Function": {
"name": "Function",
"value": "Interior",
"internalDefinitionName": "FUNCTION_PARAM"
},
"Wrapping at Ends": {
"name": "Wrapping at Ends",
"value": "None",
"internalDefinitionName": "WRAPPING_AT_ENDS_PARAM"
},
"Wrapping at Inserts": {
"name": "Wrapping at Inserts",
"value": "Do not wrap",
"internalDefinitionName": "WRAPPING_AT_INSERTS_PARAM"
}
},
"Identity Data": {
"Cost": {
"name": "Cost",
"units": "Currency",
"value": 0,
"internalDefinitionName": "ALL_MODEL_COST"
},
"Type Name": {
"name": "Type Name",
"value": "W30(Fc24)",
"internalDefinitionName": "SYMBOL_NAME_PARAM"
}
},
"IFC Parameters": {
"Type IfcGUID": {
"name": "Type IfcGUID",
"value": "0oqavz9$zDZxycDyfZdYh0",
"internalDefinitionName": "IFC_TYPE_GUID"
},
"Export Type to IFC": {
"name": "Export Type to IFC",
"value": "Default",
"internalDefinitionName": "IFC_EXPORT_ELEMENT_TYPE"
}
},
"Analytical Properties": {
"Roughness": {
"name": "Roughness",
"value": 1,
"internalDefinitionName": "ANALYTICAL_ROUGHNESS"
},
"Absorptance": {
"name": "Absorptance",
"units": "General",
"value": 0.1,
"internalDefinitionName": "ANALYTICAL_ABSORPTANCE"
},
"Thermal Mass": {
"name": "Thermal Mass",
"units": "Kilojoules per square meter Kelvin",
"value": 0,
"internalDefinitionName": "ANALYTICAL_THERMAL_MASS"
},
"Thermal Resistance (R)": {
"name": "Thermal Resistance (R)",
"units": "Square meter kelvins per watt",
"value": 0,
"internalDefinitionName": "ANALYTICAL_THERMAL_RESISTANCE"
},
"Heat Transfer Coefficient (U)": {
"name": "Heat Transfer Coefficient (U)",
"units": "Watts per square meter kelvin",
"value": 0,
"internalDefinitionName": "ANALYTICAL_HEAT_TRANSFER_COEFFICIENT"
}
},
"Materials and Finishes": {
"Structural Material": {
"name": "Structural Material",
"value": "Fc24",
"internalDefinitionName": "STRUCTURAL_MATERIAL_PARAM"
}
}
},
"Instance Parameters": {
"Other": {
"Type": {
"name": "Type",
"value": "W30(Fc24)",
"internalDefinitionName": "ELEM_TYPE_PARAM"
},
"Family": {
"name": "Family",
"value": "Basic Wall",
"internalDefinitionName": "ELEM_FAMILY_PARAM"
},
"Type Id": {
"name": "Type Id",
"value": "Basic Wall W30(Fc24)",
"internalDefinitionName": "SYMBOL_ID_PARAM"
},
"Family and Type": {
"name": "Family and Type",
"value": "Basic Wall W30(Fc24)",
"internalDefinitionName": "ELEM_FAMILY_AND_TYPE_PARAM"
}
},
"Phasing": {
"Phase Created": {
"name": "Phase Created",
"value": "フェーズ1",
"internalDefinitionName": "PHASE_CREATED"
}
},
"Dimensions": {
"Area": {
"name": "Area",
"units": "Square meters",
"value": 7.630000000000015,
"internalDefinitionName": "HOST_AREA_COMPUTED"
},
"Length": {
"name": "Length",
"units": "Millimeters",
"value": 5300.000000000001,
"internalDefinitionName": "CURVE_ELEM_LENGTH"
},
"Volume": {
"name": "Volume",
"units": "Cubic meters",
"value": 2.2890000000000135,
"internalDefinitionName": "HOST_VOLUME_COMPUTED"
}
},
"Structural": {
"Structural": {
"name": "Structural",
"value": "Yes",
"internalDefinitionName": "WALL_STRUCTURAL_SIGNIFICANT"
},
"Structural Usage": {
"name": "Structural Usage",
"value": "Bearing",
"internalDefinitionName": "WALL_STRUCTURAL_USAGE_PARAM"
},
"Rebar Cover - Other Faces": {
"name": "Rebar Cover - Other Faces",
"value": "Rebar Cover Settings 内壁(フレーム、柱、および耐力壁)",
"internalDefinitionName": "CLEAR_COVER_OTHER"
},
"Rebar Cover - Exterior Face": {
"name": "Rebar Cover - Exterior Face",
"value": "Rebar Cover Settings 内壁(フレーム、柱、および耐力壁)",
"internalDefinitionName": "CLEAR_COVER_EXTERIOR"
},
"Rebar Cover - Interior Face": {
"name": "Rebar Cover - Interior Face",
"value": "Rebar Cover Settings 内壁(フレーム、柱、および耐力壁)",
"internalDefinitionName": "CLEAR_COVER_INTERIOR"
},
"構造スリット 下端": {
"name": "構造スリット 下端",
"value": "No",
"internalDefinitionName": "0560d1bb-007b-45e4-b8c3-87f9c6a3d73c"
},
"構造スリット 始端": {
"name": "構造スリット 始端",
"value": "No",
"internalDefinitionName": "60795a90-8c91-4e11-90a0-b77b819c2403"
},
"構造スリット 終端": {
"name": "構造スリット 終端",
"value": "No",
"internalDefinitionName": "e3223f14-6158-4d4f-8697-0de2bcc7f737"
}
},
"Constraints": {
"Top Offset": {
"name": "Top Offset",
"units": "Millimeters",
"value": -600,
"internalDefinitionName": "WALL_TOP_OFFSET"
},
"Base Offset": {
"name": "Base Offset",
"units": "Millimeters",
"value": -2000,
"internalDefinitionName": "WALL_BASE_OFFSET"
},
"Location Line": {
"name": "Location Line",
"value": "Core Centerline",
"internalDefinitionName": "WALL_KEY_REF_PARAM"
},
"Room Bounding": {
"name": "Room Bounding",
"value": "Yes",
"internalDefinitionName": "WALL_ATTR_ROOM_BOUNDING"
},
"Top Constraint": {
"name": "Top Constraint",
"value": "1FL",
"internalDefinitionName": "WALL_HEIGHT_TYPE"
},
"Base Constraint": {
"name": "Base Constraint",
"value": "1FL",
"internalDefinitionName": "WALL_BASE_CONSTRAINT"
},
"Related to Mass": {
"name": "Related to Mass",
"value": "No",
"internalDefinitionName": "RELATED_TO_MASS"
},
"Top is Attached": {
"name": "Top is Attached",
"value": "No",
"internalDefinitionName": "WALL_TOP_IS_ATTACHED"
},
"Base is Attached": {
"name": "Base is Attached",
"value": "No",
"internalDefinitionName": "WALL_BOTTOM_IS_ATTACHED"
},
"Unconnected Height": {
"name": "Unconnected Height",
"units": "Millimeters",
"value": 1400,
"internalDefinitionName": "WALL_USER_HEIGHT_PARAM"
},
"Top Extension Distance": {
"name": "Top Extension Distance",
"units": "Millimeters",
"value": 0,
"internalDefinitionName": "WALL_TOP_EXTENSION_DIST_PARAM"
},
"Base Extension Distance": {
"name": "Base Extension Distance",
"units": "Millimeters",
"value": 0,
"internalDefinitionName": "WALL_BOTTOM_EXTENSION_DIST_PARAM"
}
},
"Identity Data": {
"Has Association": {
"name": "Has Association",
"value": "Yes",
"internalDefinitionName": "ANALYTICAL_ELEMENT_HAS_ASSOCIATION"
},
"SPECKLE_Classification": {
"name": "SPECKLE_Classification",
"value": "Wall",
"internalDefinitionName": "SPECKLE_Classification"
}
},
"IFC Parameters": {
"IfcGUID": {
"name": "IfcGUID",
"value": "0oqavz9$zDZxycDyfZdYsW",
"internalDefinitionName": "IFC_GUID"
},
"Export to IFC": {
"name": "Export to IFC",
"value": "By Type",
"internalDefinitionName": "IFC_EXPORT_ELEMENT"
}
},
"Cross-Section Definition": {
"Cross-Section": {
"name": "Cross-Section",
"value": "Vertical",
"internalDefinitionName": "WALL_CROSS_SECTION"
}
}
}
},
"Material Quantities": {
"Fc24": {
"area": 7630000.000000016,
"units": "mm",
"volume": 2289000000.0000134,
"materialName": "Fc24",
"materialClass": "コンクリート",
"materialCategory": "コンクリート"
}
}
},
"worksetName": "ワークセット1",
"displayValue": [
{
"__closure": null,
"referencedId": "ec2040af4bd9c8619f9029a43df61a2e",
"speckle_type": "reference"
}
],
"isStructural": true,
"speckle_type": "Objects.Data.DataObject:Objects.Data.RevitObject",
"applicationId": "32d24e7d-27ff-4d8f-bf26-37ca63da76cc-00445b6c",
"builtInCategory": "OST_Walls",
"totalChildrenCount": null
},
{
"id": "0ad4db1fe261a5b640ad9f315a46a6fc",
"data": [
3,
5,
0,
1,
3,
5,
1,
4,
3,
4,
2,
3,
3,
2,
4,
1,
3,
11,
12,
9,
3,
9,
10,
11,
3,
12,
13,
9,
3,
13,
6,
8,
3,
7,
8,
6,
3,
13,
8,
9,
3,
15,
16,
18,
3,
14,
15,
19,
3,
17,
18,
16,
3,
19,
20,
14,
3,
18,
19,
15,
3,
21,
22,
23,
3,
23,
24,
21,
3,
26,
27,
29,
3,
25,
26,
30,
3,
28,
29,
27,
3,
30,
31,
25,
3,
29,
30,
26,
3,
32,
33,
34,
3,
34,
35,
32
],
"speckle_type": "Speckle.Core.Models.DataChunk",
"applicationId": null
},
{
"id": "0d5518a7a3e63fe345e198ad5d6acc4e",
"data": [
22550.00031738281,
15049.999969482422,
-2000.000015258789,
22550.00031738281,
20274.999645996093,
-2000.000015258789,
22550.00031738281,
20499.999865722657,
-2000.000015258789,
22550.00031738281,
20499.999865722657,
-600.0000045776368,
22550.00031738281,
20274.999645996093,
-600.0000045776368,
22550.00031738281,
15049.999969482422,
-600.0000045776368,
22250.000024414065,
15049.999969482422,
-2000.000015258789,
22250.000024414065,
15049.999969482422,
-600.0000045776368,
22250.000024414065,
15350.000262451173,
-600.0000045776368,
22250.000024414065,
20274.999645996093,
-600.0000045776368,
22250.000024414065,
20499.999865722657,
-600.0000045776368,
22250.000024414065,
20499.999865722657,
-2000.000015258789,
22250.000024414065,
20274.999645996093,
-2000.000015258789,
22250.000024414065,
15350.000262451173,
-2000.000015258789,
22550.00031738281,
20499.999865722657,
-2000.000015258789,
22550.00031738281,
20274.999645996093,
-2000.000015258789,
22550.00031738281,
15049.999969482422,
-2000.000015258789,
22250.000024414065,
15049.999969482422,
-2000.000015258789,
22250.000024414065,
15350.000262451173,
-2000.000015258789,
22250.000024414065,
20274.999645996093,
-2000.000015258789,
22250.000024414065,
20499.999865722657,
-2000.000015258789,
22550.00031738281,
20499.999865722657,
-2000.000015258789,
22250.000024414065,
20499.999865722657,
-2000.000015258789,
22250.000024414065,
20499.999865722657,
-600.0000045776368,
22550.00031738281,
20499.999865722657,
-600.0000045776368,
22550.00031738281,
15049.999969482422,
-600.0000045776368,
22550.00031738281,
20274.999645996093,
-600.0000045776368,
22550.00031738281,
20499.999865722657,
-600.0000045776368,
22250.000024414065,
20499.999865722657,
-600.0000045776368,
22250.000024414065,
20274.999645996093,
-600.0000045776368,
22250.000024414065,
15350.000262451173,
-600.0000045776368,
22250.000024414065,
15049.999969482422,
-600.0000045776368,
22550.00031738281,
15049.999969482422,
-600.0000045776368,
22250.000024414065,
15049.999969482422,
-600.0000045776368,
22250.000024414065,
15049.999969482422,
-2000.000015258789,
22550.00031738281,
15049.999969482422,
-2000.000015258789
],
"speckle_type": "Speckle.Core.Models.DataChunk",
"applicationId": null
},
{
"id": "ec2040af4bd9c8619f9029a43df61a2e",
"area": 0,
"bbox": null,
"faces": [
{
"__closure": null,
"referencedId": "0ad4db1fe261a5b640ad9f315a46a6fc",
"speckle_type": "reference"
}
],
"units": "mm",
"colors": [],
"volume": 0,
"vertices": [
{
"__closure": null,
"referencedId": "0d5518a7a3e63fe345e198ad5d6acc4e",
"speckle_type": "reference"
}
],
"__closure": {
"0ad4db1fe261a5b640ad9f315a46a6fc": 100,
"0d5518a7a3e63fe345e198ad5d6acc4e": 100
},
"speckle_type": "Objects.Geometry.Mesh",
"applicationId": "d2c33253-d0ed-4682-9633-e083daaa87db",
"textureCoordinates": []
}
]
+126 -18
View File
@@ -1,24 +1,132 @@
import os
from dotenv import load_dotenv
import pytest
from specklepy.objects.base import Base
def pytest_configure(config):
load_dotenv(dotenv_path=".env")
@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
token_var = "SPECKLE_TOKEN"
server_var = "SPECKLE_SERVER_URL"
token = os.getenv(token_var)
server = os.getenv(server_var)
# 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
if not token:
raise ValueError(f"Cannot run tests without a {token_var} environment variable")
# Create parameters structure
wall.parameters = Base()
if not server:
raise ValueError(
f"Cannot run tests without a {server_var} environment variable"
)
# 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"
# Set the token as an attribute on the config object
config.SPECKLE_TOKEN = token
config.SPECKLE_SERVER_URL = server
# 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
def v3_wall():
"""Creates a v3-style Speckle wall object."""
wall = Base()
wall.id = "46f06fef727d64a0bbcbd7ced51e0cd2"
wall.name = "Walls - W30(Fc24)"
wall.type = "W30(Fc24)"
wall.units = "mm"
wall.family = "Basic Wall"
wall.flipped = False
wall.category = "Walls"
wall.elementId = "4479852"
wall.worksetId = "0"
# Create location geometry
wall.location = Base()
wall.location.start = Base()
wall.location.start.x = 22400.000000000007
wall.location.start.y = 15199.999999999998
wall.location.start.z = 0
wall.location.end = Base()
wall.location.end.x = 22400.000000000015
wall.location.end.y = 20500
wall.location.end.z = 0
wall.location.units = "mm"
wall.location.length = 5300.000000000002
# Create nested properties structure
wall.properties = Base()
wall.properties.Parameters = Base()
# Type Parameters
wall.properties.Parameters["Type Parameters"] = Base()
# Add Text section with GUID parameter
wall.properties.Parameters["Type Parameters"].Text = Base()
wall.properties.Parameters["Type Parameters"].Text["符号"] = {
"name": "符号",
"value": "W30",
"internalDefinitionName": "ee1f33e1-5506-4a64-b87b-7b98d30aea52",
}
wall.properties.Parameters["Type Parameters"].Structure = Base()
wall.properties.Parameters["Type Parameters"].Structure["Fc24 (0)"] = {
"units": "mm",
"function": "Structure",
"material": "Fc24",
"thickness": 300,
}
# Instance Parameters
wall.properties.Parameters["Instance Parameters"] = Base()
wall.properties.Parameters["Instance Parameters"].Structural = Base()
wall.properties.Parameters["Instance Parameters"].Structural.Structural = {"name": "Structural", "value": "Yes"}
# Create basic level references
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
-32
View File
@@ -1,32 +0,0 @@
"""Run integration tests with a speckle server."""
from speckle_automate import (
AutomationContext,
AutomationRunData,
AutomationStatus,
run_function
)
from src.inputs import FunctionInputs
from src.function import automate_function
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
)
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(
automation_context,
automate_function,
FunctionInputs(spreadsheet_url=default_url),
)
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
# cli command to run just this test with pytest: pytest tests/local_test_exercise2.py::test_function_run
+86
View File
@@ -0,0 +1,86 @@
from typing import Any
import pytest
from src.rules import PropertyRules
class TestValueComparison:
"""Test suite for value comparison functionality."""
@pytest.mark.parametrize(
"value1, value2",
[
# Basic numeric strings
("1400", 1400.0),
("1400.0", 1400),
("1400.00", 1400),
# Whitespace handling
(" 1400 ", 1400.0),
(" 1400 ", 1400.0),
("\t1400\n", 1400.0),
# Negative numbers
("-1400", -1400.0),
(" -1400 ", -1400.0),
("-1400.0", -1400),
# Zero handling
("0", 0.0),
("-0", 0.0),
("0.0", 0),
# Simple integers
("1", 1),
("1.0", 1),
],
)
def test_numeric_string_comparison(self, value1: Any, value2: Any):
"""Test comparison of numeric strings with numbers."""
assert PropertyRules.compare_values(value1, value2)
# Test reverse comparison
assert PropertyRules.compare_values(value2, value1)
@pytest.mark.parametrize(
"value1, value2, expected",
[
("Yes", True, True),
("No", False, True),
("yes", True, True),
("no", False, True),
("YES", True, True),
("NO", False, True),
("true", True, True),
("false", False, True),
("True", True, True),
("False", False, True),
],
)
def test_boolean_string_comparison(self, value1: str, value2: bool, expected: bool):
"""Test comparison of boolean strings with booleans."""
assert PropertyRules.compare_values(value1, value2) == expected
# Test reverse comparison
assert PropertyRules.compare_values(value2, value1) == expected
@pytest.mark.parametrize(
"value1, value2, case_sensitive, expected",
[
("hello", "HELLO", False, True),
("hello", "HELLO", True, False),
("Hello", "hello", False, True),
("Hello", "Hello", True, True),
],
)
def test_string_comparison(self, value1: str, value2: str, case_sensitive: bool, expected: bool):
"""Test string comparison with case sensitivity options."""
assert PropertyRules.compare_values(value1, value2, case_sensitive=case_sensitive) == expected
@pytest.mark.parametrize(
"value1, value2, tolerance, expected",
[
(1.0001, 1.0, 1e-3, True),
(1.0001, 1.0, 1e-6, False),
(1.00000001, 1.0, 1e-6, True),
(-1.0001, -1.0, 1e-3, True),
],
)
def test_float_comparison_tolerance(self, value1: float, value2: float, tolerance: float, expected: bool):
"""Test float comparison with different tolerance levels."""
assert PropertyRules.compare_values(value1, value2, tolerance=tolerance) == expected
+30 -20
View File
@@ -1,31 +1,41 @@
"""Run integration tests with a speckle server."""
from pydantic import SecretStr
from speckle_automate import (
AutomationContext,
AutomationRunData,
AutomationStatus,
run_function
run_function,
)
from speckle_automate.fixtures import * # noqa: F401, F403
from main import FunctionInputs, automate_function
from speckle_automate.fixtures import *
from inputs import MinimumSeverity
from src.function import automate_function
from src.helpers import speckle_print
from src.inputs import FunctionInputs
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"),
),
)
class TestFunction:
"""Test suite for the automate function."""
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
def test_function_run(self, test_automation_run_data: AutomationRunData, test_automation_token: str):
"""Run an integration test for the automate function.
Args:
test_automation_run_data (AutomationRunData): The automation run data provided by sdk.
test_automation_token (str): The automation token.
"""
speckle_print(str(test_automation_run_data))
speckle_print(str(test_automation_token))
"""Run an integration test for the automate function."""
automation_context = AutomationContext.initialize(test_automation_run_data, test_automation_token)
default_url: str = "https://drive.google.com/uc?export=download&id=1hiPSw23eOaqd27QD_YsXvZg9PWm7_XBx"
automate_sdk = run_function(
automation_context,
automate_function,
FunctionInputs(spreadsheet_url=default_url, minimum_severity=MinimumSeverity.WARNING, hide_skipped=True),
)
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
+417
View File
@@ -0,0 +1,417 @@
"""Test suite for parameter handling functionality."""
import os
from typing import Any
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.transports.server import ServerTransport
from helpers import speckle_print
from src.rules import PropertyRules
class TestParameterHandling:
"""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
def test_objects(self, v2_wall: Any, v3_wall: Any) -> tuple[Base, Base]:
"""Pytest fixture to provide test objects."""
return self.load_test_objects(v2_wall, v3_wall)
def test_deserialization_structure(self, test_objects):
"""Test that objects are properly deserialized with correct structure."""
v2_obj, v3_obj = test_objects
# Check base class type
for obj in [v2_obj, v3_obj]:
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
assert hasattr(v3_obj, "properties"), "v3_obj should have 'properties' attribute"
assert v3_obj["properties"] is not None, "v3_obj['properties'] should not be None"
assert "Parameters" in v3_obj["properties"], "'Parameters' key should exist in v3_obj['properties']"
@pytest.mark.parametrize(
"param_name, expected_result",
[
("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(
"param_name, expected_result",
[
("category", True), # Test parameters that should exist
("Width", True), # Test nested parameters
("non_existent_param", False), # Test non-existent parameters
],
)
def test_v3_parameter_exists(self, test_objects, param_name, expected_result):
"""Test parameter existence checking in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result
@pytest.mark.parametrize(
"param_name_1, param_name_2",
[
(
"properties.Parameters.Instance Parameters.Dimensions.Length.value",
"Instance Parameters.Dimensions.Length",
),
],
)
def test_v3_parameter_search_equivalence(self, test_objects, param_name_1, param_name_2):
"""Test parameter existence checking equivalence in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.get_parameter_value(v3_obj, param_name_1) == PropertyRules.get_parameter_value(
v3_obj, param_name_2
)
@pytest.mark.parametrize(
"obj_version, param_name, expected_value, default_value",
[
# Test direct parameters
("v2", "category", "Walls", None),
("v3", "category", "Walls", None),
# Test nested parameters - using both internal and friendly names
("v2", "WALL_ATTR_WIDTH_PARAM", 300, None),
("v3", "Construction.Width", 300, None),
# Test parameters with units
("v2", "CURVE_ELEM_LENGTH", 5300.000000000001, None),
("v3", "Instance Parameters.Dimensions.Length", 5300.000000000001, None),
# Test non-existent parameters with a default value
("v2", "parameters.non_existent", "default", "default"),
("v3", "properties.Parameters.non_existent", "default", "default"),
],
)
def test_parameter_value_retrieval(self, test_objects, obj_version, param_name, expected_value, default_value):
"""Test parameter value retrieval from both v2 and v3 objects."""
v2_obj, v3_obj = test_objects
obj = v2_obj if obj_version == "v2" else v3_obj
result = PropertyRules.get_parameter_value(obj, param_name, default_value=default_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(
"param_name, expected_value, expected_result",
[
("category", "Walls", True), # Test exact match
("Width", 300, True), # Test numeric match
("category", "Windows", False), # Test non-match
],
)
def test_v3_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result):
"""Test parameter value matching in v3 objects."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value(v3_obj, param_name, expected_value) == expected_result
@pytest.mark.parametrize(
"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_in_range, "WALL_ATTR_WIDTH_PARAM", "200,400", True), # Test in range
],
)
def test_v2_parameter_numeric_comparisons(self, test_objects, comparison_func, param_name, value, expected_result):
"""Test numeric parameter comparisons in v2 objects."""
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."""
_, v3_obj = test_objects
assert comparison_func(v3_obj, param_name, value) == 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_v2_parameter_value_like(self, test_objects, param_name, pattern, fuzzy, expected_result):
"""Test pattern matching on parameter values in v2 objects."""
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."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_like(v3_obj, param_name, pattern, fuzzy=fuzzy) == expected_result
@pytest.mark.parametrize(
"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_v2_parameter_lists(self, test_objects, param_name, valid_list, expected_result):
"""Test list-based parameter checks in v2 objects."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_in_list(v2_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"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."""
_, v3_obj = test_objects
assert PropertyRules.is_parameter_value_in_list(v3_obj, param_name, valid_list) == expected_result
@pytest.mark.parametrize(
"param_name, expected_result",
[
("WALL_ATTR_ROOM_BOUNDING.value", True), # Test true values
("wall_top_is_attached", False), # Test false values
],
)
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(
"param_name, expected_result",
[
("Room Bounding", True), # Test true values
("top is attached", False), # Test false values
("Top is Attached", False), # Case sensitivity test
],
)
def test_v3_boolean_parameters(self, test_objects, param_name, expected_result):
"""Test boolean parameter checks in v3 objects."""
_, v3_obj = test_objects
if expected_result:
assert PropertyRules.is_parameter_value_true(v3_obj, param_name)
else:
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(
"attribute, value, expected",
[
# Test numeric value comparisons
("Type Parameters.Structure.Fc24 (0).thickness", 300, True),
("location.length", 5300.000000000002, True),
("location.length", 5300, True),
# Test string value comparisons
("Type Parameters.Text.符号.value", "W30", True),
("Instance Parameters.Structural.Structural.value", "Yes", True),
# Test non-matches
("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):
"""Test value comparisons using v3 wall parameters."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected
@pytest.mark.parametrize(
"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", "Type Parameters.Structure.Fc24 (0).thickness", 300, 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", "location.length", 5300, False),
],
)
def test_identical_comparisons(self, request, wall, attribute, value, expected):
"""Test identical value comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_identical_value(wall_instance, attribute, value) == expected
@pytest.mark.parametrize(
"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", "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):
"""Test not equal comparisons on both wall versions."""
wall_instance = request.getfixturevalue(wall)
assert PropertyRules.is_not_equal_value(wall_instance, attribute, value)
@pytest.mark.parametrize(
"attribute, value, expected_equal, expected_identical",
[
# 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", "yes", True, False), # Yes vs "yes"
],
)
def test_boolean_conversions(self, v3_wall, attribute, value, expected_equal, expected_identical):
"""Test conversion of Yes/No strings to boolean values."""
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected_equal
assert PropertyRules.is_identical_value(v3_wall, attribute, value) == expected_identical
@pytest.mark.parametrize(
"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", "Type Parameters.Structure.Fc24 (0).thickness", "300"),
("v3_wall", "location.length", "5300.000000000002"),
],
)
def test_numeric_string_handling(self, wall, attribute, expected_value, request):
"""Test handling of numeric strings in both wall versions."""
wall_instance = request.getfixturevalue(wall) # Retrieve fixture dynamically
assert PropertyRules.is_equal_value(wall_instance, attribute, expected_value)
@pytest.mark.parametrize(
"param_name, substring, expected_result",
[
("speckle_type", "Revit", True), # Test basic substring match
("speckle_type", "revit", True), # Test case-insensitive
("speckle_type", "NotPresent", False), # Test no match
("speckle_type", "", True), # Test empty string
("non_existent", "anything", False), # Test non-existent parameter
],
)
def test_parameter_value_contains(self, test_objects, param_name, substring, expected_result):
"""Test substring matching on parameter values."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_containing(v2_obj, param_name, substring) == expected_result
@pytest.mark.parametrize(
"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."""
v2_obj, _ = test_objects
assert PropertyRules.is_parameter_value_not_containing(v2_obj, param_name, substring) == expected_result
+36
View File
@@ -0,0 +1,36 @@
"""Tests for rule processing functionality."""
import pandas as pd
import pytest
@pytest.fixture
def explicit_check_rule():
"""Create a rule using explicit CHECK format."""
return pd.DataFrame(
{
"Rule Number": [1, 1, 1],
"Logic": ["WHERE", "AND", "CHECK"],
"Property Name": ["category", "width", "material"],
"Predicate": ["matches", "greater than", "matches"],
"Value": ["Walls", "200", "Concrete"],
"Message": ["Test message", "", ""],
"Severity": ["Error", "", ""],
}
)
@pytest.fixture
def legacy_rule():
"""Create a rule using legacy format (last AND is implicit check)."""
return pd.DataFrame(
{
"Rule Number": [1, 1, 1],
"Logic": ["WHERE", "AND", "AND"],
"Property Name": ["category", "width", "material"],
"Predicate": ["matches", "greater than", "matches"],
"Value": ["Walls", "200", "Concrete"],
"Message": ["Test message", "", ""],
"Severity": ["Error", "", ""],
}
)
+33
View File
@@ -0,0 +1,33 @@
import pandas as pd
import pytest
from src.rule_processor import SeverityLevel, get_severity
@pytest.mark.parametrize(
"input_severity, expected_enum",
[
("INFO", SeverityLevel.INFO),
("info", SeverityLevel.INFO),
("Info", SeverityLevel.INFO),
("WARNING", SeverityLevel.WARNING),
("warning", SeverityLevel.WARNING),
("Warning", SeverityLevel.WARNING),
("ERROR", SeverityLevel.ERROR),
("error", SeverityLevel.ERROR),
("Error", SeverityLevel.ERROR),
("WARN", SeverityLevel.WARNING),
("warn", SeverityLevel.WARNING),
("Critical", SeverityLevel.ERROR), # Invalid → Defaults to ERROR
("Severe", SeverityLevel.ERROR), # Invalid → Defaults to ERROR
("", SeverityLevel.ERROR), # Empty string → Defaults to ERROR
(None, SeverityLevel.ERROR), # None → Defaults to ERROR
(1.0, SeverityLevel.ERROR), # None → Defaults to ERROR
],
)
def test_severity_conversion(input_severity, expected_enum):
"""Test various user inputs for severity and check expected outputs."""
rule_info = pd.Series({"Report Severity": input_severity})
severity = get_severity(rule_info)
assert severity == expected_enum, f"Failed for input: {input_severity}"