Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7171a54cb | |||
| 0019667302 | |||
| 129132dd3a | |||
| f902f9c23f | |||
| 7158d0576d | |||
| bb87a7b932 | |||
| f1c4e65d72 | |||
| 1fa7bcb31a | |||
| 66312e1cdd | |||
| 38d2073dbb |
@@ -0,0 +1,30 @@
|
||||
# Use the official Python 3.13 slim image as the base
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Change to UK mirror for better reliability (robust for missing files)
|
||||
RUN find /etc/apt/ -name '*.list' -exec sed -i 's|http://deb.debian.org|http://ftp.uk.debian.org|g' {} + || true
|
||||
|
||||
# Force apt to use IPv4 to avoid CDN/network issues
|
||||
RUN echo 'Acquire::ForceIPv4 "true";' > /etc/apt/apt.conf.d/99force-ipv4
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /home/speckle
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -ms /bin/bash vscode
|
||||
USER vscode
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/home/speckle
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt requirements-dev.txt pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir -r requirements-dev.txt && \
|
||||
echo 'export PATH=$PATH:$HOME/.local/bin' >> ~/.bashrc
|
||||
@@ -1,43 +1,49 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-contrib/features/poetry:2": {}
|
||||
},
|
||||
|
||||
"remoteEnv": {
|
||||
"SPECKLE_TOKEN": "foobar"
|
||||
},
|
||||
"containerEnv": {
|
||||
"SPECKLE_TOKEN": "asdfasdf"
|
||||
},
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp .env.example .env && POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-root",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"name": "Model Checker - An Automate Function",
|
||||
"dockerFile": "Dockerfile",
|
||||
"context": "..",
|
||||
"workspaceFolder": "/home/speckle",
|
||||
"runArgs": [
|
||||
"--network",
|
||||
"host"
|
||||
],
|
||||
"mounts": [
|
||||
"source=${localWorkspaceFolder},target=/home/speckle,type=bind,consistency=cached"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-python.black-formatter",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"mikestead.dotenv"
|
||||
]
|
||||
"ms-python.isort",
|
||||
"ms-python.flake8",
|
||||
"littlefoxteam.vscode-python-test-adapter",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"charliermarsh.ruff"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.formatting.provider": "black",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.nosetestsEnabled": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.autoTestDiscoverOnSaveEnabled": true,
|
||||
"python.testing.cwd": "${workspaceFolder}",
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sh -c \"mkdir -p ~/.pip && echo '[global]\nprefer-ipv4 = true' > ~/.pip/pip.conf\"",
|
||||
"postStartCommand": "echo 'Container started successfully!'"
|
||||
}
|
||||
@@ -44,3 +44,4 @@ jobs:
|
||||
speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }}
|
||||
speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }}
|
||||
speckle_function_command: 'python -u main.py run'
|
||||
speckle_function_recommended_memory_mi: 5000
|
||||
|
||||
Generated
+3
@@ -4,4 +4,7 @@
|
||||
<option name="sdkName" value="WSL Checker" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="uv (Checker)" project-jdk-type="Python SDK" />
|
||||
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
+24
-2
@@ -5,5 +5,27 @@
|
||||
"stringcase",
|
||||
"typer"
|
||||
],
|
||||
"python.defaultInterpreterPath": ".venv/bin/python"
|
||||
}
|
||||
"python.defaultInterpreterPath": ".venv/bin/python",
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.nosetestsEnabled": false,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.autoTestDiscoverOnSaveEnabled": true,
|
||||
"python.testing.cwd": "${workspaceFolder}",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.rulers": [
|
||||
79
|
||||
],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.ruff": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
+349
-28
@@ -1,56 +1,377 @@
|
||||
# Checker Function Development Guide
|
||||
# Checker Function Developer Guide
|
||||
|
||||
## Setup
|
||||
This document provides technical details for developers working on the Speckle Checker Automate function.
|
||||
|
||||
1. Install dependencies:
|
||||
## Project Overview
|
||||
|
||||
The Checker function enables validation of Speckle objects against user-defined rules in a spreadsheet. It's designed to
|
||||
be flexible, supporting various object schemas including both v2 and v3 Speckle APIs.
|
||||
|
||||
## Setup Development Environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Poetry for dependency management
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
3. Activate the virtual environment:
|
||||
```bash
|
||||
poetry shell
|
||||
```
|
||||
|
||||
### Test Automation Environment
|
||||
|
||||
The project uses Speckle's [Test Automation feature](https://speckle.guide/automate/function-testing.html) to run
|
||||
integration tests against real Speckle data. This provides a sandboxed environment to validate the function's business
|
||||
logic without triggering actual automations.
|
||||
|
||||
#### Setting Up a Test Automation
|
||||
|
||||
1. Navigate to your Speckle project
|
||||
2. Go to the **Automations** tab
|
||||
3. Click **New Automation**
|
||||
4. Select **Create Test Automation** in the bottom left
|
||||
5. Follow the configuration steps
|
||||
|
||||
Note: To create a test automation, you must:
|
||||
|
||||
- Be an owner of the Speckle project
|
||||
- Have published this function to the Function Library
|
||||
- Have at least one release for the function
|
||||
|
||||
#### Environment Configuration
|
||||
|
||||
For local integration testing, create a `.env` file in the project root with these variables:
|
||||
|
||||
```
|
||||
# Your Personal Access Token from Speckle
|
||||
SPECKLE_TOKEN=your_speckle_token
|
||||
|
||||
# The Speckle server URL
|
||||
SPECKLE_SERVER_URL=https://app.speckle.systems
|
||||
|
||||
# From the test automation URL: /projects/[project-id]/automations/[automation-id]
|
||||
SPECKLE_PROJECT_ID=your_project_id
|
||||
SPECKLE_AUTOMATION_ID=your_automation_id
|
||||
```
|
||||
|
||||
This configuration allows the test suite to:
|
||||
|
||||
1. Connect to your test automation via the Speckle API
|
||||
2. Run the function locally against real Speckle data
|
||||
3. Submit results to the test automation for validation
|
||||
|
||||
For detailed instructions, refer to
|
||||
the [official documentation on function testing](https://speckle.guide/automate/function-testing.html#how-to-create-a-test-automation).
|
||||
|
||||
#### Running Integration Tests
|
||||
|
||||
With the `.env` file configured:
|
||||
|
||||
```bash
|
||||
poetry shell && poetry install
|
||||
# Run the integration tests
|
||||
pytest test_function.py
|
||||
```
|
||||
|
||||
2. Configure `.env`:
|
||||
The SDK utilities will automatically:
|
||||
|
||||
```
|
||||
SPECKLE_TOKEN=your_speckle_token
|
||||
SPECKLE_SERVER_URL=app.speckle.systems
|
||||
- Connect to your test automation
|
||||
- Execute your function with the specified test data
|
||||
- Submit results back to Speckle
|
||||
|
||||
Test results will be visible on the automation page in the Speckle UI.
|
||||
|
||||
#### Unit Tests
|
||||
|
||||
For unit tests that don't require a full Speckle connection, you can run:
|
||||
|
||||
```bash
|
||||
# Run unit tests only
|
||||
pytest test_comparisons.py test_rule_processing.py
|
||||
```
|
||||
|
||||
Get test automation details from app.speckle.systems
|
||||
Note: The `.env` file should never be committed to version control (it's included in .gitignore)
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `function.py`: Main business logic
|
||||
- `rules.py`: Rule definitions and processing
|
||||
- `inputs.py`: Function input schema
|
||||
- `helpers.py`: Utility functions
|
||||
- `spreadsheet.py`: TSV handling
|
||||
```
|
||||
├── main.py # Entry point for Automate
|
||||
├── src/
|
||||
│ ├── function.py # Main function logic
|
||||
│ ├── inputs.py # Function input schema
|
||||
│ ├── helpers.py # Utility functions
|
||||
│ ├── filters.py # Object filtering functions
|
||||
│ ├── rules.py # Rule definitions and property handling
|
||||
│ ├── predicates.py # Predicate mapping for spreadsheet values
|
||||
│ ├── rule_processor.py # Rule application and result handling
|
||||
│ └── spreadsheet.py # TSV file parsing
|
||||
├── tests/
|
||||
│ ├── conftest.py # Test fixtures
|
||||
│ ├── test_function.py # Main function tests
|
||||
│ ├── test_comparisons.py # Value comparison tests
|
||||
│ ├── test_parameters.py # Parameter handling tests
|
||||
│ └── test_rule_processing.py # Rule processing tests
|
||||
├── pyproject.toml # Project dependencies
|
||||
└── poetry.lock # Locked dependencies
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Function Execution Flow
|
||||
|
||||
The main execution flow is defined in `function.py`:
|
||||
|
||||
1. `automate_function()` receives context and inputs from Automate
|
||||
2. Retrieves Speckle objects via `automate_context.receive_version()`
|
||||
3. Flattens the object tree using `flatten_base()`
|
||||
4. Loads rules from the spreadsheet URL via `read_rules_from_spreadsheet()`
|
||||
5. Applies rules to objects using `apply_rules_to_objects()`
|
||||
6. Reports results via the Automate context
|
||||
|
||||
### 2. Rule Processing
|
||||
|
||||
Rules are processed through several stages:
|
||||
|
||||
1. **Spreadsheet Parsing** (`spreadsheet.py`):
|
||||
- Reads TSV data
|
||||
- Groups rules by rule number
|
||||
- Validates rule structure
|
||||
|
||||
2. **Rule Application** (`rule_processor.py`):
|
||||
- Processes rule logic (WHERE, AND, CHECK)
|
||||
- Evaluates conditions against objects
|
||||
- Attaches results to objects in Automate context
|
||||
|
||||
3. **Property Rules** (`rules.py`):
|
||||
- Handles property lookups in objects
|
||||
- Implements comparison logic
|
||||
- Supports both v2 and v3 Speckle schemas
|
||||
|
||||
### 3. Property Access System
|
||||
|
||||
The system uses a flexible property access mechanism that works with different Speckle schemas:
|
||||
|
||||
- **V2 Schema**: Properties in `parameters` dictionary with internal definition names
|
||||
- **V3 Schema**: Properties in nested `properties.Parameters` structure
|
||||
|
||||
The `PropertyRules` class provides methods to:
|
||||
|
||||
- Find properties by path or name
|
||||
- Extract values with appropriate type conversion
|
||||
- Perform comparisons with tolerance and type handling
|
||||
|
||||
## Test-Driven Development for Rules
|
||||
|
||||
The test infrastructure is designed to support Test-Driven Development (TDD) when creating new rules or extending
|
||||
functionality. This approach is especially powerful for rule development as it allows you to verify behavior against
|
||||
known test objects.
|
||||
|
||||
### Using Test Fixtures for Rule Development
|
||||
|
||||
The `conftest.py` file contains test fixtures that provide sample Speckle objects for testing:
|
||||
|
||||
```python
|
||||
@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"
|
||||
# ... more properties
|
||||
|
||||
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"
|
||||
# ... more properties
|
||||
|
||||
return wall
|
||||
```
|
||||
|
||||
These fixtures create standardized test objects that represent different Speckle schema versions, allowing you to test
|
||||
rule behavior consistently.
|
||||
|
||||
### TDD Workflow for New Rules
|
||||
|
||||
When developing a new rule or predicate, follow this TDD approach:
|
||||
|
||||
1. **Add test fixtures**: First, expand `conftest.py` with representative objects that your rule will process
|
||||
|
||||
2. **Write tests first**: Create test cases in a test file (e.g., `test_my_rule.py`):
|
||||
```python
|
||||
def test_new_wall_rule(v2_wall, v3_wall):
|
||||
"""Test a new rule that checks wall thickness requirements"""
|
||||
# Test with v2 schema
|
||||
assert PropertyRules.is_new_wall_check(v2_wall, "width", "300")
|
||||
|
||||
# Test with v3 schema
|
||||
assert PropertyRules.is_new_wall_check(v3_wall, "Width", "300")
|
||||
|
||||
# Test failure case
|
||||
v2_wall.parameters["WALL_ATTR_WIDTH_PARAM"].value = 200
|
||||
assert not PropertyRules.is_new_wall_check(v2_wall, "width", "300")
|
||||
```
|
||||
|
||||
3. **Implement the rule**: Add the new rule method to the `PropertyRules` class in `rules.py`:
|
||||
```python
|
||||
@staticmethod
|
||||
def is_new_wall_check(speckle_object: Base, parameter_name: str, expected_value: str) -> bool:
|
||||
"""Checks if a wall meets specific thickness requirements"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# Implement rule logic
|
||||
return result
|
||||
```
|
||||
|
||||
4. **Add to predicate mapping**: Register your new rule in `predicates.py`:
|
||||
```python
|
||||
PREDICATE_METHOD_MAP = {
|
||||
# Existing predicates...
|
||||
"new_wall_check": PropertyRules.is_new_wall_check.__name__,
|
||||
}
|
||||
```
|
||||
|
||||
5. **Run tests to verify**:
|
||||
```bash
|
||||
pytest test_my_rule.py -v
|
||||
```
|
||||
|
||||
### Creating Comprehensive Test Objects
|
||||
|
||||
For the most effective testing, your test objects in `conftest.py` should:
|
||||
|
||||
1. **Include diverse objects**: Walls, columns, beams, etc.
|
||||
2. **Cover edge cases**: Null values, missing properties, special characters
|
||||
3. **Represent both schemas**: Include both v2 and v3 format objects
|
||||
4. **Include real-world examples**: Extract sample objects from actual projects
|
||||
|
||||
You can extract real objects for testing using:
|
||||
|
||||
```python
|
||||
# Example code to extract and save real objects for test fixtures
|
||||
from specklepy.api import operations
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
client = SpeckleClient(host="app.speckle.systems")
|
||||
client.authenticate_with_token(token)
|
||||
|
||||
transport = ServerTransport(client=client, stream_id="stream_id")
|
||||
obj = operations.receive("object_id", transport)
|
||||
|
||||
# Print structure to help with fixture creation
|
||||
print(obj.get_member_names())
|
||||
print(obj.get_dynamic_member_names())
|
||||
```
|
||||
|
||||
By following this TDD approach and maintaining comprehensive test fixtures, you can develop robust rules that work
|
||||
reliably across different object schemas and handle edge cases appropriately.
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
poetry run pytest
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_parameters.py
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src
|
||||
```
|
||||
|
||||
## Extending Rules
|
||||
### Test Data
|
||||
|
||||
1. Add new predicate to `input_predicate_mapping` in `rules.py`
|
||||
2. Create corresponding method in `PropertyRules` class
|
||||
3. Update tests
|
||||
Test fixtures in `conftest.py` provide sample objects:
|
||||
|
||||
## Building
|
||||
- `v2_wall`: Wall object in v2 schema
|
||||
- `v3_wall`: Wall object in v3 schema
|
||||
|
||||
The function is packaged as a Docker container:
|
||||
### Manual Testing with Real Data
|
||||
|
||||
```bash
|
||||
docker build -f ./Dockerfile -t checker .
|
||||
```
|
||||
For testing with real Speckle data:
|
||||
|
||||
## Local Testing
|
||||
```python
|
||||
from specklepy.api import operations
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.credentials import get_account_from_token
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
```bash
|
||||
docker run --rm checker python -u main.py run [automation_data] [parameters] [token]
|
||||
client = SpeckleClient(host="app.speckle.systems")
|
||||
account = get_account_from_token(token, "app.speckle.systems")
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
transport = ServerTransport(client=client, stream_id="your_stream_id")
|
||||
commit = client.commit.get(stream_id="your_stream_id", commit_id="your_commit_id")
|
||||
obj = operations.receive(commit.referencedObject, transport)
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Create a GitHub release to trigger deployment to Speckle Automate.
|
||||
The function is deployed through GitHub Actions:
|
||||
|
||||
1. Create a GitHub release to trigger the build workflow
|
||||
2. The workflow builds the necessary artifacts and pushes them to the Speckle Automate registry
|
||||
3. The function becomes available in the Speckle Automate UI
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Large Object Trees**: When processing large models, use aggressive filtering with WHERE clauses
|
||||
- **Rule Complexity**: Minimize the number of nested property lookups
|
||||
- **Memory Usage**: Be aware of object reference handling and avoid deep copies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Rule not matching expected objects**:
|
||||
- Check property paths for the specific object type
|
||||
- Verify data types (strings vs. numbers)
|
||||
- Enable debug logging
|
||||
|
||||
2. **Slow performance**:
|
||||
- Check for inefficient property lookups
|
||||
- Add more specific WHERE filters to reduce object set
|
||||
|
||||
3. **Docker build failures**:
|
||||
- Check dependency compatibility
|
||||
- Verify Python version requirements
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Create a branch for your feature or fix
|
||||
2. Add tests for new functionality
|
||||
3. Update documentation
|
||||
4. Submit a pull request
|
||||
5. Ensure CI tests pass
|
||||
|
||||
## Future Development
|
||||
|
||||
Potential improvements:
|
||||
|
||||
- Support for more complex rule logic (OR conditions)
|
||||
- UI-based rule editor
|
||||
- Result visualization tools
|
||||
- Performance optimizations for large models
|
||||
- Support for referencing other objects in rules
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Use the official Python 3.11 slim image as the base
|
||||
FROM python:3.11-slim
|
||||
# Use the official Python 3.13 slim image as the base
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /home/speckle
|
||||
@@ -9,7 +9,7 @@ COPY . /home/speckle
|
||||
|
||||
# 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
|
||||
pip install --no-cache-dir -r /home/speckle/requirements.txt
|
||||
|
||||
# Set the entrypoint for running the Speckle function
|
||||
CMD ["python", "-u", "main.py", "run"]
|
||||
|
||||
@@ -1,36 +1,130 @@
|
||||
# Public Function: Checker
|
||||
# Model Checker
|
||||
|
||||
Validate Speckle objects against configurable rules using spreadsheet definitions.
|
||||
Model Checker is an Automate function that validates Speckle objects against configurable rules. This approach provides
|
||||
a flexible way to implement quality checks and maintain consistent standards across projects.
|
||||
|
||||
## Usage
|
||||
## Overview
|
||||
|
||||
1. Access the template Google Sheet [link needed]
|
||||
2. Make a copy to your Google Drive using File > Make a copy
|
||||
3. Define your rules in your sheet
|
||||
4. Click "Speckle" menu > "Publish Rules" to get your TSV URL
|
||||
5. Create an Automation in Speckle Automate using the Checker function
|
||||
6. Paste your TSV URL into the function configuration
|
||||
7. Run your automation
|
||||
The Model Checker allows you to:
|
||||
|
||||
## Rule Types
|
||||
- Define validation rules for your objects
|
||||
- Configure severity levels for issues
|
||||
- Check properties across different types of objects
|
||||
- Generate reports of validation results
|
||||
- Apply consistent standards across projects
|
||||
|
||||
- Property existence
|
||||
- Value matching
|
||||
- Numeric comparisons
|
||||
- Range checks
|
||||
- List membership
|
||||
- Pattern matching
|
||||
- Boolean checks
|
||||
## Getting Started
|
||||
|
||||
## Severity Levels
|
||||
### 1. Access the Model Checker Application
|
||||
|
||||
- WARNING: Issues that should be reviewed
|
||||
- ERROR: Critical issues requiring attention
|
||||
1. Go to the [Model Checker Application](https://model-checker.speckle.systems)
|
||||
2. Sign in with your Speckle account
|
||||
3. Create and manage your validation rules through the intuitive web interface
|
||||
|
||||
### 2. Create an Automation
|
||||
|
||||
1. Go to your workspace project in [Speckle](https://app.speckle.systems/)
|
||||
2. Create a new Automation
|
||||
3. Select the Model Checker function
|
||||
4. Configure the function:
|
||||
- Set minimum severity level to report
|
||||
- Configure other options as needed
|
||||
5. Save and run your automation
|
||||
|
||||
## Rule Definition Format
|
||||
|
||||
Rules are defined with the following components:
|
||||
|
||||
| Logic | Property Name | Predicate | Value | Message | Report Severity |
|
||||
|-------|---------------|--------------|-----------|----------------------|-----------------|
|
||||
| WHERE | category | matches | Walls | Wall thickness check | ERROR |
|
||||
| CHECK | Width | greater than | 200 | | |
|
||||
| WHERE | category | matches | Columns | Column height check | WARNING |
|
||||
| AND | height | in range | 2500,4000 | | |
|
||||
|
||||
### Component Explanation
|
||||
|
||||
- **Logic**: Defines how conditions are combined (WHERE, AND, CHECK)
|
||||
- **Property Name**: The object property or parameter to check
|
||||
- **Predicate**: Comparison operation (equals, greater than, etc.)
|
||||
- **Value**: Reference value for comparison
|
||||
- **Message**: Description shown in validation results
|
||||
- **Report Severity**: ERROR, WARNING, or INFO
|
||||
|
||||
### Supported Predicates
|
||||
|
||||
| Predicate | Description | Example |
|
||||
|------------------|-----------------------------|---------------------------------------|
|
||||
| exists | Checks if a property exists | `height` exists |
|
||||
| equal to | Exact value match | `width` equal to `300` |
|
||||
| not equal to | Value doesn't match | `material` not equal to `Concrete` |
|
||||
| greater than | Value exceeds threshold | `height` greater than `3000` |
|
||||
| less than | Value below threshold | `thickness` less than `50` |
|
||||
| in range | Value within bounds | `elevation` in range `0,10000` |
|
||||
| in list | Value in allowed set | `type` in list `W1,W2,W3` |
|
||||
| contains | Property contains substring | `name` contains `Beam` |
|
||||
| does not contain | Property doesn't contain | `name` does not contain `temp` |
|
||||
| is true | Boolean property is true | `is_structural` is true |
|
||||
| is false | Boolean property is false | `is_placeholder` is false |
|
||||
| is like | Loose text matching | `name` is like `Wall` matches `Walls` |
|
||||
|
||||
## Rule Logic
|
||||
|
||||
- **WHERE**: Filters objects to check (like SELECT WHERE in SQL)
|
||||
- **AND**: Additional filter conditions
|
||||
- **CHECK**: Final check condition (optional, defaults to last AND)
|
||||
|
||||
Objects pass a rule when they match all conditions. Objects that match WHERE/AND filters but fail the CHECK condition
|
||||
are reported as issues.
|
||||
|
||||
## Working with Object Properties
|
||||
|
||||
The Model Checker understands properties in Speckle objects regardless of schema:
|
||||
|
||||
- Direct properties: `category`, `name`, `id`
|
||||
- Nested properties: `parameters.WIDTH.value`
|
||||
- Revit parameters: Use parameter names like `Mark`, `Width`, `Assembly Code`
|
||||
|
||||
## Example Rules
|
||||
|
||||
[Screenshot or example table to be added]
|
||||
### Wall Thickness Check
|
||||
|
||||
```
|
||||
Rule: WHERE category equals "Walls" AND width less than "200"
|
||||
Message: "Walls must have width of at least 200."
|
||||
Severity: ERROR
|
||||
```
|
||||
|
||||
### Door Naming Convention
|
||||
|
||||
```
|
||||
Rule: WHERE category equals "Doors" AND name is not like "^D\d{3}$"
|
||||
Message: "All doors must have a name that follows the format "D" followed by three digits."
|
||||
Severity: WARNING
|
||||
```
|
||||
|
||||
### Structural Column Height Range
|
||||
|
||||
```
|
||||
Rule: WHERE category equals "Columns" AND is_structural is true AND height not in range "2400,4000"
|
||||
Message: "Structural columns must have a height between 2400 and 4000."
|
||||
Severity: ERROR
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please open a GitHub issue.
|
||||
For issues or questions, please let us know on the [Speckle Community Forum](https://speckle.community/).
|
||||
|
||||
### Alternative: TSV File Format
|
||||
|
||||
While the Model Checker Application is the recommended way to create and manage rules, you can also create compatible
|
||||
TSV (Tab-Separated Values) files manually. This can be useful for:
|
||||
|
||||
- Programmatically generating rules
|
||||
- Version controlling rules in a text format
|
||||
- Integrating with existing workflows
|
||||
- Creating rules in bulk
|
||||
|
||||
The TSV file should follow the same structure as shown in the table above, with columns separated by tabs. The file will
|
||||
then need to be hosted somewhere and served with MIME-type of `text/tab-separated-values` and the URL used in the
|
||||
automation configuration.
|
||||
|
||||
+42
-21
@@ -1,36 +1,57 @@
|
||||
[project]
|
||||
name = "speckle-automate-checker"
|
||||
version = "0.1.0"
|
||||
version = "3.0.0"
|
||||
description = "Allows for QAQC property checking with Speckle"
|
||||
authors = ["Jonathon Broughton <jonathon@speckle.systems>"]
|
||||
authors = [{ name = "Jonathon Broughton", email = "jonathon@speckle.systems" }]
|
||||
readme = "README.md"
|
||||
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",
|
||||
"more-itertools>=10.6.0",
|
||||
"pandas>=2.2.3",
|
||||
"pydantic==2.10.6",
|
||||
"python-dotenv>=1.0.1",
|
||||
"python-levenshtein>=0.26.1",
|
||||
"specklepy>=3.0.0",
|
||||
"pydantic-settings>=2.7.1",
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy>=1.15.0",
|
||||
"pytest>=8.3.4",
|
||||
"pytest-assertcount>=1.0.0",
|
||||
"ruff==0.11.12",
|
||||
]
|
||||
|
||||
[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"]
|
||||
ignore = ["F401", "F403", "E501"]
|
||||
exclude = [".venv", "**/*.yml"]
|
||||
line-length = 79
|
||||
|
||||
[tool.ruff.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "auto"
|
||||
docstring-code-format = true
|
||||
docstring-code-line-length = 79
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["src"]
|
||||
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = []
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
argcomplete==3.6.2
|
||||
click==8.1.8
|
||||
colorama==0.4.6
|
||||
coverage==7.8.2
|
||||
flake8==7.2.0
|
||||
iniconfig==2.1.0
|
||||
isort==6.0.1
|
||||
mccabe==0.7.0
|
||||
mypy_extensions==1.1.0
|
||||
packaging==24.2
|
||||
pathspec==0.12.1
|
||||
pipx==1.7.1
|
||||
platformdirs==4.3.7
|
||||
pluggy==1.6.0
|
||||
pycodestyle==2.13.0
|
||||
pyflakes==3.3.2
|
||||
Pygments==2.19.1
|
||||
pytest>=8.3.4
|
||||
pytest-assertcount>=1.0.0
|
||||
pytest-cov==6.1.1
|
||||
ruff==0.11.12
|
||||
userpath==1.9.2
|
||||
mypy>=1.15.0
|
||||
+6
-53
@@ -1,54 +1,7 @@
|
||||
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
|
||||
more-itertools>=10.6.0
|
||||
pandas>=2.2.3
|
||||
pydantic==2.10.6
|
||||
pydantic-core==2.27.2
|
||||
pydantic-settings==2.7.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
|
||||
python-dotenv>=1.0.1
|
||||
python-levenshtein>=0.26.1
|
||||
specklepy>=3.0.0
|
||||
pydantic-settings>=2.7.1
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Store the current Python environment
|
||||
CURRENT_ENV=$(pip freeze)
|
||||
|
||||
# Remove dev dependencies
|
||||
pip uninstall -y pytest pytest-cov isort flake8 ruff
|
||||
|
||||
# Generate production requirements
|
||||
pip freeze > requirements.txt
|
||||
|
||||
# Reinstall dev dependencies
|
||||
pip install pytest pytest-cov isort flake8 ruff
|
||||
|
||||
# Generate dev requirements
|
||||
pip freeze > requirements-dev.txt
|
||||
|
||||
# Restore the original environment
|
||||
pip uninstall -y pytest pytest-cov isort flake8 ruff
|
||||
echo "$CURRENT_ENV" | pip install -r /dev/stdin
|
||||
|
||||
echo "Requirements files have been updated successfully!"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"files.autoSave": "onFocusChange",
|
||||
"editor.defaultFormatter": null,
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
+78
-20
@@ -1,7 +1,17 @@
|
||||
"""This is the main function that will be executed when the automation is triggered.
|
||||
"""This is the main entry point for the Speckle Automate function.
|
||||
|
||||
It will receive the inputs from the user, and the context of the run.
|
||||
It will then apply the rules to the objects in the model, and report back the results.
|
||||
The Speckle Automate system works as follows:
|
||||
1. When a model is committed to Speckle, it triggers automations associated with the project
|
||||
2. For each automation, Speckle Automate prepares a runtime environment and context
|
||||
3. The automation context includes the model data and function inputs
|
||||
4. This function is executed to process the model and provide results
|
||||
5. Results are attached to objects in the model, creating an annotated view
|
||||
|
||||
This function implements a configurable rule-based validation system that:
|
||||
- Reads validation rules from an external spreadsheet
|
||||
- Applies these rules to objects in the Speckle model
|
||||
- Reports validation results back to the Speckle platform
|
||||
- Provides an annotated view of the model showing validation issues
|
||||
"""
|
||||
|
||||
from speckle_automate import AutomationContext
|
||||
@@ -19,38 +29,79 @@ def automate_function(
|
||||
automate_context: AutomationContext,
|
||||
function_inputs: FunctionInputs,
|
||||
) -> None:
|
||||
"""This VERSION of the function will add a check for the new provide inputs.
|
||||
"""Main entry point for the Speckle Automate function.
|
||||
|
||||
This function is called by the Speckle Automate system when the automation is triggered.
|
||||
It orchestrates the entire validation process:
|
||||
|
||||
1. Receiving and flattening the model data
|
||||
2. Detecting the Speckle object schema version
|
||||
3. Loading and grouping rules from the external spreadsheet
|
||||
4. Applying rules to objects and collecting results
|
||||
5. Reporting results back to the Speckle platform
|
||||
|
||||
Args:
|
||||
automate_context: A context helper object, that carries relevant information
|
||||
about the runtime context of this function.
|
||||
It gives access to the Speckle project data, that triggered this run.
|
||||
It also has convenience methods attach result data to the Speckle model.
|
||||
function_inputs: An instance object matching the defined schema.
|
||||
automate_context: A context helper provided by Speckle Automate that:
|
||||
- Provides access to the Speckle model data
|
||||
- Handles result reporting and view management
|
||||
- Manages run status (success, failure, exception)
|
||||
function_inputs: User-provided inputs defined in the FunctionInputs schema,
|
||||
particularly the URL to the rules spreadsheet
|
||||
"""
|
||||
# the context provides a convenient way, to receive the triggering VERSION
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 1: Receive and process the model data
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# The AutomationContext provides a convenient way to access the model data
|
||||
# that triggered this automation run
|
||||
version_root_object: Base = automate_context.receive_version()
|
||||
|
||||
# We can continue to work with a flattened list of objects.
|
||||
# Flatten the object tree into a list of objects
|
||||
# The Speckle object model is hierarchical, but for validation purposes,
|
||||
# it's easier to work with a flat list of objects
|
||||
flat_list_of_objects = list(flatten_base(version_root_object))
|
||||
|
||||
# If it is a next_gen model, we can get the VERSION from the root object
|
||||
# This function's rules don't make use of this check, but it is here for reference if you want to.
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 2: Detect Speckle object schema version
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# The Speckle object schema has evolved over time
|
||||
# In newer models, we can detect the version from the root object
|
||||
# This version information helps our validation logic handle different schemas
|
||||
global VERSION
|
||||
VERSION = getattr(version_root_object, "version", 2) # noqa: F841SION = getattr(version_root_object,"version", 2) # noqa: F841 # noqa: F841
|
||||
|
||||
# Read and group rules
|
||||
grouped_rules, messages = read_rules_from_spreadsheet(function_inputs.spreadsheet_url)
|
||||
# In v2, parameters are stored in a 'parameters' dictionary on each object
|
||||
# In v3, they are nested in 'properties.Parameters' with categorization
|
||||
speckle_print(f"Detected Speckle object schema version: {VERSION}")
|
||||
|
||||
# Handle any validation messages
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 3: Load and process rules from the spreadsheet
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# The rules are defined in an external spreadsheet (TSV format)
|
||||
# This allows non-technical users to define and modify rules
|
||||
# without changing the code
|
||||
grouped_rules, messages = read_rules_from_spreadsheet(
|
||||
function_inputs.spreadsheet_url
|
||||
)
|
||||
|
||||
# Handle any validation messages from rule processing
|
||||
for message in messages:
|
||||
speckle_print(message) # or log them appropriately
|
||||
|
||||
# If rule processing failed, mark the run as failed and exit
|
||||
if grouped_rules is None:
|
||||
automate_context.mark_run_exception("Failed to process rules")
|
||||
return
|
||||
|
||||
# apply the rules to the objects
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 4: Apply rules to objects and collect results
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# This is where the actual validation happens
|
||||
# Each rule is applied to relevant objects, and results are collected
|
||||
# Results are attached to objects in the model to create an annotated view
|
||||
apply_rules_to_objects(
|
||||
flat_list_of_objects,
|
||||
grouped_rules,
|
||||
@@ -59,10 +110,17 @@ def automate_function(
|
||||
hide_skipped=function_inputs.hide_skipped,
|
||||
)
|
||||
|
||||
# set the automation context view, to the original model / VERSION view
|
||||
# -------------------------------------------------------------------------
|
||||
# Step 5: Finalize the automation run
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Set the context view to the original model/version view
|
||||
# This ensures that the results are displayed in the correct context
|
||||
automate_context.set_context_view()
|
||||
|
||||
# report success
|
||||
# Mark the run as successful and provide a summary message
|
||||
# This message will be displayed to the user in the Speckle UI
|
||||
automate_context.mark_run_success(
|
||||
f"Successfully applied {len(grouped_rules)} rules to {len(flat_list_of_objects)} version {VERSION} objects."
|
||||
f"Successfully applied {len(grouped_rules)} rules to "
|
||||
f"{len(flat_list_of_objects)} version {VERSION} objects."
|
||||
)
|
||||
|
||||
+57
-26
@@ -4,7 +4,7 @@ from collections.abc import Generator, Iterable
|
||||
from typing import Any
|
||||
|
||||
from specklepy.objects import Base
|
||||
from specklepy.objects.other import Instance, Transform
|
||||
from specklepy.objects.proxies import InstanceProxy as Instance
|
||||
|
||||
|
||||
def speckle_print(log_string: str = "banana") -> None:
|
||||
@@ -27,7 +27,8 @@ def get_item(obj: Base | dict[str, Any], key, default=None):
|
||||
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
|
||||
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:
|
||||
@@ -39,7 +40,9 @@ def has_item(obj: Base | dict[str, Any], key: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]:
|
||||
def flatten_base_thorough(
|
||||
base: Base, parent_type: str | None = None
|
||||
) -> Iterable[Base]:
|
||||
"""Take a base and flatten it to an iterable of bases.
|
||||
|
||||
Args:
|
||||
@@ -69,7 +72,9 @@ 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
|
||||
@@ -80,52 +85,78 @@ def flatten_base_thorough(base: Base, parent_type: str = None) -> Iterable[Base]
|
||||
def extract_base_and_transform(
|
||||
base: Base,
|
||||
inherited_instance_id: str | None = None,
|
||||
transform_list: list[Transform] | None = None,
|
||||
transform_list: list[list[float]] | None = None,
|
||||
) -> Generator[
|
||||
Base | str | list[Transform] | None | tuple[Base, Any | None, list[Transform] | None | list[Any]], Any | None, None
|
||||
Base
|
||||
| str
|
||||
| list[list[float]]
|
||||
| None
|
||||
| tuple[Base, Any | None, list[list[float]] | None | list[Any]],
|
||||
Any | None,
|
||||
]:
|
||||
"""Traverses Speckle object hierarchies to yield `Base` objects and their transformations.
|
||||
"""Traverses Speckle object hierarchies to yield `Base`s and transformas.
|
||||
|
||||
Tailored to Speckle's AEC data structures, it covers the newer hierarchical structures
|
||||
with Collections and also with patterns found in older Revit specific data.
|
||||
Tailored to Speckle's AEC data structures, it covers the newer
|
||||
hierarchical structures with Collections and also with patterns found in
|
||||
older Revit specific data.
|
||||
|
||||
Parameters:
|
||||
- base (Base): The starting point `Base` object for traversal.
|
||||
- inherited_instance_id (str, optional): The inherited identifier for `Base` objects without a unique ID.
|
||||
- transform_list (List[Transform], optional): Accumulated list of transformations from parent to child objects.
|
||||
- inherited_instance_id (str, optional): The inherited identifier for
|
||||
`Base` objects without a unique ID.
|
||||
- transform_list (List[List[float]], optional): Accumulated list of
|
||||
transformations from parent to child objects.
|
||||
|
||||
Yields:
|
||||
- tuple: A `Base` object, its identifier, and a list of applicable `Transform` objects or None.
|
||||
- tuple: A `Base` object, its identifier, and a list of applicable
|
||||
transformations or None.
|
||||
|
||||
The id of the `Base` object is either the inherited identifier for a definition from an instance
|
||||
or the one defined in the object.
|
||||
The id of the `Base` object is either the inherited identifier for a
|
||||
definition from an instance or the one defined in the object.
|
||||
"""
|
||||
# Derive the identifier for the current `Base` object, defaulting to an inherited one if needed.
|
||||
# Derive the identifier for the current `Base` object, defaulting to an
|
||||
# inherited one if needed.
|
||||
current_id = getattr(base, "id", inherited_instance_id)
|
||||
transform_list = transform_list or []
|
||||
|
||||
if isinstance(base, Instance):
|
||||
# Append transformation data and dive into the definition of `Instance` objects.
|
||||
# Append transformation data and dive into the definition of `Instance`
|
||||
# objects.
|
||||
if base.transform:
|
||||
transform_list.append(base.transform)
|
||||
if base.definition:
|
||||
yield from extract_base_and_transform(base.definition, current_id, transform_list.copy())
|
||||
yield from extract_base_and_transform(
|
||||
base.definition, current_id, transform_list.copy()
|
||||
)
|
||||
else:
|
||||
# Initial yield for the current `Base` object.
|
||||
yield base, current_id, transform_list
|
||||
|
||||
# Process 'elements' and '@elements', typical containers for `Base` objects in AEC models.
|
||||
elements_attr = getattr(base, "elements", []) or getattr(base, "@elements", [])
|
||||
# Process 'elements' and '@elements', typical containers for `Base`
|
||||
# objects in AEC models.
|
||||
elements_attr = getattr(base, "elements", []) or getattr(
|
||||
base, "@elements", []
|
||||
)
|
||||
for element in elements_attr:
|
||||
if isinstance(element, Base):
|
||||
# Recurse into each `Base` object within 'elements' or '@elements'.
|
||||
yield from extract_base_and_transform(element, current_id, transform_list.copy())
|
||||
# Recurse into each `Base` object within 'elements' or
|
||||
# '@elements'.
|
||||
yield from extract_base_and_transform(
|
||||
element, current_id, transform_list.copy()
|
||||
)
|
||||
|
||||
# Recursively process '@'-prefixed properties that are Base objects with 'elements'.
|
||||
# This is a common pattern in older Speckle data models, such as those used for Revit commits.
|
||||
# Recursively process '@'-prefixed properties that are Base objects
|
||||
# with 'elements'.
|
||||
# This is a common pattern in older Speckle data models, such as those
|
||||
# used for Revit commits.
|
||||
for attr_name in dir(base):
|
||||
if attr_name.startswith("@"):
|
||||
attr_value = getattr(base, attr_name)
|
||||
# If the attribute is a Base object containing 'elements', recurse into it.
|
||||
if isinstance(attr_value, Base) and hasattr(attr_value, "elements"):
|
||||
yield from extract_base_and_transform(attr_value, current_id, transform_list.copy())
|
||||
# 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()
|
||||
)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
"""This file contains the inputs for the function.
|
||||
|
||||
It is used to define the inputs for the function and to validate them.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
"""Configuration module defining mappings between spreadsheet predicates and rule methods."""
|
||||
"""Defines mappings between spreadsheet predicates and rule methods."""
|
||||
|
||||
from src.rules import PropertyRules
|
||||
|
||||
@@ -16,5 +16,7 @@ PREDICATE_METHOD_MAP = {
|
||||
"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__,
|
||||
"does not contain": (
|
||||
PropertyRules.is_parameter_value_not_containing.__name__
|
||||
),
|
||||
}
|
||||
|
||||
+210
-67
@@ -1,4 +1,16 @@
|
||||
"""Module for processing rules against Speckle objects and updating the automate context with the results."""
|
||||
"""Module for processing rules against Speckle objects and updating the automate context with the results.
|
||||
|
||||
This module implements the core rule processing logic that:
|
||||
1. Validates rule structure and logic
|
||||
2. Evaluates rule conditions against Speckle objects
|
||||
3. Separates filtering conditions and final check conditions
|
||||
4. Processes rule groups and tracks results
|
||||
5. Reports results back to the Speckle Automate context
|
||||
|
||||
The rule processing follows a "filter then validate" approach:
|
||||
- Filter conditions (WHERE, AND) narrow down which objects to check
|
||||
- The final check condition (CHECK or last AND) determines pass/fail
|
||||
"""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
@@ -18,6 +30,11 @@ from src.rules import PropertyRules
|
||||
def validate_rule_structure(rule_group: pd.DataFrame) -> None:
|
||||
"""Validates the structure and logic of a rule group.
|
||||
|
||||
This ensures the rule follows the proper format:
|
||||
- First condition must be WHERE
|
||||
- Following conditions can be AND
|
||||
- Only one CHECK condition is allowed (and must be last)
|
||||
|
||||
Args:
|
||||
rule_group: DataFrame containing the rule conditions
|
||||
|
||||
@@ -36,17 +53,23 @@ def validate_rule_structure(rule_group: pd.DataFrame) -> None:
|
||||
|
||||
# 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")
|
||||
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")
|
||||
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']}")
|
||||
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"}
|
||||
@@ -56,49 +79,71 @@ def validate_rule_structure(rule_group: pd.DataFrame) -> None:
|
||||
|
||||
|
||||
def evaluate_condition(
|
||||
speckle_object: Base, condition: pd.Series, rule_number: str | None = None, case_number: int | None = None
|
||||
speckle_object: Base,
|
||||
condition: pd.Series,
|
||||
rule_number: str | None = None,
|
||||
case_number: int | None = None,
|
||||
) -> bool:
|
||||
"""Given a Speckle object and a condition, evaluates the condition and returns a boolean value.
|
||||
"""Evaluates a single condition against a Speckle object.
|
||||
|
||||
A condition is a pandas Series object with the following keys:
|
||||
- 'Property Name': The name of the property to evaluate.
|
||||
- 'Predicate': The predicate to use for evaluation.
|
||||
- 'Value': The value to compare against.
|
||||
This function is the bridge between the rules defined in the spreadsheet
|
||||
and the property checking methods in PropertyRules. It:
|
||||
1. Extracts the property name, predicate, and value from the condition
|
||||
2. Maps the predicate to the corresponding method in PropertyRules
|
||||
3. Calls the method with the object, property name, and value
|
||||
|
||||
Args:
|
||||
rule_number (string): For information the rule number.
|
||||
case_number (int): For information the rule clause number.
|
||||
speckle_object (Base): The Speckle object to evaluate.
|
||||
condition (pd.Series): The condition to evaluate.
|
||||
speckle_object: The Speckle object to evaluate against
|
||||
condition: A pandas Series containing the condition details
|
||||
- 'Property Name': The name of the property to check
|
||||
- 'Predicate': The comparison operation (like 'equals',
|
||||
'greater than')
|
||||
- 'Value': The value to compare against
|
||||
rule_number: For tracking, the rule number being evaluated
|
||||
case_number: For tracking, the condition number within the rule
|
||||
|
||||
Returns:
|
||||
bool: The result of the evaluation. True if the condition is met, False otherwise.
|
||||
True if the condition is met, False otherwise
|
||||
"""
|
||||
property_name = condition["Property Name"]
|
||||
property_name = condition.get(
|
||||
"Property Name", condition.get("Property Path")
|
||||
)
|
||||
predicate_key = condition["Predicate"]
|
||||
value = condition["Value"]
|
||||
|
||||
# Debugging info
|
||||
_ = rule_number
|
||||
_ = case_number
|
||||
|
||||
# Look up the method name in the predicate map
|
||||
# This map connects spreadsheet predicates to PropertyRules methods
|
||||
if predicate_key in PREDICATE_METHOD_MAP:
|
||||
method_name = PREDICATE_METHOD_MAP[predicate_key]
|
||||
method = getattr(PropertyRules, method_name, None)
|
||||
|
||||
if method:
|
||||
# Call the method with the object, property name, and value
|
||||
return method(speckle_object, property_name, value)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
|
||||
"""Separates rule conditions into filters and final check.
|
||||
def get_filters_and_check(
|
||||
rule_group: pd.DataFrame,
|
||||
) -> tuple[pd.DataFrame, pd.Series]:
|
||||
"""Separates rule conditions into filtering conditions and the final check condition.
|
||||
|
||||
This function handles two rule formats:
|
||||
1. Explicit format: WHERE + AND... + CHECK
|
||||
2. Legacy format: WHERE + AND... (last AND is implicitly the check)
|
||||
|
||||
This separation enables the "filter then validate" approach.
|
||||
|
||||
Args:
|
||||
rule_group: DataFrame containing rule conditions
|
||||
|
||||
Returns:
|
||||
Tuple containing filter conditions and final check condition
|
||||
Tuple containing (filter_conditions, final_check_condition)
|
||||
"""
|
||||
if rule_group.empty:
|
||||
return pd.DataFrame(), pd.Series()
|
||||
@@ -127,7 +172,9 @@ def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Se
|
||||
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
|
||||
final_check = rule_group.iloc[
|
||||
0
|
||||
] # Default to first condition as check
|
||||
|
||||
return filters, final_check
|
||||
|
||||
@@ -135,16 +182,21 @@ def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Se
|
||||
def process_rule(
|
||||
speckle_objects: list[Base], rule_group: pd.DataFrame
|
||||
) -> tuple[list[Any], list[Any]] | tuple[list[Base], list[Base]]:
|
||||
"""Processes a set of rules against Speckle objects, returning those that pass and fail.
|
||||
"""Processes a rule group against a list of Speckle objects.
|
||||
|
||||
The first rule is used as a filter ('WHERE'), and subsequent rules as conditions ('AND').
|
||||
This function implements the "filter then validate" approach:
|
||||
1. Apply filter conditions sequentially to narrow down objects
|
||||
2. Apply the final check condition to determine pass/fail
|
||||
|
||||
This approach is efficient for large models as it reduces the number
|
||||
of objects that need full validation.
|
||||
|
||||
Args:
|
||||
speckle_objects: List of Speckle objects to be processed.
|
||||
rule_group: DataFrame defining the filter and conditions.
|
||||
speckle_objects: List of Speckle objects to be processed
|
||||
rule_group: DataFrame defining the filter and check conditions
|
||||
|
||||
Returns:
|
||||
A tuple of lists containing objects that passed and failed the rule.
|
||||
A tuple of lists (pass_objects, fail_objects)
|
||||
"""
|
||||
if not speckle_objects or rule_group.empty:
|
||||
return [], []
|
||||
@@ -168,7 +220,10 @@ def process_rule(
|
||||
obj
|
||||
for obj in filtered_objects
|
||||
if evaluate_condition(
|
||||
speckle_object=obj, condition=filter_condition, rule_number=rule_number, case_number=index
|
||||
speckle_object=obj,
|
||||
condition=filter_condition,
|
||||
rule_number=rule_number,
|
||||
case_number=index,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -177,12 +232,16 @@ def process_rule(
|
||||
return [], []
|
||||
|
||||
# For remaining objects, evaluate the final check
|
||||
# This separates objects into pass/fail groups
|
||||
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)
|
||||
speckle_object=obj,
|
||||
condition=final_check,
|
||||
rule_number=rule_number,
|
||||
case_number=len(filters),
|
||||
):
|
||||
pass_objects.append(obj)
|
||||
else:
|
||||
@@ -198,18 +257,31 @@ def apply_rules_to_objects(
|
||||
minimum_severity: MinimumSeverity = MinimumSeverity.INFO,
|
||||
hide_skipped: bool = False,
|
||||
) -> dict[str, tuple[list[Base], list[Base]]]:
|
||||
"""Applies defined rules to a list of objects and updates the automate context based on the results.
|
||||
"""Applies rules to objects and updates the automate context results.
|
||||
|
||||
This is the main orchestration function that:
|
||||
1. Processes each rule group against all objects
|
||||
2. Filters results based on severity levels
|
||||
3. Attaches results to objects in the Speckle Automate context
|
||||
4. Reports skipped rules (where no objects matched filters)
|
||||
|
||||
Args:
|
||||
speckle_objects (List[Base]): The list of objects to which rules are applied.
|
||||
grouped_rules (pd.DataFrameGroupBy): The DataFrame containing rule definitions.
|
||||
automate_context (Any): Context manager for attaching rule results.
|
||||
speckle_objects: The list of objects to which rules are applied
|
||||
grouped_rules: The rules grouped by rule number
|
||||
automate_context: Context manager for attaching results to objects
|
||||
minimum_severity: Minimum severity level to report
|
||||
hide_skipped: Whether to hide skipped tests
|
||||
hide_skipped: Whether to hide skipped rules in results
|
||||
|
||||
Returns:
|
||||
Dictionary mapping rule IDs to (pass_objects, fail_objects) tuples
|
||||
"""
|
||||
grouped_results = {}
|
||||
rules_processed = 0
|
||||
severity_levels = {MinimumSeverity.INFO: 0, MinimumSeverity.WARNING: 1, MinimumSeverity.ERROR: 2}
|
||||
severity_levels = {
|
||||
MinimumSeverity.INFO: 0,
|
||||
MinimumSeverity.WARNING: 1,
|
||||
MinimumSeverity.ERROR: 2,
|
||||
}
|
||||
min_severity_level = severity_levels[minimum_severity]
|
||||
|
||||
for rule_id, rule_group in grouped_rules:
|
||||
@@ -217,27 +289,56 @@ def apply_rules_to_objects(
|
||||
rules_processed += 1
|
||||
|
||||
# Ensure rule_group has necessary columns
|
||||
if "Message" not in rule_group.columns or "Report Severity" not in rule_group.columns:
|
||||
if "Message" not in rule_group.columns or (
|
||||
"Report Severity" not in rule_group.columns
|
||||
and "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)]
|
||||
rule_severity_level = severity_levels[
|
||||
MinimumSeverity(rule_severity.value)
|
||||
]
|
||||
|
||||
# Check if the rule severity level meets the minimum severity level
|
||||
# no point in processing lower severity rules
|
||||
if rule_severity_level < min_severity_level:
|
||||
continue
|
||||
|
||||
pass_objects, fail_objects = process_rule(speckle_objects, rule_group)
|
||||
|
||||
# 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)
|
||||
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(fail_objects) and 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
|
||||
):
|
||||
speckle_print(f"Rule {rule_id_str} Skipped")
|
||||
|
||||
if len(pass_objects) == 0 and len(fail_objects) == 0 and not hide_skipped:
|
||||
automate_context.attach_info_to_objects(
|
||||
category=f"Rule {rule_id_str} Skipped",
|
||||
object_ids=["0"], # This is a hack to get a rule to report with no valid objects
|
||||
object_ids=[Base()],
|
||||
# This is a hack to get a rule to report with no valid objects
|
||||
message=f"No objects found for rule {rule_id_str}",
|
||||
metadata={},
|
||||
)
|
||||
@@ -249,7 +350,13 @@ def apply_rules_to_objects(
|
||||
|
||||
|
||||
class SeverityLevel(Enum):
|
||||
"""Enum for severity levels."""
|
||||
"""Enumeration for severity levels of rule results.
|
||||
|
||||
These severity levels determine how rule failures are displayed:
|
||||
- INFO: Informational, no action required
|
||||
- WARNING: Potential issue that should be reviewed
|
||||
- ERROR: Critical issue requiring attention
|
||||
"""
|
||||
|
||||
INFO = "Info"
|
||||
WARNING = "Warning"
|
||||
@@ -257,21 +364,33 @@ class SeverityLevel(Enum):
|
||||
|
||||
|
||||
def get_severity(rule_info: pd.Series) -> SeverityLevel:
|
||||
"""Convert a string severity level to the corresponding SeverityLevel enum.
|
||||
"""Convert a string severity to the corresponding SeverityLevel enum.
|
||||
|
||||
This function normalizes input strings (because processing user entered dead is hard), handling:
|
||||
This function normalizes user input with robust handling for:
|
||||
- Case insensitivity (e.g., "info", "WARNING" → "Info", "Warning")
|
||||
- Shorthand mappings (e.g., "WARN" → "Warning")
|
||||
- Stripping whitespace
|
||||
- Defaults to SeverityLevel.ERROR if the input is invalid
|
||||
"""
|
||||
severity = rule_info.get("Report Severity") # Extract severity from input data
|
||||
- Whitespace handling
|
||||
- Default fallback to ERROR for invalid input
|
||||
|
||||
# If severity is None or not a string (e.g., numeric input), default to ERROR
|
||||
Args:
|
||||
rule_info: Series containing rule information with 'Report Severity'
|
||||
key
|
||||
|
||||
Returns:
|
||||
Appropriate SeverityLevel enum value
|
||||
"""
|
||||
severity = rule_info.get("Report Severity") or rule_info.get(
|
||||
"Severity"
|
||||
) # Extract severity from input data
|
||||
|
||||
# If severity is None or not a string (e.g., numeric input),
|
||||
# default to ERROR
|
||||
if not isinstance(severity, str):
|
||||
return SeverityLevel.ERROR
|
||||
|
||||
severity = severity.strip().upper() # Remove leading/trailing spaces & normalize case
|
||||
severity = (
|
||||
severity.strip().upper()
|
||||
) # Remove leading/trailing spaces & normalize case
|
||||
|
||||
# Define a mapping for shorthand or alternate spellings
|
||||
alias_map = {
|
||||
@@ -281,7 +400,8 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
|
||||
# Replace shorthand values if applicable
|
||||
severity = alias_map.get(severity, severity)
|
||||
|
||||
# Attempt to match with an existing SeverityLevel enum value (case-insensitive)
|
||||
# Attempt to match with an existing SeverityLevel enum value
|
||||
# (case-insensitive)
|
||||
return next(
|
||||
(level for level in SeverityLevel if level.value.upper() == severity),
|
||||
SeverityLevel.ERROR, # Default to ERROR if no match is found
|
||||
@@ -289,20 +409,27 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
|
||||
|
||||
|
||||
def get_metadata(
|
||||
rule_id: str, rule_info: pd.Series, passed: bool, speckle_objects: list[Base]
|
||||
rule_id: str,
|
||||
rule_info: pd.Series,
|
||||
passed: bool,
|
||||
speckle_objects: list[Base],
|
||||
) -> dict[str, str | int | Any]:
|
||||
"""Function that generates metadata with severity validation and ensures JSON serializability.
|
||||
"""Generates structured metadata for rule results.
|
||||
|
||||
Reasoning is that non-valid metadata fails inside the Automate context. So let's ensure it's valid.
|
||||
This metadata is attached to objects in the Speckle platform and is:
|
||||
1. Validated for JSON serializability
|
||||
2. Structured for consistent representation
|
||||
3. Includes key information about the rule and results
|
||||
|
||||
Args:
|
||||
rule_id: Identifier for the rule
|
||||
rule_info: Series containing rule information
|
||||
passed: Boolean indicating if the rule passed
|
||||
speckle_objects: List of Speckle objects
|
||||
speckle_objects: List of Speckle objects affected
|
||||
|
||||
Returns:
|
||||
Dictionary containing metadata if valid JSON serializable, empty dict otherwise
|
||||
Dictionary containing metadata if valid JSON serializable,
|
||||
empty dict otherwise
|
||||
"""
|
||||
try:
|
||||
metadata = {
|
||||
@@ -330,14 +457,20 @@ def attach_results(
|
||||
context: AutomationContext,
|
||||
passed: bool,
|
||||
) -> None:
|
||||
"""Attaches the results of a rule to the objects in the context.
|
||||
"""Attaches rule results to objects in the Speckle Automate context.
|
||||
|
||||
This function is the interface to the Speckle platform for reporting
|
||||
results:
|
||||
- For failing objects, attaches results with appropriate severity levels
|
||||
- For passing objects, attaches informational results
|
||||
- Includes structured metadata for consistent reporting
|
||||
|
||||
Args:
|
||||
speckle_objects (List[Base]): The list of objects to which the rule was applied.
|
||||
rule_info (pd.Series): The information about the rule.
|
||||
rule_id (str): The ID of the rule.
|
||||
context (AutomationContext): The context manager for attaching results.
|
||||
passed (bool): Whether the rule passed or failed.
|
||||
speckle_objects: The list of objects affected by the rule
|
||||
rule_info: Information about the rule
|
||||
rule_id: Identifier for the rule
|
||||
context: The Speckle Automate context for result attachment
|
||||
passed: Whether the objects passed the rule
|
||||
"""
|
||||
if not speckle_objects:
|
||||
return
|
||||
@@ -357,7 +490,7 @@ def attach_results(
|
||||
)
|
||||
context.attach_result_to_objects(
|
||||
category=f"Rule {rule_id}",
|
||||
object_ids=[speckle_object.id for speckle_object in speckle_objects],
|
||||
affected_objects=speckle_objects,
|
||||
message=message,
|
||||
level=severity,
|
||||
metadata=metadata,
|
||||
@@ -365,17 +498,27 @@ def attach_results(
|
||||
else:
|
||||
context.attach_info_to_objects(
|
||||
category=f"Rule {rule_id}",
|
||||
object_ids=[speckle_object.id for speckle_object in speckle_objects],
|
||||
affected_objects=speckle_objects,
|
||||
message=message,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def format_message(rule_info):
|
||||
"""Format the message for the rule."""
|
||||
"""Format the message for the rule result.
|
||||
|
||||
Handles cases where the message might be None or NaN.
|
||||
|
||||
Args:
|
||||
rule_info: Series containing rule information with 'Message' key
|
||||
|
||||
Returns:
|
||||
Formatted message string
|
||||
"""
|
||||
message = (
|
||||
str(rule_info["Message"])
|
||||
if rule_info["Message"] is not None and not pd.isna(rule_info["Message"])
|
||||
if rule_info["Message"] is not None
|
||||
and not pd.isna(rule_info["Message"])
|
||||
else "No Message"
|
||||
)
|
||||
return message
|
||||
|
||||
+362
-41
@@ -1,4 +1,15 @@
|
||||
"""A collection of rules for processing Speckle objects and their properties."""
|
||||
"""A collection of rules for processing Speckle objects and their properties.
|
||||
|
||||
This module provides essential utilities for:
|
||||
1. Accessing and comparing properties across different Speckle object versions (v2/v3)
|
||||
2. Handling nested property paths with a flexible search mechanism
|
||||
3. Converting between different value types (strings, booleans, numbers)
|
||||
4. Implementing various comparison predicates for validation rules
|
||||
|
||||
The core challenge addressed by this module is the evolving schema of Speckle objects.
|
||||
In v2, parameters were stored directly in a 'parameters' dictionary, while in v3,
|
||||
they are nested within a more complex 'properties.Parameters' structure with categories.
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
@@ -11,13 +22,30 @@ PRIMITIVE_TYPES = (bool, int, float, str, type(None))
|
||||
|
||||
|
||||
class Rules:
|
||||
"""A collection of rules for processing properties in Speckle objects."""
|
||||
"""A collection of rules for processing properties in Speckle objects.
|
||||
|
||||
This class provides utilities for working with displayable objects
|
||||
in the Speckle ecosystem.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def try_get_display_value(
|
||||
speckle_object: Base,
|
||||
) -> list[Base] | None:
|
||||
"""Try fetching the display value from a Speckle object."""
|
||||
"""Try fetching the display value from a Speckle object.
|
||||
|
||||
Speckle objects might store display geometry in various ways:
|
||||
- 'displayValue' (newer versions)
|
||||
- '@displayValue' (older versions)
|
||||
|
||||
This method handles both cases transparently.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to extract display value from
|
||||
|
||||
Returns:
|
||||
List of Base objects representing display geometry, or None if not found
|
||||
"""
|
||||
raw_display_value = getattr(speckle_object, "displayValue", None) or getattr(
|
||||
speckle_object, "@displayValue", None
|
||||
)
|
||||
@@ -34,7 +62,21 @@ class Rules:
|
||||
|
||||
@staticmethod
|
||||
def is_displayable_object(speckle_object: Base) -> bool:
|
||||
"""Determines if a given Speckle object is displayable."""
|
||||
"""Determines if a given Speckle object is displayable.
|
||||
|
||||
A Speckle object is considered displayable if:
|
||||
1. It has an ID and displayable geometry, OR
|
||||
2. It has a definition with an ID and displayable geometry
|
||||
(typically for instanced objects)
|
||||
|
||||
This is useful for filtering out non-visible/utility objects.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
|
||||
Returns:
|
||||
True if the object is displayable, False otherwise
|
||||
"""
|
||||
display_values = Rules.try_get_display_value(speckle_object)
|
||||
if display_values and getattr(speckle_object, "id", None) is not None:
|
||||
return True
|
||||
@@ -49,7 +91,17 @@ class Rules:
|
||||
|
||||
@staticmethod
|
||||
def get_displayable_objects(flat_list_of_objects: list[Base]) -> list[Base]:
|
||||
"""Filters a list of Speckle objects to only include displayable objects."""
|
||||
"""Filters a list of Speckle objects to only include displayable objects.
|
||||
|
||||
This is useful when processing a flattened object tree but only wanting
|
||||
to work with objects that have visual representation.
|
||||
|
||||
Args:
|
||||
flat_list_of_objects: A list of Speckle objects to filter
|
||||
|
||||
Returns:
|
||||
A filtered list containing only displayable objects with IDs
|
||||
"""
|
||||
return [
|
||||
speckle_object
|
||||
for speckle_object in flat_list_of_objects
|
||||
@@ -58,19 +110,30 @@ class Rules:
|
||||
|
||||
|
||||
class PropertyRules:
|
||||
"""A collection of rules for processing parameters in Speckle objects."""
|
||||
"""A collection of rules for processing parameters in Speckle objects.
|
||||
|
||||
This class provides the core functionality for:
|
||||
- Locating properties in complex object hierarchies
|
||||
- Converting between different value types
|
||||
- Comparing values with appropriate type handling
|
||||
- Implementing various comparison predicates for validation rules
|
||||
|
||||
It's designed to work with both Speckle v2 and v3 object schemas.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value_not_containing(speckle_object: Base, parameter_name: str, substring: str) -> bool:
|
||||
"""Checks if parameter value does not contain the given substring.
|
||||
|
||||
This is the logical inverse of is_parameter_value_containing.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: Name of the parameter to check
|
||||
substring: The substring to look for
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: Name of the parameter to check
|
||||
substring: The substring to look for
|
||||
|
||||
Returns:
|
||||
bool: True if the parameter value does not contain the substring
|
||||
True if the parameter value does not contain the substring
|
||||
"""
|
||||
# Invert the result of contains check
|
||||
return not PropertyRules.is_parameter_value_containing(speckle_object, parameter_name, substring)
|
||||
@@ -79,13 +142,16 @@ class PropertyRules:
|
||||
def is_parameter_value_containing(speckle_object: Base, parameter_name: str, substring: str) -> bool:
|
||||
"""Checks if parameter value contains the given substring.
|
||||
|
||||
Case-insensitive substring matching for parameters.
|
||||
If the parameter doesn't exist, returns False.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: Name of the parameter to check
|
||||
substring: The substring to look for
|
||||
|
||||
Returns:
|
||||
bool: True if the parameter value contains the substring
|
||||
True if the parameter value contains the substring
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
if parameter_value is None:
|
||||
@@ -102,14 +168,41 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def normalize_path(path: str) -> str:
|
||||
"""Remove technical path prefixes like 'properties' and 'parameters'."""
|
||||
"""Remove technical path prefixes like 'properties' and 'parameters'.
|
||||
|
||||
This helps make property paths version-agnostic by focusing on the
|
||||
meaningful parts of the path rather than the container structure.
|
||||
|
||||
Examples:
|
||||
- 'properties.Parameters.Type Parameters.Construction.Width' becomes 'Type Parameters.Construction.Width'
|
||||
- 'parameters.WALL_ATTR_WIDTH_PARAM' becomes 'WALL_ATTR_WIDTH_PARAM'
|
||||
|
||||
Args:
|
||||
path: The parameter path to normalize
|
||||
|
||||
Returns:
|
||||
A normalized path with technical prefixes removed
|
||||
"""
|
||||
parts = path.split(".")
|
||||
filtered = [p for p in parts if p.lower() not in ("properties", "parameters")]
|
||||
return ".".join(filtered)
|
||||
|
||||
@staticmethod
|
||||
def convert_revit_boolean(value: Any) -> Any:
|
||||
"""Convert Revit-style Yes/No strings to boolean values."""
|
||||
"""Convert Revit-style Yes/No strings to boolean values.
|
||||
|
||||
Revit and some other BIM applications use "Yes"/"No" strings
|
||||
instead of boolean values. This function converts them:
|
||||
- "Yes" → True
|
||||
- "No" → False
|
||||
- Other values remain unchanged
|
||||
|
||||
Args:
|
||||
value: The value to potentially convert
|
||||
|
||||
Returns:
|
||||
Converted boolean if applicable, otherwise original value
|
||||
"""
|
||||
# Handle None case
|
||||
if value is None:
|
||||
return None
|
||||
@@ -131,7 +224,20 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def get_obj_value(obj: Any, get_raw: bool = False) -> Any:
|
||||
"""Extract appropriate value from an object, handling special cases."""
|
||||
"""Extract appropriate value from an object, handling special cases.
|
||||
|
||||
This function handles the various ways values might be stored:
|
||||
- In v2 Parameter objects (with .value property)
|
||||
- In v3 dictionary structures (with 'value' key)
|
||||
- As primitive values directly
|
||||
|
||||
Args:
|
||||
obj: The object to extract value from
|
||||
get_raw: If True, return the object itself without extracting value
|
||||
|
||||
Returns:
|
||||
The extracted value, possibly with Yes/No conversion
|
||||
"""
|
||||
if get_raw:
|
||||
return obj
|
||||
|
||||
@@ -155,7 +261,19 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def search_obj(obj: Any, parts: list[str]) -> tuple[bool, Any]:
|
||||
"""Recursively search an object following a path."""
|
||||
"""Recursively search an object following a path.
|
||||
|
||||
This is a key part of the property access mechanism, allowing
|
||||
navigation through nested object structures using dot notation.
|
||||
The search is case-insensitive to handle inconsistencies.
|
||||
|
||||
Args:
|
||||
obj: The object to search within
|
||||
parts: List of path components to follow
|
||||
|
||||
Returns:
|
||||
Tuple of (found: bool, value: Any)
|
||||
"""
|
||||
if not parts:
|
||||
return True, obj
|
||||
|
||||
@@ -184,6 +302,14 @@ class PropertyRules:
|
||||
def find_property(root: Any, search_path: str, get_raw: bool = False) -> tuple[bool, Any]:
|
||||
"""Find a property by searching through nested objects.
|
||||
|
||||
This method implements a flexible property search that:
|
||||
1. First attempts a direct path match
|
||||
2. Then recursively searches through nested object structures
|
||||
3. Uses cycle detection to prevent infinite recursion
|
||||
|
||||
The approach handles both v2 and v3 Speckle object schemas and
|
||||
supports fuzzy property matching by normalizing paths.
|
||||
|
||||
Args:
|
||||
root: The root object to search
|
||||
search_path: Path to the property to find
|
||||
@@ -241,7 +367,17 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool:
|
||||
"""Check if a parameter exists in the Speckle object."""
|
||||
"""Check if a parameter exists in the Speckle object.
|
||||
|
||||
This method is version-agnostic and works with both v2 and v3 objects.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to look for
|
||||
|
||||
Returns:
|
||||
True if parameter exists, False otherwise
|
||||
"""
|
||||
found, _ = PropertyRules.find_property(speckle_object, parameter_name)
|
||||
return found
|
||||
|
||||
@@ -252,29 +388,58 @@ class PropertyRules:
|
||||
default_value: Any = None,
|
||||
get_raw: bool = False,
|
||||
) -> Any:
|
||||
"""Get a parameter value from the Speckle object using strict path matching.
|
||||
"""Get a parameter value from the Speckle object using path matching.
|
||||
|
||||
This is the core property access method that:
|
||||
1. Handles both v2 and v3 object structures
|
||||
2. Supports direct and nested property paths
|
||||
3. Applies appropriate value extraction and conversion
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to search
|
||||
parameter_name: Exact parameter path to find
|
||||
parameter_name: Parameter path to find
|
||||
default_value: Value to return if parameter not found
|
||||
get_raw: Whether to return raw values without conversion
|
||||
|
||||
Returns:
|
||||
The parameter value if found using exact path matching, otherwise default_value
|
||||
The parameter value if found, otherwise default_value
|
||||
"""
|
||||
found, value = PropertyRules.find_property(speckle_object, parameter_name, get_raw)
|
||||
return value if found else default_value
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value(speckle_object: Base, parameter_name: str, value_to_match: Any) -> bool:
|
||||
"""Checks if the value of the specified parameter matches the given value."""
|
||||
"""Checks if the value of the specified parameter matches the given value.
|
||||
|
||||
This is a basic equality check that leverages the parameter access system.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
value_to_match: The value to compare against
|
||||
|
||||
Returns:
|
||||
True if values match, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
return parameter_value == value_to_match
|
||||
|
||||
@staticmethod
|
||||
def parse_number_from_string(input_string: str):
|
||||
"""Attempts to parse a number from a string."""
|
||||
"""Attempts to parse a number from a string.
|
||||
|
||||
First tries to parse as integer, then as float if that fails.
|
||||
Raises ValueError if the string is not a valid number.
|
||||
|
||||
Args:
|
||||
input_string: The string to parse
|
||||
|
||||
Returns:
|
||||
int or float value
|
||||
|
||||
Raises:
|
||||
ValueError: If the string is not a valid number
|
||||
"""
|
||||
try:
|
||||
return int(input_string)
|
||||
except ValueError:
|
||||
@@ -287,8 +452,19 @@ class PropertyRules:
|
||||
def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
|
||||
"""Checks if parameter value is greater than threshold.
|
||||
|
||||
This implements the 'greater than' predicate for numeric comparisons.
|
||||
|
||||
Note: From a UX perspective, if someone writes 'height greater than 2401',
|
||||
they mean "flag an error if height <= 2401". So we flip the comparison.
|
||||
they mean "flag an error if height <= 2401". So we implement the check to match
|
||||
that intuitive interpretation.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
threshold: The threshold value as a string
|
||||
|
||||
Returns:
|
||||
True if parameter value > threshold, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
if parameter_value is None:
|
||||
@@ -303,8 +479,19 @@ class PropertyRules:
|
||||
def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
|
||||
"""Checks if parameter value is less than threshold.
|
||||
|
||||
This implements the 'less than' predicate for numeric comparisons.
|
||||
|
||||
Note: From a UX perspective, if someone writes 'height less than 2401',
|
||||
they mean "flag an error if height >= 2401". So we flip the comparison.
|
||||
they mean "flag an error if height >= 2401". So we implement the check to match
|
||||
that intuitive interpretation.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
threshold: The threshold value as a string
|
||||
|
||||
Returns:
|
||||
True if parameter value < threshold, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
if parameter_value is None:
|
||||
@@ -318,10 +505,21 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool:
|
||||
"""Checks if parameter value falls outside specified range.
|
||||
"""Checks if parameter value falls within specified range.
|
||||
|
||||
This implements the 'in range' predicate for numeric comparisons.
|
||||
The range is specified as "min,max" and is inclusive.
|
||||
|
||||
Note: From a UX perspective, if someone writes 'height in range 2401,3000',
|
||||
they mean "flag an error if height < 2401 or height > 3000".
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
value_range: Range specification as "min,max"
|
||||
|
||||
Returns:
|
||||
True if min <= parameter value <= max, False otherwise
|
||||
"""
|
||||
min_value, max_value = value_range.split(",")
|
||||
min_value = PropertyRules.parse_number_from_string(min_value)
|
||||
@@ -345,7 +543,22 @@ class PropertyRules:
|
||||
fuzzy: bool = False,
|
||||
threshold: float = 0.8,
|
||||
) -> bool:
|
||||
"""Checks if parameter value matches pattern."""
|
||||
"""Checks if parameter value matches pattern.
|
||||
|
||||
This implements the 'is like' predicate with two modes:
|
||||
1. Regular expression matching (fuzzy=False)
|
||||
2. Levenshtein distance-based fuzzy matching (fuzzy=True)
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
pattern: Regex pattern or string to match
|
||||
fuzzy: Whether to use fuzzy matching
|
||||
threshold: Similarity threshold for fuzzy matching (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
True if the parameter value matches the pattern, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
if parameter_value is None:
|
||||
return False
|
||||
@@ -358,7 +571,20 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value_in_list(speckle_object: Base, parameter_name: str, value_list: list[Any] | str) -> bool:
|
||||
"""Checks if parameter value is in list."""
|
||||
"""Checks if parameter value is in list.
|
||||
|
||||
This implements the 'in list' predicate, supporting both:
|
||||
1. Python lists
|
||||
2. Comma-separated string lists
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
value_list: List of values or comma-separated string
|
||||
|
||||
Returns:
|
||||
True if parameter value is in the list, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
|
||||
if isinstance(value_list, str):
|
||||
@@ -373,7 +599,19 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool:
|
||||
"""Check if a value matches any target value in expected format."""
|
||||
"""Check if a value matches any target value in expected format.
|
||||
|
||||
This is a helper for boolean parameter checking that handles:
|
||||
- Boolean literals (True/False)
|
||||
- String representations ("yes", "true", "1", etc.)
|
||||
|
||||
Args:
|
||||
value: The value to check
|
||||
values_to_match: Tuple of string values representing the target state
|
||||
|
||||
Returns:
|
||||
True if value matches any target value, False otherwise
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value is (True if "true" in values_to_match else False)
|
||||
|
||||
@@ -384,35 +622,103 @@ class PropertyRules:
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool:
|
||||
"""Check if parameter value represents true."""
|
||||
"""Check if parameter value represents true.
|
||||
|
||||
This implements the 'is true' predicate, handling various
|
||||
representations of true values ("yes", "true", "1").
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
|
||||
Returns:
|
||||
True if parameter value represents true, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
return PropertyRules.check_boolean_value(parameter_value, ("yes", "true", "1"))
|
||||
|
||||
@staticmethod
|
||||
def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool:
|
||||
"""Check if parameter value represents false."""
|
||||
"""Check if parameter value represents false.
|
||||
|
||||
This implements the 'is false' predicate, handling various
|
||||
representations of false values ("no", "false", "0").
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
parameter_name: The parameter name/path to check
|
||||
|
||||
Returns:
|
||||
True if parameter value represents false, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
return PropertyRules.check_boolean_value(parameter_value, ("no", "false", "0"))
|
||||
|
||||
@staticmethod
|
||||
def has_category(speckle_object: Base) -> bool:
|
||||
"""Check if object has category."""
|
||||
"""Check if object has category.
|
||||
|
||||
This is a convenience method specifically for checking
|
||||
the existence of the 'category' property.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
|
||||
Returns:
|
||||
True if object has a category property, False otherwise
|
||||
"""
|
||||
return PropertyRules.has_parameter(speckle_object, "category")
|
||||
|
||||
@staticmethod
|
||||
def is_category(speckle_object: Base, category_input: str) -> bool:
|
||||
"""Check if object matches category."""
|
||||
"""Check if object matches category.
|
||||
|
||||
This is a convenience method for filtering objects by category,
|
||||
which is a common operation in Speckle.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to check
|
||||
category_input: The category value to match
|
||||
|
||||
Returns:
|
||||
True if object's category matches input, False otherwise
|
||||
"""
|
||||
category_value = PropertyRules.get_parameter_value(speckle_object, "category")
|
||||
return category_value == category_input
|
||||
|
||||
@staticmethod
|
||||
def get_category_value(speckle_object: Base) -> str:
|
||||
"""Get object's category value."""
|
||||
"""Get object's category value.
|
||||
|
||||
This is a convenience method for retrieving an object's category.
|
||||
|
||||
Args:
|
||||
speckle_object: The Speckle object to get category from
|
||||
|
||||
Returns:
|
||||
The category value as a string
|
||||
"""
|
||||
return PropertyRules.get_parameter_value(speckle_object, "category")
|
||||
|
||||
@staticmethod
|
||||
def try_boolean_comparison(value1: Any, value2: Any, allow_yes_no: bool) -> tuple[bool, bool]:
|
||||
"""Attempts to compare two values as booleans."""
|
||||
"""Attempts to compare two values as booleans.
|
||||
|
||||
This handles various boolean representations:
|
||||
- Boolean literals (True/False)
|
||||
- String representations ("true"/"false")
|
||||
- Revit-style "Yes"/"No" strings (if allow_yes_no=True)
|
||||
|
||||
Args:
|
||||
value1: First value to compare
|
||||
value2: Second value to compare
|
||||
allow_yes_no: Whether to convert Yes/No strings to booleans
|
||||
|
||||
Returns:
|
||||
Tuple of (can_compare: bool, result: bool) where:
|
||||
- can_compare indicates if both values could be interpreted as booleans
|
||||
- result is the comparison result if can_compare is True
|
||||
"""
|
||||
|
||||
def strict_convert_boolean(value: Any) -> Any:
|
||||
"""Convert 'True'/'False' strings to booleans, and use `convert_revit_boolean` for Yes/No."""
|
||||
@@ -451,15 +757,25 @@ class PropertyRules:
|
||||
) -> bool:
|
||||
"""Core logic for comparing two values with type handling and tolerance.
|
||||
|
||||
This is the comprehensive value comparison function that:
|
||||
1. Tries boolean comparison first
|
||||
2. Handles numeric string conversion
|
||||
3. Implements case sensitivity options for strings
|
||||
4. Uses tolerance-based floating point comparison
|
||||
5. Falls back to regular equality
|
||||
|
||||
This function is used by multiple predicates.
|
||||
|
||||
Args:
|
||||
value1: First value to compare
|
||||
value2: Second value to compare
|
||||
case_sensitive: Whether to perform case-sensitive string comparison
|
||||
tolerance: Tolerance for floating point comparisons
|
||||
allow_yes_no_bools: Whether to convert Yes/No strings to booleans when comparing with boolean values
|
||||
allow_yes_no_bools: Whether to convert Yes/No strings to booleans
|
||||
use_exact: Whether to use exact equality for numeric comparisons
|
||||
|
||||
Returns:
|
||||
bool: True if values are considered equal, False otherwise
|
||||
True if values are considered equal, False otherwise
|
||||
"""
|
||||
# Try boolean comparison first
|
||||
can_compare, result = PropertyRules.try_boolean_comparison(value1, value2, allow_yes_no_bools)
|
||||
@@ -502,15 +818,20 @@ class PropertyRules:
|
||||
) -> bool:
|
||||
"""Compares a parameter value from a Speckle object with the provided value.
|
||||
|
||||
This implements the 'equal to' predicate with flexible comparison rules:
|
||||
- Case insensitivity option for strings
|
||||
- Tolerance-based comparison for floating point numbers
|
||||
- Type conversion for common scenarios (numeric strings, Yes/No)
|
||||
|
||||
Args:
|
||||
speckle_object (Base): The Speckle object containing the parameter
|
||||
parameter_name (str): Name of the parameter to compare
|
||||
value_to_match: The value to compare against (float, string, int, etc.)
|
||||
case_sensitive (bool): Whether to perform case-sensitive comparison for strings
|
||||
tolerance (float): Tolerance for floating point comparisons
|
||||
speckle_object: The Speckle object containing the parameter
|
||||
parameter_name: Name of the parameter to compare
|
||||
value_to_match: The value to compare against
|
||||
case_sensitive: Whether to perform case-sensitive comparison for strings
|
||||
tolerance: Tolerance for floating point comparisons
|
||||
|
||||
Returns:
|
||||
bool: True if values are considered equal, False otherwise
|
||||
True if values are considered equal, False otherwise
|
||||
"""
|
||||
parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
if parameter_value is None:
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
# import re
|
||||
# from typing import Any
|
||||
#
|
||||
# from Levenshtein import ratio
|
||||
# from specklepy.objects.base import Base
|
||||
#
|
||||
# from src.helpers import get_item, has_item, speckle_print
|
||||
# from src.inputs import PropertyMatchMode
|
||||
|
||||
# We're going to define a set of rules that will allow us to filter and
|
||||
# process parameters in our Speckle objects. These rules will be encapsulated
|
||||
# in a class called `ParameterRules`.
|
||||
|
||||
|
||||
# class Rules:
|
||||
# """A collection of rules for processing properties in Speckle objects.
|
||||
#
|
||||
# Simple rules can be straightforwardly implemented as static methods that
|
||||
# return boolean value to be used either as a filter or a condition.
|
||||
# These can then be abstracted into returning lambda functions that we can
|
||||
# use in our main processing logic. By encapsulating these rules, we can easily
|
||||
# extend or modify them in the future.
|
||||
# """
|
||||
#
|
||||
# @staticmethod
|
||||
# def try_get_display_value(
|
||||
# speckle_object: Base,
|
||||
# ) -> list[Base] | None:
|
||||
# """Try fetching the display value from a Speckle object.
|
||||
#
|
||||
# This method encapsulates the logic for attempting to retrieve the display value from a
|
||||
# Speckle object. It returns a list containing the display values if found,
|
||||
# otherwise it returns None.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to extract the display value from.
|
||||
#
|
||||
# Returns:
|
||||
# Optional[List[Base]]: A list containing the display values.
|
||||
# If no display value is found, returns None.
|
||||
# """
|
||||
# # Attempt to get the display value from the speckle_object
|
||||
# raw_display_value = getattr(speckle_object, "displayValue", None) or getattr(
|
||||
# speckle_object, "@displayValue", None
|
||||
# )
|
||||
#
|
||||
# # If no display value found, return None
|
||||
# if raw_display_value is None:
|
||||
# return None
|
||||
#
|
||||
# # If display value found, filter out non-Base objects
|
||||
# display_values = [value for value in raw_display_value if isinstance(value, Base)]
|
||||
#
|
||||
# # If no valid display values found, return None
|
||||
# if not display_values:
|
||||
# return None
|
||||
#
|
||||
# return display_values
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_displayable_object(speckle_object: Base) -> bool:
|
||||
# """Determines if a given Speckle object is displayable.
|
||||
#
|
||||
# This method encapsulates the logic for determining if a Speckle object is displayable.
|
||||
# It checks if the speckle_object has a display value and returns True if it does, otherwise it returns False.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the object has a display value, False otherwise.
|
||||
# """
|
||||
# # Check for direct displayable state using try_get_display_value
|
||||
# display_values = Rules.try_get_display_value(speckle_object)
|
||||
# if display_values and getattr(speckle_object, "id", None) is not None:
|
||||
# return True
|
||||
#
|
||||
# # Check for displayable state via definition, using try_get_display_value on the definition object
|
||||
# definition = getattr(speckle_object, "definition", None)
|
||||
# if definition:
|
||||
# definition_display_values = Rules.try_get_display_value(definition)
|
||||
# if definition_display_values and getattr(definition, "id", None) is not None:
|
||||
# return True
|
||||
#
|
||||
# return False
|
||||
#
|
||||
# @staticmethod
|
||||
# def get_displayable_objects(flat_list_of_objects: list[Base]) -> list[Base]:
|
||||
# """Filters a list of Speckle objects to only include displayable objects.
|
||||
#
|
||||
# This function takes a list of Speckle objects and filters out the objects that are displayable.
|
||||
# It returns a list containing only the displayable objects.
|
||||
#
|
||||
# Args:
|
||||
# flat_list_of_objects (List[Base]): The list of Speckle objects to filter.
|
||||
# """
|
||||
# return [
|
||||
# speckle_object
|
||||
# for speckle_object in flat_list_of_objects
|
||||
# if Rules.is_displayable_object(speckle_object) and getattr(speckle_object, "id", None)
|
||||
# ]
|
||||
#
|
||||
#
|
||||
# class PropertyRules:
|
||||
# """A collection of rules for processing Revit parameters in Speckle objects."""
|
||||
#
|
||||
# @staticmethod
|
||||
# def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool:
|
||||
# """Checks if the speckle_object has a parameter with the given name."""
|
||||
# found, _ = ParameterSearch.lookup_parameter(speckle_object, parameter_name)
|
||||
# return found
|
||||
#
|
||||
# @staticmethod
|
||||
# def get_parameter_value(
|
||||
# speckle_object: Base,
|
||||
# parameter_name: str,
|
||||
# match_mode: PropertyMatchMode = PropertyMatchMode.MIXED,
|
||||
# default_value: Any = None,
|
||||
# ) -> Any:
|
||||
# """Gets the value of a parameter if it exists."""
|
||||
# found, value = ParameterSearch.lookup_parameter(speckle_object, parameter_name, match_mode)
|
||||
# return value if found else default_value
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_v3(speckle_object: Base) -> bool:
|
||||
# """Determines if a Speckle object uses v3 parameter structure.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if object uses v3 structure, False otherwise
|
||||
# """
|
||||
# properties = get_item(speckle_object, "properties")
|
||||
# return bool(properties and has_item(properties, "Parameters"))
|
||||
#
|
||||
# # @staticmethod
|
||||
# # def has_parameter(speckle_object: Base, parameter_name: str, *_args, **_kwargs) -> bool:
|
||||
# # """Checks if the speckle_object has a Revit parameter with the given name.
|
||||
# #
|
||||
# # First checks direct properties, then determines if it's a v2 or v3 object structure
|
||||
# # and searches in the appropriate parameter hierarchy.
|
||||
# #
|
||||
# # Args:
|
||||
# # speckle_object (Base): The Speckle object to check.
|
||||
# # parameter_name (str): The name of the parameter to check for.
|
||||
# # *_args: Extra positional arguments which are ignored.
|
||||
# # **_kwargs: Extra keyword arguments which are ignored.
|
||||
# #
|
||||
# # Returns:
|
||||
# # bool: True if the object has the parameter, False otherwise.
|
||||
# # """
|
||||
# # # Check direct property first regardless of version
|
||||
# # if has_item(speckle_object, parameter_name):
|
||||
# # return True
|
||||
# #
|
||||
# # if PropertyRules.is_v3(speckle_object):
|
||||
# # properties = get_item(speckle_object, "properties")
|
||||
# # parameters = get_item(properties, "Parameters")
|
||||
# # if parameters:
|
||||
# #
|
||||
# # def search_v3_params(params: dict, search_name: str) -> bool:
|
||||
# # for key, value in params.items():
|
||||
# # if isinstance(value, dict):
|
||||
# # # Check direct name match
|
||||
# # if key.lower() == search_name.lower():
|
||||
# # return True
|
||||
# # # Check nested parameters
|
||||
# # if search_v3_params(value, search_name):
|
||||
# # return True
|
||||
# # return False
|
||||
# #
|
||||
# # return search_v3_params(parameters, parameter_name)
|
||||
# # else:
|
||||
# # # Handle v2 structure
|
||||
# # parameters = get_item(speckle_object, "parameters")
|
||||
# # if not parameters:
|
||||
# # return False
|
||||
# #
|
||||
# # # Check direct parameter name match
|
||||
# # if has_item(parameters, parameter_name):
|
||||
# # return True
|
||||
# #
|
||||
# # # Check nested parameters with name property
|
||||
# # def check_nested_name(value: Any) -> bool:
|
||||
# # if isinstance(value, dict):
|
||||
# # return get_item(value, "name") == parameter_name
|
||||
# # return get_item(value, "name") == parameter_name if hasattr(value, "name") else False
|
||||
# #
|
||||
# # return any(check_nested_name(param_value) for param_value in parameters.values() if param_value is not None)
|
||||
# #
|
||||
# # return False
|
||||
# #
|
||||
# # @staticmethod
|
||||
# # def get_parameter_value(
|
||||
# # speckle_object: Base,
|
||||
# # parameter_name: str,
|
||||
# # match_mode: PropertyMatchMode = PropertyMatchMode.MIXED,
|
||||
# # default_value: Any = None,
|
||||
# # ) -> Any | None:
|
||||
# # """Retrieves the value of the specified parameter from the speckle_object.
|
||||
# #
|
||||
# # First checks direct properties, then determines if it's a v2 or v3 object structure
|
||||
# # and retrieves from the appropriate parameter hierarchy.
|
||||
# #
|
||||
# # Args:
|
||||
# # speckle_object (Base): The Speckle object to retrieve the parameter value from.
|
||||
# # parameter_name (str): The name of the parameter to retrieve the value for.
|
||||
# # match_mode (PropertyMatchMode): The matching mode to use for parameter lookup
|
||||
# # default_value: The default value to return if parameter not found.
|
||||
# #
|
||||
# # Returns:
|
||||
# # The value of the parameter if found, else default_value.
|
||||
# # """
|
||||
# # # Check direct property first regardless of version
|
||||
# # if has_item(speckle_object, parameter_name):
|
||||
# # value = get_item(speckle_object, parameter_name)
|
||||
# # return value if value is not None else default_value
|
||||
# #
|
||||
# # if PropertyRules.is_v3(speckle_object):
|
||||
# # return PropertyRules.get_v3_parameter(speckle_object, parameter_name, match_mode, default_value)
|
||||
# # else:
|
||||
# # return PropertyRules.get_v2_parameter(speckle_object, parameter_name, match_mode, default_value)
|
||||
#
|
||||
# # @staticmethod
|
||||
# # def get_v2_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any:
|
||||
# # """Get parameter value from v2 Speckle object structure.
|
||||
# #
|
||||
# # Args:
|
||||
# # obj: Speckle object to get parameter from
|
||||
# # name: Parameter name to retrieve
|
||||
# # mode: Match mode for parameter lookup
|
||||
# # default: Default value if parameter not found
|
||||
# #
|
||||
# # Returns:
|
||||
# # Parameter value if found, else default
|
||||
# # """
|
||||
# # parameters = get_item(obj, "parameters")
|
||||
# # if not parameters:
|
||||
# # return default
|
||||
# #
|
||||
# # if mode == PropertyMatchMode.STRICT:
|
||||
# # return PropertyRules.strict_parameter_lookup(name, parameters, default)
|
||||
# #
|
||||
# # def search_params(param_dict: dict, search_name: str, fuzzy: bool) -> Any:
|
||||
# # for key, value in param_dict.items():
|
||||
# # key_match = (key.lower() == search_name.lower()) or (fuzzy and search_name.lower() in key.lower())
|
||||
# # if key_match:
|
||||
# # # Handle both direct values and nested parameter objects
|
||||
# # return get_item(value, "value", value)
|
||||
# # return None
|
||||
# #
|
||||
# # result = search_params(parameters, name, mode == PropertyMatchMode.FUZZY)
|
||||
# # return result if result is not None else default
|
||||
# #
|
||||
# # @staticmethod
|
||||
# # def get_v3_parameter(obj: Base, name: str, mode: PropertyMatchMode, default: Any) -> Any:
|
||||
# # """Get parameter value from v3 Speckle object structure.
|
||||
# #
|
||||
# # Args:
|
||||
# # obj: Speckle object to get parameter from
|
||||
# # name: Parameter name to retrieve
|
||||
# # mode: Match mode for parameter lookup
|
||||
# # default: Default value if parameter not found
|
||||
# #
|
||||
# # Returns:
|
||||
# # Parameter value if found, else default
|
||||
# # """
|
||||
# # properties = get_item(obj, "properties")
|
||||
# # if not properties or not has_item(properties, "Parameters"):
|
||||
# # return default
|
||||
# #
|
||||
# # parameters = get_item(properties, "Parameters")
|
||||
# # if not parameters:
|
||||
# # return default
|
||||
# #
|
||||
# # if mode == PropertyMatchMode.STRICT:
|
||||
# # return PropertyRules.strict_parameter_lookup(name, parameters, default)
|
||||
# #
|
||||
# # def search_nested(data: dict, search_name: str, fuzzy: bool) -> Any:
|
||||
# # for nested_key, value in data.items():
|
||||
# # if isinstance(value, dict):
|
||||
# # key_match = (nested_key.lower() == search_name.lower()) or (
|
||||
# # fuzzy and search_name.lower() in nested_key.lower()
|
||||
# # )
|
||||
# #
|
||||
# # if key_match and has_item(value, "value"):
|
||||
# # return get_item(value, "value")
|
||||
# #
|
||||
# # nested_result = search_nested(value, search_name, fuzzy)
|
||||
# # if nested_result is not None:
|
||||
# # return nested_result
|
||||
# # return None
|
||||
# #
|
||||
# # result = search_nested(parameters, name, mode == PropertyMatchMode.FUZZY)
|
||||
# # return result if result is not None else default
|
||||
# #
|
||||
# # @staticmethod
|
||||
# # def strict_parameter_lookup(name: str, parameters: dict, default: Any) -> Any:
|
||||
# # """Perform strict parameter lookup following exact path.
|
||||
# #
|
||||
# # Args:
|
||||
# # name: Parameter path (dot separated)
|
||||
# # parameters: Parameters dictionary
|
||||
# # default: Default value if not found
|
||||
# #
|
||||
# # Returns:
|
||||
# # Parameter value if found, else default
|
||||
# # """
|
||||
# # path_parts = name.split(".")
|
||||
# # current = parameters
|
||||
# #
|
||||
# # for part in path_parts:
|
||||
# # if not current or not isinstance(current, dict):
|
||||
# # return default
|
||||
# #
|
||||
# # # Find exact case-insensitive match
|
||||
# # key = next((k for k in current.keys() if k.lower() == part.lower()), None)
|
||||
# # if not key:
|
||||
# # return default
|
||||
# #
|
||||
# # current = get_item(current, key)
|
||||
# #
|
||||
# # # Handle both direct values and parameter objects
|
||||
# # if isinstance(current, dict):
|
||||
# # return get_item(current, "value", current)
|
||||
# # return current
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value(speckle_object: Base, parameter_name: str, value_to_match: Any) -> bool:
|
||||
# """Checks if the value of the specified parameter matches the given value.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# value_to_match (Any): The value to match against.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value matches the given value, False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# return parameter_value == value_to_match
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_like(
|
||||
# speckle_object: Base,
|
||||
# parameter_name: str,
|
||||
# pattern: str,
|
||||
# fuzzy: bool = False,
|
||||
# threshold: float = 0.8,
|
||||
# ) -> bool:
|
||||
# """Checks if the value of the specified parameter matches the given pattern.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# pattern (str): The pattern to match against.
|
||||
# fuzzy (bool): If True, performs fuzzy matching using Levenshtein distance.
|
||||
# If False (default), performs exact pattern matching using regular expressions.
|
||||
# threshold (float): The similarity threshold for fuzzy matching (default: 0.8).
|
||||
# Only applicable when fuzzy=True.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value matches the pattern (exact or fuzzy), False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# if parameter_value is None:
|
||||
# return False
|
||||
#
|
||||
# if fuzzy:
|
||||
# similarity = ratio(str(parameter_value), pattern)
|
||||
# return similarity >= threshold
|
||||
# else:
|
||||
# return bool(re.match(pattern, str(parameter_value)))
|
||||
#
|
||||
# @staticmethod
|
||||
# def parse_number_from_string(input_string: str):
|
||||
# """Attempts to parse an integer or float from a given string.
|
||||
#
|
||||
# Args:
|
||||
# input_string (str): The string containing the number to be parsed.
|
||||
#
|
||||
# Returns:
|
||||
# int or float: The parsed number, or raises ValueError if parsing is not possible.
|
||||
# """
|
||||
# try:
|
||||
# # First try to convert it to an integer
|
||||
# return int(input_string)
|
||||
# except ValueError:
|
||||
# # If it fails to convert to an integer, try to convert to a float
|
||||
# try:
|
||||
# return float(input_string)
|
||||
# except ValueError:
|
||||
# # Raise an error if neither conversion is possible
|
||||
# raise ValueError("Input string is not a valid integer or float")
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_greater_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
|
||||
# """Checks if the value of the specified parameter is greater than the given threshold.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# threshold (Union[int, float]): The threshold value to compare against.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value is greater than the threshold, False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# if parameter_value is None:
|
||||
# return False
|
||||
#
|
||||
# if not isinstance(parameter_value, int | float):
|
||||
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
|
||||
# return parameter_value > PropertyRules.parse_number_from_string(threshold)
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_less_than(speckle_object: Base, parameter_name: str, threshold: str) -> bool:
|
||||
# """Checks if the value of the specified parameter is less than the given threshold.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# threshold (Union[int, float]): The threshold value to compare against.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value is less than the threshold, False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# if parameter_value is None:
|
||||
# return False
|
||||
# if not isinstance(parameter_value, int | float):
|
||||
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
|
||||
# return parameter_value < PropertyRules.parse_number_from_string(threshold)
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_in_range(speckle_object: Base, parameter_name: str, value_range: str) -> bool:
|
||||
# """Checks if the value of the specified parameter falls within the given range.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# value_range (str): The range to check against, in the format "min_value, max_value".
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value falls within the range (inclusive), False otherwise.
|
||||
# """
|
||||
# min_value, max_value = value_range.split(",")
|
||||
# min_value = PropertyRules.parse_number_from_string(min_value)
|
||||
# max_value = PropertyRules.parse_number_from_string(max_value)
|
||||
#
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# if parameter_value is None:
|
||||
# return False
|
||||
# if not isinstance(parameter_value, int | float):
|
||||
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
|
||||
#
|
||||
# return min_value <= parameter_value <= max_value
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_in_range_expanded(
|
||||
# speckle_object: Base,
|
||||
# parameter_name: str,
|
||||
# min_value: int | float,
|
||||
# max_value: int | float,
|
||||
# inclusive: bool = True,
|
||||
# ) -> bool:
|
||||
# """Checks if the value of the specified parameter falls within the given range.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# min_value (Union[int, float]): The minimum value of the range.
|
||||
# max_value (Union[int, float]): The maximum value of the range.
|
||||
# inclusive (bool): If True (default), the range is inclusive (min <= value <= max).
|
||||
# If False, the range is exclusive (min < value < max).
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value falls within the range (inclusive), False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# if parameter_value is None:
|
||||
# return False
|
||||
# if not isinstance(parameter_value, int | float):
|
||||
# raise ValueError(f"Parameter value must be a number, got {type(parameter_value)}")
|
||||
#
|
||||
# return min_value <= parameter_value <= max_value if inclusive else min_value < parameter_value < max_value
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_in_list(speckle_object: Base, parameter_name: str, value_list: list[Any] | str) -> bool:
|
||||
# """Checks if the value of the specified parameter is present in the given list of values.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# parameter_name (str): The name of the parameter to check.
|
||||
# value_list (List[Any]): The list of values to check against.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the parameter value is found in the list, False otherwise.
|
||||
# """
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
#
|
||||
# if isinstance(value_list, str):
|
||||
# value_list = [value.strip() for value in value_list.split(",")]
|
||||
#
|
||||
# # parameter_value is effectively Any type, so to find its value in the value_list
|
||||
# def is_value_in_list(value: Any, my_list: Any) -> bool:
|
||||
# # Ensure that my_list is actually a list
|
||||
# if isinstance(my_list, list):
|
||||
# return value in my_list or str(value) in my_list
|
||||
# else:
|
||||
# speckle_print(f"Expected a list, got {type(my_list)} instead.")
|
||||
# return False
|
||||
#
|
||||
# return is_value_in_list(parameter_value, value_list)
|
||||
#
|
||||
# @staticmethod
|
||||
# def _check_boolean_value(value: Any, values_to_match: tuple[str, ...]) -> bool:
|
||||
# """Check if a value matches any target value in expected format."""
|
||||
# if isinstance(value, bool):
|
||||
# return value is (True if "true" in values_to_match else False)
|
||||
#
|
||||
# if isinstance(value, str):
|
||||
# return value.lower() in values_to_match
|
||||
#
|
||||
# return False
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_true(speckle_object: Base, parameter_name: str) -> bool:
|
||||
# """Check if parameter value represents true (boolean True, 'yes', 'true', '1')."""
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# return PropertyRules._check_boolean_value(parameter_value, ("yes", "true", "1"))
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_parameter_value_false(speckle_object: Base, parameter_name: str) -> bool:
|
||||
# """Check if parameter value represents false (boolean False, 'no', 'false', '0')."""
|
||||
# parameter_value = PropertyRules.get_parameter_value(speckle_object, parameter_name)
|
||||
# return PropertyRules._check_boolean_value(parameter_value, ("no", "false", "0"))
|
||||
#
|
||||
# @staticmethod
|
||||
# def has_category(speckle_object: Base) -> bool:
|
||||
# """Checks if the speckle_object has a 'category' parameter.
|
||||
#
|
||||
# This method checks if the speckle_object has a 'category' parameter.
|
||||
# If the 'category' parameter exists, it returns True; otherwise, it returns False.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the object has the 'category' parameter, False otherwise.
|
||||
# """
|
||||
# return PropertyRules.has_parameter(speckle_object, "category")
|
||||
#
|
||||
# @staticmethod
|
||||
# def is_category(speckle_object: Base, category_input: str) -> bool:
|
||||
# """Checks if the value of the 'category' property matches the given input.
|
||||
#
|
||||
# This method checks if the 'category' property of the speckle_object
|
||||
# matches the given category_input. If they match, it returns True;
|
||||
# otherwise, it returns False.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to check.
|
||||
# category_input (str): The category value to compare against.
|
||||
#
|
||||
# Returns:
|
||||
# bool: True if the 'category' property matches the input, False otherwise.
|
||||
# """
|
||||
# category_value = PropertyRules.get_parameter_value(speckle_object, "category")
|
||||
# return category_value == category_input
|
||||
#
|
||||
# @staticmethod
|
||||
# def get_category_value(speckle_object: Base) -> str:
|
||||
# """Retrieves the value of the 'category' parameter from the speckle_object.
|
||||
#
|
||||
# This method retrieves the value of the 'category' parameter from the speckle_object.
|
||||
# If the 'category' parameter exists and its value is not None, it returns the value.
|
||||
# If the 'category' parameter does not exist or its value is None, it returns an empty string.
|
||||
#
|
||||
# Args:
|
||||
# speckle_object (Base): The Speckle object to retrieve the 'category' parameter value from.
|
||||
#
|
||||
# Returns:
|
||||
# str: The value of the 'category' parameter if it exists and is not None, or an empty string otherwise.
|
||||
# """
|
||||
# return PropertyRules.get_parameter_value(speckle_object, "category")
|
||||
#
|
||||
#
|
||||
# class ParameterSearch:
|
||||
# """Unified parameter search functionality for Speckle objects."""
|
||||
#
|
||||
# @staticmethod
|
||||
# def convert_revit_boolean(value: Any) -> Any:
|
||||
# """Convert Revit-style Yes/No strings to boolean values.
|
||||
#
|
||||
# Args:
|
||||
# value: The value to potentially convert
|
||||
#
|
||||
# Returns:
|
||||
# bool if value is a Revit boolean string, original value otherwise
|
||||
# """
|
||||
# if isinstance(value, str):
|
||||
# if value.lower() == "yes":
|
||||
# return True
|
||||
# if value.lower() == "no":
|
||||
# return False
|
||||
# return value
|
||||
#
|
||||
# @staticmethod
|
||||
# def search_parameters(
|
||||
# params: dict, search_name: str, mode: PropertyMatchMode = PropertyMatchMode.STRICT
|
||||
# ) -> tuple[bool, Any]:
|
||||
# """Search for parameters using consistent matching logic.
|
||||
#
|
||||
# Supports flexible property chain matching that can find paths like "Instance Parameters.Dimensions.Length"
|
||||
# within longer chains like "properties.Parameters.Instance Parameters.Dimensions.Length.value".
|
||||
# Uses STRICT matching by default for more predictable results.
|
||||
#
|
||||
# Args:
|
||||
# params: Parameter dictionary to search
|
||||
# search_name: Name of parameter to find, can be dot-separated chain
|
||||
# mode: Matching mode to use (STRICT by default, or FUZZY/MIXED for looser matching)
|
||||
#
|
||||
# Returns:
|
||||
# Tuple of (value_found: bool, value: Any)
|
||||
# """
|
||||
#
|
||||
# def matches_name(match_key: str, target: str, match_mode: PropertyMatchMode) -> bool:
|
||||
# if match_mode == PropertyMatchMode.STRICT:
|
||||
# return match_key.lower() == target.lower()
|
||||
# elif match_mode == PropertyMatchMode.FUZZY:
|
||||
# return target.lower() in match_key.lower()
|
||||
# else: # MIXED mode
|
||||
# return match_key.lower() == target.lower() or target.lower() in match_key.lower()
|
||||
#
|
||||
# def try_get_value(obj: Any) -> Any:
|
||||
# """Extract value from parameter object or return as is.
|
||||
#
|
||||
# Handles both dict and Base objects, checking for 'value' property in both cases.
|
||||
# Returns the 'value' if found, otherwise returns the original object.
|
||||
# """
|
||||
# # Handle dictionary objects
|
||||
# if isinstance(obj, dict):
|
||||
# return obj.get("value", obj)
|
||||
#
|
||||
# # Handle Base objects
|
||||
# if isinstance(obj, Base):
|
||||
# return getattr(obj, "value", obj)
|
||||
#
|
||||
# # For all other types, return as is
|
||||
# return obj
|
||||
#
|
||||
# # First try property chain lookup
|
||||
# if "." in search_name:
|
||||
# search_parts = search_name.split(".")
|
||||
#
|
||||
# def try_match_path(current: dict, remaining_search_parts: list[str], depth: int = 0) -> tuple[bool, Any]:
|
||||
# if not isinstance(current, dict):
|
||||
# return False, None
|
||||
#
|
||||
# if not remaining_search_parts: # We've matched all parts
|
||||
# return True, try_get_value(current)
|
||||
#
|
||||
# current_search = remaining_search_parts[0]
|
||||
#
|
||||
# # Try each key at current level
|
||||
# for key, item_value in current.items():
|
||||
# if matches_name(key, current_search, mode):
|
||||
# # Found a match for current part, recurse with rest
|
||||
# match_found, result = try_match_path(item_value, remaining_search_parts[1:], depth + 1)
|
||||
# if match_found:
|
||||
# return True, result
|
||||
#
|
||||
# # If no match found and value is a dict, try searching deeper
|
||||
# if isinstance(item_value, dict):
|
||||
# match_found, result = try_match_path(item_value, remaining_search_parts, depth)
|
||||
# if match_found:
|
||||
# return True, result
|
||||
#
|
||||
# return False, None
|
||||
#
|
||||
# try:
|
||||
# found, value = try_match_path(params, search_parts)
|
||||
# if found:
|
||||
# return True, value
|
||||
# except Exception:
|
||||
# pass # Fall through to recursive search if chain lookup fails
|
||||
#
|
||||
# # Recursive search through nested dictionaries
|
||||
# def recursive_search(data: dict | Base, target: str) -> tuple[bool, Any]:
|
||||
# if not isinstance(data, dict | Base):
|
||||
# return False, None
|
||||
#
|
||||
# # Handle both dict and Base objects for iteration
|
||||
# if isinstance(data, dict):
|
||||
# items = data.items()
|
||||
# else:
|
||||
# items = [(k, getattr(data, k)) for k in dir(data) if not k.startswith("_")]
|
||||
#
|
||||
# # First check current level
|
||||
# for key, item_value in items:
|
||||
# if matches_name(key, target, mode):
|
||||
# return True, try_get_value(item_value)
|
||||
#
|
||||
# # Then check nested levels
|
||||
# for _, item_value in items:
|
||||
# if isinstance(item_value, dict | Base):
|
||||
# item_found, result = recursive_search(item_value, target)
|
||||
# if item_found:
|
||||
# return True, result
|
||||
#
|
||||
# return False, None
|
||||
#
|
||||
# return recursive_search(params, search_name.split(".")[-1] if "." in search_name else search_name)
|
||||
#
|
||||
# @staticmethod
|
||||
# def lookup_parameter(
|
||||
# obj: Base, param_name: str, mode: PropertyMatchMode = PropertyMatchMode.MIXED
|
||||
# ) -> tuple[bool, Any]:
|
||||
# """Unified parameter lookup for both checking existence and getting values.
|
||||
#
|
||||
# Args:
|
||||
# obj: Speckle object to search
|
||||
# param_name: Parameter name to find
|
||||
# mode: Matching mode to use
|
||||
#
|
||||
# Returns:
|
||||
# Tuple of (found: bool, value: Any)
|
||||
# """
|
||||
# # Check direct property first
|
||||
# if has_item(obj, param_name):
|
||||
# value = get_item(obj, param_name)
|
||||
# # Check if the direct property has a value field
|
||||
# if isinstance(value, dict) and "value" in value:
|
||||
# return True, value["value"]
|
||||
# return True, value
|
||||
#
|
||||
# # Handle v3 structure
|
||||
# if PropertyRules.is_v3(obj):
|
||||
# properties = get_item(obj, "properties")
|
||||
# if not properties or not has_item(properties, "Parameters"):
|
||||
# return False, None
|
||||
#
|
||||
# parameters = get_item(properties, "Parameters")
|
||||
# if not parameters:
|
||||
# return False, None
|
||||
#
|
||||
# return ParameterSearch.search_parameters(parameters, param_name, mode)
|
||||
#
|
||||
# # Handle v2 structure
|
||||
# parameters = get_item(obj, "parameters")
|
||||
# if not parameters:
|
||||
# return False, None
|
||||
#
|
||||
# return ParameterSearch.search_parameters(parameters, param_name, mode)
|
||||
+75
-15
@@ -1,4 +1,26 @@
|
||||
"""Module for reading and processing rules from a cloud hosted TSV file."""
|
||||
"""Module for reading and processing rules from a cloud hosted TSV file.
|
||||
|
||||
This module handles the loading and processing of validation rules from external
|
||||
spreadsheet data, enabling non-technical users to define and modify rules.
|
||||
|
||||
Key features:
|
||||
1. Reading from hosted TSV files (e.g., from Google Sheets)
|
||||
2. Processing rule numbers for consistent grouping
|
||||
3. Handling mixed data types in spreadsheet columns
|
||||
4. Validating rule structure and providing feedback
|
||||
5. Grouping related rule conditions for execution
|
||||
|
||||
The spreadsheet format used follows a specific structure:
|
||||
- Rule Number: Groups related conditions together
|
||||
- Logic: WHERE/AND/CHECK to define condition relationships
|
||||
- Property Name: The property path to check
|
||||
- Predicate: The comparison operation (equals, greater than, etc.)
|
||||
- Value: The value to compare against
|
||||
- Message: The message to display for rule results
|
||||
- Severity: INFO/WARNING/ERROR level for failures
|
||||
"""
|
||||
|
||||
import traceback
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
@@ -8,14 +30,20 @@ from pandas.core.groupby import DataFrameGroupBy
|
||||
def process_rule_numbers(df: DataFrame) -> DataFrame:
|
||||
"""Process rule numbers in a DataFrame while preserving original rule identifiers.
|
||||
|
||||
Makes no assumptions about rule number format - preserves them exactly as provided.
|
||||
Only generates new numbers (as integers) when no rule number exists.
|
||||
This function handles various rule numbering scenarios:
|
||||
1. Preserves existing rule numbers exactly as provided
|
||||
2. Generates sequential numbers for missing rule numbers
|
||||
3. Ensures all rows in a logical rule group have the same rule number
|
||||
|
||||
This is important because rule numbers determine how conditions are grouped
|
||||
and executed together.
|
||||
|
||||
Args:
|
||||
df: DataFrame with columns including 'Rule Number' and 'Logic'
|
||||
|
||||
Returns:
|
||||
DataFrame with processed rule numbers
|
||||
DataFrame with processed rule numbers, where all related conditions
|
||||
have the same rule number
|
||||
"""
|
||||
# Create a copy to avoid modifying original
|
||||
df = df.copy()
|
||||
@@ -36,8 +64,10 @@ def process_rule_numbers(df: DataFrame) -> DataFrame:
|
||||
# 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]
|
||||
# Try to get rule number from first row, fall back to "Rule #"
|
||||
group_rule_num = (
|
||||
group_slice["Rule Number"].iloc[0] if not pd.isna(group_slice["Rule Number"].iloc[0]) else "Rule #"
|
||||
)
|
||||
|
||||
if pd.isna(group_rule_num):
|
||||
# If no rule number, generate next available number
|
||||
@@ -64,6 +94,14 @@ def process_rule_numbers(df: DataFrame) -> DataFrame:
|
||||
def validate_rule_numbers(df: DataFrame) -> list[str]:
|
||||
"""Validate rule numbers and return any warnings or errors.
|
||||
|
||||
This checks for issues like:
|
||||
1. Missing rule numbers
|
||||
2. Non-integer rule numbers
|
||||
3. Duplicate rule numbers
|
||||
|
||||
These validations help ensure rule integrity without being overly strict,
|
||||
allowing for different user approaches to rule numbering.
|
||||
|
||||
Args:
|
||||
df: DataFrame with processed rule numbers
|
||||
|
||||
@@ -76,10 +114,10 @@ def validate_rule_numbers(df: DataFrame) -> list[str]:
|
||||
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 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"]
|
||||
@@ -91,10 +129,17 @@ def validate_rule_numbers(df: DataFrame) -> list[str]:
|
||||
|
||||
|
||||
def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] | tuple[None, list[str]]:
|
||||
"""Reads a TSV file from a provided URL, processes rule numbers, and returns grouped rules.
|
||||
"""Reads rules from a TSV file at the provided URL, processes them, and returns grouped rules.
|
||||
|
||||
This function is the main entry point for rule loading:
|
||||
1. Reads the TSV file from the provided URL
|
||||
2. Converts mixed type columns to appropriate types
|
||||
3. Processes rule numbers for consistent grouping
|
||||
4. Validates rule numbers and collects messages
|
||||
5. Groups rules by rule number for execution
|
||||
|
||||
Args:
|
||||
url (str): The URL to the TSV file
|
||||
url: The URL to the TSV file containing rule definitions
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
@@ -103,33 +148,48 @@ def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]]
|
||||
"""
|
||||
try:
|
||||
# Read the TSV file
|
||||
# The TSV format is chosen for compatibility with Google Sheets
|
||||
# and other spreadsheet applications
|
||||
df = pd.read_csv(url, sep="\t")
|
||||
|
||||
# Convert mixed type columns
|
||||
# This handles inconsistencies in spreadsheet data
|
||||
df = convert_mixed_columns(df)
|
||||
|
||||
# Process rule numbers
|
||||
# This ensures all related conditions have the same rule number
|
||||
df = process_rule_numbers(df)
|
||||
|
||||
# Get validation messages
|
||||
# These are warnings about potential issues with the rules
|
||||
messages = validate_rule_numbers(df)
|
||||
|
||||
# Group by rule number
|
||||
# This creates a DataFrameGroupBy object that groups related conditions
|
||||
grouped_rules = df.groupby("Rule Number")
|
||||
|
||||
return grouped_rules, messages
|
||||
|
||||
except Exception as e:
|
||||
return None, [f"Failed to read the TSV from the URL: {str(e)}"]
|
||||
# Handle any errors in reading or processing the spreadsheet
|
||||
traceback.print_exc()
|
||||
return None, [f"Failed to read the TSV from the URL: {str(e)}:{e.with_traceback(None)}"]
|
||||
|
||||
|
||||
def convert_mixed_columns(df: DataFrame) -> DataFrame:
|
||||
"""Converts columns in a DataFrame to appropriate types based on their content.
|
||||
|
||||
null or empty strings are converted to empty strings instead of NaN.
|
||||
This handles common issues with spreadsheet data:
|
||||
1. Numeric columns that contain strings
|
||||
2. Mixed type columns
|
||||
3. Empty cells and NaN values
|
||||
|
||||
The approach is to convert each column appropriately:
|
||||
- Numeric columns remain as numbers
|
||||
- Other columns are converted to strings, with empty strings for missing values
|
||||
|
||||
Args:
|
||||
df (DataFrame): The DataFrame whose columns are to be converted
|
||||
df: The DataFrame whose columns are to be converted
|
||||
|
||||
Returns:
|
||||
DataFrame with columns converted to appropriate types
|
||||
|
||||
+41
-78
@@ -2,67 +2,6 @@ import pytest
|
||||
from specklepy.objects.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v2_wall():
|
||||
"""Creates a v2-style Speckle wall object."""
|
||||
wall = Base()
|
||||
wall.id = "cdb18060dc48281909e94f0f1d8d3cc0"
|
||||
wall.type = "W30(Fc24)"
|
||||
wall.units = "mm"
|
||||
wall.family = "Basic Wall"
|
||||
wall.height = 1400
|
||||
wall.flipped = False
|
||||
wall.category = "Walls"
|
||||
wall.elementId = "4479852"
|
||||
wall.worksetId = "0"
|
||||
wall.structural = True
|
||||
wall.baseOffset = -2000
|
||||
wall.topOffset = -600
|
||||
|
||||
# Create base line geometry
|
||||
wall.baseLine = Base()
|
||||
wall.baseLine.start = Base()
|
||||
wall.baseLine.start.x = 22400.000000000007
|
||||
wall.baseLine.start.y = 15199.999999999998
|
||||
wall.baseLine.start.z = -2000.0000000000002
|
||||
wall.baseLine.end = Base()
|
||||
wall.baseLine.end.x = 22400.000000000015
|
||||
wall.baseLine.end.y = 20500
|
||||
wall.baseLine.end.z = -2000.0000000000002
|
||||
wall.baseLine.units = "mm"
|
||||
wall.baseLine.length = 5300.000000000002
|
||||
|
||||
# Create parameters structure
|
||||
wall.parameters = Base()
|
||||
|
||||
# Standard parameter
|
||||
wall.parameters["WALL_ATTR_WIDTH_PARAM"] = Base()
|
||||
wall.parameters["WALL_ATTR_WIDTH_PARAM"].name = "Width"
|
||||
wall.parameters["WALL_ATTR_WIDTH_PARAM"].value = 300
|
||||
wall.parameters["WALL_ATTR_WIDTH_PARAM"].units = "mm"
|
||||
|
||||
# Parameter with GUID key
|
||||
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"] = Base()
|
||||
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].name = "符号"
|
||||
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].value = "W30"
|
||||
wall.parameters["ee1f33e1-5506-4a64-b87b-7b98d30aea52"].isShared = True
|
||||
wall.parameters[
|
||||
"ee1f33e1-5506-4a64-b87b-7b98d30aea52"
|
||||
].internalDefinitionName = "ee1f33e1-5506-4a64-b87b-7b98d30aea52"
|
||||
|
||||
wall.parameters["STRUCTURAL_MATERIAL_PARAM"] = Base()
|
||||
wall.parameters["STRUCTURAL_MATERIAL_PARAM"].name = "Structural Material"
|
||||
wall.parameters["STRUCTURAL_MATERIAL_PARAM"].value = "Fc24"
|
||||
|
||||
# Create basic level reference
|
||||
wall.level = Base()
|
||||
wall.level.name = "1FL"
|
||||
wall.level.elevation = 0
|
||||
wall.level.units = "mm"
|
||||
|
||||
return wall
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v3_wall():
|
||||
"""Creates a v3-style Speckle wall object."""
|
||||
@@ -79,25 +18,47 @@ def v3_wall():
|
||||
|
||||
# Create location geometry
|
||||
wall.location = Base()
|
||||
wall.location.id = "9c76b8de34382c9052965ee463f8374b"
|
||||
wall.location.start = Base()
|
||||
wall.location.start.x = 22400.000000000007
|
||||
wall.location.start.y = 15199.999999999998
|
||||
wall.location.start.z = 0
|
||||
wall.location.start.id = "d0c4fdb2e11cc825e7f05f9dc88a0be1"
|
||||
wall.location.start.units = "mm"
|
||||
wall.location.start.speckle_type = "Objects.Geometry.Point"
|
||||
wall.location.end = Base()
|
||||
wall.location.end.x = 22400.000000000015
|
||||
wall.location.end.y = 20500
|
||||
wall.location.end.z = 0
|
||||
wall.location.end.id = "3455575bfd8939f264d295b61e74156f"
|
||||
wall.location.end.units = "mm"
|
||||
wall.location.end.speckle_type = "Objects.Geometry.Point"
|
||||
wall.location.units = "mm"
|
||||
wall.location.domain = Base()
|
||||
wall.location.domain.id = "3b97feaad2dbcc2d894c9cec024a9bf2"
|
||||
wall.location.domain.end = 17.388451443569522
|
||||
wall.location.domain.start = -3.552713668866051e-14
|
||||
wall.location.domain.speckle_type = "Objects.Primitive.Interval"
|
||||
wall.location.length = 5300.000000000002
|
||||
wall.location.speckle_type = "Objects.Geometry.Line"
|
||||
|
||||
# Create nested properties structure
|
||||
# Create level references
|
||||
wall.level = Base()
|
||||
wall.level.name = "1FL"
|
||||
wall.level.units = "mm"
|
||||
wall.level.elevation = 0
|
||||
|
||||
wall.topLevel = Base()
|
||||
wall.topLevel.name = "1FL"
|
||||
wall.topLevel.units = "mm"
|
||||
wall.topLevel.elevation = 0
|
||||
|
||||
# Create properties structure
|
||||
wall.properties = Base()
|
||||
wall.properties.Parameters = Base()
|
||||
|
||||
# Type Parameters
|
||||
wall.properties.Parameters["Type Parameters"] = Base()
|
||||
|
||||
# Add Text section with GUID parameter
|
||||
# Add Text section
|
||||
wall.properties.Parameters["Type Parameters"].Text = Base()
|
||||
wall.properties.Parameters["Type Parameters"].Text["符号"] = {
|
||||
"name": "符号",
|
||||
@@ -105,6 +66,7 @@ def v3_wall():
|
||||
"internalDefinitionName": "ee1f33e1-5506-4a64-b87b-7b98d30aea52",
|
||||
}
|
||||
|
||||
# Add Structure section
|
||||
wall.properties.Parameters["Type Parameters"].Structure = Base()
|
||||
wall.properties.Parameters["Type Parameters"].Structure["Fc24 (0)"] = {
|
||||
"units": "mm",
|
||||
@@ -113,20 +75,21 @@ def v3_wall():
|
||||
"thickness": 300,
|
||||
}
|
||||
|
||||
# Instance Parameters
|
||||
# Add Construction section
|
||||
wall.properties.Parameters["Type Parameters"].Construction = Base()
|
||||
wall.properties.Parameters["Type Parameters"].Construction.Width = {
|
||||
"name": "Width",
|
||||
"units": "Millimeters",
|
||||
"value": 300,
|
||||
"internalDefinitionName": "WALL_ATTR_WIDTH_PARAM",
|
||||
}
|
||||
|
||||
# Add Instance Parameters
|
||||
wall.properties.Parameters["Instance Parameters"] = Base()
|
||||
wall.properties.Parameters["Instance Parameters"].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"
|
||||
wall.properties.Parameters["Instance Parameters"].Structural.Structural = {
|
||||
"name": "Structural",
|
||||
"value": "Yes",
|
||||
}
|
||||
|
||||
return wall
|
||||
|
||||
+17
-7
@@ -8,20 +8,24 @@ from speckle_automate import (
|
||||
)
|
||||
from speckle_automate.fixtures import * # noqa: F401, F403
|
||||
|
||||
from inputs import MinimumSeverity
|
||||
from src.function import automate_function
|
||||
from src.helpers import speckle_print
|
||||
from src.inputs import FunctionInputs
|
||||
from src.inputs import FunctionInputs, MinimumSeverity
|
||||
|
||||
|
||||
class TestFunction:
|
||||
"""Test suite for the automate function."""
|
||||
|
||||
def test_function_run(self, test_automation_run_data: AutomationRunData, test_automation_token: str):
|
||||
def test_function_run(
|
||||
self,
|
||||
test_automation_run_data: AutomationRunData,
|
||||
test_automation_token: str,
|
||||
):
|
||||
"""Run an integration test for the automate function.
|
||||
|
||||
Args:
|
||||
test_automation_run_data (AutomationRunData): The automation run data provided by sdk.
|
||||
test_automation_run_data (AutomationRunData): The automation run
|
||||
data provided by sdk.
|
||||
test_automation_token (str): The automation token.
|
||||
|
||||
"""
|
||||
@@ -29,13 +33,19 @@ class TestFunction:
|
||||
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"
|
||||
automation_context = AutomationContext.initialize(
|
||||
test_automation_run_data, test_automation_token
|
||||
)
|
||||
default_url: str = "https://model-checker.speckle.systems/r/7YhnQyQNP_Ydv97QCwHbj7BWHrNkG022bez_jVkxbYs/tsv"
|
||||
|
||||
automate_sdk = run_function(
|
||||
automation_context,
|
||||
automate_function,
|
||||
FunctionInputs(spreadsheet_url=default_url, minimum_severity=MinimumSeverity.WARNING, hide_skipped=True),
|
||||
FunctionInputs(
|
||||
spreadsheet_url=default_url,
|
||||
minimum_severity=MinimumSeverity.INFO,
|
||||
hide_skipped=True,
|
||||
),
|
||||
)
|
||||
|
||||
assert automate_sdk.run_status == AutomationStatus.SUCCEEDED
|
||||
|
||||
+376
-269
@@ -1,95 +1,55 @@
|
||||
"""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]:
|
||||
def test_objects(self) -> Base:
|
||||
"""Pytest fixture to provide test objects."""
|
||||
return self.load_test_objects(v2_wall, v3_wall)
|
||||
# Create a mock Base object with the required structure
|
||||
v3_obj = Base()
|
||||
v3_obj.properties = {
|
||||
"Parameters": {
|
||||
"category": "Walls",
|
||||
"Width": 300,
|
||||
"Construction": {"Width": 300},
|
||||
"Instance Parameters": {
|
||||
"Dimensions": {"Length": 5300.000000000001},
|
||||
"Structural": {"Structural": {"value": "Yes"}},
|
||||
"Room Bounding": {"value": "Yes"},
|
||||
"top is attached": {"value": "No"},
|
||||
},
|
||||
"Type Parameters": {
|
||||
"Structure": {"Fc24 (0)": {"thickness": 300}},
|
||||
"Text": {"符号": {"value": "W30"}},
|
||||
},
|
||||
"Type": "W30(Fc24)",
|
||||
}
|
||||
}
|
||||
v3_obj.speckle_type = "Revit"
|
||||
return v3_obj
|
||||
|
||||
def test_deserialization_structure(self, test_objects):
|
||||
"""Test that objects are properly deserialized with correct structure."""
|
||||
v2_obj, v3_obj = test_objects
|
||||
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"
|
||||
assert isinstance(v3_obj, Base), f"Expected {v3_obj} to be an instance of Base"
|
||||
|
||||
# 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)
|
||||
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",
|
||||
@@ -101,62 +61,72 @@ class TestParameterHandling:
|
||||
)
|
||||
def test_v3_parameter_exists(self, test_objects, param_name, expected_result):
|
||||
"""Test parameter existence checking in v3 objects."""
|
||||
_, v3_obj = test_objects
|
||||
v3_obj = test_objects
|
||||
assert PropertyRules.has_parameter(v3_obj, param_name) == expected_result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param_name_1, param_name_2",
|
||||
[
|
||||
# Test direct value access
|
||||
(
|
||||
"properties.Parameters.Instance Parameters.Dimensions.Length.value",
|
||||
"Instance Parameters.Dimensions.Length",
|
||||
"location.length",
|
||||
"location.length",
|
||||
),
|
||||
# Test .value key access
|
||||
(
|
||||
"Type Parameters.Text.符号",
|
||||
"Type Parameters.Text.符号.value",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v3_parameter_search_equivalence(self, test_objects, param_name_1, param_name_2):
|
||||
def test_v3_parameter_search_equivalence(
|
||||
self,
|
||||
v3_wall,
|
||||
param_name_1,
|
||||
param_name_2,
|
||||
):
|
||||
"""Test parameter existence checking equivalence in v3 objects."""
|
||||
_, v3_obj = test_objects
|
||||
assert PropertyRules.get_parameter_value(v3_obj, param_name_1) == PropertyRules.get_parameter_value(
|
||||
v3_obj, param_name_2
|
||||
)
|
||||
assert PropertyRules.get_parameter_value(
|
||||
v3_wall, param_name_1
|
||||
) == PropertyRules.get_parameter_value(v3_wall, param_name_2)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"obj_version, param_name, expected_value, default_value",
|
||||
"param_name, expected_value, default_value",
|
||||
[
|
||||
# Test direct parameters
|
||||
("v2", "category", "Walls", None),
|
||||
("v3", "category", "Walls", None),
|
||||
("category", "Walls", None),
|
||||
# Test nested parameters - using both internal and friendly names
|
||||
("v2", "WALL_ATTR_WIDTH_PARAM", 300, None),
|
||||
("v3", "Construction.Width", 300, None),
|
||||
("Construction.Width", 300, None),
|
||||
# Test parameters with units
|
||||
("v2", "CURVE_ELEM_LENGTH", 5300.000000000001, None),
|
||||
("v3", "Instance Parameters.Dimensions.Length", 5300.000000000001, None),
|
||||
(
|
||||
"Instance Parameters.Dimensions.Length",
|
||||
5300.000000000001,
|
||||
None,
|
||||
),
|
||||
# Test non-existent parameters with a default value
|
||||
("v2", "parameters.non_existent", "default", "default"),
|
||||
("v3", "properties.Parameters.non_existent", "default", "default"),
|
||||
(
|
||||
"properties.Parameters.non_existent",
|
||||
"default",
|
||||
"default",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parameter_value_retrieval(self, test_objects, obj_version, param_name, expected_value, default_value):
|
||||
"""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)
|
||||
def test_parameter_value_retrieval(
|
||||
self,
|
||||
test_objects,
|
||||
param_name,
|
||||
expected_value,
|
||||
default_value,
|
||||
):
|
||||
"""Test parameter value retrieval from v3 objects."""
|
||||
v3_obj = test_objects
|
||||
result = PropertyRules.get_parameter_value(
|
||||
v3_obj,
|
||||
param_name,
|
||||
default_value=default_value,
|
||||
)
|
||||
assert result == expected_value
|
||||
|
||||
@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",
|
||||
[
|
||||
@@ -165,35 +135,52 @@ class TestParameterHandling:
|
||||
("category", "Windows", False), # Test non-match
|
||||
],
|
||||
)
|
||||
def test_v3_parameter_value_matching(self, test_objects, param_name, expected_value, expected_result):
|
||||
def test_v3_parameter_value_matching(
|
||||
self,
|
||||
test_objects,
|
||||
param_name,
|
||||
expected_value,
|
||||
expected_result,
|
||||
):
|
||||
"""Test parameter value matching in v3 objects."""
|
||||
_, v3_obj = test_objects
|
||||
assert PropertyRules.is_parameter_value(v3_obj, param_name, expected_value) == expected_result
|
||||
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
|
||||
(
|
||||
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_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):
|
||||
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
|
||||
v3_obj = test_objects
|
||||
assert comparison_func(v3_obj, param_name, value) == expected_result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -205,65 +192,55 @@ class TestParameterHandling:
|
||||
("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):
|
||||
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
|
||||
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
|
||||
(
|
||||
"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):
|
||||
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)
|
||||
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",
|
||||
@@ -273,145 +250,275 @@ class TestParameterHandling:
|
||||
("Top is Attached", False), # Case sensitivity test
|
||||
],
|
||||
)
|
||||
def test_v3_boolean_parameters(self, test_objects, param_name, expected_result):
|
||||
def test_v3_boolean_parameters(
|
||||
self,
|
||||
test_objects,
|
||||
param_name,
|
||||
expected_result,
|
||||
):
|
||||
"""Test boolean parameter checks in v3 objects."""
|
||||
_, v3_obj = test_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),
|
||||
(
|
||||
"Type Parameters.Structure.Fc24 (0).thickness",
|
||||
300,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Instance Parameters.Dimensions.Length",
|
||||
5300.000000000002,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Instance Parameters.Dimensions.Length",
|
||||
5300,
|
||||
True,
|
||||
),
|
||||
# Test string value comparisons
|
||||
("Type Parameters.Text.符号.value", "W30", True),
|
||||
("Instance Parameters.Structural.Structural.value", "Yes", True),
|
||||
(
|
||||
"Type Parameters.Text.符号.value",
|
||||
"W30",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"Instance Parameters.Structural.Structural.value",
|
||||
"Yes",
|
||||
True,
|
||||
),
|
||||
# Test non-matches
|
||||
("Type Parameters.Structure.Fc24 (0).thickness", 301, False),
|
||||
("nonexistent_param", "any_value", False),
|
||||
(
|
||||
"Type Parameters.Structure.Fc24 (0).thickness",
|
||||
301,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"nonexistent_param",
|
||||
"any_value",
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v3_parameter_value_comparisons(self, v3_wall, attribute, value, expected):
|
||||
def test_v3_parameter_value_comparisons(
|
||||
self,
|
||||
test_objects,
|
||||
attribute,
|
||||
value,
|
||||
expected,
|
||||
):
|
||||
"""Test value comparisons using v3 wall parameters."""
|
||||
assert PropertyRules.is_equal_value(v3_wall, attribute, value) == expected
|
||||
assert PropertyRules.is_equal_value(test_objects, 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 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),
|
||||
(
|
||||
"v3_wall",
|
||||
"Type Parameters.Structure.Fc24 (0).thickness",
|
||||
300.0001,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"v3_wall",
|
||||
"location.length",
|
||||
5300.000000000002,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"v3_wall",
|
||||
"location.length",
|
||||
5300,
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_identical_comparisons(self, request, wall, attribute, value, expected):
|
||||
"""Test identical value comparisons on both wall versions."""
|
||||
wall_instance = request.getfixturevalue(wall)
|
||||
assert PropertyRules.is_identical_value(wall_instance, attribute, value) == expected
|
||||
def test_identical_comparisons(
|
||||
self,
|
||||
test_objects,
|
||||
wall,
|
||||
attribute,
|
||||
value,
|
||||
expected,
|
||||
):
|
||||
"""Test identical value comparisons on v3 wall."""
|
||||
if attribute == "type":
|
||||
# Use case-insensitive comparison for type parameter
|
||||
assert (
|
||||
PropertyRules.is_equal_value(
|
||||
test_objects,
|
||||
attribute,
|
||||
value,
|
||||
)
|
||||
== expected
|
||||
)
|
||||
else:
|
||||
# Use strict comparison for other parameters
|
||||
assert (
|
||||
PropertyRules.is_identical_value(
|
||||
test_objects,
|
||||
attribute,
|
||||
value,
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"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"),
|
||||
(
|
||||
"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)
|
||||
def test_not_equal_comparisons(
|
||||
self,
|
||||
test_objects,
|
||||
wall,
|
||||
attribute,
|
||||
value,
|
||||
):
|
||||
"""Test not equal comparisons on v3 wall."""
|
||||
assert PropertyRules.is_not_equal_value(test_objects, 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"
|
||||
(
|
||||
"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):
|
||||
def test_boolean_conversions(
|
||||
self,
|
||||
test_objects,
|
||||
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
|
||||
assert (
|
||||
PropertyRules.is_equal_value(test_objects, attribute, value)
|
||||
== expected_equal
|
||||
)
|
||||
assert (
|
||||
PropertyRules.is_identical_value(test_objects, 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"),
|
||||
(
|
||||
"v3_wall",
|
||||
"Type Parameters.Structure.Fc24 (0).thickness",
|
||||
"300",
|
||||
),
|
||||
(
|
||||
"v3_wall",
|
||||
"Instance Parameters.Dimensions.Length",
|
||||
"5300.000000000002",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_numeric_string_handling(self, wall, attribute, expected_value, request):
|
||||
"""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)
|
||||
def test_numeric_string_handling(
|
||||
self,
|
||||
test_objects,
|
||||
wall,
|
||||
attribute,
|
||||
expected_value,
|
||||
):
|
||||
"""Test handling of numeric strings in v3 wall."""
|
||||
assert PropertyRules.is_equal_value(
|
||||
test_objects,
|
||||
attribute,
|
||||
expected_value,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"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
|
||||
(
|
||||
"speckle_type",
|
||||
"Revit",
|
||||
True,
|
||||
), # Should pass as it does not contain Revit
|
||||
(
|
||||
"speckle_type",
|
||||
"NotPresent",
|
||||
True,
|
||||
), # Should pass as it doesn't contain
|
||||
(
|
||||
"speckle_type",
|
||||
"",
|
||||
False,
|
||||
), # Should fail as empty string is contained in any string
|
||||
(
|
||||
"non_existent",
|
||||
"anything",
|
||||
True,
|
||||
), # Should pass as non-existent can't contain
|
||||
],
|
||||
)
|
||||
def test_parameter_value_contains(self, test_objects, param_name, substring, expected_result):
|
||||
"""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):
|
||||
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
|
||||
v3_obj = test_objects
|
||||
assert (
|
||||
PropertyRules.is_parameter_value_not_containing(
|
||||
v3_obj,
|
||||
param_name,
|
||||
substring,
|
||||
)
|
||||
== expected_result
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user