Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c49948081 | |||
| 3f5880156b | |||
| 6032306cc2 | |||
| c7171a54cb | |||
| 0019667302 | |||
| 129132dd3a | |||
| f902f9c23f | |||
| 7158d0576d | |||
| bb87a7b932 | |||
| f1c4e65d72 |
@@ -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!'"
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
# Step 1: Checkout the repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
# Step 2: Set up Python
|
||||
- name: Setup Python
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,14 +1,13 @@
|
||||
# Speckle Checker
|
||||
# Model Checker
|
||||
|
||||
Speckle Checker is an Automate function that validates Speckle objects against configurable rules defined in a
|
||||
spreadsheet. This approach provides a flexible way to implement quality checks without coding, making it accessible to
|
||||
all team members.
|
||||
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.
|
||||
|
||||
## Overview
|
||||
|
||||
The Checker function allows you to:
|
||||
The Model Checker allows you to:
|
||||
|
||||
- Define validation rules in a spreadsheet
|
||||
- Define validation rules for your objects
|
||||
- Configure severity levels for issues
|
||||
- Check properties across different types of objects
|
||||
- Generate reports of validation results
|
||||
@@ -16,39 +15,35 @@ The Checker function allows you to:
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Prepare Your Rule Spreadsheet
|
||||
### 1. Access the Model Checker Application
|
||||
|
||||
1. Access the [template spreadsheet](https://docs.google.com/spreadsheets/d/1hiPSw23eOaqd27QD_YsXvZg9PWm7_XBx/edit) (
|
||||
make a copy to your drive)
|
||||
2. Define your rules using the format explained below
|
||||
3. Publish your rules by clicking "File > Download > Tab-separated values (.tsv)"
|
||||
4. Upload the TSV file to a hosting service (Google Drive, Dropbox, etc.) and get a public URL
|
||||
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 [Speckle Automate](https://automate.speckle.dev/)
|
||||
1. Go to your workspace project in [Speckle](https://app.speckle.systems/)
|
||||
2. Create a new Automation
|
||||
3. Select the Checker function
|
||||
3. Select the Model Checker function
|
||||
4. Configure the function:
|
||||
- Paste your TSV URL
|
||||
- Set minimum severity level to report
|
||||
- Configure other options as needed
|
||||
5. Save and run your automation
|
||||
|
||||
## Rule Definition Format
|
||||
|
||||
Rules are defined in a spreadsheet with the following columns:
|
||||
Rules are defined with the following components:
|
||||
|
||||
| Rule Number | Logic | Property Name | Predicate | Value | Message | Report Severity |
|
||||
|-------------|-------|---------------|--------------|-----------|----------------------|-----------------|
|
||||
| 1 | WHERE | category | matches | Walls | Wall thickness check | ERROR |
|
||||
| 1 | AND | Width | greater than | 200 | | |
|
||||
| 2 | WHERE | category | matches | Columns | Column height check | WARNING |
|
||||
| 2 | AND | height | in range | 2500,4000 | | |
|
||||
| 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 | | |
|
||||
|
||||
### Column Explanation
|
||||
### Component Explanation
|
||||
|
||||
- **Rule Number**: Groups conditions that belong to the same rule
|
||||
- **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.)
|
||||
@@ -58,20 +53,20 @@ Rules are defined in a spreadsheet with the following columns:
|
||||
|
||||
### 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 | Pattern matching | `name` is like `^BR\d+$` |
|
||||
| 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
|
||||
|
||||
@@ -84,7 +79,7 @@ are reported as issues.
|
||||
|
||||
## Working with Object Properties
|
||||
|
||||
The Checker understands properties in Speckle objects regardless of schema:
|
||||
The Model Checker understands properties in Speckle objects regardless of schema:
|
||||
|
||||
- Direct properties: `category`, `name`, `id`
|
||||
- Nested properties: `parameters.WIDTH.value`
|
||||
@@ -95,27 +90,41 @@ The Checker understands properties in Speckle objects regardless of schema:
|
||||
### Wall Thickness Check
|
||||
|
||||
```
|
||||
Rule 1: WHERE category equals "Walls" AND width less than "200"
|
||||
Message: "Wall too thin - minimum thickness is 200mm"
|
||||
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 2: WHERE category equals "Doors" AND name is not like "^D\d{3}$"
|
||||
Message: "Door name must follow pattern D followed by 3 digits"
|
||||
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 3: WHERE category equals "Columns" AND is_structural is true AND height not in range "2400,4000"
|
||||
Message: "Structural column height outside acceptable range (2400-4000mm)"
|
||||
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 or contact your Speckle support representative.
|
||||
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
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -82,7 +82,9 @@ def automate_function(
|
||||
# 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)
|
||||
grouped_rules, messages = read_rules_from_spreadsheet(
|
||||
function_inputs.spreadsheet_url
|
||||
)
|
||||
|
||||
# Handle any validation messages from rule processing
|
||||
for message in messages:
|
||||
@@ -119,5 +121,6 @@ def automate_function(
|
||||
# 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__
|
||||
),
|
||||
}
|
||||
|
||||
+104
-33
@@ -53,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"}
|
||||
@@ -73,7 +79,10 @@ 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:
|
||||
"""Evaluates a single condition against a Speckle object.
|
||||
|
||||
@@ -87,7 +96,8 @@ def evaluate_condition(
|
||||
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')
|
||||
- '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
|
||||
@@ -95,7 +105,9 @@ def evaluate_condition(
|
||||
Returns:
|
||||
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"]
|
||||
|
||||
@@ -116,7 +128,9 @@ def evaluate_condition(
|
||||
return False
|
||||
|
||||
|
||||
def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
|
||||
def get_filters_and_check(
|
||||
rule_group: pd.DataFrame,
|
||||
) -> tuple[pd.DataFrame, pd.Series]:
|
||||
"""Separates rule conditions into filtering conditions and the final check condition.
|
||||
|
||||
This function handles two rule formats:
|
||||
@@ -158,7 +172,9 @@ def get_filters_and_check(rule_group: pd.DataFrame) -> tuple[pd.DataFrame, pd.Se
|
||||
else:
|
||||
# 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
|
||||
|
||||
@@ -204,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,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -219,7 +238,10 @@ def process_rule(
|
||||
|
||||
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:
|
||||
@@ -235,7 +257,7 @@ 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 with 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
|
||||
@@ -255,7 +277,11 @@ def apply_rules_to_objects(
|
||||
"""
|
||||
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:
|
||||
@@ -263,27 +289,59 @@ 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")
|
||||
|
||||
newBase = Base()
|
||||
newBase.id = "123"
|
||||
|
||||
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
|
||||
affected_objects=[newBase],
|
||||
# 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={},
|
||||
)
|
||||
@@ -309,7 +367,7 @@ class SeverityLevel(Enum):
|
||||
|
||||
|
||||
def get_severity(rule_info: pd.Series) -> SeverityLevel:
|
||||
"""Convert a string severity level from the spreadsheet to the corresponding SeverityLevel enum.
|
||||
"""Convert a string severity to the corresponding SeverityLevel enum.
|
||||
|
||||
This function normalizes user input with robust handling for:
|
||||
- Case insensitivity (e.g., "info", "WARNING" → "Info", "Warning")
|
||||
@@ -318,18 +376,24 @@ def get_severity(rule_info: pd.Series) -> SeverityLevel:
|
||||
- Default fallback to ERROR for invalid input
|
||||
|
||||
Args:
|
||||
rule_info: Series containing rule information with 'Report Severity' key
|
||||
rule_info: Series containing rule information with 'Report Severity'
|
||||
key
|
||||
|
||||
Returns:
|
||||
Appropriate SeverityLevel enum value
|
||||
"""
|
||||
severity = rule_info.get("Report Severity") # Extract severity from input data
|
||||
severity = rule_info.get("Report Severity") or rule_info.get(
|
||||
"Severity"
|
||||
) # Extract severity from input data
|
||||
|
||||
# If severity is None or not a string (e.g., numeric input), default to ERROR
|
||||
# If severity is None or not a string (e.g., numeric input),
|
||||
# default to ERROR
|
||||
if not isinstance(severity, str):
|
||||
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 = {
|
||||
@@ -339,7 +403,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
|
||||
@@ -347,7 +412,10 @@ 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]:
|
||||
"""Generates structured metadata for rule results.
|
||||
|
||||
@@ -363,7 +431,8 @@ def get_metadata(
|
||||
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 = {
|
||||
@@ -393,7 +462,8 @@ def attach_results(
|
||||
) -> None:
|
||||
"""Attaches rule results to objects in the Speckle Automate context.
|
||||
|
||||
This function is the interface to the Speckle platform for reporting results:
|
||||
This function is the interface to the Speckle platform for reporting
|
||||
results:
|
||||
- For failing objects, attaches results with appropriate severity levels
|
||||
- For passing objects, attaches informational results
|
||||
- Includes structured metadata for consistent reporting
|
||||
@@ -423,7 +493,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,
|
||||
@@ -431,7 +501,7 @@ 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,
|
||||
)
|
||||
@@ -450,7 +520,8 @@ def format_message(rule_info):
|
||||
"""
|
||||
message = (
|
||||
str(rule_info["Message"])
|
||||
if rule_info["Message"] is not None and not pd.isna(rule_info["Message"])
|
||||
if rule_info["Message"] is not None
|
||||
and not pd.isna(rule_info["Message"])
|
||||
else "No Message"
|
||||
)
|
||||
return message
|
||||
|
||||
+6
-6
@@ -64,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
|
||||
@@ -90,8 +92,7 @@ def process_rule_numbers(df: DataFrame) -> DataFrame:
|
||||
|
||||
|
||||
def validate_rule_numbers(df: DataFrame) -> list[str]:
|
||||
""" "
|
||||
Validate rule numbers and return any warnings or errors.
|
||||
"""Validate rule numbers and return any warnings or errors.
|
||||
|
||||
This checks for issues like:
|
||||
1. Missing rule numbers
|
||||
@@ -128,8 +129,7 @@ def validate_rule_numbers(df: DataFrame) -> list[str]:
|
||||
|
||||
|
||||
def read_rules_from_spreadsheet(url: str) -> tuple[DataFrameGroupBy, list[str]] | tuple[None, list[str]]:
|
||||
""" "
|
||||
Reads rules from a TSV file at the provided URL, processes them, 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
|
||||
|
||||
+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