diff --git a/alembic/versions/e6153267b741_add_execution_config_to_test_run_execution.py b/alembic/versions/e6153267b741_add_execution_config_to_test_run_execution.py new file mode 100644 index 00000000..96af2a89 --- /dev/null +++ b/alembic/versions/e6153267b741_add_execution_config_to_test_run_execution.py @@ -0,0 +1,32 @@ +"""add execution_config to test_run_execution + +Revision ID: e6153267b741 +Revises: 0a251edfd975 +Create Date: 2026-01-15 17:36:00.000000 + +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e6153267b741" +down_revision = "0a251edfd975" +branch_labels = None +depends_on = None + + +def upgrade(): + # Add execution_config column to testrunexecution table (optional JSON) + op.add_column( + "testrunexecution", + sa.Column( + "execution_config", postgresql.JSON(astext_type=sa.Text()), nullable=True + ), + ) + + +def downgrade(): + # Remove execution_config column + op.drop_column("testrunexecution", "execution_config") diff --git a/app/api/api_v1/endpoints/test_run_executions.py b/app/api/api_v1/endpoints/test_run_executions.py index e465f1e5..9235a71d 100644 --- a/app/api/api_v1/endpoints/test_run_executions.py +++ b/app/api/api_v1/endpoints/test_run_executions.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025 Project CHIP Authors +# Copyright (c) 2023-2026 Project CHIP Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -129,7 +129,7 @@ def __convert_pics_dict_to_object(pics: dict) -> schemas.PICS | None: return None -def __cli_project( +def _cli_project( db: Session, project_id: int | None, config: dict | None, @@ -179,14 +179,19 @@ def create_cli_test_run_execution( test_run_execution_in: schemas.TestRunExecutionCreate, selected_tests: schemas.TestSelection, config: dict | None = None, + execution_config: dict | None = None, pics: dict = {}, ) -> TestRunExecution: """Creates a new test run execution on CLI request. + Attention: if both config and execution_config are provided, + only config will be persisted, while execution_config will be for + this execution only. Args: test_run_execution_in: Test run execution data selected_tests: Selected tests to run - config: Configuration parameters (optional) + config: Configuration parameters that update project (optional, persists) + execution_config: Execution-specific config override (optional, temporary) pics: PICS configuration (optional) """ @@ -200,9 +205,15 @@ def create_cli_test_run_execution( ) # Retrieve or create the CLI project - cli_project = __cli_project(db, test_run_execution_in.project_id, config, pics_obj) + # Only update project if config is provided (not execution_config) + cli_project = _cli_project(db, test_run_execution_in.project_id, config, pics_obj) test_run_execution_in.project_id = cli_project.id + # Store execution_config if provided (temporary, per-execution) + if execution_config is not None: + logger.info(f"CLI Execution Config (Temporary): {execution_config}") + test_run_execution_in.execution_config = execution_config + test_run_execution_in.certification_mode = False test_run_execution = crud.test_run_execution.create( diff --git a/app/models/test_run_execution.py b/app/models/test_run_execution.py index a0817e19..e09f9ece 100644 --- a/app/models/test_run_execution.py +++ b/app/models/test_run_execution.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2023 Project CHIP Authors +# Copyright (c) 2023-2026 Project CHIP Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ # limitations under the License. # from datetime import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any -from sqlalchemy import Enum, ForeignKey, func, select +from sqlalchemy import JSON, Enum, ForeignKey, func, select from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.mutable import MutableList from sqlalchemy.ext.orderinglist import ordering_list @@ -46,15 +46,20 @@ class TestRunExecution(Base): ) title: Mapped[str] = mapped_column(nullable=False) created_at: Mapped[datetime] = mapped_column(default=datetime.now, nullable=False) - started_at: Mapped[Optional[datetime]] - completed_at: Mapped[Optional[datetime]] - archived_at: Mapped[Optional[datetime]] - imported_at: Mapped[Optional[datetime]] + started_at: Mapped[datetime | None] + completed_at: Mapped[datetime | None] + archived_at: Mapped[datetime | None] + imported_at: Mapped[datetime | None] certification_mode: Mapped[bool] = mapped_column(default=False, nullable=False) - description: Mapped[Optional[str]] = mapped_column(default=None, nullable=True) + description: Mapped[str | None] = mapped_column(default=None, nullable=True) - test_run_config_id: Mapped[Optional[int]] = mapped_column( + # Execution-specific config (temporary, not persisted to project) + execution_config: Mapped[dict[str, Any] | None] = mapped_column( + JSON, nullable=True, default=None + ) + + test_run_config_id: Mapped[int | None] = mapped_column( ForeignKey("testrunconfig.id"), nullable=True ) test_run_config: Mapped["TestRunConfig"] = relationship( @@ -76,7 +81,7 @@ class TestRunExecution(Base): collection_class=ordering_list("execution_index"), cascade="all, delete-orphan", ) - operator_id: Mapped[Optional[int]] = mapped_column( + operator_id: Mapped[int | None] = mapped_column( ForeignKey("operator.id"), nullable=True ) operator: Mapped["Operator"] = relationship( @@ -92,7 +97,7 @@ def append_to_log(self, log_record: "TestRunLogEntry") -> None: def test_suite_execution_by_public_id( self, public_id: str - ) -> Optional[TestSuiteExecution]: + ) -> TestSuiteExecution | None: return self.obj_session().scalar( select(TestSuiteExecution) .where(with_parent(self, TestSuiteExecution.test_run_execution)) diff --git a/app/schemas/test_run_execution.py b/app/schemas/test_run_execution.py index dddfe743..2b356aee 100644 --- a/app/schemas/test_run_execution.py +++ b/app/schemas/test_run_execution.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025 Project CHIP Authors +# Copyright (c) 2023-2026 Project CHIP Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,7 +14,6 @@ # limitations under the License. # from datetime import datetime -from typing import Dict, List, Optional from pydantic import BaseModel @@ -30,7 +29,7 @@ class TestRunExecutionStats(BaseModel): __test__ = False # Needed to indicate to PyTest that this is not a "test" test_case_count: int = 0 - states: Dict[TestStateEnum, int] = {} + states: dict[TestStateEnum, int] = {} # Shared properties @@ -38,30 +37,31 @@ class TestRunExecutionBase(BaseModel): __test__ = False # Needed to indicate to PyTest that this is not a "test" title: str - description: Optional[str] + description: str | None + execution_config: dict | None = None certification_mode: bool = False # Base + properties that represent relationhips class TestRunExecutionBaseWithRelationships(TestRunExecutionBase): - test_run_config_id: Optional[int] - project_id: Optional[int] + test_run_config_id: int | None + project_id: int | None # Properties additional fields on creation class TestRunExecutionCreate(TestRunExecutionBaseWithRelationships): # TODO(#124): Require project ID when UI supports project management. - operator_id: Optional[int] + operator_id: int | None # Properties shared by models stored in DB class TestRunExecutionInDBBase(TestRunExecutionBaseWithRelationships): id: int state: TestStateEnum - started_at: Optional[datetime] - completed_at: Optional[datetime] - imported_at: Optional[datetime] - archived_at: Optional[datetime] + started_at: datetime | None + completed_at: datetime | None + imported_at: datetime | None + archived_at: datetime | None class Config: orm_mode = True @@ -69,7 +69,7 @@ class Config: # Properties to return to client class TestRunExecution(TestRunExecutionInDBBase): - operator: Optional[Operator] + operator: Operator | None # Properties to return to client @@ -79,7 +79,7 @@ class TestRunExecutionWithStats(TestRunExecution): # Properties to return to client class TestRunExecutionWithChildren(TestRunExecution): - test_suite_executions: Optional[List[TestSuiteExecution]] + test_suite_executions: list[TestSuiteExecution] | None class TestRunExecutionUpdate(TestRunExecutionBase): @@ -91,7 +91,7 @@ class Config: # Additional Properties properties stored in DB class TestRunExecutionInDB(TestRunExecutionInDBBase): - operator_id: Optional[int] + operator_id: int | None created_at: datetime log: list[TestRunLogEntry] @@ -99,10 +99,10 @@ class TestRunExecutionInDB(TestRunExecutionInDBBase): # Shared properties between export and import schemas class TestRunExecutionExportImportBase(TestRunExecutionBase): state: TestStateEnum - started_at: Optional[datetime] - completed_at: Optional[datetime] - archived_at: Optional[datetime] - test_suite_executions: Optional[List[TestSuiteExecutionToExport]] + started_at: datetime | None + completed_at: datetime | None + archived_at: datetime | None + test_suite_executions: list[TestSuiteExecutionToExport] | None created_at: datetime log: list[TestRunLogEntry] @@ -112,8 +112,8 @@ class Config: # Schema used to export test run executions class TestRunExecutionToExport(TestRunExecutionExportImportBase): - operator: Optional[OperatorToExport] - test_run_config: Optional[TestRunConfigToExport] + operator: OperatorToExport | None + test_run_config: TestRunConfigToExport | None # Schema used to export test run executions @@ -127,7 +127,7 @@ class Config: # Schema used to import test run executions class TestRunExecutionToImport(TestRunExecutionExportImportBase): - project_id: Optional[int] - operator_id: Optional[int] - imported_at: Optional[datetime] - test_run_config_id: Optional[int] + project_id: int | None + operator_id: int | None + imported_at: datetime | None + test_run_config_id: int | None diff --git a/app/test_engine/models/test_case.py b/app/test_engine/models/test_case.py index e915dcc4..28252f1a 100644 --- a/app/test_engine/models/test_case.py +++ b/app/test_engine/models/test_case.py @@ -79,6 +79,16 @@ def project(self) -> Project: @property def config(self) -> dict: + """Get configuration for test case. + + Returns execution_config if available (temporary override from CLI), + otherwise returns project.config (persistent configuration). + """ + test_run_execution = ( + self.test_case_execution.test_suite_execution.test_run_execution + ) + if test_run_execution.execution_config is not None: + return test_run_execution.execution_config return self.project.config @property diff --git a/app/test_engine/models/test_run.py b/app/test_engine/models/test_run.py index ce95f8ef..69a95588 100644 --- a/app/test_engine/models/test_run.py +++ b/app/test_engine/models/test_run.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2025 Project CHIP Authors +# Copyright (c) 2023-2026 Project CHIP Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,7 +14,6 @@ # limitations under the License. # from asyncio import CancelledError, Task, create_task -from typing import List, Optional from app.models import Project, TestRunExecution, TestStateEnum from app.schemas.test_run_log_entry import TestRunLogEntry @@ -37,10 +36,10 @@ class TestRun(TestObservable, UserPromptSupport): def __init__(self, test_run_execution: TestRunExecution): super().__init__() self.test_run_execution = test_run_execution - self.current_test_suite: Optional[TestSuite] = None - self.test_suites: List[TestSuite] = [] + self.current_test_suite: TestSuite | None = None + self.test_suites: list[TestSuite] = [] self.__state = TestStateEnum.PENDING - self.__current_testing_task: Optional[Task] = None + self.__current_testing_task: Task | None = None self.log: list[TestRunLogEntry] = [] @property @@ -54,7 +53,13 @@ def project(self) -> Project: @property def config(self) -> dict: - """Convenience getter to access project config.""" + """Convenience getter to access config. + + Returns execution_config if present (temporary, per-execution config), + otherwise returns project.config (persistent config). + """ + if self.test_run_execution.execution_config is not None: + return self.test_run_execution.execution_config return self.project.config @property @@ -168,7 +173,7 @@ def append_log_entries(self, entries: list[TestRunLogEntry]) -> None: self.log.extend(entries) self.notify() - def subscribe(self, observers: List[Observer]) -> None: + def subscribe(self, observers: list[Observer]) -> None: """Subscribe a list of observers to test run changes, and changes on sub-models test suites, test cases, and test steps. @@ -178,7 +183,7 @@ def subscribe(self, observers: List[Observer]) -> None: super().subscribe(observers) self.__subscribe_test_suites(observers) - def __subscribe_test_suites(self, observers: List[Observer]) -> None: + def __subscribe_test_suites(self, observers: list[Observer]) -> None: """Subscribe sub-models to observers Args: @@ -187,7 +192,7 @@ def __subscribe_test_suites(self, observers: List[Observer]) -> None: for test_suite in self.test_suites: test_suite.subscribe(observers) - def unsubscribe(self, observers: List[Observer]) -> None: + def unsubscribe(self, observers: list[Observer]) -> None: """Unsubscribe observers from changes to test run changes, and sub-models test suites, test cases, and test steps. @@ -197,7 +202,7 @@ def unsubscribe(self, observers: List[Observer]) -> None: super().unsubscribe(observers) self.__unsubscribe_test_suites(observers) - def __unsubscribe_test_suites(self, observers: List[Observer]) -> None: + def __unsubscribe_test_suites(self, observers: list[Observer]) -> None: """Unsubscribe sub-models to observers Args: diff --git a/app/test_engine/models/test_suite.py b/app/test_engine/models/test_suite.py index cdfb24d0..ab054109 100644 --- a/app/test_engine/models/test_suite.py +++ b/app/test_engine/models/test_suite.py @@ -58,6 +58,14 @@ def project(self) -> Project: @property def config(self) -> dict: + """Get configuration for test suite. + + Returns execution_config if available (temporary override from CLI), + otherwise returns project.config (persistent configuration). + """ + test_run_execution = self.test_suite_execution.test_run_execution + if test_run_execution.execution_config is not None: + return test_run_execution.execution_config return self.project.config @property @@ -76,7 +84,7 @@ def state(self, value: TestStateEnum) -> None: def __compute_state(self) -> TestStateEnum: """ - State is computed based test_suite errors and on on test case states. + State is computed based on test_suite errors and test case states. """ if self.errors is not None and len(self.errors) > 0: return TestStateEnum.ERROR