diff --git a/python/Makefile b/python/Makefile index 1d00eda3f..4fb9268f4 100644 --- a/python/Makefile +++ b/python/Makefile @@ -121,8 +121,4 @@ lint-fix: .PHONY: default help pkg-test-all pkg-ty-all build generate sync \ clean clean-venv clean-build clean-test test test-report ty \ - lint lint-fix \ - pkg-ty-jumpstarter \ - pkg-ty-jumpstarter-cli-admin \ - pkg-ty-jumpstarter-kubernetes \ - pkg-ty-jumpstarter-protocol + lint lint-fix diff --git a/python/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py b/python/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py index 353ac8bf6..7aed0ae06 100644 --- a/python/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py +++ b/python/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py @@ -10,7 +10,7 @@ from jumpstarter_imagehash import ImageHash from jumpstarter_testing.pytest import JumpstarterTest -log = logging.getLogger(__file__) +log = logging.getLogger(__name__) class TestResource(JumpstarterTest): diff --git a/python/examples/xcp-ecu/jumpstarter_example_xcp_ecu/conftest.py b/python/examples/xcp-ecu/jumpstarter_example_xcp_ecu/conftest.py index 6903c9d42..d6c656a1c 100644 --- a/python/examples/xcp-ecu/jumpstarter_example_xcp_ecu/conftest.py +++ b/python/examples/xcp-ecu/jumpstarter_example_xcp_ecu/conftest.py @@ -25,6 +25,5 @@ def ecu_client(mock_ecu): with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_ecu, - ): - with serve(driver) as client: - yield client + ), serve(driver) as client: + yield client diff --git a/python/packages/hatch-pin-jumpstarter/src/hatch_pin_jumpstarter/__init__.py b/python/packages/hatch-pin-jumpstarter/src/hatch_pin_jumpstarter/__init__.py index f2daeab8f..e6bc782ef 100644 --- a/python/packages/hatch-pin-jumpstarter/src/hatch_pin_jumpstarter/__init__.py +++ b/python/packages/hatch-pin-jumpstarter/src/hatch_pin_jumpstarter/__init__.py @@ -29,9 +29,8 @@ def initialize(self, version, build_data): req.specifier &= SpecifierSet(f"=={self.metadata.version}") metadata["project"]["dependencies"][i] = str(req) - f = NamedTemporaryFile(delete=False) - tomli_w.dump(metadata, f) - f.close() + with NamedTemporaryFile(delete=False) as f: # pragma: no cover + tomli_w.dump(metadata, f) build_data["__hatch_pin_jumpstarter_tempfile"] = f build_data["force_include"][f.name] = "pyproject.toml" diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply.py index 9d3d2290a..6874670d7 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import IO, Optional +from typing import IO import click from jumpstarter_cli_common.blocking import blocking @@ -53,8 +53,8 @@ async def apply( force_conflicts: bool, dry_run: bool, namespace: str, - kubeconfig: Optional[str], - context: Optional[str], + kubeconfig: str | None, + context: str | None, output: OutputType, ): """Apply Jumpstarter manifests to a Kubernetes cluster diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply_test.py index b1763208c..81b9e6ec1 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/apply_test.py @@ -28,11 +28,11 @@ def applied(kind: str, name: str, action: str, api_version: str = "jumpstarter.dev/v1alpha1"): return V1Alpha1AppliedResource( - apiVersion=api_version, + apiVersion=api_version, # type: ignore[call-arg] kind=kind, name=name, namespace="default", - action=action, + action=action, # type: ignore[arg-type] resource={"apiVersion": api_version, "kind": kind, "metadata": {"name": name}}, ) diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py index 5b5134dc7..0b3d035c9 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create.py @@ -1,4 +1,3 @@ -from typing import Optional import click from jumpstarter_cli_common.alias import AliasedGroup @@ -76,16 +75,16 @@ def create(): @opt_output_all @blocking async def create_client( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, insecure_tls: bool, namespace: str, labels: dict[str, str], save: bool, - allow: Optional[str], + allow: str | None, unsafe: bool, - out: Optional[str], + out: str | None, oidc_username: str | None, nointeractive: bool, output: OutputType, @@ -154,14 +153,14 @@ async def create_client( @opt_output_all @blocking async def create_exporter( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, insecure_tls: bool, namespace: str, labels: dict[str, str], save: bool, - out: Optional[str], + out: str | None, oidc_username: str | None, nointeractive: bool, output: OutputType, @@ -244,23 +243,23 @@ async def create_exporter( @blocking async def create_cluster( name: str, - kind: Optional[str], - minikube: Optional[str], - k3s: Optional[str], + kind: str | None, + minikube: str | None, + k3s: str | None, force_recreate: bool, kind_extra_args: str, minikube_extra_args: str, - extra_certs: Optional[str], + extra_certs: str | None, skip_install: bool, - operator_installer: Optional[str], + operator_installer: str | None, namespace: str, - ip: Optional[str], - basedomain: Optional[str], - grpc_endpoint: Optional[str], - router_endpoint: Optional[str], - version: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + ip: str | None, + basedomain: str | None, + grpc_endpoint: str | None, + router_endpoint: str | None, + version: str | None, + kubeconfig: str | None, + context: str | None, nointeractive: bool, output: OutputType, ): diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py index d5c76acfa..3a2bd4874 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/create_test.py @@ -40,35 +40,35 @@ ), ) -CLIENT_JSON = """{{ +CLIENT_JSON = f"""{{ "apiVersion": "jumpstarter.dev/v1alpha1", "kind": "Client", "metadata": {{ "creationTimestamp": "2024-01-01T21:00:00Z", - "name": "{name}", + "name": "{CLIENT_NAME}", "namespace": "default" }}, "status": {{ "credential": {{ - "name": "{name}-credential" + "name": "{CLIENT_NAME}-credential" }}, - "endpoint": "{endpoint}" + "endpoint": "{CLIENT_ENDPOINT}" }} }} -""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT) +""" -CLIENT_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +CLIENT_YAML = f"""apiVersion: jumpstarter.dev/v1alpha1 kind: Client metadata: creationTimestamp: '2024-01-01T21:00:00Z' - name: {name} + name: {CLIENT_NAME} namespace: default status: credential: - name: {name}-credential - endpoint: {endpoint} + name: {CLIENT_NAME}-credential + endpoint: {CLIENT_ENDPOINT} -""".format(name=CLIENT_NAME, endpoint=CLIENT_ENDPOINT) +""" UNSAFE_CLIENT_CONFIG = ClientConfigV1Alpha1( alias=CLIENT_NAME, @@ -217,45 +217,45 @@ def test_create_client( kind="Exporter", metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"), status=V1Alpha1ExporterStatus( - endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] + endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] # type: ignore[call-arg] ), ) -EXPORTER_JSON = """{{ +EXPORTER_JSON = f"""{{ "apiVersion": "jumpstarter.dev/v1alpha1", "kind": "Exporter", "metadata": {{ "creationTimestamp": "2024-01-01T21:00:00Z", - "name": "{name}", + "name": "{EXPORTER_NAME}", "namespace": "default" }}, "status": {{ "credential": {{ - "name": "{name}-credential" + "name": "{EXPORTER_NAME}-credential" }}, "devices": [], - "endpoint": "{endpoint}", + "endpoint": "{EXPORTER_ENDPOINT}", "exporterStatus": null, "statusMessage": null }} }} -""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT) +""" -EXPORTER_YAML = """apiVersion: jumpstarter.dev/v1alpha1 +EXPORTER_YAML = f"""apiVersion: jumpstarter.dev/v1alpha1 kind: Exporter metadata: creationTimestamp: '2024-01-01T21:00:00Z' - name: {name} + name: {EXPORTER_NAME} namespace: default status: credential: - name: {name}-credential + name: {EXPORTER_NAME}-credential devices: [] - endpoint: {endpoint} + endpoint: {EXPORTER_ENDPOINT} exporterStatus: null statusMessage: null -""".format(name=EXPORTER_NAME, endpoint=EXPORTER_ENDPOINT) +""" EXPORTER_CONFIG = ExporterConfigV1Alpha1( alias=EXPORTER_NAME, @@ -399,7 +399,7 @@ def test_create_cluster_kind_minimal(self, mock_validate, mock_create): mock_create.assert_called_once() # Verify the arguments passed to create_cluster_and_install - args, kwargs = mock_create.call_args + args, _kwargs = mock_create.call_args assert args[0] == "kind" # cluster_type assert args[1] is False # force_recreate_cluster assert args[2] == "test-cluster" # cluster_name @@ -423,7 +423,7 @@ def test_create_cluster_minikube_minimal(self, mock_validate, mock_create): mock_create.assert_called_once() # Verify the arguments passed to create_cluster_and_install - args, kwargs = mock_create.call_args + args, _kwargs = mock_create.call_args assert args[0] == "minikube" # cluster_type assert args[1] is False # force_recreate_cluster assert args[2] == "test-cluster" # cluster_name diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py index ca736a4ea..964fec17f 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete.py @@ -1,4 +1,3 @@ -from typing import Optional import click from jumpstarter_cli_common.alias import AliasedGroup @@ -48,9 +47,9 @@ def delete(): @opt_nointeractive @blocking async def delete_client( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, namespace: str, delete: bool, output: NameOutputType, @@ -58,6 +57,7 @@ async def delete_client( ): """Delete a client object in the Kubernetes cluster""" validate_name(name) + assert name is not None try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: await api.delete_client(name) @@ -100,9 +100,9 @@ async def delete_client( @opt_nointeractive @blocking async def delete_exporter( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, namespace: str, delete: bool, output: NameOutputType, @@ -110,6 +110,7 @@ async def delete_exporter( ): """Delete an exporter object in the Kubernetes cluster""" validate_name(name) + assert name is not None try: async with ExportersV1Alpha1Api(namespace, kubeconfig, context) as api: await api.delete_exporter(name) @@ -153,8 +154,8 @@ async def delete_exporter( @blocking async def delete_cluster( name: str, - kind: Optional[str], - minikube: Optional[str], + kind: str | None, + minikube: str | None, force: bool, output: NameOutputType, ): diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py index d3c7caeab..ee33497f4 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/delete_test.py @@ -163,7 +163,7 @@ def test_delete_client( kind="Exporter", metadata=V1ObjectMeta(namespace="default", name=EXPORTER_NAME, creation_timestamp="2024-01-01T21:00:00Z"), status=V1Alpha1ExporterStatus( - endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] + endpoint=EXPORTER_ENDPOINT, credential=V1ObjectReference(name=f"{EXPORTER_NAME}-credential"), devices=[] # type: ignore[call-arg] ), ) EXPORTER_CONFIG = ExporterConfigV1Alpha1( diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py index 3cee8663e..ebd0001ac 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get.py @@ -1,4 +1,3 @@ -from typing import Optional import click from jumpstarter_cli_common.alias import AliasedGroup @@ -40,7 +39,7 @@ def get(): @opt_output_all @blocking async def get_client( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType + name: str | None, kubeconfig: str | None, context: str | None, namespace: str, output: OutputType ): """Get the client objects in a Kubernetes cluster""" try: @@ -66,9 +65,9 @@ async def get_client( @click.option("-d", "--devices", is_flag=True, help="Display the devices hosted by the exporter(s)") @blocking async def get_exporter( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, namespace: str, devices: bool, output: OutputType, @@ -96,7 +95,7 @@ async def get_exporter( @opt_output_all @blocking async def get_lease( - name: Optional[str], kubeconfig: Optional[str], context: Optional[str], namespace: str, output: OutputType + name: str | None, kubeconfig: str | None, context: str | None, namespace: str, output: OutputType ): """Get the lease objects in a Kubernetes cluster""" try: @@ -123,7 +122,7 @@ async def get_lease( @opt_output_all @blocking async def get_cluster( - name: Optional[str], type: str, kubectl: str, minikube: str, output: OutputType + name: str | None, type: str, kubectl: str, minikube: str, output: OutputType ): """Get information about a specific cluster or list all clusters""" try: diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py index d2caade86..a7a495acd 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/get_test.py @@ -303,7 +303,7 @@ def test_get_clients(_load_kube_config_mock, list_clients_mock: AsyncMock): status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), - devices=[], + devices=[], # type: ignore[call-arg] exporter_status="Available", ), ) @@ -400,7 +400,7 @@ def test_get_exporter(_load_kube_config_mock, get_exporter_mock: AsyncMock): status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), - devices=[ + devices=[ # type: ignore[call-arg] V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1"), V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), ], @@ -523,7 +523,7 @@ def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: AsyncMo status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), - devices=[], + devices=[], # type: ignore[call-arg] exporter_status="Available", ), ), @@ -534,7 +534,7 @@ def test_get_exporter_devices(_load_kube_config_mock, get_exporter_mock: AsyncMo status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="another-credential"), - devices=[], + devices=[], # type: ignore[call-arg] exporter_status="Available", ), ), @@ -677,7 +677,7 @@ def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncMock): status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="test-credential"), - devices=[ + devices=[ # type: ignore[call-arg] V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="82a8ac0d-d7ff-4009-8948-18a3c5c607b1") ], exporter_status="Available", @@ -690,7 +690,7 @@ def test_get_exporters(_load_kube_config_mock, list_exporters_mock: AsyncMock): status=V1Alpha1ExporterStatus( endpoint="grpc://example.com:443", credential=V1ObjectReference(name="another-credential"), - devices=[ + devices=[ # type: ignore[call-arg] V1Alpha1ExporterDevice(labels={"hardware": "rpi4"}, uuid="f7cd30ac-64a3-42c6-ba31-b25f033b97c1"), ], exporter_status="Available", diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py index d73769150..a8929f60b 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/import_res.py @@ -1,4 +1,4 @@ -from typing import Optional + import click from jumpstarter_cli_common.blocking import blocking @@ -56,12 +56,12 @@ def import_res(): async def import_client( name: str, namespace: str, - kubeconfig: Optional[str], - context: Optional[str], + kubeconfig: str | None, + context: str | None, insecure_tls: bool, - allow: Optional[str], + allow: str | None, unsafe: bool, - out: Optional[str], + out: str | None, output: PathOutputType, nointeractive: bool, ): @@ -118,9 +118,9 @@ async def import_client( async def import_exporter( name: str, namespace: str, - out: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + out: str | None, + kubeconfig: str | None, + context: str | None, insecure_tls: bool, output: PathOutputType, nointeractive: bool, diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/k8s.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/k8s.py index 6f9524942..7c9e127e8 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/k8s.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/k8s.py @@ -9,8 +9,8 @@ def handle_k8s_api_exception(e: ApiException): """Handle a Kubernetes API exception""" # Try to parse the JSON response try: - json_body = json.loads(e.body) - except (json.decoder.JSONDecodeError, TypeError): + json_body = json.loads(e.body) # type: ignore[arg-type] + except (json.JSONDecodeError, TypeError): raise click.ClickException(f"Server error: {e.body}") from e # Valid JSON is not necessarily a Status: a proxy in front of the API server diff --git a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py index 6417517e7..821494fab 100644 --- a/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py +++ b/python/packages/jumpstarter-cli-admin/jumpstarter_cli_admin/rotate.py @@ -1,4 +1,3 @@ -from typing import Optional import click from jumpstarter_cli_common.alias import AliasedGroup @@ -48,16 +47,17 @@ def rotate(): @opt_output_name_only @blocking async def rotate_client( - name: Optional[str], - kubeconfig: Optional[str], - context: Optional[str], + name: str | None, + kubeconfig: str | None, + context: str | None, namespace: str, save: bool, - out: Optional[str], + out: str | None, output: NameOutputType, ): """Rotate the internal token for a client object""" validate_name(name) + assert name is not None try: async with ClientsV1Alpha1Api(namespace, kubeconfig, context) as api: if output is None: diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/alias.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/alias.py index 9293d98b0..6f886e238 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/alias.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/alias.py @@ -1,10 +1,12 @@ +from typing import ClassVar + import click class AliasedGroup(click.Group): """An aliased command group.""" - common_aliases: dict[str, list[str]] = { + common_aliases: ClassVar[dict[str, list[str]]]= { "remove": ["rm"], "list": ["ls"], "create": ["c"], diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py index b11d09372..f051662fe 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/config.py @@ -94,7 +94,7 @@ def wrapper(*args, **kwds): # noqa: C901 except click.ClickException: raise except Exception as e: - raise click.ClickException("Failed to load config: {}".format(e)) from e + raise click.ClickException(f"Failed to load config: {e}") from e return f(*args, **kwds, config=config) diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py index 56ca5e6b6..061e63d41 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py @@ -108,14 +108,14 @@ def _extract_grpc_code_and_details(exc: BaseException) -> tuple[str | None, str] if callable(code_member): grpc_code = code_member() code = grpc_code.name if hasattr(grpc_code, "name") else str(grpc_code) - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 code = None try: details_member = exc.details # ty: ignore[unresolved-attribute] if callable(details_member): details = str(details_member() or "") - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 details = "" return code, details @@ -212,7 +212,7 @@ async def wrapped(*args, **kwargs): if cli_exc := _map_cli_exception(exc): raise cli_exc from None # If no handled exceptions, re-raise the original group - raise eg + raise except Exception as e: if cli_exc := _map_cli_exception(e): raise cli_exc from None @@ -261,7 +261,7 @@ def _handle_connection_error_with_reauth(exc, login_func): config = exc.get_config() try: login_func(config) - except Exception as reauth_exc: + except Exception as reauth_exc: # noqa: BLE001 raise ClickExceptionRed(f"Re-authentication failed: {reauth_exc}") from None raise _ReauthSucceeded() from None else: diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py index 26059d341..5c1bebdec 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc.py @@ -93,12 +93,11 @@ class Config: async def configuration(self): ssl_context: ssl.SSLContext | bool = False if self.insecure_tls else _get_ssl_context() connector = aiohttp.TCPConnector(ssl=ssl_context) - async with aiohttp.ClientSession(connector=connector) as session: - async with session.get( - URL(self.issuer).joinpath(".well-known", "openid-configuration"), - raise_for_status=True, - ) as response: - return await response.json() + async with aiohttp.ClientSession(connector=connector) as session, session.get( + URL(self.issuer).joinpath(".well-known", "openid-configuration"), + raise_for_status=True, + ) as response: + return await response.json() def _scopes(self) -> list[str]: if self.offline_access: @@ -192,7 +191,7 @@ async def callback(request): await runner.cleanup() raise click.ClickException(f"Failed to start callback server on port {port}: {e}") from None - redirect_uri = "http://localhost:%d/callback" % site._server.sockets[0].getsockname()[1] + redirect_uri = f"http://localhost:{site._server.sockets[0].getsockname()[1]}/callback" client = self.client(redirect_uri=redirect_uri) @@ -201,7 +200,7 @@ async def callback(request): if prompt: auth_params["prompt"] = prompt - uri, state = client.create_authorization_url(config["authorization_endpoint"], **auth_params) + uri, _state = client.create_authorization_url(config["authorization_endpoint"], **auth_params) print("Please open the URL in browser: ", uri) @@ -329,7 +328,7 @@ async def device_authorization_grant(self): # noqa: C901 def decode_jwt(token: str): try: return json.loads(extract_compact(token.encode()).payload) - except (ValueError, KeyError, TypeError, JoseError) as e: + except (ValueError, KeyError, TypeError, JoseError) as e: # pragma: no cover raise ValueError(f"Invalid JWT format: {e}") from e diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py index 3a7382921..918e80874 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/oidc_test.py @@ -235,9 +235,9 @@ def mock_post(url, data=None, **kwargs): with ( patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + pytest.raises(click.ClickException, match="denied by the user"), ): - with pytest.raises(click.ClickException, match="denied by the user"): - await config.device_authorization_grant() + await config.device_authorization_grant() @pytest.mark.asyncio async def test_raises_on_expired_token(self) -> None: @@ -277,9 +277,9 @@ def mock_post(url, data=None, **kwargs): with ( patch.object(config, "configuration", new_callable=AsyncMock, return_value=discovery), patch("jumpstarter_cli_common.oidc.aiohttp.ClientSession", return_value=mock_session), + pytest.raises(click.ClickException, match="expired"), ): - with pytest.raises(click.ClickException, match="expired"): - await config.device_authorization_grant() + await config.device_authorization_grant() # --------------------------------------------------------------------------- diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py index e17e6cd30..b9fccff19 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/opt.py @@ -1,7 +1,7 @@ import logging import sys from functools import partial -from typing import Literal, Optional +from typing import Literal import click from rich import traceback @@ -135,7 +135,7 @@ def _opt_labels_callback(ctx, param, value): for label in value: k, sep, v = label.partition("=") if sep == "": - raise click.BadParameter("Invalid label '{}', should be formatted as 'key=value'".format(k)) + raise click.BadParameter(f"Invalid label '{k}', should be formatted as 'key=value'") labels[k] = v return labels @@ -165,19 +165,18 @@ def _opt_labels_callback(ctx, param, value): def confirm_insecure_tls(insecure_tls: bool, nointeractive: bool): - if nointeractive is False and insecure_tls: - if not click.confirm( - "Insecure TLS mode is enabled. Certificate verification will be" - " disabled for HTTPS connections. Continue?" - ): - click.echo("Aborting.") - raise click.Abort() + if nointeractive is False and insecure_tls and not click.confirm( + "Insecure TLS mode is enabled. Certificate verification will be" + " disabled for HTTPS connections. Continue?" + ): + click.echo("Aborting.") + raise click.Abort() confirm_insecure = confirm_insecure_tls -def validate_name(name: Optional[str]) -> None: +def validate_name(name: str | None) -> None: if not name or not name.strip(): raise click.UsageError("Missing required argument 'NAME'.") @@ -189,7 +188,7 @@ class OutputMode(str): PATH = "path" -OutputType = Optional[OutputMode] +OutputType = OutputMode | None opt_output_all = click.option( "-o", @@ -199,7 +198,7 @@ class OutputMode(str): help='Output mode. Use "-o name" for shorter output (resource/name).', ) -DataOutputType = Optional[Literal["json", "yaml"]] +DataOutputType = Literal["json", "yaml"] | None opt_output_json_yaml = click.option( "-o", @@ -209,7 +208,7 @@ class OutputMode(str): help='Output mode. Use "-o json" or "-o yaml" for machine-readable output.', ) -NameOutputType = Optional[Literal["name"]] +NameOutputType = Literal["name"] | None opt_output_name_only = click.option( "-o", @@ -219,7 +218,7 @@ class OutputMode(str): help='Output mode. Use "-o name" for shorter output (resource/name).', ) -PathOutputType = Optional[Literal["path"]] +PathOutputType = Literal["path"] | None opt_output_path_only = click.option( "-o", diff --git a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py index 572338f6d..23167a742 100644 --- a/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py +++ b/python/packages/jumpstarter-cli-common/jumpstarter_cli_common/print.py @@ -68,7 +68,7 @@ def model_print( # noqa: C901 if len(table.rows) == 0: if namespace: - console.print("No resources found in {} namespace.".format(namespace)) + console.print(f"No resources found in {namespace} namespace.") else: console.print("No resources found.") else: diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/auth.py b/python/packages/jumpstarter-cli/jumpstarter_cli/auth.py index 979632abd..147a9786b 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/auth.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/auth.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone -from typing import Literal, Optional +from datetime import UTC, datetime +from typing import Literal import click from jumpstarter_cli_common.blocking import blocking @@ -31,21 +31,21 @@ class AuthStatusV1Alpha1(BaseModel): kind: Literal["AuthStatus"] = Field(default="AuthStatus") status: Literal["valid", "expiring-soon", "expired", "no-expiry", "no-token", "invalid-token"] - expires_at: Optional[datetime] = Field(alias="expiresAt", default=None) - remaining_seconds: Optional[float] = Field(alias="remainingSeconds", default=None) - subject: Optional[str] = None - issuer: Optional[str] = None - issued_at: Optional[datetime] = Field(alias="issuedAt", default=None) - auth_time: Optional[datetime] = Field(alias="authTime", default=None) + expires_at: datetime | None = Field(alias="expiresAt", default=None) + remaining_seconds: float | None = Field(alias="remainingSeconds", default=None) + subject: str | None = None + issuer: str | None = None + issued_at: datetime | None = Field(alias="issuedAt", default=None) + auth_time: datetime | None = Field(alias="authTime", default=None) refresh_token_stored: bool = Field(alias="refreshTokenStored", default=False) - error: Optional[str] = None + error: str | None = None -def _timestamp_claim(payload: dict, claim: str) -> Optional[datetime]: +def _timestamp_claim(payload: dict, claim: str) -> datetime | None: value = payload.get(claim) if not isinstance(value, int): return None - return datetime.fromtimestamp(value, tz=timezone.utc) + return datetime.fromtimestamp(value, tz=UTC) def _collect_auth_status(config) -> AuthStatusV1Alpha1: @@ -112,7 +112,7 @@ def _print_subject_issuer(payload: dict) -> None: def _print_timestamp(label: str, value: int | None) -> None: if value is None: return - dt = datetime.fromtimestamp(value, tz=timezone.utc) + dt = datetime.fromtimestamp(value, tz=UTC) click.echo(f"{label}: {dt.strftime('%Y-%m-%d %H:%M:%S %Z')}") @@ -156,7 +156,7 @@ def token_status(config, verbose: bool, output: DataOutputType): return exp = payload.get("exp") - exp_dt = datetime.fromtimestamp(exp, tz=timezone.utc) + exp_dt = datetime.fromtimestamp(exp, tz=UTC) click.echo(f"Token expiry: {exp_dt.strftime('%Y-%m-%d %H:%M:%S %Z')}") _print_token_status(remaining) @@ -230,7 +230,7 @@ async def rotate_token(config): duration = format_duration(new_remaining) exp = payload.get("exp") if exp: - exp_dt = datetime.fromtimestamp(exp, tz=timezone.utc) + exp_dt = datetime.fromtimestamp(exp, tz=UTC) click.echo(f"Token rotated. New expiry: {exp_dt.strftime('%Y-%m-%d %H:%M:%S %Z')} ({duration} remaining)") else: click.echo(f"Token rotated. {duration} remaining.") diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/common.py b/python/packages/jumpstarter-cli/jumpstarter_cli/common.py index 993bbb472..c69001f32 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/common.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/common.py @@ -79,12 +79,12 @@ def convert(self, value, param, ctx): param, ctx, ) - raise # satisfy ty: self.fail is NoReturn but ty cannot verify it + raise # pragma: no cover # noqa: PLE0704 # satisfy ty: self.fail is NoReturn but ty cannot verify it if self.minimum is not None and td < self.minimum: min_seconds = int(self.minimum.total_seconds()) self.fail(f"{value!r} must be at least {min_seconds} seconds", param, ctx) - raise # satisfy ty: self.fail is NoReturn but ty cannot verify it + raise # pragma: no cover # noqa: PLE0704 # satisfy ty: self.fail is NoReturn but ty cannot verify it return td diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/common_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/common_test.py index deb504e2d..e102cea81 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/common_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/common_test.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import click import pytest @@ -19,7 +19,7 @@ def test_parse_iso8601_with_timezone(self): assert dt.minute == 0 assert dt.second == 0 assert dt.tzinfo is not None - assert dt.tzinfo == timezone.utc + assert dt.tzinfo == UTC def test_parse_iso8601_naive_gets_normalized(self): """Test that naive datetime gets normalized to local timezone.""" @@ -35,14 +35,14 @@ def test_parse_iso8601_naive_gets_normalized(self): def test_pass_through_datetime_object_with_timezone(self): """Test that datetime object with timezone passes through.""" - input_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + input_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) dt = DATETIME.convert(input_dt, None, None) assert dt == input_dt - assert dt.tzinfo == timezone.utc + assert dt.tzinfo == UTC def test_pass_through_datetime_object_naive_gets_normalized(self): """Test that naive datetime object gets normalized.""" - input_dt = datetime(2024, 1, 1, 12, 0, 0) # Naive + input_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) # Naive dt = DATETIME.convert(input_dt, None, None) assert dt.year == 2024 assert dt.month == 1 diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/config.py b/python/packages/jumpstarter-cli/jumpstarter_cli/config.py index 652f7fd09..420ccd2c1 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/config.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/config.py @@ -9,7 +9,6 @@ def config(): """ Manage local configurations """ - pass config.add_command(config_client) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/config_client.py b/python/packages/jumpstarter-cli/jumpstarter_cli/config_client.py index 98cce1e7a..bc3447c76 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/config_client.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/config_client.py @@ -1,5 +1,4 @@ from os import PathLike -from typing import Optional import click from jumpstarter_cli_common.exceptions import handle_exceptions @@ -85,7 +84,7 @@ def create_client_config( unsafe: bool, insecure_tls: bool, nointeractive: bool, - out: Optional[PathLike], + out: PathLike | None, output: PathOutputType, ): """Create a Jumpstarter client configuration.""" diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py b/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py index fb3dd64ae..9ca61763a 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/delete.py @@ -58,4 +58,4 @@ def delete_leases( case OutputMode.NAME: click.echo(name) case _: - click.echo('lease "{}" deleted'.format(name)) + click.echo(f'lease "{name}" deleted') diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/describe.py b/python/packages/jumpstarter-cli/jumpstarter_cli/describe.py index 755295f24..64b5fdb7a 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/describe.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/describe.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import click from jumpstarter_cli_common.alias import AliasedGroup @@ -54,7 +54,7 @@ def _print_mapping(label: str, mapping: dict[str, str], indent: int = 0) -> None def _condition_time(condition) -> datetime | None: if condition.HasField("lastTransitionTime"): time = condition.lastTransitionTime - return datetime.fromtimestamp(time.seconds + time.nanos / 1e9, tz=timezone.utc) + return datetime.fromtimestamp(time.seconds + time.nanos / 1e9, tz=UTC) return None @@ -261,13 +261,13 @@ def _token_details(token: str | None) -> tuple[datetime | None, str]: return None, "no token" try: payload = decode_jwt(token) - except Exception: + except Exception: # noqa: BLE001 return None, "malformed" exp = payload.get("exp") remaining = get_token_remaining_seconds(token) if exp is None or remaining is None: return None, "no expiry claim" - expiry = datetime.fromtimestamp(exp, tz=timezone.utc) + expiry = datetime.fromtimestamp(exp, tz=UTC) if remaining < 0: return expiry, f"expired ({format_duration(remaining)} ago)" return expiry, f"valid ({format_duration(remaining)} remaining)" diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/describe_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/describe_test.py index 02075cb93..6cdd06d5c 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/describe_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/describe_test.py @@ -2,7 +2,7 @@ import json import time from contextlib import ExitStack -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch @@ -34,7 +34,7 @@ def _make_jwt(exp_offset_seconds=3600, include_exp=True): def _make_condition(type="Ready", status="True", reason="Ready", message="lease is ready"): condition = kubernetes_pb2.Condition(type=type, status=status, reason=reason, message=message) - condition.lastTransitionTime.seconds = int(datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc).timestamp()) + condition.lastTransitionTime.seconds = int(datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC).timestamp()) return condition @@ -47,7 +47,7 @@ def _make_lease(name="lease-1", conditions=None, exporter="exporter-1", **kwargs client="my-client", exporter=exporter, conditions=conditions if conditions is not None else [_make_condition()], - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=timezone.utc), + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC), tags={"build": "1234"}, context={"purpose": "ci"}, **kwargs, diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py index fac0b8b8a..6affbb3ff 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/get_test.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from unittest.mock import Mock import click @@ -188,7 +188,7 @@ def test_with_options_parsing_unknown(self): # This test verifies that the new parse_with function would reject unknown options # The actual CLI behavior now validates input, so unknown options cause failures # This test documents the expected behavior change - pass # Test is no longer relevant since parse_with now validates input + # Test is no longer relevant since parse_with now validates input def test_exporter_list_creation_basic(self): """Test creating ExporterList with basic exporters""" @@ -350,7 +350,7 @@ def test_only_active_excludes_expired_leases(self): active_lease = self.create_test_lease( name="active-lease", status="In-Use", - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC) ) leases_from_server = LeaseList(leases=[active_lease], next_page_token=None) @@ -365,13 +365,13 @@ def test_show_all_includes_expired_leases(self): active_lease = self.create_test_lease( name="active-lease", status="In-Use", - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC) ) expired_lease = self.create_test_lease( name="expired-lease", status="Expired", - effective_begin_time=datetime(2023, 1, 1, 8, 0, 0), - effective_end_time=datetime(2023, 1, 1, 9, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 8, 0, 0, tzinfo=UTC), + effective_end_time=datetime(2023, 1, 1, 9, 0, 0, tzinfo=UTC) ) leases_from_server = LeaseList(leases=[active_lease, expired_lease], next_page_token=None) @@ -386,17 +386,17 @@ def test_multiple_active_leases_returned(self): lease1 = self.create_test_lease( name="lease-1", status="In-Use", - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC) ) lease2 = self.create_test_lease( name="lease-2", status="Waiting", - effective_begin_time=datetime(2023, 1, 1, 11, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 11, 0, 0, tzinfo=UTC) ) lease3 = self.create_test_lease( name="lease-3", status="In-Use", - effective_begin_time=datetime(2023, 1, 1, 12, 0, 0) + effective_begin_time=datetime(2023, 1, 1, 12, 0, 0, tzinfo=UTC) ) leases_from_server = LeaseList(leases=[lease1, lease2, lease3], next_page_token=None) @@ -410,12 +410,12 @@ def test_all_expired_when_show_all(self): expired1 = self.create_test_lease( name="expired-1", status="Expired", - effective_end_time=datetime(2023, 1, 1, 8, 0, 0) + effective_end_time=datetime(2023, 1, 1, 8, 0, 0, tzinfo=UTC) ) expired2 = self.create_test_lease( name="expired-2", status="Expired", - effective_end_time=datetime(2023, 1, 1, 9, 0, 0) + effective_end_time=datetime(2023, 1, 1, 9, 0, 0, tzinfo=UTC) ) leases_from_server = LeaseList(leases=[expired1, expired2], next_page_token=None) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/j.py b/python/packages/jumpstarter-cli/jumpstarter_cli/j.py index c077fce51..03f645568 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/j.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/j.py @@ -34,7 +34,7 @@ async def cli(): # Handle exceptions wrapped in ExceptionGroup (e.g., from task groups) if exc := find_exception_in_group(eg, EnvironmentVariableNotSetError): raise ClickExceptionRed(f"Error: the j command must be used inside a jmp shell: {exc}") from eg - raise eg + raise try: async with create_task_group() as tg: tg.start_soon(signal_handler, tg.cancel_scope) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/login.py b/python/packages/jumpstarter-cli/jumpstarter_cli/login.py index 8ba54c596..8b0f0cce5 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/login.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/login.py @@ -63,16 +63,15 @@ async def fetch_auth_config( _validate_login_endpoint_url(login_endpoint, allow_http=insecure_tls) url = f"{login_endpoint.rstrip('/')}/v1/auth/config" - ssl_context: ssl.SSLContext | bool = False if insecure_tls else True + ssl_context: ssl.SSLContext | bool = not insecure_tls timeout = aiohttp.ClientTimeout(total=_HTTP_TIMEOUT_SECONDS) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url, ssl=ssl_context) as response: - if response.status != 200: - raise click.ClickException(f"Failed to fetch auth config from {url}: HTTP {response.status}") - payload = await response.json() - return _validate_auth_config_payload(payload, url) + async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url, ssl=ssl_context) as response: + if response.status != 200: + raise click.ClickException(f"Failed to fetch auth config from {url}: HTTP {response.status}") + payload = await response.json() + return _validate_auth_config_payload(payload, url) except aiohttp.ClientConnectorCertificateError as e: raise click.ClickException( f"TLS certificate verification failed while connecting to {login_endpoint}. " @@ -335,7 +334,6 @@ def save_config() -> None: except Exception as e: if nointeractive: raise click.ClickException(f"Failed to refresh access token: {e}") from e - pass if token is not None: kwargs = {"connector_id": connector_id} if connector_id is not None else {} @@ -383,7 +381,9 @@ async def relogin_client(config: ClientConfigV1Alpha1): insecure_tls=config.tls.insecure, ) if config.refresh_token: - try: + import contextlib + + with contextlib.suppress(Exception): tokens = await oidc.refresh_token_grant(config.refresh_token) config.token = tokens["access_token"] refresh_token = tokens.get("refresh_token") @@ -391,8 +391,6 @@ async def relogin_client(config: ClientConfigV1Alpha1): config.refresh_token = refresh_token ClientConfigV1Alpha1.save(config) # ty: ignore[invalid-argument-type] return - except Exception: - pass if should_use_device_flow(device_flow_flag=False): tokens = await oidc.device_authorization_grant() diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/run.py b/python/packages/jumpstarter-cli/jumpstarter_cli/run.py index 2e3f705cb..dea6cc995 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/run.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/run.py @@ -73,7 +73,7 @@ def _reap_zombie_processes(capture_child=None): logger.debug(f"PARENT: Reaped zombie process {pid} with status {status}") except ChildProcessError: break # No more children - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 logger.warning(f"PARENT: Error during zombie reaping: {e}") @@ -143,7 +143,7 @@ async def signal_handler(): tls_credentials=tls_credentials, interceptors=interceptors, ) - except* Exception as excgroup: + except* Exception as excgroup: # noqa: BLE001 _handle_exporter_exceptions(excgroup) exporter_exit_code = exporter.exit_code else: @@ -152,7 +152,7 @@ async def signal_handler(): async with config.create_exporter() as exporter: try: await exporter.serve() - except* Exception as excgroup: + except* Exception as excgroup: # noqa: BLE001 _handle_exporter_exceptions(excgroup) # Check if exporter set an exit code (e.g., from hook failure with on_failure='exit') diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/self.py b/python/packages/jumpstarter-cli/jumpstarter_cli/self.py index 44ba95e92..9d654bd10 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/self.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/self.py @@ -8,7 +8,6 @@ def self(): """ Manage the jumpstarter executables """ - pass self.add_command(self_update) diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/shell.py b/python/packages/jumpstarter-cli/jumpstarter_cli/shell.py index 121386284..bee2d8d96 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/shell.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/shell.py @@ -126,11 +126,11 @@ async def _try_refresh_token(config, lease) -> bool: # Persist to disk (best-effort, uses original config path) try: ClientConfigV1Alpha1.save(config, path=config.path) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.warning("Failed to save refreshed token to disk: %s", e) return True - except Exception as e: + except Exception as e: # noqa: BLE001 # Restore old token so the monitor doesn't think we succeeded config.token = old_token config.refresh_token = old_refresh_token @@ -169,7 +169,7 @@ async def _try_reload_token_from_disk(config, lease) -> bool: await _update_lease_channel(config, lease) return True - except Exception as e: + except Exception as e: # noqa: BLE001 config.token = old_token config.refresh_token = old_refresh_token logger.debug("Failed to reload token from disk: %s", e) @@ -274,7 +274,7 @@ async def _monitor_token_expiry(config, lease, cancel_scope, token_state=None) - await anyio.sleep(5) else: await anyio.sleep(30) - except Exception: + except Exception: # noqa: BLE001 return @@ -384,184 +384,187 @@ async def _run_shell_session(lease, exporter_logs, config, command, cancel_scope Uses non-blocking polling via StatusMonitor for robust status tracking. If Ctrl+C is pressed during EndSession, the wait is skipped but the lease is still released. """ - async with lease.serve_unix_async() as path: - async with lease.monitor_async(): - # Use ExitStack for the client (required by client_from_path) - with ExitStack() as stack: - async with client_from_path( - path, - lease.portal, - stack, - allow=lease.allow, - unsafe=lease.unsafe, - tls_config=getattr(lease, "tls_config", None), - grpc_options=getattr(lease, "grpc_options", None), - insecure=getattr(lease, "insecure", False), - passphrase=getattr(lease, "passphrase", None), - ) as client: - try: - await client.get_status_async() - except grpc.aio.AioRpcError as e: - if e.code() in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED): - raise ExporterUnreachableError( - f"Exporter {lease.exporter_name} did not respond to initial status check" - ) from e - raise + async with lease.serve_unix_async() as path, lease.monitor_async(): + # Use ExitStack for the client (required by client_from_path) + with ExitStack() as stack: + async with client_from_path( + path, + lease.portal, + stack, + allow=lease.allow, + unsafe=lease.unsafe, + tls_config=getattr(lease, "tls_config", None), + grpc_options=getattr(lease, "grpc_options", None), + insecure=getattr(lease, "insecure", False), + passphrase=getattr(lease, "passphrase", None), + ) as client: + try: + await client.get_status_async() + except grpc.aio.AioRpcError as e: + if e.code() in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED): + raise ExporterUnreachableError( + f"Exporter {lease.exporter_name} did not respond to initial status check" + ) from e + raise # pragma: no cover + + # Start log streaming and status monitor together + # The status monitor polls in the background for reliable status tracking + async with ( + client.log_stream_async(show_all_logs=exporter_logs), + client.status_monitor_async(poll_interval=0.3) as monitor, + ): + # Wait for beforeLease hook to complete while logs are streaming + # This allows hook output to be displayed in real-time + # Uses non-blocking polling instead of streaming for robustness + logger.info("Waiting for beforeLease hook to complete...") + + # Wait for LEASE_READY or hook failure using background monitor + result = await monitor.wait_for_any_of( + [ExporterStatus.LEASE_READY, ExporterStatus.BEFORE_LEASE_HOOK_FAILED], timeout=300.0 + ) - # Start log streaming and status monitor together - # The status monitor polls in the background for reliable status tracking - async with client.log_stream_async(show_all_logs=exporter_logs): - async with client.status_monitor_async(poll_interval=0.3) as monitor: - # Wait for beforeLease hook to complete while logs are streaming - # This allows hook output to be displayed in real-time - # Uses non-blocking polling instead of streaming for robustness - logger.info("Waiting for beforeLease hook to complete...") - - # Wait for LEASE_READY or hook failure using background monitor - result = await monitor.wait_for_any_of( - [ExporterStatus.LEASE_READY, ExporterStatus.BEFORE_LEASE_HOOK_FAILED], timeout=300.0 - ) - - if result == ExporterStatus.BEFORE_LEASE_HOOK_FAILED: - reason = monitor.status_message or "beforeLease hook failed" - raise ExporterOfflineError(reason) - elif result is None: - if monitor.connection_lost: - # Connection lost while waiting for hook - lease expired - logger.info("Lease expired while waiting for beforeLease hook to complete") - return 0 - else: - reason = monitor.status_message or "Timeout waiting for beforeLease hook" - raise ExporterOfflineError(reason) - - logger.debug("Exporter ready (status: %s), launching shell...", result) - - if monitor.status_message and monitor.status_message.startswith(HOOK_WARNING_PREFIX): - warning_text = monitor.status_message[len(HOOK_WARNING_PREFIX) :] - click.echo(click.style(f"Warning: {warning_text}", fg="yellow", bold=True)) - - # Fetch motd now (after the beforeLease hook has run); - # skipped in command mode where it is never shown - motd = None - if not command: - try: - with anyio.fail_after(5): - motd = await fetch_motd(client) - except Exception: - logger.debug("Failed to fetch motd, continuing without it") - - # Run the shell command. The exit code is reported from - # inside the thread: run_sync is not cancellable, so if the - # enclosing task group is already unwinding, the await below - # raises instead of returning and the value would be lost. - def _run_and_record(): - code = _run_shell_only(lease, config, command, path, motd) - on_shell_exit(code) - return code - - exit_code = await anyio.to_thread.run_sync(_run_and_record) - - # Shell has exited. For auto-created leases (release=True), call - # EndSession to trigger afterLease hook while keeping log stream - # and status monitor open. For pre-created leases (release=False), - # skip EndSession so the exporter stays in LEASE_READY and the - # user can reconnect later. - if ( - lease.release - and lease.name - and not lease.lease_ended - and not cancel_scope.cancel_called - and not monitor._get_status_unsupported - ): - # Quick probe to catch exporter restarts the slow-poll loop - # (5s interval in LEASE_READY) may not have detected yet. - if not monitor.connection_lost: - try: - probe_status = await _cancel_if_connection_lost( - monitor, client.get_status_async() - ) - if probe_status is None: - logger.debug( - "Connection probe timed out, marking connection as lost" - ) - monitor._connection_lost = True - elif lease.lease_ended: - logger.debug( - "Lease ended during probe (status=%s), skipping afterLease hook", - probe_status, + if result == ExporterStatus.BEFORE_LEASE_HOOK_FAILED: # pragma: no cover + reason = monitor.status_message or "beforeLease hook failed" + raise ExporterOfflineError(reason) + elif result is None: + if monitor.connection_lost: # pragma: no cover + # Connection lost while waiting for hook - lease expired + logger.info("Lease expired while waiting for beforeLease hook to complete") + return 0 + else: # pragma: no cover + reason = monitor.status_message or "Timeout waiting for beforeLease hook" + raise ExporterOfflineError(reason) + + logger.debug("Exporter ready (status: %s), launching shell...", result) + + if monitor.status_message and monitor.status_message.startswith( # pragma: no cover + HOOK_WARNING_PREFIX + ): + warning_text = monitor.status_message[len(HOOK_WARNING_PREFIX) :] + click.echo(click.style(f"Warning: {warning_text}", fg="yellow", bold=True)) + + # Fetch motd now (after the beforeLease hook has run); + # skipped in command mode where it is never shown + motd = None + if not command: + try: + with anyio.fail_after(5): + motd = await fetch_motd(client) + except Exception: # pragma: no cover # noqa: BLE001 + logger.debug("Failed to fetch motd, continuing without it") + + # Run the shell command. The exit code is reported from + # inside the thread: run_sync is not cancellable, so if the + # enclosing task group is already unwinding, the await below + # raises instead of returning and the value would be lost. + def _run_and_record(): + code = _run_shell_only(lease, config, command, path, motd) + on_shell_exit(code) + return code + + exit_code = await anyio.to_thread.run_sync(_run_and_record) + + # Shell has exited. For auto-created leases (release=True), call + # EndSession to trigger afterLease hook while keeping log stream + # and status monitor open. For pre-created leases (release=False), + # skip EndSession so the exporter stays in LEASE_READY and the + # user can reconnect later. + if ( + lease.release + and lease.name + and not lease.lease_ended + and not cancel_scope.cancel_called + and not monitor._get_status_unsupported + ): + # Quick probe to catch exporter restarts the slow-poll loop + # (5s interval in LEASE_READY) may not have detected yet. + if not monitor.connection_lost: + try: + probe_status = await _cancel_if_connection_lost( + monitor, client.get_status_async() + ) + if probe_status is None: + logger.debug( + "Connection probe timed out, marking connection as lost" + ) + monitor._connection_lost = True + elif lease.lease_ended: + logger.debug( + "Lease ended during probe (status=%s), skipping afterLease hook", + probe_status, + ) + return exit_code + elif probe_status not in ( # pragma: no cover + ExporterStatus.LEASE_READY, + ExporterStatus.AFTER_LEASE_HOOK, + ): + logger.debug( + "Exporter in unexpected state (%s), skipping afterLease hook", + probe_status, + ) + monitor._connection_lost = True + except Exception: # pragma: no cover # noqa: BLE001 + if lease.lease_ended: + logger.debug("Lease ended during probe, skipping afterLease hook") + return exit_code + logger.debug("Connection probe failed, marking connection as lost") + monitor._connection_lost = True + + if monitor.connection_lost: + logger.debug("Connection already lost, skipping afterLease hook") + else: + logger.info("Running afterLease hook (Ctrl+C to skip)...") + try: + # EndSession triggers the afterLease hook asynchronously + # Wrap in anyio timeout as safety net in case gRPC deadline + # doesn't fire on a broken channel (e.g. lease timeout) + success = False + with anyio.move_on_after(10): + success = await client.end_session_async() + if success: + # Wait for hook to complete using background monitor + # This allows afterLease logs to be displayed in real-time + result = await monitor.wait_for_any_of( + [ExporterStatus.AVAILABLE, ExporterStatus.AFTER_LEASE_HOOK_FAILED], + timeout=300.0, + ) + if result == ExporterStatus.AVAILABLE: + if monitor.status_message and monitor.status_message.startswith( + HOOK_WARNING_PREFIX # pragma: no cover + ): + warning_text = monitor.status_message[len(HOOK_WARNING_PREFIX) :] + click.echo( + click.style(f"Warning: {warning_text}", fg="yellow", bold=True) ) - return exit_code - elif probe_status not in ( - ExporterStatus.LEASE_READY, + logger.info("afterLease hook completed") + elif result == ExporterStatus.AFTER_LEASE_HOOK_FAILED: # pragma: no cover + reason = monitor.status_message or "afterLease hook failed" + raise ExporterOfflineError(reason) + elif monitor.connection_lost: # pragma: no cover + # If connection lost during afterLease hook lifecycle + # (running or failed), the exporter shut down + if monitor.current_status in ( ExporterStatus.AFTER_LEASE_HOOK, + ExporterStatus.AFTER_LEASE_HOOK_FAILED, ): - logger.debug( - "Exporter in unexpected state (%s), skipping afterLease hook", - probe_status, - ) - monitor._connection_lost = True - except Exception: - if lease.lease_ended: - logger.debug("Lease ended during probe, skipping afterLease hook") - return exit_code - logger.debug("Connection probe failed, marking connection as lost") - monitor._connection_lost = True - - if monitor.connection_lost: - logger.debug("Connection already lost, skipping afterLease hook") - else: - logger.info("Running afterLease hook (Ctrl+C to skip)...") - try: - # EndSession triggers the afterLease hook asynchronously - # Wrap in anyio timeout as safety net in case gRPC deadline - # doesn't fire on a broken channel (e.g. lease timeout) - success = False - with anyio.move_on_after(10): - success = await client.end_session_async() - if success: - # Wait for hook to complete using background monitor - # This allows afterLease logs to be displayed in real-time - result = await monitor.wait_for_any_of( - [ExporterStatus.AVAILABLE, ExporterStatus.AFTER_LEASE_HOOK_FAILED], - timeout=300.0, + reason = ( + monitor.status_message + or "afterLease hook failed (connection lost)" ) - if result == ExporterStatus.AVAILABLE: - if monitor.status_message and monitor.status_message.startswith( - HOOK_WARNING_PREFIX - ): - warning_text = monitor.status_message[len(HOOK_WARNING_PREFIX) :] - click.echo( - click.style(f"Warning: {warning_text}", fg="yellow", bold=True) - ) - logger.info("afterLease hook completed") - elif result == ExporterStatus.AFTER_LEASE_HOOK_FAILED: - reason = monitor.status_message or "afterLease hook failed" - raise ExporterOfflineError(reason) - elif monitor.connection_lost: - # If connection lost during afterLease hook lifecycle - # (running or failed), the exporter shut down - if monitor.current_status in ( - ExporterStatus.AFTER_LEASE_HOOK, - ExporterStatus.AFTER_LEASE_HOOK_FAILED, - ): - reason = ( - monitor.status_message - or "afterLease hook failed (connection lost)" - ) - raise ExporterOfflineError(reason) - # Connection lost but hook wasn't running. This is expected when - # the lease times out - exporter handles its own cleanup. - logger.info("Connection lost, skipping afterLease hook wait") - elif result is None: - logger.warning("Timeout waiting for afterLease hook to complete") - else: - logger.debug("EndSession not implemented, skipping hook wait") - except ExporterOfflineError: - raise - except Exception as e: - logger.warning("Error during afterLease hook: %s", e) - - return exit_code + raise ExporterOfflineError(reason) + # Connection lost but hook wasn't running. This is expected when + # the lease times out - exporter handles its own cleanup. + logger.info("Connection lost, skipping afterLease hook wait") + elif result is None: # pragma: no cover + logger.warning("Timeout waiting for afterLease hook to complete") + else: # pragma: no cover + logger.debug("EndSession not implemented, skipping hook wait") + except ExporterOfflineError: # pragma: no cover + raise + except Exception as e: # pragma: no cover # noqa: BLE001 + logger.warning("Error during afterLease hook: %s", e) + + return exit_code async def _shell_with_signal_handling( # noqa: C901 @@ -735,7 +738,7 @@ async def _resolve_lease_from_active_async(config) -> str: click.echo(f" {info}") click.echo() chosen = click.prompt( - "Select a lease [1-{}]".format(len(leases)), + f"Select a lease [1-{len(leases)}]", type=click.IntRange(1, len(leases)), ) return leases[chosen - 1].name diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py b/python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py index 0a3f945f7..0b701f915 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/shell_test.py @@ -5,10 +5,11 @@ import math import time from contextlib import asynccontextmanager, contextmanager -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, Mock, patch import anyio +import anyio.lowlevel import click import grpc import grpc.aio @@ -49,7 +50,7 @@ def _make_lease(name: str, client: str = "test-client") -> Lease: exporter_name=None, duration=timedelta(minutes=30), effective_duration=None, - begin_time=datetime.now(), + begin_time=datetime.now(tz=UTC), client=client, exporter="test-exporter", conditions=[], @@ -129,7 +130,7 @@ async def fake_monitor(_config, _lease, _cancel_scope, token_state=None): token_state["expired_unrecovered"] = True async def fake_run_shell(*_args): - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() return 0 with ( @@ -1007,9 +1008,9 @@ async def fake_client_from_path(*_a, **_kw): with ( patch("jumpstarter_cli.shell.client_from_path", side_effect=fake_client_from_path), patch("jumpstarter_cli.shell._run_shell_only", return_value=0) as run_shell, + pytest.raises(ExporterUnreachableError), ): - with pytest.raises(ExporterUnreachableError): - await _run_shell_with_lease_async(lease, False, None, (), cancel_scope) + await _run_shell_with_lease_async(lease, False, None, (), cancel_scope) run_shell.assert_not_called() @@ -1194,11 +1195,11 @@ async def fake_run(*_): with ( patch("jumpstarter_cli.shell._monitor_token_expiry", new_callable=AsyncMock), patch("jumpstarter_cli.shell._run_shell_with_lease_async", side_effect=fake_run), + pytest.raises(BaseExceptionGroup) as exc_info, ): - with pytest.raises(BaseExceptionGroup) as exc_info: - await _shell_with_signal_handling( - config, None, None, None, timedelta(minutes=1), False, (), None - ) + await _shell_with_signal_handling( + config, None, None, None, timedelta(minutes=1), False, (), None + ) assert isinstance(exc_info.value, BaseExceptionGroup) offline_exc = find_exception_in_group(exc_info.value, ExporterOfflineError) @@ -1238,11 +1239,11 @@ async def fake_run(*_): with ( patch("jumpstarter_cli.shell._monitor_token_expiry", new_callable=AsyncMock), patch("jumpstarter_cli.shell._run_shell_with_lease_async", side_effect=fake_run), + pytest.raises((ExporterUnreachableError, BaseExceptionGroup)) as exc_info, ): - with pytest.raises((ExporterUnreachableError, BaseExceptionGroup)) as exc_info: - await _shell_with_signal_handling( - config, None, None, None, timedelta(minutes=1), False, (), None - ) + await _shell_with_signal_handling( + config, None, None, None, timedelta(minutes=1), False, (), None + ) exc = exc_info.value if isinstance(exc, BaseExceptionGroup): diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 19d5a917c..4541a3f69 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -3,9 +3,9 @@ import subprocess import sys import tempfile +from collections.abc import Generator from contextlib import contextmanager from threading import Event -from typing import Generator import click from jumpstarter_driver_network.adapters import TcpPortforwardAdapter @@ -150,7 +150,7 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): click.echo("\n" + "=" * 60) click.echo("ADB built-in help (from local adb binary):") click.echo("=" * 60 + "\n") - subprocess.run([adb, "help"], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr) + subprocess.run([adb, "help"], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, check=False) return 0 _validate_adb_args(args) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index f09f39503..780372fe5 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -56,8 +56,7 @@ def __post_init__(self): result = subprocess.run( [self.adb_path, "version"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, ) self.logger.debug(result.stdout.strip()) @@ -84,8 +83,7 @@ def start_server(self) -> int: result = subprocess.run( [self.adb_path, "start-server"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=self.adb_env(), ) @@ -105,8 +103,7 @@ def kill_server(self) -> int: result = subprocess.run( [self.adb_path, "kill-server"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=self.adb_env(), ) @@ -122,8 +119,7 @@ def _connect_device(self, device: str) -> str: result = subprocess.run( [self.adb_path, "connect", device], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=self.adb_env(), timeout=self.connect_timeout, @@ -160,8 +156,7 @@ def disconnect_device(self, device: str) -> str: result = subprocess.run( [self.adb_path, "disconnect", device], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=self.adb_env(), timeout=self.connect_timeout, @@ -184,8 +179,7 @@ def list_devices(self) -> str: result = subprocess.run( [self.adb_path, "devices", "-l"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, env=self.adb_env(), ) diff --git a/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/client.py b/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/client.py index fdb6b6d71..a84fe60a1 100644 --- a/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/client.py +++ b/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/client.py @@ -1,3 +1,4 @@ +import contextlib import time from contextlib import contextmanager @@ -42,14 +43,12 @@ def _wait_for_boot(self, adb, timeout: int = 180) -> None: """Poll until the emulator reports boot complete.""" deadline = time.time() + timeout while time.time() < deadline: - try: + with contextlib.suppress(Exception): devices = adb.device_list() if devices: result = devices[0].shell("getprop sys.boot_completed").strip() if result == "1": return - except Exception: - pass time.sleep(2) raise TimeoutError(f"Emulator did not boot within {timeout} seconds") diff --git a/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/driver.py b/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/driver.py index 5a6c983ae..507f54e62 100644 --- a/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/driver.py +++ b/python/packages/jumpstarter-driver-androidemulator/jumpstarter_driver_androidemulator/driver.py @@ -103,7 +103,7 @@ def _process_logs(self, pipe: IO[bytes], is_stderr: bool = False) -> None: self.logger.info(message.strip()) else: self.logger.info(text) - except (ValueError, IOError): + except (OSError, ValueError): pass finally: pipe.close() @@ -147,7 +147,7 @@ def on(self) -> None: self._stderr_thread.start() @export - def off(self) -> None: # noqa: C901 + def off(self) -> None: """Stop the Android emulator.""" if self._process is None or self._process.returncode is not None: self.logger.warning("Emulator not running, ignoring") @@ -161,6 +161,7 @@ def off(self) -> None: # noqa: C901 env={**os.environ, "ANDROID_ADB_SERVER_PORT": str(self.parent.adb_server_port)}, timeout=5, capture_output=True, + check=False, ) self._process.wait(timeout=15) self.logger.info("Emulator shut down gracefully") diff --git a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/client.py b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/client.py index 67f891438..14826ddfd 100644 --- a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/client.py +++ b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/client.py @@ -43,11 +43,10 @@ def pexpect(self): with PexpectAdapter(client=self) as adapter: yield adapter - def cli(self): # noqa: C901 + def cli(self): @driver_click_group(self) def base(): """ble client""" - pass @base.command() def info(): diff --git a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver.py b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver.py index b3ce22f20..dfa627e5d 100644 --- a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver.py +++ b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver.py @@ -21,7 +21,7 @@ def _ble_notify_handler(_sender: BleakGATTCharacteristic, data: bytearray, print("Warning: Data queue is full, dropping message") -class AsyncBleConfig(): +class AsyncBleConfig: def __init__( self, address: str, diff --git a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver_test.py b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver_test.py index 037bc38dd..7d01d9b1d 100644 --- a/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver_test.py +++ b/python/packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver_test.py @@ -57,13 +57,12 @@ def test_ble_driver_info(): """Test the info export returns correct device information via e2e server/client.""" mock_client = _make_mock_bleak_client() - with patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client): - with serve(_make_driver()) as client: - info = client.call("info") - assert TEST_ADDRESS in info - assert TEST_SERVICE_UUID in info - assert TEST_WRITE_CHAR_UUID in info - assert TEST_NOTIFY_CHAR_UUID in info + with patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client), serve(_make_driver()) as client: + info = client.call("info") + assert TEST_ADDRESS in info + assert TEST_SERVICE_UUID in info + assert TEST_WRITE_CHAR_UUID in info + assert TEST_NOTIFY_CHAR_UUID in info def test_ble_driver_connect_stream(): @@ -72,21 +71,23 @@ def test_ble_driver_connect_stream(): mock_client.write_gatt_char = AsyncMock() - with patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client): - with serve(_make_driver()) as client: - with client.stream() as stream: - # Send data through the stream - stream.send(b"hello") + with ( + patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client), + serve(_make_driver()) as client, + client.stream() as stream, + ): + # Send data through the stream + stream.send(b"hello") - # stream.send() only guarantees data was written to the - # gRPC transport, not that the server has called - # write_gatt_char yet — poll until it has. - eventually(mock_client.write_gatt_char.assert_called) + # stream.send() only guarantees data was written to the + # gRPC transport, not that the server has called + # write_gatt_char yet — poll until it has. + eventually(mock_client.write_gatt_char.assert_called) - # Verify start_notify was called for the notify characteristic - mock_client.start_notify.assert_called_once() - call_args = mock_client.start_notify.call_args - assert call_args[0][0] == TEST_NOTIFY_CHAR_UUID + # Verify start_notify was called for the notify characteristic + mock_client.start_notify.assert_called_once() + call_args = mock_client.start_notify.call_args + assert call_args[0][0] == TEST_NOTIFY_CHAR_UUID def test_ble_notify_handler(): @@ -103,7 +104,7 @@ def test_ble_notify_handler(): def test_ble_notify_handler_queue_full(capsys): """Test the notification handler handles a full buffer gracefully.""" - send_stream, receive_stream = anyio.create_memory_object_stream[bytearray](max_buffer_size=1) # ty: ignore[call-non-callable] + send_stream, _receive_stream = anyio.create_memory_object_stream[bytearray](max_buffer_size=1) # ty: ignore[call-non-callable] sender = MagicMock() # Fill the buffer @@ -189,16 +190,15 @@ def test_ble_driver_connect_not_connected(): """Test that connect raises when client fails to connect.""" mock_client = _make_mock_bleak_client(is_connected=False) - with patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client): - with serve(_make_driver()) as client: - raised = False - try: - with client.stream() as stream: - stream.send(b"hello") - stream.receive() - except BaseException: - raised = True - assert raised, "Expected an exception when BLE device is not connected" + with patch("jumpstarter_driver_ble.driver.BleakClient", return_value=mock_client), serve(_make_driver()) as client: + raised = False + try: + with client.stream() as stream: + stream.send(b"hello") + stream.receive() + except BaseException: # noqa: BLE001 + raised = True + assert raised, "Expected an exception when BLE device is not connected" def test_ble_driver_client_class_reference(): diff --git a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/client.py b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/client.py index 54ba33f9b..94fdb79b4 100644 --- a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/client.py +++ b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/client.py @@ -16,14 +16,14 @@ def _parse(raw: str) -> dict[str, Any] | list[Any] | str: def _parse_dict(raw: str) -> dict[str, Any]: result = _parse(raw) if not isinstance(result, dict): - raise ValueError(f"expected dict, got {type(result).__name__}: {raw!r}") + raise TypeError(f"expected dict, got {type(result).__name__}: {raw!r}") return result def _parse_list(raw: str) -> list[Any]: result = _parse(raw) if not isinstance(result, list): - raise ValueError(f"expected list, got {type(result).__name__}: {raw!r}") + raise TypeError(f"expected list, got {type(result).__name__}: {raw!r}") return result diff --git a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver.py b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver.py index 3e839ab68..d9e50ab77 100644 --- a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver.py +++ b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver.py @@ -1,3 +1,4 @@ +import contextlib import json import time from collections import deque @@ -227,10 +228,8 @@ async def start_peer(self, config_json: str = "{}") -> str: await self._device.set_connectable(True) except Exception: if self._device is not None: - try: + with contextlib.suppress(Exception): await self._device.power_off() - except Exception: - pass self._device = None self._avdtp_listener = None if self._transport is not None: @@ -261,13 +260,13 @@ async def stop_peer(self) -> str: try: if device is not None: await device.power_off() - except Exception as exc: + except Exception as exc: # noqa: BLE001 first_error = exc finally: try: if transport is not None: await transport.close() - except Exception as exc: + except Exception as exc: # pragma: no cover # noqa: BLE001 if first_error is None: first_error = exc diff --git a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver_test.py b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver_test.py index 1a08a74bd..b44d0857b 100644 --- a/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver_test.py +++ b/python/packages/jumpstarter-driver-bt-peer/jumpstarter_driver_bt_peer/driver_test.py @@ -577,10 +577,9 @@ async def test_start_peer_exception_cleanup(): patch( "jumpstarter_driver_bt_peer.driver.Device", side_effect=RuntimeError("device init failed"), - ), + ),pytest.raises(RuntimeError, match="device init failed") ): - with pytest.raises(RuntimeError, match="device init failed"): - await peer.start_peer() + await peer.start_peer() assert peer._device is None assert peer._transport is None diff --git a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/client.py b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/client.py index c90d6fc53..4e46ecfa3 100644 --- a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/client.py +++ b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/client.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import cached_property -from typing import Callable, List, Optional, Sequence, Tuple from uuid import UUID import can @@ -36,7 +36,7 @@ def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - self._periodic_tasks: List[_SelfRemovingCyclicTask] = [] + self._periodic_tasks: list[_SelfRemovingCyclicTask] = [] self._filters = None self._is_shutdown: bool = False @@ -73,14 +73,14 @@ def protocol(self) -> can.CanProtocol: return self.call("protocol") @validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True)) - def _recv_internal(self, timeout: Optional[float]) -> Tuple[Optional[can.Message], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: msg, filtered = self.call("_recv_internal", timeout) if msg: return can.Message(**CanMessage.model_validate(msg).__dict__), filtered return None, filtered @validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True)) - def send(self, msg: can.Message, timeout: Optional[float] = None) -> None: + def send(self, msg: can.Message, timeout: float | None = None) -> None: """ Send an individual CAN message. """ @@ -91,9 +91,9 @@ def _send_periodic_internal( self, msgs: Sequence[can.Message], period: float, - duration: Optional[float] = None, + duration: float | None = None, autostart: bool = True, - modifier_callback: Optional[Callable[[can.Message], None]] = None, + modifier_callback: Callable[[can.Message], None] | None = None, ) -> can.broadcastmanager.CyclicSendTaskABC: if modifier_callback: return super()._send_periodic_internal(msgs, period, duration, autostart, modifier_callback) @@ -106,7 +106,7 @@ def _send_periodic_internal( # python-can bug # https://docs.pydantic.dev/2.8/errors/usage_errors/#typed-dict-version # @validate_call(validate_return=True) - def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def _apply_filters(self, filters: can.typechecking.CanFilters | None) -> None: self.call("_apply_filters", filters) @validate_call(validate_return=True) diff --git a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/common.py b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/common.py index dd4f3bb21..c1714d399 100644 --- a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/common.py +++ b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/common.py @@ -1,4 +1,3 @@ -from typing import Optional import isotp from isotp.address import AddressingMode @@ -15,9 +14,9 @@ class CanMessage(BaseModel): is_extended_id: bool is_remote_frame: bool is_error_frame: bool - channel: Optional[int | str] - dlc: Optional[int] - data: Optional[Base64Bytes] + channel: int | str | None + dlc: int | None + data: Base64Bytes | None is_fd: bool is_rx: bool bitrate_switch: bool @@ -88,7 +87,7 @@ class IsoTpMessage(BaseModel): An ISO-TP CAN message. """ - data: Optional[Base64Bytes] + data: Base64Bytes | None class IsoTpAddress(BaseModel): diff --git a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/driver.py b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/driver.py index f05132d3b..983233020 100644 --- a/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/driver.py +++ b/python/packages/jumpstarter-driver-can/jumpstarter_driver_can/driver.py @@ -1,5 +1,5 @@ +from collections.abc import Callable, Sequence from dataclasses import field -from typing import Callable, Optional, Sequence, Tuple, Union from uuid import UUID, uuid4 import can @@ -54,7 +54,7 @@ def __post_init__(self): @export @validate_call(validate_return=True) - def _recv_internal(self, timeout: Optional[float]) -> Tuple[Optional[CanMessage], bool]: + def _recv_internal(self, timeout: float | None) -> tuple[CanMessage | None, bool]: msg, filtered = self.bus._recv_internal(timeout) if msg: return CanMessage.construct(msg), filtered @@ -72,11 +72,11 @@ def send(self, msg: CanMessage, timeout: float | None = None): @validate_call(validate_return=True, config=ConfigDict(arbitrary_types_allowed=True)) def _send_periodic_internal( self, - msgs: Union[Sequence[CanMessage], CanMessage], + msgs: Sequence[CanMessage] | CanMessage, period: float, - duration: Optional[float] = None, + duration: float | None = None, autostart: bool = True, - modifier_callback: Optional[Callable[[can.Message], None]] = None, + modifier_callback: Callable[[can.Message], None] | None = None, ) -> UUID: assert modifier_callback is None task = self.bus._send_periodic_internal(msgs, period, duration, autostart, modifier_callback) @@ -126,7 +126,7 @@ def channel_info(self) -> str: # python-can bug # https://docs.pydantic.dev/2.8/errors/usage_errors/#typed-dict-version # @validate_call(validate_return=True) - def _apply_filters(self, filters: Optional[can.typechecking.CanFilters]) -> None: + def _apply_filters(self, filters: can.typechecking.CanFilters | None) -> None: self.bus._apply_filters(filters) @export diff --git a/python/packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py b/python/packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py index 4da4c0a6c..6da8700a9 100644 --- a/python/packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py +++ b/python/packages/jumpstarter-driver-composite/jumpstarter_driver_composite/client.py @@ -34,7 +34,7 @@ def __getattr__(self, name): raise AttributeError(name) from None def close(self): - for _, v in self.children.items(): + for v in self.children.values(): if hasattr(v, "close"): v.close() @@ -50,7 +50,6 @@ def cli(self): ) def base(): """Generic composite device""" - pass for k, v in self.children.items(): if isinstance(v, StubDriverClient): diff --git a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py index 7794624bb..0dafd2a8e 100644 --- a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py +++ b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py @@ -1,4 +1,3 @@ -from typing import Optional import requests import requests.exceptions @@ -30,7 +29,7 @@ def baseurl(self) -> str: """ return f'https://{self.host}/api' - def get_project(self, project_ref: str = 'Default Project') -> Optional[Project]: + def get_project(self, project_ref: str = 'Default Project') -> Project | None: """ Retrieve a project based on project_ref, which is either its id or name. """ @@ -51,7 +50,7 @@ def get_project(self, project_ref: str = 'Default Project') -> Optional[Project] return None - def get_device(self, model: str) -> Optional[Device]: + def get_device(self, model: str) -> Device | None: """ Get a device spec from Corellium's list based on the model name. @@ -98,7 +97,7 @@ def create_instance(self, name: str, project: Project, device: Device, os_versio return Instance(**data) # ty: ignore[missing-argument] - def get_instance(self, instance_ref: str) -> Optional[Instance]: + def get_instance(self, instance_ref: str) -> Instance | None: """ Retrieve an existing instance by its name. @@ -166,7 +165,7 @@ def destroy_instance(self, instance: Instance) -> None: raise CorelliumApiException(msgerr) from e - def get_instance_console_id(self, instance: Instance, console_name: str) -> Optional[str]: + def get_instance_console_id(self, instance: Instance, console_name: str) -> str | None: """ Retrieve an instance's console id by its name. @@ -189,7 +188,7 @@ def get_instance_console_id(self, instance: Instance, console_name: str) -> Opti return None - def get_instance_console_url(self, instance: Instance, console_id: str) -> Optional[str]: + def get_instance_console_url(self, instance: Instance, console_id: str) -> str | None: """ Get a console URL (websocket) to stream logs from. """ diff --git a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py index e4699df98..08d1cf04f 100644 --- a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py +++ b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py @@ -2,7 +2,6 @@ Corellium API types. """ from dataclasses import dataclass, field -from typing import Optional @dataclass @@ -36,4 +35,4 @@ class Instance: Virtual instance dataclass. """ id: str - state: Optional[str] = field(default=None) + state: str | None = field(default=None) diff --git a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py index 2b35f563d..46461a767 100644 --- a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py +++ b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py @@ -6,7 +6,6 @@ import time from collections.abc import AsyncGenerator from dataclasses import dataclass, field -from typing import Dict, Optional from jumpstarter_driver_network.driver import WebsocketNetwork from jumpstarter_driver_power.driver import PowerReading, VirtualPowerInterface @@ -101,16 +100,16 @@ class CorelliumPower(VirtualPowerInterface, Driver): parent: Corellium - def get_timeout_opts(self) -> Dict[str, int]: + def get_timeout_opts(self) -> dict[str, int]: """ Return config/opts to be used when waiting for Corellium's API. """ return { - "retries": int(os.environ.get("CORELLIUM_API_RETRIES", 12)), - "interval": os.environ.get("CORELLIUM_API_INTERVAL", 5), + "retries": int(os.environ.get("CORELLIUM_API_RETRIES", "12")), + "interval": int(os.environ.get("CORELLIUM_API_INTERVAL", "5")), } - def wait_instance(self, current: Instance, desired: Optional[Instance]): + def wait_instance(self, current: Instance, desired: Instance | None): """ Wait for `current` instance to reach the same state as the `desired` instance. diff --git a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py index bfe33295a..08f0eae40 100644 --- a/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py +++ b/python/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py @@ -98,14 +98,13 @@ def test_driver_power_on_error(monkeypatch, mock_data): root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') power = CorelliumPower(parent=root) - with pytest.raises((CorelliumApiException, ValueError)): - with (patch.object(root.api, 'get_project', - **mock_data.get('get_project', {'return_value': project})), - patch.object(root.api, 'get_instance', - **mock_data.get('get_instance', {'return_value': instance})), - patch.object(root.api, 'create_instance', - **mock_data.get('create_instance', {'return_value': instance}))): - power.on() + with (pytest.raises((CorelliumApiException, ValueError)), patch.object(root.api, 'get_project', + **mock_data.get('get_project', {'return_value': project})), + patch.object(root.api, 'get_instance', + **mock_data.get('get_instance', {'return_value': instance})), + patch.object(root.api, 'create_instance', + **mock_data.get('create_instance', {'return_value': instance}))): + power.on() def test_driver_power_off_ok(monkeypatch): @@ -138,14 +137,13 @@ def test_driver_power_off_error(monkeypatch, mock_data): root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') power = CorelliumPower(parent=root) - with pytest.raises((CorelliumApiException, ValueError)): - with (patch.object(root.api, 'get_project', - **mock_data.get('get_project', {'return_value': project})), - patch.object(root.api, 'get_instance', - **mock_data.get('get_instance', {'side_effect': [instance, None]})), - patch.object(root.api, 'destroy_instance', - **mock_data.get('destroy_instance', {'return_value': instance}))): - power.off() + with (pytest.raises((CorelliumApiException, ValueError)), patch.object(root.api, 'get_project', + **mock_data.get('get_project', {'return_value': project})), + patch.object(root.api, 'get_instance', + **mock_data.get('get_instance', {'side_effect': [instance, None]})), + patch.object(root.api, 'destroy_instance', + **mock_data.get('destroy_instance', {'return_value': instance}))): + power.off() def test_driver_console_get_url_ok(monkeypatch): @@ -179,13 +177,12 @@ def test_driver_console_get_url_error(monkeypatch, mock_data): root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') console = CorelliumConsole(parent=root, url='') - with pytest.raises((CorelliumApiException, ValueError)): - with (patch.object(root.api, 'get_project', - **mock_data.get('get_project', {'return_value': project})), - patch.object(root.api, 'get_instance', - **mock_data.get('get_instance', {'side_effect': [instance, None]})), - patch.object(root.api, 'get_instance_console_id', - **mock_data.get('get_instance_console_id', {'return_value': 'uart7-cons'})), - patch.object(root.api, 'get_instance_console_id', - **mock_data.get('get_instance_console_url', {'return_value': 'ws://mock'}))): - assert console.url + with (pytest.raises((CorelliumApiException, ValueError)), patch.object(root.api, 'get_project', + **mock_data.get('get_project', {'return_value': project})), + patch.object(root.api, 'get_instance', + **mock_data.get('get_instance', {'side_effect': [instance, None]})), + patch.object(root.api, 'get_instance_console_id', + **mock_data.get('get_instance_console_id', {'return_value': 'uart7-cons'})), + patch.object(root.api, 'get_instance_console_id', + **mock_data.get('get_instance_console_url', {'return_value': 'ws://mock'}))): + assert console.url diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py index 346aa9280..b3f50011b 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py @@ -30,7 +30,7 @@ def _run_with_progress(label: str, fn): def worker(): try: result[0] = fn() - except Exception as e: + except Exception as e: # noqa: BLE001 error[0] = e t = threading.Thread(target=worker) diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli.py index 04398747e..c2dc7591a 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli.py @@ -51,7 +51,7 @@ def instance_to_cvd(group_name: str, instance: dict) -> dict: clients see identical documents from both backends. """ if not isinstance(instance, dict): - raise ValueError(f"unexpected cvd instance document: {instance!r}") + raise TypeError(f"unexpected cvd instance document: {instance!r}") return { "group": group_name, "name": instance.get("instance_name"), @@ -65,7 +65,7 @@ def instance_to_cvd(group_name: str, instance: dict) -> dict: def group_to_cvds(group: dict) -> list[dict]: if not isinstance(group, dict) or not isinstance(group.get("instances"), list): - raise ValueError(f"unexpected cvd group document: {group!r}") + raise TypeError(f"unexpected cvd group document: {group!r}") return [instance_to_cvd(group.get("group_name", ""), instance) for instance in group["instances"]] @@ -76,7 +76,7 @@ def fleet_to_cvds(output: str) -> list[dict]: except ValueError as e: raise ValueError(f"cvd fleet returned invalid JSON: {output[:200]!r}") from e if not isinstance(data, dict) or not isinstance(data.get("groups"), list): - raise ValueError(f"unexpected cvd fleet document: {output[:200]!r}") + raise TypeError(f"unexpected cvd fleet document: {output[:200]!r}") cvds = [] for group in data["groups"]: cvds.extend(group_to_cvds(group)) diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli_test.py index 51ff61b9f..1bc2c79e0 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli_test.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/cvdcli_test.py @@ -62,7 +62,7 @@ def test_fleet_to_cvds_matches_host_orchestrator_shape(): json.dumps({"groups": [{"group_name": "cvd_1", "instances": [None]}]}), ]) def test_fleet_to_cvds_rejects_unexpected_documents(output): - with pytest.raises(ValueError): + with pytest.raises((ValueError, TypeError)): fleet_to_cvds(output) diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py index c09fb8d32..8e3ea6cbe 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py @@ -1,3 +1,4 @@ +import contextlib import json import os import subprocess @@ -6,6 +7,7 @@ from collections.abc import Generator from dataclasses import dataclass, field from pathlib import Path +from typing import ClassVar import requests from jumpstarter_driver_adb.driver import AdbServer @@ -38,7 +40,7 @@ class HostOrchestratorBackend: delete_is_group_scoped = False - _paths = { + _paths: ClassVar[dict] = { "create": ("POST", "/cvds"), "start": ("POST", "{cvd}/:start"), "stop": ("POST", "{cvd}/:stop"), @@ -148,7 +150,7 @@ class CvdCliBackend: keep the same shape. """ - _subcommands = { + _subcommands: ClassVar[dict] = { "start": ["start", "--report_anonymous_usage_stats=n"], "stop": ["stop"], "restart": ["restart"], @@ -173,7 +175,7 @@ def _cvd(self, args: list[str], timeout: float) -> str: argv = cvd_argv(self.socket, args) self.driver.logger.debug("running %s", " ".join(argv)) try: - proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, check=False) except OSError as e: raise CuttlefishError(f"cannot run jumpstarter-exec at {exec_binary(self.socket)}: {e}") from e except subprocess.TimeoutExpired as e: @@ -389,7 +391,7 @@ def _auto_connect_adb(self) -> str: self.logger.info(f"Auto-connecting ADB to {device}") try: adb.connect_device(device) - except Exception: + except Exception: # noqa: BLE001 self.logger.warning("ADB connect to %s failed, will retry during boot wait", device) return device @@ -399,10 +401,8 @@ def _auto_disconnect_adb(self): return device = self._cvd_device self.logger.info(f"Disconnecting ADB from {device}") - try: + with contextlib.suppress(Exception): adb.disconnect_device(device) - except Exception: - pass def _wait_boot(self, timeout: float = 300): """Wait for CVD to be ADB-reachable and fully booted.""" @@ -425,6 +425,7 @@ def _wait_boot(self, timeout: float = 300): text=True, timeout=5, env=adb_env, + check=False, ) except (subprocess.TimeoutExpired, OSError): pass @@ -435,6 +436,7 @@ def _wait_boot(self, timeout: float = 300): text=True, timeout=5, env=adb_env, + check=False, ) for line in r.stdout.splitlines(): if device in line and "\tdevice" in line: @@ -458,6 +460,7 @@ def _wait_boot(self, timeout: float = 300): text=True, timeout=10, env=adb_env, + check=False, ) if r.stdout.strip() == "1": self.logger.info("Boot completed on %s", device) diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_exec_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_exec_test.py index f57a7a775..4f92c4481 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_exec_test.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_exec_test.py @@ -136,19 +136,18 @@ def test_failure_reports_exit_code_and_stderr_without_banner(cvd, drv): def test_timeout_and_missing_launcher(drv): with patch("jumpstarter_driver_cuttlefish.driver.subprocess.run", - side_effect=subprocess.TimeoutExpired("cvd", 5)): - with pytest.raises(CuttlefishTimeout, match="timed out"): - drv.start_cvd() - with patch("jumpstarter_driver_cuttlefish.driver.subprocess.run", side_effect=FileNotFoundError("missing")): - with pytest.raises(CuttlefishError, match="cannot run jumpstarter-exec"): - drv.status() + side_effect=subprocess.TimeoutExpired("cvd", 5)), pytest.raises(CuttlefishTimeout, match="timed out"): + drv.start_cvd() + with patch("jumpstarter_driver_cuttlefish.driver.subprocess.run", side_effect=FileNotFoundError("missing")), \ + pytest.raises(CuttlefishError, match="cannot run jumpstarter-exec"): + drv.status() def test_invalid_fleet_output(drv): with patch("jumpstarter_driver_cuttlefish.driver.subprocess.run", - return_value=subprocess.CompletedProcess([], 0, stdout="garbage", stderr="")): - with pytest.raises(CuttlefishError, match="invalid JSON"): - drv.list_cvds() + return_value=subprocess.CompletedProcess([], 0, stdout="garbage", stderr="")), \ + pytest.raises(CuttlefishError, match="invalid JSON"): + drv.list_cvds() def test_list_operations_unsupported(drv): diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py index 36fff3553..2229c1abb 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py @@ -635,9 +635,11 @@ def perform(*args): def test_managed_records_failed_operation(managed_drv): from pathlib import Path - with patch.object(managed_drv._backend, "operate", side_effect=CuttlefishError("runtime died")): - with pytest.raises(CuttlefishError): - managed_drv.start_cvd() + with ( + patch.object(managed_drv._backend, "operate", side_effect=CuttlefishError("runtime died")), + pytest.raises(CuttlefishError), + ): + managed_drv.start_cvd() assert json.loads(Path(managed_drv.health_state_path).read_text())["state"] == "failed" diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health.py index ac7fcbfa3..e9b5ea754 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health.py @@ -26,7 +26,7 @@ def exec_inventory(state: dict) -> list[dict]: """List CVDs through the cvd CLI; failure means the launcher or cvd is down.""" argv = cvd_argv(state["socket"], ["fleet"]) try: - proc = subprocess.run(argv, capture_output=True, text=True, timeout=PROBE_TIMEOUT) + proc = subprocess.run(argv, capture_output=True, text=True, timeout=PROBE_TIMEOUT, check=False) except OSError as exc: raise RuntimeError(f"cannot run jumpstarter-exec: {exc}") from exc except subprocess.TimeoutExpired as exc: @@ -40,7 +40,7 @@ def exec_reachable(state: dict) -> None: """Check that the launcher accepts commands without contending on cvd.""" argv = [str(exec_binary(state["socket"])), "exec", "--socket", state["socket"], "--", "/bin/true"] try: - proc = subprocess.run(argv, capture_output=True, text=True, timeout=2) + proc = subprocess.run(argv, capture_output=True, text=True, timeout=2, check=False) except (OSError, subprocess.TimeoutExpired) as exc: raise RuntimeError(f"launcher is unavailable: {exc}") from exc if proc.returncode != 0: @@ -96,7 +96,7 @@ def wait_ready(endpoint: str, attempts: int = 60, interval: float = 5) -> None: else: http_reachable(state) return - except Exception: + except Exception: # noqa: BLE001 time.sleep(interval) raise RuntimeError(f"Cuttlefish runtime at {endpoint} did not become ready") diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health_test.py index 2876bea2c..fb562bcd1 100644 --- a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health_test.py +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/health_test.py @@ -106,15 +106,16 @@ def read_table(path, *args, **kwargs): @pytest.mark.parametrize("error", [FileNotFoundError, PermissionError]) def test_required_listener_table_errors(error): - with patch.object(Path, "read_text", side_effect=error("/proc/net/tcp")): - with pytest.raises(error): - listening_ports() + with patch.object(Path, "read_text", side_effect=error("/proc/net/tcp")), pytest.raises(error): + listening_ports() def test_ipv6_permission_errors_are_not_ignored(): - with patch.object(Path, "read_text", side_effect=["header\n", PermissionError("/proc/net/tcp6")]): - with pytest.raises(PermissionError): - listening_ports() + with ( + patch.object(Path, "read_text", side_effect=["header\n", PermissionError("/proc/net/tcp6")]), + pytest.raises(PermissionError), + ): + listening_ports() def test_warm_exporter_before_first_lease(tmp_path): @@ -138,9 +139,9 @@ def test_wait_ready_gate(): wait_ready("http://127.0.0.1:2081", attempts=1, interval=0) assert urlopen.call_args.args[0] == "http://127.0.0.1:2081/_debug/statusz" with patch("jumpstarter_driver_cuttlefish.health.urllib.request.urlopen", side_effect=OSError("refused")), \ - patch("jumpstarter_driver_cuttlefish.health.time.sleep") as sleep: - with pytest.raises(RuntimeError, match="did not become ready"): - wait_ready("http://127.0.0.1:2081", attempts=3, interval=5) + patch("jumpstarter_driver_cuttlefish.health.time.sleep") as sleep, \ + pytest.raises(RuntimeError, match="did not become ready"): + wait_ready("http://127.0.0.1:2081", attempts=3, interval=5) assert sleep.call_count == 3 @@ -174,9 +175,9 @@ def test_exec_backend_checks_inventory_through_cvd_fleet(tmp_path): stopped = [{"group_name": "cvd_1", "instances": [{"instance_name": "1", "status": "Stopped"}]}] with patch("jumpstarter_driver_cuttlefish.health.exec_reachable"), \ patch("jumpstarter_driver_cuttlefish.health.subprocess.run", return_value=_fleet(stopped)), \ - patch("jumpstarter_driver_cuttlefish.health.listening_ports", return_value={7681}): - with pytest.raises(RuntimeError, match="stopped unexpectedly"): - check(str(path)) + patch("jumpstarter_driver_cuttlefish.health.listening_ports", return_value={7681}), \ + pytest.raises(RuntimeError, match="stopped unexpectedly"): + check(str(path)) def test_exec_backend_launcher_failure_is_unhealthy_even_when_off(tmp_path): @@ -186,9 +187,9 @@ def test_exec_backend_launcher_failure_is_unhealthy_even_when_off(tmp_path): import subprocess failure = subprocess.CompletedProcess([], 1, stdout="", stderr="socket unavailable") - with patch("jumpstarter_driver_cuttlefish.health.subprocess.run", return_value=failure): - with pytest.raises(RuntimeError, match="launcher check failed"): - check(str(path)) + with patch("jumpstarter_driver_cuttlefish.health.subprocess.run", return_value=failure), \ + pytest.raises(RuntimeError, match="launcher check failed"): + check(str(path)) with patch("jumpstarter_driver_cuttlefish.health.subprocess.run", return_value=_fleet([])) as run: check(str(path)) assert run.call_args.args[0][-1] == "/bin/true" @@ -207,9 +208,9 @@ def test_wait_ready_for_both_backends(tmp_path): assert run.call_args.args[0][-1] == "/bin/true" with patch("jumpstarter_driver_cuttlefish.health.urllib.request.urlopen", return_value=io.BytesIO()): wait_ready("http://127.0.0.1:2081", attempts=1, interval=0) - with patch("jumpstarter_driver_cuttlefish.health.urllib.request.urlopen", side_effect=OSError("refused")): - with pytest.raises(RuntimeError, match="did not become ready"): - wait_ready("http://127.0.0.1:2081", attempts=2, interval=0) + with patch("jumpstarter_driver_cuttlefish.health.urllib.request.urlopen", side_effect=OSError("refused")), \ + pytest.raises(RuntimeError, match="did not become ready"): + wait_ready("http://127.0.0.1:2081", attempts=2, interval=0) def test_initialize_records_exec_endpoint(tmp_path): diff --git a/python/packages/jumpstarter-driver-doip/jumpstarter_driver_doip/driver_test.py b/python/packages/jumpstarter-driver-doip/jumpstarter_driver_doip/driver_test.py index 7073d95c5..3378440d2 100644 --- a/python/packages/jumpstarter-driver-doip/jumpstarter_driver_doip/driver_test.py +++ b/python/packages/jumpstarter-driver-doip/jumpstarter_driver_doip/driver_test.py @@ -174,9 +174,8 @@ def test_doip_receive_diagnostic_timeout(mock_doip_cls): mock_doip_cls.return_value = mock_client driver = DoIP(ecu_ip="192.168.1.100", ecu_logical_address=0x00E0) - with serve(driver) as client: - with pytest.raises(DriverError, match="No response from ECU"): - client.receive_diagnostic(timeout=0.1) + with serve(driver) as client, pytest.raises(DriverError, match="No response from ECU"): + client.receive_diagnostic(timeout=0.1) @patch("jumpstarter_driver_doip.driver.DoIPClient") @@ -186,9 +185,8 @@ def test_doip_entity_status_connection_error(mock_doip_cls): mock_doip_cls.return_value = mock_client driver = DoIP(ecu_ip="192.168.1.100", ecu_logical_address=0x00E0) - with serve(driver) as client: - with pytest.raises(DriverError, match="Lost connection"): - client.entity_status() + with serve(driver) as client, pytest.raises(DriverError, match="Lost connection"): + client.entity_status() @patch("jumpstarter_driver_doip.driver.DoIPClient") @@ -198,9 +196,8 @@ def test_doip_reconnect_failure(mock_doip_cls): mock_doip_cls.return_value = mock_client driver = DoIP(ecu_ip="192.168.1.100", ecu_logical_address=0x00E0) - with serve(driver) as client: - with pytest.raises(DriverError, match="Cannot reconnect"): - client.reconnect(close_delay=0.1) + with serve(driver) as client, pytest.raises(DriverError, match="Cannot reconnect"): + client.reconnect(close_delay=0.1) # --- Config validation tests --- diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/client.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/client.py index 6419ec748..193dcd433 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/client.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/client.py @@ -1,7 +1,7 @@ """Client interface for the DUT Network isolation driver.""" import json -from typing import Generator +from collections.abc import Generator import click @@ -75,15 +75,13 @@ def tcpdump(self, args: list[str] | None = None) -> Generator[str, None, None]: Yields: Lines of tcpdump text output. """ - for line in self.streamingcall("tcpdump", args): - yield line + yield from self.streamingcall("tcpdump", args) # pragma: no cover def cli(self) -> click.Group: # noqa: C901 """Build the Click CLI command group for this driver.""" @driver_click_group(self) def base(): """DUT Network Isolation""" - pass @base.command() def status(): diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/dnsmasq.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/dnsmasq.py index 781b3dd47..3dc3515bd 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/dnsmasq.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/dnsmasq.py @@ -1,3 +1,4 @@ +import contextlib import ipaddress import logging import os @@ -145,11 +146,9 @@ def write_config( def _drain_pipe(pipe, log_fn) -> None: """Read from a pipe until EOF, forwarding each line to a logger.""" - try: + with contextlib.suppress(Exception): for line in pipe: log_fn(line.decode(errors="replace").rstrip()) - except Exception: - pass _STARTUP_TIMEOUT = 2.0 diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/ntp_server.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/ntp_server.py index c370791b7..0da5563c0 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/ntp_server.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/ntp_server.py @@ -114,7 +114,7 @@ def _serve(self) -> None: while not self._stop_event.is_set(): try: data, addr = self._sock.recvfrom(1024) - except socket.timeout: + except TimeoutError: continue except OSError: break diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_cli.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_cli.py index 9db4c4a8e..d136ab848 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_cli.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_cli.py @@ -86,78 +86,71 @@ def test_all_commands_listed(self, tmp_path: Path, runner: CliRunner): class TestStatusCommand: def test_outputs_json(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "status", return_value={ - "interface": "eth-dut", - "subnet": "192.168.100.0/24", - "nat_mode": "masquerade", - "interface_status": {"name": "eth-dut", "addresses": ["192.168.100.1/24"]}, - }): - result = runner.invoke(client.cli(), ["status"]) - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["interface"] == "eth-dut" - assert data["subnet"] == "192.168.100.0/24" - assert data["nat_mode"] == "masquerade" + with _make_client(tmp_path) as client, patch.object(client, "status", return_value={ + "interface": "eth-dut", + "subnet": "192.168.100.0/24", + "nat_mode": "masquerade", + "interface_status": {"name": "eth-dut", "addresses": ["192.168.100.1/24"]}, + }): + result = runner.invoke(client.cli(), ["status"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["interface"] == "eth-dut" + assert data["subnet"] == "192.168.100.0/24" + assert data["nat_mode"] == "masquerade" def test_interface_status_present(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "status", return_value={ - "interface": "eth-dut", - "interface_status": {"name": "eth-dut"}, - }): - result = runner.invoke(client.cli(), ["status"]) - data = json.loads(result.output) - assert "interface_status" in data - assert data["interface_status"]["name"] == "eth-dut" + with _make_client(tmp_path) as client, patch.object(client, "status", return_value={ + "interface": "eth-dut", + "interface_status": {"name": "eth-dut"}, + }): + result = runner.invoke(client.cli(), ["status"]) + data = json.loads(result.output) + assert "interface_status" in data + assert data["interface_status"]["name"] == "eth-dut" class TestLeasesCommand: def test_no_leases_message(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_leases", return_value=[]): - result = runner.invoke(client.cli(), ["leases"]) - assert result.exit_code == 0 - assert "No active DHCP leases" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_leases", return_value=[]): + result = runner.invoke(client.cli(), ["leases"]) + assert result.exit_code == 0 + assert "No active DHCP leases" in result.output def test_displays_leases(self, tmp_path: Path, runner: CliRunner): leases = [ {"mac": "aa:bb:cc:dd:ee:ff", "ip": "192.168.100.10", "hostname": "dut1", "expiry": "2099-01-01"}, ] - with _make_client(tmp_path) as client: - with patch.object(client, "get_leases", return_value=leases): - result = runner.invoke(client.cli(), ["leases"]) - assert result.exit_code == 0 - assert "aa:bb:cc:dd:ee:ff" in result.output - assert "192.168.100.10" in result.output - assert "dut1" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_leases", return_value=leases): + result = runner.invoke(client.cli(), ["leases"]) + assert result.exit_code == 0 + assert "aa:bb:cc:dd:ee:ff" in result.output + assert "192.168.100.10" in result.output + assert "dut1" in result.output def test_displays_table_header(self, tmp_path: Path, runner: CliRunner): leases = [ {"mac": "00:11:22:33:44:55", "ip": "192.168.100.20", "hostname": "", "expiry": "static"}, ] - with _make_client(tmp_path) as client: - with patch.object(client, "get_leases", return_value=leases): - result = runner.invoke(client.cli(), ["leases"]) - assert "MAC" in result.output - assert "IP" in result.output - assert "Hostname" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_leases", return_value=leases): + result = runner.invoke(client.cli(), ["leases"]) + assert "MAC" in result.output + assert "IP" in result.output + assert "Hostname" in result.output class TestGetIpCommand: def test_returns_ip_for_known_mac(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_dut_ip", return_value="192.168.100.10"): - result = runner.invoke(client.cli(), ["get-ip", "aa:bb:cc:dd:ee:ff"]) - assert result.exit_code == 0 - assert "192.168.100.10" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_dut_ip", return_value="192.168.100.10"): + result = runner.invoke(client.cli(), ["get-ip", "aa:bb:cc:dd:ee:ff"]) + assert result.exit_code == 0 + assert "192.168.100.10" in result.output def test_error_for_unknown_mac(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_dut_ip", return_value=None): - result = runner.invoke(client.cli(), ["get-ip", "ff:ff:ff:ff:ff:ff"]) - assert result.exit_code != 0 - assert "No lease found" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_dut_ip", return_value=None): + result = runner.invoke(client.cli(), ["get-ip", "ff:ff:ff:ff:ff:ff"]) + assert result.exit_code != 0 + assert "No lease found" in result.output def test_requires_mac_argument(self, tmp_path: Path, runner: CliRunner): with _make_client(tmp_path) as client: @@ -167,49 +160,45 @@ def test_requires_mac_argument(self, tmp_path: Path, runner: CliRunner): class TestAddAddressCommand: def test_add_address_output(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_address"): - result = runner.invoke(client.cli(), ["add-address", "192.168.100.50", "-m", "aa:bb:cc:dd:ee:ff"]) - assert result.exit_code == 0 - assert "Added address" in result.output - assert "192.168.100.50" in result.output + with _make_client(tmp_path) as client, patch.object(client, "add_address"): + result = runner.invoke(client.cli(), ["add-address", "192.168.100.50", "-m", "aa:bb:cc:dd:ee:ff"]) + assert result.exit_code == 0 + assert "Added address" in result.output + assert "192.168.100.50" in result.output def test_add_address_without_mac(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_address") as mock_add: - result = runner.invoke(client.cli(), [ - "add-address", "192.168.100.50", "--public-ip", "10.0.0.50", - ]) - assert result.exit_code == 0 - mock_add.assert_called_once_with( - "192.168.100.50", None, "", "10.0.0.50", None, None, - ) + with _make_client(tmp_path) as client, patch.object(client, "add_address") as mock_add: + result = runner.invoke(client.cli(), [ + "add-address", "192.168.100.50", "--public-ip", "10.0.0.50", + ]) + assert result.exit_code == 0 + mock_add.assert_called_once_with( + "192.168.100.50", None, "", "10.0.0.50", None, None, + ) def test_add_address_with_hostname(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_address") as mock_add: - result = runner.invoke( - client.cli(), - ["add-address", "192.168.100.50", "-m", "aa:bb:cc:dd:ee:ff", "-n", "my-dut"], - ) - assert result.exit_code == 0 - mock_add.assert_called_once_with( - "192.168.100.50", "aa:bb:cc:dd:ee:ff", "my-dut", None, None, None, - ) + with _make_client(tmp_path) as client, patch.object(client, "add_address") as mock_add: + result = runner.invoke( + client.cli(), + ["add-address", "192.168.100.50", "-m", "aa:bb:cc:dd:ee:ff", "-n", "my-dut"], + ) + assert result.exit_code == 0 + mock_add.assert_called_once_with( + "192.168.100.50", "aa:bb:cc:dd:ee:ff", "my-dut", None, None, None, + ) def test_add_address_with_vlan_options(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_address") as mock_add: - result = runner.invoke(client.cli(), [ - "add-address", "192.168.100.50", - "--public-ip", "203.0.113.1", - "--vlan-id", "905", - "--public-gateway", "203.0.113.254", - ]) - assert result.exit_code == 0 - mock_add.assert_called_once_with( - "192.168.100.50", None, "", "203.0.113.1", 905, "203.0.113.254", - ) + with _make_client(tmp_path) as client, patch.object(client, "add_address") as mock_add: + result = runner.invoke(client.cli(), [ + "add-address", "192.168.100.50", + "--public-ip", "203.0.113.1", + "--vlan-id", "905", + "--public-gateway", "203.0.113.254", + ]) + assert result.exit_code == 0 + mock_add.assert_called_once_with( + "192.168.100.50", None, "", "203.0.113.1", 905, "203.0.113.254", + ) def test_requires_ip_argument(self, tmp_path: Path, runner: CliRunner): with _make_client(tmp_path) as client: @@ -219,18 +208,16 @@ def test_requires_ip_argument(self, tmp_path: Path, runner: CliRunner): class TestRemoveAddressCommand: def test_remove_address_output(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "remove_address"): - result = runner.invoke(client.cli(), ["remove-address", "192.168.100.50"]) - assert result.exit_code == 0 - assert "Removed address" in result.output - assert "192.168.100.50" in result.output + with _make_client(tmp_path) as client, patch.object(client, "remove_address"): + result = runner.invoke(client.cli(), ["remove-address", "192.168.100.50"]) + assert result.exit_code == 0 + assert "Removed address" in result.output + assert "192.168.100.50" in result.output def test_calls_client_method(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "remove_address") as mock_rm: - runner.invoke(client.cli(), ["remove-address", "192.168.100.50"]) - mock_rm.assert_called_once_with("192.168.100.50") + with _make_client(tmp_path) as client, patch.object(client, "remove_address") as mock_rm: + runner.invoke(client.cli(), ["remove-address", "192.168.100.50"]) + mock_rm.assert_called_once_with("192.168.100.50") def test_requires_ip_argument(self, tmp_path: Path, runner: CliRunner): with _make_client(tmp_path) as client: @@ -240,61 +227,57 @@ def test_requires_ip_argument(self, tmp_path: Path, runner: CliRunner): class TestNatRulesCommand: def test_displays_rules(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_nat_rules", return_value="table ip jmp { masquerade }"): - result = runner.invoke(client.cli(), ["nat-rules"]) - assert result.exit_code == 0 - assert "masquerade" in result.output + with ( + _make_client(tmp_path) as client, + patch.object(client, "get_nat_rules", return_value="table ip jmp { masquerade }"), + ): + result = runner.invoke(client.cli(), ["nat-rules"]) + assert result.exit_code == 0 + assert "masquerade" in result.output def test_no_rules_message(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_nat_rules", return_value=""): - result = runner.invoke(client.cli(), ["nat-rules"]) - assert result.exit_code == 0 - assert "No active NAT rules" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_nat_rules", return_value=""): + result = runner.invoke(client.cli(), ["nat-rules"]) + assert result.exit_code == 0 + assert "No active NAT rules" in result.output class TestDnsEntriesCommand: def test_no_entries_message(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "get_dns_entries", return_value=[]): - result = runner.invoke(client.cli(), ["dns-entries"]) - assert result.exit_code == 0 - assert "No DNS entries configured" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_dns_entries", return_value=[]): + result = runner.invoke(client.cli(), ["dns-entries"]) + assert result.exit_code == 0 + assert "No DNS entries configured" in result.output def test_displays_entries(self, tmp_path: Path, runner: CliRunner): entries = [{"hostname": "myhost.local", "ip": "10.0.0.1"}] - with _make_client(tmp_path) as client: - with patch.object(client, "get_dns_entries", return_value=entries): - result = runner.invoke(client.cli(), ["dns-entries"]) - assert result.exit_code == 0 - assert "myhost.local" in result.output - assert "10.0.0.1" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_dns_entries", return_value=entries): + result = runner.invoke(client.cli(), ["dns-entries"]) + assert result.exit_code == 0 + assert "myhost.local" in result.output + assert "10.0.0.1" in result.output def test_displays_table_header(self, tmp_path: Path, runner: CliRunner): entries = [{"hostname": "h.local", "ip": "1.2.3.4"}] - with _make_client(tmp_path) as client: - with patch.object(client, "get_dns_entries", return_value=entries): - result = runner.invoke(client.cli(), ["dns-entries"]) - assert "Hostname" in result.output - assert "IP" in result.output + with _make_client(tmp_path) as client, patch.object(client, "get_dns_entries", return_value=entries): + result = runner.invoke(client.cli(), ["dns-entries"]) + assert "Hostname" in result.output + assert "IP" in result.output class TestAddDnsCommand: def test_add_dns_output(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_dns_entry"): - result = runner.invoke(client.cli(), ["add-dns", "new.local", "10.0.0.99"]) - assert result.exit_code == 0 - assert "Added DNS entry" in result.output - assert "new.local" in result.output - assert "10.0.0.99" in result.output + with _make_client(tmp_path) as client, patch.object(client, "add_dns_entry"): + result = runner.invoke(client.cli(), ["add-dns", "new.local", "10.0.0.99"]) + assert result.exit_code == 0 + assert "Added DNS entry" in result.output + assert "new.local" in result.output + assert "10.0.0.99" in result.output def test_calls_client_method(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "add_dns_entry") as mock_add: - runner.invoke(client.cli(), ["add-dns", "x.local", "9.8.7.6"]) - mock_add.assert_called_once_with("x.local", "9.8.7.6") + with _make_client(tmp_path) as client, patch.object(client, "add_dns_entry") as mock_add: + runner.invoke(client.cli(), ["add-dns", "x.local", "9.8.7.6"]) + mock_add.assert_called_once_with("x.local", "9.8.7.6") def test_requires_hostname_and_ip(self, tmp_path: Path, runner: CliRunner): with _make_client(tmp_path) as client: @@ -304,18 +287,16 @@ def test_requires_hostname_and_ip(self, tmp_path: Path, runner: CliRunner): class TestRemoveDnsCommand: def test_remove_dns_output(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "remove_dns_entry"): - result = runner.invoke(client.cli(), ["remove-dns", "old.local"]) - assert result.exit_code == 0 - assert "Removed DNS entry" in result.output - assert "old.local" in result.output + with _make_client(tmp_path) as client, patch.object(client, "remove_dns_entry"): + result = runner.invoke(client.cli(), ["remove-dns", "old.local"]) + assert result.exit_code == 0 + assert "Removed DNS entry" in result.output + assert "old.local" in result.output def test_calls_client_method(self, tmp_path: Path, runner: CliRunner): - with _make_client(tmp_path) as client: - with patch.object(client, "remove_dns_entry") as mock_rm: - runner.invoke(client.cli(), ["remove-dns", "gone.local"]) - mock_rm.assert_called_once_with("gone.local") + with _make_client(tmp_path) as client, patch.object(client, "remove_dns_entry") as mock_rm: + runner.invoke(client.cli(), ["remove-dns", "gone.local"]) + mock_rm.assert_called_once_with("gone.local") def test_requires_hostname_argument(self, tmp_path: Path, runner: CliRunner): with _make_client(tmp_path) as client: diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_dnsmasq.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_dnsmasq.py index ec28c8a11..1e4d8cb65 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_dnsmasq.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_dnsmasq.py @@ -155,11 +155,13 @@ def test_raises_when_process_exits_immediately(self, tmp_path: Path): mock_proc = MagicMock() mock_proc.poll.return_value = 1 mock_proc.stderr.read.return_value = b"bind failed" - with patch(f"{_DNSMASQ_MODULE}.subprocess.Popen", return_value=mock_proc), \ - patch(f"{_DNSMASQ_MODULE}.time.sleep"), \ - patch(f"{_DNSMASQ_MODULE}.time.monotonic", side_effect=[0.0, 3.0]): - with pytest.raises(RuntimeError, match="dnsmasq failed to start"): - dnsmasq.start(tmp_path) + with ( + patch(f"{_DNSMASQ_MODULE}.subprocess.Popen", return_value=mock_proc), + patch(f"{_DNSMASQ_MODULE}.time.sleep"), + patch(f"{_DNSMASQ_MODULE}.time.monotonic", side_effect=[0.0, 3.0]), + pytest.raises(RuntimeError, match="dnsmasq failed to start"), + ): + dnsmasq.start(tmp_path) def test_raises_when_pidfile_not_created(self, tmp_path: Path): diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver.py index 7ed3a206e..111e0ad19 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver.py @@ -218,26 +218,28 @@ def test_no_filter_passes_none(self, tmp_path: Path): class TestTransactionalSetup: def test_cleanup_called_on_setup_failure(self, tmp_path: Path): - with pytest.raises(RuntimeError, match="Cannot detect upstream"): - with patch(f"{_DRIVER_MODULE}.sys") as mock_sys, \ - patch(f"{_DRIVER_MODULE}.shutil") as mock_shutil, \ - patch(f"{_DRIVER_MODULE}.iproute") as mock_iproute, \ - patch(f"{_DRIVER_MODULE}.nftables") as mock_nft, \ - patch(f"{_DRIVER_MODULE}.dnsmasq"): - mock_sys.platform = "linux" - mock_shutil.which.return_value = "/usr/bin/fake" - mock_iproute.interface_exists.return_value = False - mock_iproute.detect_upstream_interface.return_value = None - mock_nft._table_name_for.return_value = "jumpstarter_eth0" - from .driver import DutNetwork - DutNetwork( - interface="eth0", - subnet="192.168.100.0/24", - gateway_ip="192.168.100.1", - upstream_interface=None, - nat_mode="masquerade", - state_dir=str(tmp_path), - ) # type: ignore[missing-argument] + with ( + pytest.raises(RuntimeError, match="Cannot detect upstream"), + patch(f"{_DRIVER_MODULE}.sys") as mock_sys, + patch(f"{_DRIVER_MODULE}.shutil") as mock_shutil, + patch(f"{_DRIVER_MODULE}.iproute") as mock_iproute, + patch(f"{_DRIVER_MODULE}.nftables") as mock_nft, + patch(f"{_DRIVER_MODULE}.dnsmasq"), + ): + mock_sys.platform = "linux" + mock_shutil.which.return_value = "/usr/bin/fake" + mock_iproute.interface_exists.return_value = False + mock_iproute.detect_upstream_interface.return_value = None + mock_nft._table_name_for.return_value = "jumpstarter_eth0" + from .driver import DutNetwork + DutNetwork( + interface="eth0", + subnet="192.168.100.0/24", + gateway_ip="192.168.100.1", + upstream_interface=None, + nat_mode="masquerade", + state_dir=str(tmp_path), + ) # type: ignore[missing-argument] class TestDriverSetupMasquerade: @@ -256,7 +258,7 @@ def test_calls_configure_and_nat(self, tmp_path: Path): mock_dns.start.assert_called_once() def test_saves_previous_forwarding_per_interface(self, tmp_path: Path): - driver, mock_ip, _, _ = _make_driver(tmp_path, nat_mode="masquerade") + _driver, mock_ip, _, _ = _make_driver(tmp_path, nat_mode="masquerade") assert mock_ip.get_interface_forwarding.call_count == 2 def test_calls_ensure_filter_forward(self, tmp_path: Path): @@ -313,7 +315,7 @@ def test_interface_still_configured(self, tmp_path: Path): mock_ip.configure_interface.assert_called_once() def test_upstream_not_required(self, tmp_path: Path): - driver, mock_ip, _, _ = _make_driver( + _driver, mock_ip, _, _ = _make_driver( tmp_path, nat_mode="disabled", upstream_interface=None, ) mock_ip.detect_upstream_interface.assert_not_called() @@ -321,7 +323,7 @@ def test_upstream_not_required(self, tmp_path: Path): class TestDriverCleanup: def test_cleanup_masquerade(self, tmp_path: Path): - driver, mock_ip, mock_nft, mock_dns = _make_driver(tmp_path, nat_mode="masquerade") + driver, _mock_ip, _mock_nft, _mock_dns = _make_driver(tmp_path, nat_mode="masquerade") with patch(f"{_DRIVER_MODULE}.iproute") as mock_ip2, \ patch(f"{_DRIVER_MODULE}.nftables") as mock_nft2, \ patch(f"{_DRIVER_MODULE}.dnsmasq") as mock_dns2: @@ -495,16 +497,20 @@ def test_hostname_resolved_to_ip(self): def test_unresolvable_hostname_raises(self): from .driver import DutNetwork - with patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", side_effect=socket.gaierror("Name or service not known")): - with pytest.raises(ValueError, match="Cannot resolve hostname"): - DutNetwork._resolve_ip("no-such-host.invalid") + with ( + patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", side_effect=socket.gaierror("Name or service not known")), + pytest.raises(ValueError, match="Cannot resolve hostname"), + ): + DutNetwork._resolve_ip("no-such-host.invalid") def test_empty_getaddrinfo_result_raises(self): from .driver import DutNetwork - with patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", return_value=[]): - with pytest.raises(ValueError, match="Cannot resolve hostname"): - DutNetwork._resolve_ip("empty-result.invalid") + with ( + patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", return_value=[]), + pytest.raises(ValueError, match="Cannot resolve hostname"), + ): + DutNetwork._resolve_ip("empty-result.invalid") class TestDnsNameIn1to1: @@ -536,7 +542,7 @@ def test_mixed_ip_and_hostname(self, tmp_path: Path): {"mac": "aa:bb:cc:dd:ee:02", "ip": "192.168.100.11", "public_ip": "myhost.example.com"}, ] with patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", return_value=fake_result): - driver, mock_ip, mock_nft, _ = _make_driver( + _driver, mock_ip, _mock_nft, _ = _make_driver( tmp_path, nat_mode="1to1", addresses=leases, ) assert mock_ip.add_ip_alias.call_count == 2 @@ -547,9 +553,11 @@ def test_unresolvable_hostname_raises_during_setup(self, tmp_path: Path): leases = [ {"mac": "aa:bb:cc:dd:ee:01", "ip": "192.168.100.10", "public_ip": "bad-host.invalid"}, ] - with patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", side_effect=socket.gaierror("fail")): - with pytest.raises(ValueError, match="Cannot resolve hostname"): - _make_driver(tmp_path, nat_mode="1to1", addresses=leases) + with ( + patch(f"{_DRIVER_MODULE}.socket.getaddrinfo", side_effect=socket.gaierror("fail")), + pytest.raises(ValueError, match="Cannot resolve hostname"), + ): + _make_driver(tmp_path, nat_mode="1to1", addresses=leases) class TestAddressEntryValidation: @@ -691,7 +699,7 @@ def test_vlan_only_still_includes_upstream_for_unexpected_duts(self, tmp_path: P {"ip": "192.168.100.126", "vlan_id": 906, "public_ip": "203.0.113.2", "public_gateway": "203.0.113.254"}, ] - driver, _, mock_nft, _ = _make_driver( + _driver, _, mock_nft, _ = _make_driver( tmp_path, nat_mode="masquerade", addresses=addrs, ) call_kwargs = mock_nft.apply_masquerade_rules.call_args[1] diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver_integration.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver_integration.py index 431e829ed..a94b47f2c 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver_integration.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_driver_integration.py @@ -134,7 +134,7 @@ def _can_nat_between_namespaces() -> bool: result = _run("ping -c 1 -W 2 172.31.1.1", ns=src_ns, check=False) return result.returncode == 0 - except Exception: + except Exception: # noqa: BLE001 return False finally: for handle in fwd_handles: diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_iproute.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_iproute.py index 0ff92f805..2829530de 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_iproute.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_iproute.py @@ -201,9 +201,8 @@ def test_add_policy_route_raises_on_failure(self): fail = subprocess.CompletedProcess( args=[], returncode=2, stdout="", stderr="Error: some failure\n", ) - with patch.object(iproute, "_run_priv", return_value=fail): - with pytest.raises(RuntimeError, match="some failure"): - iproute.add_policy_route("10.0.0.1", "eth0", 100) + with patch.object(iproute, "_run_priv", return_value=fail), pytest.raises(RuntimeError, match="some failure"): + iproute.add_policy_route("10.0.0.1", "eth0", 100) def test_add_policy_route_rejects_reserved_table(self): with pytest.raises(ValueError, match="reserved"): @@ -230,9 +229,8 @@ def test_add_ip_rule_raises_on_failure(self): fail = subprocess.CompletedProcess( args=[], returncode=2, stdout="", stderr="Error: some failure\n", ) - with patch.object(iproute, "_run_priv", return_value=fail): - with pytest.raises(RuntimeError, match="some failure"): - iproute.add_ip_rule("192.168.100.10", 100) + with patch.object(iproute, "_run_priv", return_value=fail), pytest.raises(RuntimeError, match="some failure"): + iproute.add_ip_rule("192.168.100.10", 100) def test_delete_ip_rule(self): with patch.object(iproute, "_run_priv") as mock: diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_ntp.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_ntp.py index bb5d4295d..c62b85cdc 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_ntp.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_ntp.py @@ -209,7 +209,7 @@ def test_ntp_disabled_by_default(self, tmp_path): mock_nft.apply_ntp_redirect.assert_not_called() def test_ntp_enabled_starts_server_and_redirect(self, tmp_path): - driver, _, mock_nft, _, mock_ntp_cls = _make_driver(tmp_path, local_ntp=True) + _driver, _, mock_nft, _, mock_ntp_cls = _make_driver(tmp_path, local_ntp=True) mock_ntp_cls.assert_called_once_with("192.168.100.1") mock_ntp_cls.return_value.start.assert_called_once() mock_nft.apply_ntp_redirect.assert_called_once_with( diff --git a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_tcpdump.py b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_tcpdump.py index ba4909ce2..dc3e66348 100644 --- a/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_tcpdump.py +++ b/python/packages/jumpstarter-driver-dut-network/jumpstarter_driver_dut_network/test_tcpdump.py @@ -65,39 +65,38 @@ def test_enable_tcpdump_set_true(self, tmp_path: Path): def test_tcpdump_missing_binary_raises_when_enabled(self, tmp_path: Path): """When enable_tcpdump is True but tcpdump is not installed, raise.""" - with pytest.raises(RuntimeError, match="tcpdump"): - with patch(f"{_DRIVER_MODULE}.sys") as mock_sys, \ + with pytest.raises(RuntimeError, match="tcpdump"), patch(f"{_DRIVER_MODULE}.sys") as mock_sys, \ patch(f"{_DRIVER_MODULE}.shutil") as mock_shutil, \ patch(f"{_DRIVER_MODULE}.iproute") as mock_iproute, \ patch(f"{_DRIVER_MODULE}.nftables") as mock_nftables, \ patch(f"{_DRIVER_MODULE}.dnsmasq") as mock_dnsmasq: - mock_sys.platform = "linux" - - def which_side_effect(cmd): - if cmd == "tcpdump": - return None - return "/usr/bin/fake" - - mock_shutil.which.side_effect = which_side_effect - mock_dnsmasq.state_dir_for_interface.return_value = tmp_path - mock_dnsmasq.start.return_value = MagicMock() - mock_iproute.detect_upstream_interface.return_value = "eth-up" - mock_iproute.interface_exists.return_value = False - mock_iproute.get_interface_addresses.return_value = [] - mock_iproute.get_interface_forwarding.return_value = "0" - mock_iproute.get_interface_prefix_len.return_value = 24 - mock_nftables.ensure_filter_forward.return_value = [] - mock_nftables.list_rules.return_value = "" - mock_nftables._table_name_for.return_value = "jumpstarter_eth_dut" - DutNetwork( - interface="eth-dut", - subnet="192.168.100.0/24", - gateway_ip="192.168.100.1", - upstream_interface="eth-up", - nat_mode="masquerade", - enable_tcpdump=True, - state_dir=str(tmp_path), - ) # type: ignore[missing-argument] + mock_sys.platform = "linux" + + def which_side_effect(cmd): + if cmd == "tcpdump": + return None + return "/usr/bin/fake" + + mock_shutil.which.side_effect = which_side_effect + mock_dnsmasq.state_dir_for_interface.return_value = tmp_path + mock_dnsmasq.start.return_value = MagicMock() + mock_iproute.detect_upstream_interface.return_value = "eth-up" + mock_iproute.interface_exists.return_value = False + mock_iproute.get_interface_addresses.return_value = [] + mock_iproute.get_interface_forwarding.return_value = "0" + mock_iproute.get_interface_prefix_len.return_value = 24 + mock_nftables.ensure_filter_forward.return_value = [] + mock_nftables.list_rules.return_value = "" + mock_nftables._table_name_for.return_value = "jumpstarter_eth_dut" + DutNetwork( + interface="eth-dut", + subnet="192.168.100.0/24", + gateway_ip="192.168.100.1", + upstream_interface="eth-up", + nat_mode="masquerade", + enable_tcpdump=True, + state_dir=str(tmp_path), + ) # type: ignore[missing-argument] def test_tcpdump_missing_binary_ok_when_disabled(self, tmp_path: Path): """When enable_tcpdump is False, missing tcpdump binary is fine.""" @@ -275,11 +274,10 @@ def test_tcpdump_cleanup_on_cancel(self, tmp_path: Path): mock_proc.wait = AsyncMock() with patch(f"{_DRIVER_MODULE}.asyncio.subprocess.create_subprocess_exec", - return_value=mock_proc): - with pytest.raises(asyncio.CancelledError): - asyncio.run( - _consume_async_gen(driver.tcpdump()) - ) + return_value=mock_proc), pytest.raises(asyncio.CancelledError): + asyncio.run( + _consume_async_gen(driver.tcpdump()) + ) # Verify the process was terminated mock_proc.terminate.assert_called_once() diff --git a/python/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py b/python/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py index 0726188dd..35454ff8d 100644 --- a/python/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py +++ b/python/packages/jumpstarter-driver-energenie/jumpstarter_driver_energenie/driver.py @@ -31,9 +31,11 @@ def login(self): try: response = requests.post(login_url, data={"pw": self.password}, timeout=10) return response.status_code == 200 - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, - requests.exceptions.RequestException) as e: - self.logger.error(f"Login failed: {str(e)}") + except ( # pragma: no cover + requests.exceptions.ConnectionError, requests.exceptions.Timeout, + requests.exceptions.RequestException + ) as e: + self.logger.error(f"Login failed: {e!s}") return False def __post_init__(self): @@ -71,9 +73,11 @@ def set_switch(self, switch_number, state): if response.status_code != 200: self.logger.error(f"Set switch {switch_number} to {state} state failed!") return False - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, - requests.exceptions.RequestException) as e: - self.logger.error(f"Set switch failed: {str(e)}") + except ( # pragma: no cover + requests.exceptions.ConnectionError, requests.exceptions.Timeout, + requests.exceptions.RequestException + ) as e: + self.logger.error(f"Set switch failed: {e!s}") return False self.logger.debug(f"Set switch {switch_number} to {state} state") diff --git a/python/packages/jumpstarter-driver-esp32/jumpstarter_driver_esp32/driver.py b/python/packages/jumpstarter-driver-esp32/jumpstarter_driver_esp32/driver.py index ed5688aae..0631f4843 100644 --- a/python/packages/jumpstarter-driver-esp32/jumpstarter_driver_esp32/driver.py +++ b/python/packages/jumpstarter-driver-esp32/jumpstarter_driver_esp32/driver.py @@ -1,3 +1,4 @@ +import contextlib import gc import os import tempfile @@ -54,13 +55,11 @@ def _connect_esp(self): def _close_esp(self, esp): port_path = None - try: + with contextlib.suppress(Exception): if hasattr(esp, "_port") and esp._port: port_path = getattr(esp._port, "portstr", None) or getattr(esp._port, "name", None) esp._port.close() esp._port = None - except Exception: - pass if port_path: self._force_release_port(port_path) @@ -95,10 +94,9 @@ def _force_release_port(self, port: str): async def flash(self, source, target: str | None = None): address = int(target or "0", 0) with _temporary_filename() as filename: - async with await FileWriteStream.from_path(filename) as stream: - async with self.resource(source) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(filename) as stream, self.resource(source) as res: + async for chunk in res: + await stream.send(chunk) def _do_flash(): esp = self._connect_esp() @@ -130,10 +128,9 @@ def _do_read(): await to_thread.run_sync(_do_read) - async with await FileReadStream.from_path(filename) as stream: - async with self.resource(target) as res: - async for chunk in stream: - await res.send(chunk) + async with await FileReadStream.from_path(filename) as stream, self.resource(target) as res: + async for chunk in stream: + await res.send(chunk) @export def get_chip_info(self) -> dict[str, str]: diff --git a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py index 9bc8cbd1d..e6620d808 100644 --- a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py +++ b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py @@ -1047,7 +1047,7 @@ def _transfer_bg_thread( self.logger.info(f"Uploading image to storage: {filename}") to_storage.write_from_path(filename, src_path, src_operator) - metadata, metadata_json = self._create_metadata_and_json( + _metadata, metadata_json = self._create_metadata_and_json( src_operator, src_path, file_hash, original_url, headers ) metadata_file = filename + ".metadata" @@ -1086,7 +1086,7 @@ def _create_metadata_and_json( "etag": metadata.etag, } ) - except Exception as e: + except Exception as e: # noqa: BLE001 # TODO(bennyz): remove when opendal issue is sorted out # https://github.com/apache/opendal/discussions/6418 # fallback to request if we're using a custom certificate @@ -1106,7 +1106,7 @@ def _create_metadata_and_json( metadata_dict.update(http_metadata) self.logger.info("Successfully got HTTP metadata using requests fallback") - except Exception as http_e: + except Exception as http_e: # noqa: BLE001 self.logger.error(f"Error getting HTTP metadata with requests fallback: {http_e}") else: self.logger.error(f"Error getting metadata: {e}") @@ -1253,23 +1253,20 @@ def _busybox(self): def use_dtb(self, path: PathBuf, operator: Operator | None = None): """Use DTB file""" if operator is None: - path, operator, operator_scheme = operator_for_path(path) + path, operator, _operator_scheme = operator_for_path(path) - ... def use_initram(self, path: PathBuf, operator: Operator | None = None): """Use initramfs file""" if operator is None: - path, operator, operator_scheme = operator_for_path(path) + path, operator, _operator_scheme = operator_for_path(path) - ... def use_kernel(self, path: PathBuf, operator: Operator | None = None): """Use kernel file""" if operator is None: - path, operator, operator_scheme = operator_for_path(path) + path, operator, _operator_scheme = operator_for_path(path) - ... @property def manifest(self): @@ -1317,7 +1314,7 @@ def _parse_headers(self, headers: list[str]) -> dict[str, str]: def _prepare_headers(self, headers: dict[str, str] | None, bearer_token: str | None) -> str: all_headers = headers.copy() if headers else {} if bearer_token: - if any(k.lower() == "authorization" for k in all_headers.keys()): + if any(k.lower() == "authorization" for k in all_headers): self.logger.warning("Authorization header provided - ignoring bearer token") else: all_headers["Authorization"] = f"Bearer {bearer_token}" @@ -1348,7 +1345,7 @@ def _validate_bearer_token(self, token: str | None) -> str | None: def _resolve_oci_credentials( self, path: PathBuf, username: str | None, password: str | None - ) -> "OciCredentials": + ) -> OciCredentials: from jumpstarter.common.oci import OciCredentials, resolve_oci_credentials if username is not None or password is not None or str(path).startswith("oci://"): @@ -1486,7 +1483,6 @@ def cli(self): @driver_click_group(self) def base(): """Software-defined flasher interface""" - pass @base.command() @click.argument("file", required=False) diff --git a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client_test.py b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client_test.py index 6ae2fbc91..18d986db8 100644 --- a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client_test.py +++ b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client_test.py @@ -458,7 +458,7 @@ def test_categorize_exception_preserves_cause_for_wrapped_exceptions(): """Test that wrapped unknown exceptions preserve the cause chain""" client = MockFlasherClient() - original = IOError("File not found") + original = OSError("File not found") result = client._categorize_exception(original) assert isinstance(result, FlashRetryableError) diff --git a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver.py b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver.py index ca72ba8a7..76b201dee 100644 --- a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver.py +++ b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver.py @@ -77,7 +77,7 @@ async def get_cacert(self) -> str | None: """Return the CA certificate contents if configured""" if not self.cacert: return None - with open(self.cacert) as f: + with open(self.cacert) as f: # noqa: ASYNC230 return f.read() @export @@ -187,7 +187,7 @@ async def _get_file_path(self, filename) -> Path: @export async def get_flasher_manifest_yaml(self) -> str: """Return the manifest yaml as a string for client side consumption""" - with open(await self._get_file_path(self.manifest)) as f: + with open(await self._get_file_path(self.manifest)) as f: # noqa: ASYNC230 return f.read() async def get_flasher_manifest(self) -> FlasherBundleManifestV1Alpha1: diff --git a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver_test.py b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver_test.py index f2bbb932a..0fe42ed03 100644 --- a/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver_test.py +++ b/python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/driver_test.py @@ -137,7 +137,5 @@ def test_drivers_flashers_get_bootcmd_variant_switching(complete_flasher): def test_drivers_flashers_get_bootcmd_invalid_variant(complete_flasher): """Test that get_bootcmd raises DriverInvalidArgument for invalid DTB variant""" - with serve(complete_flasher) as client: - # Set an invalid variant - with pytest.raises(DriverInvalidArgument): - client.call("use_dtb_variant", "noexists") + with serve(complete_flasher) as client, pytest.raises(DriverInvalidArgument): + client.call("use_dtb_variant", "noexists") diff --git a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/client.py b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/client.py index 3c30f5c54..c12d7514b 100644 --- a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/client.py +++ b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/client.py @@ -38,7 +38,6 @@ def cli(self): @driver_click_group(self) def gpio(): """GPIO power control commands.""" - pass for cmd in super().cli().commands.values(): gpio.add_command(cmd) @@ -83,7 +82,6 @@ def cli(self): @driver_click_group(self) def gpio(): """GPIO input commands.""" - pass @gpio.command() def read(): diff --git a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver.py b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver.py index 88e4159ef..6f8806691 100644 --- a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver.py +++ b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import time from collections.abc import Generator from dataclasses import dataclass, field @@ -39,14 +40,12 @@ def __post_init__(self): if hasattr(super(), "__post_init__"): super().__post_init__() - def close(self): - try: + def close(self): # pragma: no cover + with contextlib.suppress(Exception): if hasattr(self, "_line") and self._line: self._line.release() if hasattr(self, "_chip") and self._chip: self._chip.close() - except Exception: - pass super().close() @export diff --git a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver_test.py b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver_test.py index 91555b390..ebfd2cdec 100644 --- a/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver_test.py +++ b/python/packages/jumpstarter-driver-gpiod/jumpstarter_driver_gpiod/driver_test.py @@ -141,7 +141,7 @@ class TestDriverMethods: def test_digital_output_initialization(self, mock_gpiod): """Test DigitalOutput driver initialization with mocked gpiod""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) # Import and test the driver from jumpstarter_driver_gpiod.driver import DigitalOutput @@ -164,7 +164,7 @@ def test_digital_output_initialization(self, mock_gpiod): def test_digital_input_initialization(self, mock_gpiod): """Test DigitalInput driver initialization with mocked gpiod""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=17) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=17) # Import and test the driver from jumpstarter_driver_gpiod.driver import DigitalInput @@ -187,7 +187,7 @@ def test_digital_input_initialization(self, mock_gpiod): def test_digital_output_methods(self, mock_gpiod): """Test DigitalOutput driver methods with mocked gpiod""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) # Import and test the driver from jumpstarter_driver_gpiod.driver import DigitalOutput @@ -217,7 +217,7 @@ def test_digital_output_methods(self, mock_gpiod): def test_digital_input_methods(self, mock_gpiod): """Test DigitalInput driver methods with mocked gpiod""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=17) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=17) # Import and test the driver from jumpstarter_driver_gpiod.driver import DigitalInput @@ -299,7 +299,7 @@ def test_chip_open_error(self, mock_gpiod): def test_line_request_error(self, mock_gpiod): """Test handling of line request error""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) # Set up the error condition mock_chip.request_lines.side_effect = Exception("Cannot request line") @@ -313,7 +313,7 @@ def test_line_request_error(self, mock_gpiod): def test_invalid_drive_value(self, mock_gpiod): """Test initialization with invalid drive value""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) from jumpstarter_driver_gpiod.driver import DigitalOutput @@ -324,7 +324,7 @@ def test_invalid_drive_value(self, mock_gpiod): def test_invalid_bias_value(self, mock_gpiod): """Test initialization with invalid bias value""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) from jumpstarter_driver_gpiod.driver import DigitalOutput @@ -335,7 +335,7 @@ def test_invalid_bias_value(self, mock_gpiod): def test_invalid_initial_value(self, mock_gpiod): """Test initialization with invalid initial value""" # Set up common mocks - mock_chip, mock_line, mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) + _mock_chip, _mock_line, _mock_settings = setup_gpiod_mocks(mock_gpiod, line_number=18) from jumpstarter_driver_gpiod.driver import DigitalOutput diff --git a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py index 999e8017d..eb474e122 100644 --- a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py +++ b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py @@ -1,6 +1,7 @@ import json +from collections.abc import Generator from dataclasses import dataclass, field -from typing import Any, Generator, Optional +from typing import Any from urllib.parse import urlsplit import requests @@ -23,11 +24,11 @@ def _json_path(data: Any, path: str) -> Any: class HttpEndpointConfig: url: str = field() method: str = field(default='GET') - data: Optional[str] = field(default=None) + data: str | None = field(default=None) # For read endpoints: dotted JSON paths to the values (e.g. "emeter.voltage"). # When unset, read() looks for top-level "voltage"/"current" keys. - voltage_path: Optional[str] = field(default=None) - current_path: Optional[str] = field(default=None) + voltage_path: str | None = field(default=None) + current_path: str | None = field(default=None) @dataclass(kw_only=True) @@ -44,8 +45,8 @@ class HttpDigestAuth: @dataclass(kw_only=True) class HttpAuthConfig: - basic: Optional[HttpBasicAuth] = field(default=None) - digest: Optional[HttpDigestAuth] = field(default=None) + basic: HttpBasicAuth | None = field(default=None) + digest: HttpDigestAuth | None = field(default=None) @dataclass(kw_only=True) @@ -58,9 +59,9 @@ class HttpPower(PowerInterface, Driver): # HTTP endpoints configuration power_on: HttpEndpointConfig = field() power_off: HttpEndpointConfig = field() - power_read: Optional[HttpEndpointConfig] = field(default=None) + power_read: HttpEndpointConfig | None = field(default=None) # Authentication configuration - auth: Optional[HttpAuthConfig] = field(default=None) + auth: HttpAuthConfig | None = field(default=None) def __post_init__(self): if hasattr(super(), "__post_init__"): @@ -88,7 +89,7 @@ def __post_init__(self): # shared across origins, whose nonces and realms are unrelated. self._digest_auth: dict[tuple[str, str], requests.auth.HTTPDigestAuth] = {} - def _build_auth(self, url: str) -> Optional[requests.auth.AuthBase]: + def _build_auth(self, url: str) -> requests.auth.AuthBase | None: """Build the requests auth handler for ``url`` from the configured credentials""" if self.auth is None: return None @@ -159,7 +160,7 @@ def read(self) -> Generator[PowerReading, None, None]: yield PowerReading(voltage=voltage, current=current) @staticmethod - def _extract_reading(data: Any, path: Optional[str], default_key: str) -> float: + def _extract_reading(data: Any, path: str | None, default_key: str) -> float: """Pull one numeric reading. A configured path that's missing is an error; a missing default key just means the device doesn't report it (0.0).""" key = path or default_key diff --git a/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver.py b/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver.py index d05eb343b..9c72f05a8 100644 --- a/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver.py +++ b/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver.py @@ -1,6 +1,5 @@ import os from dataclasses import dataclass, field -from typing import Optional import anyio import anyio.from_thread @@ -28,7 +27,7 @@ class HttpServer(Driver): timeout: int = field(default=600) remove_created_on_close: bool = True # Clean up temporary web files by default app: web.Application = field(init=False, default_factory=web.Application) - runner: Optional[web.AppRunner] = field(init=False, default=None) + runner: web.AppRunner | None = field(init=False, default=None) _bound_port: int = field(init=False, default=0) def __post_init__(self): @@ -68,7 +67,7 @@ async def start(self): self.logger.warning("Cleaning up stale HTTP server runner before starting.") try: await self.runner.cleanup() - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 self.logger.warning(f"Failed to clean up stale runner: {e}") self.runner = None self._bound_port = 0 @@ -139,7 +138,7 @@ def close(self): if self.runner: try: anyio.from_thread.run(self._async_cleanup) - except Exception: + except Exception: # noqa: BLE001 self._force_close_sockets() finally: self.runner = None @@ -160,7 +159,7 @@ def _force_close_sockets(self): if hasattr(site, "_server") and site._server: site._server.close() self.logger.info("HTTP server sockets force-closed.") - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 self.logger.warning(f"HTTP server force-close failed: {e}") async def _async_cleanup(self): diff --git a/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver_test.py b/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver_test.py index 8da4a36fb..ecd4e1df0 100644 --- a/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver_test.py +++ b/python/packages/jumpstarter-driver-http/jumpstarter_driver_http/driver_test.py @@ -34,11 +34,10 @@ async def test_http_server(http, tmp_path): files = list(http.storage.list("/")) assert filename in files - async with aiohttp.ClientSession() as session: - async with session.get(uploaded_url) as response: - assert response.status == 200 - retrieved_content = await response.read() - assert retrieved_content == test_content + async with aiohttp.ClientSession() as session, session.get(uploaded_url) as response: + assert response.status == 200 + retrieved_content = await response.read() + assert retrieved_content == test_content http.storage.delete(filename) @@ -65,23 +64,22 @@ async def test_opendal_tracking_on_http_server_close(tmp_path, unused_tcp_port, test_content = b"test content for tracking" # Set up logging to capture debug messages - with caplog.at_level(logging.DEBUG): - with serve(HttpServer(root_dir=str(tmp_path), port=unused_tcp_port)) as client: - client.start() + with caplog.at_level(logging.DEBUG), serve(HttpServer(root_dir=str(tmp_path), port=unused_tcp_port)) as client: + client.start() - # Write a file through the HTTP server (which uses OpenDAL internally) - (tmp_path / "src").write_bytes(test_content) - client.put_file(filename, tmp_path / "src") + # Write a file through the HTTP server (which uses OpenDAL internally) + (tmp_path / "src").write_bytes(test_content) + client.put_file(filename, tmp_path / "src") - # Verify the file was written - files = list(client.storage.list("/")) - assert filename in files + # Verify the file was written + files = list(client.storage.list("/")) + assert filename in files - # Get the tracking info before close - created_resources = client.storage.get_created_resources() - assert filename in created_resources + # Get the tracking info before close + created_resources = client.storage.get_created_resources() + assert filename in created_resources - client.stop() + client.stop() # When exiting the context manager, HttpServer.close() is called, # which calls super().close(), which calls OpenDAL.close() @@ -124,9 +122,8 @@ async def test_http_server_close_releases_port(tmp_path, unused_tcp_port): url = client.get_url() assert str(unused_tcp_port) in url - async with aiohttp.ClientSession() as session: - async with session.get(f"{url}/test.txt") as response: - assert response.status == 200 + async with aiohttp.ClientSession() as session, session.get(f"{url}/test.txt") as response: + assert response.status == 200 client.stop() @@ -136,9 +133,8 @@ async def test_http_server_close_releases_port(tmp_path, unused_tcp_port): url = client.get_url() assert str(unused_tcp_port) in url - async with aiohttp.ClientSession() as session: - async with session.get(f"{url}/test.txt") as response: - assert response.status == 200 + async with aiohttp.ClientSession() as session, session.get(f"{url}/test.txt") as response: + assert response.status == 200 client.stop() @@ -157,9 +153,8 @@ async def test_http_server_port_zero(tmp_path): url = client.get_url() assert str(port) in url - async with aiohttp.ClientSession() as session: - async with session.get(f"{url}/test.txt") as response: - assert response.status == 200 + async with aiohttp.ClientSession() as session, session.get(f"{url}/test.txt") as response: + assert response.status == 200 client.stop() @@ -272,6 +267,7 @@ async def test_async_cleanup_error_path(tmp_path, unused_tcp_port): await server._async_cleanup() # Clean up the real runner so the port is released + assert real_runner is not None await real_runner.cleanup() @@ -304,6 +300,7 @@ async def fake_async_cleanup(): assert server._bound_port == 0 # Clean up the real runner so the port is released + assert real_runner is not None await real_runner.cleanup() diff --git a/python/packages/jumpstarter-driver-iscsi/examples/iscsi.py b/python/packages/jumpstarter-driver-iscsi/examples/iscsi.py old mode 100644 new mode 100755 diff --git a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py index 66a1039e8..ea07ea92c 100644 --- a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py +++ b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py @@ -3,7 +3,7 @@ import os from dataclasses import dataclass from tempfile import NamedTemporaryFile -from typing import Any, Dict, List, Optional +from typing import Any from urllib.parse import urlparse import click @@ -75,18 +75,17 @@ def _normalized_name_from_file(self, path: str) -> str: if base.endswith(ext): base = base[: -len(ext)] break - if base.endswith(".img"): - base = base[: -len(".img")] + base = base.removesuffix(".img") return base or "image" def _get_src_and_operator( self, file: str, headers: tuple[str, ...] - ) -> tuple[str, Optional[Operator], Optional[str]]: + ) -> tuple[str, Operator | None, str | None]: from jumpstarter_driver_opendal.client import operator_for_path if file.startswith(("http://", "https://")): if headers: - header_map: Dict[str, str] = {} + header_map: dict[str, str] = {} for h in headers: if ":" not in h: raise click.ClickException(f"Invalid header format: {h!r}. Expected 'Key: Value'.") @@ -98,7 +97,7 @@ def _get_src_and_operator( header_map[key] = value parsed = urlparse(file) - tf = NamedTemporaryFile( + tf = NamedTemporaryFile( # noqa: SIM115 prefix="jumpstarter-iscsi-", suffix=os.path.basename(parsed.path), delete=False, @@ -155,7 +154,7 @@ def remove_lun(self, name: str): """ self.call("remove_lun", name) - def list_luns(self) -> List[Dict[str, Any]]: + def list_luns(self) -> list[dict[str, Any]]: """ List all configured LUNs @@ -164,7 +163,7 @@ def list_luns(self) -> List[Dict[str, Any]]: """ return self.call("list_luns") - def _calculate_file_hash(self, file_path: str, operator: Optional[Operator] = None) -> str: + def _calculate_file_hash(self, file_path: str, operator: Operator | None = None) -> str: """Calculate SHA256 hash of a file""" if operator is None: hash_obj = hashlib.sha256() @@ -183,7 +182,7 @@ def _calculate_file_hash(self, file_path: str, operator: Optional[Operator] = No hash_obj.update(chunk) return hash_obj.hexdigest() - def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Optional[Operator] = None) -> bool: + def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Operator | None = None) -> bool: """Check if source and destination files are identical""" try: if not self.storage.exists(dst_path): @@ -214,11 +213,11 @@ def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Optional[O return src_hash == dst_hash - except Exception: + except Exception: # noqa: BLE001 return False def _should_skip_upload( - self, src_path: str, dst_path: str, operator: Optional[Operator], force_upload: bool, algo: Optional[str] + self, src_path: str, dst_path: str, operator: Operator | None, force_upload: bool, algo: str | None ) -> bool: if force_upload or algo is not None or not self.storage.exists(dst_path): return False @@ -232,7 +231,7 @@ def _should_skip_upload( return False def _upload_file( - self, src_path: str, dst_name: str, dst_path: str, operator: Optional[Operator], algo: Optional[str] + self, src_path: str, dst_name: str, dst_path: str, operator: Operator | None, algo: str | None ): if algo is None: self.logger.info(f"Uploading {src_path} to {dst_path}...") @@ -253,7 +252,7 @@ def upload_image( dst_name: str, src: PathBuf, size_mb: int = 0, - operator: Optional[Operator] = None, + operator: Operator | None = None, force_upload: bool = False, ) -> str: """ @@ -289,7 +288,7 @@ def upload_image( try: dst_stat = self.storage.stat(dst_path) size_mb = max(1, int(dst_stat.content_length) // (1024 * 1024)) - except Exception: + except Exception: # noqa: BLE001 size_mb = 1 self.add_lun(dst_name, dst_path, size_mb) @@ -314,14 +313,12 @@ def cli(self): multiple=True, help="Custom HTTP header in 'Key: Value' format. Repeatable.", ) - def serve(file: str, name: Optional[str], size_mb: int, force_upload: bool, headers: tuple[str, ...]): + def serve(file: str, name: str | None, size_mb: int, force_upload: bool, headers: tuple[str, ...]): """Serve an image as an iSCSI LUN from a local path or HTTP(S) URL.""" self.start() - try: + with contextlib.suppress(Exception): self.call("clear_all_luns") - except Exception: - pass if not name: candidate = urlparse(file).path if file.startswith(("http://", "https://")) else file diff --git a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py index d86b6eb3f..ec9cdbd81 100644 --- a/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py +++ b/python/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py @@ -6,7 +6,7 @@ from contextlib import suppress from dataclasses import dataclass, field from tempfile import NamedTemporaryFile -from typing import Any, Dict, List, Optional +from typing import Any from jumpstarter_driver_opendal.driver import Opendal from pydantic import validate_call @@ -18,13 +18,11 @@ class ISCSIError(Exception): """Base exception for iSCSI server errors""" - pass class ConfigurationError(ISCSIError): """Error in iSCSI configuration""" - pass @@ -48,14 +46,14 @@ class ISCSI(Driver): host: str = field(default="") port: int = 3260 remove_created_on_close: bool = False # Keep disk images persistent by default - block_device_allowlist: List[str] = field(default_factory=list) + block_device_allowlist: list[str] = field(default_factory=list) - _rtsroot: Optional[RTSRoot] = field(init=False, default=None) - _target: Optional[Target] = field(init=False, default=None) - _tpg: Optional[TPG] = field(init=False, default=None) - _storage_objects: Dict[str, Any] = field(init=False, default_factory=dict) - _portals: List[NetworkPortal] = field(init=False, default_factory=list) - _luns: Dict[str, LUN] = field(init=False, default_factory=dict) + _rtsroot: RTSRoot | None = field(init=False, default=None) + _target: Target | None = field(init=False, default=None) + _tpg: TPG | None = field(init=False, default=None) + _storage_objects: dict[str, Any] = field(init=False, default_factory=dict) + _portals: list[NetworkPortal] = field(init=False, default_factory=list) + _luns: dict[str, LUN] = field(init=False, default_factory=dict) def __post_init__(self): if hasattr(super(), "__post_init__"): @@ -87,7 +85,7 @@ def get_default_ip(self): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)) return s.getsockname()[0] - except Exception: + except Exception: # noqa: BLE001 self.logger.warning("Could not determine default IP address, falling back to 0.0.0.0") return "0.0.0.0" @@ -121,11 +119,11 @@ def _setup_target(self): target_exists = True self.logger.info(f"Using existing target: {self._iqn}") if target.tpgs: - self._tpg = list(target.tpgs)[0] + self._tpg = next(iter(target.tpgs)) else: self._tpg = TPG(self._target, 1) break - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.warning(f"Error checking for existing target: {e}") if not target_exists: @@ -148,7 +146,7 @@ def _setup_network_portal(self): if portal.ip_address == self.host and portal.port == self.port: portal_exists = True break - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.warning(f"Error checking for existing portal: {e}") if not portal_exists: @@ -167,7 +165,7 @@ def _clear_tpg_luns(self): for lun in list(self._tpg.luns): # type: ignore[attr-defined] try: storage_obj = getattr(lun, "storage_object", None) - except Exception: + except Exception: # noqa: BLE001 storage_obj = None try: @@ -176,7 +174,7 @@ def _clear_tpg_luns(self): if storage_obj is not None: with suppress(Exception): storage_obj.delete() - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.warning(f"Failed clearing existing LUNs from TPG: {e}") def _cleanup_orphan_storage_objects(self): @@ -184,15 +182,13 @@ def _cleanup_orphan_storage_objects(self): try: root_abs = os.path.abspath(self.root_dir) for so in list(self._rtsroot.storage_objects): # type: ignore[attr-defined] - try: + with suppress(Exception): if isinstance(so, FileIOStorageObject): udev_path = os.path.abspath(getattr(so, "udev_path", "")) if udev_path.startswith(root_abs + os.sep) or udev_path == root_abs: with suppress(Exception): so.delete() - except Exception: - continue - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.debug(f"No orphan storage object cleanup performed: {e}") @export @@ -485,7 +481,7 @@ def remove_lun(self, name: str): @export @validate_call - def list_luns(self) -> List[Dict[str, Any]]: + def list_luns(self) -> list[dict[str, Any]]: """List all configured LUNs Returns: @@ -508,6 +504,6 @@ def close(self): """Clean up resources when the driver is closed""" try: self.stop() - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.error(f"Error during cleanup: {e}") super().close() diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py b/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py old mode 100644 new mode 100755 index 7c0ad3630..3903e9519 --- a/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py +++ b/python/packages/jumpstarter-driver-mitmproxy/demo/backend.py @@ -31,12 +31,12 @@ class DemoBackendHandler(BaseHTTPRequestHandler): """Handles GET/POST for the four demo API endpoints.""" # Suppress the default stderr log line per request - def log_message(self, format, *args): # noqa: A002 + def log_message(self, format, *args): pass # ── routes ──────────────────────────────────────────────── - def do_GET(self): # noqa: N802 + def do_GET(self): if self.path == "/api/v1/status": self._send_json(200, { "device_id": "DUT-REAL-001", @@ -67,7 +67,7 @@ def do_GET(self): # noqa: N802 "source": "real-backend", }) - def do_POST(self): # noqa: N802 + def do_POST(self): if self.path == "/api/v1/telemetry": # Read (and discard) the request body length = int(self.headers.get("Content-Length", 0)) diff --git a/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py b/python/packages/jumpstarter-driver-mitmproxy/demo/dut_simulator.py old mode 100644 new mode 100755 diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py index 48f1455ef..dc2d5354e 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/_template.py @@ -99,8 +99,8 @@ def websocket_message(self, flow: http.HTTPFlow, config: dict): ) # To echo back to client with modification, use: - # ctx.master.commands.call( # noqa: ERA001 - # "inject.websocket", flow, True, # noqa: ERA001 + # ctx.master.commands.call( + # "inject.websocket", flow, True, # b'{"type": "echo", "data": ...}', # noqa: ERA001 # ) # noqa: ERA001 diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py index 7c5d6831f..ec6fb4ec8 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/data_stream_websocket.py @@ -202,7 +202,7 @@ async def _push_telemetry( except asyncio.CancelledError: ctx.log.debug("Telemetry push task cancelled") - except Exception as e: + except Exception as e: # noqa: BLE001 ctx.log.error(f"Telemetry push error: {e}") diff --git a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py index fd7c19dff..8146fec8d 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py +++ b/python/packages/jumpstarter-driver-mitmproxy/examples/addons/mjpeg_stream.py @@ -113,7 +113,7 @@ def _generate_test_pattern_jpeg( timestamp = time.strftime("%H:%M:%S") try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 24) - except (OSError, IOError): + except OSError: font = ImageFont.load_default() text = f"MOCK CAMERA Frame: {frame_num:06d} {timestamp}" diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py index 63d64c40e..5b00c85a9 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/bundled_addon.py @@ -33,9 +33,9 @@ import socket as _socket import time from collections import defaultdict -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, ClassVar from urllib.parse import parse_qs, urlparse from mitmproxy import ctx, http @@ -158,12 +158,12 @@ class TemplateEngine: # Class-level counter state, intentionally shared across instances. # Counters persist across config reloads so {{counter(name)}} values # increase monotonically within a proxy session. - _counters: dict[str, int] = defaultdict(int) + _counters: ClassVar[dict[str, int]]= defaultdict(int) # Only these environment variables may be read via {{env(...)}} templates. # This prevents mock configs from leaking secrets such as credentials or # API keys. Extend this set when new env-driven behaviour is needed. - ALLOWED_ENV_VARS: set[str] = { + ALLOWED_ENV_VARS: ClassVar[set[str]]= { "JUMPSTARTER_ENV", "JUMPSTARTER_DEVICE_ID", "JUMPSTARTER_MOCK_PROFILE", @@ -234,8 +234,8 @@ def _evaluate( @classmethod def _evaluate_builtin(cls, expr: str) -> Any | None: """Evaluate built-in expressions (no flow needed).""" - if expr == "now_iso": - return datetime.now(timezone.utc).isoformat() + if expr == "now_iso": # pragma: no cover + return datetime.now(UTC).isoformat() if expr == "now_epoch": return int(time.time()) if expr == "uuid": @@ -288,7 +288,7 @@ def _eval_env(cls, expr: str) -> str: ctx.log.warn(f"env() template blocked: variable '{var_name}' is not in ALLOWED_ENV_VARS") return "" - _BUILTIN_DISPATCH: list[tuple[str, Any]] = [ + _BUILTIN_DISPATCH: ClassVar[list[tuple[str, Any]]]= [ ("random_int(", _eval_random_int), ("random_float(", _eval_random_float), ("random_choice(", _eval_random_choice), @@ -340,7 +340,7 @@ def _eval_request_path_segment(cls, expr: str, flow: http.HTTPFlow) -> str: except (IndexError, ValueError): return "" - _FLOW_DISPATCH: list[tuple[str, Any]] = [ + _FLOW_DISPATCH: ClassVar[list[tuple[str, Any]]]= [ ("request_header(", _eval_request_header), ("request_body_json(", _eval_request_body_json), ("request_query(", _eval_request_query), @@ -428,7 +428,7 @@ def get_handler(self, name: str) -> Any | None: f"Addon {name} missing Handler class" ) return None - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 ctx.log.error(f"Failed to load addon {name}: {e}") return None @@ -475,9 +475,8 @@ def send_event(self, event: dict): """Send a JSON event line. Reconnects once on failure.""" payload = json.dumps(event) + "\n" for attempt in range(2): - if self._sock is None: - if not self._connect(): - return + if self._sock is None and not self._connect(): # pragma: no cover + return try: self._sock.sendall(payload.encode()) return @@ -597,7 +596,7 @@ def _load_config(self): f"(files: {self.files_dir}, addons: {addons_dir})" ) - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 ctx.log.error(f"Failed to load config: {e}") def _load_state(self): @@ -614,7 +613,7 @@ def _load_state(self): self._state = json.load(f) self._state_mtime = mtime - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 ctx.log.error(f"Failed to load state: {e}") # ── Request matching ──────────────────────────────────── @@ -691,10 +690,9 @@ def _collect_wildcard_matches( or (is_websocket and pat_method == "WEBSOCKET") ) - if match_method and path.startswith(prefix): - if self._matches_conditions(ep, flow): - priority = ep.get("priority", 0) - candidates.append((priority, pattern, ep)) + if match_method and path.startswith(prefix) and self._matches_conditions(ep, flow): # pragma: no cover + priority = ep.get("priority", 0) + candidates.append((priority, pattern, ep)) def _matches_conditions( self, endpoint: dict, flow: http.HTTPFlow, @@ -1025,7 +1023,7 @@ def _handle_addon(self, flow: http.HTTPFlow, endpoint: dict): ctx.log.warn( f"Addon {addon_name} did not handle request" ) - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 ctx.log.error(f"Addon {addon_name} error: {e}") flow.response = http.Response.make( 500, @@ -1090,7 +1088,7 @@ def websocket_message(self, flow: http.HTTPFlow): if handler and hasattr(handler, "websocket_message"): try: handler.websocket_message(flow, endpoint.get("addon_config", {})) - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 ctx.log.error( f"Addon {addon_name} websocket error: {e}" ) diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py index a33fda9f3..cd644beeb 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/client.py @@ -39,11 +39,12 @@ def test_update_check(client): import base64 import fnmatch import json +from collections.abc import Generator from contextlib import contextmanager from ipaddress import IPv6Address, ip_address from pathlib import Path from threading import Event -from typing import Any, Generator +from typing import Any import click import yaml @@ -67,7 +68,7 @@ class CaptureContext: assert cap.requests # frozen snapshot """ - def __init__(self, client: "MitmproxyClient"): + def __init__(self, client: MitmproxyClient): self._client = client self._snapshot: list[dict] | None = None @@ -175,7 +176,6 @@ def cli(self): # noqa: C901 @driver_click_group(self) def base(): """Mitmproxy driver""" - pass # ── Lifecycle commands ───────────────────────────────── @@ -242,7 +242,6 @@ def status_cmd(): @base.group("mock") def mock_group(): """Mock endpoint management.""" - pass @mock_group.command("list") def mock_list_cmd(): @@ -295,7 +294,6 @@ def mock_load_cmd(scenario_file: str): @base.group("flow") def flow_group(): """Recorded flow file management.""" - pass @flow_group.command("list") def flow_list_cmd(): @@ -456,11 +454,9 @@ def web_cmd(address: str, port: int): from jumpstarter.common import TemporaryTcpListener from jumpstarter.streams.common import forward_stream - async def handler(client, method, conn): - async with conn: - async with client.stream_async(method) as stream: - async with forward_stream(conn, stream): - pass + async def handler(client, method, conn): # pragma: no cover + async with conn, client.stream_async(method) as stream, forward_stream(conn, stream): + pass @blocking @asynccontextmanager diff --git a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py index 20e0a45c0..3c6ac5604 100644 --- a/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py +++ b/python/packages/jumpstarter-driver-mitmproxy/jumpstarter_driver_mitmproxy/driver.py @@ -216,8 +216,7 @@ def _write_captured_file( clean = "/".join(p for p in clean.split("/") if p not in ("", ".", "..")) if not clean: clean = "root" - if clean.endswith(ext): - clean = clean[:-len(ext)] + clean = clean.removesuffix(ext) # pragma: no cover rel = f"responses/{method}/{clean}{ext}" base = files_dir.resolve() dest = (files_dir / rel).resolve() @@ -298,7 +297,7 @@ class DirectoriesConfig(BaseModel): files: str = "" @model_validator(mode="after") - def _resolve_defaults(self) -> "DirectoriesConfig": + def _resolve_defaults(self) -> DirectoriesConfig: if not self.data: import getpass import tempfile @@ -554,7 +553,7 @@ def _apply_mode_flags( try: self._load_startup_mocks() self._write_mock_config() - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 self._stop_capture_server() return f"Failed to initialize mock mode: {e}" @@ -1413,7 +1412,7 @@ async def get_flow_file(self, name: str) -> AsyncGenerator[str, None]: if not src.exists(): raise FileNotFoundError(f"Flow file not found: {name}") chunk_size = 2 * 1024 * 1024 - with open(src, "rb") as f: + with open(src, "rb") as f: # pragma: no cover # noqa: ASYNC230 while True: chunk = f.read(chunk_size) if not chunk: @@ -1766,7 +1765,7 @@ async def get_captured_file(self, relative_path: str) -> AsyncGenerator[str, Non return # 2 MB raw → ~2.7 MB base64, well under the 4 MB gRPC limit chunk_size = 2 * 1024 * 1024 - with open(src, "rb") as f: + with open(src, "rb") as f: # pragma: no cover # noqa: ASYNC230 while True: chunk = f.read(chunk_size) if not chunk: @@ -1880,7 +1879,7 @@ def _capture_accept_loop(self): ) t.start() self._capture_reader_threads.append(t) - except socket.timeout: + except TimeoutError: continue except OSError: break diff --git a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/__init__.py b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/__init__.py index 446ac9902..893c699b7 100644 --- a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/__init__.py +++ b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/__init__.py @@ -3,11 +3,11 @@ from .mouse import MouseButton __all__ = [ + "MouseButton", "NanoKVMUSB", - "NanoKVMUSBVideo", - "NanoKVMUSBHID", "NanoKVMUSBClient", - "NanoKVMUSBVideoClient", + "NanoKVMUSBHID", "NanoKVMUSBHIDClient", - "MouseButton", + "NanoKVMUSBVideo", + "NanoKVMUSBVideoClient", ] diff --git a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/client.py b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/client.py index 8dbbbd6b3..67b375c64 100644 --- a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/client.py +++ b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/client.py @@ -11,7 +11,7 @@ from jumpstarter.client import DriverClient from jumpstarter.client.decorators import driver_click_group -__all__ = ["NanoKVMUSBVideoClient", "NanoKVMUSBHIDClient", "NanoKVMUSBClient", "MouseButton"] +__all__ = ["MouseButton", "NanoKVMUSBClient", "NanoKVMUSBHIDClient", "NanoKVMUSBVideoClient"] def _decode_cli_escapes(text: str) -> str: @@ -42,7 +42,6 @@ def cli(self): @driver_click_group(self) def base(): """NanoKVM-USB video commands""" - pass @base.command() @click.argument("output", type=click.Path(), default="snapshot.jpg") @@ -88,7 +87,6 @@ def cli(self): # noqa: C901 @driver_click_group(self) def base(): """NanoKVM-USB HID commands""" - pass @base.command() @click.argument("text") @@ -96,7 +94,7 @@ def paste(text): """Paste text via keyboard HID (supports \\n for newline, \\t for tab)""" decoded_text = _decode_cli_escapes(text) self.paste_text(decoded_text) - click.echo(f"Pasted: {repr(decoded_text)}") + click.echo(f"Pasted: {decoded_text!r}") @base.command() @click.argument("key") @@ -104,7 +102,7 @@ def press(key): """Press a single key (supports \\n for Enter, \\t for Tab)""" decoded_key = _decode_cli_escapes(key) self.press_key(decoded_key) - click.echo(f"Pressed: {repr(decoded_key)}") + click.echo(f"Pressed: {decoded_key!r}") @base.command() def reset(): @@ -115,7 +113,6 @@ def reset(): @base.group() def mouse(): """Mouse control commands""" - pass @mouse.command() @click.argument("x", type=float) diff --git a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/device.py b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/device.py index 4ef57f74c..2ab7e5866 100644 --- a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/device.py +++ b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/device.py @@ -4,6 +4,7 @@ import threading import time +from typing import Self from .keyboard import KeyboardReport, resolve_key_code from .mouse import ( @@ -206,7 +207,7 @@ def capture_frame_jpeg(self, quality: int | None = None) -> bytes: self._video.discard_stale_frames(self._video_discard_stale) return self._video.read_frame_jpeg(q) - def __enter__(self) -> NanoKVMUSBDevice: + def __enter__(self) -> Self: self.connect() return self diff --git a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/driver.py b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/driver.py index 29962636c..ab0d614ed 100644 --- a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/driver.py +++ b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/driver.py @@ -11,7 +11,7 @@ from .mouse import MouseButton, resolve_button from jumpstarter.driver import Driver, export, exportstream -__all__ = ["NanoKVMUSBVideo", "NanoKVMUSBHID", "NanoKVMUSB", "MouseButton"] +__all__ = ["MouseButton", "NanoKVMUSB", "NanoKVMUSBHID", "NanoKVMUSBVideo"] @dataclass(kw_only=True) @@ -65,7 +65,7 @@ def close(self): if self._owns_device and self.device is not None: try: self.device.close() - except Exception as exc: + except Exception as exc: # noqa: BLE001 self.logger.debug(f"Error closing device: {exc}") @@ -168,7 +168,7 @@ def _press() -> None: device.press_key(key) await to_thread.run_sync(_press) - self.logger.debug(f"Pressed key: {repr(key)}") + self.logger.debug(f"Pressed key: {key!r}") @export async def reset_hid(self): @@ -287,5 +287,5 @@ def client(cls) -> str: def close(self): try: self._shared_device.close() - except Exception as exc: + except Exception as exc: # noqa: BLE001 self.logger.debug(f"Error closing shared device: {exc}") diff --git a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/v4l2_ctl_mjpeg.py b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/v4l2_ctl_mjpeg.py index 7bda3f499..bd66a4ba2 100644 --- a/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/v4l2_ctl_mjpeg.py +++ b/python/packages/jumpstarter-driver-nanokvm-usb/jumpstarter_driver_nanokvm_usb/v4l2_ctl_mjpeg.py @@ -131,15 +131,12 @@ def _start_process(self) -> subprocess.Popen[bytes]: "--stream-to=-", ] ) - try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - bufsize=0, - ) - except OSError: - raise + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + bufsize=0, + ) if proc.stdout is None: proc.kill() proc.wait(timeout=2) diff --git a/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver.py b/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver.py index 43e09ff03..270cbc34e 100644 --- a/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver.py +++ b/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver.py @@ -130,6 +130,7 @@ def _capture_toggle(self, capture_id: str, state: str) -> None: capture_output=True, text=True, timeout=10, + check=False, ) except subprocess.TimeoutExpired as e: raise NetsimError("netsim capture patch timed out after 10s") from e diff --git a/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver_test.py b/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver_test.py index 28e064ce2..5096a9f15 100644 --- a/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver_test.py +++ b/python/packages/jumpstarter-driver-netsim/jumpstarter_driver_netsim/driver_test.py @@ -252,6 +252,7 @@ def test_set_capture_on(mock_run, drv_cli): capture_output=True, text=True, timeout=10, + check=False, ) @@ -264,6 +265,7 @@ def test_set_capture_off(mock_run, drv_cli): capture_output=True, text=True, timeout=10, + check=False, ) @@ -285,6 +287,7 @@ def test_start_capture(mock_run, requests_mock, drv_cli): capture_output=True, text=True, timeout=10, + check=False, ) @@ -310,6 +313,7 @@ def test_stop_capture(mock_run, drv_cli): capture_output=True, text=True, timeout=10, + check=False, ) diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/dbus.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/dbus.py index 03fb4ecdf..bddc226c4 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/dbus.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/dbus.py @@ -13,10 +13,8 @@ def DbusAdapter(*, client: "DbusNetworkClient"): match client.kind: case "system": varname = "DBUS_SYSTEM_BUS_ADDRESS" - pass case "session": varname = "DBUS_SESSION_BUS_ADDRESS" - pass case _: raise ValueError(f"invalid bus type: {client.kind}") diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/fabric_test.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/fabric_test.py index c72c3ce74..bb7086e0b 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/fabric_test.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/fabric_test.py @@ -44,8 +44,10 @@ def test_client_adapter_fabric(): server_thread.daemon = True server_thread.start() - with serve(TcpNetwork(host=server.server_address[0], port=server.server_address[1])) as client: - with FabricAdapter(client=client, connect_kwargs={"password": "password"}) as conn: - conn.run("dummy command") + with ( + serve(TcpNetwork(host=server.server_address[0], port=server.server_address[1])) as client, + FabricAdapter(client=client, connect_kwargs={"password": "password"}) as conn, + ): + conn.run("dummy command") server.shutdown() diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py index f6eb6058e..bdd22b58b 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc.py @@ -27,11 +27,13 @@ async def NovncAdapter(*, client: DriverClient, method: str = "connect", encrypt """ async def handler(conn): - async with conn: - async with client.stream_async(method) as stream: - async with WebsocketServerStream(stream=stream) as stream: - async with forward_stream(conn, stream): - pass + async with ( + conn, + client.stream_async(method) as stream, + WebsocketServerStream(stream=stream) as stream, + forward_stream(conn, stream), + ): + pass async with TemporaryTcpListener(handler) as addr: params = { diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc_test.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc_test.py index d663bcff8..da2edcacf 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc_test.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/novnc_test.py @@ -1,4 +1,4 @@ -from contextlib import closing +from contextlib import closing, suppress from urllib.parse import parse_qsl, urlparse from anyio.from_thread import start_blocking_portal @@ -13,19 +13,18 @@ async def echo_handler(stream): async with stream: while True: - try: + with suppress(Exception): await stream.send(await stream.receive()) - except Exception: - pass def test_client_adapter_novnc(): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(TemporaryTcpListener(echo_handler, local_host="127.0.0.1")) as addr: - with serve(TcpNetwork(host=addr[0], port=addr[1])) as client: - with NovncAdapter(client=client) as url: - parsed = dict(parse_qsl(urlparse(url).query)) - with closing(create_connection(f"ws://{parsed['host']}:{parsed['port']}")) as ws: - ws.ping() - ws.send_bytes(b"hello") - assert ws.recv() == b"hello" + with ( + start_blocking_portal() as portal, + portal.wrap_async_context_manager(TemporaryTcpListener(echo_handler, local_host="127.0.0.1")) as addr, + serve(TcpNetwork(host=addr[0], port=addr[1])) as client,NovncAdapter(client=client) as url + ): + parsed = dict(parse_qsl(urlparse(url).query)) + with closing(create_connection(f"ws://{parsed['host']}:{parsed['port']}")) as ws: + ws.ping() + ws.send_bytes(b"hello") + assert ws.recv() == b"hello" diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py index 41771c591..d5e047757 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/adapters/portforward.py @@ -9,10 +9,8 @@ async def handler(client, method, conn): - async with conn: - async with client.stream_async(method) as stream: - async with forward_stream(conn, stream): - pass + async with conn, client.stream_async(method) as stream, forward_stream(conn, stream): + pass @blocking diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py index 35f6cd3ef..3a0727bfe 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/client.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from ipaddress import IPv6Address, ip_address from threading import Event -from typing import Any, Tuple +from typing import Any from urllib.parse import urlparse import click @@ -24,7 +24,6 @@ def cli(self): @driver_click_group(self) def base(): """Generic Network Connection""" - pass @base.command() @click.option("--address", default="localhost", show_default=True) @@ -44,10 +43,10 @@ def forward_tcp(address: str, port: int): host = ip_address(addr[0]) port = addr[1] match host: - case IPv6Address(): - click.echo("[{}]:{}".format(host, port)) - case _: - click.echo("{}:{}".format(host, port)) + case IPv6Address(): # pragma: no cover + click.echo(f"[{host}]:{port}") + case _: # pragma: no cover + click.echo(f"{host}:{port}") Event().wait() @@ -109,7 +108,7 @@ def kind(self): return self.labels[DbusNetwork.KIND_LABEL] -def _parse_address(addr: str) -> Tuple[str, str]: +def _parse_address(addr: str) -> tuple[str, str]: """Parse a host:port address string, handling IPv6 addresses correctly. Uses urllib.parse.urlparse for robust parsing of network addresses. diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/conftest.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/conftest.py index f8410c472..c3c0caeba 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/conftest.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/conftest.py @@ -1,3 +1,5 @@ +import contextlib + import pytest from anyio.from_thread import start_blocking_portal @@ -7,14 +9,14 @@ async def echo_handler(stream): async with stream: while True: - try: + with contextlib.suppress(Exception): await stream.send(await stream.receive()) - except Exception: - pass @pytest.fixture def tcp_echo_server(): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(TemporaryTcpListener(echo_handler, local_host="127.0.0.1")) as addr: - yield addr + with ( + start_blocking_portal() as portal, + portal.wrap_async_context_manager(TemporaryTcpListener(echo_handler, local_host="127.0.0.1")) as addr, + ): + yield addr diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py index 4882fba3b..57a2e8557 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver.py @@ -325,9 +325,8 @@ async def connect(self): ''' self.logger.info("Connecting to %s", self.url) - async with websockets.connect(self.url) as websocket: - async with WebsocketClientStream(conn=websocket) as stream: - yield stream + async with websockets.connect(self.url) as websocket, WebsocketClientStream(conn=websocket) as stream: + yield stream self.logger.info("Disconnected from %s", self.url) diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver_test.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver_test.py index d80ed25d0..e7469e292 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver_test.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/driver_test.py @@ -1,3 +1,4 @@ +import contextlib import os import socket import subprocess @@ -18,30 +19,31 @@ async def echo_handler(stream): async with stream: while True: - try: + with contextlib.suppress(Exception): await stream.send(await stream.receive()) - except Exception: - pass def test_tcp_network_portforward(tcp_echo_server): - with serve(TcpNetwork(host=tcp_echo_server[0], port=tcp_echo_server[1])) as client: - with TcpPortforwardAdapter(client=client) as addr: - stream = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - stream.connect(addr) - stream.send(b"hello") - assert stream.recv(5) == b"hello" + with ( + serve(TcpNetwork(host=tcp_echo_server[0], port=tcp_echo_server[1])) as client, + TcpPortforwardAdapter(client=client) as addr, + ): + stream = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + stream.connect(addr) + stream.send(b"hello") + assert stream.recv(5) == b"hello" def test_unix_network_portforward(): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(TemporaryUnixListener(echo_handler)) as inner: - with serve(UnixNetwork(path=inner)) as client: - with UnixPortforwardAdapter(client=client) as addr: - stream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - stream.connect(str(addr)) - stream.send(b"hello") - assert stream.recv(5) == b"hello" + with ( + start_blocking_portal() as portal, + portal.wrap_async_context_manager(TemporaryUnixListener(echo_handler)) as inner, + serve(UnixNetwork(path=inner)) as client,UnixPortforwardAdapter(client=client) as addr + ): + stream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + stream.connect(str(addr)) + stream.send(b"hello") + assert stream.recv(5) == b"hello" def test_udp_network(): @@ -50,26 +52,25 @@ def test_udp_network(): host="127.0.0.1", port=8001, ) - ) as client: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - s.bind(("127.0.0.1", 8001)) + ) as client, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.bind(("127.0.0.1", 8001)) - with client.stream() as stream: - stream.send(b"hello") - assert s.recv(5) == b"hello" + with client.stream() as stream: + stream.send(b"hello") + assert s.recv(5) == b"hello" def test_unix_network(): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(TemporaryUnixListener(echo_handler)) as path: - with serve( - UnixNetwork( - path=path, - ) - ) as client: - with client.stream() as stream: - stream.send(b"hello") - assert stream.receive() == b"hello" + with ( + start_blocking_portal() as portal, + portal.wrap_async_context_manager(TemporaryUnixListener(echo_handler)) as path,serve( + UnixNetwork( + path=path, + ) + ) as client, client.stream() as stream + ): + stream.send(b"hello") + assert stream.receive() == b"hello" @pytest.mark.skipif(which("iperf3") is None, reason="iperf3 not available") @@ -100,6 +101,7 @@ def test_tcp_network_performance(): ], stdout=sys.stdout, stderr=sys.stderr, + check=False, ) server.terminate() @@ -119,14 +121,12 @@ def test_udp_network_direct(): assert addr in ["udp://127.0.0.1:5201", "udp://localhost:5201"] def test_tcp_network_direct_disabled(): - with serve(TcpNetwork(host="127.0.0.1", port=5201, enable_address=False)) as client: - with pytest.raises(ValueError): - client.address() + with serve(TcpNetwork(host="127.0.0.1", port=5201, enable_address=False)) as client, pytest.raises(ValueError): + client.address() def test_udp_network_direct_disabled(): - with serve(UdpNetwork(host="127.0.0.1", port=5201, enable_address=False)) as client: - with pytest.raises(ValueError): - client.address() + with serve(UdpNetwork(host="127.0.0.1", port=5201, enable_address=False)) as client, pytest.raises(ValueError): + client.address() @pytest.mark.skipif( @@ -143,8 +143,7 @@ def test_dbus_network_system(monkeypatch): subprocess.run( ["busctl", "list", "--system", "--no-pager"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, ) assert oldvar == os.getenv("DBUS_SYSTEM_BUS_ADDRESS") @@ -163,8 +162,7 @@ def test_dbus_network_session(monkeypatch): subprocess.run( ["busctl", "list", "--user", "--no-pager"], check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, ) assert oldvar == os.getenv("DBUS_SESSION_BUS_ADDRESS") diff --git a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/streams/websocket.py b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/streams/websocket.py index 9ce947dd4..18f4c5e7d 100644 --- a/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/streams/websocket.py +++ b/python/packages/jumpstarter-driver-network/jumpstarter_driver_network/streams/websocket.py @@ -1,6 +1,5 @@ from contextlib import suppress from dataclasses import dataclass, field -from typing import Tuple from anyio import BrokenResourceError, WouldBlock, create_memory_object_stream from anyio.abc import AnyByteStream, ObjectStream @@ -24,7 +23,7 @@ class WebsocketServerStream(ObjectStream[bytes]): stream: AnyByteStream ws: WSConnection = field(init=False, default_factory=lambda: WSConnection(ConnectionType.SERVER)) - queue: Tuple[MemoryObjectSendStream[bytes], MemoryObjectReceiveStream[bytes]] = field( + queue: tuple[MemoryObjectSendStream[bytes], MemoryObjectReceiveStream[bytes]] = field( init=False, default_factory=lambda: create_memory_object_stream[bytes](32), # ty: ignore[call-non-callable] ) diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py index 49addc410..ecda6e7f4 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver.py @@ -146,7 +146,7 @@ def _send_command(self, cmd: bytes) -> None: _fallback = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") if _brew_lib not in _fallback.split(":"): os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = _brew_lib + (":" + _fallback if _fallback else "") - import hid # noqa: PLC0415 + import hid with hid.Device(self.vendor_id, self.product_id) as device: device.write(b"\x00" + cmd) # 0x00 = HID report ID @@ -171,7 +171,7 @@ def _query_status(self) -> dict[str, str]: _fallback = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "") if _brew_lib not in _fallback.split(":"): os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = _brew_lib + (":" + _fallback if _fallback else "") - import hid # noqa: PLC0415 + import hid cmd = _build_command(0x0F, 0x02) # 0x0F = all-channels status query pseudo-channel with hid.Device(self.vendor_id, self.product_id) as device: diff --git a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py index 308dbb250..1e6b1abbc 100644 --- a/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py +++ b/python/packages/jumpstarter-driver-noyito-relay/jumpstarter_driver_noyito_relay/driver_test.py @@ -102,9 +102,8 @@ def test_read_not_supported(mock_serial_cls): mock_ser = _make_serial_mock() mock_serial_cls.return_value = mock_ser - with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client: - with pytest.raises(NotImplementedError): - list(client.read()) + with serve(NoyitoPowerSerial(port="/dev/ttyUSB0", channel=1)) as client, pytest.raises(NotImplementedError): + list(client.read()) @patch("jumpstarter_driver_noyito_relay.driver.serial.Serial") @@ -288,9 +287,8 @@ def test_hid_read_not_supported(mock_hid_cls): mock_dev = _make_hid_mock() mock_hid_cls.return_value = mock_dev - with serve(NoyitoPowerHID(num_channels=4, channel=1)) as client: - with pytest.raises(NotImplementedError): - list(client.read()) + with serve(NoyitoPowerHID(num_channels=4, channel=1)) as client, pytest.raises(NotImplementedError): + list(client.read()) @patch("hid.Device") diff --git a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/client.py b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/client.py index 0bc81d958..19c0b6194 100644 --- a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/client.py +++ b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/client.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from .driver import OBDConnectionStatus from jumpstarter.client import DriverClient @@ -9,7 +8,7 @@ class OBDClient(DriverClient): """Client for the OBD-II driver.""" - def query(self, command_name: str) -> Optional[str]: + def query(self, command_name: str) -> str | None: """Query a PID by name (e.g. 'RPM'); returns None if the ECU doesn't answer.""" return self.call("query", command_name) diff --git a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver.py b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver.py index 00df47c1f..c1812adf2 100644 --- a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver.py +++ b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver.py @@ -1,6 +1,5 @@ from dataclasses import dataclass, field from enum import StrEnum -from typing import Optional import obd @@ -29,12 +28,12 @@ class OBD(Driver): driver_type = "automotive" - port: Optional[str] = field(default=None) + port: str | None = field(default=None) baudrate: int = field(default=38400) # fast mode is quicker but flaky on cheap clone adapters, so it defaults off fast: bool = field(default=False) - _connection: Optional[obd.OBD] = field(init=False, default=None) + _connection: obd.OBD | None = field(init=False, default=None) @classmethod def client(cls) -> str: @@ -54,7 +53,7 @@ def close(self): super().close() @export - def query(self, command_name: str) -> Optional[str]: + def query(self, command_name: str) -> str | None: """Query a PID by name (e.g. 'RPM', 'SPEED', 'COOLANT_TEMP'). Returns None if the ECU doesn't answer. Destructive commands such as diff --git a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver_test.py b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver_test.py index 7a9d51edb..87a53a8c3 100644 --- a/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver_test.py +++ b/python/packages/jumpstarter-driver-obd/jumpstarter_driver_obd/driver_test.py @@ -64,9 +64,8 @@ def fake_query(cmd, *args, **kwargs): # clear_dtc passes force=True def obd_client(): """Yield an OBDClient connected to a mocked OBD driver.""" mock_conn = _make_mock_connection() - with patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn): - with serve(OBD()) as client: - yield client + with patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn), serve(OBD()) as client: + yield client def test_obd_status(obd_client): @@ -130,9 +129,8 @@ def test_obd_query_rejects_destructive(obd_client): def test_obd_clear_dtc_invokes_mode_04(): # The dedicated method must actually send CLEAR_DTC to the adapter. mock_conn = _make_mock_connection() - with patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn): - with serve(OBD()) as client: - assert client.clear_dtc() is None + with patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn), serve(OBD()) as client: + assert client.clear_dtc() is None sent = [call.args[0] for call in mock_conn.query.call_args_list] assert obd.commands["CLEAR_DTC"] in sent @@ -140,6 +138,8 @@ def test_obd_clear_dtc_invokes_mode_04(): def test_obd_no_adapter_raises(): """Driver must raise ConnectionError when no ELM327 adapter is found.""" mock_conn = _make_mock_connection(status=obd.OBDStatus.NOT_CONNECTED) - with patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn): - with pytest.raises(ConnectionError, match="No ELM327 adapter found"): - OBD() + with ( + patch("jumpstarter_driver_obd.driver.obd.OBD", return_value=mock_conn), + pytest.raises(ConnectionError, match="No ELM327 adapter found"), + ): + OBD() diff --git a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/adapter.py b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/adapter.py index 93061601a..691baa8af 100644 --- a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/adapter.py +++ b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/adapter.py @@ -1,6 +1,7 @@ +from collections.abc import Callable, Mapping from contextlib import asynccontextmanager, suppress from dataclasses import dataclass, field -from typing import Any, Callable, Literal, Mapping +from typing import Any, Literal from anyio import BrokenResourceError, EndOfStream from anyio.abc import ObjectStream @@ -86,7 +87,7 @@ async def OpendalAdapter( else: try: metadata = await operator.to_async_operator().stat(path) - except Exception: + except Exception: # noqa: BLE001 metadata = None file = await operator.to_async_operator().open(path, mode) async with client.resource_async( diff --git a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/client.py b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/client.py index b57e1eb5a..394ca9069 100644 --- a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/client.py +++ b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/client.py @@ -123,16 +123,20 @@ def read_into_path(self, path: PathBuf, operator: Operator | None = None): @validate_call(validate_return=True) def write_bytes(self, data: bytes) -> None: buf = BytesIO(data) - with self.client.portal.wrap_async_context_manager(BytesIOStream(buf=buf)) as stream: - with self.client.portal.wrap_async_context_manager(self.client.resource_async(stream)) as handle: - self.__write(handle) + with ( + self.client.portal.wrap_async_context_manager(BytesIOStream(buf=buf)) as stream, + self.client.portal.wrap_async_context_manager(self.client.resource_async(stream)) as handle, + ): + self.__write(handle) @validate_call(validate_return=True) def read_bytes(self) -> bytes: buf = BytesIO() - with self.client.portal.wrap_async_context_manager(BytesIOStream(buf=buf)) as stream: - with self.client.portal.wrap_async_context_manager(self.client.resource_async(stream)) as handle: - self.__read(handle) + with ( + self.client.portal.wrap_async_context_manager(BytesIOStream(buf=buf)) as stream, + self.client.portal.wrap_async_context_manager(self.client.resource_async(stream)) as handle, + ): + self.__read(handle) return buf.getvalue() @validate_call(validate_return=True) @@ -441,7 +445,6 @@ def cli(self): # noqa: C901 @driver_click_group(self) def base(): """Opendal Storage""" - pass @base.command @arg_path @@ -577,7 +580,6 @@ def cli(self): @driver_click_group(self) def base(): """Generic flasher interface""" - pass @base.command() @click.argument("file", nargs=-1, required=False) @@ -642,7 +644,7 @@ def _should_upload_file( storage_hash = storage.hash(filename) return storage_hash != src_hash - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 return True def _flash_single( @@ -752,7 +754,6 @@ def cli(self, base=None): @driver_click_group(self) def base(): """Storage operations""" - pass @base.command() def host(): diff --git a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/common.py b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/common.py index 7e6094236..90696848d 100644 --- a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/common.py +++ b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/common.py @@ -1,7 +1,7 @@ # Reference: https://github.com/apache/opendal/blob/main/bindings/python/python/opendal/__init__.pyi import warnings from os import PathLike -from typing import Any, Literal, Optional +from typing import Any, Literal import opendal from pydantic import BaseModel, Field, model_validator @@ -39,11 +39,11 @@ def is_dir(self) -> bool: class Metadata(BaseModel): - content_disposition: Optional[str] + content_disposition: str | None content_length: int - content_md5: Optional[str] - content_type: Optional[str] - etag: Optional[str] + content_md5: str | None + content_type: str | None + etag: str | None mode: EntryMode @@ -94,9 +94,9 @@ class Capability(BaseModel): write_with_content_type: bool write_with_content_disposition: bool write_with_cache_control: bool - write_multi_max_size: Optional[int] - write_multi_min_size: Optional[int] - write_total_max_size: Optional[int] + write_multi_max_size: int | None + write_multi_min_size: int | None + write_total_max_size: int | None create_dir: bool delete: bool diff --git a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver.py b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver.py index 0d833aa55..53bf129df 100644 --- a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver.py +++ b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver.py @@ -59,7 +59,7 @@ def __post_init__(self): async def open(self, /, path: str, mode: Mode) -> UUID: try: metadata = await self._operator.stat(path) - except Exception: + except Exception: # noqa: BLE001 metadata = None file = await self._operator.open(path, mode) @@ -223,13 +223,15 @@ async def copy_exporter_file(self, /, source: Path, target: str): """Copy a file from the exporter to the target path. This function is intended to be used on the exporter side to copy files to the target path. """ - async with await AsyncOperator("fs", root=source.parent.as_posix()).open(source.name, "rb") as src: - async with await self._operator.open(target, "wb") as dst: - while True: - data = await src.read(size=65536) - if len(data) == 0: - break - await dst.write(bs=data) + async with ( + await AsyncOperator("fs", root=source.parent.as_posix()).open(source.name, "rb") as src, + await self._operator.open(target, "wb") as dst, + ): + while True: + data = await src.read(size=65536) + if len(data) == 0: + break + await dst.write(bs=data) # Always track path creation (assume pre-existing files are just uncleaned remnants) self._created_paths.add(self._normalize_path(target)) @@ -300,7 +302,7 @@ def _cleanup_created_resources(self): else: os.remove(full_path) self.logger.debug(f"Removed created file: {path}") - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 self.logger.error(f"Failed to remove path {path}: {e}") @@ -329,17 +331,15 @@ def __path(self, partition: str | None = None) -> str: @export async def flash(self, source, partition: str | None = None): - async with await FileWriteStream.from_path(self.__path(partition)) as stream: - async with self.resource(source) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(self.__path(partition)) as stream, self.resource(source) as res: + async for chunk in res: + await stream.send(chunk) @export async def dump(self, target, partition: str | None = None): - async with await FileReadStream.from_path(self.__path(partition)) as stream: - async with self.resource(target) as res: - async for chunk in stream: - await res.send(chunk) + async with await FileReadStream.from_path(self.__path(partition)) as stream, self.resource(target) as res: + async for chunk in stream: + await res.send(chunk) class StorageMuxInterface(metaclass=ABCMeta): @@ -389,19 +389,17 @@ async def off(self): @export async def write(self, src: str): - async with await FileWriteStream.from_path(self.file.name) as stream: - async with self.resource(src) as res: - # match write_to_storage_device: compressed images are - # detected by file signature and decompressed transparently - async for chunk in AutoDecompressIterator(source=res): - await stream.send(chunk) + async with await FileWriteStream.from_path(self.file.name) as stream, self.resource(src) as res: + # match write_to_storage_device: compressed images are + # detected by file signature and decompressed transparently + async for chunk in AutoDecompressIterator(source=res): + await stream.send(chunk) @export async def read(self, dst: str): - async with await FileReadStream.from_path(self.file.name) as stream: - async with self.resource(dst) as res: - async for chunk in stream: - await res.send(chunk) + async with await FileReadStream.from_path(self.file.name) as stream, self.resource(dst) as res: + async for chunk in stream: + await res.send(chunk) @dataclass diff --git a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver_test.py b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver_test.py index 06137c360..90a1c7f30 100644 --- a/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver_test.py +++ b/python/packages/jumpstarter-driver-opendal/jumpstarter_driver_opendal/driver_test.py @@ -239,37 +239,36 @@ def log_message(self, format, *args): def test_drivers_mock_storage_mux_fs(monkeypatch: pytest.MonkeyPatch): - with serve(MockStorageMux()) as client: - with TemporaryDirectory() as tempdir: - # original file on the client to be pushed to the exporter - original = Path(tempdir) / "original" - # new file read back from the exporter to the client - readback = Path(tempdir) / "readback" - - # test accessing files with absolute path - - # fill the original file with random bytes - original.write_bytes(randbytes(1024 * 1024 * 10)) - # write the file to the storage on the exporter - client.write_local_file(str(original)) - # read the storage on the exporter to a local file - client.read_local_file(str(readback)) - # ensure the contents are equal + with serve(MockStorageMux()) as client, TemporaryDirectory() as tempdir: + # original file on the client to be pushed to the exporter + original = Path(tempdir) / "original" + # new file read back from the exporter to the client + readback = Path(tempdir) / "readback" + + # test accessing files with absolute path + + # fill the original file with random bytes + original.write_bytes(randbytes(1024 * 1024 * 10)) + # write the file to the storage on the exporter + client.write_local_file(str(original)) + # read the storage on the exporter to a local file + client.read_local_file(str(readback)) + # ensure the contents are equal + assert original.read_bytes() == readback.read_bytes() + + # test accessing files with relative path + with monkeypatch.context() as m: + m.chdir(tempdir) + + original.write_bytes(randbytes(1024 * 1024 * 1)) + client.write_local_file("original") + client.read_local_file("readback") assert original.read_bytes() == readback.read_bytes() - # test accessing files with relative path - with monkeypatch.context() as m: - m.chdir(tempdir) - - original.write_bytes(randbytes(1024 * 1024 * 1)) - client.write_local_file("original") - client.read_local_file("readback") - assert original.read_bytes() == readback.read_bytes() - - original.write_bytes(randbytes(1024 * 1024 * 1)) - client.write_local_file("./original") - client.read_local_file("./readback") - assert original.read_bytes() == readback.read_bytes() + original.write_bytes(randbytes(1024 * 1024 * 1)) + client.write_local_file("./original") + client.read_local_file("./readback") + assert original.read_bytes() == readback.read_bytes() def test_drivers_mock_storage_mux_http(): @@ -437,13 +436,13 @@ def test_operator_for_path_strips_query_params(): from .client import operator_for_path # HTTP URL without query parameters - path, operator, scheme = operator_for_path("https://cdn.example.com/images/image.raw.xz") + path, _, scheme = operator_for_path("https://cdn.example.com/images/image.raw.xz") assert scheme == "http" assert path == Path("/images/image.raw.xz") # HTTP URL with query parameters - query params are stripped because # signed URL downloads use original_url passthrough instead - path, operator, scheme = operator_for_path( + path, _, scheme = operator_for_path( "https://cdn.example.com/images/image.raw.xz?Expires=123&Signature=abc&Key-Pair-Id=xyz" ) assert scheme == "http" @@ -451,7 +450,7 @@ def test_operator_for_path_strips_query_params(): # Filesystem path (use resolve() for the expected value since macOS # resolves /tmp to /private/tmp) - path, operator, scheme = operator_for_path("/tmp/image.raw.xz") + path, _operator, scheme = operator_for_path("/tmp/image.raw.xz") assert scheme == "fs" assert path == Path("/tmp/image.raw.xz").resolve() @@ -505,22 +504,23 @@ def test_write_from_path_http_with_explicit_operator(tmp_path): guard, otherwise the HTTP URL goes through OpenDAL presign_read which mangles it into a double-host path like endpoint/https%3A/host/path. """ - with serve(Opendal(scheme="fs", kwargs={"root": str(tmp_path)})) as client: - with _http_path_recording_server() as (port, received_paths): - url = f"http://127.0.0.1:{port}/path%40encoded/file.bin" - explicit_operator = Operator("http", endpoint=f"http://127.0.0.1:{port}") - client.write_from_path("dest.bin", url, operator=explicit_operator) - _assert_encoding_preserved(received_paths) + with ( + serve(Opendal(scheme="fs", kwargs={"root": str(tmp_path)})) as client, + _http_path_recording_server() as (port, received_paths), + ): + url = f"http://127.0.0.1:{port}/path%40encoded/file.bin" + explicit_operator = Operator("http", endpoint=f"http://127.0.0.1:{port}") + client.write_from_path("dest.bin", url, operator=explicit_operator) + _assert_encoding_preserved(received_paths) def test_flash_http_with_explicit_operator(): """FlasherClient.flash must use original_url bypass even when operator is passed explicitly.""" - with serve(MockFlasher()) as flasher: - with _http_path_recording_server() as (port, received_paths): - url = f"http://127.0.0.1:{port}/path%40encoded/file.bin" - explicit_operator = Operator("http", endpoint=f"http://127.0.0.1:{port}") - flasher.flash(url, operator=explicit_operator) - _assert_encoding_preserved(received_paths) + with serve(MockFlasher()) as flasher, _http_path_recording_server() as (port, received_paths): + url = f"http://127.0.0.1:{port}/path%40encoded/file.bin" + explicit_operator = Operator("http", endpoint=f"http://127.0.0.1:{port}") + flasher.flash(url, operator=explicit_operator) + _assert_encoding_preserved(received_paths) def test_flash_http_url_preserves_percent_encoding(): diff --git a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/client.py b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/client.py index 858580862..8e7b0dca9 100644 --- a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/client.py +++ b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/client.py @@ -52,7 +52,7 @@ def flash(file, dest_name, compression): try: click.echo("Entering BOOTSEL mode...") self.enter_bootloader() - except Exception as exc: + except Exception as exc: # noqa: BLE001 click.echo("Could not enter BOOTSEL automatically. " "Ensure the Pico is in BOOTSEL mode (hold BOOTSEL while plugging USB).\n" f" (reason: {exc})") diff --git a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver.py b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver.py index 07037e119..27ea40c50 100644 --- a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver.py +++ b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver.py @@ -181,10 +181,9 @@ async def flash(self, source, target: str | None = None): dest_path = mount / dest_name with _temporary_filename(suffix=".uf2") as tmp_path: - async with await FileWriteStream.from_path(tmp_path) as stream: - async with self.resource(source) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(tmp_path) as stream, self.resource(source) as res: + async for chunk in res: + await stream.send(chunk) self.logger.info("Copying UF2 to BOOTSEL volume %s", dest_path) diff --git a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver_test.py b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver_test.py index 20a03e452..d25e2237e 100644 --- a/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver_test.py +++ b/python/packages/jumpstarter-driver-pi-pico/jumpstarter_driver_pi_pico/driver_test.py @@ -257,7 +257,7 @@ def _fake_mounts(): def test_drivers_pi_pico_no_children_raises(monkeypatch): - monkeypatch.setattr("jumpstarter_driver_pi_pico.driver.find_all_bootloader_mounts", lambda: []) + monkeypatch.setattr("jumpstarter_driver_pi_pico.driver.find_all_bootloader_mounts", list) driver = PiPicoFlasher() with pytest.raises(NotImplementedError, match="GPIO children.*serial"): driver.enter_bootloader() diff --git a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py index 10ba3ccec..a88dbd16f 100644 --- a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py +++ b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py @@ -39,7 +39,6 @@ def cli(self): @driver_click_group(self) def base(): """Generic power""" - pass @base.command() def on(): diff --git a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py index 82525d0a1..345d700c9 100644 --- a/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py +++ b/python/packages/jumpstarter-driver-power/jumpstarter_driver_power/client_test.py @@ -9,10 +9,11 @@ def test_log_stream(caplog): """Test that driver logs are properly streamed to the client.""" - with serve(MockPower()) as client: - # Set log level to capture INFO messages from exporter:driver logger - with caplog.at_level(logging.INFO, logger="exporter:driver"): - with client.log_stream(): + with ( + serve(MockPower()) as client, + caplog.at_level(logging.INFO, logger="exporter:driver"), + client.log_stream(), + ): client.on() time.sleep(1) # to ensure log is flushed assert "power on" in caplog.text diff --git a/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/client.py b/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/client.py index 6f6bbcf69..fa9e17cbe 100644 --- a/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/client.py +++ b/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/client.py @@ -57,14 +57,13 @@ def read(self, width: int, address: int, words: int) -> list[int]: if words <= 0: raise ArgumentError("Words must be positive") - data_strs = self.call("read", f"b{int(width)}", "0x%x" % int(address), "%d" % words) + data_strs = self.call("read", f"b{int(width)}", f"0x{int(address):x}", f"{words:d}") return [int(data, 16) for data in data_strs] def cli(self): # noqa: C901 @driver_click_group(self) def base(): """probe-rs client""" - pass @base.command() def info(): @@ -101,13 +100,13 @@ def read(width, address, words): data_ints = self.read(width, address, words) if width == 8: - data_strs = ["%02x" % data for data in data_ints] + data_strs = [f"{data:02x}" for data in data_ints] elif width == 16: - data_strs = ["%04x" % data for data in data_ints] + data_strs = [f"{data:04x}" for data in data_ints] elif width == 32: - data_strs = ["%08x" % data for data in data_ints] + data_strs = [f"{data:08x}" for data in data_ints] elif width == 64: - data_strs = ["%016x" % data for data in data_ints] + data_strs = [f"{data:016x}" for data in data_ints] print(" ".join(data_strs)) diff --git a/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/driver.py b/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/driver.py index 22f609228..7aa2f9db8 100644 --- a/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/driver.py +++ b/python/packages/jumpstarter-driver-probe-rs/jumpstarter_driver_probe_rs/driver.py @@ -45,10 +45,9 @@ def erase(self) -> str: @export async def download(self, src: str): with TemporaryFilename() as filename: - async with await FileWriteStream.from_path(filename) as stream: - async with self.resource(src) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(filename) as stream, self.resource(src) as res: + async for chunk in res: + await stream.send(chunk) return self._run_cmd(["download", filename]) @export @@ -65,9 +64,10 @@ def _run_cmd(self, cmd): self.logger.debug("Running command: %s", cmd) result = subprocess.run( cmd, - capture_output=True, # Captures stdout and stderr - text=True, # Returns stdout/stderr as strings (not bytes) + capture_output=True, + text=True, env=self.env_from_cfg(), + check=False, ) if result.returncode != 0: diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/cli_test.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/cli_test.py index 5040acbdf..d47c878aa 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/cli_test.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/cli_test.py @@ -56,21 +56,19 @@ def test_pipe_command_with_output_file(pyserial_client): runner = CliRunner() cli = pyserial_client.cli() - with runner.isolated_filesystem(): - # Mock the portal.call to prevent actual execution - with patch.object(pyserial_client.portal, "call") as mock_call: - mock_call.side_effect = KeyboardInterrupt # Simulate Ctrl+C to exit + with runner.isolated_filesystem(), patch.object(pyserial_client.portal, "call") as mock_call: + mock_call.side_effect = KeyboardInterrupt # Simulate Ctrl+C to exit - # Use --no-input to explicitly disable input detection - runner.invoke(cli, ["pipe", "-o", "test.log", "--no-input"]) + # Use --no-input to explicitly disable input detection + runner.invoke(cli, ["pipe", "-o", "test.log", "--no-input"]) - # Should have attempted to call _pipe_serial - assert mock_call.called - # Check the arguments passed - args = mock_call.call_args[0] - assert args[1] == "test.log" # output file - assert args[2] is False # input_enabled - assert args[3] is False # append + # Should have attempted to call _pipe_serial + assert mock_call.called + # Check the arguments passed + args = mock_call.call_args[0] + assert args[1] == "test.log" # output file + assert args[2] is False # input_enabled + assert args[3] is False # append def test_pipe_command_with_append(pyserial_client): @@ -78,16 +76,15 @@ def test_pipe_command_with_append(pyserial_client): runner = CliRunner() cli = pyserial_client.cli() - with runner.isolated_filesystem(): - with patch.object(pyserial_client.portal, "call") as mock_call: - mock_call.side_effect = KeyboardInterrupt + with runner.isolated_filesystem(), patch.object(pyserial_client.portal, "call") as mock_call: + mock_call.side_effect = KeyboardInterrupt - runner.invoke(cli, ["pipe", "-o", "test.log", "-a"]) + runner.invoke(cli, ["pipe", "-o", "test.log", "-a"]) - assert mock_call.called - args = mock_call.call_args[0] - assert args[1] == "test.log" # output file - assert args[3] is True # append + assert mock_call.called + args = mock_call.call_args[0] + assert args[1] == "test.log" # output file + assert args[3] is True # append def test_pipe_command_with_input_flag(pyserial_client): @@ -230,18 +227,17 @@ def test_pipe_command_with_file_and_input(pyserial_client): runner = CliRunner() cli = pyserial_client.cli() - with runner.isolated_filesystem(): - with patch.object(pyserial_client.portal, "call") as mock_call: - mock_call.side_effect = KeyboardInterrupt + with runner.isolated_filesystem(), patch.object(pyserial_client.portal, "call") as mock_call: + mock_call.side_effect = KeyboardInterrupt - with patch("sys.stdin.isatty", return_value=False): - runner.invoke(cli, ["pipe", "-o", "test.log"]) + with patch("sys.stdin.isatty", return_value=False): + runner.invoke(cli, ["pipe", "-o", "test.log"]) - assert mock_call.called - args = mock_call.call_args[0] - assert args[1] == "test.log" # output file - assert args[2] is True # input_enabled (auto-detected) - assert args[3] is False # append + assert mock_call.called + args = mock_call.call_args[0] + assert args[1] == "test.log" # output file + assert args[2] is True # input_enabled (auto-detected) + assert args[3] is False # append def test_pipe_command_keyboard_interrupt_handling(pyserial_client): @@ -397,7 +393,7 @@ async def test_serial_to_output_receives_data_then_end_of_stream(pyserial_client await pyserial_client._serial_to_output(mock_stream, "test.log", False) # Verify the file contains the data - with open("test.log", "rb") as f: + with open("test.log", "rb") as f: # noqa: ASYNC230 content = f.read() assert content == b"HelloWorld" diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py index 4f12ca51a..4bd42855c 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/client.py @@ -1,6 +1,5 @@ import sys from contextlib import contextmanager -from typing import Optional import click from anyio import BrokenResourceError, EndOfStream, create_task_group, open_file @@ -42,7 +41,7 @@ def pexpect(self): async def _pipe_serial( self, - output_file: Optional[str] = None, + output_file: str | None = None, input_enabled: bool = False, append: bool = False, no_output: bool = False, @@ -79,7 +78,7 @@ async def _pipe_serial( await self._serial_to_output(stream, output_file, append) tg.cancel_scope.cancel() - async def _serial_to_output(self, stream, output_file: Optional[str], append: bool): + async def _serial_to_output(self, stream, output_file: str | None, append: bool): """Read from serial and write to file or stdout.""" try: if output_file: @@ -132,7 +131,6 @@ def cli(self): # noqa: C901 @driver_click_group(self) def base(): """Serial port client""" - pass @base.command(aliases=["start-console"]) @click.option("--observe", is_flag=True, default=False, help="Watch-only mode (read-only)") diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver.py index ad2dd5cc3..e7596327a 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver.py @@ -1,7 +1,7 @@ import os from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Any, Optional, Union +from typing import Any from anyio import ( create_memory_object_stream, @@ -27,8 +27,8 @@ @dataclass(kw_only=True) class ThrottledStream(ObjectStream): """Wrapper stream that adds CPS throttling to any ObjectStream.""" - stream: Union[ObjectSendStream[bytes], ObjectStream[bytes]] - cps: Optional[float] = None + stream: ObjectSendStream[bytes] | ObjectStream[bytes] + cps: float | None = None async def send(self, item: bytes): if self.cps is not None and self.cps > 0: @@ -68,8 +68,8 @@ async def aclose(self): @dataclass(kw_only=True) class AsyncSerial(ObjectStream): reader: StreamReaderWrapper - writer: Union[StreamWriterWrapper, ThrottledStream] - cps: Optional[float] = None # characters per second throttling + writer: StreamWriterWrapper | ThrottledStream + cps: float | None = None # characters per second throttling def __post_init__(self): # Replace writer with throttled version if chars-per-second throttling is set @@ -100,7 +100,7 @@ class PySerial(FanOutStreamMixin, Driver): url: str baudrate: int = field(default=115200) check_present: bool = field(default=True) - cps: Optional[float] = field(default=None) # characters per second throttling + cps: float | None = field(default=None) # characters per second throttling disable_hupcl: bool = field(default=False) _transport: Any = field(default=None, init=False, repr=False) diff --git a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py index 0f3f62a71..4900ba322 100644 --- a/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py +++ b/python/packages/jumpstarter-driver-pyserial/jumpstarter_driver_pyserial/driver_test.py @@ -1,3 +1,4 @@ +import contextlib import time from types import SimpleNamespace from typing import cast @@ -14,19 +15,21 @@ def test_bare_pyserial(): - with serve(PySerial(url="loop://")) as client: - with client.stream() as stream: - stream.send(b"hello") - assert "hello".startswith(stream.receive().decode("utf-8")) + with serve(PySerial(url="loop://")) as client, client.stream() as stream: + stream.send(b"hello") + assert "hello".startswith(stream.receive().decode("utf-8")) + def test_second_exclusive_connect_surfaces_console_in_use(): """A second exclusive stream should fail with a clean 'console in use' error.""" - with serve(PySerial(url="loop://")) as client: - with client.stream() as _held: - with pytest.raises(Exception) as exc_info: - with client.stream() as stream: - stream.receive() + with ( + serve(PySerial(url="loop://")) as client, + client.stream() as _held, + pytest.raises(Exception) as exc_info, + client.stream() as stream, + ): + stream.receive() combined = [] cause = exc_info.value @@ -46,6 +49,7 @@ def test_second_exclusive_connect_surfaces_console_in_use(): assert "Unexpected pts_path self._ready_targets: set[str] = set() - self._process: Optional[subprocess.Popen] = None - self._monitor_thread: Optional[threading.Thread] = None + self._process: subprocess.Popen | None = None + self._monitor_thread: threading.Thread | None = None self._shutdown = threading.Event() self._cleanup_done = False # Process configuration (must be same for all drivers) - self._demuxer_path: Optional[str] = None - self._device: Optional[str] = None - self._chip: Optional[str] = None + self._demuxer_path: str | None = None + self._device: str | None = None + self._chip: str | None = None self._poll_interval: float = 1.0 # Register atexit handler for cleanup on normal exit @@ -186,7 +187,7 @@ def handler(signum: int, frame): cls._original_sigint_handler = signal.signal(signal.SIGINT, make_handler(signal.SIGINT)) cls._signal_handlers_installed = True logger.debug("Installed signal handlers for SIGTERM and SIGINT") - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 logger.warning("Failed to install signal handlers: %s", e) def _validate_config(self, demuxer_path: str, device: str, chip: str, target: str): @@ -326,19 +327,21 @@ def _stop_monitor(self): except ProcessLookupError: # Process already dead logger.debug("Demuxer process already exited") - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 logger.error("Error terminating demuxer process: %s", e) finally: self._process = None # Wait for monitor thread to exit monitor_thread = self._monitor_thread - if monitor_thread is not None and monitor_thread.is_alive(): - # Don't join if we're being called from the monitor thread itself - if threading.current_thread() is not monitor_thread: - monitor_thread.join(timeout=2.0) - if monitor_thread.is_alive(): - logger.warning("Monitor thread did not exit within timeout") + if ( + monitor_thread is not None + and monitor_thread.is_alive() + and threading.current_thread() is not monitor_thread + ): + monitor_thread.join(timeout=2.0) + if monitor_thread.is_alive(): + logger.warning("Monitor thread did not exit within timeout") self._monitor_thread = None logger.debug("Stopped demuxer monitor") @@ -370,7 +373,7 @@ def _monitor_loop(self): while not self._shutdown.is_set(): try: self._run_demuxer_cycle() - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 logger.error("Error in demuxer monitor loop: %s", e) # Clear ready state on error with self._lock: @@ -438,7 +441,7 @@ def _start_demuxer_process(self, device: str) -> bool: stderr=subprocess.PIPE, text=True, bufsize=1, # Line buffered - preexec_fn=preexec_fn, + preexec_fn=preexec_fn, # noqa: PLW1509 ) logger.debug("Demuxer process started with PID %d", self._process.pid) return True @@ -484,7 +487,7 @@ def _read_demuxer_stderr(self): # Force immediate process termination os._exit(1) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.error("Error reading demuxer stderr: %s", e) def _read_demuxer_output(self): @@ -510,7 +513,7 @@ def _read_demuxer_output(self): self._pts_map[target] = pts_path self._ready_targets.add(target) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.error("Error reading demuxer output: %s", e) # Clear state when process ends diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py index 396e4c997..102829bc2 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client.py @@ -94,7 +94,6 @@ def cli(self): @base.group() def resize(): """Resize QEMU resources""" - pass @resize.command(name="disk") @click.argument("size") diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client_test.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client_test.py index 996dacd56..bc2d6f704 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client_test.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/client_test.py @@ -21,9 +21,11 @@ def test_flash_oci_forwards_authenticated_credentials(qemu_client): flash_oci call. """ creds = OciCredentials(username="myuser", password=SecretStr("mypass")) - with patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=creds): - with patch.object(qemu_client.flasher, "streamingcall", return_value=iter([])) as mock_sc: - qemu_client.flasher.flash("oci://quay.io/private/image:tag") + with ( + patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=creds), + patch.object(qemu_client.flasher, "streamingcall", return_value=iter([])) as mock_sc, + ): + qemu_client.flasher.flash("oci://quay.io/private/image:tag") mock_sc.assert_called_once() args = mock_sc.call_args.args @@ -37,9 +39,11 @@ def test_flash_oci_forwards_authenticated_credentials(qemu_client): def test_flash_oci_forwards_none_when_unauthenticated(qemu_client): """Without client-side creds, forward None/None so the exporter falls back to its own env/auth-file resolution (backward compatible).""" - with patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()): - with patch.object(qemu_client.flasher, "streamingcall", return_value=iter([])) as mock_sc: - qemu_client.flasher.flash("oci://quay.io/public/image:tag") + with ( + patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()), + patch.object(qemu_client.flasher, "streamingcall", return_value=iter([])) as mock_sc, + ): + qemu_client.flasher.flash("oci://quay.io/public/image:tag") mock_sc.assert_called_once() args = mock_sc.call_args.args diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py index d4a9034b6..ee371548a 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver.py @@ -75,11 +75,13 @@ async def flash(self, source, partition: str | None = None): pass return - async with await FileWriteStream.from_path(self.parent.validate_partition(partition)) as stream: - async with self.resource(source) as res: - # Wrap with auto-decompression to handle .gz, .xz, .bz2, .zstd files - async for chunk in AutoDecompressIterator(source=res): - await stream.send(chunk) + async with ( + await FileWriteStream.from_path(self.parent.validate_partition(partition)) as stream, + self.resource(source) as res, + ): + # Wrap with auto-decompression to handle .gz, .xz, .bz2, .zstd files + async for chunk in AutoDecompressIterator(source=res): + await stream.send(chunk) @export async def flash_oci( @@ -164,7 +166,7 @@ async def _stream_subprocess( remaining = self.parent.flash_timeout - elapsed try: name, text = await asyncio.wait_for(output_queue.get(), timeout=min(remaining, 30)) - except asyncio.TimeoutError: + except TimeoutError: continue if text is None: @@ -194,12 +196,11 @@ async def _stream_subprocess( @export async def dump(self, target, partition: str | None = None): - async with await FileReadStream.from_path( + async with await FileReadStream.from_path( # pragma: no cover self.parent.validate_partition(partition, use_default_partitions=True) - ) as stream: - async with self.resource(target) as res: - async for chunk in stream: - await res.send(chunk) + ) as stream, self.resource(target) as res: + async for chunk in stream: + await res.send(chunk) @dataclass(kw_only=True) @@ -277,7 +278,7 @@ async def on(self) -> None: # noqa: C901 ",".join( ["user", "id=eth0"] + [ - "hostfwd={}:{}:{}-:{}".format(v.protocol, v.hostaddr, v.hostport, v.guestport) + f"hostfwd={v.protocol}:{v.hostaddr}:{v.hostport}-:{v.guestport}" for k, v in self.parent.hostfwd.items() ] ), @@ -292,7 +293,7 @@ async def on(self) -> None: # noqa: C901 ] if _vsock_available(): - devices.append("vhost-vsock-pci,guest-cid={}".format(self.parent._cid)) + devices.append(f"vhost-vsock-pci,guest-cid={self.parent._cid}") for device in devices: cmdline += ["-device", device] @@ -384,12 +385,12 @@ async def on(self) -> None: # noqa: C901 f"{blk_device},drive=cidata", ] - self._process = Popen(self.parent._wrap_command(cmdline), stdin=PIPE) + self._process = Popen(self.parent._wrap_command(cmdline), stdin=PIPE) # noqa: ASYNC220 qmp = QMPClient(self.parent.hostname) logging.getLogger( - "qemu.qmp.protocol.{}".format(self.parent.hostname), + f"qemu.qmp.protocol.{self.parent.hostname}", ).addFilter(QmpLogFilter()) with fail_after(10): diff --git a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py index 63c7d1f7e..fa4f2c259 100644 --- a/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py +++ b/python/packages/jumpstarter-driver-qemu/jumpstarter_driver_qemu/driver_test.py @@ -37,7 +37,8 @@ def ovmf(tmpdir_factory): for chunk in r.iter_content(chunk_size=8192): f.write(chunk) - tarfile.open(tmp_path / "ovmf.tar.xz").extractall(tmp_path, filter="data") + with tarfile.open(tmp_path / "ovmf.tar.xz") as tf: + tf.extractall(tmp_path, filter="data") yield tmp_path / f"{ver}-bin" @@ -140,9 +141,11 @@ async def test_resize_shrink_blocked(resize_test): """Shrinking disk should raise RuntimeError.""" driver, current = resize_test("10G", 20) # requested: 10G, current: 20G - with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)): - with pytest.raises(RuntimeError, match="Shrinking disk is not supported"): - await driver.children["power"].on() + with ( + patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)), + pytest.raises(RuntimeError, match="Shrinking disk is not supported"), + ): + await driver.children["power"].on() @pytest.mark.anyio @@ -152,10 +155,12 @@ async def test_resize_insufficient_space_blocked(resize_test): mock_usage = SimpleNamespace(free=5 * 1024**3) # only 5G free - with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)): - with patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage): - with pytest.raises(RuntimeError, match="Not enough disk space"): - await driver.children["power"].on() + with ( + patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)), + patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage), + pytest.raises(RuntimeError, match="Not enough disk space"), + ): + await driver.children["power"].on() @pytest.mark.anyio @@ -164,12 +169,13 @@ async def test_resize_succeeds(resize_test): driver, current = resize_test("20G", 10) # requested: 20G, current: 10G mock_usage = SimpleNamespace(free=50 * 1024**3) - with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)) as mock_run: - with patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage): - # Mock Popen to stop before actually starting QEMU VM - with patch("jumpstarter_driver_qemu.driver.Popen", side_effect=RuntimeError("mock popen")): - with pytest.raises(RuntimeError, match="mock popen"): - await driver.children["power"].on() + with ( + patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(current)) as mock_run, + patch("jumpstarter_driver_qemu.driver.shutil.disk_usage", return_value=mock_usage), + patch("jumpstarter_driver_qemu.driver.Popen", side_effect=RuntimeError("mock popen")), + pytest.raises(RuntimeError, match="mock popen"), + ): + await driver.children["power"].on() # Find the resize call and verify size argument resize_calls = [c for c in mock_run.call_args_list if "resize" in c.args[0]] @@ -198,10 +204,12 @@ def _create(**kwargs): async def _captured_cmdline(driver): """Power on `driver`, aborting at Popen, and return the cmdline it was given.""" - with patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(0)): - with patch("jumpstarter_driver_qemu.driver.Popen", side_effect=RuntimeError("mock popen")) as mock_popen: - with pytest.raises(RuntimeError, match="mock popen"): - await driver.children["power"].on() + with ( + patch("jumpstarter_driver_qemu.driver.run_process", side_effect=_mock_qemu_img_info(0)), + patch("jumpstarter_driver_qemu.driver.Popen", side_effect=RuntimeError("mock popen")) as mock_popen, + pytest.raises(RuntimeError, match="mock popen"), + ): + await driver.children["power"].on() return mock_popen.call_args.args[0] @@ -240,9 +248,8 @@ async def test_virtio_transport_mmio_requires_4g(cmdline_test): too-little memory must fail loudly instead of silently failing to boot.""" driver = cmdline_test(virtio_transport="mmio") # default mem=512M - with patch("jumpstarter_driver_qemu.driver.Popen") as mock_popen: - with pytest.raises(RuntimeError, match="4G"): - await driver.children["power"].on() + with patch("jumpstarter_driver_qemu.driver.Popen") as mock_popen, pytest.raises(RuntimeError, match="4G"): + await driver.children["power"].on() mock_popen.assert_not_called() @@ -364,19 +371,21 @@ async def test_flash_oci_success(): expected_target = str(Path(driver._tmp_dir.name) / "root") mock_process = _create_mock_process(stdout_lines=["Flashing complete\n"]) - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="/usr/local/bin/fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec: - results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="/usr/local/bin/fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec, + ): + results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") - # Verify final chunk has returncode 0 - assert any(r[2] == 0 for r in results) + # Verify final chunk has returncode 0 + assert any(r[2] == 0 for r in results) - mock_exec.assert_called_once() - call_args = mock_exec.call_args - assert call_args.args[0] == "/usr/local/bin/fls" - assert call_args.args[1] == "from-url" - assert call_args.args[2] == "oci://quay.io/org/image:tag" - assert call_args.args[3] == expected_target + mock_exec.assert_called_once() + call_args = mock_exec.call_args + assert call_args.args[0] == "/usr/local/bin/fls" + assert call_args.args[1] == "from-url" + assert call_args.args[2] == "oci://quay.io/org/image:tag" + assert call_args.args[3] == expected_target @pytest.mark.anyio @@ -387,11 +396,13 @@ async def test_flash_oci_with_partition(): expected_target = str(Path(driver._tmp_dir.name) / "bios") mock_process = _create_mock_process() - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec: - await _collect_flash_oci(flasher, "oci://quay.io/org/bios:v1", partition="bios") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec, + ): + await _collect_flash_oci(flasher, "oci://quay.io/org/bios:v1", partition="bios") - assert mock_exec.call_args.args[3] == expected_target + assert mock_exec.call_args.args[3] == expected_target @pytest.mark.anyio @@ -401,23 +412,25 @@ async def test_flash_oci_with_credentials(): flasher = driver.children["flasher"] mock_process = _create_mock_process() - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec: - await _collect_flash_oci( - flasher, - "oci://quay.io/private/image:tag", - oci_username="myuser", - oci_password="mypass", - ) + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process) as mock_exec, + ): + await _collect_flash_oci( + flasher, + "oci://quay.io/private/image:tag", + oci_username="myuser", + oci_password="mypass", + ) - # Credentials should NOT appear in command args - assert "myuser" not in mock_exec.call_args.args - assert "mypass" not in mock_exec.call_args.args + # Credentials should NOT appear in command args + assert "myuser" not in mock_exec.call_args.args + assert "mypass" not in mock_exec.call_args.args - # Credentials should be in env vars - env = mock_exec.call_args.kwargs["env"] - assert env["FLS_REGISTRY_USERNAME"] == "myuser" - assert env["FLS_REGISTRY_PASSWORD"] == "mypass" + # Credentials should be in env vars + env = mock_exec.call_args.kwargs["env"] + assert env["FLS_REGISTRY_USERNAME"] == "myuser" + assert env["FLS_REGISTRY_PASSWORD"] == "mypass" @pytest.mark.anyio @@ -429,16 +442,17 @@ async def test_flash_oci_no_credentials(): # Ensure OCI env vars are not set so driver doesn't pick them up env_clean = {k: v for k, v in os.environ.items() if k not in ("OCI_USERNAME", "OCI_PASSWORD")} - with patch.dict(os.environ, env_clean, clear=True): - with patch("jumpstarter.common.oci.read_auth_file_credentials", return_value=OciCredentials()): - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process - ) as mock_exec: - await _collect_flash_oci(flasher, "oci://quay.io/public/image:tag") + with ( + patch.dict(os.environ, env_clean, clear=True), + patch("jumpstarter.common.oci.read_auth_file_credentials", return_value=OciCredentials()), + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"),patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process + ) as mock_exec + ): + await _collect_flash_oci(flasher, "oci://quay.io/public/image:tag") - env = mock_exec.call_args.kwargs["env"] - assert env is None + env = mock_exec.call_args.kwargs["env"] + assert env is None @pytest.mark.anyio @@ -448,16 +462,17 @@ async def test_flash_oci_credentials_from_env(): flasher = driver.children["flasher"] mock_process = _create_mock_process() - with patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}): - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process - ) as mock_exec: - await _collect_flash_oci(flasher, "oci://quay.io/private/image:tag") + with ( + patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}), + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"),patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process + ) as mock_exec + ): + await _collect_flash_oci(flasher, "oci://quay.io/private/image:tag") - env = mock_exec.call_args.kwargs["env"] - assert env["FLS_REGISTRY_USERNAME"] == "envuser" - assert env["FLS_REGISTRY_PASSWORD"] == "envpass" + env = mock_exec.call_args.kwargs["env"] + assert env["FLS_REGISTRY_USERNAME"] == "envuser" + assert env["FLS_REGISTRY_PASSWORD"] == "envpass" @pytest.mark.anyio @@ -467,21 +482,22 @@ async def test_flash_oci_explicit_credentials_override_env(): flasher = driver.children["flasher"] mock_process = _create_mock_process() - with patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}): - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process - ) as mock_exec: - await _collect_flash_oci( - flasher, - "oci://quay.io/private/image:tag", - oci_username="explicit_user", - oci_password="explicit_pass", - ) + with ( + patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}), + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"),patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process + ) as mock_exec + ): + await _collect_flash_oci( + flasher, + "oci://quay.io/private/image:tag", + oci_username="explicit_user", + oci_password="explicit_pass", + ) - env = mock_exec.call_args.kwargs["env"] - assert env["FLS_REGISTRY_USERNAME"] == "explicit_user" - assert env["FLS_REGISTRY_PASSWORD"] == "explicit_pass" + env = mock_exec.call_args.kwargs["env"] + assert env["FLS_REGISTRY_USERNAME"] == "explicit_user" + assert env["FLS_REGISTRY_PASSWORD"] == "explicit_pass" @pytest.mark.anyio @@ -494,16 +510,18 @@ async def test_flash_oci_streams_output(): stderr_lines=["progress: 50%\n", "progress: 100%\n"], ) - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process): - results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process), + ): + results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") - # Should have received streaming output plus the final returncode chunk - stdout_chunks = [r[0] for r in results if r[0]] - stderr_chunks = [r[1] for r in results if r[1]] - assert len(stdout_chunks) > 0 - assert len(stderr_chunks) > 0 - assert any(r[2] == 0 for r in results) + # Should have received streaming output plus the final returncode chunk + stdout_chunks = [r[0] for r in results if r[0]] + stderr_chunks = [r[1] for r in results if r[1]] + assert len(stdout_chunks) > 0 + assert len(stderr_chunks) > 0 + assert any(r[2] == 0 for r in results) @pytest.mark.anyio @@ -545,10 +563,12 @@ async def test_flash_oci_fls_failure(): flasher = driver.children["flasher"] mock_process = _create_mock_process(returncode=1) - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process): - with pytest.raises(RuntimeError, match="fls flash failed"): - await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process), + pytest.raises(RuntimeError, match="fls flash failed"), + ): + await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") @pytest.mark.anyio @@ -579,12 +599,14 @@ async def mock_wait(): stderr_stream.readline = hanging_readline mock_process.stderr = stderr_stream - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process): - with pytest.raises(RuntimeError, match="fls flash timed out"): - await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process), + ): + with pytest.raises(RuntimeError, match="fls flash timed out"): + await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") - mock_process.kill.assert_called_once() + mock_process.kill.assert_called_once() @pytest.mark.anyio @@ -603,16 +625,18 @@ async def mock_wait_for(awaitable, *, timeout): timeout_fired = True if hasattr(awaitable, "close"): awaitable.close() - raise asyncio.TimeoutError() + raise TimeoutError() return await original_wait_for(awaitable, timeout=timeout) - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process): - with patch("asyncio.wait_for", mock_wait_for): - results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process), + patch("asyncio.wait_for", mock_wait_for), + ): + results = await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") - assert timeout_fired - assert any(r[2] == 0 for r in results) + assert timeout_fired + assert any(r[2] == 0 for r in results) @pytest.mark.anyio @@ -654,10 +678,12 @@ async def test_flash_oci_fls_not_found(): driver = Qemu() flasher = driver.children["flasher"] - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, side_effect=FileNotFoundError): - with pytest.raises(RuntimeError, match="fls command not found"): - await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, side_effect=FileNotFoundError), + pytest.raises(RuntimeError, match="fls command not found"), + ): + await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") @pytest.mark.anyio @@ -667,15 +693,17 @@ async def test_flash_oci_uses_fls_config(): flasher = driver.children["flasher"] mock_process = _create_mock_process() - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls") as mock_get: - with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process): - await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") + with ( + patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls") as mock_get, + patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process), + ): + await _collect_flash_oci(flasher, "oci://quay.io/org/image:tag") - mock_get.assert_called_once_with( - fls_version="0.2.0", - fls_binary_url=None, - allow_custom_binaries=False, - ) + mock_get.assert_called_once_with( + fls_version="0.2.0", + fls_binary_url=None, + allow_custom_binaries=False, + ) @pytest.mark.anyio @@ -697,33 +725,29 @@ def test_flash_oci_via_flasher_client(): """flasher.flash('oci://...') should route through flash_oci on the driver.""" mock_process = _create_mock_process(stdout_lines=["done\n"]) - with serve(Qemu()) as qemu: - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process - ) as mock_exec: - qemu.flasher.flash("oci://quay.io/org/image:tag") + with serve(Qemu()) as qemu, patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process + ) as mock_exec: + qemu.flasher.flash("oci://quay.io/org/image:tag") - mock_exec.assert_called_once() - assert mock_exec.call_args.args[1] == "from-url" - assert mock_exec.call_args.args[2] == "oci://quay.io/org/image:tag" + mock_exec.assert_called_once() + assert mock_exec.call_args.args[1] == "from-url" + assert mock_exec.call_args.args[2] == "oci://quay.io/org/image:tag" def test_flash_oci_convenience_method(): """qemu.flash_oci() should delegate to flasher.flash().""" mock_process = _create_mock_process() - with serve(Qemu()) as qemu: - with patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"): - with patch( - "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process - ) as mock_exec: - qemu.flash_oci("oci://quay.io/org/image:tag", partition="bios") - - mock_exec.assert_called_once() - assert mock_exec.call_args.args[1] == "from-url" - assert mock_exec.call_args.args[2] == "oci://quay.io/org/image:tag" - assert Path(mock_exec.call_args.args[3]).name == "bios" + with serve(Qemu()) as qemu, patch("jumpstarter_driver_qemu.driver.get_fls_binary", return_value="fls"), patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=mock_process + ) as mock_exec: + qemu.flash_oci("oci://quay.io/org/image:tag", partition="bios") + + mock_exec.assert_called_once() + assert mock_exec.call_args.args[1] == "from-url" + assert mock_exec.call_args.args[2] == "oci://quay.io/org/image:tag" + assert Path(mock_exec.call_args.args[3]).name == "bios" @pytest.mark.anyio diff --git a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py index af792a2d2..d9f75a4d4 100644 --- a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py +++ b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging import shutil import socket @@ -75,10 +76,9 @@ async def flash(self, source, load_command: str | None = None): raise ValueError(f"unsupported load_command {load_command!r}, allowed: {sorted(_ALLOWED_LOAD_COMMANDS)}") firmware_path = self.parent._tmp_dir.name + "/firmware" - async with await FileWriteStream.from_path(firmware_path) as stream: - async with self.resource(source) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(firmware_path) as stream, self.resource(source) as res: + async for chunk in res: + await stream.send(chunk) if load_command is not None: cmd = load_command @@ -142,7 +142,7 @@ async def on(self) -> None: ] self.logger.info("starting Renode: %s", " ".join(cmdline)) - self._process = Popen(cmdline, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) + self._process = Popen(cmdline, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) # noqa: ASYNC220 self._monitor = RenodeMonitor() try: @@ -180,10 +180,8 @@ async def off(self) -> None: return if self._monitor is not None: - try: + with contextlib.suppress(Exception): await self._monitor.execute("quit") - except Exception: - pass await self._monitor.disconnect() self._monitor = None diff --git a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py index 4c6bd5290..1a5db892f 100644 --- a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py +++ b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/driver_test.py @@ -43,9 +43,8 @@ async def mock_connect_tcp(host, port): with patch( "jumpstarter_driver_renode.monitor.connect_tcp", side_effect=mock_connect_tcp, - ): - with patch("jumpstarter_driver_renode.monitor.sleep", new_callable=AsyncMock): - await monitor.connect("127.0.0.1", 12345) + ), patch("jumpstarter_driver_renode.monitor.sleep", new_callable=AsyncMock): + await monitor.connect("127.0.0.1", 12345) assert call_count == 3 assert monitor._stream is not None @@ -140,9 +139,8 @@ async def mock_connect_tcp(host, port): with patch( "jumpstarter_driver_renode.monitor.connect_tcp", side_effect=mock_connect_tcp, - ): - with patch("jumpstarter_driver_renode.monitor.sleep", new_callable=AsyncMock): - await monitor.connect("127.0.0.1", 12345) + ), patch("jumpstarter_driver_renode.monitor.sleep", new_callable=AsyncMock): + await monitor.connect("127.0.0.1", 12345) streams[0].aclose.assert_called_once() @@ -213,9 +211,8 @@ async def always_fail(host, port): with patch( "jumpstarter_driver_renode.monitor.connect_tcp", side_effect=always_fail, - ): - with pytest.raises(TimeoutError): - await monitor.connect("127.0.0.1", 12345, timeout=0.5) + ), pytest.raises(TimeoutError): + await monitor.connect("127.0.0.1", 12345, timeout=0.5) @pytest.mark.anyio async def test_read_until_prompt_connection_closed(self): @@ -271,18 +268,16 @@ async def test_power_on_command_sequence(self): with patch( "jumpstarter_driver_renode.driver._find_renode", return_value="/usr/bin/renode", - ): + ), patch( + "jumpstarter_driver_renode.driver._find_free_port", + return_value=54321, + ), patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: + mock_popen.return_value = MagicMock() with patch( - "jumpstarter_driver_renode.driver._find_free_port", - return_value=54321, + "jumpstarter_driver_renode.driver.RenodeMonitor", + return_value=mock_monitor, ): - with patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: - mock_popen.return_value = MagicMock() - with patch( - "jumpstarter_driver_renode.driver.RenodeMonitor", - return_value=mock_monitor, - ): - await power.on() + await power.on() calls = [c.args[0] for c in mock_monitor.execute.call_args_list] assert calls[0] == 'mach create "machine-0"' @@ -304,18 +299,16 @@ async def test_power_on_with_extra_commands(self): with patch( "jumpstarter_driver_renode.driver._find_renode", return_value="/usr/bin/renode", - ): + ), patch( + "jumpstarter_driver_renode.driver._find_free_port", + return_value=54321, + ), patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: + mock_popen.return_value = MagicMock() with patch( - "jumpstarter_driver_renode.driver._find_free_port", - return_value=54321, + "jumpstarter_driver_renode.driver.RenodeMonitor", + return_value=mock_monitor, ): - with patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: - mock_popen.return_value = MagicMock() - with patch( - "jumpstarter_driver_renode.driver.RenodeMonitor", - return_value=mock_monitor, - ): - await power.on() + await power.on() calls = [c.args[0] for c in mock_monitor.execute.call_args_list] connect_idx = next(i for i, c in enumerate(calls) if "connector Connect" in c) @@ -333,18 +326,16 @@ async def test_power_on_without_firmware(self): with patch( "jumpstarter_driver_renode.driver._find_renode", return_value="/usr/bin/renode", - ): + ), patch( + "jumpstarter_driver_renode.driver._find_free_port", + return_value=54321, + ), patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: + mock_popen.return_value = MagicMock() with patch( - "jumpstarter_driver_renode.driver._find_free_port", - return_value=54321, + "jumpstarter_driver_renode.driver.RenodeMonitor", + return_value=mock_monitor, ): - with patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: - mock_popen.return_value = MagicMock() - with patch( - "jumpstarter_driver_renode.driver.RenodeMonitor", - return_value=mock_monitor, - ): - await power.on() + await power.on() calls = [c.args[0] for c in mock_monitor.execute.call_args_list] assert not any("LoadELF" in c for c in calls) @@ -357,9 +348,11 @@ async def test_power_on_idempotent(self): power: RenodePower = driver.children["power"] # ty: ignore[invalid-assignment] power._process = MagicMock() - with patch("jumpstarter_driver_renode.driver.Popen") as mock_popen: - with patch("jumpstarter_driver_renode.driver.RenodeMonitor") as mock_monitor_cls: - await power.on() + with ( + patch("jumpstarter_driver_renode.driver.Popen") as mock_popen, + patch("jumpstarter_driver_renode.driver.RenodeMonitor") as mock_monitor_cls, + ): + await power.on() mock_popen.assert_not_called() mock_monitor_cls.assert_not_called() @@ -474,21 +467,17 @@ async def test_power_on_cleanup_on_failure(self): with patch( "jumpstarter_driver_renode.driver._find_renode", return_value="/usr/bin/renode", - ): - with patch( - "jumpstarter_driver_renode.driver._find_free_port", - return_value=54321, - ): - with patch( - "jumpstarter_driver_renode.driver.Popen", - return_value=mock_process, - ): - with patch( - "jumpstarter_driver_renode.driver.RenodeMonitor", - return_value=mock_monitor, - ): - with pytest.raises(RenodeMonitorError): - await power.on() + ), patch( + "jumpstarter_driver_renode.driver._find_free_port", + return_value=54321, + ), patch( + "jumpstarter_driver_renode.driver.Popen", + return_value=mock_process, + ), patch( + "jumpstarter_driver_renode.driver.RenodeMonitor", + return_value=mock_monitor, + ), pytest.raises(RenodeMonitorError): + await power.on() assert power._process is None assert power._monitor is None @@ -703,9 +692,8 @@ def test_find_renode_not_on_path(self): with patch( "jumpstarter_driver_renode.driver.shutil.which", return_value=None, - ): - with pytest.raises(FileNotFoundError, match="renode executable not found"): - _find_renode() + ), pytest.raises(FileNotFoundError, match="renode executable not found"): + _find_renode() def test_set_firmware(self): """set_firmware stores path and command on the driver.""" @@ -774,9 +762,8 @@ def test_client_monitor_cmd_disabled_by_default(self): driver = _make_driver() - with serve(driver) as client: - with pytest.raises(DriverError, match="raw monitor access is disabled"): - client.monitor_cmd("help") + with serve(driver) as client, pytest.raises(DriverError, match="raw monitor access is disabled"): + client.monitor_cmd("help") def test_client_monitor_cmd_not_running(self): """monitor_cmd raises when Renode is not running (but monitor enabled).""" @@ -784,9 +771,8 @@ def test_client_monitor_cmd_not_running(self): driver = _make_driver(allow_raw_monitor=True) - with serve(driver) as client: - with pytest.raises(DriverError, match="not running"): - client.monitor_cmd("help") + with serve(driver) as client, pytest.raises(DriverError, match="not running"): + client.monitor_cmd("help") def test_client_cli_renders(self): """CLI group includes monitor command.""" diff --git a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py index 83211f8e5..08ba469d1 100644 --- a/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py +++ b/python/packages/jumpstarter-driver-renode/jumpstarter_driver_renode/monitor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging from anyio import connect_tcp, fail_after, sleep @@ -45,10 +46,8 @@ async def connect(self, host: str, port: int, timeout: float = 10) -> None: return except OSError: if self._stream is not None: - try: + with contextlib.suppress(Exception): await self._stream.aclose() - except Exception: - pass self._stream = None await sleep(0.5) @@ -92,10 +91,8 @@ async def execute(self, command: str, timeout: float = 30) -> str: async def disconnect(self) -> None: """Close the monitor connection.""" if self._stream is not None: - try: + with contextlib.suppress(Exception): await self._stream.aclose() - except Exception: - pass self._stream = None self._buffer = b"" @@ -109,11 +106,9 @@ def close_sync(self) -> None: self._stream = None self._buffer = b"" if stream is not None: - try: + with contextlib.suppress(Exception): raw_sock = stream.extra(SocketAttribute.raw_socket) raw_sock.close() - except Exception: - pass async def _read_until_prompt(self) -> str: """Read from the stream until a monitor prompt line is detected. diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py index 8e427cbf9..9694f3d83 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from jumpstarter.common.oci import OciCredentials @@ -77,7 +77,7 @@ def _upload_file_if_needed(self, file_path: str, operator: Operator | None = Non return filename - def flash_images(self, partitions: Dict[str, str], operators: Optional[Dict[str, Operator]] = None): + def flash_images(self, partitions: dict[str, str], operators: dict[str, Operator] | None = None): """Flash images to specified partitions Args: @@ -111,7 +111,7 @@ def flash_images(self, partitions: Dict[str, str], operators: Optional[Dict[str, return flash_result - def _validate_partition_mappings(self, partitions: Dict[str, str] | None) -> None: + def _validate_partition_mappings(self, partitions: dict[str, str] | None) -> None: """Validate partition mappings; raise ValueError if any path is empty.""" if partitions is None: return @@ -159,8 +159,8 @@ def _execute_flash_operation(self, operation_func, *args, power_off: bool = True try: self._power_off_if_available(power_off) - except Exception as power_error: - self.logger.exception("power-off cleanup failed after flash operation error: %s", power_error) + except Exception: + self.logger.exception("power-off cleanup failed after flash operation error") if wrapped_error is not None: raise wrapped_error from flash_error @@ -168,18 +168,18 @@ def _execute_flash_operation(self, operation_func, *args, power_off: bool = True try: self._power_off_if_available(power_off) - except Exception as power_error: + except Exception: # Keep successful flashes successful, but make cleanup failures visible. - self.logger.exception("power-off cleanup failed after successful flash operation: %s", power_error) + self.logger.exception("power-off cleanup failed after successful flash operation") return result def flash( self, - path: str | Dict[str, str], + path: str | dict[str, str], *, target: str | None = None, - operator: Operator | Dict[str, Operator] | None = None, + operator: Operator | dict[str, Operator] | None = None, compression=None, power_off: bool = True, ): @@ -244,7 +244,7 @@ def flash( def flash_with_targets( self, oci_url: str, - partitions: Dict[str, str], + partitions: dict[str, str], *, power_off: bool = True, ): @@ -274,8 +274,8 @@ def _flash_operation(): def flash_local( self, - partitions: Dict[str, str], - operators: Dict[str, Operator] | None = None, + partitions: dict[str, str], + operators: dict[str, Operator] | None = None, *, power_off: bool = True, ): @@ -304,7 +304,7 @@ def _read_oci_credentials(self, oci_url: str) -> OciCredentials: def _flash_oci_auto_impl( self, oci_url: str, - partitions: Dict[str, str] | None = None, + partitions: dict[str, str] | None = None, ): """Core implementation of OCI flash without wrapper logic.""" creds = self._read_oci_credentials(oci_url) @@ -339,7 +339,7 @@ def _flash_oci_auto_impl( def flash_oci_auto( self, oci_url: str, - partitions: Dict[str, str] | None = None, + partitions: dict[str, str] | None = None, *, power_off: bool = True, ): @@ -435,7 +435,6 @@ def cli(self): @driver_click_group(self) def base(): """RideSX storage operations""" - pass # Add all generic commands except 'flash' (we override it) for name, cmd in generic_cli.commands.items(): diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client_test.py index 7b70fbade..0a290c55b 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client_test.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/client_test.py @@ -61,21 +61,23 @@ def test_validate_partition_mappings(ridesx_client): def test_flash_oci_auto_success(ridesx_client): """Test successful flash_oci_auto call""" - with patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()): - with patch.object(ridesx_client, "call") as mock_call: - mock_call.side_effect = [ - None, # boot_to_fastboot call - {"status": "device_found", "device_id": "ABC123"}, - {"status": "success"}, - ] + with ( + patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()), + patch.object(ridesx_client, "call") as mock_call, + ): + mock_call.side_effect = [ + None, # boot_to_fastboot call + {"status": "device_found", "device_id": "ABC123"}, + {"status": "success"}, + ] - result = ridesx_client.flash_oci_auto("oci://quay.io/org/image:tag") + result = ridesx_client.flash_oci_auto("oci://quay.io/org/image:tag") - assert result == {"status": "success"} - # Verify flash_oci_image was called with the OCI URL - flash_call = mock_call.call_args_list[2] - assert flash_call[0][0] == "flash_oci_image" - assert flash_call[0][1] == "oci://quay.io/org/image:tag" + assert result == {"status": "success"} + # Verify flash_oci_image was called with the OCI URL + flash_call = mock_call.call_args_list[2] + assert flash_call[0][0] == "flash_oci_image" + assert flash_call[0][1] == "oci://quay.io/org/image:tag" def test_flash_oci_auto_error_cases(ridesx_client): @@ -89,30 +91,34 @@ def test_flash_oci_auto_error_cases(ridesx_client): ridesx_client.flash_oci_auto("quay.io/org/image:tag") # No device found - with patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()): - with patch.object(ridesx_client, "call") as mock_call: - mock_call.return_value = {"status": "no_device_found", "device_id": None} + with ( + patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=OciCredentials()), + patch.object(ridesx_client, "call") as mock_call, + ): + mock_call.return_value = {"status": "no_device_found", "device_id": None} - with pytest.raises(click.ClickException, match="No fastboot devices found"): - ridesx_client.flash_oci_auto("oci://image:tag") + with pytest.raises(click.ClickException, match="No fastboot devices found"): + ridesx_client.flash_oci_auto("oci://image:tag") def test_flash_oci_auto_passes_authenticated_credentials(ridesx_client): """Authenticated credentials should pass username and plain password to flash_oci_image.""" creds = OciCredentials(username="myuser", password=SecretStr("mypass")) - with patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=creds): - with patch.object(ridesx_client, "call") as mock_call: - mock_call.side_effect = [ - None, # boot_to_fastboot - {"status": "device_found", "device_id": "ABC123"}, - {"status": "success"}, - ] - - ridesx_client.flash_oci_auto("oci://quay.io/org/image:tag") - - flash_call = mock_call.call_args_list[2] - assert flash_call[0][3] == "myuser" - assert flash_call[0][4] == "mypass" + with ( + patch("jumpstarter.common.oci.resolve_oci_credentials", return_value=creds), + patch.object(ridesx_client, "call") as mock_call, + ): + mock_call.side_effect = [ + None, # boot_to_fastboot + {"status": "device_found", "device_id": "ABC123"}, + {"status": "success"}, + ] + + ridesx_client.flash_oci_auto("oci://quay.io/org/image:tag") + + flash_call = mock_call.call_args_list[2] + assert flash_call[0][3] == "myuser" + assert flash_call[0][4] == "mypass" # _execute_flash_command Tests diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py index 4c805f2ca..ccde22d91 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver.py @@ -78,9 +78,7 @@ def _needs_decompression(self, filename: str) -> bool: return filename.endswith((".gz", ".gzip", ".xz")) def _decompress_file(self, compressed_file: Path) -> Path: - if compressed_file.name.endswith(".xz"): - decompressed_name = compressed_file.name[:-3] - elif compressed_file.name.endswith(".gz"): + if compressed_file.name.endswith(".xz") or compressed_file.name.endswith(".gz"): decompressed_name = compressed_file.name[:-3] elif compressed_file.name.endswith(".gzip"): decompressed_name = compressed_file.name[:-5] diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver_test.py index 63dd31fa7..7a66a81d7 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver_test.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver_test.py @@ -48,73 +48,68 @@ def test_missing_serial(temp_storage_dir): def test_detect_fastboot_device_found(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "ABC123456789 fastboot\n" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + mock_result = MagicMock() + mock_result.stdout = "ABC123456789 fastboot\n" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client.call("detect_fastboot_device", 1, 0.1) + result = client.call("detect_fastboot_device", 1, 0.1) - assert result["status"] == "device_found" - assert result["device_id"] == "ABC123456789" - mock_subprocess.assert_called_once() + assert result["status"] == "device_found" + assert result["device_id"] == "ABC123456789" + mock_subprocess.assert_called_once() def test_detect_fastboot_device_not_found(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client.call("detect_fastboot_device", 2, 0.01) + result = client.call("detect_fastboot_device", 2, 0.01) - assert result["status"] == "no_device_found" - assert result["device_id"] is None - # Driver makes max_attempts calls plus one final attempt - assert mock_subprocess.call_count >= 2 + assert result["status"] == "no_device_found" + assert result["device_id"] is None + # Driver makes max_attempts calls plus one final attempt + assert mock_subprocess.call_count >= 2 def test_detect_fastboot_device_timeout(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - mock_subprocess.side_effect = subprocess.TimeoutExpired("fastboot", 10) + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + mock_subprocess.side_effect = subprocess.TimeoutExpired("fastboot", 10) - result = client.call("detect_fastboot_device", 2, 0.01) + result = client.call("detect_fastboot_device", 2, 0.01) - assert result["status"] == "no_device_found" - assert result["device_id"] is None + assert result["status"] == "no_device_found" + assert result["device_id"] is None def test_detect_fastboot_device_not_found_error(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run", side_effect=FileNotFoundError("fastboot not found")): - # When called through client, RuntimeError becomes DriverError - from jumpstarter.client.core import DriverError + with serve(ridesx_driver) as client, patch("subprocess.run", side_effect=FileNotFoundError("fastboot not found")): + # When called through client, RuntimeError becomes DriverError + from jumpstarter.client.core import DriverError - with pytest.raises(DriverError, match="fastboot command not found"): - client.call("detect_fastboot_device", 1, 0.1) + with pytest.raises(DriverError, match="fastboot command not found"): + client.call("detect_fastboot_device", 1, 0.1) def test_detect_fastboot_device_retry_logic(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - # First two attempts return empty, third returns device - mock_results = [ - MagicMock(stdout="", returncode=0), - MagicMock(stdout="", returncode=0), - MagicMock(stdout="ABC123456789 fastboot\n", returncode=0), - ] - mock_subprocess.side_effect = mock_results + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + # First two attempts return empty, third returns device + mock_results = [ + MagicMock(stdout="", returncode=0), + MagicMock(stdout="", returncode=0), + MagicMock(stdout="ABC123456789 fastboot\n", returncode=0), + ] + mock_subprocess.side_effect = mock_results - result = client.call("detect_fastboot_device", 3, 0.01) + result = client.call("detect_fastboot_device", 3, 0.01) - assert result["status"] == "device_found" - assert result["device_id"] == "ABC123456789" - assert mock_subprocess.call_count == 3 + assert result["status"] == "device_found" + assert result["device_id"] == "ABC123456789" + assert mock_subprocess.call_count == 3 # File Decompression Tests @@ -223,31 +218,30 @@ def test_flash_with_fastboot_single_partition(temp_storage_dir, ridesx_driver): image_file = Path(temp_storage_dir) / "boot.img" image_file.write_bytes(b"boot image data") - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - # Mock flash command - flash_result = MagicMock() - flash_result.stdout = "Flashing boot..." - flash_result.stderr = "" - flash_result.returncode = 0 + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + # Mock flash command + flash_result = MagicMock() + flash_result.stdout = "Flashing boot..." + flash_result.stderr = "" + flash_result.returncode = 0 - # Mock continue command - continue_result = MagicMock() - continue_result.stdout = "Continuing..." - continue_result.stderr = "" - continue_result.returncode = 0 + # Mock continue command + continue_result = MagicMock() + continue_result.stdout = "Continuing..." + continue_result.stderr = "" + continue_result.returncode = 0 - mock_subprocess.side_effect = [flash_result, continue_result] + mock_subprocess.side_effect = [flash_result, continue_result] - client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) + client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) - assert mock_subprocess.call_count == 2 - # Check flash command - flash_call = mock_subprocess.call_args_list[0] - assert flash_call[0][0] == ["fastboot", "-s", "ABC123", "flash", "boot", str(image_file)] - # Check continue command - continue_call = mock_subprocess.call_args_list[1] - assert continue_call[0][0] == ["fastboot", "-s", "ABC123", "continue"] + assert mock_subprocess.call_count == 2 + # Check flash command + flash_call = mock_subprocess.call_args_list[0] + assert flash_call[0][0] == ["fastboot", "-s", "ABC123", "flash", "boot", str(image_file)] + # Check continue command + continue_call = mock_subprocess.call_args_list[1] + assert continue_call[0][0] == ["fastboot", "-s", "ABC123", "continue"] def test_flash_with_fastboot_multiple_partitions(temp_storage_dir, ridesx_driver): @@ -257,27 +251,26 @@ def test_flash_with_fastboot_multiple_partitions(temp_storage_dir, ridesx_driver system_file = Path(temp_storage_dir) / "system.img" system_file.write_bytes(b"system image data") - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - flash_result = MagicMock() - flash_result.stdout = "Flashing..." - flash_result.stderr = "" - flash_result.returncode = 0 + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + flash_result = MagicMock() + flash_result.stdout = "Flashing..." + flash_result.stderr = "" + flash_result.returncode = 0 - continue_result = MagicMock() - continue_result.stdout = "Continuing..." - continue_result.stderr = "" - continue_result.returncode = 0 + continue_result = MagicMock() + continue_result.stdout = "Continuing..." + continue_result.stderr = "" + continue_result.returncode = 0 - mock_subprocess.side_effect = [flash_result, flash_result, continue_result] + mock_subprocess.side_effect = [flash_result, flash_result, continue_result] - client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img", "system": "system.img"}) + client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img", "system": "system.img"}) - assert mock_subprocess.call_count == 3 - # Verify both partitions were flashed - flash_calls = [call[0][0] for call in mock_subprocess.call_args_list[:2]] - assert ["fastboot", "-s", "ABC123", "flash", "boot", str(boot_file)] in flash_calls - assert ["fastboot", "-s", "ABC123", "flash", "system", str(system_file)] in flash_calls + assert mock_subprocess.call_count == 3 + # Verify both partitions were flashed + flash_calls = [call[0][0] for call in mock_subprocess.call_args_list[:2]] + assert ["fastboot", "-s", "ABC123", "flash", "boot", str(boot_file)] in flash_calls + assert ["fastboot", "-s", "ABC123", "flash", "system", str(system_file)] in flash_calls def test_flash_with_fastboot_compressed_file(temp_storage_dir, ridesx_driver): @@ -289,28 +282,30 @@ def test_flash_with_fastboot_compressed_file(temp_storage_dir, ridesx_driver): decompressed_file = Path(temp_storage_dir) / "boot.img" decompressed_file.write_bytes(b"decompressed data") - with serve(ridesx_driver) as client: - with patch.object(ridesx_driver, "_decompress_file", return_value=decompressed_file): - with patch("subprocess.run") as mock_subprocess: - flash_result = MagicMock() - flash_result.stdout = "Flashing..." - flash_result.stderr = "" - flash_result.returncode = 0 + with ( + serve(ridesx_driver) as client, + patch.object(ridesx_driver, "_decompress_file", return_value=decompressed_file), + patch("subprocess.run") as mock_subprocess, + ): + flash_result = MagicMock() + flash_result.stdout = "Flashing..." + flash_result.stderr = "" + flash_result.returncode = 0 - continue_result = MagicMock() - continue_result.stdout = "Continuing..." - continue_result.stderr = "" - continue_result.returncode = 0 + continue_result = MagicMock() + continue_result.stdout = "Continuing..." + continue_result.stderr = "" + continue_result.returncode = 0 - mock_subprocess.side_effect = [flash_result, continue_result] + mock_subprocess.side_effect = [flash_result, continue_result] - client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img.gz"}) + client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img.gz"}) - # Verify decompression was called - ridesx_driver._decompress_file.assert_called_once_with(compressed_file) - # Verify flash used decompressed file - flash_call = mock_subprocess.call_args_list[0] - assert str(decompressed_file) in flash_call[0][0] + # Verify decompression was called + ridesx_driver._decompress_file.assert_called_once_with(compressed_file) + # Verify flash used decompressed file + flash_call = mock_subprocess.call_args_list[0] + assert str(decompressed_file) in flash_call[0][0] def test_flash_with_fastboot_file_not_found(temp_storage_dir, ridesx_driver): @@ -323,9 +318,8 @@ def test_flash_with_fastboot_file_not_found(temp_storage_dir, ridesx_driver): def test_flash_with_fastboot_empty_partitions(ridesx_driver): - with serve(ridesx_driver) as client: - with pytest.raises(ValueError, match="At least one partition must be provided"): - client.call("flash_with_fastboot", "ABC123", {}) + with serve(ridesx_driver) as client, pytest.raises(ValueError, match="At least one partition must be provided"): + client.call("flash_with_fastboot", "ABC123", {}) def test_flash_with_fastboot_flash_failure(temp_storage_dir, ridesx_driver): @@ -364,70 +358,67 @@ def test_flash_with_fastboot_continue_success(temp_storage_dir, ridesx_driver): image_file = Path(temp_storage_dir) / "boot.img" image_file.write_bytes(b"boot image data") - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - flash_result = MagicMock() - flash_result.stdout = "Flashing..." - flash_result.stderr = "" - flash_result.returncode = 0 + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + flash_result = MagicMock() + flash_result.stdout = "Flashing..." + flash_result.stderr = "" + flash_result.returncode = 0 - continue_result = MagicMock() - continue_result.stdout = "Continuing..." - continue_result.stderr = "" - continue_result.returncode = 0 + continue_result = MagicMock() + continue_result.stdout = "Continuing..." + continue_result.stderr = "" + continue_result.returncode = 0 - mock_subprocess.side_effect = [flash_result, continue_result] + mock_subprocess.side_effect = [flash_result, continue_result] - client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) + client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) - # Verify continue was called - continue_call = mock_subprocess.call_args_list[1] - assert continue_call[0][0] == ["fastboot", "-s", "ABC123", "continue"] + # Verify continue was called + continue_call = mock_subprocess.call_args_list[1] + assert continue_call[0][0] == ["fastboot", "-s", "ABC123", "continue"] def test_flash_with_fastboot_continue_failure(temp_storage_dir, ridesx_driver): image_file = Path(temp_storage_dir) / "boot.img" image_file.write_bytes(b"boot image data") - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - flash_result = MagicMock() - flash_result.stdout = "Flashing..." - flash_result.stderr = "" - flash_result.returncode = 0 + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + flash_result = MagicMock() + flash_result.stdout = "Flashing..." + flash_result.stderr = "" + flash_result.returncode = 0 - # First call succeeds (flash), second call fails (continue) - mock_subprocess.side_effect = [ - flash_result, - subprocess.CalledProcessError(1, "fastboot", stderr=b"continue failed"), - ] + # First call succeeds (flash), second call fails (continue) + mock_subprocess.side_effect = [ + flash_result, + subprocess.CalledProcessError(1, "fastboot", stderr=b"continue failed"), + ] - # Should not raise, just log warning - client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) + # Should not raise, just log warning + client.call("flash_with_fastboot", "ABC123", {"boot": "boot.img"}) - # Verify both flash and continue were called - assert mock_subprocess.call_count == 2 + # Verify both flash and continue were called + assert mock_subprocess.call_count == 2 # Erase Partition Tests def test_erase_partition_success(ridesx_driver): - with serve(ridesx_driver) as client: - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "Finished. Total time: 0.042s" - mock_result.stderr = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with serve(ridesx_driver) as client, patch("subprocess.run") as mock_subprocess: + mock_result = MagicMock() + mock_result.stdout = "Finished. Total time: 0.042s" + mock_result.stderr = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client.call("erase_partition", "ABC123", "recoveryinfo") + result = client.call("erase_partition", "ABC123", "recoveryinfo") - assert result["status"] == "success" - assert result["partition"] == "recoveryinfo" - mock_subprocess.assert_called_once() - call_args = mock_subprocess.call_args[0][0] - assert call_args == ["fastboot", "-s", "ABC123", "erase", "recoveryinfo"] + assert result["status"] == "success" + assert result["partition"] == "recoveryinfo" + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert call_args == ["fastboot", "-s", "ABC123", "erase", "recoveryinfo"] def test_erase_partition_failure(ridesx_driver): @@ -459,15 +450,16 @@ def test_erase_partition_fastboot_not_found(ridesx_driver): with serve(ridesx_driver) as client: from jumpstarter.client.core import DriverError - with patch("subprocess.run", side_effect=FileNotFoundError("fastboot not found")): - with pytest.raises(DriverError, match="fastboot command not found"): - client.call("erase_partition", "ABC123", "recoveryinfo") + with ( + patch("subprocess.run", side_effect=FileNotFoundError("fastboot not found")), + pytest.raises(DriverError, match="fastboot command not found"), + ): + client.call("erase_partition", "ABC123", "recoveryinfo") def test_erase_partition_empty_name(ridesx_driver): - with serve(ridesx_driver) as client: - with pytest.raises(ValueError, match="Partition name cannot be empty"): - client.call("erase_partition", "ABC123", "") + with serve(ridesx_driver) as client, pytest.raises(ValueError, match="Partition name cannot be empty"): + client.call("erase_partition", "ABC123", "") def test_power_missing_serial(): @@ -499,21 +491,22 @@ def test_power_off_exported(ridesx_power_driver): @pytest.mark.asyncio async def test_power_cycle(ridesx_power_driver): """Test power cycle calls off, waits, then on""" - with patch.object(ridesx_power_driver, "off", new_callable=AsyncMock) as mock_off: - with patch.object(ridesx_power_driver, "on", new_callable=AsyncMock) as mock_on: - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - await ridesx_power_driver.cycle(delay=0.1) + with ( + patch.object(ridesx_power_driver, "off", new_callable=AsyncMock) as mock_off, + patch.object(ridesx_power_driver, "on", new_callable=AsyncMock) as mock_on, + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + ): + await ridesx_power_driver.cycle(delay=0.1) - mock_off.assert_called_once() - mock_on.assert_called_once() - mock_sleep.assert_called_once_with(0.1) + mock_off.assert_called_once() + mock_on.assert_called_once() + mock_sleep.assert_called_once_with(0.1) def test_power_rescue(ridesx_power_driver): """Test that rescue raises NotImplementedError""" - with serve(ridesx_power_driver) as client: - with pytest.raises(NotImplementedError, match="Rescue mode not available"): - client.call("rescue") + with serve(ridesx_power_driver) as client, pytest.raises(NotImplementedError, match="Rescue mode not available"): + client.call("rescue") # Flash OCI Image Tests @@ -521,44 +514,48 @@ def test_power_rescue(ridesx_power_driver): def test_flash_oci_image_success(temp_storage_dir, ridesx_driver): - with serve(ridesx_driver) as client: - with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="/usr/local/bin/fls"): - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "Flashing complete" - mock_result.stderr = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with ( + serve(ridesx_driver) as client, + patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="/usr/local/bin/fls"), + patch("subprocess.run") as mock_subprocess, + ): + mock_result = MagicMock() + mock_result.stdout = "Flashing complete" + mock_result.stderr = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client.call("flash_oci_image", "oci://quay.io/image:tag", None) + result = client.call("flash_oci_image", "oci://quay.io/image:tag", None) - assert result["status"] == "success" - mock_subprocess.assert_called_once() - call_args = mock_subprocess.call_args[0][0] - assert call_args[0] == "/usr/local/bin/fls" - assert call_args[1] == "fastboot" - assert call_args[2] == "oci://quay.io/image:tag" + assert result["status"] == "success" + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert call_args[0] == "/usr/local/bin/fls" + assert call_args[1] == "fastboot" + assert call_args[2] == "oci://quay.io/image:tag" def test_flash_oci_image_with_partitions(temp_storage_dir, ridesx_driver): - with serve(ridesx_driver) as client: - with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"): - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "Flashing complete" - mock_result.stderr = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with ( + serve(ridesx_driver) as client, + patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"), + patch("subprocess.run") as mock_subprocess, + ): + mock_result = MagicMock() + mock_result.stdout = "Flashing complete" + mock_result.stderr = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - partitions = {"boot_a": "boot.img", "system_a": "rootfs.simg"} - result = client.call("flash_oci_image", "oci://image:tag", partitions) + partitions = {"boot_a": "boot.img", "system_a": "rootfs.simg"} + result = client.call("flash_oci_image", "oci://image:tag", partitions) - assert result["status"] == "success" - call_args = mock_subprocess.call_args[0][0] - # Check that -t flags are present for partitions - assert "-t" in call_args - assert "boot_a:boot.img" in call_args - assert "system_a:rootfs.simg" in call_args + assert result["status"] == "success" + call_args = mock_subprocess.call_args[0][0] + # Check that -t flags are present for partitions + assert "-t" in call_args + assert "boot_a:boot.img" in call_args + assert "system_a:rootfs.simg" in call_args def test_flash_oci_image_error_cases(temp_storage_dir, ridesx_driver): @@ -570,55 +567,59 @@ def test_flash_oci_image_error_cases(temp_storage_dir, ridesx_driver): with pytest.raises(DriverError, match="OCI URL must start with oci://"): client.call("flash_oci_image", "docker://image:tag", None) - with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"): - with patch("subprocess.run") as mock_subprocess: - # CalledProcessError - error = subprocess.CalledProcessError(1, "fls") - error.stdout = "" - error.stderr = "Flash failed" - mock_subprocess.side_effect = error + with ( + patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"), + patch("subprocess.run") as mock_subprocess, + ): + # CalledProcessError + error = subprocess.CalledProcessError(1, "fls") + error.stdout = "" + error.stderr = "Flash failed" + mock_subprocess.side_effect = error - with pytest.raises(DriverError, match="FLS fastboot failed: Flash failed"): - client.call("flash_oci_image", "oci://image:tag", None) + with pytest.raises(DriverError, match="FLS fastboot failed: Flash failed"): + client.call("flash_oci_image", "oci://image:tag", None) - # TimeoutExpired - mock_subprocess.side_effect = subprocess.TimeoutExpired("fls", 1800) + # TimeoutExpired + mock_subprocess.side_effect = subprocess.TimeoutExpired("fls", 1800) - with pytest.raises(DriverError, match="FLS fastboot auto-detection timeout"): - client.call("flash_oci_image", "oci://image:tag", None) + with pytest.raises(DriverError, match="FLS fastboot auto-detection timeout"): + client.call("flash_oci_image", "oci://image:tag", None) - # FileNotFoundError - mock_subprocess.side_effect = FileNotFoundError("fls not found") + # FileNotFoundError + mock_subprocess.side_effect = FileNotFoundError("fls not found") - with pytest.raises(DriverError, match="FLS command not found"): - client.call("flash_oci_image", "oci://image:tag", None) + with pytest.raises(DriverError, match="FLS command not found"): + client.call("flash_oci_image", "oci://image:tag", None) def test_flash_oci_image_with_credentials(temp_storage_dir, ridesx_driver): """Test that OCI credentials are passed via env vars to FLS""" - with serve(ridesx_driver) as client: - with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"): - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "Flashing complete" - mock_result.stderr = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result - - result = client.call("flash_oci_image", "oci://quay.io/private/image:tag", None, "myuser", "mypass") - - assert result["status"] == "success" - # Credentials should NOT appear in the command args - call_args = mock_subprocess.call_args[0][0] - assert "-u" not in call_args - assert "-p" not in call_args - assert "myuser" not in call_args - assert "mypass" not in call_args - # Credentials should be passed via env vars - call_kwargs = mock_subprocess.call_args[1] - env = call_kwargs["env"] - assert env["FLS_REGISTRY_USERNAME"] == "myuser" - assert env["FLS_REGISTRY_PASSWORD"] == "mypass" + with ( + serve(ridesx_driver) as client, + patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"), + patch("subprocess.run") as mock_subprocess, + ): + mock_result = MagicMock() + mock_result.stdout = "Flashing complete" + mock_result.stderr = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result + + result = client.call("flash_oci_image", "oci://quay.io/private/image:tag", None, "myuser", "mypass") + + assert result["status"] == "success" + # Credentials should NOT appear in the command args + call_args = mock_subprocess.call_args[0][0] + assert "-u" not in call_args + assert "-p" not in call_args + assert "myuser" not in call_args + assert "mypass" not in call_args + # Credentials should be passed via env vars + call_kwargs = mock_subprocess.call_args[1] + env = call_kwargs["env"] + assert env["FLS_REGISTRY_USERNAME"] == "myuser" + assert env["FLS_REGISTRY_PASSWORD"] == "mypass" def test_flash_oci_image_partial_credentials_rejected(temp_storage_dir, ridesx_driver): @@ -635,32 +636,32 @@ def test_flash_oci_image_partial_credentials_rejected(temp_storage_dir, ridesx_d def test_flash_oci_image_no_credentials(temp_storage_dir, ridesx_driver): """Test that omitting credentials works (anonymous access)""" - with serve(ridesx_driver) as client: - with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"): - with patch("subprocess.run") as mock_subprocess: - mock_result = MagicMock() - mock_result.stdout = "Flashing complete" - mock_result.stderr = "" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with ( + serve(ridesx_driver) as client, + patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"), + patch("subprocess.run") as mock_subprocess, + ): + mock_result = MagicMock() + mock_result.stdout = "Flashing complete" + mock_result.stderr = "" + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client.call("flash_oci_image", "oci://image:tag", None, None, None) + result = client.call("flash_oci_image", "oci://image:tag", None, None, None) - assert result["status"] == "success" - call_kwargs = mock_subprocess.call_args[1] - env = call_kwargs["env"] - assert "FLS_REGISTRY_USERNAME" not in env - assert "FLS_REGISTRY_PASSWORD" not in env + assert result["status"] == "success" + call_kwargs = mock_subprocess.call_args[1] + env = call_kwargs["env"] + assert "FLS_REGISTRY_USERNAME" not in env + assert "FLS_REGISTRY_PASSWORD" not in env def test_flash_oci_image_requires_oci_scheme(temp_storage_dir, ridesx_driver): """Test that only oci:// URLs are accepted""" from jumpstarter.client.core import DriverError - with serve(ridesx_driver) as client: - # Bare registry URL should be rejected - with pytest.raises(DriverError, match="OCI URL must start with oci://"): - client.call("flash_oci_image", "quay.io/org/image:v1", None) + with serve(ridesx_driver) as client, pytest.raises(DriverError, match="OCI URL must start with oci://"): + client.call("flash_oci_image", "quay.io/org/image:v1", None) # OCI URL Validation Tests (direct unit tests for _validate_oci_url) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py index 91a453659..8f67b1d5b 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/client.py @@ -278,6 +278,7 @@ def flash_stream( if url is not None: source_id = hashlib.sha256(str(path).encode()).hexdigest()[:12] else: + assert local_path is not None stat = local_path.stat() source_id = hashlib.sha256( f"{local_path.resolve()}:{stat.st_mtime_ns}:{stat.st_size}".encode(), @@ -318,7 +319,6 @@ def cli(self): @driver_click_group(self) def base(): """Qualcomm firmware flasher""" - pass @base.command() @click.argument("file", metavar="FILE|URL") diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py index 47e26fc56..9c6d518c4 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver.py @@ -3,12 +3,12 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import re import shutil import subprocess -import sys import tarfile import tempfile import time @@ -127,9 +127,8 @@ def _validate_member(member: tarfile.TarInfo, extract_root: Path) -> None: @staticmethod def _safe_extractall(archive: tarfile.TarFile, extract_root: Path) -> None: - if sys.version_info >= (3, 12): - archive.extractall(path=extract_root, filter="data") - return + archive.extractall(path=extract_root, filter="data") + return destination = extract_root.resolve() for member in archive.getmembers(): QualcommFlasher._validate_member(member, destination) @@ -212,25 +211,25 @@ def _write_cache_marker( @staticmethod def _extract_source_url(source: Any) -> str | None: """Extract the URL from a PresignedRequestResource handle, or None.""" - try: + with contextlib.suppress(Exception): handle = TypeAdapter(Resource).validate_python(source) if isinstance(handle, PresignedRequestResource) and handle.method == "GET": return handle.url - except Exception: - pass return None @staticmethod async def _http_head_metadata(url: str) -> dict[str, str]: """Fetch ETag/Last-Modified/Content-Length via a HEAD request.""" metadata: dict[str, str] = {} - async with aiohttp.ClientSession() as session: - async with session.head(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=15)) as resp: - if resp.status == 200: - for key in ("ETag", "Last-Modified", "Content-Length"): - value = resp.headers.get(key) - if value: - metadata[key] = value + async with ( + aiohttp.ClientSession() as session, + session.head(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=15)) as resp, + ): + if resp.status == 200: + for key in ("ETag", "Last-Modified", "Content-Length"): + value = resp.headers.get(key) + if value: + metadata[key] = value return metadata @staticmethod @@ -278,7 +277,7 @@ async def _cache_is_fresh( return True try: current = await self._http_head_metadata(url) - except Exception: + except Exception: # noqa: BLE001 logger.debug("HEAD request failed for cache freshness check; assuming fresh") return True for key in ("ETag", "Last-Modified", "Content-Length"): @@ -354,7 +353,7 @@ async def _collect_source_metadata(self, source: Any) -> dict[str, str] | None: return None try: return await self._http_head_metadata(source_url) - except Exception: + except Exception: # noqa: BLE001 logger.debug("Failed to collect HTTP metadata for cache marker") return None @@ -477,7 +476,7 @@ def _start_tar(header: bytes, extract_root: Path) -> subprocess.Popen: decompress_flag = QualcommFlasher._detect_compression(header) tar_cmd = QualcommFlasher._build_tar_cmd(extract_root, decompress_flag) logger.info("Running: %s", " ".join(tar_cmd)) - stderr_file = tempfile.TemporaryFile() + stderr_file = tempfile.TemporaryFile() # noqa: SIM115 return subprocess.Popen( tar_cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=stderr_file, ) @@ -655,7 +654,7 @@ async def flash( ctx.manifest, ctx.firmware_root, ): yield status - except Exception as exc: + except Exception as exc: # noqa: BLE001 yield FlashStatus(phase=FlashPhase.ERROR, message=str(exc)) if ctx.cache_dir is not None and not self._cache_is_valid(ctx.cache_dir): shutil.rmtree(ctx.cache_dir, ignore_errors=True) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py index 392dec217..cfc4384f4 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/driver_test.py @@ -96,8 +96,10 @@ def test_safe_extractall_rejects_path_traversal(tmp_path): archive.addfile(info, io.BytesIO(b"evil")) - with tarfile.open(archive_path, "r") as archive: + with ( + tarfile.open(archive_path, "r") as archive, # Python 3.12+ filter="data" raises OutsideDestinationError (a FilterError), # while our 3.11 fallback raises ExtractError. Both are subclasses of TarError. - with pytest.raises(tarfile.TarError): - QualcommFlasher._safe_extractall(archive, extract_root) + pytest.raises(tarfile.TarError), + ): + QualcommFlasher._safe_extractall(archive, extract_root) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py index aba417622..560b206f8 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/executor_test.py @@ -53,20 +53,7 @@ def test_fix_provision_default_xml_strips_invalid_header(tmp_path): ufs_dir.mkdir() provision = ufs_dir / "provision_default.xml" provision.write_text( - "\n".join( - [ - "", - "", - "", - "", - "", - "", - "", - "", - "", - '', - ] - ), + '\n\n\n\n\n\n\n\n\n', # noqa: E501 encoding="utf-8", ) fix_provision_default_xml(ufs_dir) diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/firmware_id.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/firmware_id.py index b76800c88..4cad64185 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/firmware_id.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/firmware_id.py @@ -120,9 +120,7 @@ def _process_match( def _validate_main_version(key: str, value: str) -> bool: """Reject spurious UEFI version matches that are too short or lack BOOT.""" - if key == "uefi_version" and (len(value) < 10 or ("BOOT" not in value and len(value) < 20)): - return False - return True + return not (key == "uefi_version" and (len(value) < 10 or "BOOT" not in value and len(value) < 20)) def extract_sail_version(sail, timeout=60, log_buffer=None) -> dict[str, str]: @@ -202,28 +200,27 @@ def collect_version_info(serial, sail, power_cycle_callable, *, verbose=False, c sail_capture = _SerialCapture() main_capture = _SerialCapture() - with sail: - with serial: - if verbose: - logger.info("Power cycling device...") - power_cycle_callable() - if verbose: - logger.info("Collecting version information...") - # Run both scans concurrently: SAIL output appears early in - # boot while main serial output spans SBL1 through UEFI/ABL. - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - sail_future = pool.submit(extract_sail_version, sail, timeout=60, log_buffer=sail_capture) - main_future = pool.submit(extract_main_version, serial, timeout=60, log_buffer=main_capture) - result.sail_versions = sail_future.result() - result.main_versions = main_future.result() - if "hypervisor" in result.sail_versions and "hypervisor" not in result.main_versions: - result.main_versions["hypervisor"] = result.sail_versions["hypervisor"] - qc_image_version = result.main_versions.get("qc_image_version") - rm_version = result.main_versions.get("rm_version") - if qc_image_version: - result.firmware_variant = identify_firmware_variant(qc_image_version, rm_version=rm_version) + with sail, serial: + if verbose: + logger.info("Power cycling device...") + power_cycle_callable() + if verbose: + logger.info("Collecting version information...") + # Run both scans concurrently: SAIL output appears early in + # boot while main serial output spans SBL1 through UEFI/ABL. + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + sail_future = pool.submit(extract_sail_version, sail, timeout=60, log_buffer=sail_capture) + main_future = pool.submit(extract_main_version, serial, timeout=60, log_buffer=main_capture) + result.sail_versions = sail_future.result() + result.main_versions = main_future.result() + if "hypervisor" in result.sail_versions and "hypervisor" not in result.main_versions: + result.main_versions["hypervisor"] = result.sail_versions["hypervisor"] + qc_image_version = result.main_versions.get("qc_image_version") + rm_version = result.main_versions.get("rm_version") + if qc_image_version: + result.firmware_variant = identify_firmware_variant(qc_image_version, rm_version=rm_version) result.sail_raw = sail_capture.getvalue() result.main_raw = main_capture.getvalue() diff --git a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py index f769b20c4..1f6b563a7 100644 --- a/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py +++ b/python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/qdl/schema.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated, Literal, Union +from typing import Annotated, Literal import yaml from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -70,7 +70,7 @@ class FastbootStep(StepBase): Step = Annotated[ - Union[SetModeStep, SleepStep, QdlStep, FastbootStep], + SetModeStep | SleepStep | QdlStep | FastbootStep, Field(discriminator=None), ] diff --git a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py index f8821588c..50e846378 100644 --- a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py +++ b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py @@ -46,7 +46,6 @@ def cli(self): @driver_click_group(self) def base(): """Shell command executor""" - pass # Get available methods from the driver if self._methods is None: diff --git a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py index e6e2530e6..a7295a301 100644 --- a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py +++ b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py @@ -1,10 +1,11 @@ import asyncio import asyncio.subprocess +import contextlib import os import signal import subprocess +from collections.abc import AsyncGenerator from dataclasses import dataclass, field -from typing import AsyncGenerator from jumpstarter.driver import Driver, export @@ -126,7 +127,7 @@ def _validate_script_params(self, script, args, env_vars): # Validate arguments for arg in args: if not isinstance(arg, str): - raise ValueError(f"All arguments must be strings, got {type(arg)}") + raise TypeError(f"All arguments must be strings, got {type(arg)}") # pragma: no cover # Validate working directory if set if self.cwd and not os.path.isdir(self.cwd): @@ -146,27 +147,23 @@ async def _read_process_output(self, process, read_all=False): # Read from stdout if process.stdout: - try: + with contextlib.suppress(Exception): if read_all: chunk = await process.stdout.read() else: chunk = await asyncio.wait_for(process.stdout.read(1024), timeout=0.01) if chunk: stdout_data = chunk.decode('utf-8', errors='replace') - except (asyncio.TimeoutError, Exception): - pass # Read from stderr if process.stderr: - try: + with contextlib.suppress(Exception): if read_all: chunk = await process.stderr.read() else: chunk = await asyncio.wait_for(process.stderr.read(1024), timeout=0.01) if chunk: stderr_data = chunk.decode('utf-8', errors='replace') - except (asyncio.TimeoutError, Exception): - pass return stdout_data, stderr_data @@ -216,7 +213,7 @@ async def _run_inline_shell_script( pass try: await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: + except TimeoutError: # pragma: no cover try: os.killpg(process.pid, signal.SIGKILL) self.logger.warning(f"SIGTERM failed to terminate {process.pid}, sending SIGKILL") @@ -234,7 +231,7 @@ async def _run_inline_shell_script( # Small delay to prevent busy waiting await asyncio.sleep(0.1) - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 break # Process completed, get return code and final output diff --git a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py index 0bff21a32..2a538167f 100644 --- a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py +++ b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py @@ -240,7 +240,7 @@ def test_blocked_env_var_prefixes(client): def test_safe_env_vars_allowed(client): """Test that legitimate environment variables still work""" - stdout, stderr, returncode = _collect_streaming_output(client, "env", {"ENV1": "safe_value"}) + stdout, _stderr, returncode = _collect_streaming_output(client, "env", {"ENV1": "safe_value"}) assert stdout == "safe_value\n" assert returncode == 0 diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/__init__.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/__init__.py index 7b134cb86..e53c281a6 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/__init__.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/__init__.py @@ -7,5 +7,5 @@ ) from jumpstarter_driver_sigrok.driver import Sigrok -__all__ = ["Sigrok", "CaptureConfig", "CaptureResult", "DecoderConfig", "OutputFormat", "Sample"] +__all__ = ["CaptureConfig", "CaptureResult", "DecoderConfig", "OutputFormat", "Sample", "Sigrok"] diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/client.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/client.py index 58b4439d5..cd9612f71 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/client.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/client.py @@ -27,8 +27,7 @@ def capture_stream(self, config: CaptureConfig | dict) -> Generator[bytes, None, Yields: bytes: Chunks of captured data """ - for chunk in self.streamingcall("capture_stream", config): - yield chunk + yield from self.streamingcall("capture_stream", config) # pragma: no cover def get_driver_info(self) -> dict[str, Any]: return self.call("get_driver_info") diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/common.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/common.py index 18416baea..e64fce8aa 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/common.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/common.py @@ -76,7 +76,7 @@ class DecoderConfig(BaseModel): channels: dict[str, str] | None = None options: dict[str, str | int | float | bool] | None = None annotations: list[str] | None = None - stack: list["DecoderConfig"] | None = None + stack: list[DecoderConfig] | None = None @field_validator("name") @classmethod @@ -103,7 +103,9 @@ def validate_channels(cls, v: dict[str, str] | None) -> dict[str, str] | None: @field_validator("options") @classmethod - def validate_options(cls, v: dict[str, str | int | float | bool] | None) -> dict[str, str | int | float | bool] | None: # noqa: E501 + def validate_options( + cls, v: dict[str, str | int | float | bool] | None + ) -> dict[str, str | int | float | bool] | None: if v is None: return v for key, value in v.items(): diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv.py index 81bf32c60..d8aceb359 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv.py @@ -3,7 +3,7 @@ from __future__ import annotations import csv -from typing import Iterator +from collections.abc import Iterator def parse_csv(data: bytes, sample_rate: str) -> Iterator[dict]: diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv_test.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv_test.py index a5a119fa3..4090bf365 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv_test.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/csv_test.py @@ -197,7 +197,7 @@ def test_csv_format_analog_channels(demo_client: SigrokClient): assert len(first_sample.values) > 0 # Analog values should be floats (voltages) - for _channel, value in first_sample.values.items(): + for value in first_sample.values.values(): assert isinstance(value, (int, float)) diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver.py index 2d93af70e..3c3fafd04 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver.py @@ -127,7 +127,7 @@ async def capture_stream(self, config: CaptureConfig | dict) -> AsyncIterator[by process.terminate() try: await asyncio.wait_for(process.wait(), timeout=5) - except asyncio.TimeoutError: + except TimeoutError: # pragma: no cover process.kill() # --- Command builders ----------------------------------------------- diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver_test.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver_test.py index 54401e15e..4f3559ab1 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver_test.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/driver_test.py @@ -386,7 +386,7 @@ def test_decode_vcd_format(demo_client): assert len(sample.values) > 0 # Values should be integers for digital channels - for _channel, value in sample.values.items(): + for value in sample.values.values(): assert isinstance(value, int) @@ -467,7 +467,7 @@ def test_decode_analog_csv(demo_client): assert len(first_sample.values) > 0 # Analog values should be floats (voltages) - for _channel, value in first_sample.values.items(): + for value in first_sample.values.values(): assert isinstance(value, (int, float)) @@ -725,13 +725,17 @@ class TestTimeoutEnforcement: def test_scan_timeout_propagated(self): driver = Sigrok(driver="demo", executable="/usr/bin/sigrok-cli", timeout=10) - with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sigrok-cli", timeout=10)): - with pytest.raises(subprocess.TimeoutExpired): - driver.scan() + with ( + patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sigrok-cli", timeout=10)), + pytest.raises(subprocess.TimeoutExpired), + ): + driver.scan() def test_capture_timeout_propagated(self): driver = Sigrok(driver="demo", executable="/usr/bin/sigrok-cli", timeout=10) cfg = CaptureConfig(sample_rate="1M", samples=100) - with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sigrok-cli", timeout=10)): - with pytest.raises(subprocess.TimeoutExpired): - driver.capture(cfg) + with ( + patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sigrok-cli", timeout=10)), + pytest.raises(subprocess.TimeoutExpired), + ): + driver.capture(cfg) diff --git a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/vcd.py b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/vcd.py index b94d3950f..cee846b06 100644 --- a/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/vcd.py +++ b/python/packages/jumpstarter-driver-sigrok/jumpstarter_driver_sigrok/vcd.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Iterator +from collections.abc import Iterator logger = logging.getLogger(__name__) diff --git a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/client.py b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/client.py index 17777e46f..b651b1807 100644 --- a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/client.py +++ b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/client.py @@ -21,7 +21,6 @@ def cli(self): @driver_click_group(self) def snmp(): """SNMP power control commands""" - pass for cmd in super().cli().commands.values(): snmp.add_command(cmd) diff --git a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver.py b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver.py index 3e322e85a..79732cf37 100644 --- a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver.py +++ b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver.py @@ -2,7 +2,7 @@ import socket from dataclasses import dataclass, field from enum import Enum, IntEnum -from typing import Any, Dict +from typing import Any from pysnmp.carrier.asyncio.dgram import udp from pysnmp.entity import config, engine @@ -32,7 +32,6 @@ class PowerState(IntEnum): class SNMPError(Exception): """Base exception for SNMP errors""" - pass @dataclass(kw_only=True) @@ -127,7 +126,7 @@ def _setup_snmp(self): def client(cls) -> str: return "jumpstarter_driver_snmp.client.SNMPServerClient" - def _create_snmp_callback(self, result: Dict[str, Any], response_received: asyncio.Event): + def _create_snmp_callback(self, result: dict[str, Any], response_received: asyncio.Event): def callback(snmpEngine, sendRequestHandle, errorIndication, errorStatus, errorIndex, varBinds, cbCtx): self.logger.debug(f"Callback {errorIndication} {errorStatus} {errorIndex} {varBinds}") if errorIndication: @@ -191,7 +190,7 @@ async def _snmp_set(self, state: PowerState): self._run_snmp_dispatcher(snmp_engine, response_received), self.timeout ) - except asyncio.TimeoutError: + except TimeoutError: self.logger.warning(f"SNMP operation timed out after {self.timeout} seconds") result["error"] = "SNMP operation timed out" @@ -203,7 +202,7 @@ async def _snmp_set(self, state: PowerState): except SNMPError: raise except Exception as e: - error_msg = f"SNMP set failed: {str(e)}" + error_msg = f"SNMP set failed: {e!s}" self.logger.error(error_msg) raise SNMPError(error_msg) from e diff --git a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py index 58642b3d6..4948bb033 100644 --- a/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py +++ b/python/packages/jumpstarter-driver-snmp/jumpstarter_driver_snmp/driver_protocol_test.py @@ -14,7 +14,7 @@ import time from collections.abc import Generator from contextlib import contextmanager -from typing import Any +from typing import Any, Self import pytest from pysnmp.carrier.asyncio.dgram import udp @@ -112,7 +112,7 @@ def _run(self, sock: socket.socket): try: try: self._loop.run_until_complete(self._setup(sock)) - except BaseException as e: + except BaseException as e: # noqa: BLE001 self._error = e self._ready.set() return @@ -164,7 +164,7 @@ def stop(self): if self._thread.is_alive(): raise RuntimeError("SNMP test agent did not stop in time") - def __enter__(self) -> SnmpTestAgent: + def __enter__(self) -> Self: return self.start() def __exit__(self, *exc_info): diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py index 6522e9906..6dead05b9 100644 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py @@ -86,7 +86,7 @@ def _read_someip_message(conn: socket.socket) -> tuple[int, int, int, int, int, return None header += chunk - service_id, method_id, length, client_id, session_id, proto_ver, iface_ver, msg_type, ret_code = struct.unpack( + service_id, method_id, length, client_id, session_id, _proto_ver, _iface_ver, msg_type, ret_code = struct.unpack( "!HHIHHBBBB", header ) @@ -142,7 +142,7 @@ def _handle_client(self, conn: socket.socket): result = _read_someip_message(conn) if result is None: break - service_id, method_id, client_id, session_id, msg_type, ret_code, payload = result + service_id, method_id, client_id, session_id, msg_type, _ret_code, payload = result responses = self._dispatch( service_id, method_id, client_id, session_id, msg_type, payload ) @@ -345,10 +345,12 @@ def find(self, service, *, callback=None): """Simulate service discovery by calling back with matching registered services.""" self._require_started() for svc in self._registered_services: - if svc.service_id == service.service_id: - if service.instance_id == 0xFFFF or svc.instance_id == service.instance_id: - if callback: - callback(svc) + if ( + svc.service_id == service.service_id + and (service.instance_id == 0xFFFF or svc.instance_id == service.instance_id) + and callback + ): + callback(svc) def subscribe_events(self, eventgroup_id: int): self._require_started() @@ -492,10 +494,12 @@ def call(self, message_id, *, payload: bytes = b"", timeout: float = 5.0): def find(self, service, *, callback=None): self._require_started() for svc in self._server.offered_services: - if svc.service_id == service.service_id: - if service.instance_id == 0xFFFF or svc.instance_id == service.instance_id: - if callback: - callback(svc) + if ( + svc.service_id == service.service_id + and (service.instance_id == 0xFFFF or svc.instance_id == service.instance_id) + and callback + ): + callback(svc) @pytest.fixture diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py old mode 100755 new mode 100644 index 0433bfc5e..9cc8a0d0c --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py @@ -16,7 +16,8 @@ try: from opensomeip._bridge import get_ext except ImportError: - get_ext = lambda: None # noqa: E731 # ty: ignore[invalid-assignment] + def get_ext(): # pragma: no cover # ty: ignore[invalid-assignment] + return None from pydantic import ConfigDict, SkipValidation, validate_call from pydantic.dataclasses import dataclass diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py index 839bbdea3..19d426d07 100644 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py @@ -251,9 +251,8 @@ def test_someip_rpc_call_timeout(mock_osip_cls): mock_osip_cls.return_value = mock_client driver = SomeIp(host="127.0.0.1", port=30490) - with serve(driver) as client: - with pytest.raises(DriverError, match="No response from service"): - client.rpc_call(0x1234, 0x0001, b"\x01") + with serve(driver) as client, pytest.raises(DriverError, match="No response from service"): + client.rpc_call(0x1234, 0x0001, b"\x01") @patch("jumpstarter_driver_someip.driver.OsipClient") @@ -263,9 +262,8 @@ def test_someip_receive_message_timeout(mock_osip_cls): mock_osip_cls.return_value = mock_client driver = SomeIp(host="127.0.0.1", port=30490) - with serve(driver) as client: - with pytest.raises(DriverError, match="No message received"): - client.receive_message(timeout=0.1) + with serve(driver) as client, pytest.raises(DriverError, match="No message received"): + client.receive_message(timeout=0.1) @patch("jumpstarter_driver_someip.driver.OsipClient") @@ -277,9 +275,8 @@ def test_someip_receive_event_timeout(mock_osip_cls): mock_osip_cls.return_value = mock_client driver = SomeIp(host="127.0.0.1", port=30490) - with serve(driver) as client: - with pytest.raises(DriverError, match="No event received"): - client.receive_event(timeout=0.1) + with serve(driver) as client, pytest.raises(DriverError, match="No event received"): + client.receive_event(timeout=0.1) @patch("jumpstarter_driver_someip.driver.OsipClient") @@ -287,9 +284,8 @@ def test_someip_connection_error(mock_osip_cls): mock_osip_cls.return_value.start.side_effect = ConnectionRefusedError("Connection refused") driver = SomeIp(host="192.168.1.100", port=30490) - with serve(driver) as client: - with pytest.raises(DriverError, match="Connection refused"): - client.start() + with serve(driver) as client, pytest.raises(DriverError, match="Connection refused"): + client.start() # ========================================================================= diff --git a/python/packages/jumpstarter-driver-ssh-mitm/jumpstarter_driver_ssh_mitm/driver.py b/python/packages/jumpstarter-driver-ssh-mitm/jumpstarter_driver_ssh_mitm/driver.py index 05a9ccb63..9e2594af9 100644 --- a/python/packages/jumpstarter-driver-ssh-mitm/jumpstarter_driver_ssh_mitm/driver.py +++ b/python/packages/jumpstarter-driver-ssh-mitm/jumpstarter_driver_ssh_mitm/driver.py @@ -15,7 +15,6 @@ from contextlib import asynccontextmanager, suppress from dataclasses import dataclass, field from pathlib import Path -from typing import Optional import paramiko from anyio import get_cancelled_exc_class @@ -80,7 +79,7 @@ def _forward_recv(self): except (BrokenPipeError, OSError): break self._running = False - except Exception as exc: + except Exception as exc: # noqa: BLE001 socket_logger.debug("recv loop stopped: %s", exc) def _forward_send(self): @@ -95,13 +94,13 @@ def _forward_send(self): self.portal.call(self.send_stream.send, data) else: break - except socket.timeout: + except TimeoutError: # Allow loop to check _running and exit cleanly continue except (BrokenPipeError, OSError): break self._running = False - except Exception as exc: + except Exception as exc: # noqa: BLE001 socket_logger.debug("send loop stopped: %s", exc) def get_paramiko_socket(self): @@ -116,22 +115,14 @@ def close(self): self.portal.call(self.recv_stream.aclose) with suppress(Exception): self.portal.call(self.send_stream.aclose) - try: + with suppress(Exception): self.client_sock.shutdown(socket.SHUT_RDWR) - except Exception: - pass - try: + with suppress(Exception): self.server_sock.shutdown(socket.SHUT_RDWR) - except Exception: - pass - try: + with suppress(Exception): self.client_sock.close() - except Exception: - pass - try: + with suppress(Exception): self.server_sock.close() - except Exception: - pass self._recv_thread.join(timeout=5) self._send_thread.join(timeout=5) if self._recv_thread.is_alive() or self._send_thread.is_alive(): @@ -156,9 +147,7 @@ def __init__(self, allowed_username: str = "", default_dut_username: str = ""): self.pty_term: str = "xterm" def _check_username(self, username: str | None) -> bool: - if self.allowed_username and username and username != self.allowed_username: - return False - return True + return not (self.allowed_username and username and username != self.allowed_username) def check_channel_request(self, kind, chanid): if kind == "session": @@ -220,7 +209,7 @@ class SSHMITM(Driver): default_pty_width: int = 80 default_pty_height: int = 24 - _host_key: Optional[paramiko.RSAKey] = field(init=False, default=None) + _host_key: paramiko.RSAKey | None = field(init=False, default=None) def __post_init__(self): if hasattr(super(), "__post_init__"): @@ -249,7 +238,7 @@ def _get_ssh_identity(self) -> str | None: if self.ssh_identity_file: try: return Path(self.ssh_identity_file).expanduser().read_text() - except Exception as e: + except Exception as e: # noqa: BLE001 raise ConfigurationError(f"Failed to read ssh_identity_file '{self.ssh_identity_file}': {e}") from None return None @@ -325,13 +314,11 @@ def forward(src, dst, name): if not data: break dst.sendall(data) - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.debug("Channel %s ended: %s", name, e) finally: - try: + with suppress(Exception): dst.close() - except Exception: - pass t1 = threading.Thread(target=forward, args=(client_channel, dut_channel, "client→dut"), daemon=True) t2 = threading.Thread(target=forward, args=(dut_channel, client_channel, "dut→client"), daemon=True) @@ -362,7 +349,7 @@ def _open_dut_channel(self, server: MITMServerInterface) -> tuple[paramiko.SSHCl return dut_client, channel - def _handle_session(self, transport: paramiko.Transport): # noqa: C901 + def _handle_session(self, transport: paramiko.Transport): """Handle incoming SSH session: accept client, connect to DUT, proxy.""" server = MITMServerInterface(self.default_username, default_dut_username=self.default_username) @@ -394,28 +381,21 @@ def _handle_session(self, transport: paramiko.Transport): # noqa: C901 self._proxy_channels(client_channel, dut_channel) if server.exec_command: - try: + with suppress(Exception): exit_status = dut_channel.recv_exit_status() client_channel.send_exit_status(exit_status) - except Exception: - pass - finally: - client_channel.close() + client_channel.close() - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.error("Failed to connect to DUT: %s", e) client_channel.close() finally: if dut_channel: - try: + with suppress(Exception): dut_channel.close() - except Exception: - pass if dut_client: - try: + with suppress(Exception): dut_client.close() - except Exception: - pass transport.close() @exportstream @@ -448,7 +428,7 @@ async def connect(self): try: yield client_stream - except (cancelled_exc, Exception) as e: + except (cancelled_exc, Exception) as e: # noqa: BLE001 if isinstance(e, cancelled_exc): self.logger.debug("SSH stream cancelled by client") else: diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py index d2d914ead..b185bf00a 100644 --- a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import shutil import subprocess @@ -288,6 +289,7 @@ def _run_subshell(self, mountpoint: str, remote_path: str) -> None: subprocess.run( [shell, "--norc", "--noprofile", "-i"], env=env, + check=False, ) elif shell_name == "fish": if no_color: @@ -313,12 +315,12 @@ def _run_subshell(self, mountpoint: str, remote_path: str) -> None: "set_color normal; " "end" ) - subprocess.run([shell, "--init-command", fish_fn], env=env) + subprocess.run([shell, "--init-command", fish_fn], env=env, check=False) elif shell_name == "zsh": env["PS1"] = _tag_mount_ps1(env.get("PS1", "%# "), mount_tag, remote_path, no_icons) - subprocess.run([shell, "--no-rcs", "-i"], env=env) + subprocess.run([shell, "--no-rcs", "-i"], env=env, check=False) else: - subprocess.run([shell, "-i"], env=env) + subprocess.run([shell, "-i"], env=env, check=False) except FileNotFoundError as err: raise click.ClickException( f"Shell '{shell}' not found. Set the SHELL environment variable to a valid shell." @@ -378,22 +380,18 @@ def _create_temp_identity_file(self) -> str | None: except Exception as e: self.logger.error("Failed to create temporary identity file: %s", e) if fd is not None: - try: + with contextlib.suppress(Exception): os.close(fd) - except Exception: - pass if temp_path: - try: + with contextlib.suppress(Exception): os.unlink(temp_path) - except Exception: - pass raise def _cleanup_identity_file(self, identity_file: str | None) -> None: if identity_file: try: os.unlink(identity_file) - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.warning("Failed to clean up identity file %s: %s", identity_file, e) def umount(self, mountpoint: str, *, lazy: bool = False) -> None: @@ -402,7 +400,7 @@ def umount(self, mountpoint: str, *, lazy: bool = False) -> None: cmd = self._build_umount_cmd(mountpoint, lazy=lazy) self.logger.debug("Running unmount command: %s", cmd) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, check=False) if result.returncode != 0: stderr = result.stderr.strip() @@ -413,11 +411,11 @@ def umount(self, mountpoint: str, *, lazy: bool = False) -> None: def _force_umount(self, mountpoint: str) -> None: cmd = self._build_umount_cmd(mountpoint, lazy=False) try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, check=False) if result.returncode != 0: self.logger.debug("Force umount of %s returned %d: %s", mountpoint, result.returncode, result.stderr.strip()) - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.debug("Force umount of %s failed: %s", mountpoint, e) def _build_umount_cmd(self, mountpoint: str, *, lazy: bool = False) -> list[str]: diff --git a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py index cd8589999..a6fe129ec 100644 --- a/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py +++ b/python/packages/jumpstarter-driver-ssh-mount/jumpstarter_driver_ssh_mount/driver_test.py @@ -42,10 +42,12 @@ def test_mount_sshfs_not_installed(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.object(client, '_find_executable', return_value=None): - with pytest.raises(Exception, match="sshfs is not installed"): - client.mount("/tmp/test-mount") + with ( + serve(instance) as client, + patch.object(client, '_find_executable', return_value=None), + pytest.raises(Exception, match="sshfs is not installed"), + ): + client.mount("/tmp/test-mount") def test_mount_sshfs_constructs_correct_args_and_detects_immediate_exit(): @@ -63,26 +65,32 @@ def test_mount_sshfs_constructs_correct_args_and_detects_immediate_exit(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] - - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 2222)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount", remote_path="/home/user") - - popen_args = mock_popen.call_args_list[0][0][0] - assert popen_args[0] == "sshfs" - assert "testuser@127.0.0.1:/home/user" in popen_args - assert os.path.realpath("/tmp/test-mount") in popen_args - assert "-p" in popen_args - assert "2222" in popen_args - assert "-f" in popen_args + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] + + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 2222)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount", remote_path="/home/user") + + popen_args = mock_popen.call_args_list[0][0][0] + assert popen_args[0] == "sshfs" + assert "testuser@127.0.0.1:/home/user" in popen_args + assert os.path.realpath("/tmp/test-mount") in popen_args + assert "-p" in popen_args + assert "2222" in popen_args + assert "-f" in popen_args def test_mount_sshfs_identity_in_args(): @@ -100,25 +108,31 @@ def test_mount_sshfs_identity_in_args(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] - - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount") - - popen_args = mock_popen.call_args_list[0][0][0] - identity_opts = [ - popen_args[i + 1] for i in range(len(popen_args) - 1) - if popen_args[i] == "-o" and popen_args[i + 1].startswith("IdentityFile=") - ] - assert len(identity_opts) == 1 + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] + + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount") + + popen_args = mock_popen.call_args_list[0][0][0] + identity_opts = [ + popen_args[i + 1] for i in range(len(popen_args) - 1) + if popen_args[i] == "-o" and popen_args[i + 1].startswith("IdentityFile=") + ] + assert len(identity_opts) == 1 def test_mount_sshfs_allow_other_fallback(): @@ -147,32 +161,36 @@ def test_mount_sshfs_allow_other_fallback(): second_proc.wait.side_effect = [None] second_proc.poll.return_value = 0 - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', side_effect=[first_proc, second_proc]) as mock_popen: - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount", extra_args=["allow_other"]) - - # First Popen should have allow_other (from extra_args) - first_call_args = mock_popen.call_args_list[0][0][0] - allow_other_found = any("allow_other" in a for a in first_call_args) - assert allow_other_found, "First call should have allow_other" - - # Second Popen (retry) should not have allow_other - second_call_args = mock_popen.call_args_list[1][0][0] - allow_other_found = any("allow_other" in a for a in second_call_args) - assert not allow_other_found, "Retry should not have allow_other" - # Verify no orphaned -o flags - for i, arg in enumerate(second_call_args): - if arg == "-o": - assert i + 1 < len(second_call_args), "Orphaned -o flag found" - assert not second_call_args[i + 1].startswith("-"), \ - f"Orphaned -o flag followed by {second_call_args[i + 1]}" + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', side_effect=[first_proc, second_proc]) as mock_popen, + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount", extra_args=["allow_other"]) + + # First Popen should have allow_other (from extra_args) + first_call_args = mock_popen.call_args_list[0][0][0] + allow_other_found = any("allow_other" in a for a in first_call_args) + assert allow_other_found, "First call should have allow_other" + + # Second Popen (retry) should not have allow_other + second_call_args = mock_popen.call_args_list[1][0][0] + allow_other_found = any("allow_other" in a for a in second_call_args) + assert not allow_other_found, "Retry should not have allow_other" + # Verify no orphaned -o flags + for i, arg in enumerate(second_call_args): + if arg == "-o": + assert i + 1 < len(second_call_args), "Orphaned -o flag found" + assert not second_call_args[i + 1].startswith("-"), \ + f"Orphaned -o flag followed by {second_call_args[i + 1]}" def test_mount_sshfs_generic_failure(): @@ -190,21 +208,25 @@ def test_mount_sshfs_generic_failure(): mock_proc.stderr = mock_stderr mock_proc.wait.side_effect = [None] - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount") + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount") - # Only one Popen call -- no retry since error is not allow_other - assert mock_popen.call_count == 1 - popen_args = mock_popen.call_args_list[0][0][0] - assert popen_args[0] == "sshfs" + # Only one Popen call -- no retry since error is not allow_other + assert mock_popen.call_count == 1 + popen_args = mock_popen.call_args_list[0][0][0] + assert popen_args[0] == "sshfs" def test_mount_sshfs_direct_constructs_correct_args_and_detects_immediate_exit(): @@ -221,20 +243,21 @@ def test_mount_sshfs_direct_constructs_correct_args_and_detects_immediate_exit() mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] - with patch('os.makedirs'): - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount", direct=True) + with patch('os.makedirs'): + with patch.object(client, '_force_umount'), pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount", direct=True) - popen_args = mock_popen.call_args_list[0][0][0] - assert popen_args[0] == "sshfs" - assert "testuser@10.0.0.1:/" in popen_args - assert "-p" in popen_args - assert "2222" in popen_args + popen_args = mock_popen.call_args_list[0][0][0] + assert popen_args[0] == "sshfs" + assert "testuser@10.0.0.1:/" in popen_args + assert "-p" in popen_args + assert "2222" in popen_args def test_mount_sshfs_direct_fallback_to_portforward(): @@ -251,35 +274,41 @@ def test_mount_sshfs_direct_fallback_to_portforward(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 3333)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 3333)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - original_ssh = client.ssh + original_ssh = client.ssh - class FakeTcp: - def address(self): - raise ValueError("not available") + class FakeTcp: + def address(self): + raise ValueError("not available") - class FakeSsh: - def __getattr__(self, name): - if name == "tcp": - return FakeTcp() - return getattr(original_ssh, name) + class FakeSsh: + def __getattr__(self, name): + if name == "tcp": + return FakeTcp() + return getattr(original_ssh, name) - with patch.object(client, 'ssh', FakeSsh()): - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount", direct=True) + with ( + patch.object(client, 'ssh', FakeSsh()), + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount", direct=True) - popen_args = mock_popen.call_args_list[0][0][0] - # Should have used port forwarding (port 3333) - assert "3333" in popen_args + popen_args = mock_popen.call_args_list[0][0][0] + # Should have used port forwarding (port 3333) + assert "3333" in popen_args def test_mount_foreground_mode(): @@ -299,26 +328,28 @@ def test_mount_foreground_mode(): mock_proc.stderr = MagicMock() mock_proc.stderr.close = MagicMock() - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - with patch('os.makedirs'): - # First call: mount readiness poll (must be True to proceed) - # Second call: cleanup check in _run_sshfs finally block - with patch('os.path.ismount', side_effect=[True, False]): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - client.mount("/tmp/test-mount", foreground=True) - - # Should have waited on sshfs (foreground mode) - assert mock_proc.wait.call_count >= 2 - # Port forward should be cleaned up - mock_adapter.return_value.__exit__.assert_called() - # Verify -f flag is in the Popen args - popen_args = mock_popen.call_args_list[0][0][0] - assert "-f" in popen_args + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + patch('os.makedirs'), + # First call: mount readiness poll (must be True to proceed) + # Second call: cleanup check in _run_sshfs finally block + patch('os.path.ismount', side_effect=[True, False]), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with patch.object(client, '_force_umount'): + client.mount("/tmp/test-mount", foreground=True) + + # Should have waited on sshfs (foreground mode) + assert mock_proc.wait.call_count >= 2 + # Port forward should be cleaned up + mock_adapter.return_value.__exit__.assert_called() + # Verify -f flag is in the Popen args + popen_args = mock_popen.call_args_list[0][0][0] + assert "-f" in popen_args def test_mount_subshell_mode(): @@ -337,25 +368,29 @@ def test_mount_subshell_mode(): mock_proc.stderr = MagicMock() mock_proc.stderr.close = MagicMock() - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc): - with patch('os.makedirs'): - # First call: mount readiness poll (must be True to proceed) - # Second call: cleanup check in _run_sshfs finally block - with patch('os.path.ismount', side_effect=[True, False]): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with patch.object(client, '_run_subshell') as mock_subshell: - client.mount("/tmp/test-mount") + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc), + patch('os.makedirs'), + # First call: mount readiness poll (must be True to proceed) + # Second call: cleanup check in _run_sshfs finally block + patch('os.path.ismount', side_effect=[True, False]), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + patch.object(client, '_run_subshell') as mock_subshell, + ): + client.mount("/tmp/test-mount") - # Subshell should have been called - resolved = os.path.realpath("/tmp/test-mount") - mock_subshell.assert_called_once_with(resolved, "/") - # sshfs process should be terminated after subshell exits - mock_proc.terminate.assert_called_once() + # Subshell should have been called + resolved = os.path.realpath("/tmp/test-mount") + mock_subshell.assert_called_once_with(resolved, "/") + # sshfs process should be terminated after subshell exits + mock_proc.terminate.assert_called_once() def test_mount_cleanup_on_failure(): @@ -373,23 +408,24 @@ def test_mount_cleanup_on_failure(): mock_proc.stderr = mock_stderr mock_proc.wait.side_effect = [None] - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc): - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc), + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - with patch.object(client, '_force_umount'): - with patch('os.unlink') as mock_unlink: - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount") + with patch.object(client, '_force_umount'), patch('os.unlink') as mock_unlink: + with pytest.raises(Exception, match="sshfs mount failed"): + client.mount("/tmp/test-mount") - # Identity file should be cleaned up on failure - # Verify unlink was called with a path ending in _ssh_key - assert mock_unlink.called - unlink_path = mock_unlink.call_args_list[-1][0][0] - assert unlink_path.endswith("_ssh_key") + # Identity file should be cleaned up on failure + # Verify unlink was called with a path ending in _ssh_key + assert mock_unlink.called + unlink_path = mock_unlink.call_args_list[-1][0][0] + assert unlink_path.endswith("_ssh_key") def test_umount_with_fusermount(): @@ -401,16 +437,15 @@ def test_umount_with_fusermount(): def _fake_find(name): return "/usr/bin/fusermount" if name == "fusermount" else None - with patch.object(client, '_find_executable', side_effect=_fake_find): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with patch.object(client, '_find_executable', side_effect=_fake_find), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - client.umount("/tmp/test-mount") + client.umount("/tmp/test-mount") - assert mock_run.called - call_args = mock_run.call_args[0][0] - assert call_args[0] == "/usr/bin/fusermount" - assert "-u" in call_args + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert call_args[0] == "/usr/bin/fusermount" + assert "-u" in call_args def test_umount_with_system_umount_fallback(): @@ -418,16 +453,18 @@ def test_umount_with_system_umount_fallback(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.object(client, '_find_executable', return_value=None): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with ( + serve(instance) as client, + patch.object(client, '_find_executable', return_value=None), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - client.umount("/tmp/test-mount") + client.umount("/tmp/test-mount") - assert mock_run.called - call_args = mock_run.call_args[0][0] - assert call_args[0] == "umount" + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert call_args[0] == "umount" def test_umount_lazy(): @@ -439,15 +476,14 @@ def test_umount_lazy(): def _fake_find(name): return "/usr/bin/fusermount" if name == "fusermount" else None - with patch.object(client, '_find_executable', side_effect=_fake_find): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with patch.object(client, '_find_executable', side_effect=_fake_find), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - client.umount("/tmp/test-mount", lazy=True) + client.umount("/tmp/test-mount", lazy=True) - assert mock_run.called - call_args = mock_run.call_args[0][0] - assert "-z" in call_args + assert mock_run.called + call_args = mock_run.call_args[0][0] + assert "-z" in call_args def test_umount_failure(): @@ -459,12 +495,11 @@ def test_umount_failure(): def _fake_find(name): return "/usr/bin/fusermount" if name == "fusermount" else None - with patch.object(client, '_find_executable', side_effect=_fake_find): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not mounted") + with patch.object(client, '_find_executable', side_effect=_fake_find), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not mounted") - with pytest.raises(Exception, match="Unmount failed"): - client.umount("/tmp/test-mount") + with pytest.raises(Exception, match="Unmount failed"): + client.umount("/tmp/test-mount") def test_cli_has_mount_and_umount_flag(): @@ -537,21 +572,23 @@ def test_mount_foreground_keyboard_interrupt(): mock_proc.stderr = MagicMock() mock_proc.stderr.close = MagicMock() - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc): - with patch('os.makedirs'): - # First call: mount readiness poll (must be True to proceed) - # Second call: cleanup check in _run_sshfs finally block - with patch('os.path.ismount', side_effect=[True, False]): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc), + patch('os.makedirs'), + # First call: mount readiness poll (must be True to proceed) + # Second call: cleanup check in _run_sshfs finally block + patch('os.path.ismount', side_effect=[True, False]), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - with patch.object(client, '_force_umount'): - client.mount("/tmp/test-mount", foreground=True) + with patch.object(client, '_force_umount'): + client.mount("/tmp/test-mount", foreground=True) - # sshfs should have been terminated - mock_proc.terminate.assert_called_once() + # sshfs should have been terminated + mock_proc.terminate.assert_called_once() def test_umount_passes_timeout(): @@ -559,15 +596,17 @@ def test_umount_passes_timeout(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.object(client, '_find_executable', return_value=None): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with ( + serve(instance) as client, + patch.object(client, '_find_executable', return_value=None), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - client.umount("/tmp/test-mount") + client.umount("/tmp/test-mount") - # Verify timeout=120 is passed - assert mock_run.call_args[1].get("timeout") == 120 + # Verify timeout=120 is passed + assert mock_run.call_args[1].get("timeout") == 120 def test_mount_port_22_omits_p_flag(): @@ -584,21 +623,27 @@ def test_mount_port_22_omits_p_flag(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount") + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount") - popen_args = mock_popen.call_args_list[0][0][0] - assert "-p" not in popen_args + popen_args = mock_popen.call_args_list[0][0][0] + assert "-p" not in popen_args def test_umount_prefers_fusermount3(): @@ -614,14 +659,13 @@ def _fake_find(name): return "/usr/bin/fusermount" return None - with patch.object(client, '_find_executable', side_effect=_fake_find): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with patch.object(client, '_find_executable', side_effect=_fake_find), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - client.umount("/tmp/test-mount") + client.umount("/tmp/test-mount") - call_args = mock_run.call_args[0][0] - assert call_args[0] == "/usr/bin/fusermount3" + call_args = mock_run.call_args[0][0] + assert call_args[0] == "/usr/bin/fusermount3" def test_umount_lazy_macos_uses_force(): @@ -629,18 +673,20 @@ def test_umount_lazy_macos_uses_force(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.object(client, '_find_executable', return_value=None): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with ( + serve(instance) as client, + patch.object(client, '_find_executable', return_value=None), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - with patch('jumpstarter_driver_ssh_mount.client.sys') as mock_sys: - mock_sys.platform = "darwin" - client.umount("/tmp/test-mount", lazy=True) + with patch('jumpstarter_driver_ssh_mount.client.sys') as mock_sys: + mock_sys.platform = "darwin" + client.umount("/tmp/test-mount", lazy=True) - call_args = mock_run.call_args[0][0] - assert "-f" in call_args - assert "-l" not in call_args + call_args = mock_run.call_args[0][0] + assert "-f" in call_args + assert "-l" not in call_args def test_extra_args_prefixed_with_dash_o(): @@ -657,25 +703,31 @@ def test_extra_args_prefixed_with_dash_o(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] - - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount", extra_args=["reconnect", "cache=yes"]) - - popen_args = mock_popen.call_args_list[0][0][0] - # Each extra arg should be preceded by -o - for extra in ["reconnect", "cache=yes"]: - idx = popen_args.index(extra) - assert popen_args[idx - 1] == "-o", \ - f"Extra arg '{extra}' not preceded by '-o'" + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] + + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount", extra_args=["reconnect", "cache=yes"]) + + popen_args = mock_popen.call_args_list[0][0][0] + # Each extra arg should be preceded by -o + for extra in ["reconnect", "cache=yes"]: + idx = popen_args.index(extra) + assert popen_args[idx - 1] == "-o", \ + f"Extra arg '{extra}' not preceded by '-o'" def test_extra_args_override_default_ssh_options(): @@ -693,38 +745,44 @@ def test_extra_args_override_default_ssh_options(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] - - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount( - "/tmp/test-mount", - extra_args=["StrictHostKeyChecking=yes"], - ) - - popen_args = mock_popen.call_args_list[0][0][0] - # Find positions of both StrictHostKeyChecking options - user_idx = None - default_idx = None - for i, arg in enumerate(popen_args): - if arg == "StrictHostKeyChecking=yes": - user_idx = i - elif arg == "StrictHostKeyChecking=no": - default_idx = i - - assert user_idx is not None, "User option not found in args" - assert default_idx is not None, "Default option not found in args" - assert user_idx < default_idx, ( - "User-supplied option must appear before default " - "for OpenSSH first-match-wins to work" - ) + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] + + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount( + "/tmp/test-mount", + extra_args=["StrictHostKeyChecking=yes"], + ) + + popen_args = mock_popen.call_args_list[0][0][0] + # Find positions of both StrictHostKeyChecking options + user_idx = None + default_idx = None + for i, arg in enumerate(popen_args): + if arg == "StrictHostKeyChecking=yes": + user_idx = i + elif arg == "StrictHostKeyChecking=no": + default_idx = i + + assert user_idx is not None, "User option not found in args" + assert default_idx is not None, "Default option not found in args" + assert user_idx < default_idx, ( + "User-supplied option must appear before default " + "for OpenSSH first-match-wins to work" + ) def test_mount_ipv6_address_bracketed(): @@ -742,24 +800,30 @@ def test_mount_ipv6_address_bracketed(): mock_stderr.close = MagicMock() mock_proc.stderr = mock_stderr - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc) as mock_popen: - mock_proc.wait.side_effect = [None] + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc) as mock_popen, + ): + mock_proc.wait.side_effect = [None] - with patch('os.makedirs'): - with patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("::1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + with ( + patch('os.makedirs'), + patch('jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter') as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("::1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="sshfs mount failed"): - client.mount("/tmp/test-mount") + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="sshfs mount failed"), + ): + client.mount("/tmp/test-mount") - popen_args = mock_popen.call_args_list[0][0][0] - remote_spec = popen_args[1] - assert "[::1]" in remote_spec, ( - f"IPv6 not bracketed in remote spec: {remote_spec}" - ) + popen_args = mock_popen.call_args_list[0][0][0] + remote_spec = popen_args[1] + assert "[::1]" in remote_spec, ( + f"IPv6 not bracketed in remote spec: {remote_spec}" + ) def test_mount_sshfs_not_mounted_after_startup(): @@ -784,24 +848,30 @@ def fake_monotonic(): call_count[0] += 1 return call_count[0] * 100.0 # Jump far ahead to exceed deadline - with patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"): - with patch('subprocess.Popen', return_value=mock_proc): - with patch('os.makedirs'): - with patch('os.path.ismount', return_value=False): - monotonic_path = 'jumpstarter_driver_ssh_mount.client.time.monotonic' - sleep_path = 'jumpstarter_driver_ssh_mount.client.time.sleep' - adapter_path = 'jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter' - with patch(monotonic_path, side_effect=fake_monotonic): - with patch(sleep_path): - with patch(adapter_path) as mock_adapter: - mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) - mock_adapter.return_value.__exit__ = MagicMock(return_value=None) - - with patch.object(client, '_force_umount'): - with pytest.raises(Exception, match="is not mounted"): - client.mount("/tmp/test-mount", foreground=True) - - mock_proc.terminate.assert_called() + with ( + patch.object(client, '_find_executable', return_value="/usr/bin/sshfs"), + patch('subprocess.Popen', return_value=mock_proc), + patch('os.makedirs'), + patch('os.path.ismount', return_value=False), + ): + monotonic_path = 'jumpstarter_driver_ssh_mount.client.time.monotonic' + sleep_path = 'jumpstarter_driver_ssh_mount.client.time.sleep' + adapter_path = 'jumpstarter_driver_ssh_mount.client.TcpPortforwardAdapter' + with ( + patch(monotonic_path, side_effect=fake_monotonic), + patch(sleep_path), + patch(adapter_path) as mock_adapter, + ): + mock_adapter.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 22)) + mock_adapter.return_value.__exit__ = MagicMock(return_value=None) + + with ( + patch.object(client, '_force_umount'), + pytest.raises(Exception, match="is not mounted"), + ): + client.mount("/tmp/test-mount", foreground=True) + + mock_proc.terminate.assert_called() def test_subshell_bad_shell_raises_click_exception(): @@ -809,11 +879,13 @@ def test_subshell_bad_shell_raises_click_exception(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/nonexistent/shell"}): - with patch('subprocess.run', side_effect=FileNotFoundError("No such file")): - with pytest.raises(Exception, match="Shell .* not found"): - client._run_subshell("/tmp/test-mount", "/") + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/nonexistent/shell"}), + patch('subprocess.run', side_effect=FileNotFoundError("No such file")), + pytest.raises(Exception, match="Shell .* not found"), + ): + client._run_subshell("/tmp/test-mount", "/") def test_subshell_fish_prompt(): @@ -821,20 +893,22 @@ def test_subshell_fish_prompt(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/usr/bin/fish"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/usr/bin/fish"}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert call_args[0] == "/usr/bin/fish" - assert "--init-command" in call_args - # The fish_prompt function should contain (mount) and the arrow - init_cmd = call_args[call_args.index("--init-command") + 1] - assert "(mount)" in init_cmd - assert "fish_prompt" in init_cmd + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args[0] == "/usr/bin/fish" + assert "--init-command" in call_args + # The fish_prompt function should contain (mount) and the arrow + init_cmd = call_args[call_args.index("--init-command") + 1] + assert "(mount)" in init_cmd + assert "fish_prompt" in init_cmd def test_subshell_bash_inserts_mount_tag(): @@ -844,15 +918,14 @@ def test_subshell_bash_inserts_mount_tag(): with serve(instance) as client: jmp_ps1 = "\\w ⚡exporter ➤ " - with patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": jmp_ps1}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + with patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": jmp_ps1}), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - env_passed = mock_run.call_args[1].get("env", {}) - assert "(mount)➤" in env_passed.get("PS1", "") - assert "sshfs" not in env_passed.get("PS1", "") + mock_run.assert_called_once() + env_passed = mock_run.call_args[1].get("env", {}) + assert "(mount)➤" in env_passed.get("PS1", "") + assert "sshfs" not in env_passed.get("PS1", "") def test_subshell_bash_fallback_prefix(): @@ -860,14 +933,16 @@ def test_subshell_bash_fallback_prefix(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": r"\$ "}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/home/user") + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": r"\$ "}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/home/user") - env_passed = mock_run.call_args[1].get("env", {}) - assert env_passed.get("PS1", "").startswith("[sshfs:/home/user]") + env_passed = mock_run.call_args[1].get("env", {}) + assert env_passed.get("PS1", "").startswith("[sshfs:/home/user]") def test_subshell_zsh_inserts_mount_tag(): @@ -877,18 +952,17 @@ def test_subshell_zsh_inserts_mount_tag(): with serve(instance) as client: jmp_ps1 = "%~ ⚡exporter ➤ " - with patch.dict(os.environ, {"SHELL": "/bin/zsh", "PS1": jmp_ps1}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + with patch.dict(os.environ, {"SHELL": "/bin/zsh", "PS1": jmp_ps1}), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert call_args[0] == "/bin/zsh" - assert "--no-rcs" in call_args - assert "-i" in call_args - env_passed = mock_run.call_args[1].get("env", {}) - assert "(mount)➤" in env_passed.get("PS1", "") + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args[0] == "/bin/zsh" + assert "--no-rcs" in call_args + assert "-i" in call_args + env_passed = mock_run.call_args[1].get("env", {}) + assert "(mount)➤" in env_passed.get("PS1", "") def test_subshell_bash_inserts_mount_tag_ascii_prompt(): @@ -896,17 +970,19 @@ def test_subshell_bash_inserts_mount_tag_ascii_prompt(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - jmp_ps1 = "\\w ^exporter > " - with patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": jmp_ps1, "NO_ICONS": "1"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + jmp_ps1 = "\\w ^exporter > " + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": jmp_ps1, "NO_ICONS": "1"}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - env_passed = mock_run.call_args[1].get("env", {}) - assert "(mount)>" in env_passed.get("PS1", "") - assert "sshfs" not in env_passed.get("PS1", "") + mock_run.assert_called_once() + env_passed = mock_run.call_args[1].get("env", {}) + assert "(mount)>" in env_passed.get("PS1", "") + assert "sshfs" not in env_passed.get("PS1", "") def test_subshell_bash_ascii_prompt_only_tags_last_arrow(): @@ -914,16 +990,18 @@ def test_subshell_bash_ascii_prompt_only_tags_last_arrow(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - custom_ps1 = "a > b > " - with patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": custom_ps1, "NO_ICONS": "1"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + custom_ps1 = "a > b > " + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/bin/bash", "PS1": custom_ps1, "NO_ICONS": "1"}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - env_passed = mock_run.call_args[1].get("env", {}) - assert env_passed.get("PS1") == "a > b (mount)> " + mock_run.assert_called_once() + env_passed = mock_run.call_args[1].get("env", {}) + assert env_passed.get("PS1") == "a > b (mount)> " def test_subshell_zsh_inserts_mount_tag_ascii_prompt(): @@ -931,16 +1009,18 @@ def test_subshell_zsh_inserts_mount_tag_ascii_prompt(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - jmp_ps1 = "%~ ^exporter > " - with patch.dict(os.environ, {"SHELL": "/bin/zsh", "PS1": jmp_ps1, "NO_ICONS": "1"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + jmp_ps1 = "%~ ^exporter > " + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/bin/zsh", "PS1": jmp_ps1, "NO_ICONS": "1"}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - env_passed = mock_run.call_args[1].get("env", {}) - assert "(mount)>" in env_passed.get("PS1", "") + mock_run.assert_called_once() + env_passed = mock_run.call_args[1].get("env", {}) + assert "(mount)>" in env_passed.get("PS1", "") def test_subshell_fish_prompt_ascii_when_no_icons(): @@ -948,19 +1028,21 @@ def test_subshell_fish_prompt_ascii_when_no_icons(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/usr/bin/fish", "NO_ICONS": "1"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/usr/bin/fish", "NO_ICONS": "1"}), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - init_cmd = call_args[call_args.index("--init-command") + 1] - assert 'printf "^"' in init_cmd - assert 'printf "> "' in init_cmd - assert "⚡" not in init_cmd - assert "➤" not in init_cmd + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + init_cmd = call_args[call_args.index("--init-command") + 1] + assert 'printf "^"' in init_cmd + assert 'printf "> "' in init_cmd + assert "⚡" not in init_cmd + assert "➤" not in init_cmd def test_subshell_fish_prompt_plain_when_no_color(): @@ -968,19 +1050,21 @@ def test_subshell_fish_prompt_plain_when_no_color(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/usr/bin/fish", "NO_COLOR": "1"}, clear=True): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") + with ( + serve(instance) as client, + patch.dict(os.environ, {"SHELL": "/usr/bin/fish", "NO_COLOR": "1"}, clear=True), + patch('subprocess.run') as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - init_cmd = call_args[call_args.index("--init-command") + 1] - assert "set_color" not in init_cmd - assert 'printf "⚡"' in init_cmd - assert 'printf "(mount)"' in init_cmd - assert 'printf "➤ "' in init_cmd + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + init_cmd = call_args[call_args.index("--init-command") + 1] + assert "set_color" not in init_cmd + assert 'printf "⚡"' in init_cmd + assert 'printf "(mount)"' in init_cmd + assert 'printf "➤ "' in init_cmd def test_create_temp_identity_file_failure(): @@ -988,16 +1072,18 @@ def test_create_temp_identity_file_failure(): children={"ssh": _make_ssh_child(ssh_identity=TEST_SSH_KEY)}, ) - with serve(instance) as client: - with patch('os.write', side_effect=OSError("disk full")): - with patch('os.close') as mock_close: - with patch('os.unlink') as mock_unlink: - with pytest.raises(OSError, match="disk full"): - client._create_temp_identity_file() + with ( + serve(instance) as client, + patch('os.write', side_effect=OSError("disk full")), + patch('os.close') as mock_close, + patch('os.unlink') as mock_unlink + ): + with pytest.raises(OSError, match="disk full"): + client._create_temp_identity_file() - # fd and temp file should be cleaned up - assert mock_close.called - assert mock_unlink.called + # fd and temp file should be cleaned up + assert mock_close.called + assert mock_unlink.called def test_allow_other_comma_separated_removal(): @@ -1027,12 +1113,10 @@ def test_subshell_unknown_shell_fallback(): children={"ssh": _make_ssh_child()}, ) - with serve(instance) as client: - with patch.dict(os.environ, {"SHELL": "/bin/dash"}): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) - client._run_subshell("/tmp/test-mount", "/") - - mock_run.assert_called_once() - call_args = mock_run.call_args[0][0] - assert call_args == ["/bin/dash", "-i"] + with serve(instance) as client, patch.dict(os.environ, {"SHELL": "/bin/dash"}), patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + client._run_subshell("/tmp/test-mount", "/") + + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args == ["/bin/dash", "-i"] diff --git a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py index 5574dcc1a..8fc6f20d8 100644 --- a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py +++ b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/client.py @@ -1,3 +1,4 @@ +import contextlib import os import shlex import subprocess @@ -157,9 +158,8 @@ def _run_ssh_local(self, host, port, options, args): temp_file = None if ssh_identity: try: - temp_file = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='_ssh_key') - temp_file.write(ssh_identity) - temp_file.close() + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='_ssh_key') as temp_file: + temp_file.write(ssh_identity) # Set proper permissions (600) for SSH key os.chmod(temp_file.name, 0o600) identity_file = temp_file.name @@ -167,10 +167,8 @@ def _run_ssh_local(self, host, port, options, args): except Exception as e: self.logger.error("Failed to create temporary identity file: %s", e) if temp_file: - try: + with contextlib.suppress(Exception): os.unlink(temp_file.name) - except Exception: - pass raise try: @@ -191,7 +189,7 @@ def _run_ssh_local(self, host, port, options, args): try: os.unlink(identity_file) self.logger.debug("Cleaned up temporary identity file: %s", identity_file) - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.warning("Failed to clean up temporary identity file %s: %s", identity_file, str(e)) def _build_ssh_command_args(self, port, identity_file, args): @@ -288,7 +286,9 @@ def _build_final_ssh_command(self, ssh_args, ssh_options, host, command_args): def _execute_ssh_command(self, ssh_args, options: SSHCommandRunOptions) -> SSHCommandRunResult: """Execute the SSH command and return the result""" try: - result = subprocess.run(ssh_args, capture_output=options.capture_output, text=options.capture_as_text) + result = subprocess.run( + ssh_args, capture_output=options.capture_output, text=options.capture_as_text, check=False + ) return SSHCommandRunResult.from_completed_process(result) except FileNotFoundError: self.logger.error( diff --git a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py index f334a3d4a..9ebc7c680 100644 --- a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py +++ b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver.py @@ -47,6 +47,6 @@ def get_ssh_identity(self): if self.ssh_identity is None and self.ssh_identity_file: try: self.ssh_identity = Path(self.ssh_identity_file).read_text() - except Exception as e: + except Exception as e: # noqa: BLE001 raise ConfigurationError(f"Failed to read ssh_identity_file '{self.ssh_identity_file}': {e}") from None return self.ssh_identity diff --git a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py index c1d451960..ba40f5dce 100644 --- a/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py +++ b/python/packages/jumpstarter-driver-ssh/jumpstarter_driver_ssh/driver_test.py @@ -50,29 +50,28 @@ def test_ssh_command_with_default_username(): default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with default username - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with default username + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args - assert call_args[call_args.index("-l") + 1] == "testuser" + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args + assert call_args[call_args.index("-l") + 1] == "testuser" - # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command - assert "127.0.0.1" in call_args - assert "hostname" in call_args # Should be preserved as command argument + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_without_default_username(): @@ -82,27 +81,26 @@ def test_ssh_command_without_default_username(): default_username="" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command without default username - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command without default username + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should NOT include -l flag - assert "-l" not in call_args + # Should NOT include -l flag + assert "-l" not in call_args - # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command - assert "127.0.0.1" in call_args - assert "hostname" in call_args # Should be preserved as command argument + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_user_override(): @@ -112,30 +110,29 @@ def test_ssh_command_with_user_override(): default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with -l flag overriding default username - result = client.run(SSHCommandRunOptions(direct=False), ["-l", "myuser", "hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with -l flag overriding default username + result = client.run(SSHCommandRunOptions(direct=False), ["-l", "myuser", "hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -l myuser (not testuser) - assert "-l" in call_args - assert "myuser" in call_args - assert "testuser" not in call_args - assert call_args[call_args.index("-l") + 1] == "myuser" + # Should include -l myuser (not testuser) + assert "-l" in call_args + assert "myuser" in call_args + assert "testuser" not in call_args + assert call_args[call_args.index("-l") + 1] == "myuser" - # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command - assert "127.0.0.1" in call_args - assert "hostname" in call_args # Should be preserved as command argument + # Should include the actual hostname (127.0.0.1) at the end, and preserve "hostname" as a command + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_port(): @@ -145,38 +142,37 @@ def test_ssh_command_with_port(): default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Mock the TcpPortforwardAdapter to return the expected port - with patch('jumpstarter_driver_ssh.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__.return_value = ("127.0.0.1", 2222) - mock_adapter.return_value.__exit__.return_value = None + # Mock the TcpPortforwardAdapter to return the expected port + with patch('jumpstarter_driver_ssh.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__.return_value = ("127.0.0.1", 2222) + mock_adapter.return_value.__exit__.return_value = None - # Test SSH command with custom port - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with custom port + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -p 2222 - assert "-p" in call_args - assert "2222" in call_args - assert call_args[call_args.index("-p") + 1] == "2222" + # Should include -p 2222 + assert "-p" in call_args + assert "2222" in call_args + assert call_args[call_args.index("-p") + 1] == "2222" - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - assert "hostname" in call_args # Should be preserved as command argument + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + assert "hostname" in call_args # Should be preserved as command argument - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_direct_flag(): @@ -186,30 +182,29 @@ def test_ssh_command_with_direct_flag(): default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Mock the tcp.address() method - with patch.object(client.tcp, 'address', return_value="tcp://192.168.1.100:22"): - # Test SSH command with direct flag - result = client.run(SSHCommandRunOptions(direct=True), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Mock the tcp.address() method + with patch.object(client.tcp, 'address', return_value="tcp://192.168.1.100:22"): + # Test SSH command with direct flag + result = client.run(SSHCommandRunOptions(direct=True), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args - # Should include the actual hostname (192.168.1.100) at the end, and preserve "hostname" as a command - assert "192.168.1.100" in call_args - assert "hostname" in call_args # Should be preserved as command argument + # Should include the actual hostname (192.168.1.100) at the end, and preserve "hostname" as a command + assert "192.168.1.100" in call_args + assert "hostname" in call_args # Should be preserved as command argument - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_error_handling(): @@ -219,18 +214,17 @@ def test_ssh_command_error_handling(): default_username="" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.side_effect = FileNotFoundError("SSH not found") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.side_effect = FileNotFoundError("SSH not found") - # Test SSH command error handling - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command error handling + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Should return error code 127 - assert result.return_code == 127 - assert result.stdout == "" - assert "not found" in result.stderr + # Should return error code 127 + assert result.return_code == 127 + assert result.stdout == "" + assert "not found" in result.stderr def test_ssh_command_with_multiple_ssh_options(): @@ -240,35 +234,34 @@ def test_ssh_command_with_multiple_ssh_options(): default_username="" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with multiple SSH options - result = client.run(SSHCommandRunOptions(direct=False), [ - "-o", "StrictHostKeyChecking=no", "-i", "/path/to/key", "command", "arg1", "arg2" - ]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with multiple SSH options + result = client.run(SSHCommandRunOptions(direct=False), [ + "-o", "StrictHostKeyChecking=no", "-i", "/path/to/key", "command", "arg1", "arg2" + ]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include SSH options - assert "-o" in call_args - assert "StrictHostKeyChecking=no" in call_args - assert "-i" in call_args - assert "/path/to/key" in call_args + # Should include SSH options + assert "-o" in call_args + assert "StrictHostKeyChecking=no" in call_args + assert "-i" in call_args + assert "/path/to/key" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - # Should preserve command arguments - assert "command" in call_args - assert "arg1" in call_args - assert "arg2" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should preserve command arguments + assert "command" in call_args + assert "arg1" in call_args + assert "arg2" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_unknown_option_treated_as_command(): @@ -278,31 +271,30 @@ def test_ssh_command_with_unknown_option_treated_as_command(): default_username="" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with unknown option - result = client.run(SSHCommandRunOptions(direct=False), ["-l", "user", "-unknown", "command", "arg1"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with unknown option + result = client.run(SSHCommandRunOptions(direct=False), ["-l", "user", "-unknown", "command", "arg1"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include known SSH options - assert "-l" in call_args - assert "user" in call_args + # Should include known SSH options + assert "-l" in call_args + assert "user" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - # Should treat everything after -l user as command (including -unknown) - assert "-unknown" in call_args - assert "command" in call_args - assert "arg1" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should treat everything after -l user as command (including -unknown) + assert "-unknown" in call_args + assert "command" in call_args + assert "arg1" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_no_ssh_options(): @@ -312,27 +304,26 @@ def test_ssh_command_with_no_ssh_options(): default_username="" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with no SSH options - result = client.run(SSHCommandRunOptions(direct=False), ["command", "arg1", "arg2"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with no SSH options + result = client.run(SSHCommandRunOptions(direct=False), ["command", "arg1", "arg2"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - # Should preserve all command arguments - assert "command" in call_args - assert "arg1" in call_args - assert "arg2" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + # Should preserve all command arguments + assert "command" in call_args + assert "arg1" in call_args + assert "arg2" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_command_l_flag_does_not_interfere_with_username_injection(): @@ -342,41 +333,40 @@ def test_ssh_command_with_command_l_flag_does_not_interfere_with_username_inject default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with -l flag in the command (like ls -la -l ajo) - result = client.run(SSHCommandRunOptions(direct=False), ["ls", "-la", "-l", "ajo"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with -l flag in the command (like ls -la -l ajo) + result = client.run(SSHCommandRunOptions(direct=False), ["ls", "-la", "-l", "ajo"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -l testuser (SSH login flag) - assert "-l" in call_args - assert "testuser" in call_args - assert call_args[call_args.index("-l") + 1] == "testuser" + # Should include -l testuser (SSH login flag) + assert "-l" in call_args + assert "testuser" in call_args + assert call_args[call_args.index("-l") + 1] == "testuser" - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args - # Should preserve command arguments including the -l flag for ls - assert "ls" in call_args - assert "-la" in call_args - assert "-l" in call_args # This should be the ls -l flag, not SSH -l - assert "ajo" in call_args + # Should preserve command arguments including the -l flag for ls + assert "ls" in call_args + assert "-la" in call_args + assert "-l" in call_args # This should be the ls -l flag, not SSH -l + assert "ajo" in call_args - # Verify that the SSH -l flag comes before the hostname, and command -l comes after - ssh_l_index = call_args.index("-l") - hostname_index = call_args.index("127.0.0.1") - command_l_index = call_args.index("-l", ssh_l_index + 1) # Find second -l + # Verify that the SSH -l flag comes before the hostname, and command -l comes after + ssh_l_index = call_args.index("-l") + hostname_index = call_args.index("127.0.0.1") + command_l_index = call_args.index("-l", ssh_l_index + 1) # Find second -l - assert ssh_l_index < hostname_index < command_l_index + assert ssh_l_index < hostname_index < command_l_index - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_identity_string_configuration(): @@ -464,37 +454,36 @@ def test_ssh_command_with_identity_string(): ssh_identity=TEST_SSH_KEY ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with identity string - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with identity string + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -i flag with temporary identity file - assert "-i" in call_args - identity_file_index = call_args.index("-i") - identity_file_path = call_args[identity_file_index + 1] + # Should include -i flag with temporary identity file + assert "-i" in call_args + identity_file_index = call_args.index("-i") + identity_file_path = call_args[identity_file_index + 1] - # The identity file should be a temporary file - assert identity_file_path.endswith("_ssh_key") - assert "/tmp" in identity_file_path or "/var/tmp" in identity_file_path + # The identity file should be a temporary file + assert identity_file_path.endswith("_ssh_key") + assert "/tmp" in identity_file_path or "/var/tmp" in identity_file_path - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - assert "hostname" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + assert "hostname" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_command_with_identity_file(): @@ -514,38 +503,37 @@ def test_ssh_command_with_identity_file(): ssh_identity_file=temp_file_path ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command with identity file - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command with identity file + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should include -i flag with temporary identity file - assert "-i" in call_args - identity_file_index = call_args.index("-i") - identity_file_path = call_args[identity_file_index + 1] + # Should include -i flag with temporary identity file + assert "-i" in call_args + identity_file_index = call_args.index("-i") + identity_file_path = call_args[identity_file_index + 1] - # The identity file should be a temporary file (not the original file) - assert identity_file_path.endswith("_ssh_key") - assert "/tmp" in identity_file_path or "/var/tmp" in identity_file_path - assert identity_file_path != temp_file_path + # The identity file should be a temporary file (not the original file) + assert identity_file_path.endswith("_ssh_key") + assert "/tmp" in identity_file_path or "/var/tmp" in identity_file_path + assert identity_file_path != temp_file_path - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - assert "hostname" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + assert "hostname" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" finally: # Clean up the temporary file os.unlink(temp_file_path) @@ -558,31 +546,30 @@ def test_ssh_command_without_identity(): default_username="testuser" ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - # Test SSH command without identity - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + # Test SSH command without identity + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify subprocess.run was called - assert mock_run.called - call_args = mock_run.call_args[0][0] # First positional argument + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args[0][0] # First positional argument - # Should NOT include -i flag - assert "-i" not in call_args + # Should NOT include -i flag + assert "-i" not in call_args - # Should include -l testuser - assert "-l" in call_args - assert "testuser" in call_args + # Should include -l testuser + assert "-l" in call_args + assert "testuser" in call_args - # Should include the actual hostname (127.0.0.1) at the end - assert "127.0.0.1" in call_args - assert "hostname" in call_args + # Should include the actual hostname (127.0.0.1) at the end + assert "127.0.0.1" in call_args + assert "hostname" in call_args - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_identity_temp_file_creation_and_cleanup(): @@ -593,37 +580,38 @@ def test_ssh_identity_temp_file_creation_and_cleanup(): ssh_identity=TEST_SSH_KEY ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") - - with patch('tempfile.NamedTemporaryFile') as mock_temp_file: - with patch('os.chmod') as mock_chmod: - with patch('os.unlink') as mock_unlink: - # Mock the temporary file - mock_temp_file_instance = MagicMock() - mock_temp_file_instance.name = "/tmp/test_ssh_key_12345" - mock_temp_file_instance.write = MagicMock() - mock_temp_file_instance.close = MagicMock() - mock_temp_file.return_value = mock_temp_file_instance - - # Test SSH command with identity - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + + with ( + patch('tempfile.NamedTemporaryFile') as mock_temp_file, + patch('os.chmod') as mock_chmod, + patch('os.unlink') as mock_unlink, + ): + # Mock the temporary file; __enter__ must return the mock itself so + # writes inside the `with` block land on mock_temp_file_instance. + mock_temp_file_instance = MagicMock() + mock_temp_file_instance.name = "/tmp/test_ssh_key_12345" + mock_temp_file_instance.__enter__ = MagicMock(return_value=mock_temp_file_instance) + mock_temp_file_instance.__exit__ = MagicMock(return_value=False) + mock_temp_file.return_value = mock_temp_file_instance + + # Test SSH command with identity + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) - # Verify temporary file was created - mock_temp_file.assert_called_once_with(mode='w', delete=False, suffix='_ssh_key') - mock_temp_file_instance.write.assert_called_once_with(TEST_SSH_KEY) - mock_temp_file_instance.close.assert_called_once() + # Verify temporary file was created + mock_temp_file.assert_called_once_with(mode='w', delete=False, suffix='_ssh_key') + mock_temp_file_instance.write.assert_called_once_with(TEST_SSH_KEY) - # Verify proper permissions were set - mock_chmod.assert_called_once_with("/tmp/test_ssh_key_12345", 0o600) + # Verify proper permissions were set + mock_chmod.assert_called_once_with("/tmp/test_ssh_key_12345", 0o600) - # Verify temporary file was cleaned up - mock_unlink.assert_called_once_with("/tmp/test_ssh_key_12345") + # Verify temporary file was cleaned up + mock_unlink.assert_called_once_with("/tmp/test_ssh_key_12345") - assert result.return_code == 0 - assert result.stdout == "some stdout" + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_identity_temp_file_creation_error(): @@ -634,20 +622,19 @@ def test_ssh_identity_temp_file_creation_error(): ssh_identity=TEST_SSH_KEY ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0) + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) - with patch('tempfile.NamedTemporaryFile') as mock_temp_file: - mock_temp_file.side_effect = OSError("Permission denied") + with patch('tempfile.NamedTemporaryFile') as mock_temp_file: + mock_temp_file.side_effect = OSError("Permission denied") - # Test SSH command with identity should raise an error - # The exception will be wrapped in an ExceptionGroup due to the context manager - with pytest.raises(ExceptionGroup) as exc_info: - client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + # Test SSH command with identity should raise an error + # The exception will be wrapped in an ExceptionGroup due to the context manager + with pytest.raises(ExceptionGroup) as exc_info: + client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - # Check that the original OSError is in the exception group - assert any(isinstance(e, OSError) and "Permission denied" in str(e) for e in exc_info.value.exceptions) # ty: ignore[unresolved-attribute] + # Check that the original OSError is in the exception group + assert any(isinstance(e, OSError) and "Permission denied" in str(e) for e in exc_info.value.exceptions) # ty: ignore[unresolved-attribute] def test_ssh_identity_temp_file_cleanup_error(): @@ -658,40 +645,42 @@ def test_ssh_identity_temp_file_cleanup_error(): ssh_identity=TEST_SSH_KEY ) - with serve(instance) as client: - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + with serve(instance) as client, patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="some stdout", stderr="") + + with ( + patch('tempfile.NamedTemporaryFile') as mock_temp_file, + patch('os.chmod') as mock_chmod, + patch('os.unlink') as mock_unlink, + ): + # Mock the temporary file; __enter__ must return the mock itself so + # writes inside the `with` block land on mock_temp_file_instance. + mock_temp_file_instance = MagicMock() + mock_temp_file_instance.name = "/tmp/test_ssh_key_12345" + mock_temp_file_instance.__enter__ = MagicMock(return_value=mock_temp_file_instance) + mock_temp_file_instance.__exit__ = MagicMock(return_value=False) + mock_temp_file.return_value = mock_temp_file_instance + + # Mock cleanup failure + mock_unlink.side_effect = OSError("Permission denied") + + # Test SSH command with identity - should still succeed but log warning + with patch.object(client, 'logger') as mock_logger: + result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) + assert isinstance(result, SSHCommandRunResult) + + # Verify chmod was called + mock_chmod.assert_called_once_with("/tmp/test_ssh_key_12345", 0o600) - with patch('tempfile.NamedTemporaryFile') as mock_temp_file: - with patch('os.chmod') as mock_chmod: - with patch('os.unlink') as mock_unlink: - # Mock the temporary file - mock_temp_file_instance = MagicMock() - mock_temp_file_instance.name = "/tmp/test_ssh_key_12345" - mock_temp_file_instance.write = MagicMock() - mock_temp_file_instance.close = MagicMock() - mock_temp_file.return_value = mock_temp_file_instance - - # Mock cleanup failure - mock_unlink.side_effect = OSError("Permission denied") - - # Test SSH command with identity - should still succeed but log warning - with patch.object(client, 'logger') as mock_logger: - result = client.run(SSHCommandRunOptions(direct=False), ["hostname"]) - assert isinstance(result, SSHCommandRunResult) - - # Verify chmod was called - mock_chmod.assert_called_once_with("/tmp/test_ssh_key_12345", 0o600) - - # Verify warning was logged - mock_logger.warning.assert_called_once_with( - "Failed to clean up temporary identity file %s: %s", - "/tmp/test_ssh_key_12345", - str(mock_unlink.side_effect) - ) - - assert result.return_code == 0 - assert result.stdout == "some stdout" + # Verify warning was logged + mock_logger.warning.assert_called_once_with( + "Failed to clean up temporary identity file %s: %s", + "/tmp/test_ssh_key_12345", + str(mock_unlink.side_effect) + ) + + assert result.return_code == 0 + assert result.stdout == "some stdout" def test_ssh_client_properties(): diff --git a/python/packages/jumpstarter-driver-stlink-msd/jumpstarter_driver_stlink_msd/driver.py b/python/packages/jumpstarter-driver-stlink-msd/jumpstarter_driver_stlink_msd/driver.py index 4a9f472e7..078c2b7c2 100644 --- a/python/packages/jumpstarter-driver-stlink-msd/jumpstarter_driver_stlink_msd/driver.py +++ b/python/packages/jumpstarter-driver-stlink-msd/jumpstarter_driver_stlink_msd/driver.py @@ -100,10 +100,9 @@ async def flash(self, source, target: str | None = None): with tempfile.TemporaryDirectory() as tmpdir: tmp_path = os.path.join(tmpdir, dest_name) - async with await FileWriteStream.from_path(tmp_path) as stream: - async with self.resource(source) as res: - async for chunk in res: - await stream.send(chunk) + async with await FileWriteStream.from_path(tmp_path) as stream, self.resource(source) as res: + async for chunk in res: + await stream.send(chunk) dest_path = os.path.join(mount, dest_name) self.logger.info("Copying firmware to %s", dest_path) diff --git a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver.py b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver.py index ca239bffa..fbc1b1c4a 100644 --- a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver.py +++ b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver.py @@ -15,13 +15,11 @@ class TftpError(Exception): """Base exception for TFTP server errors""" - pass class ServerNotRunning(TftpError): """Server is not running""" - pass @dataclass(kw_only=True) @@ -43,11 +41,11 @@ class Tftp(Driver): port: int = 69 remove_created_on_close: bool = True # Clean up temporary boot files by default server: Optional["TftpServer"] = field(init=False, default=None) - server_thread: Optional[threading.Thread] = field(init=False, default=None) + server_thread: threading.Thread | None = field(init=False, default=None) _shutdown_event: threading.Event = field(init=False, default_factory=threading.Event) _loop_ready: threading.Event = field(init=False, default_factory=threading.Event) - _loop: Optional[asyncio.AbstractEventLoop] = field(init=False, default=None) - _startup_error: Optional[BaseException] = field(init=False, default=None) + _loop: asyncio.AbstractEventLoop | None = field(init=False, default=None) + _startup_error: BaseException | None = field(init=False, default=None) def __post_init__(self): if hasattr(super(), "__post_init__"): @@ -72,7 +70,7 @@ def client(cls) -> str: def _start_server(self): try: asyncio.run(self._run_server_lifecycle()) - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.error(f"Error running TFTP server: {e}") finally: self.logger.info("TFTP server thread completed") diff --git a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver_test.py b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver_test.py index c713dfa84..d312534ef 100644 --- a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver_test.py +++ b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/driver_test.py @@ -117,8 +117,7 @@ def test_tftp_start_surfaces_startup_error(tmp_path): with patch( "jumpstarter_driver_tftp.driver.TftpServer", side_effect=RuntimeError("port already in use"), - ): - with pytest.raises(TftpError, match="port already in use"): - server.start() + ), pytest.raises(TftpError, match="port already in use"): + server.start() server.close() diff --git a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server.py b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server.py index 8d5a5fe78..26ee16f42 100644 --- a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server.py +++ b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server.py @@ -2,7 +2,6 @@ import logging import pathlib from enum import IntEnum -from typing import Optional, Set, Tuple from opendal import AsyncOperator @@ -48,10 +47,10 @@ def __init__( self.block_size = block_size self.timeout = timeout self.retries = retries - self.active_transfers: Set["TftpTransfer"] = set() + self.active_transfers: set[TftpTransfer] = set() self.shutdown_event = asyncio.Event() - self.transport: Optional[asyncio.DatagramTransport] = None - self.protocol: Optional["TftpServerProtocol"] = None + self.transport: asyncio.DatagramTransport | None = None + self.protocol: TftpServerProtocol | None = None if logger is not None: self.logger = logger.getChild(self.__class__.__name__) @@ -61,7 +60,7 @@ def __init__( self.ready_event = asyncio.Event() @property - def address(self) -> Optional[Tuple[str, int]]: + def address(self) -> tuple[str, int] | None: """Get the server's bound address and port.""" if self.transport: return self.transport.get_extra_info("socket").getsockname() @@ -119,17 +118,17 @@ class TftpServerProtocol(asyncio.DatagramProtocol): def __init__(self, server: TftpServer): self.server = server - self.transport: Optional[asyncio.DatagramTransport] = None + self.transport: asyncio.DatagramTransport | None = None self.logger = server.logger.getChild(self.__class__.__name__) def connection_made(self, transport: asyncio.DatagramTransport): self.transport = transport self.logger.debug("Server protocol connection established") - def connection_lost(self, exc: Optional[Exception]): + def connection_lost(self, exc: Exception | None): self.logger.info("TFTP server protocol connection lost") - def datagram_received(self, data: bytes, addr: Tuple[str, int]): + def datagram_received(self, data: bytes, addr: tuple[str, int]): self.logger.debug(f"Received datagram from {addr}") if len(data) < 4: self.logger.warning(f"Received malformed packet from {addr}") @@ -155,7 +154,7 @@ def datagram_received(self, data: bytes, addr: Tuple[str, int]): self.logger.warning(f"Unsupported opcode {opcode} from {addr}") self._send_error(addr, TftpErrorCode.ILLEGAL_OPERATION, "Unsupported operation") - async def _handle_read_request(self, data: bytes, addr: Tuple[str, int]): + async def _handle_read_request(self, data: bytes, addr: tuple[str, int]): try: filename, mode, options = self._parse_request(data) self.logger.info(f"RRQ from {addr}: '{filename}' in mode '{mode}' with options {options}") @@ -170,21 +169,21 @@ async def _handle_read_request(self, data: bytes, addr: Tuple[str, int]): negotiated_options, blksize, timeout = self._negotiate_options(options) self.logger.info(f"Negotiated options: {negotiated_options}") await self._start_transfer(resolved_path, addr, blksize, timeout, negotiated_options) - except Exception as e: + except Exception as e: # pragma: no cover # noqa: BLE001 self.logger.error(f"Error handling RRQ from {addr}: {e}") self._send_error(addr, TftpErrorCode.NOT_DEFINED, str(e)) - def _send_oack(self, addr: Tuple[str, int], options: dict): + def _send_oack(self, addr: tuple[str, int], options: dict): """Send Option Acknowledgment (OACK) packet.""" oack_data = Opcode.OACK.to_bytes(2, "big") for opt_name, opt_value in options.items(): - oack_data += f"{opt_name}\0{str(opt_value)}\0".encode("utf-8") + oack_data += f"{opt_name}\0{opt_value!s}\0".encode() if self.transport: self.transport.sendto(oack_data, addr) self.logger.debug(f"Sent OACK to {addr} with options {options}") - def _send_error(self, addr: Tuple[str, int], error_code: TftpErrorCode, message: str): + def _send_error(self, addr: tuple[str, int], error_code: TftpErrorCode, message: str): error_packet = ( Opcode.ERROR.to_bytes(2, "big") + error_code.to_bytes(2, "big") + message.encode("utf-8") + b"\x00" ) @@ -192,7 +191,7 @@ def _send_error(self, addr: Tuple[str, int], error_code: TftpErrorCode, message: self.transport.sendto(error_packet, addr) self.logger.debug(f"Sent ERROR {error_code.name} to {addr}: {message}") - def _parse_request(self, data: bytes) -> Tuple[str, str, dict]: + def _parse_request(self, data: bytes) -> tuple[str, str, dict]: parts = data[2:].split(b"\x00") if len(parts) < 2: raise ValueError("Invalid RRQ format") @@ -218,18 +217,18 @@ def _parse_options(self, option_parts: list) -> dict: opt_value = option_parts[i + 1].decode("utf-8") options[opt_name] = opt_value i += 2 - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 break return options - def _validate_mode(self, mode: str, addr: Tuple[str, int]) -> bool: + def _validate_mode(self, mode: str, addr: tuple[str, int]) -> bool: if mode not in ("netascii", "octet"): self.logger.warning(f"Unsupported transfer mode '{mode}' from {addr}") self._send_error(addr, TftpErrorCode.ILLEGAL_OPERATION, "Unsupported transfer mode") return False return True - async def _resolve_and_validate_path(self, filename: str, addr: Tuple[str, int]) -> Optional[str]: + async def _resolve_and_validate_path(self, filename: str, addr: tuple[str, int]) -> str | None: normalized = pathlib.PurePosixPath(filename) if ".." in normalized.parts or normalized.is_absolute(): self.logger.error(f"Path traversal attempt from {addr}: {filename}") @@ -250,7 +249,7 @@ async def _resolve_and_validate_path(self, filename: str, addr: Tuple[str, int]) return filename - def _negotiate_block_size(self, requested_blksize: Optional[str]) -> int: + def _negotiate_block_size(self, requested_blksize: str | None) -> int: if requested_blksize is None: return self.server.block_size @@ -269,7 +268,7 @@ def _negotiate_block_size(self, requested_blksize: Optional[str]) -> int: ) return self.server.block_size - def _negotiate_timeout(self, requested_timeout: Optional[str]) -> float: + def _negotiate_timeout(self, requested_timeout: str | None) -> float: if requested_timeout is None: return self.server.timeout @@ -286,7 +285,7 @@ def _negotiate_timeout(self, requested_timeout: Optional[str]) -> float: self.logger.warning(f"Invalid timeout value '{requested_timeout}', using default: {self.server.timeout}") return self.server.timeout - def _negotiate_options(self, options: dict) -> Tuple[dict, int, float]: + def _negotiate_options(self, options: dict) -> tuple[dict, int, float]: negotiated = {} blksize = self.server.block_size timeout = self.server.timeout @@ -304,7 +303,7 @@ def _negotiate_options(self, options: dict) -> Tuple[dict, int, float]: return negotiated, blksize, timeout async def _start_transfer( - self, filepath: str, addr: Tuple[str, int], blksize: int, timeout: float, negotiated_options: dict + self, filepath: str, addr: tuple[str, int], blksize: int, timeout: float, negotiated_options: dict ): transfer = TftpReadTransfer( server=self.server, @@ -329,7 +328,7 @@ def __init__( self, server: TftpServer, filepath: pathlib.Path, - client_addr: Tuple[str, int], + client_addr: tuple[str, int], block_size: int, timeout: float, retries: int, @@ -340,9 +339,9 @@ def __init__( self.block_size = block_size self.timeout = timeout self.retries = retries - self.transport: Optional[asyncio.DatagramTransport] = None - self.protocol: Optional["TftpTransferProtocol"] = None - self.cleanup_task: Optional[asyncio.Task] = None + self.transport: asyncio.DatagramTransport | None = None + self.protocol: TftpTransferProtocol | None = None + self.cleanup_task: asyncio.Task | None = None self.logger = server.logger.getChild(self.__class__.__name__) async def start(self): @@ -363,11 +362,11 @@ def __init__( self, server: TftpServer, filepath: str, - client_addr: Tuple[str, int], + client_addr: tuple[str, int], block_size: int, timeout: float, retries: int, - negotiated_options: Optional[dict] = None, + negotiated_options: dict | None = None, ): super().__init__( server=server, @@ -382,7 +381,7 @@ def __init__( self.last_ack = 0 self.oack_confirmed = False self.negotiated_options = negotiated_options - self.current_packet: Optional[bytes] = None + self.current_packet: bytes | None = None async def start(self): self.logger.info(f"Starting read transfer of '{self.filepath}' to {self.client_addr}") @@ -396,7 +395,7 @@ async def start(self): self.oack_confirmed = True await self._perform_transfer() - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.error(f"Error during read transfer: {e}") finally: await self.cleanup() @@ -481,7 +480,7 @@ async def _handle_data_block(self, data: bytes) -> bool: def _create_oack_packet(self) -> bytes: packet = Opcode.OACK.to_bytes(2, "big") for opt_name, opt_value in self.negotiated_options.items(): - packet += f"{opt_name}\0{str(opt_value)}\0".encode("utf-8") + packet += f"{opt_name}\0{opt_value!s}\0".encode() return packet def _create_data_packet(self, data: bytes) -> bytes: @@ -515,7 +514,7 @@ async def _send_with_retries(self, packet: bytes, is_oack: bool = False) -> bool else: self.logger.warning(f"Received wrong ACK: expected {expected_block}, got {self.last_ack}") - except asyncio.TimeoutError: + except TimeoutError: self.logger.warning(f"Timeout waiting for ACK of block {expected_block} (Attempt {attempt})") return False @@ -554,7 +553,7 @@ def connection_made(self, transport: asyncio.DatagramTransport): local_addr = transport.get_extra_info("sockname") self.logger.debug(f"Transfer protocol connection established on {local_addr} for {self.transfer.client_addr}") - def datagram_received(self, data: bytes, addr: Tuple[str, int]): + def datagram_received(self, data: bytes, addr: tuple[str, int]): self.logger.debug(f"Received datagram from {addr}") if addr != self.transfer.client_addr: self.logger.warning(f"Ignoring packet from unknown source {addr}") @@ -585,7 +584,7 @@ def error_received(self, exc): def connection_lost(self, exc): self.logger.debug(f"Connection closed for transfer to {self.transfer.client_addr}") - def _send_error(self, addr: Tuple[str, int], error_code: TftpErrorCode, message: str): + def _send_error(self, addr: tuple[str, int], error_code: TftpErrorCode, message: str): error_packet = ( Opcode.ERROR.to_bytes(2, "big") + error_code.to_bytes(2, "big") + message.encode("utf-8") + b"\x00" ) diff --git a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server_test.py b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server_test.py index 3fd297e5d..e7abcb711 100644 --- a/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server_test.py +++ b/python/packages/jumpstarter-driver-tftp/jumpstarter_driver_tftp/server_test.py @@ -51,7 +51,7 @@ async def create_test_client(server_port): @pytest.mark.asyncio async def test_server_startup_and_shutdown(tftp_server): """Test that server starts up and shuts down cleanly.""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, _server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() @@ -66,7 +66,7 @@ async def test_server_startup_and_shutdown(tftp_server): @pytest.mark.asyncio async def test_read_request_for_existing_file(tftp_server): """Test reading an existing file from the server.""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() @@ -94,12 +94,12 @@ async def test_read_request_for_existing_file(tftp_server): @pytest.mark.asyncio async def test_read_request_for_nonexistent_file(tftp_server): """Test reading a non-existent file returns appropriate error.""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) try: - transport, protocol = await create_test_client(server_port) + transport, _protocol = await create_test_client(server_port) rrq_packet = Opcode.RRQ.to_bytes(2, "big") + b"nonexistent.txt\x00" + b"octet\x00" @@ -115,7 +115,7 @@ async def test_read_request_for_nonexistent_file(tftp_server): @pytest.mark.asyncio async def test_write_request_rejection(tftp_server): """Test that write requests are properly rejected (server is read-only).""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) try: @@ -134,7 +134,7 @@ async def test_write_request_rejection(tftp_server): @pytest.mark.asyncio async def test_invalid_packet_handling(tftp_server): - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() @@ -153,7 +153,7 @@ async def test_invalid_packet_handling(tftp_server): @pytest.mark.asyncio async def test_path_traversal_prevention(tftp_server): """Test that path traversal attempts are blocked.""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() @@ -176,7 +176,7 @@ async def test_path_traversal_prevention(tftp_server): @pytest.mark.asyncio async def test_options_negotiation(tftp_server): """Test that options (blksize, timeout) are properly negotiated.""" - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() @@ -244,8 +244,8 @@ def datagram_received(self, data, addr): assert len(block_numbers) == 1, "All retried packets should be for the same block" assert 1 in block_numbers, "First block number should be 1" - except Exception as e: - pytest.fail(f"Test failed with error: {str(e)}") # ty: ignore[call-non-callable] + except Exception as e: # noqa: BLE001 + pytest.fail(f"Test failed with error: {e!s}") # ty: ignore[call-non-callable] finally: if transport is not None: @@ -254,7 +254,7 @@ def datagram_received(self, data, addr): @pytest.mark.asyncio async def test_invalid_options_handling(tftp_server): - server, temp_dir, server_port = tftp_server + server, _temp_dir, server_port = tftp_server server_task = asyncio.create_task(server.start()) await server.ready_event.wait() diff --git a/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/client.py b/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/client.py index 539a57e88..bdb9cb76b 100644 --- a/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/client.py +++ b/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/client.py @@ -97,7 +97,7 @@ def _run_tmt_local(self, host, port, tmt_cmd, username, password, hard_reboot_cm self.logger.debug(f"Running TMT command: {[tmt_cmd] + safe_args}") # execute the command on the local machine try: - result = subprocess.run([tmt_cmd] + args) + result = subprocess.run([tmt_cmd] + args, check=False) return result.returncode except FileNotFoundError: self.logger.error( diff --git a/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/driver_test.py b/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/driver_test.py index de4c7cba9..4abeb5bc6 100644 --- a/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/driver_test.py +++ b/python/packages/jumpstarter-driver-tmt/jumpstarter_driver_tmt/driver_test.py @@ -126,30 +126,28 @@ def test_drivers_tmt_run_tmt_with_forward_ssh(): """Test run_tmt method with SSH forwarding""" instance = TMT(children={"ssh": TcpNetwork(host="127.0.0.1", port=22)}) - with serve(instance) as client: - with patch('jumpstarter_driver_tmt.client.TcpPortforwardAdapter') as mock_adapter: - mock_adapter.return_value.__enter__.return_value = ("localhost", 2222) - with patch.object(client, '_run_tmt_local') as mock_run_tmt: - mock_run_tmt.return_value = 0 - result = client.run_tmt_local(True, "tmt", "user", "pass", ["arg1"]) - assert result == 0 - mock_run_tmt.assert_called_once_with( - "localhost", 2222, "tmt", "user", "pass", "", ["arg1"] - ) + with serve(instance) as client, patch('jumpstarter_driver_tmt.client.TcpPortforwardAdapter') as mock_adapter: + mock_adapter.return_value.__enter__.return_value = ("localhost", 2222) + with patch.object(client, '_run_tmt_local') as mock_run_tmt: + mock_run_tmt.return_value = 0 + result = client.run_tmt_local(True, "tmt", "user", "pass", ["arg1"]) + assert result == 0 + mock_run_tmt.assert_called_once_with( + "localhost", 2222, "tmt", "user", "pass", "", ["arg1"] + ) def test_drivers_tmt_run_tmt_with_direct_address(): """Test run_tmt method with direct address connection""" instance = TMT(children={"ssh": TcpNetwork(host="127.0.0.1", port=22)}) - with serve(instance) as client: - with patch.object(client, '_run_tmt_local') as mock_run_tmt: - mock_run_tmt.return_value = 0 - result = client.run_tmt_local(False, "tmt", "user", "pass", ["arg1"]) - assert result == 0 - mock_run_tmt.assert_called_once_with( - "127.0.0.1", 22, "tmt", "user", "pass", "", ["arg1"] - ) + with serve(instance) as client, patch.object(client, '_run_tmt_local') as mock_run_tmt: + mock_run_tmt.return_value = 0 + result = client.run_tmt_local(False, "tmt", "user", "pass", ["arg1"]) + assert result == 0 + mock_run_tmt.assert_called_once_with( + "127.0.0.1", 22, "tmt", "user", "pass", "", ["arg1"] + ) def test_drivers_tmt_run_tmt_fallback_to_forwarding(): @@ -176,37 +174,35 @@ def test_drivers_tmt_run_tmt_internal(): """Test the internal _run_tmt method""" instance = TMT(children={"ssh": TcpNetwork(host="127.0.0.1", port=22)}) - with serve(instance) as client: - with patch('subprocess.run') as mock_subprocess: - mock_result = MagicMock() - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result + with serve(instance) as client, patch('subprocess.run') as mock_subprocess: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_subprocess.return_value = mock_result - result = client._run_tmt_local("localhost", 2222, "tmt", "user", "pass", "j power cycle", ["test", "arg"]) + result = client._run_tmt_local("localhost", 2222, "tmt", "user", "pass", "j power cycle", ["test", "arg"]) - assert result == 0 - mock_subprocess.assert_called_once() - # Verify the command and args passed to subprocess.run - call_args = mock_subprocess.call_args[0][0] - assert call_args[0] == "tmt" - assert "test" in call_args - assert "arg" in call_args + assert result == 0 + mock_subprocess.assert_called_once() + # Verify the command and args passed to subprocess.run + call_args = mock_subprocess.call_args[0][0] + assert call_args[0] == "tmt" + assert "test" in call_args + assert "arg" in call_args def test_drivers_tmt_run_tmt_internal_with_error(): """Test the internal _run_tmt method with error return code""" instance = TMT(children={"ssh": TcpNetwork(host="127.0.0.1", port=22)}) - with serve(instance) as client: - with patch('subprocess.run') as mock_subprocess: - mock_result = MagicMock() - mock_result.returncode = 1 - mock_subprocess.return_value = mock_result + with serve(instance) as client, patch('subprocess.run') as mock_subprocess: + mock_result = MagicMock() + mock_result.returncode = 1 + mock_subprocess.return_value = mock_result - result = client._run_tmt_local("localhost", 2222, "tmt", "user", "pass", "j power cycle", ["test"]) + result = client._run_tmt_local("localhost", 2222, "tmt", "user", "pass", "j power cycle", ["test"]) - assert result == 1 - mock_subprocess.assert_called_once() + assert result == 1 + mock_subprocess.assert_called_once() def test_drivers_tmt_driver_exports(): diff --git a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client.py b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client.py index 9b57d09a3..4e4499542 100644 --- a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client.py +++ b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client.py @@ -83,11 +83,11 @@ def run_command_checked(self, cmd: str, timeout: int = 60, check=True, tries=1) while tries > 0: tries-=1 self.logger.info(f"Running command checked: {cmd}") - output = self.run_command("{}; echo $?".format(cmd), timeout=timeout, _internal_log=False) + output = self.run_command(f"{cmd}; echo $?", timeout=timeout, _internal_log=False) parsed = output.strip().decode().splitlines() if len(parsed) < 2: - raise RuntimeError("Insufficient lines returned from command execution, raw output: {}".format(output)) + raise RuntimeError(f"Insufficient lines returned from command execution, raw output: {output}") try: retval = int(parsed[-1]) @@ -103,7 +103,7 @@ def run_command_checked(self, cmd: str, timeout: int = 60, check=True, tries=1) self.run_command("sleep 2", _internal_log=False) if check and retval != 0: - raise RuntimeError("Command failed with return value: {}, output: {}".format(retval, output)) + raise RuntimeError(f"Command failed with return value: {retval}, output: {output}") return parsed[1:-1] @@ -135,10 +135,10 @@ def get_env(self, key: str, timeout: int = 5) -> str | None: self.logger.debug(f"Getting U-Boot env var: {key}") try: - output = self.run_command_checked("printenv {}".format(key), timeout, check=False) + output = self.run_command_checked(f"printenv {key}", timeout, check=False) if len(output) != 1: raise RuntimeError( - "Invalid number of lines returned from printenv command, output: {}".format(output), + f"Invalid number of lines returned from printenv command, output: {output}", ) if output[0].startswith("## Error") and output[0].endswith("not defined"): @@ -147,7 +147,7 @@ def get_env(self, key: str, timeout: int = 5) -> str | None: parsed = output[0].split("=", 1) if len(parsed) != 2: raise RuntimeError( - "Failed to parse output of printenv command, output: {}".format(output[0]), + f"Failed to parse output of printenv command, output: {output[0]}", ) return parsed[1] @@ -160,9 +160,9 @@ def set_env(self, key: str, value: str | None, timeout: int = 5) -> None: """ if value is not None: - cmd = "setenv {} '{}'".format(key, value) + cmd = f"setenv {key} '{value}'" else: - cmd = "setenv {}".format(key) + cmd = f"setenv {key}" try: self.run_command_checked(cmd, timeout=timeout) diff --git a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client_test.py b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client_test.py index e55a73710..53bb55a59 100644 --- a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client_test.py +++ b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/client_test.py @@ -36,10 +36,11 @@ def fake_pexpect(): client.logger = logging.getLogger("test_uboot") prompt_value = "=> " - with patch.object(type(client), "prompt", new_callable=lambda: property(lambda self: prompt_value)): - with pytest.raises(RuntimeError, match="Failed to get U-Boot prompt"): - with client.reboot_to_console(retries=3): - pass + with ( + patch.object(type(client), "prompt", new_callable=lambda: property(lambda self: prompt_value)), + pytest.raises(RuntimeError, match="Failed to get U-Boot prompt"),client.reboot_to_console(retries=3) + ): + pass assert mock_pexpect_process.send.call_count == 3 mock_pexpect_process.send.assert_has_calls([call(ESC)] * 3) @@ -92,10 +93,11 @@ def fake_pexpect(): client.logger = logging.getLogger("test_uboot") prompt_value = "=> " - with patch.object(type(client), "prompt", new_callable=lambda: property(lambda self: prompt_value)): - with pytest.raises(RuntimeError, match="Failed to get U-Boot prompt"): - with client.reboot_to_console(retries=0): - pass + with ( + patch.object(type(client), "prompt", new_callable=lambda: property(lambda self: prompt_value)), + pytest.raises(RuntimeError, match="Failed to get U-Boot prompt"),client.reboot_to_console(retries=0) + ): + pass mock_pexpect_process.send.assert_not_called() mock_power.cycle.assert_called_once() diff --git a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/common.py b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/common.py index dc7ecf333..cddcd0db0 100644 --- a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/common.py +++ b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/common.py @@ -14,5 +14,5 @@ def cidr(self) -> str: octets = [int(x) for x in self.netmask.split(".")] binary = "".join([bin(x)[2:].zfill(8) for x in octets]) return str(binary.count("1")) - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 return "24" diff --git a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/driver.py b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/driver.py index 502b07fb7..9803d2f43 100644 --- a/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/driver.py +++ b/python/packages/jumpstarter-driver-uboot/jumpstarter_driver_uboot/driver.py @@ -19,7 +19,7 @@ def __post_init__(self): for child in ("power", "serial"): if child not in self.children: - raise ValueError("UbootConsole: {} driver not configured as a child".format(child)) + raise ValueError(f"UbootConsole: {child} driver not configured as a child") @export def get_prompt(self) -> str: diff --git a/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/conftest.py b/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/conftest.py index 88730f7bc..c49757213 100644 --- a/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/conftest.py +++ b/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/conftest.py @@ -1,3 +1,4 @@ +import contextlib import struct import threading @@ -122,18 +123,12 @@ def _run(self): def stop(self): self._running = False self._thread.join(timeout=2) - try: + with contextlib.suppress(Exception): self._stack.stop() - except Exception: - pass - try: + with contextlib.suppress(Exception): self._notifier.stop() - except Exception: - pass - try: + with contextlib.suppress(Exception): self._bus.shutdown() - except Exception: - pass @pytest.fixture diff --git a/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/driver_test.py b/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/driver_test.py index 63e1e21f9..19e7504d5 100644 --- a/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/driver_test.py +++ b/python/packages/jumpstarter-driver-uds-can/jumpstarter_driver_uds_can/driver_test.py @@ -267,9 +267,8 @@ def test_uds_can_timeout_on_read(mock_bus_cls, mock_notifier_cls, mock_stack_cls uds_mock.read_data_by_identifier.side_effect = TimeoutError("No CAN response") driver = UdsCan(channel="vcan0", rxid=0x641, txid=0x642) - with serve(driver) as client: - with pytest.raises(DriverError, match="No CAN response"): - client.read_data_by_identifier([0xF190]) + with serve(driver) as client, pytest.raises(DriverError, match="No CAN response"): + client.read_data_by_identifier([0xF190]) @patch("jumpstarter_driver_uds_can.driver.UdsoncanClient") diff --git a/python/packages/jumpstarter-driver-uds-doip/jumpstarter_driver_uds_doip/driver_test.py b/python/packages/jumpstarter-driver-uds-doip/jumpstarter_driver_uds_doip/driver_test.py index d0aa0406f..952222236 100644 --- a/python/packages/jumpstarter-driver-uds-doip/jumpstarter_driver_uds_doip/driver_test.py +++ b/python/packages/jumpstarter-driver-uds-doip/jumpstarter_driver_uds_doip/driver_test.py @@ -221,9 +221,8 @@ def test_uds_doip_timeout_on_change_session(mock_doip_cls, mock_conn_cls, mock_u uds_mock.change_session.side_effect = TimeoutError("Request timed out") driver = UdsDoip(ecu_ip="192.168.1.100", ecu_logical_address=0x00E0) - with serve(driver) as client: - with pytest.raises(DriverError, match="Request timed out"): - client.change_session(UdsSessionType.EXTENDED) + with serve(driver) as client, pytest.raises(DriverError, match="Request timed out"): + client.change_session(UdsSessionType.EXTENDED) @patch("jumpstarter_driver_uds_doip.driver.UdsoncanClient") diff --git a/python/packages/jumpstarter-driver-ustreamer/jumpstarter_driver_ustreamer/driver.py b/python/packages/jumpstarter-driver-ustreamer/jumpstarter_driver_ustreamer/driver.py index 3a7678b6e..cccc79c67 100644 --- a/python/packages/jumpstarter-driver-ustreamer/jumpstarter_driver_ustreamer/driver.py +++ b/python/packages/jumpstarter-driver-ustreamer/jumpstarter_driver_ustreamer/driver.py @@ -80,7 +80,7 @@ def __post_init__(self): cmdline, stdout=sys.stdout, stderr=sys.stderr, - preexec_fn=_get_preexec_fn(), + preexec_fn=_get_preexec_fn(), # noqa: PLW1509 ) def close(self): @@ -91,21 +91,25 @@ def close(self): self.process.kill() @export - async def state(self): - async with ClientSession(connector=UnixConnector(path=self.socketp)) as session: - async with session.get("http://localhost/state") as r: - json = await r.json() - self.logger.debug(f"state: {json}") - return UStreamerState.model_validate(json) + async def state(self): # pragma: no cover + async with ( + ClientSession(connector=UnixConnector(path=self.socketp)) as session, + session.get("http://localhost/state") as r, + ): + json = await r.json() + self.logger.debug(f"state: {json}") + return UStreamerState.model_validate(json) @export - async def snapshot(self): - async with ClientSession(connector=UnixConnector(path=self.socketp)) as session: - async with session.get("http://localhost/snapshot") as r: - data = await r.read() - length = len(data) - self.logger.debug(f"snapshot: {length} bytes") - return b64encode(data).decode("ascii") + async def snapshot(self): # pragma: no cover + async with ( + ClientSession(connector=UnixConnector(path=self.socketp)) as session, + session.get("http://localhost/snapshot") as r, + ): + data = await r.read() + length = len(data) + self.logger.debug(f"snapshot: {length} bytes") + return b64encode(data).decode("ascii") @export def stream_path(self) -> str: diff --git a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client.py b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client.py index 37e8d7a65..8885cd2b1 100644 --- a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client.py +++ b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client.py @@ -191,7 +191,6 @@ def cli(self): @driver_click_group(self) def video(): """Video capture and streaming""" - pass @video.command() def state(): diff --git a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client_test.py b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client_test.py index a01e46056..b963262f4 100644 --- a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client_test.py +++ b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/client_test.py @@ -222,10 +222,9 @@ def test_run_server_propagates_startup_errors(): "jumpstarter_driver_video.client.move_on_after", side_effect=lambda *args, **kwargs: contextlib.nullcontext(), ), - patch("jumpstarter_driver_video.client.click.echo") as mock_echo, + patch("jumpstarter_driver_video.client.click.echo") as mock_echo,pytest.raises(OSError, match="port in use") ): - with pytest.raises(OSError, match="port in use"): - run_video_server(client, object(), 0, False) + run_video_server(client, object(), 0, False) runner.setup.assert_awaited_once() site.start.assert_awaited_once() @@ -244,10 +243,9 @@ def test_run_server_raises_when_no_bound_address_is_reported(): patch( "jumpstarter_driver_video.client.move_on_after", side_effect=lambda *args, **kwargs: contextlib.nullcontext(), - ), + ),pytest.raises(RuntimeError, match="without a bound address") ): - with pytest.raises(RuntimeError, match="without a bound address"): - run_video_server(client, object(), 0, False) + run_video_server(client, object(), 0, False) runner.setup.assert_awaited_once() site.start.assert_awaited_once() @@ -363,18 +361,20 @@ async def test_proxy_mjpeg_stream_forwards_headers_and_body_chunks(): async def test_proxy_returns_502_on_invalid_chunk_size(): tunnel = _FakeTunnel( [ - b"HTTP/1.1 200 OK\r\n" + (b"HTTP/1.1 200 OK\r\n" b"Transfer-Encoding: chunked\r\n" b"\r\n" - b"NOT_HEX\r\ndata\r\n", + b"NOT_HEX\r\ndata\r\n"), ] ) client = SimpleNamespace(stream_async=lambda method: _FakeStreamContext(tunnel)) response = _FakeStreamResponse() - with patch("jumpstarter_driver_video.client.web.StreamResponse", return_value=response): - with pytest.raises(web.HTTPBadGateway, match="invalid chunk size"): - await proxy_mjpeg_stream(client, object(), "/stream") + with ( + patch("jumpstarter_driver_video.client.web.StreamResponse", return_value=response), + pytest.raises(web.HTTPBadGateway, match="invalid chunk size"), + ): + await proxy_mjpeg_stream(client, object(), "/stream") @pytest.mark.anyio @@ -391,6 +391,8 @@ async def test_proxy_returns_502_on_oversized_chunk(): client = SimpleNamespace(stream_async=lambda method: _FakeStreamContext(tunnel)) response = _FakeStreamResponse() - with patch("jumpstarter_driver_video.client.web.StreamResponse", return_value=response): - with pytest.raises(web.HTTPBadGateway, match="chunk too large"): - await proxy_mjpeg_stream(client, object(), "/stream") + with ( + patch("jumpstarter_driver_video.client.web.StreamResponse", return_value=response), + pytest.raises(web.HTTPBadGateway, match="chunk too large"), + ): + await proxy_mjpeg_stream(client, object(), "/stream") diff --git a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/driver.py b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/driver.py index e45499e79..12fdea3b2 100644 --- a/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/driver.py +++ b/python/packages/jumpstarter-driver-video/jumpstarter_driver_video/driver.py @@ -93,13 +93,13 @@ async def state(self) -> VideoState: """ try: data = await self._fetch_frame() - except Exception as e: + except Exception as e: # noqa: BLE001 self.logger.debug("state: source unreachable: %s", e) return VideoState(online=False) try: with Image.open(io.BytesIO(data)) as img: width, height = img.size - except Exception: + except Exception: # pragma: no cover # noqa: BLE001 return VideoState(online=True) return VideoState(online=True, width=width, height=height) diff --git a/python/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py b/python/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py index 1c735dd4d..0f8e1ae17 100644 --- a/python/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py +++ b/python/packages/jumpstarter-driver-vnc/jumpstarter_driver_vnc/__init__.py @@ -1,3 +1,3 @@ from .client import VNClient -VNClient = VNClient +__all__ = ["VNClient"] diff --git a/python/packages/jumpstarter-driver-xcp/jumpstarter_driver_xcp/driver_test.py b/python/packages/jumpstarter-driver-xcp/jumpstarter_driver_xcp/driver_test.py index f5bb93d88..13ffd4e06 100644 --- a/python/packages/jumpstarter-driver-xcp/jumpstarter_driver_xcp/driver_test.py +++ b/python/packages/jumpstarter-driver-xcp/jumpstarter_driver_xcp/driver_test.py @@ -66,9 +66,8 @@ def client(mock_master): with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - yield client + ), serve(instance) as client: + yield client # ============================================================================= @@ -279,84 +278,77 @@ def test_unlock_with_resources(client, mock_master): def test_connect_timeout(mock_master): mock_master.connect.side_effect = TimeoutError("No response from ECU") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - with pytest.raises(DriverError, match="No response from ECU"): - client.connect() + ), serve(instance) as client, pytest.raises(DriverError, match="No response from ECU"): + client.connect() def test_upload_error(mock_master): mock_master.shortUpload.side_effect = RuntimeError("XCP ERR_ACCESS_DENIED") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - client.connect() - with pytest.raises(DriverError, match="ERR_ACCESS_DENIED"): - client.upload(4, 0x1000, 0) + ), serve(instance) as client: + client.connect() + with pytest.raises(DriverError, match="ERR_ACCESS_DENIED"): + client.upload(4, 0x1000, 0) def test_download_error(mock_master): mock_master.download.side_effect = RuntimeError("XCP ERR_OUT_OF_RANGE") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - client.connect() - with pytest.raises(DriverError, match="ERR_OUT_OF_RANGE"): - client.download(0x2000, b"\x01\x02", 0) + ), serve(instance) as client: + client.connect() + with pytest.raises(DriverError, match="ERR_OUT_OF_RANGE"): + client.download(0x2000, b"\x01\x02", 0) def test_program_clear_error(mock_master): mock_master.programClear.side_effect = RuntimeError("Erase failed") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - client.connect() - with pytest.raises(DriverError, match="Erase failed"): - client.program_clear(0x10000) + ), serve(instance) as client: + client.connect() + with pytest.raises(DriverError, match="Erase failed"): + client.program_clear(0x10000) def test_unlock_error(mock_master): mock_master.cond_unlock.side_effect = RuntimeError("Seed & key failed") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - client.connect() - with pytest.raises(DriverError, match="Seed & key failed"): - client.unlock() + ), serve(instance) as client: + client.connect() + with pytest.raises(DriverError, match="Seed & key failed"): + client.unlock() def test_get_daq_info_error(mock_master): mock_master.getDaqInfo.side_effect = RuntimeError("DAQ not supported") - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=mock_master, - ): - with serve(instance) as client: - client.connect() - with pytest.raises(DriverError, match="DAQ not supported"): - client.get_daq_info() + ), serve(instance) as client: + client.connect() + with pytest.raises(DriverError, match="DAQ not supported"): + client.get_daq_info() # ============================================================================= @@ -428,13 +420,12 @@ def test_custom_config_forwarded(mock_create): def _stateful_client_ctx(stateful_master): """Context manager helper: serve() an Xcp driver backed by the stateful mock.""" - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=stateful_master, - ): - with serve(instance) as c: - yield c + ), serve(instance) as c: + yield c @pytest.fixture @@ -608,29 +599,27 @@ def test_stateful_full_programming_flow(stateful_client, stateful_master): def test_stateful_program_clear_before_start_raises(stateful_master): """programClear without programStart should fail.""" - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=stateful_master, - ): - with serve(instance) as c: - c.connect() - with pytest.raises(DriverError, match="programStart must be called"): - c.program_clear(0x10000) + ), serve(instance) as c: + c.connect() + with pytest.raises(DriverError, match="programStart must be called"): + c.program_clear(0x10000) def test_stateful_program_before_clear_raises(stateful_master): """program without programClear should fail.""" - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=stateful_master, - ): - with serve(instance) as c: - c.connect() - c.program_start() - with pytest.raises(DriverError, match="programClear must be called"): - c.program(b"\x00" * 8) + ), serve(instance) as c: + c.connect() + c.program_start() + with pytest.raises(DriverError, match="programClear must be called"): + c.program(b"\x00" * 8) # - end-to-end calibration workflow ------------------------------------------ @@ -668,14 +657,12 @@ def test_stateful_calibration_workflow(stateful_client): def test_stateful_operations_before_connect_raise(stateful_master): """Methods called before connect() should fail.""" - instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] # noqa: E501 + instance = Xcp(transport="ETH", host="127.0.0.1", port=5555, protocol="TCP") # ty: ignore[invalid-argument-type] with patch( "jumpstarter_driver_xcp.driver._create_xcp_master", return_value=stateful_master, - ): - with serve(instance) as c: - with pytest.raises(DriverError, match="Not connected"): - c.get_id() + ), serve(instance) as c, pytest.raises(DriverError, match="Not connected"): + c.get_id() def test_stateful_reconnect_after_disconnect(stateful_client, stateful_master): diff --git a/python/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py b/python/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py index 875dda0e5..af6ef0fad 100644 --- a/python/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py +++ b/python/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py @@ -48,7 +48,7 @@ def __post_init__(self): with _USB_DEVS_LOCK: # another instance already claimed this device? if self.serial is None and len(_USB_DEVS.keys()) > 0: - self.serial = list(_USB_DEVS.keys())[0] + self.serial = next(iter(_USB_DEVS.keys())) self.dev = _USB_DEVS[self.serial] return diff --git a/python/packages/jumpstarter-imagehash/jumpstarter_imagehash/__init__.py b/python/packages/jumpstarter-imagehash/jumpstarter_imagehash/__init__.py index 7f8289221..61f81e1a1 100644 --- a/python/packages/jumpstarter-imagehash/jumpstarter_imagehash/__init__.py +++ b/python/packages/jumpstarter-imagehash/jumpstarter_imagehash/__init__.py @@ -1,3 +1,3 @@ from .imagehash import ImageHash -ImageHash = ImageHash +__all__ = ["ImageHash"] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py index 48b6487af..925362730 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/__init__.py @@ -45,44 +45,44 @@ __all__ = [ "ApplyV1Alpha1Api", + "ClientsV1Alpha1Api", + "ExportersV1Alpha1Api", + "LeasesV1Alpha1Api", "ManifestError", "V1Alpha1AppliedResource", "V1Alpha1AppliedResourceList", - "load_manifests", - "ClientsV1Alpha1Api", "V1Alpha1Client", "V1Alpha1ClientList", "V1Alpha1ClientStatus", "V1Alpha1ClusterInfo", "V1Alpha1ClusterList", - "V1Alpha1JumpstarterInstance", - "ExportersV1Alpha1Api", "V1Alpha1Exporter", + "V1Alpha1ExporterDevice", "V1Alpha1ExporterList", "V1Alpha1ExporterStatus", - "V1Alpha1ExporterDevice", - "LeasesV1Alpha1Api", + "V1Alpha1JumpstarterInstance", "V1Alpha1Lease", - "V1Alpha1LeaseStatus", "V1Alpha1LeaseList", "V1Alpha1LeaseSelector", "V1Alpha1LeaseSpec", + "V1Alpha1LeaseStatus", "V1Alpha1List", - "minikube_installed", - "kind_installed", - "create_minikube_cluster", - "create_kind_cluster", - "delete_minikube_cluster", - "delete_kind_cluster", + "check_jumpstarter_installation", "create_cluster_and_install", "create_cluster_only", + "create_kind_cluster", + "create_minikube_cluster", "delete_cluster_by_name", + "delete_kind_cluster", + "delete_minikube_cluster", + "detect_cluster_type", "get_cluster_info", - "list_clusters", "get_ip_generic", + "get_latest_compatible_controller_version", + "kind_installed", + "list_clusters", "list_kubectl_contexts", - "detect_cluster_type", - "check_jumpstarter_installation", + "load_manifests", + "minikube_installed", "validate_cluster_type_selection", - "get_latest_compatible_controller_version", ] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply.py index ee63436cd..e698e2ffd 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply.py @@ -10,7 +10,7 @@ """ import logging -from typing import Literal, Optional +from typing import Literal import yaml from kubernetes_asyncio.client.exceptions import ApiException @@ -101,7 +101,7 @@ class V1Alpha1AppliedResource(JsonBaseModel): api_version: str = Field(alias="apiVersion") kind: str name: str - namespace: Optional[str] = None + namespace: str | None = None action: Literal["created", "configured", "unchanged"] resource: dict @@ -143,7 +143,7 @@ def rich_add_names(self, names): class ApplyV1Alpha1Api(AbstractAsyncCustomObjectApi): """Apply Jumpstarter manifests of any kind the cluster serves.""" - def __init__(self, namespace: str, config_file: Optional[str] = None, context: Optional[str] = None): + def __init__(self, namespace: str, config_file: str | None = None, context: str | None = None): super().__init__(namespace, config_file, context) self._resources: dict[tuple[str, str, str], tuple[str, bool]] = {} @@ -185,7 +185,7 @@ async def _resolve_resource(self, group: str, version: str, kind: str) -> tuple[ raise ManifestError(f"the cluster does not serve kind {kind} in {group}/{version}") - async def _read(self, group, version, plural, name, namespace) -> Optional[dict]: + async def _read(self, group, version, plural, name, namespace) -> dict | None: try: if namespace is None: return await self.api.get_cluster_custom_object(group=group, version=version, plural=plural, name=name) @@ -265,7 +265,7 @@ async def apply( ) return V1Alpha1AppliedResource( - apiVersion=api_version, + apiVersion=api_version, # type: ignore[call-arg] kind=kind, name=name, namespace=namespace, @@ -284,7 +284,7 @@ async def apply_all( def _action_taken( - existing: Optional[dict], applied: dict, *, dry_run: bool = False + existing: dict | None, applied: dict, *, dry_run: bool = False ) -> Literal["created", "configured", "unchanged"]: """Describe an apply by what the cluster did, or would do, with it. diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply_test.py index b3fc2be1c..c96027735 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/apply_test.py @@ -116,7 +116,7 @@ async def call_api(path, method, **kwargs): def patch_call(api: ApplyV1Alpha1Api): """The PATCH the API sent, as (path, path_params, query_params, body).""" - for call in api._client.call_api.await_args_list: + for call in api._client.call_api.await_args_list: # type: ignore[attr-defined] if call.args[1] == "PATCH": return call raise AssertionError("no apply request was sent") @@ -288,7 +288,7 @@ async def test_apply_rejects_a_kind_the_cluster_does_not_serve(): @pytest.mark.asyncio async def test_apply_explains_a_missing_api_group(): api = make_api() - api._client.call_api.side_effect = ApiException(status=404) + api._client.call_api.side_effect = ApiException(status=404) # type: ignore[attr-defined] with pytest.raises(ManifestError, match="CRDs are installed"): await api.apply(load_manifests(CLIENT_MANIFEST)[0]) @@ -301,5 +301,5 @@ async def test_apply_all_looks_up_each_kind_once(): applied = await api.apply_all(load_manifests(f"{CLIENT_MANIFEST}\n---\n{CLIENT_MANIFEST}")) assert [item.action for item in applied.items] == ["created", "created"] - discoveries = [call for call in api._client.call_api.await_args_list if call.args[1] == "GET"] + discoveries = [call for call in api._client.call_api.await_args_list if call.args[1] == "GET"] # type: ignore[attr-defined] assert len(discoveries) == 1 diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py index 6294c1ef9..4de13b8ac 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/callbacks.py @@ -42,19 +42,15 @@ class SilentCallback: def progress(self, message: str) -> None: """Does nothing.""" - pass def success(self, message: str) -> None: """Does nothing.""" - pass def warning(self, message: str) -> None: """Does nothing.""" - pass def error(self, message: str) -> None: """Does nothing.""" - pass def confirm(self, prompt: str) -> bool: """Always returns True (auto-confirm).""" @@ -67,7 +63,7 @@ class LoggingCallback: Useful for server applications or when you want structured logging. """ - def __init__(self, logger: logging.Logger = None): + def __init__(self, logger: logging.Logger | None = None): """Initialize with optional logger. If None, uses root logger.""" self.logger = logger or logging.getLogger(__name__) @@ -99,7 +95,7 @@ class ForceCallback: Skips all confirmations and produces minimal output. """ - def __init__(self, output_callback: OutputCallback = None): + def __init__(self, output_callback: OutputCallback | None = None): """Initialize with optional output callback for messages.""" self.output_callback = output_callback or SilentCallback() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py index bce4ecf9a..b8427f6c7 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients.py @@ -1,7 +1,7 @@ import asyncio import base64 import logging -from typing import Literal, Optional +from typing import Literal from kubernetes_asyncio.client.exceptions import ApiException from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference @@ -23,7 +23,7 @@ class V1Alpha1ClientStatus(JsonBaseModel): - credential: Optional[SerializeV1ObjectReference] = None + credential: SerializeV1ObjectReference | None = None endpoint: str @@ -31,7 +31,7 @@ class V1Alpha1Client(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Client"] = Field(default="Client") metadata: SerializeV1ObjectMeta - status: Optional[V1Alpha1ClientStatus] + status: V1Alpha1ClientStatus | None @staticmethod def from_dict(dict: dict): @@ -125,12 +125,11 @@ async def create_client( namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1", name=name ) # check if the client status is updated with the credentials - if "status" in updated_client: - if "credential" in updated_client["status"]: - return V1Alpha1Client.from_dict(updated_client) + if "status" in updated_client and "credential" in updated_client["status"]: + return V1Alpha1Client.from_dict(updated_client) count += 1 await asyncio.sleep(CREATE_CLIENT_DELAY) - raise Exception("Timeout waiting for client credentials") + raise Exception("Timeout waiting for client credentials") # noqa: TRY002 async def list_clients(self) -> V1Alpha1List[V1Alpha1Client]: """List the client objects in the cluster async""" @@ -172,7 +171,7 @@ async def rotate_client_token(self, name: str) -> str: """Rotate the internal token for a client by deleting its secret and waiting for regeneration.""" client = await self.get_client(name) if client.status is None or client.status.credential is None: - raise Exception(f"Client '{name}' has no credential secret") + raise Exception(f"Client '{name}' has no credential secret") # noqa: TRY002 secret_name = client.status.credential.name await self.core_api.delete_namespaced_secret(secret_name, self.namespace) @@ -188,7 +187,7 @@ async def rotate_client_token(self, name: str) -> str: raise count += 1 await asyncio.sleep(CREATE_CLIENT_DELAY) - raise Exception("Timeout waiting for token regeneration") + raise Exception("Timeout waiting for token regeneration") # noqa: TRY002 async def delete_client(self, name: str): """Delete a client object""" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py index 969e94918..3b7fb0a8e 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clients_test.py @@ -315,7 +315,7 @@ async def test_get_ca_bundle_other_api_error(): with pytest.raises(ApiException) as exc_info: await api.get_ca_bundle() - assert exc_info.value.status == 403 + assert exc_info.value.status == 403 # type: ignore[attr-defined] @pytest.mark.asyncio diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py index 7e95eda92..519c4abcc 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/__init__.py @@ -84,52 +84,52 @@ # Types "ClusterType", "KubectlContext", + # Detection and configuration + "auto_detect_cluster_type", + "check_jumpstarter_installation", + # Kubectl operations + "check_kubernetes_access", + "configure_endpoints", + # High-level operations + "create_cluster_and_install", + "create_cluster_only", + "create_k3s_cluster", + "create_kind_cluster", + "create_minikube_cluster", + "delete_cluster_by_name", + "delete_k3s_cluster", + "delete_kind_cluster", + "delete_minikube_cluster", + "detect_cluster_type", + "detect_existing_cluster_type", # Common utilities "extract_host_from_ssh", - "validate_cluster_name", - "validate_cluster_type", + "fetch_k3s_kubeconfig", "format_cluster_name", + "get_cluster_info", "get_extra_certs_path", + "get_ip_generic", + "get_kubectl_contexts", + "get_minikube_cluster_ip", + # Operator operations + "install_jumpstarter_operator", + "k3s_cluster_exists", # k3s operations "k3s_reachable", - "k3s_cluster_exists", - "create_k3s_cluster", - "delete_k3s_cluster", - "fetch_k3s_kubeconfig", + "kind_cluster_exists", # Kind operations "kind_installed", - "kind_cluster_exists", - "create_kind_cluster", - "delete_kind_cluster", + "list_clusters", "list_kind_clusters", + "list_kubectl_contexts", + "list_minikube_clusters", + "minikube_cluster_exists", # Minikube operations "minikube_installed", - "minikube_cluster_exists", - "create_minikube_cluster", - "delete_minikube_cluster", - "list_minikube_clusters", - "get_minikube_cluster_ip", - # Operator operations - "install_jumpstarter_operator", - # Kubectl operations - "check_kubernetes_access", - "get_kubectl_contexts", - "list_kubectl_contexts", - "check_jumpstarter_installation", - "get_cluster_info", - "list_clusters", - # Detection and configuration - "auto_detect_cluster_type", - "detect_cluster_type", - "detect_existing_cluster_type", - "get_ip_generic", - "configure_endpoints", - # High-level operations - "create_cluster_and_install", - "create_cluster_only", - "delete_cluster_by_name", - "validate_cluster_type_selection", # Utility functions "run_command", "run_command_with_output", + "validate_cluster_name", + "validate_cluster_type", + "validate_cluster_type_selection", ] diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py index 9ba5ef0f8..e2882227a 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common.py @@ -1,12 +1,13 @@ """Common utilities and types for cluster operations.""" import asyncio +import asyncio.subprocess import os -from typing import Literal, Optional +from typing import Literal from ..exceptions import ClusterTypeValidationError -ClusterType = Literal["kind"] | Literal["minikube"] | Literal["k3s"] +ClusterType = Literal["kind", "minikube", "k3s"] # NodePort assignments (must match kind_cluster.yaml extraPortMappings and operator CR) GRPC_NODEPORT = 30010 @@ -19,8 +20,8 @@ def validate_cluster_type( - kind: Optional[str], minikube: Optional[str] -) -> Optional[ClusterType]: + kind: str | None, minikube: str | None +) -> ClusterType | None: """Validate cluster type selection - returns None if neither is specified.""" if kind and minikube: raise ClusterTypeValidationError('You can only select one local cluster type "kind" or "minikube"') @@ -33,7 +34,7 @@ def validate_cluster_type( return None -def get_extra_certs_path(extra_certs: Optional[str]) -> Optional[str]: +def get_extra_certs_path(extra_certs: str | None) -> str | None: """Get the absolute path to extra certificates file if provided. Expands ~ (tilde) and environment variables before resolving to absolute path. @@ -72,7 +73,7 @@ async def run_command(cmd: list[str]) -> tuple[int, str, str]: raise ValueError("Command list cannot be empty") try: - process = await asyncio.create_subprocess_exec( + process = await asyncio.create_subprocess_exec( # type: ignore[missing-argument] *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() @@ -99,7 +100,7 @@ async def run_command_with_output(cmd: list[str]) -> int: raise ValueError("Command list cannot be empty") try: - process = await asyncio.create_subprocess_exec(*cmd) + process = await asyncio.create_subprocess_exec(*cmd) # type: ignore[missing-argument] return await process.wait() except builtins.FileNotFoundError as e: raise RuntimeError(f"Command not found: {cmd[0]}") from e diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py index 2ee4cbb9e..d39996573 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/common_test.py @@ -1,6 +1,7 @@ """Tests for common cluster utilities and types.""" import asyncio +import asyncio.subprocess import os import tempfile from unittest.mock import AsyncMock, patch @@ -171,7 +172,7 @@ def test_validate_cluster_name_none_raises_error(self): # This would be caught by type checking, but test runtime behavior with pytest.raises(ClusterNameValidationError, match="Cluster name cannot be empty"): - validate_cluster_name(None) + validate_cluster_name(None) # type: ignore[arg-type] def test_validate_cluster_name_with_special_chars(self): result = validate_cluster_name("test-cluster_123") @@ -218,9 +219,11 @@ async def test_run_command_failure(self): @pytest.mark.asyncio async def test_run_command_not_found(self): - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")): - with pytest.raises(RuntimeError, match="Command not found: nonexistent"): - await run_command(["nonexistent"]) + with ( + patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")), + pytest.raises(RuntimeError, match="Command not found: nonexistent"), + ): + await run_command(["nonexistent"]) @pytest.mark.asyncio async def test_run_command_with_output_success(self): @@ -236,9 +239,11 @@ async def test_run_command_with_output_success(self): @pytest.mark.asyncio async def test_run_command_with_output_not_found(self): - with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")): - with pytest.raises(RuntimeError, match="Command not found: nonexistent"): - await run_command_with_output(["nonexistent"]) + with ( + patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")), + pytest.raises(RuntimeError, match="Command not found: nonexistent"), + ): + await run_command_with_output(["nonexistent"]) @pytest.mark.asyncio async def test_run_command_with_output_failure(self): diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py index fa7f1ad40..e47b3e873 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/detection.py @@ -3,7 +3,7 @@ import json import re import shutil -from typing import Literal, Optional +from typing import Literal from ..exceptions import ToolNotInstalledError from .common import run_command @@ -56,7 +56,7 @@ async def detect_kind_provider(cluster_name: str) -> tuple[str, str]: return runtime, f"{cluster_name}-control-plane" -async def detect_existing_cluster_type(cluster_name: str) -> Optional[Literal["kind"] | Literal["minikube"]]: +async def detect_existing_cluster_type(cluster_name: str) -> Literal["kind", "minikube"] | None: """Detect which type of cluster exists with the given name.""" kind_exists = False minikube_exists = False @@ -94,7 +94,7 @@ async def detect_existing_cluster_type(cluster_name: str) -> Optional[Literal["k return None -def auto_detect_cluster_type() -> Literal["kind"] | Literal["minikube"]: +def auto_detect_cluster_type() -> Literal["kind", "minikube"]: """Auto-detect available cluster type, preferring Kind over Minikube. Note: k3s is not auto-detected because it requires an explicit --k3s argument. diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py index 3a923477f..afb853cf8 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/endpoints.py @@ -1,6 +1,5 @@ """Endpoint configuration for cluster management.""" -from typing import Optional from ..exceptions import EndpointConfigurationError, ToolNotInstalledError from .common import GRPC_NODEPORT, KIND_GRPC_HOST_PORT, KIND_ROUTER_HOST_PORT, ROUTER_NODEPORT @@ -8,7 +7,7 @@ from jumpstarter.common.ipaddr import get_ip_address, get_minikube_ip -async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_name: str) -> str: +async def get_ip_generic(cluster_type: str | None, minikube: str, cluster_name: str) -> str: """Get IP address for the cluster.""" if cluster_type == "minikube": if not minikube_installed(minikube): @@ -26,13 +25,13 @@ async def get_ip_generic(cluster_type: Optional[str], minikube: str, cluster_nam async def configure_endpoints( - cluster_type: Optional[str], + cluster_type: str | None, minikube: str, cluster_name: str, - ip: Optional[str], - basedomain: Optional[str], - grpc_endpoint: Optional[str], - router_endpoint: Optional[str], + ip: str | None, + basedomain: str | None, + grpc_endpoint: str | None, + router_endpoint: str | None, ) -> tuple[str, str, str, str]: """Configure endpoints for Jumpstarter installation.""" if ip is None: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/k3s.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/k3s.py index 88677343d..2e768f305 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/k3s.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/k3s.py @@ -50,7 +50,7 @@ async def _ssh_run_with_output(ssh_host: str, command: str) -> int: async def create_k3s_cluster( ssh_host: str, force_recreate: bool = False, - callback: OutputCallback = None, + callback: OutputCallback | None = None, ) -> str: """Create a k3s cluster on a remote host via SSH. @@ -98,7 +98,7 @@ async def create_k3s_cluster( return kubeconfig_path -async def fetch_k3s_kubeconfig(ssh_host: str, callback: OutputCallback = None) -> str: +async def fetch_k3s_kubeconfig(ssh_host: str, callback: OutputCallback | None = None) -> str: """Fetch the k3s kubeconfig from a remote host and save it locally. Replaces 127.0.0.1 with the SSH host IP so it's usable from the local machine. @@ -129,7 +129,7 @@ async def fetch_k3s_kubeconfig(ssh_host: str, callback: OutputCallback = None) - return kubeconfig_path -async def delete_k3s_cluster(ssh_host: str, callback: OutputCallback = None) -> None: +async def delete_k3s_cluster(ssh_host: str, callback: OutputCallback | None = None) -> None: """Uninstall k3s from a remote host via SSH.""" if callback is None: callback = SilentCallback() @@ -159,7 +159,7 @@ async def create_k3s_cluster_with_options( ssh_host: str, cluster_name: str, force_recreate: bool = False, - callback: OutputCallback = None, + callback: OutputCallback | None = None, ) -> str: """Create a k3s cluster with options, matching the interface of kind/minikube helpers. @@ -178,7 +178,9 @@ async def create_k3s_cluster_with_options( raise ClusterOperationError(action, cluster_name, "k3s", e) from e -async def delete_k3s_cluster_with_feedback(ssh_host: str, cluster_name: str, callback: OutputCallback = None) -> None: +async def delete_k3s_cluster_with_feedback( + ssh_host: str, cluster_name: str, callback: OutputCallback | None = None +) -> None: """Delete a k3s cluster with user feedback.""" if callback is None: callback = SilentCallback() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py index aa7d40681..d5b377f60 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind.py @@ -4,7 +4,6 @@ import shlex import shutil import tempfile -from typing import List, Optional from ..callbacks import OutputCallback, SilentCallback from ..exceptions import ( @@ -50,7 +49,7 @@ async def delete_kind_cluster(kind: str, cluster_name: str) -> bool: async def create_kind_cluster( - kind: str, cluster_name: str, extra_args: Optional[List[str]] = None, force_recreate: bool = False + kind: str, cluster_name: str, extra_args: list[str] | None = None, force_recreate: bool = False ) -> bool: """Create a Kind cluster.""" if extra_args is None: @@ -125,7 +124,7 @@ async def create_kind_cluster( pass -async def list_kind_clusters(kind: str) -> List[str]: +async def list_kind_clusters(kind: str) -> list[str]: """List all Kind clusters.""" if not kind_installed(kind): return [] @@ -140,7 +139,7 @@ async def list_kind_clusters(kind: str) -> List[str]: return [] -async def inject_certificates(extra_certs: str, cluster_name: str, callback: OutputCallback = None) -> None: +async def inject_certificates(extra_certs: str, cluster_name: str, callback: OutputCallback | None = None) -> None: """Inject custom certificates into a Kind cluster.""" if callback is None: callback = SilentCallback() @@ -183,8 +182,8 @@ async def create_kind_cluster_with_options( cluster_name: str, kind_extra_args: str, force_recreate_cluster: bool, - extra_certs: Optional[str] = None, - callback: OutputCallback = None, + extra_certs: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Create a Kind cluster with optional certificate injection.""" if callback is None: @@ -217,7 +216,9 @@ async def create_kind_cluster_with_options( raise ClusterOperationError(action, cluster_name, "kind", e) from e -async def delete_kind_cluster_with_feedback(kind: str, cluster_name: str, callback: OutputCallback = None) -> None: +async def delete_kind_cluster_with_feedback( + kind: str, cluster_name: str, callback: OutputCallback | None = None +) -> None: """Delete a Kind cluster with user feedback.""" if callback is None: callback = SilentCallback() diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py index a26df7102..5f6c38f1f 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kind_test.py @@ -135,8 +135,8 @@ async def test_create_kind_cluster_already_exists(self, mock_cluster_exists, moc with pytest.raises(ClusterAlreadyExistsError) as exc_info: await create_kind_cluster("kind", "test-cluster") - assert exc_info.value.cluster_name == "test-cluster" - assert exc_info.value.cluster_type == "kind" + assert exc_info.value.cluster_name == "test-cluster" # type: ignore[attr-defined] + assert exc_info.value.cluster_type == "kind" # type: ignore[attr-defined] @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kind.kind_installed") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py index 90dfc5a80..9c1ea0042 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl.py @@ -1,7 +1,7 @@ """Kubectl operations for cluster management.""" import json -from typing import List, Literal, Optional, TypedDict, Union +from typing import Literal, TypedDict from ..clusters import V1Alpha1ClusterInfo, V1Alpha1ClusterList, V1Alpha1JumpstarterInstance from ..exceptions import JumpstarterKubernetesError @@ -17,7 +17,7 @@ class KubectlContext(TypedDict): current: bool -async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = "kubectl") -> bool: +async def check_kubernetes_access(context: str | None = None, kubectl: str = "kubectl") -> bool: """Check if Kubernetes cluster is accessible.""" try: cmd = [kubectl] @@ -31,7 +31,7 @@ async def check_kubernetes_access(context: Optional[str] = None, kubectl: str = return False -async def get_kubectl_contexts(kubectl: str = "kubectl") -> List[KubectlContext]: +async def get_kubectl_contexts(kubectl: str = "kubectl") -> list[KubectlContext]: """Get all kubectl contexts.""" contexts = [] @@ -97,11 +97,11 @@ class CrInstanceNotFound(TypedDict): installed: Literal[False] -CrInstanceResult = Union[CrInstanceSuccess, CrInstanceError, CrInstanceNotFound] +CrInstanceResult = CrInstanceSuccess | CrInstanceError | CrInstanceNotFound async def _check_cr_instances( - kubectl: str, context: str, namespace: Optional[str] + kubectl: str, context: str, namespace: str | None ) -> CrInstanceResult: """Query for Jumpstarter CR instances to confirm full installation.""" cr_resource = "jumpstarters.operator.jumpstarter.dev" @@ -145,7 +145,7 @@ def _apply_cr_result(result_data: dict, cr_result: CrInstanceResult) -> None: async def check_jumpstarter_installation( - context: str, namespace: Optional[str] = None, kubectl: str = "kubectl" + context: str, namespace: str | None = None, kubectl: str = "kubectl" ) -> V1Alpha1JumpstarterInstance: """Check if Jumpstarter is installed in the cluster using CRD detection.""" result_data = { @@ -166,7 +166,7 @@ async def check_jumpstarter_installation( if returncode != 0: result_data["error"] = f"Command failed: {stderr or stdout}" - return V1Alpha1JumpstarterInstance(**result_data) + return V1Alpha1JumpstarterInstance(**result_data) # type: ignore[missing-argument] crds = _parse_json_with_prefix(stdout) jumpstarter_crds = [ @@ -187,7 +187,7 @@ async def check_jumpstarter_installation( except RuntimeError as e: result_data["error"] = f"Command failed: {e}" - return V1Alpha1JumpstarterInstance(**result_data) + return V1Alpha1JumpstarterInstance(**result_data) # type: ignore[missing-argument] async def get_cluster_info( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py index f7d9c5ce8..877bed8b1 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/kubectl_test.py @@ -218,9 +218,11 @@ async def test_get_kubectl_contexts_custom_kubectl(self, mock_run_command): @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") async def test_get_kubectl_contexts_propagates_programming_errors(self, mock_run_command): mock_run_command.return_value = (0, '{"contexts": [], "clusters": []}', "") - with patch("jumpstarter_kubernetes.cluster.kubectl.json.loads", side_effect=TypeError("unexpected type")): - with pytest.raises(TypeError, match="unexpected type"): - await get_kubectl_contexts() + with ( + patch("jumpstarter_kubernetes.cluster.kubectl.json.loads", side_effect=TypeError("unexpected type")), + pytest.raises(TypeError, match="unexpected type"), + ): + await get_kubectl_contexts() @pytest.mark.asyncio @patch("jumpstarter_kubernetes.cluster.kubectl.run_command") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py index 670cdaa3f..a297118af 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/minikube.py @@ -5,7 +5,6 @@ import shlex import shutil from pathlib import Path -from typing import List, Optional from ..callbacks import OutputCallback, SilentCallback from ..exceptions import ( @@ -23,7 +22,7 @@ def minikube_installed(minikube: str) -> bool: return shutil.which(minikube) is not None -async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool: # noqa: C901 +async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool: """Check if a Minikube cluster exists. Uses 'minikube profile list' to distinguish between stopped and non-existent clusters. @@ -59,22 +58,17 @@ async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool: # # Check if the error indicates profile doesn't exist combined_output = (stdout + stderr).lower() - if "profile" in combined_output and "not found" in combined_output: - return False - # Non-zero exit but not "not found" means cluster exists but may be stopped - return True + return not ("profile" in combined_output and "not found" in combined_output) except RuntimeError as e: # Check if the error message indicates profile not found error_msg = str(e).lower() - if "profile" in error_msg and "not found" in error_msg: - return False # Other errors may indicate the cluster exists but has issues - return True + return not ("profile" in error_msg and "not found" in error_msg) -async def delete_minikube_cluster(minikube: str, cluster_name: str, callback: OutputCallback = None) -> bool: +async def delete_minikube_cluster(minikube: str, cluster_name: str, callback: OutputCallback | None = None) -> bool: """Delete a Minikube cluster.""" if callback is None: callback = SilentCallback() @@ -100,9 +94,9 @@ async def delete_minikube_cluster(minikube: str, cluster_name: str, callback: Ou async def create_minikube_cluster( # noqa: C901 minikube: str, cluster_name: str, - extra_args: Optional[List[str]] = None, + extra_args: list[str] | None = None, force_recreate: bool = False, - callback: OutputCallback = None, + callback: OutputCallback | None = None, ) -> bool: """Create a Minikube cluster.""" if extra_args is None: @@ -157,7 +151,7 @@ async def create_minikube_cluster( # noqa: C901 ) -async def list_minikube_clusters(minikube: str) -> List[str]: +async def list_minikube_clusters(minikube: str) -> list[str]: """List all Minikube clusters.""" if not minikube_installed(minikube): return [] @@ -178,7 +172,7 @@ async def get_minikube_cluster_ip(minikube: str, cluster_name: str) -> str: return await get_minikube_ip(cluster_name, minikube) -async def prepare_certificates(extra_certs: str, callback: OutputCallback = None) -> None: +async def prepare_certificates(extra_certs: str, callback: OutputCallback | None = None) -> None: """Prepare custom certificates for Minikube.""" if callback is None: callback = SilentCallback() @@ -199,7 +193,7 @@ async def prepare_certificates(extra_certs: str, callback: OutputCallback = None # If ca.crt already exists, append to it if cert_dest.exists(): - with open(extra_certs_path, "r") as src, open(cert_dest, "a") as dst: + with open(extra_certs_path, "r") as src, open(cert_dest, "a") as dst: # noqa: ASYNC230 dst.write("\n") dst.write(src.read()) else: @@ -213,8 +207,8 @@ async def create_minikube_cluster_with_options( cluster_name: str, minikube_extra_args: str, force_recreate_cluster: bool, - extra_certs: Optional[str] = None, - callback: OutputCallback = None, + extra_certs: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Create a Minikube cluster with optional certificate preparation.""" if callback is None: @@ -248,7 +242,7 @@ async def create_minikube_cluster_with_options( async def delete_minikube_cluster_with_feedback( - minikube: str, cluster_name: str, callback: OutputCallback = None + minikube: str, cluster_name: str, callback: OutputCallback | None = None ) -> None: """Delete a Minikube cluster with user feedback.""" if callback is None: diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py index 246aae924..f0057eccd 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operations.py @@ -1,6 +1,5 @@ """High-level cluster operations and orchestration.""" -from typing import Optional from ..callbacks import OutputCallback, SilentCallback from ..exceptions import ( @@ -32,7 +31,7 @@ def validate_cluster_type_selection( - kind: Optional[str], minikube: Optional[str], k3s: Optional[str] = None + kind: str | None, minikube: str | None, k3s: str | None = None ) -> ClusterType: """Validate cluster type selection and return the cluster type.""" selected = sum(1 for x in [kind, minikube, k3s] if x is not None) @@ -52,9 +51,9 @@ def validate_cluster_type_selection( async def delete_cluster_by_name( # noqa: C901 cluster_name: str, - cluster_type: Optional[str] = None, + cluster_type: str | None = None, force: bool = False, - callback: Optional[OutputCallback] = None, + callback: OutputCallback | None = None, ) -> None: """Delete a cluster by name, with auto-detection if type not specified.""" if callback is None: @@ -94,12 +93,11 @@ async def delete_cluster_by_name( # noqa: C901 raise ClusterTypeValidationError(cluster_type, ["kind", "minikube"]) # Confirm deletion unless force is specified - if not force: - if not callback.confirm( - f'This will permanently delete the "{cluster_name}" {cluster_type} cluster and ALL its data. Continue?' - ): - callback.progress("Cluster deletion cancelled.") - return + if not force and not callback.confirm( + f'This will permanently delete the "{cluster_name}" {cluster_type} cluster and ALL its data. Continue?' + ): + callback.progress("Cluster deletion cancelled.") + return # Delete the cluster if cluster_type == "kind": @@ -118,19 +116,19 @@ async def create_cluster_and_install( # noqa: C901 minikube_extra_args: str, kind: str, minikube: str, - extra_certs: Optional[str] = None, + extra_certs: str | None = None, install_jumpstarter: bool = True, namespace: str = "jumpstarter-lab", - version: Optional[str] = None, - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - ip: Optional[str] = None, - basedomain: Optional[str] = None, - grpc_endpoint: Optional[str] = None, - router_endpoint: Optional[str] = None, - callback: Optional[OutputCallback] = None, - k3s_ssh_host: Optional[str] = None, - operator_installer: Optional[str] = None, + version: str | None = None, + kubeconfig: str | None = None, + context: str | None = None, + ip: str | None = None, + basedomain: str | None = None, + grpc_endpoint: str | None = None, + router_endpoint: str | None = None, + callback: OutputCallback | None = None, + k3s_ssh_host: str | None = None, + operator_installer: str | None = None, ) -> None: """Create a cluster and optionally install Jumpstarter.""" if callback is None: @@ -184,7 +182,7 @@ async def create_cluster_and_install( # noqa: C901 ip = extract_host_from_ssh(k3s_ssh_host) # Configure endpoints - actual_ip, actual_basedomain, actual_grpc, actual_router = await configure_endpoints( + _actual_ip, actual_basedomain, actual_grpc, actual_router = await configure_endpoints( cluster_type, minikube, cluster_name, ip, basedomain, grpc_endpoint, router_endpoint ) @@ -219,8 +217,8 @@ async def create_cluster_only( minikube_extra_args: str, kind: str, minikube: str, - custom_certs: Optional[str] = None, - callback: Optional[OutputCallback] = None, + custom_certs: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Create a cluster without installing Jumpstarter.""" await create_cluster_and_install( diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operator.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operator.py index a81003e68..4663a8a60 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operator.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/cluster/operator.py @@ -1,7 +1,8 @@ """Operator-based Jumpstarter installation.""" import asyncio -from typing import Literal, Optional +import asyncio.subprocess +from typing import Literal from ..callbacks import OutputCallback, SilentCallback from ..exceptions import ClusterOperationError @@ -16,7 +17,7 @@ OPERATOR_DEPLOYMENT = "jumpstarter-operator-controller-manager" -def _kubectl_base(kubeconfig: Optional[str] = None, context: Optional[str] = None) -> list[str]: +def _kubectl_base(kubeconfig: str | None = None, context: str | None = None) -> list[str]: """Build base kubectl command with optional kubeconfig and context.""" cmd = ["kubectl"] if kubeconfig: @@ -27,9 +28,9 @@ def _kubectl_base(kubeconfig: Optional[str] = None, context: Optional[str] = Non async def install_cert_manager( - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - callback: OutputCallback = None, + kubeconfig: str | None = None, + context: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Install cert-manager if not already present.""" if callback is None: @@ -72,10 +73,10 @@ async def install_cert_manager( async def install_operator( version: str, - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - operator_installer: Optional[str] = None, - callback: OutputCallback = None, + kubeconfig: str | None = None, + context: str | None = None, + operator_installer: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Apply the operator installer YAML from a GitHub release or local path.""" if callback is None: @@ -133,7 +134,7 @@ def _build_jumpstarter_cr( grpc_endpoint: str, router_endpoint: str, mode: Literal["nodeport", "ingress"], - image: Optional[str] = None, + image: str | None = None, ) -> str: """Build the Jumpstarter CR YAML.""" if mode == "nodeport": @@ -216,10 +217,10 @@ async def apply_jumpstarter_cr( grpc_endpoint: str, router_endpoint: str, mode: Literal["nodeport", "ingress"] = "nodeport", - image: Optional[str] = None, - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - callback: OutputCallback = None, + image: str | None = None, + kubeconfig: str | None = None, + context: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Create and apply the Jumpstarter custom resource.""" if callback is None: @@ -232,7 +233,7 @@ async def apply_jumpstarter_cr( returncode, ns_yaml, _ = await run_command(cmd) if returncode == 0: apply_cmd = _kubectl_base(kubeconfig, context) + ["apply", "-f", "-"] - process = await asyncio.create_subprocess_exec( + process = await asyncio.create_subprocess_exec( # type: ignore[missing-argument] *apply_cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -249,13 +250,13 @@ async def apply_jumpstarter_cr( callback.progress("Applying Jumpstarter CR...") apply_cmd = _kubectl_base(kubeconfig, context) + ["apply", "-f", "-"] - process = await asyncio.create_subprocess_exec( + process = await asyncio.create_subprocess_exec( # type: ignore[missing-argument] *apply_cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - stdout, stderr = await process.communicate(input=cr_yaml.encode()) + _stdout, stderr = await process.communicate(input=cr_yaml.encode()) if process.returncode != 0: raise ClusterOperationError( @@ -268,9 +269,9 @@ async def apply_jumpstarter_cr( async def wait_for_jumpstarter_ready( namespace: str = "jumpstarter-lab", - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - callback: OutputCallback = None, + kubeconfig: str | None = None, + context: str | None = None, + callback: OutputCallback | None = None, timeout: int = 300, ) -> None: """Wait for Jumpstarter controller and router deployments to become ready.""" @@ -326,11 +327,11 @@ async def install_jumpstarter_operator( grpc_endpoint: str, router_endpoint: str, mode: Literal["nodeport", "ingress"] = "nodeport", - image: Optional[str] = None, - kubeconfig: Optional[str] = None, - context: Optional[str] = None, - operator_installer: Optional[str] = None, - callback: OutputCallback = None, + image: str | None = None, + kubeconfig: str | None = None, + context: str | None = None, + operator_installer: str | None = None, + callback: OutputCallback | None = None, ) -> None: """Install Jumpstarter using the operator method. diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py index bf7cf5e1b..11e0bd846 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/clusters.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal from pydantic import Field @@ -12,14 +12,14 @@ class V1Alpha1JumpstarterInstance(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["JumpstarterInstance"] = Field(default="JumpstarterInstance") installed: bool - version: Optional[str] = None - namespace: Optional[str] = None - status: Optional[str] = None + version: str | None = None + namespace: str | None = None + status: str | None = None has_crds: bool = Field(alias="hasCrds", default=False) - error: Optional[str] = None - basedomain: Optional[str] = None - controller_endpoint: Optional[str] = Field(alias="controllerEndpoint", default=None) - router_endpoint: Optional[str] = Field(alias="routerEndpoint", default=None) + error: str | None = None + basedomain: str | None = None + controller_endpoint: str | None = Field(alias="controllerEndpoint", default=None) + router_endpoint: str | None = Field(alias="routerEndpoint", default=None) class V1Alpha1ClusterInfo(JsonBaseModel): @@ -35,9 +35,9 @@ class V1Alpha1ClusterInfo(JsonBaseModel): is_current: bool = Field(alias="isCurrent") type: Literal["kind", "minikube", "remote"] accessible: bool - version: Optional[str] = None + version: str | None = None jumpstarter: V1Alpha1JumpstarterInstance - error: Optional[str] = None + error: str | None = None @classmethod def rich_add_columns(cls, table, **kwargs): diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py index 1cb99fa27..1af24ca8d 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/controller.py @@ -1,4 +1,3 @@ -from typing import Optional import aiohttp import semver @@ -7,7 +6,7 @@ from .exceptions import JumpstarterKubernetesError -async def get_latest_compatible_controller_version(client_version: Optional[str]): # noqa: C901 +async def get_latest_compatible_controller_version(client_version: str | None): # noqa: C901 """Get the latest compatible controller version for a given client version""" if client_version is None: # Return the latest available version when no client version is specified @@ -16,7 +15,7 @@ async def get_latest_compatible_controller_version(client_version: Optional[str] else: use_fallback_only = False # Strip leading "v" for parsing but keep original for error messages - version_to_parse = client_version[1:] if client_version.startswith("v") else client_version + version_to_parse = client_version.removeprefix("v") try: client_version_parsed = Version(version_to_parse) except Exception as e: @@ -48,7 +47,7 @@ async def get_latest_compatible_controller_version(client_version: Optional[str] tag_name = tag["name"] # Strip leading "v" for parsing but keep original tag name - version_str = tag_name[1:] if tag_name.startswith("v") else tag_name + version_str = tag_name.removeprefix("v") try: version = semver.VersionInfo.parse(version_str) @@ -58,16 +57,20 @@ async def get_latest_compatible_controller_version(client_version: Optional[str] if use_fallback_only: # When no client version specified, all versions are candidates fallback.add((version, tag_name)) - elif version.major == client_version_parsed.major and version.minor == client_version_parsed.minor: + elif ( + client_version_parsed is not None + and version.major == client_version_parsed.major + and version.minor == client_version_parsed.minor + ): compatible.add((version, tag_name)) else: fallback.add((version, tag_name)) if compatible: # max() on tuples compares by first element (version), then second (tag_name) - selected_version, selected_tag = max(compatible) + _selected_version, selected_tag = max(compatible) elif fallback: - selected_version, selected_tag = max(fallback) + _selected_version, selected_tag = max(fallback) else: raise JumpstarterKubernetesError("No valid controller versions found in the repository") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py index 880833732..d1b7b78d7 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime.py @@ -1,10 +1,10 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime def time_since(t_str: str): # Format the elapsed time in a readable way - t = datetime.strptime(t_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) - now = datetime.now(timezone.utc) + t = datetime.strptime(t_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + now = datetime.now(UTC) elapsed = now - t # Format the elapsed time in a readable way diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py index 727fdc4de..a34744255 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/datetime_test.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import patch from .datetime import time_since @@ -6,7 +6,7 @@ def test_time_since_seconds(): """Test time_since for elapsed time < 1 minute""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(seconds=30) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -19,7 +19,7 @@ def test_time_since_seconds(): def test_time_since_minutes_with_seconds(): """Test time_since for elapsed time in minutes with seconds""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(minutes=5, seconds=30) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -32,7 +32,7 @@ def test_time_since_minutes_with_seconds(): def test_time_since_minutes_without_seconds(): """Test time_since for elapsed time in exact minutes""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(minutes=10) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -45,7 +45,7 @@ def test_time_since_minutes_without_seconds(): def test_time_since_hours_with_minutes_under_2h(): """Test time_since for elapsed time in hours with minutes (under 2 hours)""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(hours=1, minutes=30) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -58,7 +58,7 @@ def test_time_since_hours_with_minutes_under_2h(): def test_time_since_hours_without_minutes(): """Test time_since for elapsed time in hours >= 2""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(hours=3, minutes=15) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -71,7 +71,7 @@ def test_time_since_hours_without_minutes(): def test_time_since_days_with_hours(): """Test time_since for elapsed time in days with hours""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=5, hours=6) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -84,7 +84,7 @@ def test_time_since_days_with_hours(): def test_time_since_days_without_hours(): """Test time_since for elapsed time in exact days""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=10) t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -97,7 +97,7 @@ def test_time_since_days_without_hours(): def test_time_since_months_with_days(): """Test time_since for elapsed time in months with days""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=65) # ~2 months + 5 days t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -110,7 +110,7 @@ def test_time_since_months_with_days(): def test_time_since_months_without_days(): """Test time_since for elapsed time in exact months""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=90) # Exactly 3 months t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -123,7 +123,7 @@ def test_time_since_months_without_days(): def test_time_since_years_with_months(): """Test time_since for elapsed time in years with months""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=425) # ~1 year + 2 months t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -136,7 +136,7 @@ def test_time_since_years_with_months(): def test_time_since_years_without_months(): """Test time_since for elapsed time in exact years""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) past = now - timedelta(days=730) # Exactly 2 years t_str = past.strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py index af7d1c3f6..812b681e6 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exceptions.py @@ -8,7 +8,6 @@ class JumpstarterKubernetesError(Exception): """Base exception for all jumpstarter-kubernetes errors.""" - pass class CredentialNotReadyError(JumpstarterKubernetesError): @@ -43,7 +42,7 @@ def __init__(self, tool_name: str, additional_info: str = ""): class ClusterNotFoundError(JumpstarterKubernetesError): """Raised when a cluster cannot be found.""" - def __init__(self, cluster_name: str, cluster_type: str = None): + def __init__(self, cluster_name: str, cluster_type: str | None = None): self.cluster_name = cluster_name self.cluster_type = cluster_type if cluster_type: @@ -66,7 +65,7 @@ def __init__(self, cluster_name: str, cluster_type: str): class ClusterOperationError(JumpstarterKubernetesError): """Raised when a cluster operation (create, delete, etc.) fails.""" - def __init__(self, operation: str, cluster_name: str, cluster_type: str, cause: Exception = None): + def __init__(self, operation: str, cluster_name: str, cluster_type: str, cause: Exception | None = None): self.operation = operation self.cluster_name = cluster_name self.cluster_type = cluster_type @@ -81,7 +80,7 @@ def __init__(self, operation: str, cluster_name: str, cluster_type: str, cause: class CertificateError(JumpstarterKubernetesError): """Raised when certificate operations fail.""" - def __init__(self, message: str, certificate_path: str = None): + def __init__(self, message: str, certificate_path: str | None = None): self.certificate_path = certificate_path super().__init__(message) @@ -89,7 +88,7 @@ def __init__(self, message: str, certificate_path: str = None): class KubeconfigError(JumpstarterKubernetesError): """Raised when kubectl configuration operations fail.""" - def __init__(self, message: str, config_path: str = None): + def __init__(self, message: str, config_path: str | None = None): self.config_path = config_path super().__init__(message) @@ -97,7 +96,7 @@ def __init__(self, message: str, config_path: str = None): class ClusterTypeValidationError(JumpstarterKubernetesError): """Raised when cluster type validation fails.""" - def __init__(self, cluster_type: str, supported_types: list = None): + def __init__(self, cluster_type: str, supported_types: list | None = None): self.cluster_type = cluster_type self.supported_types = supported_types or ["kind", "minikube"] message = f'Unsupported cluster type "{cluster_type}". Supported types: {", ".join(self.supported_types)}' @@ -115,6 +114,6 @@ def __init__(self, cluster_name: str, reason: str = "Cluster name cannot be empt class EndpointConfigurationError(JumpstarterKubernetesError): """Raised when endpoint configuration fails.""" - def __init__(self, message: str, cluster_type: str = None): + def __init__(self, message: str, cluster_type: str | None = None): self.cluster_type = cluster_type super().__init__(message) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py index 3d59d749d..b27c225bc 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters.py @@ -1,6 +1,6 @@ import asyncio import base64 -from typing import Literal, Optional +from typing import Literal from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference from pydantic import Field @@ -27,8 +27,8 @@ class V1Alpha1ExporterDevice(JsonBaseModel): class V1Alpha1ExporterStatus(JsonBaseModel): # The controller fills these in after it reconciles the exporter, so a # freshly created one has a status with nothing in it yet. - credential: Optional[SerializeV1ObjectReference] = None - devices: list[V1Alpha1ExporterDevice] = [] + credential: SerializeV1ObjectReference | None = None + devices: list[V1Alpha1ExporterDevice] = Field(default_factory=list) endpoint: str = "" exporter_status: str | None = Field(alias="exporterStatus", default=None) status_message: str | None = Field(alias="statusMessage", default=None) @@ -38,7 +38,7 @@ class V1Alpha1Exporter(JsonBaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") kind: Literal["Exporter"] = Field(default="Exporter") metadata: SerializeV1ObjectMeta - status: Optional[V1Alpha1ExporterStatus] = None + status: V1Alpha1ExporterStatus | None = None @staticmethod def from_dict(dict: dict): @@ -61,7 +61,7 @@ def from_dict(dict: dict): if "credential" in dict["status"] else None, endpoint=dict["status"].get("endpoint", ""), - devices=[V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in dict["status"]["devices"]] + devices=[V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in dict["status"]["devices"]] # type: ignore[call-arg] if "devices" in dict["status"] else [], exporter_status=dict["status"].get("exporterStatus"), @@ -108,7 +108,7 @@ def rich_add_rows(self, table, devices: bool = False): labels = [] if d.labels is not None: for label in d.labels: - labels.append(f"{label}:{str(d.labels[label])}") + labels.append(f"{label}:{d.labels[label]!s}") table.add_row( self.metadata.name, status or "Unknown", @@ -196,12 +196,11 @@ async def create_exporter( namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1", name=name ) # check if the client status is updated with the credentials - if "status" in updated_exporter: - if "credential" in updated_exporter["status"]: - return V1Alpha1Exporter.from_dict(updated_exporter) + if "status" in updated_exporter and "credential" in updated_exporter["status"]: + return V1Alpha1Exporter.from_dict(updated_exporter) count += 1 await asyncio.sleep(CREATE_EXPORTER_DELAY) - raise Exception("Timeout waiting for exporter credentials") + raise Exception("Timeout waiting for exporter credentials") # noqa: TRY002 async def get_exporter_config(self, name: str) -> ExporterConfigV1Alpha1: """Get an exporter config for a specified exporter name""" diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py index 3df1d03dc..1879dec5e 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/exporters_test.py @@ -26,7 +26,7 @@ ), status=V1Alpha1ExporterStatus( credential=V1ObjectReference(name="test-credential"), - devices=[V1Alpha1ExporterDevice(labels={"test": "label"}, uuid="f4cf49ab-fc64-46c6-94e7-a40502eb77b1")], + devices=[V1Alpha1ExporterDevice(labels={"test": "label"}, uuid="f4cf49ab-fc64-46c6-94e7-a40502eb77b1")], # type: ignore[call-arg] endpoint="https://test-exporter", ), ) @@ -468,7 +468,7 @@ def test_exporter_rich_add_rows_devices_when_it_has_none(): api_version="jumpstarter.dev/v1alpha1", kind="Exporter", metadata=V1ObjectMeta(name="never-run", namespace="default", creation_timestamp="2021-10-01T00:00:00Z"), - status=V1Alpha1ExporterStatus(endpoint="https://e", devices=[]), + status=V1Alpha1ExporterStatus(endpoint="https://e", devices=[]), # type: ignore[call-arg] ) mock_table = MagicMock() exporter.rich_add_rows(mock_table, devices=True) diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py index c73012733..10a475240 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/leases.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Literal, Optional +from typing import Literal from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference from pydantic import Field @@ -12,11 +12,11 @@ class V1Alpha1LeaseStatus(JsonBaseModel): - begin_time: Optional[str] = Field(alias="beginTime") + begin_time: str | None = Field(alias="beginTime") conditions: list[SerializeV1Condition] - end_time: Optional[str] = Field(alias="endTime") + end_time: str | None = Field(alias="endTime") ended: bool - exporter: Optional[SerializeV1ObjectReference] + exporter: SerializeV1ObjectReference | None class V1Alpha1LeaseSelector(JsonBaseModel): @@ -25,7 +25,7 @@ class V1Alpha1LeaseSelector(JsonBaseModel): class V1Alpha1LeaseSpec(JsonBaseModel): client: SerializeV1ObjectReference - duration: Optional[str] + duration: str | None selector: V1Alpha1LeaseSelector @@ -40,7 +40,7 @@ class V1Alpha1Lease(JsonBaseModel): def from_dict(data: dict): spec = data["spec"] if not isinstance(spec, Mapping): - raise TypeError(f"spec must be a dict, got {type(spec).__name__}: {repr(spec)}") + raise TypeError(f"spec must be a dict, got {type(spec).__name__}: {spec!r}") selector_data = spec.get("selector", {}) return V1Alpha1Lease( api_version=data["apiVersion"], @@ -55,8 +55,8 @@ def from_dict(data: dict): uid=data["metadata"]["uid"], ), status=V1Alpha1LeaseStatus( - begin_time=data["status"]["beginTime"] if "beginTime" in data["status"] else None, - end_time=data["status"]["endTime"] if "endTime" in data["status"] else None, + begin_time=data["status"].get("beginTime", None), + end_time=data["status"].get("endTime", None), ended=data["status"]["ended"], exporter=V1ObjectReference(name=data["status"]["exporterRef"]["name"]) if "exporterRef" in data["status"] @@ -75,7 +75,7 @@ def from_dict(data: dict): ), spec=V1Alpha1LeaseSpec( client=V1ObjectReference(name=spec["clientRef"]["name"]) if "clientRef" in spec else None, - duration=spec["duration"] if "duration" in spec else None, + duration=spec.get("duration", None), selector=V1Alpha1LeaseSelector(match_labels=selector_data.get("matchLabels", {})), ), ) @@ -113,7 +113,7 @@ def get_reason(self): def rich_add_rows(self, table): selectors = [] for label in self.spec.selector.match_labels: - selectors.append(f"{label}:{str(self.spec.selector.match_labels[label])}") + selectors.append(f"{label}:{self.spec.selector.match_labels[label]!s}") table.add_row( self.metadata.name, self.spec.client.name if self.spec.client is not None else "", diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py index 3c8dc41d1..4477c8780 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/list.py @@ -1,4 +1,4 @@ -from typing import Generic, Literal, TypeVar +from typing import Literal, TypeVar from pydantic import Field @@ -7,7 +7,7 @@ T = TypeVar("T") -class V1Alpha1List(JsonBaseModel, Generic[T]): +class V1Alpha1List[T](JsonBaseModel): """A generic list result type.""" api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py index 4233a497c..f5f0ce81a 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/rotate_test.py @@ -108,7 +108,7 @@ async def test_rotate_client_token_non_404_raises(): with pytest.raises(ApiException) as exc_info: await api.rotate_client_token("test-client") - assert exc_info.value.status == 403 + assert exc_info.value.status == 403 # type: ignore[attr-defined] @pytest.mark.asyncio diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py index 35173600d..125c4c141 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/serialize.py @@ -1,10 +1,10 @@ -from typing import Annotated, Any, Dict +from typing import Annotated, Any from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference from pydantic import WrapSerializer -def k8s_obj_to_dict(value: Any, handler, info) -> Dict[str, Any]: +def k8s_obj_to_dict(value: Any, handler, info) -> dict[str, Any]: result = value.to_dict(serialize=True) return {k: v for k, v in result.items() if v is not None} diff --git a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/util/async_custom_object_api.py b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/util/async_custom_object_api.py index 235d11433..093ed2040 100644 --- a/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/util/async_custom_object_api.py +++ b/python/packages/jumpstarter-kubernetes/jumpstarter_kubernetes/util/async_custom_object_api.py @@ -1,7 +1,7 @@ import base64 import logging from contextlib import AbstractAsyncContextManager -from typing import Optional, Self +from typing import Self from kubernetes_asyncio import config from kubernetes_asyncio.client.api import CoreV1Api, CustomObjectsApi @@ -18,13 +18,13 @@ class AbstractAsyncCustomObjectApi(AbstractAsyncContextManager): """An abstract async custom object API client""" _client: ApiClient - config_file: Optional[str] - context: Optional[str] + config_file: str | None + context: str | None namespace: str api: CustomObjectsApi core_api: CoreV1Api - def __init__(self, namespace: str, config_file: Optional[str] = None, context: Optional[str] = None): + def __init__(self, namespace: str, config_file: str | None = None, context: str | None = None): self.config_file = config_file self.context = context self.namespace = namespace @@ -48,7 +48,6 @@ async def __aexit__(self, exc_type, exc_value, traceback): self._client = None self.api = None self.core_api = None - return None async def get_ca_bundle(self) -> str: """Get the CA certificate bundle from the jumpstarter-service-ca-cert ConfigMap. diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/connections.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/connections.py index 075e454d4..e8fa778e7 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/connections.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/connections.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from contextlib import ExitStack, asynccontextmanager from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import anyio import anyio.abc @@ -34,7 +34,7 @@ class Connection: @property def uptime_seconds(self) -> float: - return (datetime.now() - self.created_at).total_seconds() + return (datetime.now(tz=UTC) - self.created_at).total_seconds() def _unwrap_exception(exc: BaseException) -> BaseException: @@ -92,7 +92,7 @@ async def _send_log(self, level: str, message: str) -> None: if self._log_callback is not None: try: await self._log_callback(level, message) - except Exception: + except Exception: # noqa: BLE001 logger.debug("Failed to send MCP log notification: %s", message) @property @@ -210,7 +210,7 @@ async def _run_connection(task_status=anyio.TASK_STATUS_IGNORED): try: conn = await self._task_group.start(_run_connection) - except BaseException as exc: + except BaseException as exc: # noqa: BLE001 self._cleanup_events.pop(connection_id, None) unwrapped = _unwrap_exception(exc) if isinstance(unwrapped, ConnectionError): @@ -242,42 +242,41 @@ def _on_lease_ending(lease_obj, remaining): lease.lease_ending_callback = _on_lease_ending - async with lease.serve_unix_async() as path: - async with lease.monitor_async(): - with ExitStack() as stack: - self._stacks[connection_id] = stack - async with client_from_path( - path, portal, stack, - allow=lease.allow, unsafe=lease.unsafe, - ) as client: - conn = Connection( - id=connection_id, - lease_name=lease.name, - exporter_name=lease.exporter_name, - socket_path=str(path), - allow=lease.allow, - unsafe=lease.unsafe, - created_at=datetime.now(), - client=client, + async with lease.serve_unix_async() as path, lease.monitor_async(): + with ExitStack() as stack: + self._stacks[connection_id] = stack + async with client_from_path( + path, portal, stack, + allow=lease.allow, unsafe=lease.unsafe, + ) as client: + conn = Connection( + id=connection_id, + lease_name=lease.name, + exporter_name=lease.exporter_name, + socket_path=str(path), + allow=lease.allow, + unsafe=lease.unsafe, + created_at=datetime.now(tz=UTC), + client=client, + ) + self._connections[connection_id] = conn + logger.info( + "Connected %s to exporter %s (socket=%s)", + connection_id, lease.exporter_name, path, + ) + + async with anyio.create_task_group() as notify_tg: + notify_tg.start_soon( + self._forward_lease_notifications, notify_recv, connection_id, event, ) - self._connections[connection_id] = conn - logger.info( - "Connected %s to exporter %s (socket=%s)", - connection_id, lease.exporter_name, path, + notify_tg.start_soon( + self._watch_lease_transfer, lease, conn, connection_id, event, ) + task_status.started(conn) + await event.wait() + notify_tg.cancel_scope.cancel() - async with anyio.create_task_group() as notify_tg: - notify_tg.start_soon( - self._forward_lease_notifications, notify_recv, connection_id, event, - ) - notify_tg.start_soon( - self._watch_lease_transfer, lease, conn, connection_id, event, - ) - task_status.started(conn) - await event.wait() - notify_tg.cancel_scope.cancel() - - await notify_send.aclose() + await notify_send.aclose() return conn async def disconnect(self, connection_id: str) -> None: diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/server.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/server.py index 95d6d4f2d..63a781160 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/server.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import os @@ -139,7 +140,7 @@ async def _ensure_fresh_token(config: ClientConfigV1Alpha1) -> ClientConfigV1Alp try: issuer = decode_jwt_issuer(token) - except Exception: + except Exception: # noqa: BLE001 logger.warning("Failed to decode JWT issuer, skipping token refresh") return config @@ -256,10 +257,8 @@ def _capture_session_for_notifications(mcp: FastMCP, manager: ConnectionManager) session = ctx.request_context.session async def _log(level: str, message: str) -> None: - try: + with contextlib.suppress(Exception): await session.send_log_message(level=level, data=message, logger="jumpstarter") - except Exception: - pass manager.set_log_callback(_log) except (LookupError, AttributeError): @@ -502,9 +501,9 @@ async def run_server(): except asyncio.CancelledError: logger.info("MCP stdio session ended (cancelled)") except BaseException as exc: - if isinstance(exc, ClosedResourceError): - logger.info("MCP client disconnected (stdio closed)") - elif isinstance(exc, BaseExceptionGroup) and _is_closed_resource_error(exc): + if isinstance(exc, ClosedResourceError) or ( + isinstance(exc, BaseExceptionGroup) and _is_closed_resource_error(exc) + ): logger.info("MCP client disconnected (stdio closed)") else: logger.exception("MCP server crashed") diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/server_test.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/server_test.py index cc2fce59e..256fe14d0 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/server_test.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/server_test.py @@ -7,7 +7,8 @@ import logging import time from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime +from typing import ClassVar from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -27,7 +28,7 @@ class FakePowerClient: - children: dict = {} + children: ClassVar[dict]= {} def on(self) -> None: """Power on the device.""" @@ -49,7 +50,7 @@ def check_exporter_status(self): class FakeSerialClient: - children: dict = {} + children: ClassVar[dict]= {} def open(self): """Open serial port.""" @@ -88,7 +89,7 @@ def _make_connection( socket_path=socket_path, allow=[], unsafe=True, - created_at=datetime.now(), + created_at=datetime.now(tz=UTC), client=client or FakeCompositeClient(), ) diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/commands_test.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/commands_test.py index 361c6f57c..3d426f4f7 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/commands_test.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/commands_test.py @@ -1,6 +1,7 @@ """Introspection must drive synchronous driver facades outside the event loop.""" from types import SimpleNamespace +from typing import ClassVar from unittest.mock import MagicMock import click @@ -12,7 +13,7 @@ class LeafClient: - children: dict = {} + children: ClassVar[dict]= {} def on(self): """Turn power on.""" diff --git a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/connections.py b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/connections.py index 85d889b7e..0523fe4cf 100644 --- a/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/connections.py +++ b/python/packages/jumpstarter-mcp/jumpstarter_mcp/tools/connections.py @@ -2,11 +2,10 @@ from __future__ import annotations +import contextlib import logging from datetime import timedelta -import anyio.to_thread # noqa: F401 - from jumpstarter_mcp.connections import ConnectionManager from jumpstarter.client.introspect import list_drivers, walk_click_tree @@ -42,19 +41,15 @@ async def connect( # Auto-explore: get CLI tree and driver list cli_tree = None drivers = None - try: + with contextlib.suppress(Exception): client = conn.client if hasattr(client, "cli"): - import anyio - cli_cmd = await anyio.to_thread.run_sync(client.cli) + from anyio import to_thread + cli_cmd = await to_thread.run_sync(client.cli) cli_tree = walk_click_tree(cli_cmd) - except Exception: - pass - try: + with contextlib.suppress(Exception): drivers = list_drivers(conn.client) - except Exception: - pass return { "connection_id": conn.id, diff --git a/python/packages/jumpstarter-protocol/jumpstarter_protocol/__init__.py b/python/packages/jumpstarter-protocol/jumpstarter_protocol/__init__.py index b53027ebb..1637aa29f 100644 --- a/python/packages/jumpstarter-protocol/jumpstarter_protocol/__init__.py +++ b/python/packages/jumpstarter-protocol/jumpstarter_protocol/__init__.py @@ -2,7 +2,6 @@ client_pb2, client_pb2_grpc, ) - from .jumpstarter.v1 import ( jumpstarter_pb2, jumpstarter_pb2_grpc, diff --git a/python/packages/jumpstarter-protocol/pyproject.toml b/python/packages/jumpstarter-protocol/pyproject.toml index 95679a24d..6a0d518be 100644 --- a/python/packages/jumpstarter-protocol/pyproject.toml +++ b/python/packages/jumpstarter-protocol/pyproject.toml @@ -37,3 +37,11 @@ build-backend = "hatchling.build" [tool.hatch.build.hooks.pin_jumpstarter] name = "pin_jumpstarter" + +[tool.ty.src] +exclude = [ + "jumpstarter_protocol/jumpstarter/**/*_pb2.py", + "jumpstarter_protocol/jumpstarter/**/*_pb2_grpc.py", + "jumpstarter_protocol/jumpstarter/**/*_pb2.pyi", + "jumpstarter_protocol/jumpstarter/**/*_pb2_grpc.pyi", +] diff --git a/python/packages/jumpstarter-testing/jumpstarter_testing/pytest.py b/python/packages/jumpstarter-testing/jumpstarter_testing/pytest.py index 660496120..5f0e963cd 100644 --- a/python/packages/jumpstarter-testing/jumpstarter_testing/pytest.py +++ b/python/packages/jumpstarter-testing/jumpstarter_testing/pytest.py @@ -58,8 +58,7 @@ def client(self): except RuntimeError: selector = getattr(self, "selector", None) config = ClientConfigV1Alpha1.load("default") - with config.lease(selector=selector) as lease: - with lease.connect() as client: - yield client + with config.lease(selector=selector) as lease, lease.connect() as client: + yield client # BUG workaround: make sure that grpc servers get the client/lease release properly time.sleep(1) diff --git a/python/packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py b/python/packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py index 073bdb89c..67515b83d 100644 --- a/python/packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py +++ b/python/packages/jumpstarter-testing/jumpstarter_testing/pytest_test.py @@ -17,11 +17,10 @@ def test_simple(self, client): """ ) - with Session(root_device=MockPower()) as session: - with session.serve_unix() as path: - # For local testing, set status to LEASE_READY since there's no lease/hook flow - session.update_status(ExporterStatus.LEASE_READY) - monkeypatch.setenv(JUMPSTARTER_HOST, str(path)) - monkeypatch.setenv(JMP_DRIVERS_ALLOW, "UNSAFE") - result = pytester.runpytest() - result.assert_outcomes(passed=1) + with Session(root_device=MockPower()) as session, session.serve_unix() as path: + # For local testing, set status to LEASE_READY since there's no lease/hook flow + session.update_status(ExporterStatus.LEASE_READY) + monkeypatch.setenv(JUMPSTARTER_HOST, str(path)) + monkeypatch.setenv(JMP_DRIVERS_ALLOW, "UNSAFE") + result = pytester.runpytest() + result.assert_outcomes(passed=1) diff --git a/python/packages/jumpstarter/conftest.py b/python/packages/jumpstarter/conftest.py index 18e262443..2b65a2c23 100644 --- a/python/packages/jumpstarter/conftest.py +++ b/python/packages/jumpstarter/conftest.py @@ -1,7 +1,6 @@ from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from ipaddress import IPv4Address -from typing import Tuple from uuid import uuid4 import grpc @@ -44,11 +43,11 @@ async def Stream(self, _request_iterator, context): @dataclass(kw_only=True) class MockController(jumpstarter_pb2_grpc.ControllerServiceServicer): router_endpoint: str - status: Tuple[ + status: tuple[ MemoryObjectSendStream[jumpstarter_pb2.StatusResponse], MemoryObjectReceiveStream[jumpstarter_pb2.StatusResponse], ] = field(init=False, default_factory=lambda: create_memory_object_stream[jumpstarter_pb2.StatusResponse](32)) - queue: Tuple[MemoryObjectSendStream[str], MemoryObjectReceiveStream[str]] = field( + queue: tuple[MemoryObjectSendStream[str], MemoryObjectReceiveStream[str]] = field( init=False, default_factory=lambda: create_memory_object_stream[str](32) ) leases: dict[str, int | str] = field(init=False, default_factory=dict) @@ -86,8 +85,8 @@ def anyio_backend(): .issuer_name(x509.Name([])) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.now()) - .not_valid_after(datetime.now() + timedelta(days=365)) + .not_valid_before(datetime.now(tz=UTC)) + .not_valid_after(datetime.now(tz=UTC) + timedelta(days=365)) .add_extension(x509.SubjectAlternativeName([x509.IPAddress(IPv4Address("127.0.0.1"))]), critical=False) .sign(private_key=key, algorithm=hashes.SHA256(), backend=default_backend()) ) diff --git a/python/packages/jumpstarter/jumpstarter/client/__init__.py b/python/packages/jumpstarter/jumpstarter/client/__init__.py index d17213749..ccb60a204 100644 --- a/python/packages/jumpstarter/jumpstarter/client/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/client/__init__.py @@ -17,15 +17,15 @@ from .lease import DirectLease, Lease __all__ = [ - "DriverClient", "DirectLease", + "DriverClient", "FlashStatus", "FlasherClient", "FlasherClientInterface", + "Lease", "StreamingFlasherClient", "StreamingFlasherClientInterface", "client_from_path", - "Lease", "describe_client", "describe_devices", "describe_devices_async", diff --git a/python/packages/jumpstarter/jumpstarter/client/base.py b/python/packages/jumpstarter/jumpstarter/client/base.py index 97d679fca..b5e9502f9 100644 --- a/python/packages/jumpstarter/jumpstarter/client/base.py +++ b/python/packages/jumpstarter/jumpstarter/client/base.py @@ -134,7 +134,7 @@ def streamingcall(self, method, *args): """Invoke streaming driver call - raises ImportError since driver is not installed.""" self._raise_missing_error() # Unreachable yield to make this a generator function for type checking - while False: # noqa: SIM114 + while False: yield @contextmanager diff --git a/python/packages/jumpstarter/jumpstarter/client/base_test.py b/python/packages/jumpstarter/jumpstarter/client/base_test.py index 98f144117..4b6812e60 100644 --- a/python/packages/jumpstarter/jumpstarter/client/base_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/base_test.py @@ -35,17 +35,16 @@ def create_stub_client(class_path: str) -> StubDriverClient: def test_missing_driver_logs_warning_and_creates_stub(caplog): """Test that a missing driver logs a warning and creates a StubDriverClient.""" expected_class_path = "nonexistent_driver_package.client.NonExistentClient" - with caplog.at_level(logging.WARNING): - with serve(MissingClientDriver()) as client: - # Should have logged a warning with the exact class path from MissingDriverError - assert f"Driver client '{expected_class_path}' is not available." in caplog.text + with caplog.at_level(logging.WARNING), serve(MissingClientDriver()) as client: + # Should have logged a warning with the exact class path from MissingDriverError + assert f"Driver client '{expected_class_path}' is not available." in caplog.text - # Should have created a StubDriverClient - assert isinstance(client, StubDriverClient) + # Should have created a StubDriverClient + assert isinstance(client, StubDriverClient) - # Using the stub should raise an error - with pytest.raises(ImportError): - client.call("some_method") + # Using the stub should raise an error + with pytest.raises(ImportError): + client.call("some_method") def test_stub_driver_client_streamingcall_raises(): @@ -60,18 +59,16 @@ def test_stub_driver_client_streamingcall_raises(): def test_stub_driver_client_stream_raises(): """Test that stream() raises ImportError with driver info.""" stub = create_stub_client("missing_driver.client.Client") - with pytest.raises(ImportError) as exc_info: - with stub.stream(): - pass + with pytest.raises(ImportError) as exc_info, stub.stream(): + pass assert "missing_driver" in str(exc_info.value) def test_stub_driver_client_log_stream_raises(): """Test that log_stream() raises ImportError with driver info.""" stub = create_stub_client("missing_driver.client.Client") - with pytest.raises(ImportError) as exc_info: - with stub.log_stream(): - pass + with pytest.raises(ImportError) as exc_info, stub.log_stream(): + pass assert "missing_driver" in str(exc_info.value) diff --git a/python/packages/jumpstarter/jumpstarter/client/client.py b/python/packages/jumpstarter/jumpstarter/client/client.py index 9a0539a1d..4bde150ce 100644 --- a/python/packages/jumpstarter/jumpstarter/client/client.py +++ b/python/packages/jumpstarter/jumpstarter/client/client.py @@ -109,7 +109,7 @@ async def client_from_channel( reports = {} clients = OrderedDict() - stub = MultipathExporterStub([channel]) + stub = MultipathExporterStub([channel]) # type: ignore[arg-type] response = await stub.GetReport(empty_pb2.Empty()) diff --git a/python/packages/jumpstarter/jumpstarter/client/client_test.py b/python/packages/jumpstarter/jumpstarter/client/client_test.py index d6a4ccbb2..c7df7d833 100644 --- a/python/packages/jumpstarter/jumpstarter/client/client_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/client_test.py @@ -313,12 +313,11 @@ async def test_unavailable_raises_exporter_unreachable(self): with patch( "jumpstarter.client.client.client_from_channel", side_effect=MockAioRpcError(grpc.StatusCode.UNAVAILABLE, "connection refused"), - ): - with pytest.raises(ExporterUnreachableError, match="did not respond"): - async with client_from_path( - "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True - ): - pass + ), pytest.raises(ExporterUnreachableError, match="did not respond"): + async with client_from_path( + "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True + ): + pass async def test_deadline_exceeded_raises_exporter_unreachable(self): """GetReport failing with DEADLINE_EXCEEDED is converted to ExporterUnreachableError.""" @@ -328,12 +327,11 @@ async def test_deadline_exceeded_raises_exporter_unreachable(self): with patch( "jumpstarter.client.client.client_from_channel", side_effect=MockAioRpcError(grpc.StatusCode.DEADLINE_EXCEEDED, "timed out"), - ): - with pytest.raises(ExporterUnreachableError, match="did not respond"): - async with client_from_path( - "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True - ): - pass + ), pytest.raises(ExporterUnreachableError, match="did not respond"): + async with client_from_path( + "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True + ): + pass async def test_other_grpc_errors_propagate_unchanged(self): """Non-connection gRPC errors are not converted to ExporterUnreachableError.""" @@ -343,10 +341,9 @@ async def test_other_grpc_errors_propagate_unchanged(self): with patch( "jumpstarter.client.client.client_from_channel", side_effect=MockAioRpcError(grpc.StatusCode.INTERNAL, "internal error"), - ): - with pytest.raises(MockAioRpcError): - async with client_from_path( - "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True - ): - pass + ), pytest.raises(MockAioRpcError): + async with client_from_path( + "/tmp/test.sock", mock_portal, mock_stack, allow=[], unsafe=True + ): + pass diff --git a/python/packages/jumpstarter/jumpstarter/client/core.py b/python/packages/jumpstarter/jumpstarter/client/core.py index 065a56cb3..0a53f0c7f 100644 --- a/python/packages/jumpstarter/jumpstarter/client/core.py +++ b/python/packages/jumpstarter/jumpstarter/client/core.py @@ -94,7 +94,7 @@ def _format_rpc_error(self, method: str, error: AioRpcError) -> str: message = f"DriverCall '{method}' failed with gRPC {error.code().name}: {details}" try: debug = error.debug_error_string() - except Exception: + except Exception: # noqa: BLE001 debug = "" if debug: self.logger.debug("gRPC debug for %s: %s", method, debug) @@ -167,7 +167,7 @@ async def wait_for_lease_ready(self, timeout: float = 300.0) -> None: try: status = await self.get_status_async() self.logger.debug("[POLL %d] GetStatus returned: %s", poll_count, status) - except Exception as e: + except Exception as e: # noqa: BLE001 # Connection error - keep trying self.logger.debug("[POLL %d] Error getting status, will retry: %s", poll_count, e) await anyio.sleep(poll_interval) @@ -238,7 +238,7 @@ async def end_session_async(self) -> bool: return True raise DriverError(f"Failed to end session: {e.details()}") from e - async def wait_for_hook_status(self, target_status: "ExporterStatus", timeout: float = 60.0) -> bool: + async def wait_for_hook_status(self, target_status: ExporterStatus, timeout: float = 60.0) -> bool: """Wait for exporter to reach a target status using polling. Used after end_session_async() to wait for afterLease hook completion @@ -384,7 +384,7 @@ async def call_async(self, method, *args): error_message = self._format_rpc_error(method, e) match e.code(): case StatusCode.FAILED_PRECONDITION: - raise ExporterNotReady(e.details()) from None + raise ExporterNotReady(e.details() or "") from None case StatusCode.NOT_FOUND: raise DriverMethodNotImplemented(error_message) from None case StatusCode.UNIMPLEMENTED: @@ -413,15 +413,15 @@ async def streamingcall_async(self, method, *args): except AioRpcError as e: match e.code(): case StatusCode.FAILED_PRECONDITION: - raise ExporterNotReady(e.details()) from None + raise ExporterNotReady(e.details() or "") from None case StatusCode.UNIMPLEMENTED: - raise DriverMethodNotImplemented(e.details()) from None + raise DriverMethodNotImplemented(e.details() or "") from None case StatusCode.INVALID_ARGUMENT: - raise DriverInvalidArgument(e.details()) from None + raise DriverInvalidArgument(e.details() or "") from None case StatusCode.UNKNOWN: - raise DriverError(e.details()) from None + raise DriverError(e.details() or "") from None case _: - raise DriverError(e.details()) from e + raise DriverError(e.details() or "") from e @asynccontextmanager async def stream_async(self, method): @@ -449,9 +449,9 @@ async def resource_async( ) metadata = dict(list(await context.initial_metadata())) async with MetadataStream(stream=RouterStream(context=context), metadata=metadata) as rstream: - metadata = ResourceMetadata(**rstream.extra(MetadataStreamAttributes.metadata)) + metadata = ResourceMetadata(**rstream.extra(MetadataStreamAttributes.metadata)) # type: ignore[call-arg] if metadata.x_jmp_accept_encoding is None: - stream = compress_stream(stream, content_encoding) + stream = compress_stream(stream, content_encoding) # type: ignore[arg-type] async with forward_stream(ProgressStream(stream=stream), rstream): yield metadata.resource.model_dump(mode="json") @@ -527,7 +527,7 @@ async def log_stream(): # noqa: C901 else: self.logger.debug("Log stream error: %s", e.code()) - except Exception as e: + except Exception as e: # noqa: BLE001 # Other errors - log and try to reconnect self.logger.debug("Log stream error: %s", e) diff --git a/python/packages/jumpstarter/jumpstarter/client/decorators.py b/python/packages/jumpstarter/jumpstarter/client/decorators.py index f2cd23139..e3a2156f1 100644 --- a/python/packages/jumpstarter/jumpstarter/client/decorators.py +++ b/python/packages/jumpstarter/jumpstarter/client/decorators.py @@ -2,7 +2,8 @@ Client-side Click group helpers for building driver CLIs. """ -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import click @@ -37,15 +38,14 @@ def on(): def decorator(f: Callable) -> DriverClickGroup: # Use function docstring if no help= provided - if "help" not in kwargs or kwargs["help"] is None: - if f.__doc__: - kwargs["help"] = f.__doc__.strip() + if ("help" not in kwargs or kwargs["help"] is None) and f.__doc__: + kwargs["help"] = f.__doc__.strip() # Server description overrides Click defaults if getattr(client, "description", None): kwargs["help"] = client.description - group = DriverClickGroup(client, name=f.__name__, callback=f, **kwargs) + group = DriverClickGroup(client, name=f.__name__, callback=f, **kwargs) # type: ignore[attr-defined] # Transfer Click parameters attached by decorators like @click.option group.params = getattr(f, "__click_params__", []) @@ -100,7 +100,7 @@ def command(self, *args: Any, **kwargs: Any) -> Callable: def decorator(f: Callable) -> click.Command: name = kwargs.get("name") if not name: - name = f.__name__.lower().replace("_", "-") + name = f.__name__.lower().replace("_", "-") # type: ignore[attr-defined] if name in self.client.methods_description: kwargs["help"] = self.client.methods_description[name] diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher.py b/python/packages/jumpstarter/jumpstarter/client/flasher.py index 5a9dfa9e1..8c3edf2c6 100644 --- a/python/packages/jumpstarter/jumpstarter/client/flasher.py +++ b/python/packages/jumpstarter/jumpstarter/client/flasher.py @@ -2,13 +2,13 @@ import warnings from abc import ABCMeta, abstractmethod -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Callable, Generator, Mapping from contextlib import asynccontextmanager from dataclasses import dataclass, field from enum import StrEnum from os import PathLike from pathlib import Path -from typing import Any, Callable, Literal, Mapping, cast +from typing import Any, Literal, cast import click from anyio import BrokenResourceError, EndOfStream @@ -222,7 +222,6 @@ def cli(self) -> click.Group: @driver_click_group(self) def base(): """Generic flasher interface""" - pass @base.command() @click.argument("file", nargs=-1, required=False) @@ -425,7 +424,6 @@ def cli(self) -> click.Group: @driver_click_group(self) def base(): """Streaming flasher interface""" - pass @base.command() @click.argument("file") diff --git a/python/packages/jumpstarter/jumpstarter/client/flasher_test.py b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py index bcbf25777..3895954fd 100644 --- a/python/packages/jumpstarter/jumpstarter/client/flasher_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/flasher_test.py @@ -170,7 +170,7 @@ async def test_receive_on_empty_iterator(self): async def gen(): return - yield # noqa: RET504 + yield stream = _AsyncIteratorStream(iterator=gen()) with pytest.raises(EndOfStream): @@ -374,13 +374,12 @@ def test_flash_http_with_compression_warns(self): with ( patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http), - patch.object(client, "call", return_value=None), + patch.object(client, "call", return_value=None),warnings.catch_warnings(record=True) as w ): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - client._flash_single("https://example.com/image.bin", target=None, compression="zstd") - assert len(w) == 1 - assert "compression parameter is ignored" in str(w[0].message) + warnings.simplefilter("always") + client._flash_single("https://example.com/image.bin", target=None, compression="zstd") + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) def test_flash_local_with_compression_no_warning(self, tmp_path): from unittest.mock import MagicMock @@ -397,12 +396,11 @@ def test_flash_local_with_compression_no_warning(self, tmp_path): with ( patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_local), - patch.object(client, "call", return_value=None), + patch.object(client, "call", return_value=None),warnings.catch_warnings(record=True) as w ): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - client._flash_single(str(test_file), target=None, compression="zstd") - assert len(w) == 0 + warnings.simplefilter("always") + client._flash_single(str(test_file), target=None, compression="zstd") + assert len(w) == 0 def test_dump_http_with_compression_warns(self): from unittest.mock import MagicMock @@ -417,13 +415,12 @@ def test_dump_http_with_compression_warns(self): with ( patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_http), - patch.object(client, "call", return_value=None), + patch.object(client, "call", return_value=None),warnings.catch_warnings(record=True) as w ): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - client.dump("https://example.com/dump.bin", target=None, compression="zstd") - assert len(w) == 1 - assert "compression parameter is ignored" in str(w[0].message) + warnings.simplefilter("always") + client.dump("https://example.com/dump.bin", target=None, compression="zstd") + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) class TestFlasherClientCli: @@ -695,12 +692,14 @@ def test_flash_stream_http_url_compression_warns(self): statuses_data = [{"phase": "complete", "message": "done"}] client.streamingcall = MagicMock(return_value=iter(statuses_data)) - with patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_adapter): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - list(client.flash_stream("https://example.com/fw.bin", compression="zstd")) - assert len(w) == 1 - assert "compression parameter is ignored" in str(w[0].message) + with ( + patch("jumpstarter.client.flasher._http_url_adapter", return_value=mock_adapter), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + list(client.flash_stream("https://example.com/fw.bin", compression="zstd")) + assert len(w) == 1 + assert "compression parameter is ignored" in str(w[0].message) def test_flash_returns_last_status(self, tmp_path): client = self._make_client() @@ -733,9 +732,11 @@ def test_flash_raises_on_incomplete(self, tmp_path): statuses_data = [{"phase": "step", "message": ""}] client.streamingcall = MagicMock(return_value=iter(statuses_data)) - with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): - with pytest.raises(RuntimeError, match="flash did not complete"): - client.flash(str(test_file)) + with ( + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter), + pytest.raises(RuntimeError, match="flash did not complete"), + ): + client.flash(str(test_file)) def test_flash_raises_on_no_statuses(self, tmp_path): client = self._make_client() @@ -748,9 +749,11 @@ def test_flash_raises_on_no_statuses(self, tmp_path): client.streamingcall = MagicMock(return_value=iter([])) - with patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter): - with pytest.raises(RuntimeError, match="without status updates"): - client.flash(str(test_file)) + with ( + patch("jumpstarter.client.flasher._local_file_adapter", return_value=mock_adapter), + pytest.raises(RuntimeError, match="without status updates"), + ): + client.flash(str(test_file)) def test_flash_dict_raises_argument_error(self): from jumpstarter.common.exceptions import ArgumentError diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc.py b/python/packages/jumpstarter/jumpstarter/client/grpc.py index e5ceb91a6..72113c157 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc.py @@ -29,7 +29,7 @@ class WithOptions: show_disabled: bool = False -def add_display_columns(table, options: WithOptions = None): +def add_display_columns(table, options: WithOptions | None = None): if options is None: options = WithOptions() table.add_column("NAME") @@ -48,7 +48,9 @@ def add_display_columns(table, options: WithOptions = None): table.add_column("RELEASE TIME") -def add_exporter_row(table, exporter, options: WithOptions = None, lease_info: tuple[str, str, str] | None = None): +def add_exporter_row( + table, exporter, options: WithOptions | None = None, lease_info: tuple[str, str, str] | None = None +): if options is None: options = WithOptions() row_data = [] @@ -63,7 +65,7 @@ def add_exporter_row(table, exporter, options: WithOptions = None, lease_info: t status_str = str(exporter.status) if exporter.status else "UNKNOWN" row_data.append(status_str) labels = exporter.labels - row_data.append(",".join(("{}={}".format(k, v) for k, v in sorted(labels.items())))) + row_data.append(",".join((f"{k}={v}" for k, v in sorted(labels.items())))) if options.show_leases: if lease_info: lease_client, lease_status, expected_release = lease_info @@ -77,11 +79,11 @@ def add_exporter_row(table, exporter, options: WithOptions = None, lease_info: t def parse_identifier(identifier: str, kind: str) -> tuple[str, str]: segments = identifier.split("/") if len(segments) != 4: - raise ValueError("incorrect number of segments in identifier, expecting 4, got {}".format(len(segments))) + raise ValueError(f"incorrect number of segments in identifier, expecting 4, got {len(segments)}") if segments[0] != "namespaces": - raise ValueError("incorrect first segment in identifier, expecting namespaces, got {}".format(segments[0])) + raise ValueError(f"incorrect first segment in identifier, expecting namespaces, got {segments[0]}") if segments[2] != kind: - raise ValueError("incorrect third segment in identifier, expecting {}, got {}".format(kind, segments[2])) + raise ValueError(f"incorrect third segment in identifier, expecting {kind}, got {segments[2]}") return segments[1], segments[3] @@ -129,10 +131,10 @@ def from_protobuf(cls, data: client_pb2.Exporter) -> Exporter: ) @classmethod - def rich_add_columns(cls, table, options: WithOptions = None): + def rich_add_columns(cls, table, options: WithOptions | None = None): add_display_columns(table, options) - def rich_add_rows(self, table, options: WithOptions = None): + def rich_add_rows(self, table, options: WithOptions | None = None): lease_info = None if options and options.show_leases and self.lease: lease_client = self.lease.client @@ -509,7 +511,7 @@ async def GetExporter(self, *, name: str, show_hidden_labels: bool = False): with translate_grpc_exceptions(): exporter = await self.stub.GetExporter( client_pb2.GetExporterRequest( - name="namespaces/{}/exporters/{}".format(self.namespace, name), + name=f"namespaces/{self.namespace}/exporters/{name}", show_hidden_labels=show_hidden_labels, ) ) @@ -525,11 +527,11 @@ async def ListExporters( ): with translate_grpc_exceptions(): exporters = await self.stub.ListExporters( - client_pb2.ListExportersRequest( - parent="namespaces/{}".format(self.namespace), - page_size=page_size, - page_token=page_token, - filter=filter, + client_pb2.ListExportersRequest( # type: ignore[call-arg] + parent=f"namespaces/{self.namespace}", + page_size=page_size, # type: ignore[arg-type] + page_token=page_token, # type: ignore[arg-type] + filter=filter, # type: ignore[arg-type] show_hidden_labels=show_hidden_labels, ) ) @@ -539,7 +541,7 @@ async def GetLease(self, *, name: str): with translate_grpc_exceptions(): lease = await self.stub.GetLease( client_pb2.GetLeaseRequest( - name="namespaces/{}/leases/{}".format(self.namespace, name), + name=f"namespaces/{self.namespace}/leases/{name}", ) ) return Lease.from_protobuf(lease) @@ -555,11 +557,11 @@ async def ListLeases( ): with translate_grpc_exceptions(): leases = await self.stub.ListLeases( - client_pb2.ListLeasesRequest( - parent="namespaces/{}".format(self.namespace), - page_size=page_size, - page_token=page_token, - filter=extract_match_labels_filter(filter), + client_pb2.ListLeasesRequest( # type: ignore[call-arg] + parent=f"namespaces/{self.namespace}", + page_size=page_size, # type: ignore[arg-type] + page_token=page_token, # type: ignore[arg-type] + filter=extract_match_labels_filter(filter), # type: ignore[arg-type] only_active=only_active, tag_filter=tag_filter or "", ) @@ -608,7 +610,7 @@ async def CreateLease( with translate_grpc_exceptions(): lease = await self.stub.CreateLease( client_pb2.CreateLeaseRequest( - parent="namespaces/{}".format(self.namespace), + parent=f"namespaces/{self.namespace}", lease=lease_pb, lease_id=lease_id or "", ) @@ -626,7 +628,7 @@ async def UpdateLease( remove_shared_with: list[str] | None = None, ): lease_pb = client_pb2.Lease( - name="namespaces/{}/leases/{}".format(self.namespace, name), + name=f"namespaces/{self.namespace}/leases/{name}", ) update_fields = [] @@ -675,7 +677,7 @@ async def DeleteLease(self, *, name: str): with translate_grpc_exceptions(): await self.stub.DeleteLease( client_pb2.DeleteLeaseRequest( - name="namespaces/{}/leases/{}".format(self.namespace, name), + name=f"namespaces/{self.namespace}/leases/{name}", ) ) @@ -683,7 +685,7 @@ async def RotateToken(self) -> str: with translate_grpc_exceptions(): response = await self.stub.RotateToken( client_pb2.RotateTokenRequest( - parent="namespaces/{}".format(self.namespace), + parent=f"namespaces/{self.namespace}", ) ) return response.token @@ -706,8 +708,8 @@ class MultipathExporterStub: def __post_init__(self, channels): for channel in channels: stub = SimpleNamespace() - jumpstarter_pb2_grpc.ExporterServiceStub.__init__(stub, channel) - router_pb2_grpc.RouterServiceStub.__init__(stub, channel) + jumpstarter_pb2_grpc.ExporterServiceStub.__init__(stub, channel) # type: ignore[arg-type] + router_pb2_grpc.RouterServiceStub.__init__(stub, channel) # type: ignore[arg-type] self.__stubs[channel] = stub def __getattr__(self, name): diff --git a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py index eee11f7d0..02406c7c0 100644 --- a/python/packages/jumpstarter/jumpstarter/client/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/grpc_test.py @@ -1,6 +1,6 @@ import json import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from io import StringIO from unittest.mock import AsyncMock, Mock, patch @@ -171,9 +171,9 @@ def test_emoji_icon_appears_in_table_output(self, _mock): assert columns[0] == "NAME" assert columns[1] == " " - console = Console(file=StringIO(), width=80) + console = Console(file=(buf := StringIO()), width=80) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "🟢" in output assert "my-exporter" in output @@ -185,9 +185,9 @@ def test_ascii_icon_appears_in_table_output(self, _mock): Exporter.rich_add_columns(table) exporter.rich_add_rows(table) - console = Console(file=StringIO(), width=80) + console = Console(file=(buf := StringIO()), width=80) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "+" in output assert "my-exporter" in output @@ -206,9 +206,9 @@ def test_icon_column_suppressed_when_show_status(self, _mock): assert " " not in columns assert "STATUS" in columns - console = Console(file=StringIO(), width=80) + console = Console(file=(buf := StringIO()), width=80) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "AVAILABLE" in output assert "my-exporter" in output @@ -218,7 +218,7 @@ def create_test_lease( self, client="test-client", status="Active", - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0), + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC), effective_duration=timedelta(hours=1), begin_time=None, duration=timedelta(hours=1), @@ -273,9 +273,9 @@ def test_exporter_with_lease_display(self): assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME # Test actual table content by rendering it - console = Console(file=StringIO(), width=120) + console = Console(file=(buf := StringIO()), width=120) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() # Check that the actual content is present in the rendered output assert "test-exporter" in output @@ -296,9 +296,9 @@ def test_exporter_without_lease_but_show_leases(self): assert len(table.columns) == 6 # NAME, icon, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME # Test actual table content by rendering it - console = Console(file=StringIO(), width=120) + console = Console(file=(buf := StringIO()), width=120) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() # Check that the actual content shows "Available" status assert "test-exporter" in output @@ -328,9 +328,9 @@ def test_exporter_online_status_display(self): assert len(table.columns) == 4 # NAME, icon, ONLINE, LABELS # Test actual table content by rendering it - console = Console(file=StringIO(), width=120) + console = Console(file=(buf := StringIO()), width=120) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() # Check that the actual content shows correct online status indicators assert "online-exporter" in output @@ -366,9 +366,9 @@ def test_exporter_all_features_display(self): assert len(table.columns) == 7 # NAME, icon, ONLINE, LABELS, LEASED BY, LEASE STATUS, RELEASE TIME # Test actual table content by rendering it - console = Console(file=StringIO(), width=150) + console = Console(file=(buf := StringIO()), width=150) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() # Verify all content is present assert "online-with-lease" in output @@ -387,7 +387,7 @@ def test_exporter_lease_info_extraction(self): lease = self.create_test_lease( client="my-client", status="Expired", - effective_end_time=datetime(2023, 1, 1, 11, 0, 0), # Ended after 1 hour + effective_end_time=datetime(2023, 1, 1, 11, 0, 0, tzinfo=UTC), # Ended after 1 hour ) exporter = Exporter( namespace="default", name="test-exporter", labels={"type": "device"}, online=True, lease=lease @@ -447,7 +447,7 @@ def test_exporter_scheduled_lease_expected_release(self): status="Scheduled", effective_begin_time=None, # Not started yet effective_duration=None, # Not started yet - begin_time=datetime(2023, 1, 1, 10, 0, 0), + begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC), duration=timedelta(hours=1), ) exporter = Exporter( @@ -465,9 +465,9 @@ def test_exporter_scheduled_lease_expected_release(self): assert len(table.rows) == 1 # Test actual table content by rendering it - console = Console(file=StringIO(), width=120) + console = Console(file=(buf := StringIO()), width=120) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() # Verify the scheduled lease displays expected release time assert "test-exporter" in output @@ -644,82 +644,82 @@ def test_rich_add_columns_excludes_begin_time_and_duration(self): def test_compute_expires_at_from_effective_end_time(self): lease = self.create_lease( - effective_end_time=datetime(2023, 1, 1, 11, 0, 0), + effective_end_time=datetime(2023, 1, 1, 11, 0, 0, tzinfo=UTC), ) - assert lease._compute_expires_at() == datetime(2023, 1, 1, 11, 0, 0) + assert lease._compute_expires_at() == datetime(2023, 1, 1, 11, 0, 0, tzinfo=UTC) def test_compute_expires_at_from_effective_begin_and_duration(self): lease = self.create_lease( - effective_begin_time=datetime(2023, 6, 15, 14, 30, 0), + effective_begin_time=datetime(2023, 6, 15, 14, 30, 0, tzinfo=UTC), duration=timedelta(hours=2), ) - assert lease._compute_expires_at() == datetime(2023, 6, 15, 16, 30, 0) + assert lease._compute_expires_at() == datetime(2023, 6, 15, 16, 30, 0, tzinfo=UTC) def test_compute_expires_at_from_begin_time_and_duration(self): lease = self.create_lease( - begin_time=datetime(2023, 3, 10, 8, 0, 0), + begin_time=datetime(2023, 3, 10, 8, 0, 0, tzinfo=UTC), duration=timedelta(minutes=30), ) - assert lease._compute_expires_at() == datetime(2023, 3, 10, 8, 30, 0) + assert lease._compute_expires_at() == datetime(2023, 3, 10, 8, 30, 0, tzinfo=UTC) def test_compute_expires_at_none_when_no_begin_time(self): lease = self.create_lease() assert lease._compute_expires_at() is None def test_format_remaining_expired(self): - past = datetime(2020, 1, 1, 0, 0, 0) + past = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC) assert Lease._format_remaining(past) == "expired" def test_format_remaining_none(self): assert Lease._format_remaining(None) == "" def test_format_remaining_days_hours_minutes(self): - now = datetime(2023, 1, 1, 0, 0, 0) - expires_at = datetime(2023, 1, 3, 3, 45, 0) + now = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + expires_at = datetime(2023, 1, 3, 3, 45, 0, tzinfo=UTC) with patch("jumpstarter.client.grpc.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now assert Lease._format_remaining(expires_at) == "2d 3h 45m" def test_format_remaining_hours_and_minutes(self): - now = datetime(2023, 1, 1, 0, 0, 0) - expires_at = datetime(2023, 1, 1, 5, 30, 0) + now = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + expires_at = datetime(2023, 1, 1, 5, 30, 0, tzinfo=UTC) with patch("jumpstarter.client.grpc.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now assert Lease._format_remaining(expires_at) == "5h 30m" def test_format_remaining_minutes_only(self): - now = datetime(2023, 1, 1, 0, 0, 0) - expires_at = datetime(2023, 1, 1, 0, 15, 0) + now = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + expires_at = datetime(2023, 1, 1, 0, 15, 0, tzinfo=UTC) with patch("jumpstarter.client.grpc.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now assert Lease._format_remaining(expires_at) == "15m" def test_format_remaining_zero_minutes_shows_0m(self): - now = datetime(2023, 1, 1, 0, 0, 0) - expires_at = datetime(2023, 1, 1, 0, 0, 30) + now = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + expires_at = datetime(2023, 1, 1, 0, 0, 30, tzinfo=UTC) with patch("jumpstarter.client.grpc.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now assert Lease._format_remaining(expires_at) == "0m" def test_format_remaining_days_only(self): - now = datetime(2023, 1, 1, 0, 0, 0) - expires_at = datetime(2023, 1, 4, 0, 0, 0) + now = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) + expires_at = datetime(2023, 1, 4, 0, 0, 0, tzinfo=UTC) with patch("jumpstarter.client.grpc.datetime", wraps=datetime) as mock_dt: mock_dt.now.return_value = now assert Lease._format_remaining(expires_at) == "3d" def test_rich_add_rows_shows_expires_at(self): lease = self.create_lease( - effective_begin_time=datetime(2023, 1, 1, 10, 0, 0), - effective_end_time=datetime(2023, 1, 1, 11, 0, 0), + effective_begin_time=datetime(2023, 1, 1, 10, 0, 0, tzinfo=UTC), + effective_end_time=datetime(2023, 1, 1, 11, 0, 0, tzinfo=UTC), ) table = Table() Lease.rich_add_columns(table) lease.rich_add_rows(table) - console = Console(file=StringIO(), width=200) + console = Console(file=(buf := StringIO()), width=200) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "2023-01-01 11:00:00" in output def test_rich_add_rows_empty_when_no_timing_data(self): @@ -728,9 +728,9 @@ def test_rich_add_rows_empty_when_no_timing_data(self): Lease.rich_add_columns(table) lease.rich_add_rows(table) - console = Console(file=StringIO(), width=200) + console = Console(file=(buf := StringIO()), width=200) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "test-lease" in output assert "test-client" in output @@ -740,9 +740,9 @@ def test_rich_display_shows_tags(self): table = Table() Lease.rich_add_columns(table) lease.rich_add_rows(table) - console = Console(file=StringIO(), force_terminal=True) + console = Console(file=(buf := StringIO()), force_terminal=True) console.print(table) - output = console.file.getvalue() + output = buf.getvalue() assert "team=devops" in output assert "ci-job=12345" in output @@ -788,9 +788,8 @@ def test_filter_warning_names_lease_filter_and_error(self, caplog): with patch( "jumpstarter.client.grpc.selector_contains", side_effect=ValueError("unknown label selector operator: 'bogus'"), - ): - with caplog.at_level(logging.WARNING, logger="jumpstarter.client.grpc"): - leases.filter_by_selector("board in rpi") + ), caplog.at_level(logging.WARNING, logger="jumpstarter.client.grpc"): + leases.filter_by_selector("board in rpi") assert "bad" in caplog.text assert "board in rpi" in caplog.text assert "unknown label selector operator: 'bogus'" in caplog.text diff --git a/python/packages/jumpstarter/jumpstarter/client/introspect.py b/python/packages/jumpstarter/jumpstarter/client/introspect.py index acea78b89..5197ddf64 100644 --- a/python/packages/jumpstarter/jumpstarter/client/introspect.py +++ b/python/packages/jumpstarter/jumpstarter/client/introspect.py @@ -285,23 +285,25 @@ async def _connect_lease(config: ClientConfigV1Alpha1, lease_name: str, portal: Passing lease_name into lease_async attaches to that lease rather than creating one, and leaves it unreleased on exit. """ - async with config.lease_async( - selector=None, - exporter_name=None, - lease_name=lease_name, - # Attaching by name never reaches Lease._create, and with selector None - # the "selector changed, make a new one" branch cannot fire either, so - # no duration is ever sent to the controller. Naming 30 minutes here - # only suggested this call could extend a lease that it cannot. - duration=timedelta(0), - portal=portal, - ) as lease: - async with lease.serve_unix_async() as path: - with ExitStack() as stack: - async with client_from_path( - path, portal, stack, allow=lease.allow, unsafe=lease.unsafe - ) as client: - yield client + async with ( + config.lease_async( + selector=None, + exporter_name=None, + lease_name=lease_name, + # Attaching by name never reaches Lease._create, and with selector None + # the "selector changed, make a new one" branch cannot fire either, so + # no duration is ever sent to the controller. Naming 30 minutes here + # only suggested this call could extend a lease that it cannot. + duration=timedelta(0), + portal=portal, + ) as lease, + lease.serve_unix_async() as path, + ): + with ExitStack() as stack: + async with client_from_path( + path, portal, stack, allow=lease.allow, unsafe=lease.unsafe + ) as client: + yield client def describe_drivers(config: ClientConfigV1Alpha1, lease_name: str) -> dict[str, Any]: @@ -322,9 +324,11 @@ def describe_drivers(config: ClientConfigV1Alpha1, lease_name: str) -> dict[str, if not lease_name: raise ValueError("lease_name must be a non-empty existing lease name") - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(_connect_lease(config, lease_name, portal)) as client: - return describe_client(client) + with ( + start_blocking_portal() as portal, + portal.wrap_async_context_manager(_connect_lease(config, lease_name, portal)) as client, + ): + return describe_client(client) async def describe_drivers_async(config: ClientConfigV1Alpha1, lease_name: str) -> dict[str, Any]: diff --git a/python/packages/jumpstarter/jumpstarter/client/introspect_test.py b/python/packages/jumpstarter/jumpstarter/client/introspect_test.py index bd5668a92..7b65303a3 100644 --- a/python/packages/jumpstarter/jumpstarter/client/introspect_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/introspect_test.py @@ -4,6 +4,7 @@ from datetime import timedelta from pathlib import Path from types import MappingProxyType +from typing import ClassVar from unittest.mock import MagicMock, patch import click @@ -21,7 +22,7 @@ def test_description_helpers_are_public_package_exports(): - import jumpstarter.client as client + from jumpstarter import client for name, helper in ( ("describe_client", describe_client), @@ -35,7 +36,7 @@ def test_description_helpers_are_public_package_exports(): class FakePowerClient: - children: dict = {} + children: ClassVar[dict]= {} def on(self) -> None: """Power on the device.""" @@ -57,7 +58,7 @@ def check_exporter_status(self): class FakeSerialClient: - children: dict = {} + children: ClassVar[dict]= {} def open(self): """Open serial port.""" diff --git a/python/packages/jumpstarter/jumpstarter/client/lease.py b/python/packages/jumpstarter/jumpstarter/client/lease.py index f306043c3..b79f7ce56 100644 --- a/python/packages/jumpstarter/jumpstarter/client/lease.py +++ b/python/packages/jumpstarter/jumpstarter/client/lease.py @@ -9,7 +9,7 @@ contextmanager, ) from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any, Self import grpc @@ -188,12 +188,15 @@ async def request_async(self): existing_lease = await self.get() if existing_lease.effective_end_time: raise LeaseError(f"lease {self.name} has already ended") - if self.client_name and existing_lease.client != self.client_name: - if not existing_lease.is_accessible_by(self.client_name): - raise LeaseError( - f"lease {self.name} belongs to client '{existing_lease.client}', " - f"not the current client '{self.client_name}'" - ) + if ( + self.client_name + and existing_lease.client != self.client_name + and not existing_lease.is_accessible_by(self.client_name) + ): + raise LeaseError( + f"lease {self.name} belongs to client '{existing_lease.client}', " + f"not the current client '{self.client_name}'" + ) if self.selector is not None and existing_lease.selector != self.selector: logger.warning( "Existing lease from env or flag %s has selector '%s' but requested selector is '%s'. " @@ -214,7 +217,7 @@ async def _fetch_exporter_labels(self): try: exporter = await self.svc.GetExporter(name=self.exporter_name) self.exporter_labels = exporter.labels - except Exception as e: + except Exception as e: # noqa: BLE001 self.exporter_labels = {} logger.warning("Could not fetch labels for exporter %s: %s", self.exporter_name, e) @@ -325,7 +328,7 @@ async def __asynccontextmanager__(self) -> AsyncGenerator[Self]: ) except TimeoutError: logger.warning("Timeout while deleting lease %s during cleanup", self.name) - except Exception: + except Exception: # noqa: BLE001 logger.debug("Error during lease cleanup for %s (likely already expired)", self.name) @contextmanager @@ -459,11 +462,11 @@ async def _monitor(): while True: try: lease = await self.get() - except Exception as e: + except Exception as e: # noqa: BLE001 logger.warning("Failed to check lease %s status: %s", self.name, e) # If we know when the lease should end, use it to bound the sleep if last_known_end_time is not None: - remain = (last_known_end_time - datetime.now().astimezone()).total_seconds() + remain = (last_known_end_time - datetime.now(tz=UTC).astimezone()).total_seconds() if remain <= 0: logger.info( "Lease %s estimated to have ended at %s (unable to confirm with server)", @@ -483,18 +486,16 @@ async def _monitor(): continue last_known_end_time = end_time - remain = end_time - datetime.now().astimezone() + remain = end_time - datetime.now(tz=UTC).astimezone() if remain < timedelta(0): - logger.info("Lease {} ended at {}".format(self.name, end_time)) + logger.info(f"Lease {self.name} ended at {end_time}") self._notify_lease_ending(timedelta(0)) break # Log once when entering the threshold window if threshold - timedelta(seconds=check_interval) <= remain < threshold: logger.info( - "Lease {} ending in {} minutes at {}".format( - self.name, int((remain.total_seconds() + 30) // 60), end_time - ) + f"Lease {self.name} ending in {int((remain.total_seconds() + 30) // 60)} minutes at {end_time}" ) self._notify_lease_ending(remain) await sleep(min(remain.total_seconds(), check_interval)) @@ -508,15 +509,16 @@ async def _monitor(): @asynccontextmanager async def connect_async(self, stack): - async with self.serve_unix_async() as path: - async with client_from_path(path, self.portal, stack, allow=self.allow, unsafe=self.unsafe) as client: - yield client + async with ( + self.serve_unix_async() as path, + client_from_path(path, self.portal, stack, allow=self.allow, unsafe=self.unsafe) as client, + ): + yield client @contextmanager def connect(self): - with ExitStack() as stack: - with self.portal.wrap_async_context_manager(self.connect_async(stack)) as client: - yield client + with ExitStack() as stack, self.portal.wrap_async_context_manager(self.connect_async(stack)) as client: + yield client @contextmanager def serve_unix(self): @@ -556,7 +558,7 @@ def _is_terminal_available(self) -> bool: ) def __enter__(self): - self.start_time = datetime.now() + self.start_time = datetime.now(tz=UTC) if self._should_show_spinner: self.spinner = self.console.status( f"Acquiring lease {self.lease_name or '...'}...", spinner="dots", spinner_style="blue" @@ -576,13 +578,13 @@ def update_status(self, message: str, force: bool = False): """ if self.spinner and self._should_show_spinner: self._current_message = f"[blue]{message}[/blue]" - elapsed = datetime.now() - self.start_time + elapsed = datetime.now(tz=UTC) - self.start_time elapsed_str = str(elapsed).split(".")[0] # Remove microseconds self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]") else: # Log info message when no console is available # Throttle updates to at most every 5 minutes unless forced - now = datetime.now() + now = datetime.now(tz=UTC) should_log = ( force or self._last_log_time is None or (now - self._last_log_time) >= self._log_throttle_interval ) @@ -596,7 +598,7 @@ def update_status(self, message: str, force: bool = False): def tick(self): """Update the spinner with current elapsed time without changing the message.""" if self.spinner and self._should_show_spinner and self._current_message: - elapsed = datetime.now() - self.start_time + elapsed = datetime.now(tz=UTC) - self.start_time elapsed_str = str(elapsed).split(".")[0] # Remove microseconds # Use the stored current message and update with new elapsed time self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]") diff --git a/python/packages/jumpstarter/jumpstarter/client/lease_test.py b/python/packages/jumpstarter/jumpstarter/client/lease_test.py index bd3c40b2d..bc73626eb 100644 --- a/python/packages/jumpstarter/jumpstarter/client/lease_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/lease_test.py @@ -2,7 +2,7 @@ import logging import sys from contextlib import asynccontextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, Mock, patch import anyio @@ -102,17 +102,16 @@ def test_context_manager_without_console(self): with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - with patch.object(spinner.console, "status") as mock_status: - with spinner as ctx_spinner: - assert ctx_spinner is spinner - assert spinner.start_time is not None - mock_status.assert_not_called() + with patch.object(spinner.console, "status") as mock_status, spinner as ctx_spinner: + assert ctx_spinner is spinner + assert spinner.start_time is not None + mock_status.assert_not_called() def test_update_status_with_console(self): """Test status update when console is available.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) mock_spinner = Mock() spinner.spinner = mock_spinner @@ -129,7 +128,7 @@ def test_update_status_without_console(self, caplog): """Test status update when console is not available (should log).""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) with caplog.at_level(logging.INFO): spinner.update_status("Test message") @@ -141,7 +140,7 @@ def test_tick_with_console_and_message(self): """Test tick update when console is available and message exists.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) spinner._current_message = "[blue]Test message[/blue]" mock_spinner = Mock() @@ -158,7 +157,7 @@ def test_tick_without_console(self): """Test tick update when console is not available (should not log).""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) spinner._current_message = "[blue]Test message[/blue]" # Should not raise any exceptions or log anything @@ -168,7 +167,7 @@ def test_tick_without_message(self): """Test tick update when no current message exists.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) spinner._current_message = None mock_spinner = Mock() @@ -183,7 +182,7 @@ def test_elapsed_time_formatting(self): """Test that elapsed time is formatted correctly.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() - timedelta(seconds=65) # 1:05 + spinner.start_time = datetime.now(tz=UTC) - timedelta(seconds=65) # 1:05 spinner._current_message = "[blue]Test message[/blue]" mock_spinner = Mock() @@ -226,7 +225,7 @@ def test_message_preservation_across_ticks(self): """Test that the base message is preserved across multiple ticks.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) # Set up mock before calling update_status mock_spinner = Mock() @@ -262,7 +261,7 @@ def test_throttling_first_update_logged(self, caplog): """Test that the first update is always logged when console is not available.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) with caplog.at_level(logging.INFO): spinner.update_status("First message") @@ -274,8 +273,8 @@ def test_throttling_second_update_within_interval_not_logged(self, caplog): """Test that updates within 5 minutes are not logged.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() - spinner._last_log_time = datetime.now() - timedelta(minutes=2) # 2 minutes ago + spinner.start_time = datetime.now(tz=UTC) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=2) # 2 minutes ago with caplog.at_level(logging.INFO): spinner.update_status("Second message") @@ -287,8 +286,8 @@ def test_throttling_update_after_interval_logged(self, caplog): """Test that updates after 5 minutes are logged.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() - spinner._last_log_time = datetime.now() - timedelta(minutes=6) # 6 minutes ago + spinner.start_time = datetime.now(tz=UTC) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=6) # 6 minutes ago with caplog.at_level(logging.INFO): spinner.update_status("After interval message") @@ -300,8 +299,8 @@ def test_throttling_forced_update_always_logged(self, caplog): """Test that forced updates are always logged regardless of throttle interval.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() - spinner._last_log_time = datetime.now() - timedelta(minutes=1) # 1 minute ago + spinner.start_time = datetime.now(tz=UTC) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=1) # 1 minute ago with caplog.at_level(logging.INFO): spinner.update_status("Forced message", force=True) @@ -313,7 +312,7 @@ def test_throttling_multiple_updates_only_logs_when_needed(self, caplog): """Test that multiple rapid updates only log at appropriate intervals.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=False): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) with caplog.at_level(logging.INFO): # First update should be logged @@ -321,7 +320,7 @@ def test_throttling_multiple_updates_only_logs_when_needed(self, caplog): assert "Message 1" in caplog.text # Set last log time to recent - spinner._last_log_time = datetime.now() - timedelta(minutes=1) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=1) # Second update should not be logged (within interval) spinner.update_status("Message 2") @@ -332,7 +331,7 @@ def test_throttling_multiple_updates_only_logs_when_needed(self, caplog): assert "Message 3" not in caplog.text # Set last log time to past the interval - spinner._last_log_time = datetime.now() - timedelta(minutes=6) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=6) # Fourth update should be logged (past interval) spinner.update_status("Message 4") @@ -342,7 +341,7 @@ def test_throttling_not_applied_when_console_available(self): """Test that throttling is not applied when console is available.""" with patch.object(LeaseAcquisitionSpinner, "_is_terminal_available", return_value=True): spinner = LeaseAcquisitionSpinner("test-lease") - spinner.start_time = datetime.now() + spinner.start_time = datetime.now(tz=UTC) mock_spinner = Mock() spinner.spinner = mock_spinner @@ -353,7 +352,7 @@ def test_throttling_not_applied_when_console_available(self): spinner.update_status("Message 3") # All should be called even if we set a recent last_log_time - spinner._last_log_time = datetime.now() - timedelta(minutes=1) + spinner._last_log_time = datetime.now(tz=UTC) - timedelta(minutes=1) spinner.update_status("Message 4") assert mock_spinner.update.call_count == 4 @@ -521,7 +520,7 @@ def test_returns_none_when_no_begin_time(self): def test_returns_none_when_no_duration(self): lease = self._make_lease() response = Mock( - effective_begin_time=datetime.now(tz=timezone.utc), + effective_begin_time=datetime.now(tz=UTC), duration=None, effective_end_time=None, ) @@ -530,9 +529,9 @@ def test_returns_none_when_no_duration(self): def test_returns_effective_end_time_when_present(self): lease = self._make_lease() - end_time = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + end_time = datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC) response = Mock( - effective_begin_time=datetime(2025, 6, 1, 11, 0, 0, tzinfo=timezone.utc), + effective_begin_time=datetime(2025, 6, 1, 11, 0, 0, tzinfo=UTC), duration=timedelta(hours=1), effective_end_time=end_time, ) @@ -541,7 +540,7 @@ def test_returns_effective_end_time_when_present(self): def test_returns_effective_end_time_even_without_begin_or_duration(self): lease = self._make_lease() - end_time = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + end_time = datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC) response = Mock( effective_begin_time=None, duration=None, @@ -552,7 +551,7 @@ def test_returns_effective_end_time_even_without_begin_or_duration(self): def test_calculates_end_time_when_no_effective_end(self): lease = self._make_lease() - begin = datetime(2025, 6, 1, 11, 0, 0, tzinfo=timezone.utc) + begin = datetime(2025, 6, 1, 11, 0, 0, tzinfo=UTC) duration = timedelta(hours=2) response = Mock( effective_begin_time=begin, @@ -586,9 +585,9 @@ async def failing_get(): nonlocal call_count call_count += 1 if call_count <= 2: - raise Exception("transient error") + raise Exception("transient error") # noqa: TRY002 # Third call: return expired lease to exit the loop - end_time = datetime.now(tz=timezone.utc) - timedelta(seconds=10) + end_time = datetime.now(tz=UTC) - timedelta(seconds=10) return Mock( effective_begin_time=end_time - timedelta(hours=1), effective_duration=timedelta(hours=1), @@ -611,7 +610,7 @@ async def test_estimates_expiry_from_last_known_end_time(self, caplog): lease.lease_ending_callback = callback # End time slightly in the future so the monitor caches it and sleeps - future_end = datetime.now(tz=timezone.utc) + timedelta(milliseconds=50) + future_end = datetime.now(tz=UTC) + timedelta(milliseconds=50) call_count = 0 async def get_then_fail(): @@ -624,7 +623,7 @@ async def get_then_fail(): effective_end_time=None, duration=timedelta(hours=1), ) - raise Exception("server unavailable") + raise Exception("server unavailable") # noqa: TRY002 lease.get = get_then_fail @@ -766,7 +765,7 @@ async def test_raises_when_lease_has_effective_end_time(self): """request_async should raise LeaseError when the lease has already ended.""" lease = self._make_lease() lease.get.return_value = Mock( - effective_end_time=datetime.now(timezone.utc), + effective_end_time=datetime.now(UTC), client="my-client", selector=None, ) @@ -870,16 +869,18 @@ async def mock_connect_router_stream(endpoint, token, stream, tls_config, grpc_o router_stream_calls.append((endpoint, token, tls_config, grpc_options)) yield - with patch.object(lease, "_dial_with_retry", side_effect=mock_dial_with_retry): - with patch("jumpstarter.client.lease.connect_router_stream", side_effect=mock_connect_router_stream): - async with lease.serve_unix_async() as socket_path: - # Readiness check should have been called - assert dial_calls == 1 + with ( + patch.object(lease, "_dial_with_retry", side_effect=mock_dial_with_retry), + patch("jumpstarter.client.lease.connect_router_stream", side_effect=mock_connect_router_stream), + ): + async with lease.serve_unix_async() as socket_path: + # Readiness check should have been called + assert dial_calls == 1 - # Connect to the Unix socket - async with await anyio.connect_unix(socket_path): - # Give the handler time to process - await anyio.sleep(0.1) + # Connect to the Unix socket + async with await anyio.connect_unix(socket_path): + # Give the handler time to process + await anyio.sleep(0.1) # Verify per-connection Dial was called assert dial_calls == 2 @@ -914,8 +915,8 @@ async def mock_dial(request): return Mock(router_endpoint="test-endpoint", router_token="test-token") raise AioRpcError( code=StatusCode.UNAVAILABLE, - initial_metadata=None, - trailing_metadata=None, + initial_metadata=None, # type: ignore[arg-type] + trailing_metadata=None, # type: ignore[arg-type] details="exporter offline", ) @@ -924,11 +925,10 @@ async def mock_dial(request): # The ExceptionGroup surfaces when the TemporaryUnixListener task group # tears down, so pytest.raises must wrap the entire serve_unix_async block. with pytest.raises(BaseExceptionGroup) as exc_info: - async with lease.serve_unix_async() as socket_path: - async with await anyio.connect_unix(socket_path): - await anyio.sleep(1) + async with lease.serve_unix_async() as socket_path, await anyio.connect_unix(socket_path): + await anyio.sleep(1) - exceptions = exc_info.value.exceptions + exceptions = exc_info.value.exceptions # type: ignore[attr-defined] assert len(exceptions) == 1 assert isinstance(exceptions[0], ExporterUnreachableError) assert "Per-connection Dial failed" in str(exceptions[0]) @@ -956,8 +956,8 @@ async def mock_dial(request): if calls["count"] == 2: raise AioRpcError( code=StatusCode.UNAVAILABLE, - initial_metadata=None, - trailing_metadata=None, + initial_metadata=None, # type: ignore[arg-type] + trailing_metadata=None, # type: ignore[arg-type] details="transient", ) return Mock(router_endpoint="test-endpoint", router_token="test-token") @@ -972,9 +972,8 @@ async def mock_connect_router_stream(endpoint, token, stream, tls_config, grpc_o yield with patch("jumpstarter.client.lease.connect_router_stream", side_effect=mock_connect_router_stream): - async with lease.serve_unix_async() as socket_path: - async with await anyio.connect_unix(socket_path): - await anyio.sleep(1) + async with lease.serve_unix_async() as socket_path, await anyio.connect_unix(socket_path): + await anyio.sleep(1) # The connection was served despite the blip assert router_stream_calls == ["test-endpoint"] diff --git a/python/packages/jumpstarter/jumpstarter/client/status_monitor.py b/python/packages/jumpstarter/jumpstarter/client/status_monitor.py index 796f7c5cc..0b01fd629 100644 --- a/python/packages/jumpstarter/jumpstarter/client/status_monitor.py +++ b/python/packages/jumpstarter/jumpstarter/client/status_monitor.py @@ -171,7 +171,7 @@ async def wait_for_status( # noqa: C901 return False # Other errors - connection might still work, continue waiting logger.debug("GetStatus error during verification: %s", e.code()) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.debug("GetStatus error during verification: %s", e) return False @@ -254,7 +254,7 @@ async def wait_for_any_of( # noqa: C901 return None # Other errors - connection might still work, continue waiting logger.debug("GetStatus error during verification: %s", e.code()) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.debug("GetStatus error during verification: %s", e) return None @@ -377,7 +377,7 @@ async def _poll_loop(self): # noqa: C901 for callback in self._on_status_change: try: await callback(new_status, old_status) - except Exception as e: + except Exception as e: # noqa: BLE001 logger.error(f"Status change callback error: {e}") except NotImplementedError: @@ -424,7 +424,7 @@ async def _poll_loop(self): # noqa: C901 logger.debug("GetStatus timed out (attempt %d), retrying...", deadline_retries) continue logger.debug(f"GetStatus poll error: {e.code()}") - except Exception as e: + except Exception as e: # noqa: BLE001 logger.debug(f"GetStatus poll error: {e}") # Wait for next poll or stop signal diff --git a/python/packages/jumpstarter/jumpstarter/client/status_test.py b/python/packages/jumpstarter/jumpstarter/client/status_test.py index 9518bd5f0..2fce61d08 100644 --- a/python/packages/jumpstarter/jumpstarter/client/status_test.py +++ b/python/packages/jumpstarter/jumpstarter/client/status_test.py @@ -20,96 +20,105 @@ def _env_with_term(self, term): return env def test_returns_false_when_term_dumb(self): - with patch.dict("os.environ", self._env_with_term("dumb"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with patch.dict("os.environ", self._env_with_term("dumb"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_term_linux(self): - with patch.dict("os.environ", self._env_with_term("linux"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with patch.dict("os.environ", self._env_with_term("linux"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_term_vt100(self): - with patch.dict("os.environ", self._env_with_term("vt100"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with patch.dict("os.environ", self._env_with_term("vt100"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_term_ansi(self): - with patch.dict("os.environ", self._env_with_term("ansi"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with patch.dict("os.environ", self._env_with_term("ansi"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_term_unset(self): env = {k: v for k, v in os.environ.items() if k not in ("TERM", "NO_COLOR", "NO_ICONS")} - with patch.dict("os.environ", env, clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with patch.dict("os.environ", env, clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_no_color_set_does_not_affect_emoji(self): """NO_COLOR only concerns ANSI color sequences (see no-color.org), not icons.""" - with patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_COLOR": ""}, clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with ( + patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_COLOR": ""}, clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_no_color_set_with_value_does_not_affect_emoji(self): - with patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_COLOR": "1"}, clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with ( + patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_COLOR": "1"}, clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_returns_false_when_no_icons_set(self): - with patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_ICONS": ""}, clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with ( + patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_ICONS": ""}, clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_no_icons_set_with_value(self): - with patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_ICONS": "1"}, clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is False + with ( + patch.dict("os.environ", {**self._env_with_term("xterm-256color"), "NO_ICONS": "1"}, clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is False def test_returns_false_when_stdout_not_tty(self): - with patch.dict("os.environ", self._env_with_term("xterm-256color"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = False - assert _use_emoji() is False + with ( + patch.dict("os.environ", self._env_with_term("xterm-256color"), clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = False + assert _use_emoji() is False def test_returns_true_for_xterm(self): - with patch.dict("os.environ", self._env_with_term("xterm-256color"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with ( + patch.dict("os.environ", self._env_with_term("xterm-256color"), clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_returns_true_for_tmux(self): - with patch.dict("os.environ", self._env_with_term("tmux-256color"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with ( + patch.dict("os.environ", self._env_with_term("tmux-256color"), clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_returns_true_for_screen(self): - with patch.dict("os.environ", self._env_with_term("screen-256color"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with ( + patch.dict("os.environ", self._env_with_term("screen-256color"), clear=True), + patch("sys.stdout") as mock_stdout, + ): + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_returns_true_for_alacritty(self): - with patch.dict("os.environ", self._env_with_term("alacritty"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with patch.dict("os.environ", self._env_with_term("alacritty"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is True def test_returns_true_for_kitty(self): - with patch.dict("os.environ", self._env_with_term("kitty"), clear=True): - with patch("sys.stdout") as mock_stdout: - mock_stdout.isatty.return_value = True - assert _use_emoji() is True + with patch.dict("os.environ", self._env_with_term("kitty"), clear=True), patch("sys.stdout") as mock_stdout: + mock_stdout.isatty.return_value = True + assert _use_emoji() is True class TestStatusIcon: diff --git a/python/packages/jumpstarter/jumpstarter/common/__init__.py b/python/packages/jumpstarter/jumpstarter/common/__init__.py index 7f181e4a0..74dd493eb 100644 --- a/python/packages/jumpstarter/jumpstarter/common/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/common/__init__.py @@ -12,11 +12,11 @@ HOOK_WARNING_PREFIX = "[HOOK_WARNING] " __all__ = [ + "HOOK_WARNING_PREFIX", "AsyncChannel", "ControllerStub", "ExporterStatus", "ExporterStub", - "HOOK_WARNING_PREFIX", "LogSource", "Metadata", "RouterStub", diff --git a/python/packages/jumpstarter/jumpstarter/common/condition.py b/python/packages/jumpstarter/jumpstarter/common/condition.py index 32ae9b1f0..de0c483bb 100644 --- a/python/packages/jumpstarter/jumpstarter/common/condition.py +++ b/python/packages/jumpstarter/jumpstarter/common/condition.py @@ -7,9 +7,8 @@ def condition_present_and_equal( conditions: list[kubernetes_pb2.Condition], condition_type: str, status: str, reason: str | None = None ) -> bool: for condition in conditions: - if condition.type == condition_type: - if reason is None or condition.reason == reason: - return condition.status == status + if condition.type == condition_type and (reason is None or condition.reason == reason): + return condition.status == status return False @@ -17,9 +16,8 @@ def condition_message( conditions: list[kubernetes_pb2.Condition], condition_type: str, reason: str | None = None ) -> str | None: for condition in conditions: - if condition.type == condition_type: - if reason is None or condition.reason == reason: - return condition.message + if condition.type == condition_type and (reason is None or condition.reason == reason): + return condition.message return None diff --git a/python/packages/jumpstarter/jumpstarter/common/display_test.py b/python/packages/jumpstarter/jumpstarter/common/display_test.py index 9fc710c4f..9f5ea30b0 100644 --- a/python/packages/jumpstarter/jumpstarter/common/display_test.py +++ b/python/packages/jumpstarter/jumpstarter/common/display_test.py @@ -51,4 +51,4 @@ def test_both_set(self, monkeypatch): def test_options_are_immutable(self): opts = DisplayOptions(no_color=True, no_icons=True) with pytest.raises(dataclasses.FrozenInstanceError): - opts.no_color = False + opts.no_color = False # type: ignore[misc] diff --git a/python/packages/jumpstarter/jumpstarter/common/exceptions.py b/python/packages/jumpstarter/jumpstarter/common/exceptions.py index b939cdbcc..41967eb3d 100644 --- a/python/packages/jumpstarter/jumpstarter/common/exceptions.py +++ b/python/packages/jumpstarter/jumpstarter/common/exceptions.py @@ -42,13 +42,11 @@ def print(self, message: str | None = None): class ConnectionError(JumpstarterException): """Raised when a connection to a jumpstarter server fails.""" - pass class ExporterOfflineError(ConnectionError): """Raised when the connection to the exporter is lost during a lease.""" - pass class ExporterUnreachableError(JumpstarterException): @@ -57,38 +55,32 @@ class ExporterUnreachableError(JumpstarterException): Signals that the lease should be released and re-acquired. """ - pass class ConfigurationError(JumpstarterException): """Raised when a configuration error exists.""" - pass class ArgumentError(JumpstarterException): """Raised when a cli argument is not valid.""" - pass class FileNotFoundError(JumpstarterException, FileNotFoundError): """Raised when a file is not found.""" - pass class ReauthenticationFailed(JumpstarterException): """Raised when a re-authentication fails.""" - pass class EnvironmentVariableNotSetError(JumpstarterException): """Raised when a environment variable is not set.""" - pass class MissingDriverError(JumpstarterException): diff --git a/python/packages/jumpstarter/jumpstarter/common/fls.py b/python/packages/jumpstarter/jumpstarter/common/fls.py index 5dce0ea45..5880bc0b3 100644 --- a/python/packages/jumpstarter/jumpstarter/common/fls.py +++ b/python/packages/jumpstarter/jumpstarter/common/fls.py @@ -63,15 +63,14 @@ def download_fls(url: str, timeout: float = 30.0) -> str: try: logger.info(f"Downloading FLS binary from: {url}") - with urllib.request.urlopen(url, timeout=timeout) as response: - with open(temp_path, 'wb') as f: - while True: - chunk = response.read(8192) - if not chunk: - break - f.write(chunk) - f.flush() - os.fsync(f.fileno()) + with urllib.request.urlopen(url, timeout=timeout) as response, open(temp_path, 'wb') as f: + while True: + chunk = response.read(8192) + if not chunk: + break + f.write(chunk) + f.flush() + os.fsync(f.fileno()) # Set permissions on temp file before rename Path(temp_path).chmod(0o755) diff --git a/python/packages/jumpstarter/jumpstarter/common/fls_test.py b/python/packages/jumpstarter/jumpstarter/common/fls_test.py index d00173195..2bc67390d 100644 --- a/python/packages/jumpstarter/jumpstarter/common/fls_test.py +++ b/python/packages/jumpstarter/jumpstarter/common/fls_test.py @@ -51,13 +51,15 @@ def test_get_fls_binary_custom_url_security_check(): def test_get_fls_binary_with_version(): - with patch("jumpstarter.common.fls.download_fls", return_value="/tmp/fls-0.1.9") as mock_download: - with patch("jumpstarter.common.fls.get_fls_github_url", return_value="https://github.com/...") as mock_url: - result = get_fls_binary(fls_version="0.1.9") + with ( + patch("jumpstarter.common.fls.download_fls", return_value="/tmp/fls-0.1.9") as mock_download, + patch("jumpstarter.common.fls.get_fls_github_url", return_value="https://github.com/...") as mock_url, + ): + result = get_fls_binary(fls_version="0.1.9") - mock_url.assert_called_once_with("0.1.9") - mock_download.assert_called_once() - assert result == "/tmp/fls-0.1.9" + mock_url.assert_called_once_with("0.1.9") + mock_download.assert_called_once() + assert result == "/tmp/fls-0.1.9" def test_get_fls_binary_falls_back_to_path(): @@ -73,26 +75,30 @@ def test_download_fls_success(): mock_response.__enter__ = MagicMock(return_value=mock_response) mock_response.__exit__ = MagicMock(return_value=None) - with patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen: - with patch("tempfile.mkstemp", return_value=(99, "/tmp/fls-test")): - with patch("os.close") as mock_close: - with patch("pathlib.Path.chmod") as mock_chmod: - with patch("os.replace") as mock_replace: - with patch("builtins.open", mock_open()): - with patch("os.fsync"): - result = download_fls("https://example.com/fls") + with ( + patch("urllib.request.urlopen", return_value=mock_response) as mock_urlopen, + patch("tempfile.mkstemp", return_value=(99, "/tmp/fls-test")), + patch("os.close") as mock_close, + patch("pathlib.Path.chmod") as mock_chmod, + patch("os.replace") as mock_replace, + patch("builtins.open", mock_open()), + patch("os.fsync"), + ): + result = download_fls("https://example.com/fls") - mock_close.assert_called_once_with(99) - mock_urlopen.assert_called_once_with("https://example.com/fls", timeout=30.0) - mock_chmod.assert_called_once_with(0o755) - mock_replace.assert_called_once_with("/tmp/fls-test.part", "/tmp/fls-test") - assert result == "/tmp/fls-test" + mock_close.assert_called_once_with(99) + mock_urlopen.assert_called_once_with("https://example.com/fls", timeout=30.0) + mock_chmod.assert_called_once_with(0o755) + mock_replace.assert_called_once_with("/tmp/fls-test.part", "/tmp/fls-test") + assert result == "/tmp/fls-test" def test_download_fls_failure(): - with patch("urllib.request.urlopen", side_effect=Exception("Network error")): - with patch("tempfile.mkstemp", return_value=(99, "/tmp/fls-test")): - with patch("os.close"): - with patch("pathlib.Path.unlink"): - with pytest.raises(RuntimeError, match="Failed to download FLS"): - download_fls("https://example.com/fls") + with ( + patch("urllib.request.urlopen", side_effect=Exception("Network error")), + patch("tempfile.mkstemp", return_value=(99, "/tmp/fls-test")), + patch("os.close"), + patch("pathlib.Path.unlink"), + pytest.raises(RuntimeError, match="Failed to download FLS"), + ): + download_fls("https://example.com/fls") diff --git a/python/packages/jumpstarter/jumpstarter/common/grpc.py b/python/packages/jumpstarter/jumpstarter/common/grpc.py index f1c859b71..5fd6548a1 100644 --- a/python/packages/jumpstarter/jumpstarter/common/grpc.py +++ b/python/packages/jumpstarter/jumpstarter/common/grpc.py @@ -4,8 +4,9 @@ import os import socket import ssl +from collections.abc import Sequence from contextlib import contextmanager -from typing import Any, Sequence, Tuple +from typing import Any from urllib.parse import urlparse import grpc @@ -94,7 +95,7 @@ async def try_with_ip(ip_address: str): ip_address, port, ssl_context, parsed.hostname, timeout ) return (ip_address, result, None) - except Exception as e: + except Exception as e: # noqa: BLE001 return (ip_address, None, e) tasks = [] @@ -163,7 +164,7 @@ def aio_secure_channel( ) -def _override_default_grpc_options(grpc_options: dict[str, str | int] | None) -> Sequence[Tuple[str, Any]]: +def _override_default_grpc_options(grpc_options: dict[str, str | int] | None) -> Sequence[tuple[str, Any]]: defaults = ( ("grpc.lb_policy_name", "round_robin"), # we keep a low keepalive time to avoid idle timeouts on cloud load balancers @@ -190,12 +191,12 @@ def translate_grpc_exceptions(): # an error returned from our functions raise ConnectionError(f"grpc controller responded: {e.details()}") from None if e.code().name == "FAILED_PRECONDITION": - raise ConnectionError(e.details()) from None + raise ConnectionError(e.details() or "") from None else: raise ConnectionError("grpc error") from e except grpc.RpcError as e: raise ConnectionError("grpc error") from e except ValueError as e: raise ConfigurationError("grpc error") from e - except Exception as e: - raise e + except Exception: + raise diff --git a/python/packages/jumpstarter/jumpstarter/common/grpc_test.py b/python/packages/jumpstarter/jumpstarter/common/grpc_test.py index 42e22ad77..2dd1f8aa2 100644 --- a/python/packages/jumpstarter/jumpstarter/common/grpc_test.py +++ b/python/packages/jumpstarter/jumpstarter/common/grpc_test.py @@ -26,14 +26,13 @@ def test_user_options_override_defaults(): def test_translate_grpc_failed_precondition_preserves_details(): - with pytest.raises(ConnectionError, match="requested exporter is disabled"): - with translate_grpc_exceptions(): - raise grpc.aio.AioRpcError( - code=grpc.StatusCode.FAILED_PRECONDITION, - initial_metadata=None, - trailing_metadata=None, - details="requested exporter is disabled", - ) + with pytest.raises(ConnectionError, match="requested exporter is disabled"), translate_grpc_exceptions(): + raise grpc.aio.AioRpcError( + code=grpc.StatusCode.FAILED_PRECONDITION, + initial_metadata=None, # type: ignore[arg-type] + trailing_metadata=None, # type: ignore[arg-type] + details="requested exporter is disabled", + ) def _addr_info(*ips): @@ -54,7 +53,7 @@ def __getattr__(self, name): def _patch_resolver(getaddrinfo): def fake_get_running_loop(): - return _LoopWithFakeResolver(asyncio.events.get_running_loop(), getaddrinfo) + return _LoopWithFakeResolver(asyncio.events.get_running_loop(), getaddrinfo) # type: ignore[attr-defined] return patch("asyncio.get_running_loop", fake_get_running_loop) @@ -73,12 +72,11 @@ async def connect(ip_address, *_args, **_kwargs): raise OSError("connection refused") return b"-----BEGIN CERTIFICATE-----\n" - with _patch_resolver(getaddrinfo): - with patch( - "jumpstarter.common.grpc._try_connect_and_extract_cert", - connect, - ): - credentials = await _ssl_channel_credentials_insecure("example.com:443", timeout=5) + with _patch_resolver(getaddrinfo), patch( + "jumpstarter.common.grpc._try_connect_and_extract_cert", + connect, + ): + credentials = await _ssl_channel_credentials_insecure("example.com:443", timeout=5) assert credentials is not None @@ -87,18 +85,16 @@ async def test_resolution_failure_names_the_host(self): async def getaddrinfo(*_args, **_kwargs): raise socket.gaierror("Name or service not known") - with _patch_resolver(getaddrinfo): - with pytest.raises(ConnectionError, match="Failed resolving example.com"): - await _ssl_channel_credentials_insecure("example.com:443", timeout=5) + with _patch_resolver(getaddrinfo), pytest.raises(ConnectionError, match="Failed resolving example.com"): + await _ssl_channel_credentials_insecure("example.com:443", timeout=5) @pytest.mark.asyncio async def test_slow_resolver_is_reported_as_a_resolution_timeout(self): async def getaddrinfo(*_args, **_kwargs): await asyncio.sleep(10) - with _patch_resolver(getaddrinfo): - with pytest.raises(ConnectionError, match="Timeout resolving example.com"): - await _ssl_channel_credentials_insecure("example.com:443", timeout=0.05) + with _patch_resolver(getaddrinfo), pytest.raises(ConnectionError, match="Timeout resolving example.com"): + await _ssl_channel_credentials_insecure("example.com:443", timeout=0.05) @pytest.mark.asyncio async def test_connect_timeout_reports_the_resolved_ips(self): @@ -108,13 +104,14 @@ async def getaddrinfo(*_args, **_kwargs): async def never_connects(*_args, **_kwargs): await asyncio.sleep(10) - with _patch_resolver(getaddrinfo): - with patch("jumpstarter.common.grpc._try_connect_and_extract_cert", never_connects): - with pytest.raises( - ConnectionError, - match=r"Timeout connecting to example\.com:443.*resolved to 192\.0\.2\.1", - ): - await _ssl_channel_credentials_insecure("example.com:443", timeout=0.05) + with ( + _patch_resolver(getaddrinfo), + patch("jumpstarter.common.grpc._try_connect_and_extract_cert", never_connects),pytest.raises( + ConnectionError, + match=r"Timeout connecting to example\.com:443.*resolved to 192\.0\.2\.1", + ) + ): + await _ssl_channel_credentials_insecure("example.com:443", timeout=0.05) @pytest.mark.asyncio async def test_all_ips_failing_lists_the_errors(self): @@ -124,7 +121,9 @@ async def getaddrinfo(*_args, **_kwargs): async def refused(*_args, **_kwargs): raise OSError("connection refused") - with _patch_resolver(getaddrinfo): - with patch("jumpstarter.common.grpc._try_connect_and_extract_cert", refused): - with pytest.raises(ConnectionError, match="all IPs exhausted"): - await _ssl_channel_credentials_insecure("example.com:443", timeout=5) + with ( + _patch_resolver(getaddrinfo), + patch("jumpstarter.common.grpc._try_connect_and_extract_cert", refused), + pytest.raises(ConnectionError, match="all IPs exhausted"), + ): + await _ssl_channel_credentials_insecure("example.com:443", timeout=5) diff --git a/python/packages/jumpstarter/jumpstarter/common/importlib.py b/python/packages/jumpstarter/jumpstarter/common/importlib.py index 48a1b7bda..ebba01dab 100644 --- a/python/packages/jumpstarter/jumpstarter/common/importlib.py +++ b/python/packages/jumpstarter/jumpstarter/common/importlib.py @@ -54,12 +54,11 @@ def import_class(class_path: str, allow: list[str], unsafe: bool): while `import_class("example_package.some_module.fooclass", allow=["notexample_package.*"], unsafe=false)` throws MissingDriverError due to not matching the allow list """ - if not unsafe: - if not any(fnmatchcase(class_path, pattern) for pattern in allow): - raise MissingDriverError( - message=f"{class_path} doesn't match any of the allowed patterns", - class_path=class_path, - ) + if not unsafe and not any(fnmatchcase(class_path, pattern) for pattern in allow): + raise MissingDriverError( + message=f"{class_path} doesn't match any of the allowed patterns", + class_path=class_path, + ) try: module_path, class_name = class_path.rsplit(".", 1) except ValueError as e: diff --git a/python/packages/jumpstarter/jumpstarter/common/ipaddr.py b/python/packages/jumpstarter/jumpstarter/common/ipaddr.py index 70944048d..edaa9af0b 100644 --- a/python/packages/jumpstarter/jumpstarter/common/ipaddr.py +++ b/python/packages/jumpstarter/jumpstarter/common/ipaddr.py @@ -1,4 +1,5 @@ import asyncio +import asyncio.subprocess import logging import socket from ipaddress import ip_address @@ -18,7 +19,7 @@ def get_ip_address(logger: logging.Logger | None = None) -> str: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("192.175.48.1", 53)) # AS112 return s.getsockname()[0] - except Exception: + except Exception: # noqa: BLE001 if logger: logger.warning("Could not determine default IP address, falling back to 0.0.0.0") return "0.0.0.0" @@ -26,13 +27,15 @@ def get_ip_address(logger: logging.Logger | None = None) -> str: return address -async def get_minikube_ip(profile: str = None, minikube: str = "minikube"): +async def get_minikube_ip(profile: str | None = None, minikube: str = "minikube"): # Create the subprocess with optional profile cmd = [minikube, "ip"] if profile: cmd.extend(["-p", profile]) - process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + process = await asyncio.create_subprocess_exec( + cmd[0], *cmd[1:], stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) # Wait for it to complete and get the output stdout, stderr = await process.communicate() diff --git a/python/packages/jumpstarter/jumpstarter/common/oci.py b/python/packages/jumpstarter/jumpstarter/common/oci.py index f41d28014..e2a7c91fb 100644 --- a/python/packages/jumpstarter/jumpstarter/common/oci.py +++ b/python/packages/jumpstarter/jumpstarter/common/oci.py @@ -115,8 +115,7 @@ def _parse_registries_for_url(oci_url: str) -> tuple[str, ...]: configured ``unqualified-search-registries``. """ url = oci_url - if url.startswith("oci://"): - url = url[len("oci://") :] + url = url.removeprefix("oci://") # Strip digest references before parsing — "ubuntu@sha256:abc" would # otherwise have the colon corrupt port/tag disambiguation. @@ -136,9 +135,12 @@ def _parse_registries_for_url(oci_url: str) -> tuple[str, ...]: else: # namespace/image form (e.g. "library/ubuntu") — first segment has # no dot and isn't localhost, so it's not a registry hostname. - if "." not in registry and registry != "localhost": - if ":" not in registry or not registry.split(":", 1)[1].isdigit(): - return _get_unqualified_search_registries() + if ( + "." not in registry + and registry != "localhost" + and (":" not in registry or not registry.split(":", 1)[1].isdigit()) + ): + return _get_unqualified_search_registries() return (registry,) @@ -249,7 +251,7 @@ def _lookup_credentials_in_auth_data(auth_data: dict[str, Any], registry: str) - decoded = base64.b64decode(auth_b64, validate=True).decode("utf-8") username, password = decoded.split(":", 1) if username and password: - return OciCredentials(username=username, password=password) + return OciCredentials(username=username, password=SecretStr(password)) except (binascii.Error, ValueError, UnicodeDecodeError) as e: logger.warning("Failed to decode auth entry for %s: %s", key, e) @@ -330,7 +332,7 @@ def resolve_oci_credentials( # Level 1: Explicit arguments if username is not None or password is not None: try: - creds = OciCredentials(username=username, password=password) + creds = OciCredentials(username=username, password=SecretStr(password) if password is not None else None) except ValidationError: raise ValueError("OCI authentication requires both username and password") from None if creds.is_authenticated: @@ -342,7 +344,10 @@ def resolve_oci_credentials( if env_username is not None or env_password is not None: try: - creds = OciCredentials(username=env_username, password=env_password) + creds = OciCredentials( + username=env_username, + password=SecretStr(env_password) if env_password is not None else None, + ) except ValidationError: logger.warning( "Only one of OCI_USERNAME/OCI_PASSWORD is set; " diff --git a/python/packages/jumpstarter/jumpstarter/common/oci_test.py b/python/packages/jumpstarter/jumpstarter/common/oci_test.py index 2293fa567..320e5139b 100644 --- a/python/packages/jumpstarter/jumpstarter/common/oci_test.py +++ b/python/packages/jumpstarter/jumpstarter/common/oci_test.py @@ -243,22 +243,26 @@ def test_env_vars_take_priority(self, tmp_path): auth_path = tmp_path / "auth.json" auth_path.write_text(_make_auth_json({"quay.io": {"auth": _encode_auth("fileuser", "filepass")}})) - with patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username == "envuser" - assert result.password.get_secret_value() == "envpass" + with ( + patch.dict(os.environ, {"OCI_USERNAME": "envuser", "OCI_PASSWORD": "envpass"}), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username == "envuser" + assert result.password.get_secret_value() == "envpass" def test_falls_back_to_auth_file(self, tmp_path): auth_path = tmp_path / "auth.json" auth_path.write_text(_make_auth_json({"quay.io": {"auth": _encode_auth("fileuser", "filepass")}})) env_clean = {k: v for k, v in os.environ.items() if k not in ("OCI_USERNAME", "OCI_PASSWORD")} - with patch.dict(os.environ, env_clean, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username == "fileuser" - assert result.password.get_secret_value() == "filepass" + with ( + patch.dict(os.environ, env_clean, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username == "fileuser" + assert result.password.get_secret_value() == "filepass" def test_partial_env_falls_back_to_auth_file(self, tmp_path): """When only one env var is set, fall through to auth file instead of returning partial.""" @@ -268,11 +272,13 @@ def test_partial_env_falls_back_to_auth_file(self, tmp_path): # Only OCI_USERNAME set, OCI_PASSWORD not set env_partial = {k: v for k, v in os.environ.items() if k != "OCI_PASSWORD"} env_partial["OCI_USERNAME"] = "partialuser" - with patch.dict(os.environ, env_partial, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username == "fileuser" - assert result.password.get_secret_value() == "filepass" + with ( + patch.dict(os.environ, env_partial, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username == "fileuser" + assert result.password.get_secret_value() == "filepass" def test_partial_env_password_only_falls_back_to_auth_file(self, tmp_path): """When only OCI_PASSWORD is set, fall through to auth file instead of returning partial.""" @@ -281,11 +287,13 @@ def test_partial_env_password_only_falls_back_to_auth_file(self, tmp_path): env_partial = {k: v for k, v in os.environ.items() if k != "OCI_USERNAME"} env_partial["OCI_PASSWORD"] = "partialpass" - with patch.dict(os.environ, env_partial, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username == "fileuser" - assert result.password.get_secret_value() == "filepass" + with ( + patch.dict(os.environ, env_partial, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username == "fileuser" + assert result.password.get_secret_value() == "filepass" def test_whitespace_env_vars_fall_through_to_auth_file(self, tmp_path): """Whitespace-only env vars should not be treated as credentials.""" @@ -295,19 +303,23 @@ def test_whitespace_env_vars_fall_through_to_auth_file(self, tmp_path): env = {k: v for k, v in os.environ.items() if k not in ("OCI_USERNAME", "OCI_PASSWORD")} env["OCI_USERNAME"] = " " env["OCI_PASSWORD"] = " " - with patch.dict(os.environ, env, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username == "fileuser" - assert result.password.get_secret_value() == "filepass" + with ( + patch.dict(os.environ, env, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username == "fileuser" + assert result.password.get_secret_value() == "filepass" def test_returns_none_when_no_source(self): env_clean = {k: v for k, v in os.environ.items() if k not in ("OCI_USERNAME", "OCI_PASSWORD")} - with patch.dict(os.environ, env_clean, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert result.username is None - assert result.password is None + with ( + patch.dict(os.environ, env_clean, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert result.username is None + assert result.password is None class TestParseOciRegistryDigest: @@ -449,17 +461,17 @@ def test_empty_password_in_base64_falls_through(self, tmp_path): class TestOciCredentials: def test_fields(self): - creds = OciCredentials(username="user", password="pass") + creds = OciCredentials(username="user", password="pass") # type: ignore[arg-type] assert creds.username == "user" assert creds.password.get_secret_value() == "pass" def test_plain_password(self): - creds = OciCredentials(username="user", password="pass") + creds = OciCredentials(username="user", password="pass") # type: ignore[arg-type] assert creds.plain_password == "pass" assert OciCredentials().plain_password is None def test_is_authenticated(self): - assert OciCredentials(username="user", password="pass").is_authenticated + assert OciCredentials(username="user", password="pass").is_authenticated # type: ignore[arg-type] assert not OciCredentials().is_authenticated assert not OciCredentials(username=None, password=None).is_authenticated @@ -467,31 +479,31 @@ def test_rejects_asymmetric_at_construction(self): with pytest.raises(ValueError, match="both username and password"): OciCredentials(username="user", password=None) with pytest.raises(ValueError, match="both username and password"): - OciCredentials(username=None, password="pass") + OciCredentials(username=None, password="pass") # type: ignore[arg-type] def test_empty_strings_normalized_to_none(self): - creds = OciCredentials(username="", password="") + creds = OciCredentials(username="", password="") # type: ignore[arg-type] assert creds.username is None assert creds.password is None assert not creds.is_authenticated def test_username_with_empty_password_rejected(self): with pytest.raises(ValueError, match="both username and password"): - OciCredentials(username="user", password="") + OciCredentials(username="user", password="") # type: ignore[arg-type] def test_whitespace_strings_normalized_to_none(self): - creds = OciCredentials(username=" ", password=" ") + creds = OciCredentials(username=" ", password=" ") # type: ignore[arg-type] assert creds.username is None assert creds.password is None assert not creds.is_authenticated def test_strips_whitespace_from_credentials(self): - creds = OciCredentials(username=" user ", password=" pass ") + creds = OciCredentials(username=" user ", password=" pass ") # type: ignore[arg-type] assert creds.username == "user" assert creds.password.get_secret_value() == "pass" def test_frozen(self): - creds = OciCredentials(username="user", password="pass") + creds = OciCredentials(username="user", password="pass") # type: ignore[arg-type] with pytest.raises(ValidationError): creds.username = "other" @@ -500,11 +512,13 @@ def test_resolve_returns_oci_credentials_type(self, tmp_path): auth_path.write_text(_make_auth_json({"quay.io": {"auth": _encode_auth("user", "pass")}})) env_clean = {k: v for k, v in os.environ.items() if k not in ("OCI_USERNAME", "OCI_PASSWORD")} - with patch.dict(os.environ, env_clean, clear=True): - with patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]): - result = resolve_oci_credentials("oci://quay.io/org/image:latest") - assert isinstance(result, OciCredentials) - assert result.is_authenticated + with ( + patch.dict(os.environ, env_clean, clear=True), + patch("jumpstarter.common.oci._get_auth_file_paths", return_value=[auth_path]), + ): + result = resolve_oci_credentials("oci://quay.io/org/image:latest") + assert isinstance(result, OciCredentials) + assert result.is_authenticated class TestUnqualifiedSearchRegistries: diff --git a/python/packages/jumpstarter/jumpstarter/common/resources.py b/python/packages/jumpstarter/jumpstarter/common/resources.py index 457e2d46e..0794807cf 100644 --- a/python/packages/jumpstarter/jumpstarter/common/resources.py +++ b/python/packages/jumpstarter/jumpstarter/common/resources.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal, Union +from typing import Annotated, Literal from uuid import UUID from pydantic import BaseModel, Field, Json @@ -18,7 +18,7 @@ class PresignedRequestResource(BaseModel): Resource = Annotated[ - Union[ClientStreamResource, PresignedRequestResource], + ClientStreamResource | PresignedRequestResource, Field(discriminator="kind"), ] diff --git a/python/packages/jumpstarter/jumpstarter/common/storage.py b/python/packages/jumpstarter/jumpstarter/common/storage.py index f332691f2..3c067fd7e 100644 --- a/python/packages/jumpstarter/jumpstarter/common/storage.py +++ b/python/packages/jumpstarter/jumpstarter/common/storage.py @@ -27,22 +27,22 @@ async def wait_for_storage_device( # noqa: C901 case "rb": fd = os.open(storage_device, os.O_RDONLY) case _: - raise ValueError("invalid mode: {}".format(mode)) + raise ValueError(f"invalid mode: {mode}") with os.fdopen(fd, mode): # to prevent fd from leaking if os.lseek(fd, 0, os.SEEK_END) > 0: if logger: - logger.info("storage device {} is ready".format(storage_device)) + logger.info(f"storage device {storage_device} is ready") break if logger: - logger.debug("waiting for storage device {} to have a nonzero size".format(storage_device)) + logger.debug(f"waiting for storage device {storage_device} to have a nonzero size") except FileNotFoundError: if logger: - logger.debug("waiting for storage device {} to appear".format(storage_device)) + logger.debug(f"waiting for storage device {storage_device} to appear") except OSError as e: match e.errno: case errno.ENOMEDIUM | errno.EIO: if logger: - logger.debug("waiting for storage device {} to be ready".format(storage_device)) + logger.debug(f"waiting for storage device {storage_device} to be ready") case _: raise @@ -78,10 +78,7 @@ async def write_to_storage_device( total_bytes += len(chunk) if total_bytes > next_print: logger.info( - "written {} MB to storage device {}".format( - total_bytes / (1024 * 1024), - storage_device, - ) + f"written {total_bytes / (1024 * 1024)} MB to storage device {storage_device}" ) next_print += 50 * 1024 * 1024 @@ -89,7 +86,7 @@ async def write_to_storage_device( while True: try: if logger: - logger.info("fsyncing storage device {}, please wait".format(storage_device)) + logger.info(f"fsyncing storage device {storage_device}, please wait") await to_thread.run_sync(os.fsync, file.fileno()) except OSError as e: if e.errno == errno.EIO: @@ -126,9 +123,6 @@ async def read_from_storage_device( total_bytes += len(chunk) if total_bytes > next_print: logger.info( - "read {} MB from storage device {}".format( - total_bytes / (1024 * 1024), - storage_device, - ) + f"read {total_bytes / (1024 * 1024)} MB from storage device {storage_device}" ) next_print += 50 * 1024 * 1024 diff --git a/python/packages/jumpstarter/jumpstarter/common/streams.py b/python/packages/jumpstarter/jumpstarter/common/streams.py index 8cdc02330..61d505c1e 100644 --- a/python/packages/jumpstarter/jumpstarter/common/streams.py +++ b/python/packages/jumpstarter/jumpstarter/common/streams.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from typing import Annotated, Literal, Union +from typing import Annotated, Literal from uuid import UUID import grpc @@ -24,7 +24,7 @@ class DriverStreamRequest(BaseModel): StreamRequest = Annotated[ - Union[ResourceStreamRequest, DriverStreamRequest], + ResourceStreamRequest | DriverStreamRequest, Field(discriminator="kind"), ] @@ -43,6 +43,5 @@ async def connect_router_stream(endpoint, token, stream, tls_config, grpc_option async with aio_secure_channel(endpoint, credentials, grpc_options) as channel: router = router_pb2_grpc.RouterServiceStub(channel) context = router.Stream(metadata=()) - async with RouterStream(context=context) as s: - async with forward_stream(s, stream): - yield + async with RouterStream(context=context) as s, forward_stream(s, stream): + yield diff --git a/python/packages/jumpstarter/jumpstarter/common/tempfile.py b/python/packages/jumpstarter/jumpstarter/common/tempfile.py index b8aebdba3..dee6b017a 100644 --- a/python/packages/jumpstarter/jumpstarter/common/tempfile.py +++ b/python/packages/jumpstarter/jumpstarter/common/tempfile.py @@ -22,14 +22,13 @@ async def TemporaryUnixListener(handler, path: PathLike | None = None): else: cm = TemporarySocket() - with cm as path: - async with await create_unix_listener(path) as listener: - async with create_task_group() as tg: - tg.start_soon(listener.serve, handler, tg) - try: - yield path - finally: - tg.cancel_scope.cancel() + with cm as resolved_path: + async with await create_unix_listener(resolved_path) as listener, create_task_group() as tg: + tg.start_soon(listener.serve, handler, tg) + try: + yield resolved_path + finally: + tg.cancel_scope.cancel() @asynccontextmanager @@ -42,10 +41,9 @@ async def TemporaryTcpListener( family=family, backlog=backlog, reuse_port=reuse_port, - ) as listener: - async with create_task_group() as tg: - tg.start_soon(listener.serve, handler, tg) - try: - yield listener.extra(SocketAttribute.local_address) - finally: - tg.cancel_scope.cancel() + ) as listener, create_task_group() as tg: + tg.start_soon(listener.serve, handler, tg) + try: + yield listener.extra(SocketAttribute.local_address) + finally: + tg.cancel_scope.cancel() diff --git a/python/packages/jumpstarter/jumpstarter/common/types.py b/python/packages/jumpstarter/jumpstarter/common/types.py index fb1104920..4200e2d75 100644 --- a/python/packages/jumpstarter/jumpstarter/common/types.py +++ b/python/packages/jumpstarter/jumpstarter/common/types.py @@ -1,21 +1,18 @@ """Type aliases for gRPC and Protobuf types.""" -from typing import TYPE_CHECKING, TypeAlias from grpc.aio import Channel from jumpstarter_protocol import jumpstarter_pb2_grpc, router_pb2_grpc # Stub type aliases (the generic Stub classes work for both sync and async) -ExporterStub: TypeAlias = jumpstarter_pb2_grpc.ExporterServiceStub -RouterStub: TypeAlias = router_pb2_grpc.RouterServiceStub -ControllerStub: TypeAlias = jumpstarter_pb2_grpc.ControllerServiceStub +type ExporterStub = jumpstarter_pb2_grpc.ExporterServiceStub +type RouterStub = router_pb2_grpc.RouterServiceStub +type ControllerStub = jumpstarter_pb2_grpc.ControllerServiceStub # Channel type alias -AsyncChannel: TypeAlias = Channel +type AsyncChannel = Channel # Async stub type aliases are only available for type checking (defined in .pyi files) -if TYPE_CHECKING: - pass __all__ = [ "AsyncChannel", diff --git a/python/packages/jumpstarter/jumpstarter/common/utils.py b/python/packages/jumpstarter/jumpstarter/common/utils.py index ab7954ea9..113973ef5 100644 --- a/python/packages/jumpstarter/jumpstarter/common/utils.py +++ b/python/packages/jumpstarter/jumpstarter/common/utils.py @@ -51,14 +51,16 @@ async def serve_async(root_device: "Driver", portal: BlockingPortal, stack: Exit @contextmanager def serve(root_device: "Driver"): - with start_blocking_portal() as portal: - with ExitStack() as stack: - with portal.wrap_async_context_manager(serve_async(root_device, portal, stack)) as client: - try: - yield client - finally: - if hasattr(client, "close"): - client.close() + with ( + start_blocking_portal() as portal, + ExitStack() as stack, + portal.wrap_async_context_manager(serve_async(root_device, portal, stack)) as client, + ): + try: + yield client + finally: + if hasattr(client, "close"): + client.close() ANSI_GRAY = "\\[\\e[90m\\]" diff --git a/python/packages/jumpstarter/jumpstarter/common/utils_test.py b/python/packages/jumpstarter/jumpstarter/common/utils_test.py index 111a0190b..2cb240933 100644 --- a/python/packages/jumpstarter/jumpstarter/common/utils_test.py +++ b/python/packages/jumpstarter/jumpstarter/common/utils_test.py @@ -52,7 +52,7 @@ def test_launch_shell_no_motd_for_command(tmp_path, monkeypatch, capfd): allow=["*"], unsafe=False, use_profiles=False, - command=(shutil.which("true"),), + command=(shutil.which("true"),), # type: ignore[arg-type] motd="Welcome to my-exporter!", ) assert exit_code == 0 @@ -473,9 +473,11 @@ def test_resolve_drivers_config_propagates_unexpected_errors(monkeypatch): monkeypatch.delenv("JMP_DRIVERS_ALLOW", raising=False) monkeypatch.delenv("JMP_DRIVERS_UNSAFE", raising=False) - with patch("jumpstarter.config.user.UserConfigV1Alpha1.load", side_effect=RuntimeError("unexpected")): - with pytest.raises(RuntimeError, match="unexpected"): - _resolve_drivers_config() + with ( + patch("jumpstarter.config.user.UserConfigV1Alpha1.load", side_effect=RuntimeError("unexpected")), + pytest.raises(RuntimeError, match="unexpected"), + ): + _resolve_drivers_config() def test_launch_shell_logs_exit_status(tmp_path, caplog): diff --git a/python/packages/jumpstarter/jumpstarter/config/client.py b/python/packages/jumpstarter/jumpstarter/config/client.py index 1524da109..ddfb8bebd 100644 --- a/python/packages/jumpstarter/jumpstarter/config/client.py +++ b/python/packages/jumpstarter/jumpstarter/config/client.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from functools import wraps from pathlib import Path -from typing import Annotated, ClassVar, Literal, Optional, Self +from typing import Annotated, ClassVar, Literal, Self import grpc import yaml @@ -155,11 +155,10 @@ def lease( lease_name: str | None = None, duration: timedelta = timedelta(minutes=30), ): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager( - self.lease_async(selector, exporter_name, lease_name, duration, portal) - ) as lease: - yield lease + with start_blocking_portal() as portal, portal.wrap_async_context_manager( + self.lease_async(selector, exporter_name, lease_name, duration, portal) + ) as lease: + yield lease @_blocking_compat @_handle_connection_error @@ -230,11 +229,10 @@ async def list_exporters( leases_response = await self._collect_all_leases(svc, page_size=page_size) lease_map = {} for lease in leases_response.leases: - if lease.exporter and lease.effective_begin_time: - if lease.conditions: - latest_condition = lease.conditions[-1] - if latest_condition.type == "Ready" and latest_condition.status == "True": - lease_map[lease.exporter] = lease + if lease.exporter and lease.effective_begin_time and lease.conditions: + latest_condition = lease.conditions[-1] + if latest_condition.type == "Ready" and latest_condition.status == "True": + lease_map[lease.exporter] = lease result.include_leases = True result.exporters = [ @@ -433,7 +431,7 @@ def load(cls, alias: str) -> Self: return cls.from_file(path) @classmethod - def save(cls, config: Self, path: Optional[os.PathLike] = None) -> Path: + def save(cls, config: Self, path: str | os.PathLike | None = None) -> Path: """Saves a client config as YAML.""" # Ensure the clients dir exists if path is None: @@ -534,7 +532,7 @@ def delete(cls, alias: str) -> Path: class ClientConfigListV1Alpha1(BaseModel): api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1") - current_config: Optional[str] = Field(alias="currentConfig") + current_config: str | None = Field(alias="currentConfig") items: list[ClientConfigV1Alpha1] kind: Literal["ClientConfigList"] = Field(default="ClientConfigList") diff --git a/python/packages/jumpstarter/jumpstarter/config/client_config_test.py b/python/packages/jumpstarter/jumpstarter/config/client_config_test.py index 37d47e732..0956835e8 100644 --- a/python/packages/jumpstarter/jumpstarter/config/client_config_test.py +++ b/python/packages/jumpstarter/jumpstarter/config/client_config_test.py @@ -1,6 +1,6 @@ import os import tempfile -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import AsyncMock, Mock, patch @@ -179,23 +179,15 @@ def test_client_config_load(): with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("") f.close() - with patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as get_path_mock: - with patch.object( - ClientConfigV1Alpha1, - "from_file", - return_value=ClientConfigV1Alpha1( - alias="another", - metadata=ObjectMeta(namespace="default", name="another"), - endpoint="abc", - token="123", - drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), - ), - ) as from_file_mock: - value = ClientConfigV1Alpha1.load("another") - assert value.alias == "another" - get_path_mock.assert_called_once_with("another") - from_file_mock.assert_called_once_with(Path(f.name)) - os.unlink(f.name) + with ( + patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as get_path_mock, + patch.object( ClientConfigV1Alpha1, "from_file", return_value=ClientConfigV1Alpha1( alias="another", metadata=ObjectMeta(namespace="default", name="another"), endpoint="abc", token="123", drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ), ) as from_file_mock, # noqa: E501 + ): + value = ClientConfigV1Alpha1.load("another") + assert value.alias == "another" + get_path_mock.assert_called_once_with("another") + from_file_mock.assert_called_once_with(Path(f.name)) + os.unlink(f.name) def test_client_config_load_not_found_raises(): @@ -232,12 +224,14 @@ def test_client_config_save(monkeypatch: pytest.MonkeyPatch): shell=ShellConfigV1Alpha1(use_profiles=False), ) with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - with patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as _get_path_mock: - with patch.object(ClientConfigV1Alpha1, "ensure_exists"): - ClientConfigV1Alpha1.save(config) - with open(f.name) as loaded: - value = loaded.read() - assert value == CLIENT_CONFIG + with ( + patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as _get_path_mock, + patch.object(ClientConfigV1Alpha1, "ensure_exists"), + ): + ClientConfigV1Alpha1.save(config) + with open(f.name) as loaded: + value = loaded.read() + assert value == CLIENT_CONFIG _get_path_mock.assert_called_once_with("testclient") os.unlink(f.name) @@ -399,13 +393,15 @@ def test_client_config_list_not_found_returns_empty(monkeypatch: pytest.MonkeyPa def test_client_config_delete(): - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - with patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as _get_path_mock: - f.write("") - f.close() - ClientConfigV1Alpha1.delete("testclient") - _get_path_mock.assert_called_once_with("testclient") - assert os.path.exists(f.name) is False + with ( + tempfile.NamedTemporaryFile(mode="w", delete=False) as f, + patch.object(ClientConfigV1Alpha1, "_get_path", return_value=Path(f.name)) as _get_path_mock, + ): + f.write("") + f.close() + ClientConfigV1Alpha1.delete("testclient") + _get_path_mock.assert_called_once_with("testclient") + assert os.path.exists(f.name) is False def test_client_config_delete_does_not_exist_raises(): @@ -695,7 +691,7 @@ async def test_list_exporters_with_leases_preserves_exporter_fields(): duration=timedelta(hours=1), client="c", exporter="exporter-a", - effective_begin_time=datetime(2026, 1, 1, tzinfo=timezone.utc), + effective_begin_time=datetime(2026, 1, 1, tzinfo=UTC), conditions=[condition], ) diff --git a/python/packages/jumpstarter/jumpstarter/config/exporter.py b/python/packages/jumpstarter/jumpstarter/config/exporter.py index 6d57c9434..c28fe2ee3 100644 --- a/python/packages/jumpstarter/jumpstarter/config/exporter.py +++ b/python/packages/jumpstarter/jumpstarter/config/exporter.py @@ -5,7 +5,7 @@ import tempfile from contextlib import asynccontextmanager, contextmanager, suppress from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Self +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self import grpc import yaml @@ -109,7 +109,7 @@ class ExporterConfigV1Alpha1DriverInstance(RootModel): | ExporterConfigV1Alpha1DriverInstanceProxy ) - def instantiate(self) -> "Driver": + def instantiate(self) -> Driver: match self.root: case ExporterConfigV1Alpha1DriverInstanceBase(): try: @@ -139,6 +139,8 @@ def instantiate(self) -> "Driver": from jumpstarter_driver_composite.driver import Proxy return Proxy(ref=self.root.ref) + case _: + raise ValueError(f"Unknown driver instance type: {type(self.root)}") @classmethod def from_path(cls, path: str) -> ExporterConfigV1Alpha1DriverInstance: @@ -274,7 +276,7 @@ def dump_yaml(self, config: Self) -> str: ) @classmethod - def save(cls, config: Self, path: Optional[str] = None) -> Path: + def save(cls, config: Self, path: str | None = None) -> Path: """Save the config to disk, defaulting to the user config dir when no path is given.""" # Set the config path before saving if path is None: @@ -327,11 +329,11 @@ async def serve_unix_async(self): from jumpstarter.exporter import Session with Session( - root_device=ExporterConfigV1Alpha1DriverInstance( - type="jumpstarter_driver_composite.driver.Composite", - description=self.description, - children=self.export, - ).instantiate(), + root_device=ExporterConfigV1Alpha1DriverInstance.model_validate({ + "type": "jumpstarter_driver_composite.driver.Composite", + "description": self.description, + "children": self.export, + }).instantiate(), motd=self.motd, ) as session: async with session.serve_unix_async() as path: @@ -341,9 +343,8 @@ async def serve_unix_async(self): @contextmanager def serve_unix(self): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(self.serve_unix_async()) as path: - yield path + with start_blocking_portal() as portal, portal.wrap_async_context_manager(self.serve_unix_async()) as path: + yield path @asynccontextmanager async def create_exporter(self, *, standalone: bool = False): @@ -385,11 +386,11 @@ async def dummy_channel_factory() -> grpc.aio.Channel: token=self.token or "", exporter_name=self.metadata.name, channel_factory=dummy_channel_factory if standalone else channel_factory, - device_factory=ExporterConfigV1Alpha1DriverInstance( - type="jumpstarter_driver_composite.driver.Composite", - description=self.description, - children=self.export, - ).instantiate, + device_factory=ExporterConfigV1Alpha1DriverInstance.model_validate({ + "type": "jumpstarter_driver_composite.driver.Composite", + "description": self.description, + "children": self.export, + }).instantiate, tls=self.tls, grpc_options=self.grpcOptions, hook_executor=hook_executor, diff --git a/python/packages/jumpstarter/jumpstarter/config/exporter_test.py b/python/packages/jumpstarter/jumpstarter/config/exporter_test.py index a51af5dd5..ff1b33b18 100644 --- a/python/packages/jumpstarter/jumpstarter/config/exporter_test.py +++ b/python/packages/jumpstarter/jumpstarter/config/exporter_test.py @@ -54,7 +54,7 @@ def test_exporter_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): config = ExporterConfigV1Alpha1.load("test") - assert config == ExporterConfigV1Alpha1( + assert config == ExporterConfigV1Alpha1( # type: ignore[call-arg] alias="test", apiVersion="jumpstarter.dev/v1alpha1", kind="ExporterConfig", @@ -63,9 +63,9 @@ def test_exporter_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): token="dGhpc2lzYXRva2VuLTEyMzQxMjM0MTIzNEyMzQtc2Rxd3Jxd2VycXdlcnF3ZXJxd2VyLTEyMzQxMjM0MTIz", tls=TLSConfigV1Alpha1(ca="cacertificatedata", insecure=True), export={ - "power": ExporterConfigV1Alpha1DriverInstance( - type="jumpstarter_driver_power.driver.PduPower", - config={ + "power": ExporterConfigV1Alpha1DriverInstance.model_validate({ + "type": "jumpstarter_driver_power.driver.PduPower", + "config": { "host": "192.168.1.111", "port": 1234, "auth": { @@ -73,27 +73,27 @@ def test_exporter_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): "password": "secret", }, }, - ), - "serial": ExporterConfigV1Alpha1DriverInstance( - type="jumpstarter_driver_pyserial.driver.Pyserial", - config={ + }), + "serial": ExporterConfigV1Alpha1DriverInstance.model_validate({ + "type": "jumpstarter_driver_pyserial.driver.Pyserial", + "config": { "port": "/dev/ttyUSB0", "baudrate": 115200, }, - ), - "nested": ExporterConfigV1Alpha1DriverInstance( - children={ - "custom": ExporterConfigV1Alpha1DriverInstance( - type="vendorpackage.CustomDriver", - children={}, - config={ + }), + "nested": ExporterConfigV1Alpha1DriverInstance.model_validate({ + "children": { + "custom": { + "type": "vendorpackage.CustomDriver", + "children": {}, + "config": { "hello": "world", }, - ) + } }, - ), + }), }, - config={}, + config={}, # type: ignore[call-arg] path=path, ) diff --git a/python/packages/jumpstarter/jumpstarter/config/user.py b/python/packages/jumpstarter/jumpstarter/config/user.py index 343e691f7..a59987ec1 100644 --- a/python/packages/jumpstarter/jumpstarter/config/user.py +++ b/python/packages/jumpstarter/jumpstarter/config/user.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import Annotated, ClassVar, Literal, Optional, Self +from typing import Annotated, ClassVar, Literal, Self import yaml from pydantic import BaseModel, ConfigDict, Field, PlainSerializer @@ -82,14 +82,14 @@ def load_or_create(cls) -> Self: return cls.load() @classmethod - def save(cls, config: Self, path: Optional[str] = None) -> Path: + def save(cls, config: Self, path: str | None = None) -> Path: """Save a user config as YAML.""" with open(path or cls.USER_CONFIG_PATH, "w") as f: yaml.safe_dump(config.model_dump(mode="json", by_alias=True), f, sort_keys=False) - return path or cls.USER_CONFIG_PATH + return Path(path) if path is not None else cls.USER_CONFIG_PATH - def use_client(self, name: Optional[str]) -> Path | None: + def use_client(self, name: str | None) -> Path | None: """Updates the current client and saves the user config.""" if name is not None: self.config.current_client = ClientConfigV1Alpha1.load(name) diff --git a/python/packages/jumpstarter/jumpstarter/config/user_config_test.py b/python/packages/jumpstarter/jumpstarter/config/user_config_test.py index 3a583b13a..c1428d7dc 100644 --- a/python/packages/jumpstarter/jumpstarter/config/user_config_test.py +++ b/python/packages/jumpstarter/jumpstarter/config/user_config_test.py @@ -33,15 +33,14 @@ def test_user_config_load(monkeypatch: pytest.MonkeyPatch): token="123", drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ), - ) as mock_load: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write(USER_CONFIG) - f.close() - monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) - config = UserConfigV1Alpha1.load() - mock_load.assert_called_once_with("testclient") - assert config.config.current_client.alias == "testclient" - os.unlink(f.name) + ) as mock_load, tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write(USER_CONFIG) + f.close() + monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) + config = UserConfigV1Alpha1.load() + mock_load.assert_called_once_with("testclient") + assert config.config.current_client.alias == "testclient" + os.unlink(f.name) def test_user_config_load_does_not_exist(monkeypatch: pytest.MonkeyPatch): @@ -65,15 +64,14 @@ def test_user_config_load_no_current_client(monkeypatch: pytest.MonkeyPatch): token="123", drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ), - ) as mock_load: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write(USER_CONFIG) - f.close() - monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) - config = UserConfigV1Alpha1.load() - mock_load.assert_not_called() - assert config.config.current_client is None - os.unlink(f.name) + ) as mock_load, tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write(USER_CONFIG) + f.close() + monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) + config = UserConfigV1Alpha1.load() + mock_load.assert_not_called() + assert config.config.current_client is None + os.unlink(f.name) def test_user_config_load_current_client_empty(monkeypatch: pytest.MonkeyPatch): @@ -92,15 +90,14 @@ def test_user_config_load_current_client_empty(monkeypatch: pytest.MonkeyPatch): token="123", drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ), - ) as mock_load: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write(USER_CONFIG) - f.close() - monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) - config = UserConfigV1Alpha1.load() - mock_load.assert_not_called() - assert config.config.current_client is None - os.unlink(f.name) + ) as mock_load, tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write(USER_CONFIG) + f.close() + monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) + config = UserConfigV1Alpha1.load() + mock_load.assert_not_called() + assert config.config.current_client is None + os.unlink(f.name) def test_user_config_load_invalid_api_version_raises(monkeypatch: pytest.MonkeyPatch): @@ -147,32 +144,32 @@ def test_user_config_load_no_config_raises(monkeypatch: pytest.MonkeyPatch): def test_user_config_load_or_create_config_exists(): - with patch.object(UserConfigV1Alpha1, "exists", return_value=True) as mock_exists: - with patch.object( - UserConfigV1Alpha1, - "load", - return_value=UserConfigV1Alpha1(config=UserConfigV1Alpha1Config(current_client=None)), - ) as mock_load: - _ = UserConfigV1Alpha1.load_or_create() - mock_exists.assert_called_once() - mock_load.assert_called_once() + with patch.object(UserConfigV1Alpha1, "exists", return_value=True) as mock_exists, patch.object( + UserConfigV1Alpha1, + "load", + return_value=UserConfigV1Alpha1(config=UserConfigV1Alpha1Config(current_client=None)), + ) as mock_load: + _ = UserConfigV1Alpha1.load_or_create() + mock_exists.assert_called_once() + mock_load.assert_called_once() def test_user_config_load_or_create_dir_exists(): - with patch.object(UserConfigV1Alpha1, "exists", return_value=False) as mock_exists: - with patch.object(os.path, "exists", return_value=True): - with patch.object(UserConfigV1Alpha1, "save") as mock_save: - _ = UserConfigV1Alpha1.load_or_create() - mock_exists.assert_called_once() - mock_save.assert_called_once_with( - UserConfigV1Alpha1(config=UserConfigV1Alpha1Config(current_client=None)) - ) + with ( + patch.object(UserConfigV1Alpha1, "exists", return_value=False) as mock_exists, + patch.object(os.path, "exists", return_value=True),patch.object(UserConfigV1Alpha1, "save") as mock_save + ): + _ = UserConfigV1Alpha1.load_or_create() + mock_exists.assert_called_once() + mock_save.assert_called_once_with( + UserConfigV1Alpha1(config=UserConfigV1Alpha1Config(current_client=None)) + ) def test_user_config_load_or_create_dir_does_not_exist(): with tempfile.TemporaryDirectory() as d: - UserConfigV1Alpha1.BASE_CONFIG_PATH = f"{d}/jumpstarter" - UserConfigV1Alpha1.USER_CONFIG_PATH = f"{d}/jumpstarter/config.yaml" + UserConfigV1Alpha1.BASE_CONFIG_PATH = f"{d}/jumpstarter" # type: ignore[assignment] + UserConfigV1Alpha1.USER_CONFIG_PATH = f"{d}/jumpstarter/config.yaml" # type: ignore[assignment] with patch.object(UserConfigV1Alpha1, "save") as mock_save: _ = UserConfigV1Alpha1.load_or_create() mock_save.assert_called_once_with(UserConfigV1Alpha1(config=UserConfigV1Alpha1Config(current_client=None))) @@ -236,27 +233,26 @@ def test_user_config_use_client(monkeypatch: pytest.MonkeyPatch): token="123", drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ), - ) as mock_load: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) - config = UserConfigV1Alpha1( - config=UserConfigV1Alpha1Config( - current_client=ClientConfigV1Alpha1( - alias="another", - metadata=ObjectMeta(namespace="default", name="testclient"), - endpoint="abc", - token="123", - drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), - ) + ) as mock_load, tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + monkeypatch.setattr(UserConfigV1Alpha1, "USER_CONFIG_PATH", f.name) + config = UserConfigV1Alpha1( + config=UserConfigV1Alpha1Config( + current_client=ClientConfigV1Alpha1( + alias="another", + metadata=ObjectMeta(namespace="default", name="testclient"), + endpoint="abc", + token="123", + drivers=ClientConfigV1Alpha1Drivers(allow=[], unsafe=False), ) ) - config.use_client("testclient") - with open(f.name) as loaded: - value = loaded.read() - assert value == USER_CONFIG - mock_load.assert_called_once_with("testclient") - assert config.config.current_client.alias == "testclient" - os.unlink(f.name) + ) + config.use_client("testclient") + with open(f.name) as loaded: + value = loaded.read() + assert value == USER_CONFIG + mock_load.assert_called_once_with("testclient") + assert config.config.current_client.alias == "testclient" + os.unlink(f.name) def test_user_config_use_client_none(monkeypatch: pytest.MonkeyPatch): diff --git a/python/packages/jumpstarter/jumpstarter/driver/base.py b/python/packages/jumpstarter/jumpstarter/driver/base.py index 0cad0c3e6..05a770bea 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/base.py +++ b/python/packages/jumpstarter/jumpstarter/driver/base.py @@ -246,7 +246,7 @@ async def DriverCall(self, request, context): # Propagate context.abort() from lookup/handlers without recording # metrics (avoids client-controlled operation label cardinality). raise - except Exception as e: + except Exception as e: # noqa: BLE001 await self._handle_driver_exception(e, op, started, context) async def StreamingDriverCall(self, request, context): @@ -289,7 +289,7 @@ async def StreamingDriverCall(self, request, context): # Propagate context.abort() from lookup/handlers without recording # metrics (avoids client-controlled operation label cardinality). raise - except Exception as e: + except Exception as e: # noqa: BLE001 await self._handle_driver_exception(e, op, started, context) @asynccontextmanager @@ -377,7 +377,7 @@ def _redact_url(url: str) -> str: """Redact query parameters from a URL to avoid leaking credentials in logs.""" parsed = urlparse(url) if parsed.query: - return urlunparse(parsed._replace(query="[REDACTED]")) + return urlunparse(parsed._replace(query="[REDACTED]")) # type: ignore[return-value] return url _SENSITIVE_HEADER_PREFIXES = ("authorization", "cookie", "proxy-authorization", "x-amz-", "x-ms-", "x-goog-") @@ -436,9 +436,8 @@ async def _resource_from_presigned(self, headers, url: str, method: str, timeout async with aiohttp.request( method, self._make_url(url), headers=headers, raise_for_status=True, data=remote, timeout=client_timeout, - ) as _resp: - async with stream: - yield ProgressStream(stream=stream, logging=True) + ) as _resp, stream: + yield ProgressStream(stream=stream, logging=True) case _: # INVARIANT: method is always one of GET or PUT, see PresignedRequestResource raise ValueError("unreachable") diff --git a/python/packages/jumpstarter/jumpstarter/exporter/__init__.py b/python/packages/jumpstarter/jumpstarter/exporter/__init__.py index f91b40a0a..8a11cd691 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/__init__.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/__init__.py @@ -1,4 +1,4 @@ from .exporter import Exporter from .session import Session -__all__ = ["Session", "Exporter"] +__all__ = ["Exporter", "Session"] diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter.py index af7fe210e..08eb5e016 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Self import anyio +import anyio.lowlevel import grpc from anyio import ( AsyncContextManagerMixin, @@ -18,6 +19,7 @@ create_task_group, move_on_after, sleep, + to_thread, ) from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream @@ -115,7 +117,7 @@ def shutdown_runtime_sidecar( socket is configured (non-sidecar / InPlaceReuse hosts) or shutdown failed. Callers on the async event loop must offload this via - ``await anyio.to_thread.run_sync(shutdown_runtime_sidecar)``. + ``await to_thread.run_sync(shutdown_runtime_sidecar)``. """ import os import subprocess @@ -651,11 +653,12 @@ async def _retry_rpc( ) await anyio.sleep(backoff) continue - logger.error("Failed to %s: %s", description, e) + logger.exception("Failed to %s", description) return False, e.code() - except Exception as e: + except Exception as e: # noqa: BLE001 logger.error("Failed to %s: %s", description, e) return False, None + return False, None async def _send_report_status_rpc(self, request: jumpstarter_pb2.ReportStatusRequest) -> bool: """Send ReportStatus RPC to the controller with retry on transient errors. @@ -823,8 +826,8 @@ async def _unregister_with_controller(self): finally: with CancelScope(shield=True): await channel.close() - except Exception as e: - logger.error("Error during controller unregistration: %s", e, exc_info=True) + except Exception as e: # noqa: BLE001 + logger.error("Error during controller unregistration: %s", e) @asynccontextmanager async def __asynccontextmanager__(self) -> AsyncGenerator[Self]: @@ -833,8 +836,8 @@ async def __asynccontextmanager__(self) -> AsyncGenerator[Self]: finally: try: await self._unregister_with_controller() - except Exception as e: - logger.error("Error during exporter cleanup: %s", e, exc_info=True) + except Exception: + logger.exception("Error during exporter cleanup") # Don't re-raise to avoid masking the original exception async def _handle_client_conn( @@ -864,7 +867,7 @@ async def _handle_client_conn( logger.debug("Connected to session, bridging to router at %s", endpoint) async with connect_router_stream(endpoint, token, stream, tls_config, grpc_options): logger.debug("Router stream established, forwarding traffic") - except Exception as e: + except Exception as e: # noqa: BLE001 logger.warning("Failed to handle client connection: %s", e) async def _handle_end_session(self, lease_context: LeaseContext) -> None: @@ -927,7 +930,7 @@ async def _wait_lease_end(): else: logger.debug("No afterLease hook configured or no client, transitioning to AVAILABLE") await self._report_status(ExporterStatus.AVAILABLE, "Available for new lease") - except Exception as e: + except Exception as e: # noqa: BLE001 logger.error("Error running afterLease hook via EndSession: %s", e) finally: # Signal that the hook is done (whether it ran or not) @@ -1097,7 +1100,7 @@ async def handle_lease(self, lease_name: str, tg: TaskGroup, lease_scope: LeaseC # before serve() gets a chance to set lease_ended (anyio's receive() # always checkpoints, even when data is buffered). Inside the try so # cancellation here still runs fallback cleanup. - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() # Fast path: if the lease is already ended (stale lease from backlog # when the exporter couldn't keep up with lease churn), skip session @@ -1222,7 +1225,7 @@ async def process_connections(): # _lease_context. This task only sets events on its own LeaseContext # and posts one message; it never clears the slot or replays status # itself, so there is no second writer to race. - with CancelScope(shield=True): + with CancelScope(shield=True): # noqa: ASYNC100 if not lease_scope.before_lease_hook.is_set(): lease_scope.before_lease_hook.set() if not lease_scope.after_lease_hook_done.is_set(): @@ -1269,7 +1272,7 @@ async def serve(self): # Ensure the runtime container exits whenever this exporter is # configured for ExitAndReplace (covers hook on_failure=exit and # other stop paths that skip the lease-end branch above). - await anyio.to_thread.run_sync(shutdown_runtime_sidecar) + await to_thread.run_sync(shutdown_runtime_sidecar) self._tg = None self._status_drain_active = False clear_log_context() diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter_retry_test.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter_retry_test.py index 8e6d2aed5..8647bc502 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter_retry_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter_retry_test.py @@ -33,10 +33,10 @@ async def stream_factory(controller): call_count += 1 if call_count <= data_cycles: yield f"item-{call_count}" - raise Exception("connection lost") + raise Exception("connection lost") # noqa: TRY002 exporter = _make_exporter() - send_tx, send_rx = create_memory_object_stream[str](100) + send_tx, _send_rx = create_memory_object_stream[str](100) with pytest.raises(Exception, match="connection lost"): await exporter._retry_stream( @@ -58,11 +58,11 @@ async def test_does_not_reset_when_error_before_any_data(self): async def stream_factory(controller): nonlocal call_count call_count += 1 - raise Exception("UNAVAILABLE") + raise Exception("UNAVAILABLE") # noqa: TRY002 yield # make it an async generator exporter = _make_exporter() - send_tx, send_rx = create_memory_object_stream[str](100) + send_tx, _send_rx = create_memory_object_stream[str](100) with pytest.raises(Exception, match="UNAVAILABLE"): await exporter._retry_stream( @@ -85,11 +85,11 @@ async def test_raises_after_exhausting_retries_without_data(self): async def stream_factory(controller): nonlocal call_count call_count += 1 - raise Exception("permanently unreachable") + raise Exception("permanently unreachable") # noqa: TRY002 yield exporter = _make_exporter() - send_tx, send_rx = create_memory_object_stream[str](100) + send_tx, _send_rx = create_memory_object_stream[str](100) with pytest.raises(Exception, match="permanently unreachable"): await exporter._retry_stream( @@ -111,12 +111,12 @@ async def stream_factory(controller): nonlocal call_count call_count += 1 if call_count == 3: - raise Exception("third failure") - raise Exception("failure") + raise Exception("third failure") # noqa: TRY002 + raise Exception("failure") # noqa: TRY002 yield exporter = _make_exporter() - send_tx, send_rx = create_memory_object_stream[str](100) + send_tx, _send_rx = create_memory_object_stream[str](100) with pytest.raises(Exception, match="failure"): await exporter._retry_stream( @@ -141,20 +141,22 @@ async def stream_factory(controller): call_count += 1 if call_count <= 1: yield f"item-{call_count}" - raise Exception("connection lost") + raise Exception("connection lost") # noqa: TRY002 exporter = _make_exporter() - send_tx, send_rx = create_memory_object_stream[str](100) - - with caplog.at_level(logging.DEBUG, logger="jumpstarter.exporter.exporter"): - with pytest.raises(Exception, match="connection lost"): - await exporter._retry_stream( - stream_name="test", - stream_factory=stream_factory, - send_tx=send_tx, - retries=retries, - backoff=0.0, - ) + send_tx, _send_rx = create_memory_object_stream[str](100) + + with ( + caplog.at_level(logging.DEBUG, logger="jumpstarter.exporter.exporter"), + pytest.raises(Exception, match="connection lost"), + ): + await exporter._retry_stream( + stream_name="test", + stream_factory=stream_factory, + send_tx=send_tx, + retries=retries, + backoff=0.0, + ) reset_messages = [r for r in caplog.records if "retry counter reset" in r.message.lower()] assert len(reset_messages) == 1 diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter_telemetry_test.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter_telemetry_test.py index 57ad81c2c..3446aae46 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter_telemetry_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter_telemetry_test.py @@ -80,8 +80,8 @@ async def test_setup_telemetry_rpc_error_is_silently_ignored(): err = grpc.aio.AioRpcError( code=grpc.StatusCode.UNIMPLEMENTED, - initial_metadata=None, - trailing_metadata=None, + initial_metadata=None, # type: ignore[arg-type] + trailing_metadata=None, # type: ignore[arg-type] ) async def raise_rpc(*_a, **_kw): @@ -124,10 +124,12 @@ async def test_setup_telemetry_insecure_uses_insecure_channel(): resp = MagicMock() resp.telemetry_endpoints = [ep] - with patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()): - with patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure: - mock_insecure.return_value = MagicMock() - await exp._setup_telemetry() + with ( + patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()), + patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure, + ): + mock_insecure.return_value = MagicMock() + await exp._setup_telemetry() mock_insecure.assert_called_once_with(ep.endpoint) assert exp._telemetry_handler is not None @@ -142,10 +144,12 @@ async def test_setup_telemetry_env_var_insecure(monkeypatch): resp = MagicMock() resp.telemetry_endpoints = [ep] - with patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()): - with patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure: - mock_insecure.return_value = MagicMock() - await exp._setup_telemetry() + with ( + patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()), + patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure, + ): + mock_insecure.return_value = MagicMock() + await exp._setup_telemetry() mock_insecure.assert_called_once() @@ -158,11 +162,13 @@ async def test_setup_telemetry_certificate_uses_secure_channel_with_cert(): resp = MagicMock() resp.telemetry_endpoints = [ep] - with patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()): - with patch("jumpstarter.exporter.exporter.grpc.aio.secure_channel") as mock_secure: - with patch("jumpstarter.exporter.exporter.grpc.ssl_channel_credentials") as mock_creds: - mock_secure.return_value = MagicMock() - await exp._setup_telemetry() + with ( + patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()), + patch("jumpstarter.exporter.exporter.grpc.aio.secure_channel") as mock_secure, + patch("jumpstarter.exporter.exporter.grpc.ssl_channel_credentials") as mock_creds, + ): + mock_secure.return_value = MagicMock() + await exp._setup_telemetry() mock_secure.assert_called_once() mock_creds.assert_called_once_with(root_certificates=b"---PEM---") @@ -176,11 +182,13 @@ async def test_setup_telemetry_no_cert_uses_system_ca(): resp = MagicMock() resp.telemetry_endpoints = [ep] - with patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()): - with patch("jumpstarter.exporter.exporter.grpc.aio.secure_channel") as mock_secure: - with patch("jumpstarter.exporter.exporter.grpc.ssl_channel_credentials") as mock_creds: - mock_secure.return_value = MagicMock() - await exp._setup_telemetry() + with ( + patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()), + patch("jumpstarter.exporter.exporter.grpc.aio.secure_channel") as mock_secure, + patch("jumpstarter.exporter.exporter.grpc.ssl_channel_credentials") as mock_creds, + ): + mock_secure.return_value = MagicMock() + await exp._setup_telemetry() mock_secure.assert_called_once() mock_creds.assert_called_once_with() @@ -195,10 +203,12 @@ async def test_setup_telemetry_applies_min_severity(): resp = MagicMock() resp.telemetry_endpoints = [ep] - with patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()): - with patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure: - mock_insecure.return_value = MagicMock() - await exp._setup_telemetry() + with ( + patch.object(exp, "_controller_stub", return_value=make_controller_stub_ctx(resp)()), + patch("jumpstarter.exporter.exporter.grpc.aio.insecure_channel") as mock_insecure, + ): + mock_insecure.return_value = MagicMock() + await exp._setup_telemetry() assert exp._telemetry_handler is not None assert exp._telemetry_handler.level == logging.WARNING diff --git a/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py b/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py index 924b00427..33a5afdba 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/exporter_test.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import anyio +import anyio.lowlevel import grpc import pytest from anyio import Event, create_memory_object_stream, create_task_group, fail_after @@ -114,9 +115,9 @@ async def tracking_run_after(*args, **kwargs): nonlocal after_lease_started_before_hook_done if not lease_ctx.before_lease_hook.is_set(): after_lease_started_before_hook_done = True - return await original_run_after(*args, **kwargs) + return await original_run_after(*args, **kwargs) # type: ignore[call-arg] - hook_executor.run_after_lease_hook = tracking_run_after + hook_executor.run_after_lease_hook = tracking_run_after # type: ignore[method-assign] exporter = make_exporter(lease_ctx, hook_executor) @@ -298,18 +299,18 @@ async def test_consecutive_leases_run_hooks_in_strict_order(self): async def tracking_before(*args, **kwargs): events.append("before_start") - result = await original_run_before(*args, **kwargs) + result = await original_run_before(*args, **kwargs) # type: ignore[call-arg] events.append("before_end") return result async def tracking_after(*args, **kwargs): events.append("after_start") - result = await original_run_after(*args, **kwargs) + result = await original_run_after(*args, **kwargs) # type: ignore[call-arg] events.append("after_end") return result - hook_executor.run_before_lease_hook = tracking_before - hook_executor.run_after_lease_hook = tracking_after + hook_executor.run_before_lease_hook = tracking_before # type: ignore[method-assign] + hook_executor.run_after_lease_hook = tracking_after # type: ignore[method-assign] lease_ctx_1 = make_lease_context(lease_name="lease-1") exporter = make_exporter(lease_ctx_1, hook_executor) @@ -455,9 +456,9 @@ async def test_duplicate_cleanup_is_noop(self): async def counting_run_after(*args, **kwargs): nonlocal after_hook_call_count after_hook_call_count += 1 - return await original_run_after(*args, **kwargs) + return await original_run_after(*args, **kwargs) # type: ignore[call-arg] - hook_executor.run_after_lease_hook = counting_run_after + hook_executor.run_after_lease_hook = counting_run_after # type: ignore[method-assign] lease_ctx = make_lease_context() lease_ctx.before_lease_hook.set() @@ -563,9 +564,11 @@ async def test_unimplemented_grpc_error_logs_warning(self, caplog): ) mock_controller, stub_ctx = _setup_mock_controller_stub(exporter, side_effect=error) - with patch.object(exporter, "_controller_stub", return_value=stub_ctx): - with caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.exporter"): - await exporter._report_status(ExporterStatus.AVAILABLE, "test") + with ( + patch.object(exporter, "_controller_stub", return_value=stub_ctx), + caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.exporter"), + ): + await exporter._report_status(ExporterStatus.AVAILABLE, "test") warning_msgs = [r for r in caplog.records if r.levelno == logging.WARNING] assert any("ReportStatus not supported" in r.message for r in warning_msgs), ( @@ -592,10 +595,12 @@ async def test_other_grpc_error_logs_error(self, caplog): ) mock_controller, stub_ctx = _setup_mock_controller_stub(exporter, side_effect=error) - with patch.object(exporter, "_controller_stub", return_value=stub_ctx), \ - patch("anyio.sleep") as mock_sleep: - with caplog.at_level(logging.DEBUG, logger="jumpstarter.exporter.exporter"): - await exporter._report_status(ExporterStatus.AVAILABLE, "test") + with ( + patch.object(exporter, "_controller_stub", return_value=stub_ctx), + patch("anyio.sleep") as mock_sleep, + caplog.at_level(logging.DEBUG, logger="jumpstarter.exporter.exporter"), + ): + await exporter._report_status(ExporterStatus.AVAILABLE, "test") # Should log retry warnings (_RPC_MAX_RETRIES) warning_msgs = [r for r in caplog.records if r.levelno == logging.WARNING] @@ -649,7 +654,7 @@ async def fail_twice_then_succeed(request, **kwargs): # Third attempt succeeds delivered_statuses.append(ExporterStatus.from_proto(request.status)) - mock_controller, stub_ctx = _setup_mock_controller_stub(exporter, side_effect=fail_twice_then_succeed) + _mock_controller, stub_ctx = _setup_mock_controller_stub(exporter, side_effect=fail_twice_then_succeed) with patch.object(exporter, "_controller_stub", return_value=stub_ctx), \ patch("anyio.sleep") as mock_sleep: @@ -1350,7 +1355,7 @@ async def fake_handle_lease(lease_name, tg, lease_scope): async with create_task_group() as tg: await exporter._apply_status(status, tg) - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() tg.cancel_scope.cancel() assert exporter._lease_context is not None @@ -1521,7 +1526,7 @@ async def test_handle_lease_posts_lease_finished_even_when_cancelled(self): with fail_after(5): async with create_task_group() as tg: tg.start_soon(exporter.handle_lease, "cancel-lease", tg, lease_ctx) - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() tg.cancel_scope.cancel() msg = status_rx.receive_nowait() @@ -1748,7 +1753,7 @@ def _wire_handle_lease(exporter): async def fake_handle_lease(lease_name, tg, lease_ctx): await lease_ctx.lease_ended.wait() lease_ctx.after_lease_hook_done.set() - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() if exporter._control_tx is not None: await exporter._control_tx.send(LeaseFinished(lease_ctx)) @@ -1786,7 +1791,7 @@ async def test_serve_not_triggered_on_startup(self): tg.start_soon(exporter.serve) await statuses_sent.wait() # Yield so serve() can process the queued status. - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() assert exporter._stop_requested is False tg.cancel_scope.cancel() @@ -2057,7 +2062,7 @@ async def tracking_watchdog(ctx): async with create_task_group() as tg: await exporter._apply_status(status, tg) - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() tg.cancel_scope.cancel() assert spawned == [lease_ctx] diff --git a/python/packages/jumpstarter/jumpstarter/exporter/hooks.py b/python/packages/jumpstarter/jumpstarter/exporter/hooks.py index 9e7d04f95..99d38c55d 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/hooks.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/hooks.py @@ -6,12 +6,14 @@ import stat import tempfile import time -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable +from contextlib import suppress from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Literal +from typing import TYPE_CHECKING, Literal import anyio -from anyio import CancelScope +import anyio.lowlevel +from anyio import CancelScope, to_thread from jumpstarter.common import HOOK_WARNING_PREFIX, ExporterStatus, LogSource from jumpstarter.config.env import JMP_DRIVERS_ALLOW, JMP_MOTD_FILE, JUMPSTARTER_HOST @@ -257,7 +259,7 @@ def _handle_hook_failure( logger.warning("%s (on_failure=warn, continuing)", error_msg) return error_msg - logger.error("%s (on_failure=%s, raising exception)", error_msg, on_failure) + logger.exception("%s (on_failure=%s, raising exception)", error_msg, on_failure) error = HookExecutionError( message=error_msg, @@ -309,7 +311,7 @@ async def _execute_hook_process( # noqa: C901 try: parent_fd, child_fd = pty.openpty() except Exception as e: - logger.error("Failed to create PTY: %s", e, exc_info=True) + logger.error("Failed to create PTY: %s", e) raise logger.debug("PTY created: parent_fd=%d, child_fd=%d", parent_fd, child_fd) @@ -347,7 +349,7 @@ async def _execute_hook_process( # noqa: C901 logger.debug("Spawning subprocess with command: %s", cmd) try: - process = subprocess.Popen( + process = subprocess.Popen( # noqa: ASYNC220 cmd, stdin=child_fd, stdout=child_fd, @@ -356,8 +358,8 @@ async def _execute_hook_process( # noqa: C901 start_new_session=True, # Equivalent to os.setsid() close_fds=True, # Close inherited fds to prevent interference with gRPC connections ) - except Exception as e: - logger.error("Failed to spawn subprocess: %s", e, exc_info=True) + except Exception: + logger.exception("Failed to spawn subprocess") raise logger.debug("Subprocess spawned with PID %d", process.pid) # Close child fd in parent process - subprocess has it now @@ -428,7 +430,7 @@ async def read_pty_output() -> None: # noqa: C901 logger.debug("read_pty_output: OSError in loop: %s", e) break - except Exception as e: + except Exception as e: # noqa: BLE001 logger.debug("read_pty_output: unexpected error in loop: %s", e) break @@ -499,7 +501,7 @@ async def wait_for_process() -> int: """ logger.debug("wait_for_process: waiting for PID %d", process.pid) try: - result = await anyio.to_thread.run_sync(process.wait, abandon_on_cancel=True) + result = await to_thread.run_sync(process.wait, abandon_on_cancel=True) logger.debug("wait_for_process: PID %d exited with code %d", process.pid, result) return result finally: @@ -518,8 +520,8 @@ async def wait_for_process() -> int: logger.debug("wait_for_process: force killing PID %d", process.pid) process.kill() # Final reap with non-abandoning wait - await anyio.to_thread.run_sync(process.wait, abandon_on_cancel=False) - except Exception as e: + await to_thread.run_sync(process.wait, abandon_on_cancel=False) + except Exception as e: # noqa: BLE001 logger.debug("wait_for_process: error during cleanup: %s", e) # Use move_on_after for timeout @@ -528,7 +530,7 @@ async def wait_for_process() -> int: # Yield to event loop to ensure other tasks can progress # This helps prevent race conditions in task scheduling - await anyio.sleep(0) + await anyio.lowlevel.checkpoint() with anyio.move_on_after(timeout) as cancel_scope: # Run output reading and process waiting concurrently @@ -553,23 +555,19 @@ async def wait_for_process() -> int: if cancel_scope.cancelled_caught: timed_out = True error_msg = f"Hook timed out after {timeout} seconds" - logger.error(error_msg) + logger.exception(error_msg) # Terminate the process if process and process.poll() is None: process.terminate() # Give it a moment to terminate gracefully - try: + with suppress(Exception): with anyio.move_on_after(5): - await anyio.to_thread.run_sync(process.wait, abandon_on_cancel=True) - except Exception: - pass + await to_thread.run_sync(process.wait, abandon_on_cancel=True) # Force kill if still running if process.poll() is None: process.kill() - try: - await anyio.to_thread.run_sync(process.wait, abandon_on_cancel=True) - except Exception: - pass + with suppress(Exception): + await to_thread.run_sync(process.wait, abandon_on_cancel=True) elif returncode == 0: logger.debug("Hook executed successfully") @@ -577,10 +575,10 @@ async def wait_for_process() -> int: else: error_msg = f"Hook failed with exit code {returncode}" - except Exception as e: + except Exception as e: # noqa: BLE001 error_msg = f"Error executing hook: {e}" cause = e - logger.error(error_msg, exc_info=True) + logger.error(error_msg) finally: # Clean up file descriptors - only close those still open to avoid # closing an unrelated fd that reused the same number. @@ -658,8 +656,8 @@ async def _safe_release_lease( if request_lease_release: try: await request_lease_release() - except Exception as e: - logger.error("Failed to request lease release: %s", e, exc_info=True) + except Exception: + logger.exception("Failed to request lease release") async def _wait_for_lease_ready( self, @@ -684,7 +682,7 @@ async def _wait_for_lease_ready( return False if elapsed >= timeout: error_msg = "Timeout waiting for lease scope to be ready" - logger.error(error_msg) + logger.exception(error_msg) await report_status(ExporterStatus.BEFORE_LEASE_HOOK_FAILED, error_msg) lease_scope.before_lease_hook.set() return False @@ -784,8 +782,8 @@ async def run_before_lease_hook( f"beforeLease hook failed (on_failure=endLease): {e}", ) - except Exception as e: - logger.error("beforeLease hook failed with unexpected error: %s", e, exc_info=True) + except Exception as e: # noqa: BLE001 + logger.error("beforeLease hook failed with unexpected error: %s", e) await report_status( ExporterStatus.BEFORE_LEASE_HOOK_FAILED, f"beforeLease hook failed: {e}", @@ -862,7 +860,7 @@ async def run_after_lease_hook( except HookExecutionError as e: if e.should_shutdown_exporter(): # on_failure='exit' - shut down the entire exporter - logger.error("afterLease hook failed with on_failure='exit': %s", e) + logger.exception("afterLease hook failed with on_failure='exit'") await report_status( ExporterStatus.AFTER_LEASE_HOOK_FAILED, f"afterLease hook failed (on_failure=exit, shutting down): {e}", @@ -887,11 +885,11 @@ async def run_after_lease_hook( f"afterLease hook failed (on_failure=endLease): {e}", ) - except Exception as e: + except Exception as e: # noqa: BLE001 # Unexpected errors: report failure but do not shut down. # Same transient status - the lease is released and the exporter # accepts new leases after the finally block completes. - logger.error("afterLease hook failed with unexpected error: %s", e, exc_info=True) + logger.error("afterLease hook failed with unexpected error: %s", e) await report_status( ExporterStatus.AFTER_LEASE_HOOK_FAILED, f"afterLease hook failed: {e}", @@ -906,5 +904,5 @@ async def run_after_lease_hook( if request_lease_release and not shutdown_called: try: await request_lease_release() - except Exception as e: - logger.error("Failed to request lease release: %s", e, exc_info=True) + except Exception: + logger.exception("Failed to request lease release") diff --git a/python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py b/python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py index 08c32a41f..23715abae 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py @@ -195,8 +195,8 @@ async def test_failed_hook_execution(self, lease_scope) -> None: await executor.execute_before_lease_hook(lease_scope) assert "exit code 1" in str(exc_info.value) - assert exc_info.value.on_failure == "endLease" - assert exc_info.value.hook_type == "before_lease" + assert exc_info.value.on_failure == "endLease" # type: ignore[attr-defined] + assert exc_info.value.hook_type == "before_lease" # type: ignore[attr-defined] async def test_hook_timeout(self, lease_scope) -> None: timeout_config = HookConfigV1Alpha1( @@ -208,7 +208,7 @@ async def test_hook_timeout(self, lease_scope) -> None: await executor.execute_before_lease_hook(lease_scope) assert "timed out after 1 seconds" in str(exc_info.value) - assert exc_info.value.on_failure == "exit" + assert exc_info.value.on_failure == "exit" # type: ignore[attr-defined] @macos_pty_xfail async def test_hook_environment_variables(self, lease_scope) -> None: @@ -419,7 +419,7 @@ def tracking_handle(error_msg, on_failure, hook_type, cause=None): warning_logged_in_context = context_active return original_handle(error_msg, on_failure, hook_type, cause) - executor._handle_hook_failure = tracking_handle + executor._handle_hook_failure = tracking_handle # type: ignore[method-assign] result = await executor.execute_before_lease_hook(lease_scope) assert result is not None diff --git a/python/packages/jumpstarter/jumpstarter/exporter/logging.py b/python/packages/jumpstarter/jumpstarter/exporter/logging.py index a9a8d5f9f..bc8f35128 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/logging.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/logging.py @@ -1,7 +1,7 @@ import logging from collections import deque from contextlib import contextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime from threading import RLock from google.protobuf.timestamp_pb2 import Timestamp @@ -59,7 +59,7 @@ def prepare(self, record): if hasattr(record, "operation"): kwargs["operation"] = record.operation ts = Timestamp() - ts.FromDatetime(datetime.fromtimestamp(record.created, tz=timezone.utc)) + ts.FromDatetime(datetime.fromtimestamp(record.created, tz=UTC)) kwargs["timestamp"] = ts structured = {} for key, val in record.__dict__.items(): @@ -74,7 +74,7 @@ def prepare(self, record): def emit(self, record): try: self.enqueue(self.prepare(record)) - except Exception: + except Exception: # noqa: BLE001 self.handleError(record) @contextmanager diff --git a/python/packages/jumpstarter/jumpstarter/exporter/logging_test.py b/python/packages/jumpstarter/jumpstarter/exporter/logging_test.py index a73a9d9fb..c4367083e 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/logging_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/logging_test.py @@ -213,10 +213,9 @@ def test_context_log_source_handles_exception(self) -> None: queue = deque() handler = LogHandler(queue, source=LogSource.SYSTEM) - with pytest.raises(ValueError): - with handler.context_log_source("hook.before", LogSource.BEFORE_LEASE_HOOK): - assert "hook.before" in handler._child_handlers - raise ValueError("Test exception") + with pytest.raises(ValueError), handler.context_log_source("hook.before", LogSource.BEFORE_LEASE_HOOK): + assert "hook.before" in handler._child_handlers + raise ValueError("Test exception") assert "hook.before" not in handler._child_handlers diff --git a/python/packages/jumpstarter/jumpstarter/exporter/session.py b/python/packages/jumpstarter/jumpstarter/exporter/session.py index 2b1bb323a..ba4d79f04 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/session.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/session.py @@ -79,14 +79,14 @@ def __contextmanager__(self) -> Generator[Self]: unbind_log_context("exporter") try: self.root_device.close() - except Exception as e: + except Exception: # Get driver name from report for more descriptive logging try: report = self.root_device.report() driver_name = report.labels.get("jumpstarter.dev/name", self.root_device.__class__.__name__) - except Exception: + except AttributeError: driver_name = self.root_device.__class__.__name__ - logger.error("Error closing driver %s: %s", driver_name, e, exc_info=True) + logger.exception("Error closing driver %s", driver_name) finally: logging.getLogger().removeHandler(self._logging_handler) @@ -269,16 +269,14 @@ async def serve_unix_with_hook_socket_async(self): Yields: tuple[str, str]: (main_socket_path, hook_socket_path) """ - with TemporarySocket() as main_path: - with TemporarySocket() as hook_path: - async with self.serve_multi_port_async(f"unix://{main_path}", f"unix://{hook_path}"): - yield main_path, hook_path + with TemporarySocket() as main_path, TemporarySocket() as hook_path: + async with self.serve_multi_port_async(f"unix://{main_path}", f"unix://{hook_path}"): + yield main_path, hook_path @contextmanager def serve_unix(self): - with start_blocking_portal() as portal: - with portal.wrap_async_context_manager(self.serve_unix_async()) as path: - yield path + with start_blocking_portal() as portal, portal.wrap_async_context_manager(self.serve_unix_async()) as path: + yield path def __getitem__(self, key: UUID): return self.mapping[key] @@ -333,7 +331,7 @@ async def StreamingDriverCall(self, request, context): yield v async def Stream(self, _request_iterator, context): - request = StreamRequestMetadata(**dict(list(context.invocation_metadata()))).request + request = StreamRequestMetadata(**dict(list(context.invocation_metadata()))).request # type: ignore[call-arg] logger.debug("Streaming(%s)", request) try: driver = self[request.uuid] @@ -343,11 +341,13 @@ async def Stream(self, _request_iterator, context): metadata.extend(stream.extra(MetadataStreamAttributes.metadata).items()) await context.send_initial_metadata(metadata) - async with RouterStream(context=context) as remote: - async with forward_stream(remote, stream, metrics_driver_type=driver.driver_type): - event = Event() - context.add_done_callback(lambda _: event.set()) - await event.wait() + async with ( + RouterStream(context=context) as remote, + forward_stream(remote, stream, metrics_driver_type=driver.driver_type), + ): + event = Event() + context.add_done_callback(lambda _: event.set()) + await event.wait() except (ExclusiveSessionActive, WriteTokenRevokedError, ReadOnlyStreamError) as e: # Abort with the exception message so clients see a gRPC status # instead of grpcio's "Unexpected " UNKNOWN wrapper. diff --git a/python/packages/jumpstarter/jumpstarter/exporter/session_test.py b/python/packages/jumpstarter/jumpstarter/exporter/session_test.py index 9f808ca79..107e917c6 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/session_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/session_test.py @@ -195,20 +195,22 @@ def test_client_fetches_motd_via_getreport(): driver = SimpleDriver() - with start_blocking_portal() as portal: - with ExitStack() as stack: - with Session( - uuid=driver.uuid, - labels=driver.labels, - root_device=driver, - motd="Welcome to my-exporter!", - ) as session: - with portal.wrap_async_context_manager(session.serve_unix_async()) as path: - with portal.wrap_async_context_manager( - client_from_path(path, portal, stack, allow=[], unsafe=True) - ) as client: - report = portal.call(lambda: client.stub.GetReport(empty_pb2.Empty())) - assert report.motd == "Welcome to my-exporter!" + with ( + start_blocking_portal() as portal, + ExitStack() as stack, + Session( + uuid=driver.uuid, + labels=driver.labels, + root_device=driver, + motd="Welcome to my-exporter!", + ) as session, + portal.wrap_async_context_manager(session.serve_unix_async()) as path, + portal.wrap_async_context_manager( + client_from_path(path, portal, stack, allow=[], unsafe=True) + ) as client, + ): + report = portal.call(lambda: client.stub.GetReport(empty_pb2.Empty())) + assert report.motd == "Welcome to my-exporter!" def test_description_override_in_exporter_config(): @@ -462,7 +464,7 @@ async def test_serve_tcp_passphrase_rejected(): stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) with pytest.raises(grpc.aio.AioRpcError) as exc_info: await stub.GetReport(empty_pb2.Empty(), metadata=metadata) - assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED + assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED # type: ignore[attr-defined] @pytest.mark.anyio @@ -476,12 +478,11 @@ async def test_serve_tcp_passphrase_missing(): with session: async with session.serve_tcp_async( "127.0.0.1", 0, interceptors=[PassphraseInterceptor(passphrase)] - ) as bound_port: - async with grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: - stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) - with pytest.raises(grpc.aio.AioRpcError) as exc_info: - await stub.GetReport(empty_pb2.Empty()) - assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED + ) as bound_port, grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: + stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) + with pytest.raises(grpc.aio.AioRpcError) as exc_info: + await stub.GetReport(empty_pb2.Empty()) + assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED # type: ignore[attr-defined] # ============================================================================ @@ -504,9 +505,11 @@ async def test_serve_tcp_passphrase_rejected_logs_warning(caplog): metadata = ((PASSPHRASE_METADATA_KEY, "wrong-passphrase"),) async with grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) - with caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.auth"): - with pytest.raises(grpc.aio.AioRpcError): - await stub.GetReport(empty_pb2.Empty(), metadata=metadata) + with ( + caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.auth"), + pytest.raises(grpc.aio.AioRpcError), + ): + await stub.GetReport(empty_pb2.Empty(), metadata=metadata) # The interceptor should have emitted a WARNING log with the method name. auth_warnings = [r for r in caplog.records if r.levelno == logging.WARNING and "authentication failed" in r.message] @@ -534,12 +537,13 @@ async def test_serve_tcp_passphrase_missing_logs_warning(caplog): with session: async with session.serve_tcp_async( "127.0.0.1", 0, interceptors=[PassphraseInterceptor(passphrase)] - ) as bound_port: - async with grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: - stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) - with caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.auth"): - with pytest.raises(grpc.aio.AioRpcError): - await stub.GetReport(empty_pb2.Empty()) + ) as bound_port, grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: + stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) + with ( + caplog.at_level(logging.WARNING, logger="jumpstarter.exporter.auth"), + pytest.raises(grpc.aio.AioRpcError), + ): + await stub.GetReport(empty_pb2.Empty()) auth_warnings = [r for r in caplog.records if r.levelno == logging.WARNING and "authentication failed" in r.message] assert len(auth_warnings) >= 1, f"expected auth failure warning log, got: {[r.message for r in caplog.records]}" @@ -618,24 +622,26 @@ async def test_log_stream_returns_enriched_fields(): session._logging_handler.emit(record) - async with session.serve_tcp_async("127.0.0.1", 0) as bound_port: - async with grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: - stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) + async with ( + session.serve_tcp_async("127.0.0.1", 0) as bound_port, + grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel, + ): + stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) - log_stream = stub.LogStream(empty_pb2.Empty()) + log_stream = stub.LogStream(empty_pb2.Empty()) - msg = await log_stream.read() + msg = await log_stream.read() - assert msg.message == "Power on completed" - assert msg.severity == "INFO" - assert msg.driver_type == "power" - assert msg.operation == "power_on" - assert msg.HasField("timestamp") - assert msg.timestamp.seconds > 0 - assert msg.structured_fields["result"] == "success" - assert msg.structured_fields["lease_id"] == "lease-123" + assert msg.message == "Power on completed" + assert msg.severity == "INFO" + assert msg.driver_type == "power" + assert msg.operation == "power_on" + assert msg.HasField("timestamp") + assert msg.timestamp.seconds > 0 + assert msg.structured_fields["result"] == "success" + assert msg.structured_fields["lease_id"] == "lease-123" - log_stream.cancel() + log_stream.cancel() @pytest.mark.anyio @@ -658,18 +664,20 @@ async def test_log_stream_without_enriched_fields(): test_logger.setLevel(logging.INFO) test_logger.info("Simple message") - async with session.serve_tcp_async("127.0.0.1", 0) as bound_port: - async with grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel: - stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) + async with ( + session.serve_tcp_async("127.0.0.1", 0) as bound_port, + grpc.aio.insecure_channel(f"127.0.0.1:{bound_port}") as channel, + ): + stub = jumpstarter_pb2_grpc.ExporterServiceStub(channel) - log_stream = stub.LogStream(empty_pb2.Empty()) - msg = await log_stream.read() + log_stream = stub.LogStream(empty_pb2.Empty()) + msg = await log_stream.read() - assert msg.message == "Simple message" - assert msg.severity == "INFO" - assert not msg.HasField("driver_type") - assert not msg.HasField("operation") - assert msg.HasField("timestamp") - assert len(msg.structured_fields) == 0 + assert msg.message == "Simple message" + assert msg.severity == "INFO" + assert not msg.HasField("driver_type") + assert not msg.HasField("operation") + assert msg.HasField("timestamp") + assert len(msg.structured_fields) == 0 - log_stream.cancel() + log_stream.cancel() diff --git a/python/packages/jumpstarter/jumpstarter/exporter/telemetry.py b/python/packages/jumpstarter/jumpstarter/exporter/telemetry.py index 17e7fe945..6b8be88f9 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/telemetry.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/telemetry.py @@ -3,7 +3,7 @@ import logging import sys from collections import deque -from datetime import datetime, timezone +from datetime import UTC, datetime import structlog.contextvars from anyio import sleep @@ -78,7 +78,7 @@ def prepare(self, record: logging.LogRecord) -> telemetry_pb2.LogEntry: 2. LogRecord extra attributes — set via logger.info(..., extra={...}) """ ts = Timestamp() - ts.FromDatetime(datetime.fromtimestamp(record.created, tz=timezone.utc)) + ts.FromDatetime(datetime.fromtimestamp(record.created, tz=UTC)) entry = telemetry_pb2.LogEntry( timestamp=ts, @@ -121,7 +121,7 @@ def prepare(self, record: logging.LogRecord) -> telemetry_pb2.LogEntry: def emit(self, record: logging.LogRecord) -> None: try: self._queue.append(self.prepare(record)) - except Exception: + except Exception: # noqa: BLE001 self.handleError(record) async def flush_loop(self) -> None: @@ -147,7 +147,7 @@ async def _flush(self) -> None: timeout=_PUSH_TIMEOUT, metadata=metadata, ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 # Avoid recursive logging: write directly to stderr. print(f"[telemetry] PushLogs failed, {len(batch)} entries dropped: {exc}", file=sys.stderr) diff --git a/python/packages/jumpstarter/jumpstarter/exporter/telemetry_test.py b/python/packages/jumpstarter/jumpstarter/exporter/telemetry_test.py index 62cb256ba..9f258565e 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/telemetry_test.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/telemetry_test.py @@ -1,5 +1,6 @@ """Unit tests for TelemetryLogHandler.""" +import contextlib import logging from collections import deque from unittest.mock import AsyncMock, MagicMock, patch @@ -301,13 +302,13 @@ async def fake_flush(): flush_calls.append(1) handler._queue.clear() - with patch.object(handler, "_flush", side_effect=fake_flush): - with patch("jumpstarter.exporter.telemetry.sleep", new_callable=AsyncMock) as mock_sleep: - mock_sleep.side_effect = [None, Exception("stop")] - try: - await handler.flush_loop() - except Exception: - pass + with ( + patch.object(handler, "_flush", side_effect=fake_flush), + patch("jumpstarter.exporter.telemetry.sleep", new_callable=AsyncMock) as mock_sleep, + ): + mock_sleep.side_effect = [None, Exception("stop")] + with contextlib.suppress(Exception): + await handler.flush_loop() assert len(flush_calls) >= 1 diff --git a/python/packages/jumpstarter/jumpstarter/logging/setup.py b/python/packages/jumpstarter/jumpstarter/logging/setup.py index 1dcd24c91..62df75218 100644 --- a/python/packages/jumpstarter/jumpstarter/logging/setup.py +++ b/python/packages/jumpstarter/jumpstarter/logging/setup.py @@ -104,7 +104,7 @@ def setup_logging( *shared_processors, structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], - wrapper_class=structlog.stdlib.BoundLogger, + wrapper_class=structlog.stdlib.BoundLogger, # type: ignore[arg-type] context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, diff --git a/python/packages/jumpstarter/jumpstarter/logging/setup_test.py b/python/packages/jumpstarter/jumpstarter/logging/setup_test.py index e0ec37934..0113191ca 100644 --- a/python/packages/jumpstarter/jumpstarter/logging/setup_test.py +++ b/python/packages/jumpstarter/jumpstarter/logging/setup_test.py @@ -218,33 +218,30 @@ def test_detect_namespace_from_service_account_file(self, tmp_path): ns_file = tmp_path / "namespace" ns_file.write_text("k8s-namespace\n") - with patch.dict("os.environ", {}, clear=True): - with patch("jumpstarter.logging.setup.Path") as mock_path: - mock_instance = mock_path.return_value - mock_instance.exists.return_value = True - mock_instance.read_text.return_value = "k8s-namespace\n" - assert _detect_namespace() == "k8s-namespace" + with patch.dict("os.environ", {}, clear=True), patch("jumpstarter.logging.setup.Path") as mock_path: + mock_instance = mock_path.return_value + mock_instance.exists.return_value = True + mock_instance.read_text.return_value = "k8s-namespace\n" + assert _detect_namespace() == "k8s-namespace" def test_detect_namespace_returns_none_when_not_in_k8s(self): """Should return None when no env vars and no service account file.""" from jumpstarter.logging.setup import _detect_namespace - with patch.dict("os.environ", {}, clear=True): - with patch("jumpstarter.logging.setup.Path") as mock_path: - mock_instance = mock_path.return_value - mock_instance.exists.return_value = False - assert _detect_namespace() is None + with patch.dict("os.environ", {}, clear=True), patch("jumpstarter.logging.setup.Path") as mock_path: + mock_instance = mock_path.return_value + mock_instance.exists.return_value = False + assert _detect_namespace() is None def test_setup_logging_binds_namespace_when_available(self): """Namespace should appear in log output when detected.""" stream = io.StringIO() - with patch.dict("os.environ", {"NAMESPACE": "test-ns"}): - with patch("jumpstarter.logging.setup.sys.stderr", stream): - setup_logging(component="exporter", log_format="json") - root = logging.getLogger() - for handler in root.handlers: - if isinstance(handler, logging.StreamHandler): - handler.stream = stream + with patch.dict("os.environ", {"NAMESPACE": "test-ns"}), patch("jumpstarter.logging.setup.sys.stderr", stream): + setup_logging(component="exporter", log_format="json") + root = logging.getLogger() + for handler in root.handlers: + if isinstance(handler, logging.StreamHandler): + handler.stream = stream logger = logging.getLogger("test.namespace_bind") logger.info("With namespace") @@ -261,16 +258,18 @@ def test_setup_logging_no_namespace_when_not_in_k8s(self): structlog.contextvars.clear_contextvars() stream = io.StringIO() - with patch.dict("os.environ", {}, clear=True): - with patch("jumpstarter.logging.setup.Path") as mock_path: - mock_instance = mock_path.return_value - mock_instance.exists.return_value = False - with patch("jumpstarter.logging.setup.sys.stderr", stream): - setup_logging(component="exporter", log_format="json") - root = logging.getLogger() - for handler in root.handlers: - if isinstance(handler, logging.StreamHandler): - handler.stream = stream + with ( + patch.dict("os.environ", {}, clear=True), + patch("jumpstarter.logging.setup.Path") as mock_path, + patch("jumpstarter.logging.setup.sys.stderr", stream), + ): + mock_instance = mock_path.return_value + mock_instance.exists.return_value = False + setup_logging(component="exporter", log_format="json") + root = logging.getLogger() + for handler in root.handlers: + if isinstance(handler, logging.StreamHandler): + handler.stream = stream logger = logging.getLogger("test.no_namespace") logger.info("Without namespace") diff --git a/python/packages/jumpstarter/jumpstarter/metrics/families.py b/python/packages/jumpstarter/jumpstarter/metrics/families.py index 608924231..23cfe0ee9 100644 --- a/python/packages/jumpstarter/jumpstarter/metrics/families.py +++ b/python/packages/jumpstarter/jumpstarter/metrics/families.py @@ -47,7 +47,7 @@ def _family_name(metric: Any) -> str: def _set_unix_timestamp(dest: Any, ts: float) -> None: seconds = int(ts) - nanos = int(round((ts - seconds) * 1_000_000_000)) + nanos = round((ts - seconds) * 1_000_000_000) if nanos >= 1_000_000_000: seconds += 1 nanos -= 1_000_000_000 diff --git a/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py b/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py index f468866c0..f4074ebc8 100644 --- a/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py +++ b/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py @@ -496,6 +496,7 @@ async def hang(self): def test_client_cancelled_driver_call_does_not_record_operation_metric(): """Client-initiated cancel must not create operation or error series.""" import concurrent.futures + import contextlib import time from jumpstarter.common.utils import serve @@ -505,10 +506,8 @@ def test_client_cancelled_driver_call_does_not_record_operation_metric(): fut = client.portal.start_task_soon(client.call_async, "hang") assert driver._ready.wait(timeout=5), "driver hang() never started" assert fut.cancel(), "expected in-flight DriverCall future to cancel" - try: + with contextlib.suppress(concurrent.futures.CancelledError): fut.result(timeout=5) - except (concurrent.futures.CancelledError, Exception): - pass # Allow any late server-side cleanup before asserting the registry. time.sleep(0.1) diff --git a/python/packages/jumpstarter/jumpstarter/metrics/server.py b/python/packages/jumpstarter/jumpstarter/metrics/server.py index 0acc09e3f..74df147b0 100644 --- a/python/packages/jumpstarter/jumpstarter/metrics/server.py +++ b/python/packages/jumpstarter/jumpstarter/metrics/server.py @@ -60,7 +60,7 @@ def start_metrics_server( class Handler(BaseHTTPRequestHandler): timeout = _METRICS_REQUEST_TIMEOUT_S - def do_GET(self): # noqa: N802 + def do_GET(self): if self.path.split("?", 1)[0] != "/metrics": self.send_error(404) return @@ -71,7 +71,7 @@ def do_GET(self): # noqa: N802 self.end_headers() self.wfile.write(body) - def log_message(self, format: str, *args) -> None: # noqa: A002 + def log_message(self, format: str, *args) -> None: return host, port = _parse_bind_addr(addr) diff --git a/python/packages/jumpstarter/jumpstarter/streams/common.py b/python/packages/jumpstarter/jumpstarter/streams/common.py index 44639822f..44f3f2b97 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/common.py +++ b/python/packages/jumpstarter/jumpstarter/streams/common.py @@ -69,31 +69,30 @@ async def copy_stream( @asynccontextmanager async def forward_stream(a, b, *, metrics_driver_type: str | None = None): - async with a, b: - async with create_task_group() as tg: - if metrics_driver_type is None: - tg.start_soon(copy_stream, a, b) - tg.start_soon(copy_stream, b, a) - else: - tg.start_soon( - partial( - copy_stream, - a, - b, - metrics_direction="tx", - metrics_driver_type=metrics_driver_type, - ) + async with a, b, create_task_group() as tg: + if metrics_driver_type is None: + tg.start_soon(copy_stream, a, b) + tg.start_soon(copy_stream, b, a) + else: + tg.start_soon( + partial( + copy_stream, + a, + b, + metrics_direction="tx", + metrics_driver_type=metrics_driver_type, ) - tg.start_soon( - partial( - copy_stream, - b, - a, - metrics_direction="rx", - metrics_driver_type=metrics_driver_type, - ) + ) + tg.start_soon( + partial( + copy_stream, + b, + a, + metrics_direction="rx", + metrics_driver_type=metrics_driver_type, ) - yield + ) + yield def create_memory_stream(): diff --git a/python/packages/jumpstarter/jumpstarter/streams/encoding.py b/python/packages/jumpstarter/jumpstarter/streams/encoding.py index b05cff1f4..d4e994cb0 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/encoding.py +++ b/python/packages/jumpstarter/jumpstarter/streams/encoding.py @@ -2,10 +2,10 @@ import lzma import sys import zlib -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable, Mapping from dataclasses import dataclass, field from enum import StrEnum -from typing import Any, Callable, Mapping +from typing import Any, Self from anyio import ClosedResourceError, EndOfStream from anyio.abc import AnyByteStream, ObjectStream @@ -235,5 +235,5 @@ async def __anext__(self) -> bytes: return self._call_decompressor("decompress", chunk) return chunk - def __aiter__(self) -> AsyncIterator[bytes]: + def __aiter__(self) -> Self: return self diff --git a/python/packages/jumpstarter/jumpstarter/streams/fanout.py b/python/packages/jumpstarter/jumpstarter/streams/fanout.py index c05cef624..78d037696 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/fanout.py +++ b/python/packages/jumpstarter/jumpstarter/streams/fanout.py @@ -3,9 +3,10 @@ import asyncio import logging from collections import deque -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Callable, ClassVar +from typing import Any, ClassVar import anyio from anyio import ( @@ -328,10 +329,8 @@ def _broadcast_data(self, data: bytes) -> None: def _broadcast_system(self, msg: bytes) -> None: """Push a system message to all clients. Non-blocking.""" for buf in self._clients.values(): - try: + with suppress(Exception): buf.push(msg) - except Exception: - pass async def _run_reader(self) -> None: try: @@ -453,10 +452,8 @@ async def _stop_reader(self) -> None: task = self._reader_task if task is not None: task.cancel() - try: + with suppress(asyncio.CancelledError, Exception): await task - except (asyncio.CancelledError, Exception): - pass self._reader_task = None self._started = False self._shutdown = False diff --git a/python/packages/jumpstarter/jumpstarter/streams/fanout_test.py b/python/packages/jumpstarter/jumpstarter/streams/fanout_test.py index ccc266bda..6aa5aba79 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/fanout_test.py +++ b/python/packages/jumpstarter/jumpstarter/streams/fanout_test.py @@ -118,7 +118,7 @@ class TestStreamFanOut: @pytest.mark.anyio async def test_exclusive_basic(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -136,8 +136,8 @@ async def factory(): @pytest.mark.anyio async def test_second_exclusive_raises(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -155,7 +155,7 @@ async def factory(): @pytest.mark.anyio async def test_observer_receives_same_data(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -163,21 +163,20 @@ async def factory(): fanout = StreamFanOut(source_factory=factory, always_on=False) - async with fanout.attach_exclusive() as primary: - async with fanout.attach_observer() as observer: - await a_tx.send(b"shared data") - await anyio.sleep(0.05) - primary_data = await primary.receive() - observer_data = await observer.receive() - assert b"shared data" in primary_data - assert b"shared data" in observer_data + async with fanout.attach_exclusive() as primary, fanout.attach_observer() as observer: + await a_tx.send(b"shared data") + await anyio.sleep(0.05) + primary_data = await primary.receive() + observer_data = await observer.receive() + assert b"shared data" in primary_data + assert b"shared data" in observer_data await fanout.close() @pytest.mark.anyio async def test_observer_send_raises(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -185,17 +184,16 @@ async def factory(): fanout = StreamFanOut(source_factory=factory, always_on=False) - async with fanout.attach_exclusive(): - async with fanout.attach_observer() as observer: - with pytest.raises(ReadOnlyStreamError): - await observer.send(b"should fail") + async with fanout.attach_exclusive(), fanout.attach_observer() as observer: + with pytest.raises(ReadOnlyStreamError): + await observer.send(b"should fail") await fanout.close() @pytest.mark.anyio async def test_primary_close_frees_token(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -214,7 +212,7 @@ async def factory(): @pytest.mark.anyio async def test_observer_before_exclusive(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -232,8 +230,8 @@ async def factory(): @pytest.mark.anyio async def test_release_write_token(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -251,7 +249,7 @@ async def factory(): @pytest.mark.anyio async def test_scrollback_replay(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -276,8 +274,8 @@ async def factory(): @pytest.mark.anyio async def test_exclusive_session_active_identity(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -294,8 +292,8 @@ async def factory(): @pytest.mark.anyio async def test_status(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -303,19 +301,18 @@ async def factory(): fanout = StreamFanOut(source_factory=factory, always_on=False) - async with fanout.attach_exclusive(identity="tester"): - async with fanout.attach_observer(): - status = fanout.status() - assert status["write_token_holder"] == "tester" - assert status["observer_count"] == 1 - assert status["total_clients"] == 2 + async with fanout.attach_exclusive(identity="tester"), fanout.attach_observer(): + status = fanout.status() + assert status["write_token_holder"] == "tester" + assert status["observer_count"] == 1 + assert status["total_clients"] == 2 await fanout.close() @pytest.mark.anyio async def test_overflow_error_policy(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -343,8 +340,8 @@ async def test_close_with_error(self): @pytest.mark.anyio async def test_exclusive_send_when_source_none(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -361,8 +358,8 @@ async def factory(): @pytest.mark.anyio async def test_send_eof_is_noop(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -377,8 +374,8 @@ async def factory(): @pytest.mark.anyio async def test_extra_attributes_empty(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -396,8 +393,8 @@ async def factory(): async def test_extra_raises_without_default(self): from anyio import TypedAttributeLookupError - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -414,7 +411,7 @@ async def factory(): @pytest.mark.anyio async def test_async_iteration(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -433,8 +430,8 @@ async def factory(): @pytest.mark.anyio async def test_anext_raises_stop_on_close(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -451,8 +448,8 @@ async def factory(): @pytest.mark.anyio async def test_context_manager_protocol(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -469,8 +466,8 @@ async def factory(): @pytest.mark.anyio async def test_observer_aclose_idempotent(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -478,17 +475,16 @@ async def factory(): fanout = StreamFanOut(source_factory=factory, always_on=False) - async with fanout.attach_exclusive(): - async with fanout.attach_observer() as observer: - await observer.aclose() - await observer.aclose() + async with fanout.attach_exclusive(), fanout.attach_observer() as observer: + await observer.aclose() + await observer.aclose() await fanout.close() @pytest.mark.anyio async def test_scrollback_overflow_trims(self): a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager async def factory(): @@ -508,7 +504,7 @@ async def factory(): def test_extra_returns_attribute_value(self): buf = ClientBuffer(max_bytes=1024) - stream = ExclusiveStream(fanout=None, client_id=1, buffer=buf) + stream = ExclusiveStream(fanout=None, client_id=1, buffer=buf) # type: ignore[arg-type] original = type(stream).extra_attributes try: type(stream).extra_attributes = property( @@ -520,7 +516,7 @@ def test_extra_returns_attribute_value(self): @pytest.mark.anyio async def test_exclusive_send_to_source(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) b_tx, b_rx = create_memory_object_stream[bytes](32) @asynccontextmanager @@ -538,8 +534,8 @@ async def factory(): @pytest.mark.anyio async def test_reconnect_broadcasts_message(self): - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) call_count = 0 @asynccontextmanager @@ -589,7 +585,7 @@ async def test_mixin_connect_and_observe(self): from dataclasses import dataclass a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -620,8 +616,8 @@ async def _open_source(self): async def test_mixin_release_console(self): from dataclasses import dataclass - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -643,8 +639,8 @@ async def _open_source(self): async def test_mixin_console_status(self): from dataclasses import dataclass - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -709,7 +705,7 @@ async def test_mixin_connect_via_method(self): from dataclasses import dataclass a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -732,7 +728,7 @@ async def test_mixin_observe_via_method(self): from dataclasses import dataclass a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -754,8 +750,8 @@ async def _open_source(self): async def test_mixin_release_console_via_method(self): from dataclasses import dataclass - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): @@ -777,8 +773,8 @@ async def _open_source(self): async def test_mixin_console_status_via_method(self): from dataclasses import dataclass - a_tx, a_rx = create_memory_object_stream[bytes](32) - b_tx, b_rx = create_memory_object_stream[bytes](32) + _a_tx, a_rx = create_memory_object_stream[bytes](32) + b_tx, _b_rx = create_memory_object_stream[bytes](32) @dataclass(kw_only=True) class TestDriver(FanOutStreamMixin): diff --git a/python/packages/jumpstarter/jumpstarter/streams/metadata.py b/python/packages/jumpstarter/jumpstarter/streams/metadata.py index 611654397..787c4e3e8 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/metadata.py +++ b/python/packages/jumpstarter/jumpstarter/streams/metadata.py @@ -1,6 +1,7 @@ +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass -from typing import Any, Callable, Mapping +from typing import Any from anyio import TypedAttributeLookupError, TypedAttributeSet, typed_attribute from anyio.abc import AnyByteStream, ObjectStream diff --git a/python/packages/jumpstarter/jumpstarter/streams/progress.py b/python/packages/jumpstarter/jumpstarter/streams/progress.py index dce4ce7c6..319a5f8ef 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/progress.py +++ b/python/packages/jumpstarter/jumpstarter/streams/progress.py @@ -1,7 +1,7 @@ import logging import os from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from io import StringIO from anyio import TypedAttributeSet, typed_attribute @@ -33,7 +33,7 @@ class ProgressStream(ObjectStream[bytes]): __prog: Progress | None = field(init=False, default=None) __recv: TaskID | None = field(init=False, default=None) __send: TaskID | None = field(init=False, default=None) - __last: datetime = field(init=False, default_factory=datetime.now) + __last: datetime = field(init=False, default_factory=lambda: datetime.now(tz=UTC)) def __post_init__(self): if hasattr(super(), "__post_init__"): @@ -66,11 +66,12 @@ async def receive(self): item = await self.stream.receive() self.__prog.advance(self.__recv, len(item)) - if self.logging and (datetime.now() - self.__last > timedelta(seconds=2)): - self.__last = datetime.now() - console = Console(file=StringIO()) + if self.logging and (datetime.now(tz=UTC) - self.__last > timedelta(seconds=2)): + self.__last = datetime.now(tz=UTC) + buf = StringIO() + console = Console(file=buf) console.print(self.__prog.get_renderable()) - logger.info(console.file.getvalue().rstrip()) + logger.info(buf.getvalue().rstrip()) return item @@ -83,11 +84,12 @@ async def send(self, item): ) self.__prog.advance(self.__recv, len(item)) - if self.logging and (datetime.now() - self.__last > timedelta(seconds=2)): - self.__last = datetime.now() - console = Console(file=StringIO()) + if self.logging and (datetime.now(tz=UTC) - self.__last > timedelta(seconds=2)): + self.__last = datetime.now(tz=UTC) + buf = StringIO() + console = Console(file=buf) console.print(self.__prog.get_renderable()) - logger.info(console.file.getvalue().rstrip()) + logger.info(buf.getvalue().rstrip()) await self.stream.send(item) diff --git a/python/packages/jumpstarter/jumpstarter/streams/router.py b/python/packages/jumpstarter/jumpstarter/streams/router.py index b626ad6b3..7ed5e6a13 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/router.py +++ b/python/packages/jumpstarter/jumpstarter/streams/router.py @@ -1,9 +1,11 @@ -import asyncio import contextlib import logging +from asyncio import InvalidStateError from dataclasses import dataclass, field +from typing import Any import grpc +import grpc.aio from anyio import ( BrokenResourceError, EndOfStream, @@ -16,14 +18,14 @@ @dataclass(kw_only=True, slots=True) class RouterStream(ObjectStream[bytes]): - context: grpc.aio.StreamStreamCall | grpc._cython.cygrpc._ServicerContext + context: grpc.aio.StreamStreamCall | Any # grpc._cython.cygrpc._ServicerContext cls: type = field(init=False) def __post_init__(self): match self.context: case grpc.aio.StreamStreamCall(): self.cls = router_pb2.StreamRequest - case grpc._cython.cygrpc._ServicerContext(): + case grpc._cython.cygrpc._ServicerContext(): # type: ignore[attr-defined] self.cls = router_pb2.StreamResponse case _: raise ValueError(f"RouterStream: invalid context type: {type(self.context)}") @@ -57,13 +59,13 @@ async def receive(self) -> bytes: return b"" async def send_eof(self): - with contextlib.suppress(grpc.aio.AioRpcError, asyncio.exceptions.InvalidStateError): + with contextlib.suppress(grpc.aio.AioRpcError, InvalidStateError): await self.context.write(self.cls(frame_type=router_pb2.FRAME_TYPE_GOAWAY)) if isinstance(self.context, grpc.aio.StreamStreamCall): await self.context.done_writing() async def aclose(self): - with contextlib.suppress(grpc.aio.AioRpcError, asyncio.exceptions.InvalidStateError): + with contextlib.suppress(grpc.aio.AioRpcError, InvalidStateError): await self.send_eof() - if isinstance(self.context, grpc._cython.cygrpc._ServicerContext): + if isinstance(self.context, grpc._cython.cygrpc._ServicerContext): # type: ignore[attr-defined] await self.context.abort(grpc.StatusCode.ABORTED, "RouterStream: aclose") diff --git a/python/packages/jumpstarter/jumpstarter/utils/env.py b/python/packages/jumpstarter/jumpstarter/utils/env.py index d4f111ca4..a2a1a2e33 100644 --- a/python/packages/jumpstarter/jumpstarter/utils/env.py +++ b/python/packages/jumpstarter/jumpstarter/utils/env.py @@ -124,10 +124,12 @@ def env(): This is useful when interacting with an already established Jumpstarter shell, to either a local exporter or a remote one. """ - with start_blocking_portal() as portal: - with ExitStack() as stack: - with portal.wrap_async_context_manager(env_async(portal, stack)) as client: - yield client + with ( + start_blocking_portal() as portal, + ExitStack() as stack, + portal.wrap_async_context_manager(env_async(portal, stack)) as client, + ): + yield client @contextmanager @@ -144,7 +146,9 @@ def env_with_metadata(): print(metadata.labels) print(metadata.lease) """ - with start_blocking_portal() as portal: - with ExitStack() as stack: - with portal.wrap_async_context_manager(env_with_metadata_async(portal, stack)) as result: - yield result + with ( + start_blocking_portal() as portal, + ExitStack() as stack, + portal.wrap_async_context_manager(env_with_metadata_async(portal, stack)) as result, + ): + yield result diff --git a/python/packages/jumpstarter/pyproject.toml b/python/packages/jumpstarter/pyproject.toml index 0e1d435ec..a58332810 100644 --- a/python/packages/jumpstarter/pyproject.toml +++ b/python/packages/jumpstarter/pyproject.toml @@ -37,6 +37,7 @@ dev = [ "jumpstarter-driver-power", "jumpstarter-driver-network", "jumpstarter-driver-composite", + "types-protobuf>=5.29.1", ] [tool.coverage.run] @@ -59,3 +60,7 @@ build-backend = "hatchling.build" [tool.hatch.build.hooks.pin_jumpstarter] name = "pin_jumpstarter" + +[tool.ty.rules] +call-non-callable = "warn" +unresolved-reference = "warn" diff --git a/python/pyproject.toml b/python/pyproject.toml index ae5696553..7c4aa50ca 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -126,7 +126,7 @@ docs = [ "pyyaml>=6.0", ] dev = [ - "ruff==0.15.22", + "ruff==0.16.8", "typos>=1.23.6", "pre-commit>=3.8.0", "esbonio>=0.16.5", diff --git a/python/uv.lock b/python/uv.lock index 87d406686..7ffd9ca89 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -118,7 +118,7 @@ dev = [ { name = "diff-cover", specifier = ">=10.2.0" }, { name = "esbonio", specifier = ">=0.16.5" }, { name = "pre-commit", specifier = ">=3.8.0" }, - { name = "ruff", specifier = "==0.15.22" }, + { name = "ruff", specifier = "==0.16.8" }, { name = "ty", specifier = ">=0.0.1a8" }, { name = "typos", specifier = ">=1.23.6" }, ] @@ -175,13 +175,13 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, + { name = "aiohappyeyeballs", marker = "python_full_version < '3.14'" }, + { name = "aiosignal", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "attrs", marker = "python_full_version < '3.14'" }, + { name = "frozenlist", marker = "python_full_version < '3.14'" }, + { name = "multidict", marker = "python_full_version < '3.14'" }, + { name = "propcache", marker = "python_full_version < '3.14'" }, + { name = "yarl", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload-time = "2025-06-14T15:15:41.354Z" } wheels = [ @@ -246,13 +246,13 @@ resolution-markers = [ "(python_full_version >= '3.14' and implementation_name == 'pypy' and platform_python_implementation == 'PyPy' and sys_platform == 'android') or (python_full_version >= '3.14' and os_name != 'nt' and platform_python_implementation == 'PyPy' and sys_platform == 'android')", ] dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, + { name = "aiohappyeyeballs", marker = "python_full_version >= '3.14'" }, + { name = "aiosignal", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "attrs", marker = "python_full_version >= '3.14'" }, + { name = "frozenlist", marker = "python_full_version >= '3.14'" }, + { name = "multidict", marker = "python_full_version >= '3.14'" }, + { name = "propcache", marker = "python_full_version >= '3.14'" }, + { name = "yarl", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ @@ -372,7 +372,7 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "frozenlist" }, + { name = "frozenlist", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } wheels = [ @@ -404,7 +404,7 @@ resolution-markers = [ "(python_full_version >= '3.14' and implementation_name == 'pypy' and platform_python_implementation == 'PyPy' and sys_platform == 'android') or (python_full_version >= '3.14' and os_name != 'nt' and platform_python_implementation == 'PyPy' and sys_platform == 'android')", ] dependencies = [ - { name = "frozenlist" }, + { name = "frozenlist", marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -481,7 +481,7 @@ resolution-markers = [ "(python_full_version >= '3.14' and implementation_name == 'pypy' and platform_python_implementation == 'PyPy' and sys_platform == 'android') or (python_full_version >= '3.14' and os_name != 'nt' and platform_python_implementation == 'PyPy' and sys_platform == 'android')", ] dependencies = [ - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911, upload-time = "2021-12-01T08:52:55.68Z" } wheels = [ @@ -507,7 +507,7 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "cffi", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } wheels = [ @@ -945,7 +945,7 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform == 'android'", ] dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ @@ -998,7 +998,7 @@ resolution-markers = [ "(python_full_version >= '3.14' and implementation_name == 'pypy' and platform_python_implementation == 'PyPy' and sys_platform == 'android') or (python_full_version >= '3.14' and os_name != 'nt' and platform_python_implementation == 'PyPy' and sys_platform == 'android')", ] dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser", marker = "python_full_version >= '3.14' and implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -1668,7 +1668,7 @@ resolution-markers = [ "(python_full_version >= '3.14' and implementation_name == 'pypy' and platform_python_implementation == 'PyPy' and sys_platform == 'android') or (python_full_version >= '3.14' and os_name != 'nt' and platform_python_implementation == 'PyPy' and sys_platform == 'android')", ] dependencies = [ - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } wheels = [ @@ -2036,6 +2036,7 @@ dev = [ { name = "pytest-anyio" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "types-protobuf" }, ] [package.metadata] @@ -2066,6 +2067,7 @@ dev = [ { name = "pytest-anyio", specifier = ">=0.0.0" }, { name = "pytest-asyncio", specifier = ">=0.0.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "types-protobuf", specifier = ">=5.29.1" }, ] [[package]] @@ -5826,7 +5828,7 @@ name = "pyobjc-framework-cocoa" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.14' and implementation_name != 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (implementation_name == 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32') or (os_name != 'nt' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" } wheels = [ @@ -5842,8 +5844,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.14' and implementation_name != 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (implementation_name == 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32') or (os_name != 'nt' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "pyobjc-framework-cocoa", marker = "(python_full_version < '3.14' and implementation_name != 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (implementation_name == 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32') or (os_name != 'nt' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/b2/ad9e8516cd73611a3a8f8ff2d7d51b917115f3f7f9e7a9760d5fc4e9dd6b/pyobjc_framework_corebluetooth-12.0.tar.gz", hash = "sha256:61ae2a56c3dcb8b7307d833e7d913bd7c063d11a1ea931158facceb38aae21d3", size = 33587, upload-time = "2025-10-21T08:31:18.036Z" } wheels = [ @@ -5859,8 +5861,8 @@ name = "pyobjc-framework-libdispatch" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "pyobjc-core", marker = "(python_full_version < '3.14' and implementation_name != 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (implementation_name == 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32') or (os_name != 'nt' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "pyobjc-framework-cocoa", marker = "(python_full_version < '3.14' and implementation_name != 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (implementation_name == 'pypy' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32') or (os_name != 'nt' and sys_platform != 'android' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/7e/251ea268ce5a341586c963de758c7ff6dea681c98a1fb6da87f6d0004bd3/pyobjc_framework_libdispatch-12.0.tar.gz", hash = "sha256:2ef31c02670c377d9e2875e74053087b1d96b240d2fc8721cc4c665c05394b3a", size = 38599, upload-time = "2025-10-21T08:34:38.878Z" } wheels = [ @@ -6793,27 +6795,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] [[package]] @@ -7429,6 +7431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/d4/3128ae3365b46b9c4a33202af79b0e0d9d4308a6348a3317ce2331fea6cb/types_pexpect-4.9.0.20250516-py3-none-any.whl", hash = "sha256:84cbd7ae9da577c0d2629d4e4fd53cf074cd012296e01fd4fa1031e01973c28a", size = 17081, upload-time = "2025-05-16T03:08:32.127Z" }, ] +[[package]] +name = "types-protobuf" +version = "7.35.1.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/6c/e3e5b3e10bc328126a39637c138f9ebfd734bf14342b9f3540039b4ab995/types_protobuf-7.35.1.20260906.tar.gz", hash = "sha256:efd1a3862d4c967dad5512ef8d56b1530ac84f182c41735b94004756518c4998", size = 69895, upload-time = "2026-09-06T06:35:28.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4e/f63e826c68f77ef875506d72f225918800346545ee99847bc28f3394f18d/types_protobuf-7.35.1.20260906-py3-none-any.whl", hash = "sha256:5155e48569e0dabff303fdf578db96cd31ea9a4a63b18018a4ceac6b0ae17462", size = 86419, upload-time = "2026-09-06T06:35:27.247Z" }, +] + [[package]] name = "types-pyserial" version = "3.5.0.20250326" @@ -7745,7 +7756,7 @@ version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -7765,7 +7776,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -7785,7 +7796,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -7805,7 +7816,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -7825,7 +7836,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -7845,7 +7856,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -7865,7 +7876,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -7885,7 +7896,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform != 'android' and sys_platform != 'emscripten'" }, + { name = "winrt-runtime", marker = "(python_full_version < '3.14' and sys_platform != 'android' and sys_platform != 'emscripten') or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [