Compare commits

...

18 Commits

Author SHA1 Message Date
Gergő Jedlicska 62c5114cb3 Merge pull request #341 from specklesystems/gergo/fixtures_no_init
fix: remove fixtures from automate exports
2024-06-07 18:53:20 +02:00
Gergő Jedlicska 43a5302a90 fix: tures 2024-06-07 18:51:03 +02:00
Gergő Jedlicska addaa996ea fix: remove fixtures from automate exports 2024-06-07 18:42:19 +02:00
Gergő Jedlicska 3b5421a5bc Merge pull request #340 from specklesystems/gergo/automateExceptionOutcome
feat: add excetion outcome reporting to functions
2024-06-07 15:29:40 +02:00
Gergő Jedlicska 88e8c86fa6 feat: add excetion outcome reporting to functions 2024-06-07 11:13:15 +02:00
Chuck Driesler d6843b9971 Merge pull request #339 from specklesystems/chuck/testAutomationHelpers
WEB-1053 Create helpers for testing automate functions
2024-06-06 12:03:00 +01:00
Charles Driesler 302a9f7f30 repair import 2024-06-06 12:01:00 +01:00
Charles Driesler ede9591c6a export fixtures 2024-06-06 11:58:18 +01:00
Charles Driesler c5b339d891 deps deps deps 2024-06-05 16:59:05 +01:00
Charles Driesler 2e35fb9e5c create helpers for testing functions 2024-06-05 16:38:49 +01:00
Gergő Jedlicska e6b822b0e3 Merge pull request #338 from specklesystems/gergo/automateExitCode
fix(automate): make sure we exit with code 0 if execution completes
2024-06-03 16:08:28 +02:00
Gergő Jedlicska 239bc4b5b9 docs(automate): finish comment block thoughts 2024-06-03 14:29:29 +02:00
Gergő Jedlicska 4eea15ddc1 fix(automate): make sure we exit with code 0 if execution completes 2024-06-03 14:27:07 +02:00
Aleksei Protopopov 204aa7466e Feature: adds connection_timeout argument to SpeckleClient (#337)
* Add connection_timeout argument to SpeckleClient

* Reformat code with black

* Set default timeout to 10s

* Make connection retries configurable
2024-05-27 14:23:39 +01:00
Gergő Jedlicska 24019e99f3 Merge pull request #335 from specklesystems/gergo/automateInterfaceRework
Rework automate SDK for the integrated automate api
2024-05-16 18:14:47 +02:00
Gergő Jedlicska 64492fafa5 fix: proper pytest skip 2024-05-16 17:24:53 +02:00
Gergő Jedlicska 3a8d634989 test: disable automation tests for now 2024-05-16 17:18:57 +02:00
Gergő Jedlicska f27650af3a feat: update automation schema and automation context for the new automate interfaces 2024-05-16 10:25:58 +02:00
11 changed files with 1044 additions and 927 deletions
Generated
+761 -744
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -33,10 +33,11 @@ pytest-ordering = "^0.6"
pytest-cov = "^3.0.0"
devtools = "^0.8.0"
pylint = "^2.14.4"
pydantic-settings = "^2.3.0"
mypy = "^0.982"
pre-commit = "^2.20.0"
commitizen = "^2.38.0"
ruff = "^0.0.187"
ruff = "^0.4.4"
types-deprecated = "^1.2.9"
types-ujson = "^5.6.0.0"
types-requests = "^2.28.11.5"
+1
View File
@@ -1,4 +1,5 @@
"""This module contains an SDK for working with Speckle Automate."""
from speckle_automate.automation_context import AutomationContext
from speckle_automate.runner import execute_automate_function, run_function
from speckle_automate.schema import (
+47 -61
View File
@@ -1,4 +1,5 @@
"""This module provides an abstraction layer above the Speckle Automate runtime."""
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -17,6 +18,7 @@ from speckle_automate.schema import (
)
from specklepy.api import operations
from specklepy.api.client import SpeckleClient
from specklepy.core.api.models import Branch
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects.base import Base
from specklepy.transports.memory import MemoryTransport
@@ -94,8 +96,10 @@ class AutomationContext:
def receive_version(self) -> Base:
"""Receive the Speckle project version that triggered this automation run."""
# TODO: this is a quick hack to keep implementation consistency. Move to proper receive many versions
version_id = self.automation_run_data.triggers[0].payload.version_id
commit = self.speckle_client.commit.get(
self.automation_run_data.project_id, self.automation_run_data.version_id
self.automation_run_data.project_id, version_id
)
if not commit.referencedObject:
raise ValueError("The commit has no referencedObject, cannot receive it.")
@@ -104,7 +108,7 @@ class AutomationContext:
)
print(
f"It took {self.elapsed():.2f} seconds to receive",
f" the speckle version {self.automation_run_data.version_id}",
f" the speckle version {version_id}",
)
return base
@@ -119,19 +123,27 @@ class AutomationContext:
version_message (str): The message for the new version.
"""
if model_name == self.automation_run_data.branch_name:
raise ValueError(
f"The target model: {model_name} cannot match the model"
f" that triggered this automation:"
f" {self.automation_run_data.model_id} /"
f" {self.automation_run_data.branch_name}"
)
branch = self.speckle_client.branch.get(
self.automation_run_data.project_id, model_name, 1
)
# we just check if it exists
if (not branch) or isinstance(branch, SpeckleException):
if isinstance(branch, Branch):
if not branch.id:
raise ValueError("Cannot use the branch without its id")
matching_trigger = [
t
for t in self.automation_run_data.triggers
if t.payload.model_id == branch.id
]
if matching_trigger:
raise ValueError(
f"The target model: {model_name} cannot match the model"
f" that triggered this automation:"
f" {matching_trigger[0].payload.model_id}"
)
model_id = branch.id
else:
# we just check if it exists
branch_create = self.speckle_client.branch.create(
self.automation_run_data.project_id,
model_name,
@@ -139,8 +151,6 @@ class AutomationContext:
if isinstance(branch_create, Exception):
raise branch_create
model_id = branch_create
else:
model_id = branch.id
root_object_id = operations.send(
root_object,
@@ -174,7 +184,8 @@ class AutomationContext:
) -> None:
link_resources = (
[
f"{self.automation_run_data.model_id}@{self.automation_run_data.version_id}"
f"{t.payload.model_id}@{t.payload.version_id}"
for t in self.automation_run_data.triggers
]
if include_source_model_version
else []
@@ -194,47 +205,26 @@ class AutomationContext:
"""Report the current run status to the project of this automation."""
query = gql(
"""
mutation ReportFunctionRunStatus(
$automationId: String!,
$automationRevisionId: String!,
$automationRunId: String!,
$versionId: String!,
$functionId: String!,
$functionName: String!,
$functionLogo: String,
$runStatus: AutomationRunStatus!
$elapsed: Float!
$contextView: String
$resultVersionIds: [String!]!
mutation AutomateFunctionRunStatusReport(
$functionRunId: String!
$status: AutomateRunStatus!
$statusMessage: String
$objectResults: JSONObject
$results: JSONObject
$contextView: String
){
automationMutations {
functionRunStatusReport(input: {
automationId: $automationId
automationRevisionId: $automationRevisionId
automationRunId: $automationRunId
versionId: $versionId
functionRuns: [
{
functionId: $functionId
functionName: $functionName
functionLogo: $functionLogo
status: $runStatus,
contextView: $contextView,
elapsed: $elapsed,
resultVersionIds: $resultVersionIds,
statusMessage: $statusMessage
results: $objectResults
}]
})
}
automateFunctionRunStatusReport(input: {
functionRunId: $functionRunId
status: $status
statusMessage: $statusMessage
contextView: $contextView
results: $results
})
}
"""
"""
)
if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]:
object_results = {
"version": "1.0.0",
"version": 1,
"values": {
"objectResults": self._automation_result.model_dump(by_alias=True)[
"objectResults"
@@ -246,19 +236,11 @@ class AutomationContext:
object_results = None
params = {
"automationId": self.automation_run_data.automation_id,
"automationRevisionId": self.automation_run_data.automation_revision_id,
"automationRunId": self.automation_run_data.automation_run_id,
"versionId": self.automation_run_data.version_id,
"functionId": self.automation_run_data.function_id,
"functionName": self.automation_run_data.function_name,
"functionLogo": self.automation_run_data.function_logo,
"runStatus": self.run_status.value,
"functionRunId": self.automation_run_data.function_run_id,
"status": self.run_status.value,
"statusMessage": self._automation_result.status_message,
"results": object_results,
"contextView": self._automation_result.result_view,
"elapsed": self.elapsed(),
"resultVersionIds": self._automation_result.result_versions,
"objectResults": object_results,
}
print(f"Reporting run status with content: {params}")
self.speckle_client.httpclient.execute(query, params)
@@ -308,6 +290,10 @@ class AutomationContext:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.FAILED, status_message)
def mark_run_exception(self, status_message: str) -> None:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.EXCEPTION, status_message)
def mark_run_success(self, status_message: Optional[str]) -> None:
"""Mark the current run a success with an optional message."""
self._mark_run(AutomationStatus.SUCCEEDED, status_message)
+154
View File
@@ -0,0 +1,154 @@
"""Some useful helpers for working with automation data."""
import secrets
import string
import pytest
from gql import gql
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from speckle_automate.schema import AutomationRunData, TestAutomationRunData
from specklepy.api.client import SpeckleClient
class TestAutomationEnvironment(BaseSettings):
"""Get known environment variables from local `.env` file"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="speckle_",
extra="ignore",
)
token: str = Field()
server_url: str = Field()
project_id: str = Field()
automation_id: str = Field()
@pytest.fixture()
def test_automation_environment() -> TestAutomationEnvironment:
return TestAutomationEnvironment()
@pytest.fixture()
def test_automation_token(
test_automation_environment: TestAutomationEnvironment,
) -> str:
"""Provide a speckle token for the test suite."""
return test_automation_environment.token
@pytest.fixture()
def speckle_client(
test_automation_environment: TestAutomationEnvironment,
) -> SpeckleClient:
"""Initialize a SpeckleClient for testing."""
speckle_client = SpeckleClient(
test_automation_environment.server_url,
test_automation_environment.server_url.startswith("https"),
)
speckle_client.authenticate_with_token(test_automation_environment.token)
return speckle_client
def create_test_automation_run(
speckle_client: SpeckleClient, project_id: str, test_automation_id: str
) -> TestAutomationRunData:
"""Create test run to report local test results to"""
query = gql(
"""
mutation CreateTestRun(
$projectId: ID!,
$automationId: ID!
) {
projectMutations {
automationMutations(projectId: $projectId) {
createTestAutomationRun(automationId: $automationId) {
automationRunId
functionRunId
triggers {
payload {
modelId
versionId
}
triggerType
}
}
}
}
}
"""
)
params = {"automationId": test_automation_id, "projectId": project_id}
result = speckle_client.httpclient.execute(query, params)
print(result)
return (
result.get("projectMutations")
.get("automationMutations")
.get("createTestAutomationRun")
)
@pytest.fixture()
def test_automation_run(
speckle_client: SpeckleClient,
test_automation_environment: TestAutomationEnvironment,
) -> TestAutomationRunData:
return create_test_automation_run(
speckle_client,
test_automation_environment.project_id,
test_automation_environment.automation_id,
)
def create_test_automation_run_data(
speckle_client: SpeckleClient,
test_automation_environment: TestAutomationEnvironment,
) -> AutomationRunData:
"""Create automation run data for a new run for a given test automation"""
test_automation_run_data = create_test_automation_run(
speckle_client,
test_automation_environment.project_id,
test_automation_environment.automation_id,
)
return AutomationRunData(
project_id=test_automation_environment.project_id,
speckle_server_url=test_automation_environment.server_url,
automation_id=test_automation_environment.automation_id,
automation_run_id=test_automation_run_data["automationRunId"],
function_run_id=test_automation_run_data["functionRunId"],
triggers=test_automation_run_data["triggers"],
)
@pytest.fixture()
def test_automation_run_data(
speckle_client: SpeckleClient,
test_automation_environment: TestAutomationEnvironment,
) -> AutomationRunData:
return create_test_automation_run_data(speckle_client, test_automation_environment)
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
__all__ = [
"test_automation_environment",
"test_automation_token",
"speckle_client",
"test_automation_run",
"test_automation_run_data",
]
-55
View File
@@ -1,55 +0,0 @@
"""Some useful helpers for working with automation data."""
import secrets
import string
from gql import gql
from specklepy.api.client import SpeckleClient
def register_new_automation(
speckle_client: SpeckleClient,
project_id: str,
model_id: str,
automation_id: str,
automation_name: str,
automation_revision_id: str,
) -> bool:
"""Register a new automation in the speckle server."""
query = gql(
"""
mutation CreateAutomation(
$projectId: String!
$modelId: String!
$automationName: String!
$automationId: String!
$automationRevisionId: String!
) {
automationMutations {
create(
input: {
projectId: $projectId
modelId: $modelId
automationName: $automationName
automationId: $automationId
automationRevisionId: $automationRevisionId
}
)
}
}
"""
)
params = {
"projectId": project_id,
"modelId": model_id,
"automationName": automation_name,
"automationId": automation_id,
"automationRevisionId": automation_revision_id,
}
return speckle_client.httpclient.execute(query, params)
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
+9 -3
View File
@@ -3,6 +3,7 @@
Provides mechanisms to execute any function,
that conforms to the AutomateFunction "interface"
"""
import json
import sys
import traceback
@@ -65,7 +66,9 @@ def execute_automate_function(
@overload
def execute_automate_function(automate_function: AutomateFunctionWithoutInputs) -> None:
def execute_automate_function(
automate_function: AutomateFunctionWithoutInputs,
) -> None:
...
@@ -127,8 +130,10 @@ def execute_automate_function(
automate_function, # type: ignore
)
# if we've gotten this far, the execution should technically be completed as expected
# thus exiting with 0 is the schemantically correct thing to do
exit_code = (
0 if automation_context.run_status == AutomationStatus.SUCCEEDED else 1
1 if automation_context.run_status == AutomationStatus.EXCEPTION else 0
)
exit(exit_code)
@@ -173,6 +178,7 @@ def run_function(
if automation_context.run_status not in [
AutomationStatus.FAILED,
AutomationStatus.SUCCEEDED,
AutomationStatus.EXCEPTION,
]:
automation_context.mark_run_success(
"WARNING: Automate assumed a success status,"
@@ -181,7 +187,7 @@ def run_function(
except Exception:
trace = traceback.format_exc()
print(trace)
automation_context.mark_run_failed(
automation_context.mark_run_exception(
"Function error. Check the automation run logs for details."
)
finally:
+32 -9
View File
@@ -1,6 +1,7 @@
""""""
from enum import Enum
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from stringcase import camelcase
@@ -12,22 +13,43 @@ class AutomateBase(BaseModel):
model_config = ConfigDict(alias_generator=camelcase, populate_by_name=True)
class VersionCreationTriggerPayload(AutomateBase):
"""Represents the version creation trigger payload."""
model_id: str
version_id: str
class VersionCreationTrigger(AutomateBase):
"""Represents a single version creation trigger for the automation run."""
trigger_type: Literal["versionCreation"]
payload: VersionCreationTriggerPayload
class AutomationRunData(BaseModel):
"""Values of the project / model that triggered the run of this function."""
project_id: str
model_id: str
branch_name: str
version_id: str
speckle_server_url: str
automation_id: str
automation_revision_id: str
automation_run_id: str
function_run_id: str
function_id: str
function_name: str
function_logo: Optional[str]
triggers: List[VersionCreationTrigger]
model_config = ConfigDict(
alias_generator=camelcase, populate_by_name=True, protected_namespaces=()
)
class TestAutomationRunData(BaseModel):
"""Values of the run created in the test automation for local test results."""
automation_run_id: str
function_run_id: str
triggers: List[VersionCreationTrigger]
model_config = ConfigDict(
alias_generator=camelcase, populate_by_name=True, protected_namespaces=()
@@ -41,6 +63,7 @@ class AutomationStatus(str, Enum):
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCEEDED = "SUCCEEDED"
EXCEPTION = "EXCEPTION"
class ObjectResultLevel(str, Enum):
+5 -2
View File
@@ -1,8 +1,9 @@
from typing import Optional
from typing import Optional, Union
from specklepy.api.models import Branch
from specklepy.core.api.resources.branch import Resource as CoreResource
from specklepy.logging import metrics
from specklepy.logging.exceptions import SpeckleException
class Resource(CoreResource):
@@ -31,7 +32,9 @@ class Resource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Branch Create"})
return super().create(stream_id, name, description)
def get(self, stream_id: str, name: str, commits_limit: int = 10):
def get(
self, stream_id: str, name: str, commits_limit: int = 10
) -> Union[Branch, None, SpeckleException]:
"""Get a branch by name from a stream
Arguments:
+8 -1
View File
@@ -64,6 +64,8 @@ class SpeckleClient:
host: str = DEFAULT_HOST,
use_ssl: bool = USE_SSL,
verify_certificate: bool = True,
connection_retries: int = 3,
connection_timeout: int = 10,
) -> None:
ws_protocol = "ws"
http_protocol = "http"
@@ -80,10 +82,15 @@ class SpeckleClient:
self.ws_url = f"{ws_protocol}://{host}/graphql"
self.account = Account()
self.verify_certificate = verify_certificate
self.connection_retries = connection_retries
self.connection_timeout = connection_timeout
self.httpclient = Client(
transport=RequestsHTTPTransport(
url=self.graphql, verify=self.verify_certificate, retries=3
url=self.graphql,
verify=self.verify_certificate,
retries=self.connection_retries,
timeout=self.connection_timeout,
)
)
self.wsclient = None
@@ -12,12 +12,13 @@ from speckle_automate import (
AutomationStatus,
run_function,
)
from speckle_automate.helpers import crypto_random_string, register_new_automation
from speckle_automate.fixtures import (
create_test_automation_run_data,
crypto_random_string,
)
from speckle_automate.schema import AutomateBase
from specklepy.api import operations
from specklepy.api.client import SpeckleClient
from specklepy.objects.base import Base
from specklepy.transports.server import ServerTransport
@pytest.fixture
@@ -40,58 +41,16 @@ def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient:
return test_client
@pytest.fixture
def test_object() -> Base:
"""Create a Base model for testing."""
root_object = Base()
root_object.foo = "bar"
return root_object
@pytest.fixture
def automation_run_data(
test_object: Base, test_client: SpeckleClient, speckle_server_url: str
test_client: SpeckleClient, speckle_server_url: str
) -> AutomationRunData:
"""Set up an automation context for testing."""
project_id = test_client.stream.create("Automate function e2e test")
branch_name = "main"
"""TODO: Set up a test automation for integration testing"""
project_id = crypto_random_string(10)
test_automation_id = crypto_random_string(10)
model = test_client.branch.get(project_id, branch_name, commits_limit=1)
model_id: str = model.id
root_obj_id = operations.send(
test_object, [ServerTransport(project_id, test_client)]
)
version_id = test_client.commit.create(project_id, root_obj_id)
automation_name = crypto_random_string(10)
automation_id = crypto_random_string(10)
automation_revision_id = crypto_random_string(10)
register_new_automation(
test_client,
project_id,
model_id,
automation_id,
automation_name,
automation_revision_id,
)
automation_run_id = crypto_random_string(10)
function_id = crypto_random_string(10)
function_name = f"automate test {crypto_random_string(3)}"
return AutomationRunData(
project_id=project_id,
model_id=model_id,
branch_name=branch_name,
version_id=version_id,
speckle_server_url=speckle_server_url,
automation_id=automation_id,
automation_revision_id=automation_revision_id,
automation_run_id=automation_run_id,
function_id=function_id,
function_name=function_name,
function_logo=None,
return create_test_automation_run_data(
test_client, speckle_server_url, project_id, test_automation_id
)
@@ -189,6 +148,9 @@ def automate_function(
automation_context.mark_run_success("No forbidden types found.")
@pytest.mark.skip(
"currently the function run cannot be integration tested with the server"
)
def test_function_run(automation_context: AutomationContext) -> None:
"""Run an integration test for the automate function."""
automation_context = run_function(
@@ -215,6 +177,9 @@ def test_file_path():
os.remove(path)
@pytest.mark.skip(
"currently the function run cannot be integration tested with the server"
)
def test_file_uploads(
automation_run_data: AutomationRunData, speckle_token: str, test_file_path: Path
):
@@ -230,6 +195,9 @@ def test_file_uploads(
assert len(automation_context._automation_result.blobs) == 1
@pytest.mark.skip(
"currently the function run cannot be integration tested with the server"
)
def test_create_version_in_project_raises_error_for_same_model(
automation_context: AutomationContext,
) -> None:
@@ -239,6 +207,9 @@ def test_create_version_in_project_raises_error_for_same_model(
)
@pytest.mark.skip(
"currently the function run cannot be integration tested with the server"
)
def test_create_version_in_project(
automation_context: AutomationContext,
) -> None:
@@ -252,6 +223,9 @@ def test_create_version_in_project(
assert version_id is not None
@pytest.mark.skip(
"currently the function run cannot be integration tested with the server"
)
def test_set_context_view(automation_context: AutomationContext) -> None:
automation_context.set_context_view()