diff --git a/.githooks/pre-push b/.githooks/pre-push index cff188e15..e5e361da2 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -14,8 +14,7 @@ echo # Check for changes in Python files # Compare against the remote branch being pushed to -python_changed_files=($(git diff --name-only --diff-filter=ACM @{upstream}... | grep '^python/' || true)) - +python_changed_files=($(git diff --name-only --diff-filter=ACM @{upstream}... | grep -E '^(python/|pyproject.toml)' || true)) echo "→ Python files changed:" if [[ -n "$python_changed_files" ]]; then echo " ${python_changed_files[*]}" diff --git a/.github/workflows/python_ci.yaml b/.github/workflows/python_ci.yaml index 1e5818dc5..9eba048f7 100644 --- a/.github/workflows/python_ci.yaml +++ b/.github/workflows/python_ci.yaml @@ -40,6 +40,10 @@ jobs: run: | ruff format --check + - name: Type Check + run: | + ty check --python $(which python) + - name: MyPy run: | mypy lib diff --git a/python/lib/sift_client/_internal/low_level_wrappers/assets.py b/python/lib/sift_client/_internal/low_level_wrappers/assets.py index 9c42bd74e..70348c302 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/assets.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/assets.py @@ -97,8 +97,8 @@ async def update_asset(self, update: AssetUpdate) -> Asset: updated_grpc_asset = cast("UpdateAssetResponse", response).asset return Asset._from_proto(updated_grpc_asset) - async def archive_asset(self, asset_id: str, archive_runs: bool = False) -> list[str] | None: + async def archive_asset(self, asset_id: str, archive_runs: bool = False) -> list[str]: request = ArchiveAssetRequest(asset_id=asset_id, archive_runs=archive_runs) response = await self._grpc_client.get_stub(AssetServiceStub).ArchiveAsset(request) response = cast("ArchiveAssetResponse", response) - return response.archived_runs + return list(response.archived_run_ids) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py index 890146d78..2d9a8848c 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py @@ -4,7 +4,6 @@ from typing import Any, cast from sift.calculated_channels.v2.calculated_channels_pb2 import ( - CalculatedChannelValidationResult, CreateCalculatedChannelResponse, GetCalculatedChannelRequest, GetCalculatedChannelResponse, @@ -185,12 +184,8 @@ async def update_calculated_channel( response = cast("UpdateCalculatedChannelResponse", response) updated_calculated_channel = CalculatedChannel._from_proto(response.calculated_channel) - inapplicable_assets = [ - cast("CalculatedChannelValidationResult", asset) - for asset in response.inapplicable_assets - ] - return updated_calculated_channel, inapplicable_assets + return updated_calculated_channel, list(response.inapplicable_assets) async def list_calculated_channel_versions( self, diff --git a/python/lib/sift_client/_tests/_internal/test_stub_module/test_py.py b/python/lib/sift_client/_tests/_internal/test_stub_module/test_py.py index e5f984484..5a9011ee2 100644 --- a/python/lib/sift_client/_tests/_internal/test_stub_module/test_py.py +++ b/python/lib/sift_client/_tests/_internal/test_stub_module/test_py.py @@ -12,7 +12,7 @@ class MockClassAsync(ResourceBase): """Mock async class docstring.""" def __init__(self, client=None): - super().__init__(client) + super().__init__(client) # ty: ignore[invalid-argument-type] async def async_method(self, param1: str, *, param2: int = 0) -> str: """Mock async method docstring. diff --git a/python/lib/sift_client/_tests/_internal/test_sync_wrapper.py b/python/lib/sift_client/_tests/_internal/test_sync_wrapper.py index 86841f2b6..267d6519b 100644 --- a/python/lib/sift_client/_tests/_internal/test_sync_wrapper.py +++ b/python/lib/sift_client/_tests/_internal/test_sync_wrapper.py @@ -46,7 +46,7 @@ class MockResourceAsync(ResourceBase): """Mock async resource class for testing the sync wrapper.""" def __init__(self, client=None, value: str = "default"): - super().__init__(client) + super().__init__(client) # ty: ignore[invalid-argument-type] self._value = value self._calls: dict[str, int] = {} diff --git a/python/lib/sift_client/_tests/resources/test_test_results.py b/python/lib/sift_client/_tests/resources/test_test_results.py index 64d575be9..4c62e8a5b 100644 --- a/python/lib/sift_client/_tests/resources/test_test_results.py +++ b/python/lib/sift_client/_tests/resources/test_test_results.py @@ -53,7 +53,7 @@ def test_create_test_report(self, sift_client, nostromo_run): "part_number": "1234567890", "start_time": simulated_time, "end_time": simulated_time, - "run_id": nostromo_run.id_, + "run_id": nostromo_run._id_or_error, }, ) assert test_report.id_ is not None @@ -64,12 +64,14 @@ def test_create_test_steps(self, sift_client): test_report = self.test_reports.get("basic_test_report") if not test_report: pytest.skip("Need to create a test report first") + return + simulated_time = test_report.start_time # Create multiple test steps using TestStepCreate step1 = sift_client.test_results.create_step( TestStepCreate( - test_report_id=test_report.id_, + test_report_id=test_report._id_or_error, name="Step 1: Initialization", description="Initialize the test environment", step_type=TestStepType.ACTION, @@ -83,8 +85,8 @@ def test_create_test_steps(self, sift_client): # Create a step using a dict step1_1 = sift_client.test_results.create_step( { - "test_report_id": test_report.id_, - "parent_step_id": step1.id_, + "test_report_id": test_report._id_or_error, + "parent_step_id": step1._id_or_error, "name": "Step 1.1: Substep 1", "description": "Substep 1 of Step 1", "step_type": TestStepType.ACTION, @@ -98,7 +100,7 @@ def test_create_test_steps(self, sift_client): step2 = sift_client.test_results.create_step( TestStepCreate( - test_report_id=test_report.id_, + test_report_id=test_report._id_or_error, name="Step 2: Data Collection", description="Collect sensor data", step_type=TestStepType.ACTION, @@ -111,7 +113,7 @@ def test_create_test_steps(self, sift_client): simulated_time = simulated_time + timedelta(seconds=10.1) step3 = sift_client.test_results.create_step( TestStepCreate( - test_report_id=test_report.id_, + test_report_id=test_report._id_or_error, name="Step 3: Validation", description="Validate collected data", step_type=TestStepType.ACTION, @@ -124,8 +126,8 @@ def test_create_test_steps(self, sift_client): step3_1 = sift_client.test_results.create_step( TestStepCreate( - test_report_id=test_report.id_, - parent_step_id=step3.id_, + test_report_id=test_report._id_or_error, + parent_step_id=step3._id_or_error, name="Step 3.1: Substep 3.1", description="Error demo", step_type=TestStepType.ACTION, @@ -155,6 +157,8 @@ def test_update_test_steps(self, sift_client): step3_1 = self.test_steps.get("step3_1") if not step3 or not step3_1: pytest.skip("Need to create a step first") + return + step3 = sift_client.test_results.update_step( step3, {"status": TestStatus.PASSED}, @@ -173,11 +177,12 @@ def test_create_test_measurements(self, sift_client): step1_1 = self.test_steps.get("step1_1") if not step1 or not step2 or not step3 or not step1_1: pytest.skip("Need to create steps first") + return # Create measurements for each step using TestMeasurementCreate measurement1 = sift_client.test_results.create_measurement( TestMeasurementCreate( - test_step_id=step1.id_, + test_step_id=step1._id_or_error, name="Temperature Reading", measurement_type=TestMeasurementType.DOUBLE, numeric_value=25.5, @@ -195,7 +200,7 @@ def test_create_test_measurements(self, sift_client): # Create a measurement using a dict measurement2 = sift_client.test_results.create_measurement( { - "test_step_id": step2.id_, + "test_step_id": step2._id_or_error, "name": "FW Version", "measurement_type": TestMeasurementType.STRING, "string_value": "1.10.3", @@ -208,7 +213,7 @@ def test_create_test_measurements(self, sift_client): measurement3 = sift_client.test_results.create_measurement( TestMeasurementCreate( - test_step_id=step3.id_, + test_step_id=step3._id_or_error, name="Status Check", measurement_type=TestMeasurementType.BOOLEAN, boolean_value=True, @@ -218,9 +223,10 @@ def test_create_test_measurements(self, sift_client): update_step=True, ) + assert step1_1.start_time measurement4 = sift_client.test_results.create_measurement( TestMeasurementCreate( - test_step_id=step1_1.id_, + test_step_id=step1_1._id_or_error, name="Substep 1.1: Substep 1.1.1", measurement_type=TestMeasurementType.BOOLEAN, boolean_value=True, @@ -244,6 +250,7 @@ def test_update_test_measurements(self, sift_client): measurement4 = self.test_measurements.get("measurement4") if not measurement2 or not measurement4: pytest.skip("Need to create measurements first") + return measurement2 = sift_client.test_results.update_measurement( measurement2, @@ -284,6 +291,8 @@ def test_update_test_report(self, sift_client): test_report = self.test_reports.get("basic_test_report") if not test_report: pytest.skip("Need to create a test report first") + return + new_end_time = test_report.start_time + timedelta(seconds=42) # Update the report with metadata updated_report = sift_client.test_results.update( @@ -341,6 +350,8 @@ def test_list_test_steps(self, sift_client): if step.parent_step_id is not None: existing_step = step break + + assert existing_step is not None assert len(steps) steps = sift_client.test_results.list_steps( test_reports=[existing_step.test_report_id], @@ -367,6 +378,7 @@ def test_archive_and_delete_test_report(self, sift_client): test_report = self.test_reports.get("basic_test_report") if not test_report: pytest.skip("Need to create a test report first") + return # Archive the report archived_report = sift_client.test_results.archive(test_report=test_report) @@ -374,7 +386,7 @@ def test_archive_and_delete_test_report(self, sift_client): sift_client.test_results.delete(test_report=test_report) try: - deleted_report = sift_client.test_results.get(test_report_id=test_report.id_) + deleted_report = sift_client.test_results.get(test_report_id=test_report._id_or_error) assert deleted_report is None # Shouldn't reach here so error if we get something. except aiogrpc.AioRpcError as e: self.test_reports.pop("basic_test_report") @@ -389,7 +401,7 @@ def test_import_test_report(self, sift_client): # Excercise find_report, custom_filter, and filtering by commonon-proto fields such as created_date found_report = sift_client.test_results.find( - filter_query=f"test_report_id == '{test_report.id_}' && created_date >= timestamp('{create_time}')" + filter_query=f"test_report_id == '{test_report._id_or_error}' && created_date >= timestamp('{create_time}')" ) assert found_report is not None assert found_report.id_ == test_report.id_ diff --git a/python/lib/sift_client/_tests/sift_types/test_base.py b/python/lib/sift_client/_tests/sift_types/test_base.py index a0c3cfc58..de0f9ef7e 100644 --- a/python/lib/sift_client/_tests/sift_types/test_base.py +++ b/python/lib/sift_client/_tests/sift_types/test_base.py @@ -433,6 +433,7 @@ def _from_proto(cls, proto, sift_client=None): # Should not raise model = TestModel(name="test", created_date=datetime.now(timezone.utc)) + assert model.created_date assert model.created_date.tzinfo is not None def test_validate_timezones_with_naive_datetime(self): diff --git a/python/lib/sift_client/_tests/sift_types/test_calculated_channel.py b/python/lib/sift_client/_tests/sift_types/test_calculated_channel.py index e5b1059c5..a3ebeb520 100644 --- a/python/lib/sift_client/_tests/sift_types/test_calculated_channel.py +++ b/python/lib/sift_client/_tests/sift_types/test_calculated_channel.py @@ -162,6 +162,7 @@ def test_expression_validator_accepts_both_set(self): ], ) assert update.expression == "$1 + $2" + assert update.expression_channel_references assert len(update.expression_channel_references) == 2 diff --git a/python/lib/sift_client/_tests/sift_types/test_ingestion.py b/python/lib/sift_client/_tests/sift_types/test_ingestion.py index cd31221d6..dff614ae1 100644 --- a/python/lib/sift_client/_tests/sift_types/test_ingestion.py +++ b/python/lib/sift_client/_tests/sift_types/test_ingestion.py @@ -61,6 +61,7 @@ def test_bitfield_validator_accepts_bitfield_with_elements(self): ], ) assert channel.data_type == ChannelDataType.BIT_FIELD + assert channel.bit_field_elements assert len(channel.bit_field_elements) == 2 def test_other_data_types_dont_require_special_fields(self): diff --git a/python/lib/sift_client/_tests/util/test_test_results_utils.py b/python/lib/sift_client/_tests/util/test_test_results_utils.py index cd7c953e6..bdb655d32 100644 --- a/python/lib/sift_client/_tests/util/test_test_results_utils.py +++ b/python/lib/sift_client/_tests/util/test_test_results_utils.py @@ -285,6 +285,7 @@ def test_evaluate_measurement_bounds_with_numeric_bounds_object(self): result = evaluate_measurement_bounds(measurement, 5.0, bounds) assert result == True assert measurement.passed == True + assert measurement.numeric_bounds assert measurement.numeric_bounds.min == 0.0 assert measurement.numeric_bounds.max == 10.0 @@ -324,6 +325,7 @@ def test_evaluate_measurement_bounds_boolean_matching(self): assert result6 == True assert measurement.passed == True assert measurement.boolean_value == True + assert measurement.string_expected_value assert measurement.string_expected_value.lower() == "true" def test_evaluate_measurement_bounds_boolean_not_matching(self): @@ -343,6 +345,7 @@ def test_evaluate_measurement_bounds_boolean_not_matching(self): assert result6 == False assert measurement.passed == False assert measurement.boolean_value == False + assert measurement.string_expected_value assert measurement.string_expected_value.lower() == "true" def test_evaluate_measurement_bounds_boolean_case_insensitive(self): diff --git a/python/lib/sift_client/util/test_results/bounds.py b/python/lib/sift_client/util/test_results/bounds.py index f1bf42920..f90ec35f2 100644 --- a/python/lib/sift_client/util/test_results/bounds.py +++ b/python/lib/sift_client/util/test_results/bounds.py @@ -35,14 +35,14 @@ def assign_value_to_measurement( def evaluate_measurement_bounds( measurement: TestMeasurement | TestMeasurementCreate | TestMeasurementUpdate, value: float | str | bool, - bounds: dict[str, float] | NumericBounds | str | bool | None, + bounds: dict[str, float] | NumericBounds | str | float | bool | None, ) -> bool: """Update a measurement with the resolved bounds type and result of evaluating the given value against those bounds. Args: measurement: The measurement to update. value: The value to evaluate the bounds of. - bounds: The bounds to evaluate the value against. Either a dictionary with "min" and "max" keys, a NumericBounds object, a string, a boolean, or None. + bounds: The bounds to evaluate the value against. Either a dictionary with "min" and "max" keys, a NumericBounds object, a string, a float, a boolean, or None. Returns: True if the value is within the bounds, False otherwise. diff --git a/python/lib/sift_client/util/test_results/context_manager.py b/python/lib/sift_client/util/test_results/context_manager.py index 6a4a391d6..ec52468f1 100644 --- a/python/lib/sift_client/util/test_results/context_manager.py +++ b/python/lib/sift_client/util/test_results/context_manager.py @@ -260,7 +260,8 @@ def __exit__(self, exc, exc_value, tb): self.update_step_from_result(exc, exc_value, tb) # Now that the step is updated. Let the report context handle removing it from the stack and updating the report context. - self.report_context.exit_step(self.current_step) + if self.current_step: + self.report_context.exit_step(self.current_step) return True diff --git a/python/pyproject.toml b/python/pyproject.toml index 0c350a71a..7d9be0930 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -80,6 +80,7 @@ dev = [ 'pytest==8.2.2', 'ruff~=0.12.10', 'tomlkit~=0.13.3', + 'ty~=0.0.4', ] dev-all = [ 'build==1.2.1', @@ -103,6 +104,7 @@ dev-all = [ 'sift-stream-bindings>=0.2.0-rc4', 'tomlkit~=0.13.3', 'types-pyOpenSSL<24.0.0', + 'ty~=0.0.4', ] development = [ 'grpcio-testing~=1.13', @@ -115,6 +117,7 @@ development = [ 'pytest==8.2.2', 'ruff~=0.12.10', 'tomlkit~=0.13.3', + 'ty~=0.0.4', ] docs = [ 'griffe-pydantic', @@ -156,6 +159,7 @@ docs-build = [ 'sift-stream-bindings>=0.2.0-rc4', 'tomlkit~=0.13.3', 'types-pyOpenSSL<24.0.0', + 'ty~=0.0.4', ] file-imports = [ 'h5py~=3.11', @@ -199,7 +203,8 @@ development = [ "pytest-mock==3.14.0", "pytest-dotenv==0.5.2", "ruff~=0.12.10", - "tomlkit~=0.13.3" + "tomlkit~=0.13.3", + "ty~=0.0.4" ] build = ["pdoc==14.5.0", "build==1.2.1"] docs = ["mkdocs", @@ -316,6 +321,9 @@ sift_grafana = ["py.typed"] sift_py = ["py.typed"] sift_client = ["py.typed", "resources/sync_stubs/*.pyi"] +[tool.ty.src] +include = ["lib/sift_client"] + [tool.ruff] line-length = 100 indent-width = 4