diff --git a/src/deadline/client/cli/_common.py b/src/deadline/client/cli/_common.py index b69091141..336f7185b 100644 --- a/src/deadline/client/cli/_common.py +++ b/src/deadline/client/cli/_common.py @@ -32,7 +32,10 @@ from contextlib import ExitStack from deadline.job_attachments.progress_tracker import ProgressReportMetadata +from .. import api as _api from ..config import config_file +from ..config.config_file import _SETTING_FARM_ID as SETTING_FARM_ID +from ..config.config_file import _SETTING_QUEUE_ID as SETTING_QUEUE_ID from ..exceptions import DeadlineOperationError from ..job_bundle import deadline_yaml_dump from ._groups._sigint_handler import SigIntHandler @@ -87,6 +90,40 @@ def wraps(ctx: click.Context, *args, **kwargs): return wraps +def _auto_select_farm(config: Optional[ConfigParser] = None) -> Optional[str]: + """Auto-select farm ID if exactly one farm is available.""" + try: + logger.debug("Auto-select farm: listing farms...") + farms = _api.list_farms(config=config).get("farms", []) + logger.debug("Auto-select farm: found %d farm(s)", len(farms)) + if len(farms) == 1: + farm_id = farms[0]["farmId"] + click.echo(f"Auto-selected the only available farm: {farm_id}") + return farm_id + except Exception: + logger.debug("Auto-select farm failed", exc_info=True) + return None + + +def _auto_select_queue(config: Optional[ConfigParser] = None) -> Optional[str]: + """Auto-select queue ID if exactly one queue is available in the current farm.""" + try: + farm_id = config_file.get_setting(SETTING_FARM_ID, config=config) + if not farm_id: + logger.debug("Auto-select queue: no farm_id configured") + return None + logger.debug("Auto-select queue: listing queues for farm %s...", farm_id) + queues = _api.list_queues(farmId=farm_id, config=config).get("queues", []) + logger.debug("Auto-select queue: found %d queue(s) in farm %s", len(queues), farm_id) + if len(queues) == 1: + queue_id = queues[0]["queueId"] + click.echo(f"Auto-selected the only available queue: {queue_id}") + return queue_id + except Exception: + logger.debug("Auto-select queue failed", exc_info=True) + return None + + def _apply_cli_options_to_config( *, config: Optional[ConfigParser] = None, required_options: Set[str] = set(), **args ) -> Optional[ConfigParser]: @@ -109,11 +146,11 @@ def _apply_cli_options_to_config( farm_id = args.pop("farm_id", None) if farm_id: - config_file.set_setting("defaults.farm_id", farm_id, config=config) + config_file.set_setting(SETTING_FARM_ID, farm_id, config=config) queue_id = args.pop("queue_id", None) if queue_id: - config_file.set_setting("defaults.queue_id", queue_id, config=config) + config_file.set_setting(SETTING_QUEUE_ID, queue_id, config=config) storage_profile_id = args.pop("storage_profile_id", None) if storage_profile_id: @@ -139,16 +176,28 @@ def _apply_cli_options_to_config( for name in ["profile", "farm_id", "queue_id", "job_id", "storage_profile_id"]: args.pop(name, None) - # Check that the required options have values + # Check that the required options have values, auto-selecting if only one exists if "farm_id" in required_options: required_options.remove("farm_id") - if not config_file.get_setting("defaults.farm_id", config=config): - raise click.UsageError("Missing '--farm-id' or default Farm ID configuration") + if not config_file.get_setting(SETTING_FARM_ID, config=config): + farm_id = _auto_select_farm(config) + if farm_id: + if config is None: + config = config_file.read_config() + config_file.set_setting(SETTING_FARM_ID, farm_id, config=config) + else: + raise click.UsageError("Missing '--farm-id' or default Farm ID configuration") if "queue_id" in required_options: required_options.remove("queue_id") - if not config_file.get_setting("defaults.queue_id", config=config): - raise click.UsageError("Missing '--queue-id' or default Queue ID configuration") + if not config_file.get_setting(SETTING_QUEUE_ID, config=config): + queue_id = _auto_select_queue(config) + if queue_id: + if config is None: + config = config_file.read_config() + config_file.set_setting(SETTING_QUEUE_ID, queue_id, config=config) + else: + raise click.UsageError("Missing '--queue-id' or default Queue ID configuration") if "job_id" in required_options: required_options.remove("job_id") diff --git a/src/deadline/client/config/config_file.py b/src/deadline/client/config/config_file.py index 9ddcee1ac..39774c28c 100644 --- a/src/deadline/client/config/config_file.py +++ b/src/deadline/client/config/config_file.py @@ -51,6 +51,10 @@ __config_file_path = None __config_mtime = None +# Setting name constants +_SETTING_FARM_ID = "defaults.farm_id" +_SETTING_QUEUE_ID = "defaults.queue_id" + # This value defines the AWS Deadline Cloud settings structure. For each named setting, # it stores: # "default" - The default value. @@ -76,7 +80,7 @@ "is_path": True, "description": "The directory in which to place the job submission history for this AWS profile name.", }, - "defaults.farm_id": { + _SETTING_FARM_ID: { "default": "", "depend": "defaults.aws_profile_name", "section_format": "{}", @@ -84,13 +88,13 @@ }, "settings.storage_profile_id": { "default": "", - "depend": "defaults.farm_id", + "depend": _SETTING_FARM_ID, "section_format": "{}", "description": "The storage profile that this workstation conforms to. It specifies where shared file systems are mounted, and where named job attachments should go.", }, - "defaults.queue_id": { + _SETTING_QUEUE_ID: { "default": "", - "depend": "defaults.farm_id", + "depend": _SETTING_FARM_ID, "section_format": "{}", "description": "The Queue ID to use by default.", }, @@ -121,7 +125,7 @@ }, "defaults.job_attachments_file_system": { "default": "COPIED", - "depend": "defaults.farm_id", + "depend": _SETTING_FARM_ID, "description": "The file system mode to use for job attachments when running jobs. COPIED means to download a copy of the attachment data, VIRTUAL means to use a virtual file system for lazy loading.", }, "settings.s3_max_pool_connections": { diff --git a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py index 2bd2bba9a..c2c707e55 100644 --- a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py +++ b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py @@ -9,10 +9,11 @@ import os import sys import json +import threading as _threading from typing import Any, Dict, Optional, Protocol import yaml -from qtpy.QtCore import QSize, Qt # pylint: disable=import-error +from qtpy.QtCore import QSize, Qt, Signal as _Signal # pylint: disable=import-error from qtpy.QtGui import QKeyEvent # pylint: disable=import-error from qtpy.QtWidgets import ( # pylint: disable=import-error; type: ignore QApplication, @@ -36,6 +37,8 @@ from ..deadline_authentication_status import DeadlineAuthenticationStatus from .._utils import block_signals, tr from ...config import get_setting, set_setting, config_file +from ...config.config_file import _SETTING_FARM_ID +from ...config.config_file import _SETTING_QUEUE_ID from ...exceptions import UserInitiatedCancel, NonValidInputError from ...job_bundle import create_job_history_bundle_dir from ...job_bundle.parameters import JobParameter @@ -104,6 +107,8 @@ class SubmitJobToDeadlineDialog(QDialog): submitter_info (SubmitterInfo): Information related to the submitter window and application it's running in """ + _auto_select_complete = _Signal() + def __init__( self, *, @@ -229,6 +234,7 @@ def _build_ui( self.deadline_authentication_status.api_availability_changed.connect( self.refresh_deadline_settings ) + self._auto_select_complete.connect(self.refresh_deadline_settings) # Refresh the submit button enable state once queue parameter status changes self.shared_job_settings.valid_parameters.connect(self._set_submit_button_state) @@ -257,8 +263,8 @@ def _set_submit_button_state(self): # Enable/disable the Submit button based on whether the # AWS Deadline Cloud API is accessible and the farm+queue are configured. api_available = self.deadline_authentication_status.api_availability is True - farm_configured = get_setting("defaults.farm_id") != "" - queue_configured = get_setting("defaults.queue_id") != "" + farm_configured = get_setting(_SETTING_FARM_ID) != "" + queue_configured = get_setting(_SETTING_QUEUE_ID) != "" queue_valid = self.shared_job_settings.is_queue_valid() enable = api_available and farm_configured and queue_configured and queue_valid @@ -295,6 +301,7 @@ def _set_submit_button_state(self): self.submit_button.setToolTip("") def refresh_deadline_settings(self): + self._auto_select_defaults() self._set_submit_button_state() self.shared_job_settings.deadline_cloud_settings_box.refresh_setting_controls( @@ -303,6 +310,51 @@ def refresh_deadline_settings(self): # If necessary, this reloads the queue parameters self.shared_job_settings.refresh_queue_parameters() + def _auto_select_defaults(self): + """Auto-select farm/queue in a background thread if only one is available.""" + if self.deadline_authentication_status.api_availability is not True: + return + if get_setting(_SETTING_FARM_ID) and get_setting(_SETTING_QUEUE_ID): + return + if getattr(self, "_auto_select_in_progress", False): + return + self._auto_select_in_progress = True + + _threading.Thread(target=self._do_auto_select, daemon=True).start() + + def _do_auto_select(self): + """Background worker that auto-selects farm/queue if only one exists.""" + try: + farm_changed = self._try_auto_select_farm() + queue_changed = self._try_auto_select_queue() + if farm_changed or queue_changed: + self._auto_select_complete.emit() + except Exception: + logger.debug("Auto-select defaults failed", exc_info=True) + finally: + self._auto_select_in_progress = False + + def _try_auto_select_farm(self) -> bool: + if get_setting(_SETTING_FARM_ID): + return False + farms = api.list_farms().get("farms", []) + if len(farms) == 1: + set_setting(_SETTING_FARM_ID, farms[0]["farmId"]) + logger.info("Auto-selected farm: %s", farms[0]["farmId"]) + return True + return False + + def _try_auto_select_queue(self) -> bool: + if not get_setting(_SETTING_FARM_ID) or get_setting(_SETTING_QUEUE_ID): + return False + farm_id = get_setting(_SETTING_FARM_ID) + queues = api.list_queues(farmId=farm_id).get("queues", []) + if len(queues) == 1: + set_setting(_SETTING_QUEUE_ID, queues[0]["queueId"]) + logger.info("Auto-selected queue: %s", queues[0]["queueId"]) + return True + return False + def keyPressEvent(self, event: QKeyEvent) -> None: """ Override to capture any enter/return key presses so that the Submit diff --git a/test/unit/deadline_client/cli/test_cli_common.py b/test/unit/deadline_client/cli/test_cli_common.py index 3b514504c..1599bd0f6 100644 --- a/test/unit/deadline_client/cli/test_cli_common.py +++ b/test/unit/deadline_client/cli/test_cli_common.py @@ -1,12 +1,20 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import json +from unittest.mock import patch + import pytest import click import yaml -from deadline.client.cli._common import _parse_file_parameter, _parse_multi_format_parameters +from deadline.client.cli._common import ( + _auto_select_farm, + _auto_select_queue, + _parse_file_parameter, + _parse_multi_format_parameters, +) +from deadline.client.config import config_file class TestParseFileParameter: @@ -282,3 +290,55 @@ def test_progress_bar_not_closed_at_zero(self): assert manager._bar_status == manager.BAR_CREATED manager._exit_stack.close() + + +class TestAutoSelectFarm: + def test_single_farm_returns_id(self, fresh_deadline_config): + with patch("deadline.client.cli._common._api.list_farms") as mock_list: + mock_list.return_value = {"farms": [{"farmId": "farm-abc123"}]} + assert _auto_select_farm() == "farm-abc123" + + def test_multiple_farms_returns_none(self, fresh_deadline_config): + with patch("deadline.client.cli._common._api.list_farms") as mock_list: + mock_list.return_value = {"farms": [{"farmId": "farm-1"}, {"farmId": "farm-2"}]} + assert _auto_select_farm() is None + + def test_no_farms_returns_none(self, fresh_deadline_config): + with patch("deadline.client.cli._common._api.list_farms") as mock_list: + mock_list.return_value = {"farms": []} + assert _auto_select_farm() is None + + def test_api_error_returns_none(self, fresh_deadline_config): + with patch("deadline.client.cli._common._api.list_farms") as mock_list: + mock_list.side_effect = Exception("API error") + assert _auto_select_farm() is None + + +class TestAutoSelectQueue: + def test_single_queue_returns_id(self, fresh_deadline_config): + config_file.set_setting("defaults.farm_id", "farm-abc123") + with patch("deadline.client.cli._common._api.list_queues") as mock_list: + mock_list.return_value = {"queues": [{"queueId": "queue-xyz789"}]} + assert _auto_select_queue() == "queue-xyz789" + mock_list.assert_called_once_with(farmId="farm-abc123", config=None) + + def test_multiple_queues_returns_none(self, fresh_deadline_config): + config_file.set_setting("defaults.farm_id", "farm-abc123") + with patch("deadline.client.cli._common._api.list_queues") as mock_list: + mock_list.return_value = {"queues": [{"queueId": "queue-1"}, {"queueId": "queue-2"}]} + assert _auto_select_queue() is None + + def test_no_queues_returns_none(self, fresh_deadline_config): + config_file.set_setting("defaults.farm_id", "farm-abc123") + with patch("deadline.client.cli._common._api.list_queues") as mock_list: + mock_list.return_value = {"queues": []} + assert _auto_select_queue() is None + + def test_no_farm_id_returns_none(self, fresh_deadline_config): + assert _auto_select_queue() is None + + def test_api_error_returns_none(self, fresh_deadline_config): + config_file.set_setting("defaults.farm_id", "farm-abc123") + with patch("deadline.client.cli._common._api.list_queues") as mock_list: + mock_list.side_effect = Exception("API error") + assert _auto_select_queue() is None