From 85c7f94bdffddc4bc5004263e0061eac892ddb6a Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 16:36:33 +0100 Subject: [PATCH 1/2] feat(manual): expose executionType and manual execution fields Angles 3.0 adds manual test case management. Builds and executions produced by a manual test run are tagged executionType: "manual"; everything this client reports stays "automated". - ExecutionTypes str enum, matching the GroupingPeriods style - Build.executionType - Execution.executionType plus the manual-only manualTestCase, manualTestCaseVersion, versionNumber and executedBy - Step.attachments, alongside the existing screenshot - Period.executionTypeBreakdown and PhaseMetrics.executionType, added to the phase metrics response in 3.0 - Optional execution_type filter on get_builds, get_builds_with_filters, get_builds_with_date_filters and get_phase_metrics All read-only. CreateBuild and CreateExecution deliberately do not carry executionType: it is what distinguishes a manual run from an automated one, and a reporting client setting it to "manual" would put a build on the dashboard that no manual test run exists to explain. It stays server-assigned. execution_type is a trailing keyword argument everywhere, so existing positional calls are unaffected; it accepts an ExecutionTypes member or a plain string, and is omitted from the query string when unset. Co-Authored-By: Claude Opus 5 --- angles_python_client/__init__.py | 2 ++ angles_python_client/models/__init__.py | 3 ++- angles_python_client/models/build.py | 4 +++- angles_python_client/models/enums.py | 13 ++++++++++++ angles_python_client/models/execution.py | 9 +++++++- angles_python_client/models/responses.py | 5 +++++ angles_python_client/models/step.py | 4 +++- angles_python_client/requests.py | 27 +++++++++++++++++++++--- 8 files changed, 60 insertions(+), 7 deletions(-) diff --git a/angles_python_client/__init__.py b/angles_python_client/__init__.py index 3dd53af..1a81d35 100644 --- a/angles_python_client/__init__.py +++ b/angles_python_client/__init__.py @@ -8,6 +8,7 @@ """ from .http import AnglesHttpClient +from .models.enums import ExecutionTypes from .reporter import AnglesReporter, angles_reporter from .requests import ( BuildRequests, @@ -22,6 +23,7 @@ __all__ = [ "AnglesHttpClient", + "ExecutionTypes", "AnglesReporter", "angles_reporter", "BuildRequests", diff --git a/angles_python_client/models/__init__.py b/angles_python_client/models/__init__.py index b2e9999..56ee60a 100644 --- a/angles_python_client/models/__init__.py +++ b/angles_python_client/models/__init__.py @@ -20,7 +20,7 @@ DiffRegion, ) -from .enums import ExecutionStates, StepStates, GroupingPeriods +from .enums import ExecutionStates, ExecutionTypes, StepStates, GroupingPeriods from .requests import ( CreateBuild, @@ -56,6 +56,7 @@ "Step", "Versions", "ExecutionStates", + "ExecutionTypes", "StepStates", "GroupingPeriods", "CreateBuild", diff --git a/angles_python_client/models/build.py b/angles_python_client/models/build.py index bb31383..f5b402a 100644 --- a/angles_python_client/models/build.py +++ b/angles_python_client/models/build.py @@ -6,7 +6,7 @@ from .artifact import Artifact from .environment import Environment -from .enums import ExecutionStates +from .enums import ExecutionStates, ExecutionTypes from .team import Team @@ -24,3 +24,5 @@ class Build: team: Optional[Team] = None component: Optional[str] = None suites: Optional[List[Any]] = None + #: Server-assigned; "automated" unless this build backs a manual test run. + executionType: Optional[ExecutionTypes] = None diff --git a/angles_python_client/models/enums.py b/angles_python_client/models/enums.py index 52c2f40..1315fce 100644 --- a/angles_python_client/models/enums.py +++ b/angles_python_client/models/enums.py @@ -18,6 +18,19 @@ class StepStates(str, Enum): FAIL = "FAIL" +class ExecutionTypes(str, Enum): + """Whether a build or execution came from an automated framework or a manual test run. + + Read-only from this client's point of view. The value is assigned by the Angles + server - a reporting client setting it to MANUAL would put a build on the dashboard + that no manual test run exists to explain - so it is absent from CreateBuild and + CreateExecution and only ever used to read results back or to filter a list call. + """ + + AUTOMATED = "automated" + MANUAL = "manual" + + class GroupingPeriods(str, Enum): DAY = "day" WEEK = "week" diff --git a/angles_python_client/models/execution.py b/angles_python_client/models/execution.py index c3aecf1..9a8021a 100644 --- a/angles_python_client/models/execution.py +++ b/angles_python_client/models/execution.py @@ -6,7 +6,7 @@ from .action import Action from .build import Build -from .enums import ExecutionStates +from .enums import ExecutionStates, ExecutionTypes from .platform import Platform @@ -24,3 +24,10 @@ class Execution: tags: Optional[List[str]] = None meta: Optional[Dict[str, Any]] = None status: Optional[ExecutionStates] = None + #: Server-assigned; "automated" unless this came from a manual test run. + executionType: Optional[ExecutionTypes] = None + #: The fields below are populated only on manual executions. + manualTestCase: Optional[str] = None + manualTestCaseVersion: Optional[str] = None + versionNumber: Optional[int] = None + executedBy: Optional[str] = None diff --git a/angles_python_client/models/responses.py b/angles_python_client/models/responses.py index a2bff3a..8542374 100644 --- a/angles_python_client/models/responses.py +++ b/angles_python_client/models/responses.py @@ -50,6 +50,9 @@ class Period: result: Optional[Dict[str, Any]] = None buildCount: Optional[int] = None phases: Optional[List[Any]] = None + #: {"automated": n, "manual": n} - counts every execution in the period, so a + #: stacked chart built from it matches result["TOTAL"]. + executionTypeBreakdown: Optional[Dict[str, int]] = None @dataclass @@ -58,6 +61,8 @@ class PhaseMetrics: fromDate: Optional[_dt.date] = None groupingPeriod: Optional[str] = None periods: Optional[List[Period]] = None + #: Echoes the requested filter; absent when both types are included. + executionType: Optional[str] = None @dataclass diff --git a/angles_python_client/models/step.py b/angles_python_client/models/step.py index 26762e7..45ec2b9 100644 --- a/angles_python_client/models/step.py +++ b/angles_python_client/models/step.py @@ -2,7 +2,7 @@ import datetime as _dt from dataclasses import dataclass -from typing import Optional +from typing import List, Optional from .enums import StepStates @@ -16,3 +16,5 @@ class Step: status: Optional[StepStates] = None timestamp: Optional[_dt.datetime] = None screenshot: Optional[str] = None + #: Attachment ids referenced by a manual step result. + attachments: Optional[List[str]] = None diff --git a/angles_python_client/requests.py b/angles_python_client/requests.py index bee3f08..49dddfd 100644 --- a/angles_python_client/requests.py +++ b/angles_python_client/requests.py @@ -7,9 +7,16 @@ from ._serialize import jsonable, json_dumps from .http import AnglesHttpClient -from .models.enums import GroupingPeriods +from .models.enums import ExecutionTypes, GroupingPeriods + +def _execution_type_param(execution_type: Optional[ExecutionTypes]) -> Optional[str]: + """Normalises an ExecutionTypes member (or a plain string) to its wire value.""" + if execution_type is None: + return None + return execution_type.value if hasattr(execution_type, "value") else str(execution_type) + class BaseRequests: def __init__(self, http: AnglesHttpClient): self.http = http @@ -77,21 +84,27 @@ class BuildRequests(BaseRequests): def create_build(self, request: Any) -> Any: return self.post("build", request) - def get_builds(self, team_id: str, build_ids: Optional[List[str]] = None, return_execution_details: bool = False) -> Any: + def get_builds(self, team_id: str, build_ids: Optional[List[str]] = None, return_execution_details: bool = False, execution_type: Optional[ExecutionTypes] = None) -> Any: # matches JS: /build?teamId=...&buildIds=...&returnExecutionDetails=... params: Dict[str, Any] = {"teamId": team_id} if build_ids: params["buildIds"] = ",".join(build_ids) if return_execution_details: params["returnExecutionDetails"] = "true" + execution_type_value = _execution_type_param(execution_type) + if execution_type_value: + params["executionType"] = execution_type_value return self.get("build", params=params) - def get_builds_with_filters(self, team_id: str, filter_environments: Optional[List[str]] = None, filter_components: Optional[List[str]] = None, skip: int = 0, limit: int = 50) -> Any: + def get_builds_with_filters(self, team_id: str, filter_environments: Optional[List[str]] = None, filter_components: Optional[List[str]] = None, skip: int = 0, limit: int = 50, execution_type: Optional[ExecutionTypes] = None) -> Any: params: Dict[str, Any] = {"teamId": team_id, "skip": skip, "limit": limit} if filter_environments: params["environmentIds"] = ",".join(filter_environments) if filter_components: params["componentIds"] = ",".join(filter_components) + execution_type_value = _execution_type_param(execution_type) + if execution_type_value: + params["executionType"] = execution_type_value return self.get("build", params=params) def get_builds_with_date_filters( @@ -103,6 +116,7 @@ def get_builds_with_date_filters( limit: int = 50, from_date: Optional[_dt.date] = None, to_date: Optional[_dt.date] = None, + execution_type: Optional[ExecutionTypes] = None, ) -> Any: params: Dict[str, Any] = {"teamId": team_id, "skip": skip, "limit": limit} if from_date: @@ -113,6 +127,9 @@ def get_builds_with_date_filters( params["environmentIds"] = ",".join(filter_environments) if filter_components: params["componentIds"] = ",".join(filter_components) + execution_type_value = _execution_type_param(execution_type) + if execution_type_value: + params["executionType"] = execution_type_value return self.get("build", params=params) def delete_builds(self, team_id: str, age_in_days: int) -> Any: @@ -363,6 +380,7 @@ def get_phase_metrics( from_date: Optional[_dt.date] = None, to_date: Optional[_dt.date] = None, grouping_period: Optional[GroupingPeriods] = None, + execution_type: Optional[ExecutionTypes] = None, ) -> Any: params: Dict[str, Any] = {"teamId": team_id} if component_id: @@ -373,6 +391,9 @@ def get_phase_metrics( params["toDate"] = to_date.isoformat() if grouping_period: params["groupingPeriod"] = grouping_period.value if hasattr(grouping_period, "value") else str(grouping_period) + execution_type_value = _execution_type_param(execution_type) + if execution_type_value: + params["executionType"] = execution_type_value # JS uses an absolute URL via new URL(baseURL + '/metrics/phase?...') # We'll just pass a relative path + params; client will build URL. return self.get("metrics/phase", params=params) From eaffdb08eb4896949bae96333bf72be6ea882ec9 Mon Sep 17 00:00:00 2001 From: Sergio Neves Barros Date: Sun, 23 Aug 2026 16:58:49 +0100 Subject: [PATCH 2/2] chore: move to the 3.x line for the Angles 3.0 API The execution_type and manual execution fields only exist on an Angles 3.0 server, so the client's major version now tracks the API's rather than continuing the 1.0.x line. Applied with bump2version so pyproject.toml and .bumpversion.cfg stay in step - the release workflow's post-release bump reads the latter. The workflow publishes the pyproject version verbatim, so this releases as 3.0.0. Co-Authored-By: Claude Opus 5 --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2d42af8..bf769a3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.14 +current_version = 3.0.0 commit = True tag = True tag_name = v{new_version} diff --git a/pyproject.toml b/pyproject.toml index 25c44c9..885915e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "angles-python-client" -version = "1.0.14" +version = "3.0.0" description = "Python client for the Angles Dashboard API" readme = "README.md" requires-python = ">=3.9"